Skip to content

Instantly share code, notes, and snippets.

@corbane
Last active December 26, 2021 07:07
Show Gist options
  • Save corbane/37c8751b6c60018a03fa4dd3ea48bf3e to your computer and use it in GitHub Desktop.
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
//@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 ()
@staticle
Copy link

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