Last active
December 26, 2021 07:07
-
-
Save corbane/37c8751b6c60018a03fa4dd3ea48bf3e to your computer and use it in GitHub Desktop.
A proof of concept to define a read-only range in the Monaco editor
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//@ts-check | |
//#region dom.js | |
/** @typedef {boolean|number|bigint|string} Scalar */ | |
/** @param {string} content */ | |
function appendStyleSheet (content) | |
{ | |
const style = document.createElement ("style") | |
style.textContent = content | |
document.head.append (style) | |
} | |
//#endregion | |
//#region intervals.js | |
// import { appendStyleSheet } from "../dom.js" | |
// /** | |
// * @typedef {import ("./types").Interval} Interval | |
// * @typedef {import ("./types").ReadonlyInterval} ReadonlyInterval | |
// */ | |
/** | |
* @typedef {[number, number]} Interval | |
* @typedef {readonly [number, number]} ReadonlyInterval | |
*/ | |
const NEGATIVE_INFINITY = Number.NEGATIVE_INFINITY | |
const POSITIVE_INFINITY = Number.POSITIVE_INFINITY | |
/** @param {monaco.editor.ICodeEditor} editor */ | |
function SynchronizedIntervals (editor) | |
{ | |
/** | |
* @typedef {(intervals: readonly ReadonlyInterval[]) => void} Callback | |
*/ | |
/** @type {monaco.IRange[]} */ | |
var ranges = [] | |
/** @type {Interval[]} */ | |
var intervals = [] | |
/** @type {Callback[]} */ | |
const callbacks = [] | |
/** | |
* @param {number} startLine | |
* @param {number} startColumn | |
* @param {number} endLine | |
* @param {number} endColumn | |
*/ | |
const exclude = (startLine, startColumn, endLine, endColumn) => { | |
ranges.push ({ | |
startLineNumber: startLine, | |
endLineNumber: endLine, | |
startColumn, | |
endColumn | |
}) | |
createIntervals () | |
dispatch () | |
} | |
/** @param {Callback} callback */ | |
const attach = (callback) => { | |
if (callbacks.indexOf (callback) < 0) | |
callbacks.push (callback) | |
} | |
/** @param {Callback} callback */ | |
const detach = (callback) => { | |
const i = callbacks.indexOf (callback) | |
if (i < 0) return | |
callbacks.splice (i, 1) | |
} | |
const dispatch = () => { | |
for (var cb of callbacks) { | |
try { | |
cb (intervals) | |
} catch (error) { | |
console.error (error) | |
} | |
} | |
} | |
const createIntervals = () => | |
{ | |
const model = editor.getModel () | |
if (!model) return | |
intervals = mergeIntervals ( | |
ranges.map((r) => { | |
return [ | |
model.getOffsetAt({ lineNumber: r.startLineNumber, column: r.startColumn }), | |
model.getOffsetAt({ lineNumber: r.endLineNumber, column: r.endColumn }) | |
] | |
}), | |
/*mergeJoins*/ true | |
) | |
if (intervals.length > 0) | |
{ | |
if (intervals[0][0] <= 0) | |
intervals[0][0] = NEGATIVE_INFINITY | |
if (intervals[intervals.length - 1][1] >= model.getValueLength ()) | |
intervals[intervals.length - 1][1] = POSITIVE_INFINITY | |
} | |
} | |
const onDidChangeModel = editor.onDidChangeModel (event => | |
{ | |
console.log ("onModel") | |
createIntervals () | |
updateDecorations () | |
console.log ("computed interval", copyIntervals (intervals)) | |
}) | |
const onDidChangeModelContent = editor.onDidChangeModelContent (event => | |
{ | |
console.log ("onContent") | |
var change, i, offset | |
for (change of event.changes) | |
{ | |
offset = change.text.length - change.rangeLength | |
console.log(" - at", change.rangeOffset, "push", offset) | |
for (i = 0; i < intervals.length; i++) { | |
if (change.rangeOffset < intervals[i][0]) break | |
if (change.rangeOffset < intervals[i][1]) { intervals[i][1] += offset; break } | |
} | |
for (/**/; i < intervals.length; i++) { | |
intervals[i][0] += offset | |
intervals[i][1] += offset | |
} | |
} | |
updateDecorations () | |
console.log (" - updated intervals", copyIntervals (intervals)) | |
}) | |
const dispose = () => | |
{ | |
onDidChangeModel.dispose () | |
onDidChangeModelContent.dispose () | |
} | |
// --- | |
/** @type {string[]} */ | |
var oldDecorations = [] | |
var style = /*css*/` | |
.idoc-readonly-mark { | |
background: hsl(195deg 20% 95%); | |
} | |
`/*end*/ | |
if (style) { | |
appendStyleSheet (style) | |
style = null | |
} | |
const show = () => { | |
attach (updateDecorations) | |
updateDecorations () | |
} | |
const hide = () => { | |
detach (updateDecorations) | |
oldDecorations = editor.deltaDecorations (oldDecorations, []) | |
} | |
const updateDecorations = () => { | |
oldDecorations = editor.deltaDecorations (oldDecorations, intervals.map (createDecoration)) | |
} | |
/** | |
* @param {Interval} interval | |
* @returns {monaco.editor.IModelDeltaDecoration} | |
*/ | |
const createDecoration = (interval) => | |
{ | |
const start = editor.getModel ().getPositionAt (interval[0]) | |
const end = editor.getModel ().getPositionAt (interval[1]) | |
return { | |
range: new monaco.Range (start.lineNumber, start.column, end.lineNumber, end.column), | |
options: { | |
className: 'idoc-readonly-mark', | |
} | |
} | |
} | |
return { | |
/** @returns {Interval[]} */ | |
get buffer () { return intervals }, | |
exclude, | |
attach, | |
detach, | |
dispose, | |
show, | |
hide, | |
} | |
} | |
/** | |
* @param {Interval[]} intervals | |
* @return {Interval[]} | |
*/ | |
function copyIntervals (intervals) | |
{ | |
//@ts-ignore | |
return intervals.map (i => i.slice (0)) | |
} | |
/** | |
* @param {Interval} interval | |
* @param {Interval[]} excludedIntervals | |
* @param {-1|0|1} prefer - used in case the whole interval is excluded. | |
* - if `prefer` is 0 then the interval is deleted. | |
* - if `prefer` is -1 then the interval placed at the beginning of the interval which excludes it. | |
* - if `prefer` is +1 then the interval placed at the end of the interval which excludes it | |
*/ | |
function excludeIntervals (interval, excludedIntervals, prefer) | |
{ | |
var inverted = false | |
/** @return {Interval[]} */ | |
const prepare = (/** @type {Interval} */ a) => | |
a[1] < a[0] | |
? (inverted = true, [[ a[1], a[0] ]]) | |
: [[...a]] | |
const finalize = (/** @type {Interval[]} */ a) => | |
inverted | |
? a.map(a => a[1] > a[0] ? swap(a) : a).reverse() | |
: a | |
var m | |
const swap = (/** @type {Interval} */a) => ( m=a[1], a[1]=a[0], a[0]=m, a ) | |
var result = prepare (interval) | |
/** @type {Interval[]} */ | |
var div | |
var e, i, index, | |
iStart, iEnd, eStart, eEnd | |
i = result[0] | |
for (e of excludedIntervals) | |
{ | |
eStart = e[0] | |
eEnd = e[1] | |
for (index = 0; index < result.length; index++) | |
{ | |
i = result[index] | |
iStart = i[0] | |
iEnd = i[1] | |
// |~~~| represents an excluded interval | |
// |+++| represents an included interval | |
// | represents an included interval that begins and ends at the same offset | |
if (iStart < eStart) | |
{ | |
// |~~~~~~~| | |
// |++| | |
// | | |
if (iEnd < eStart) | |
div = [i] | |
// |=======| | |
// |+++| | |
// |+++++++| | |
// |+++++++++++| | |
else if (iEnd <= eEnd) | |
div = [[iStart, eStart - 1]] | |
// |=======| | |
// |+++++++++++++++| | |
else | |
div = [[iStart, eStart - 1], [eEnd + 1, iEnd]] | |
} | |
else if (iStart <= eEnd) | |
{ | |
if (eStart === NEGATIVE_INFINITY) | |
div = prefer === 0 ? [] | |
: [[eEnd + 1, eEnd + 1]] | |
else | |
if (eEnd === POSITIVE_INFINITY) | |
div = prefer === 0 ? [] | |
: [[eStart - 1, eStart - 1]] | |
else | |
// |=======| | |
// |++++| | |
// |+++++++| | |
// |++| | |
// |+++++| | |
// | | |
// | | |
// | | |
if (iEnd <= eEnd) | |
div = prefer < 0 ? [[eStart - 1, eStart - 1]] | |
: prefer > 0 ? [[eEnd + 1, eEnd + 1]] | |
: [] | |
// |=======| | |
// |++++++++++| | |
// |++++++++| | |
else | |
div = [[eEnd + 1, iEnd]] | |
} | |
else | |
{ | |
// |=======| | |
// |+++| | |
// | | |
div = [i] | |
} | |
result.splice (index, 1, ...div) | |
if (result.length === 1 && result[0][1] < eStart) | |
return finalize (result) | |
} | |
} | |
return finalize (result) | |
} | |
/** | |
* @param {Interval[]} intervals | |
* @param {boolean} mergeJoins | |
* used in the case where two intervals form a sequence without spaces, like `[[0, 5], [6, 10]]`. | |
* - if `mergeJoins` is true then the result is merged as `[[0, 10]]` | |
* - otherwise the result is identical | |
*/ | |
function mergeIntervals (intervals, mergeJoins) | |
{ | |
if (intervals.length < 2) | |
return intervals | |
/** @type {Interval[]} */ var merged = [] | |
/** @type {Interval} */ var prev = null | |
var tmp, next | |
intervals = intervals | |
.map ((a) => a[1] < a[0] ? (tmp=a[1],a[1]=a[0],a[1]=tmp,a) : a) | |
.sort ((a, b) => a[0] - b[0]) | |
merged.push (intervals[0]) | |
for (var i = 1; i < intervals.length; i++) | |
{ | |
prev = merged[merged.length-1] | |
next = intervals[i] | |
if (prev[1] === next[0] && !mergeJoins) { | |
merged.push (next) | |
} | |
else if (prev[1] === next[0]-1 && mergeJoins) { | |
prev[1] = next[1] | |
merged.splice (merged.length-1, 1, prev) | |
} | |
else if (prev[1] < next[0]) { | |
merged.push (next) | |
} | |
else if (prev[1] < next[1]) { | |
prev[1] = next[1] | |
merged.splice (merged.length-1, 1, prev) | |
} | |
} | |
return merged; | |
} | |
//#endregion | |
//#region readonly-range.js | |
// import { SynchronizedIntervals, excludeIntervals } from "./intervals.js" | |
// | |
// /** | |
// * @typedef {import ("./types").Interval} Interval | |
// * @typedef {import ("./types").ReadonlyInterval} ReadonlyInterval | |
// */ | |
/** | |
* @warnings | |
* For correct behavior, all models must have a single character at the end of the line. | |
* This can be set with `ITextModel.setEOL (monaco.editor.EndOfLineSequence.LF)` | |
*/ | |
class ReadonlyFeature | |
{ | |
/** @param {monaco.editor.IStandaloneCodeEditor} editor */ | |
constructor (editor) | |
{ | |
this.editor = editor | |
this.intervals = SynchronizedIntervals (editor) | |
} | |
/** @type {monaco.IDisposable[]} */ | |
disposables = [] | |
enable () | |
{ | |
this.disposables.push( | |
this.editor.onDidChangeCursorPosition (this.onCursor), | |
this.editor.onKeyDown (this.onKey), | |
this.editor.onMouseDown (this.onRectangleSelectionStart), | |
) | |
this.intervals.show () | |
} | |
dispose () | |
{ | |
for (var d of this.disposables) | |
d.dispose() | |
this.disposables.splice(0) | |
} | |
/** | |
* @param {number} startLine | |
* @param {number} [startColumn] | |
* @param {number} [endLine] | |
* @param {number} [endColumn] | |
*/ | |
exclude (startLine, startColumn, endLine, endColumn) | |
{ | |
if (arguments.length === 1) | |
this.intervals.exclude (startLine, 0, startLine, Number.MAX_SAFE_INTEGER) | |
//this.ranges.push (new monaco.Range (startLine, 0, startLine, Number.MAX_SAFE_INTEGER)) | |
else | |
this.intervals.exclude (startLine, startColumn, endLine, endColumn) | |
//this.ranges.push (new monaco.Range (startLine, startColumn, endLine, endColumn)) | |
} | |
/** | |
* Linked to ICodeEditor.onDidChangeCursorPosition event. | |
*/ | |
onCursor = bind (this, function (/** @type {monaco.editor.ICursorPositionChangedEvent} */ event) | |
{ | |
if (event.source === "api") return | |
console.log ("onCursor") | |
const newSelections = this.getApprovedSelections () | |
if (newSelections.length !== 0) | |
this.editor.setSelections (newSelections) | |
console.log ("new selections", newSelections) | |
}) | |
/** | |
* Flag for `excludeIntervals` | |
* @type {-1|0|1} | |
*/ | |
prefer = 1 | |
/** @type {-1|0|1} */ | |
lastPrefer | |
onRectangleSelectionStart = bind (this, function ({ event }) | |
{ | |
console.log ("onRectangleSelectionStart", event) | |
if (event.middleButton ) { | |
this.lastPrefer = this.prefer | |
this.prefer = 0 | |
window.addEventListener ("pointerup", this.onRectangleSelectionStop) | |
} | |
}) | |
onRectangleSelectionStop = bind (this, function () | |
{ | |
console.log ("onRectangleSelectionStop") | |
this.prefer = this.lastPrefer | |
window.removeEventListener ("pointerup", this.onRectangleSelectionStop) | |
}) | |
/** | |
* Linked to ICodeEditor.onKeyDown event. | |
*/ | |
onKey = bind (this, function (/** @type {monaco.IKeyboardEvent} */ event) | |
{ | |
console.log ("onKey") | |
const key = event.keyCode | |
const KeyCode = monaco.KeyCode | |
if (event.altKey || (KeyCode.F1 <= key && key <= KeyCode.F19)) | |
return | |
if (event.ctrlKey && key !== KeyCode.Backspace) | |
return | |
if (key === KeyCode.UpArrow || | |
key === KeyCode.LeftArrow || | |
key === KeyCode.Home || | |
key === KeyCode.PageUp | |
){ | |
this.prefer = -1 | |
return | |
} | |
if (key === KeyCode.DownArrow || | |
key === KeyCode.RightArrow || | |
key === KeyCode.End || | |
key === KeyCode.PageDown | |
){ | |
this.prefer = 1 | |
return | |
} | |
const selections = this.getSelections () | |
const intervals = this.intervals.buffer | |
/** @type {(sel: Interval) => boolean} */ var match | |
if (key === KeyCode.Delete) | |
{ | |
match = (/** @type {Interval} */ i) => | |
( | |
i[0] === i[1] && | |
intervals.find ((e) => i[1]+1 === e[0]) != null | |
) | |
} | |
else if (key === KeyCode.Backspace) | |
{ | |
match = (/** @type {Interval} */ i) => | |
( | |
i[0] === i[1] && | |
intervals.find ((e) => e[1]+1 === i[0]) != null | |
) | |
} | |
else | |
{ | |
return | |
} | |
if (selections.findIndex (match) !== -1) | |
{ | |
event.stopPropagation () | |
event.preventDefault () | |
} | |
}) | |
getApprovedSelections () | |
{ | |
const model = this.editor.getModel () | |
const selections = this.getSelections () | |
//.map (a => (console.log ("from", a), a)) | |
.map (include => excludeIntervals (include, this.intervals.buffer, this.prefer)).flat() | |
//.map (a => (console.log ("approved", a), a)) | |
.map (interval => { | |
var a = model.getPositionAt (interval[0]), | |
b = model.getPositionAt (interval[1]) | |
return new monaco.Selection ( | |
a.lineNumber, a.column, b.lineNumber, b.column | |
) | |
}) | |
return selections | |
} | |
/** @returns {Interval[]} */ | |
getSelections () | |
{ | |
const model = this.editor.getModel () | |
return this.editor.getSelections () | |
.map (sel => [ | |
model.getOffsetAt ({ lineNumber: sel.selectionStartLineNumber, column: sel.selectionStartColumn }), | |
model.getOffsetAt ({ lineNumber: sel.positionLineNumber, column: sel.positionColumn }) | |
]) | |
} | |
/** @param {number} offset */ | |
getOffsetAt = bind (this, function (offset) | |
{ | |
var interval | |
for (var i = 0; i < this.intervals.length; i++) | |
{ | |
interval = this.intervals[i] | |
if (offset <= interval[1]) { | |
return interval[0] - offset < offset - interval[1] | |
? interval[0] | |
: interval[1] | |
} | |
} | |
}) | |
} | |
/** | |
* @template {object} T | |
* @template {(this: T, ...args: any[]) => any} F | |
* @type {(target: T, func: F & ThisType <T>) => F & ThisType <T>} | |
*/ | |
function bind (target, func) | |
{ | |
return func.bind (target) | |
} | |
//#endregion | |
var editor = monaco.editor.create(document.getElementById ("container"), { | |
value: [ | |
"/**", | |
" * ", | |
" * @param {...string} args", | |
" * @author your name", | |
" * @version 0.0.0", | |
" */", | |
"function main (...args) {", | |
" // type your code here", | |
"}", | |
].join ('\n'), | |
language: "javascript", | |
glyphMargin: true, | |
contextmenu: false | |
}); | |
editor.getModel ().setEOL (monaco.editor.EndOfLineSequence.LF) | |
const readonlyFeature = new ReadonlyFeature (editor) | |
readonlyFeature.exclude (0, 0, 2, 3) | |
readonlyFeature.exclude (3, 0, 4, 11) | |
readonlyFeature.exclude (5, 0, 5, 12) | |
readonlyFeature.exclude (6, 0, 7, Number.POSITIVE_INFINITY) | |
readonlyFeature.exclude (9, 0, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY) | |
readonlyFeature.enable () |
while there are many flaws like one could use find and replace but you deserve hatsoff for your efforts
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Someday when you're bored, you should make this a package. Maybe that works with https://www.npmjs.com/package/@monaco-editor/react