From 016f9ad74b1af76fe97e397670809ed9f3647e16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Mangeonjean?= Date: Mon, 5 Feb 2024 22:43:49 +0100 Subject: [PATCH 1/5] fix: use ICodeEditor instead of IStandaloneCodeEditor when possible --- src/keybindings/vim.ts | 2 +- src/tools.ts | 2 +- src/tools/EditorOpenHandlerRegistry.ts | 2 +- src/types/monaco-vim.d.ts | 2 +- src/types/monaco.d.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/keybindings/vim.ts b/src/keybindings/vim.ts index 75c5c82..6b86007 100644 --- a/src/keybindings/vim.ts +++ b/src/keybindings/vim.ts @@ -92,6 +92,6 @@ onConfigurationChanged(() => { } }) -export function initVimMode (editor: monaco.editor.IStandaloneCodeEditor, statusBarElement: Element): IDisposable { +export function initVimMode (editor: monaco.editor.ICodeEditor, statusBarElement: Element): IDisposable { return monacoVim.initVimMode(editor, statusBarElement) } diff --git a/src/tools.ts b/src/tools.ts index 92a464f..5104ca0 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -224,7 +224,7 @@ export function hideCodeWithoutDecoration (editor: monaco.editor.IStandaloneCode /** * Collapse everything between startToken and endToken */ -export async function collapseCodeSections (editor: monaco.editor.IStandaloneCodeEditor, startToken: string, endToken: string, isRegex: boolean = false): Promise { +export async function collapseCodeSections (editor: monaco.editor.ICodeEditor, startToken: string, endToken: string, isRegex: boolean = false): Promise { const editorModel = editor.getModel() const ranges: monaco.IRange[] = [] if (editorModel != null) { diff --git a/src/tools/EditorOpenHandlerRegistry.ts b/src/tools/EditorOpenHandlerRegistry.ts index c3dba8f..55796a1 100644 --- a/src/tools/EditorOpenHandlerRegistry.ts +++ b/src/tools/EditorOpenHandlerRegistry.ts @@ -5,7 +5,7 @@ import { createEditor } from '../monaco' let currentEditor: ({ model: monaco.editor.ITextModel - editor: monaco.editor.IStandaloneCodeEditor + editor: monaco.editor.ICodeEditor } & monaco.IDisposable) | null = null function openNewCodeEditor (modelRef: IReference) { if (currentEditor != null && modelRef.object.textEditorModel === currentEditor.model) { diff --git a/src/types/monaco-vim.d.ts b/src/types/monaco-vim.d.ts index 636240c..31effee 100644 --- a/src/types/monaco-vim.d.ts +++ b/src/types/monaco-vim.d.ts @@ -1,7 +1,7 @@ declare module 'monaco-vim' { import * as monaco from 'monaco-editor' export const initVimMode: ( - editor: monaco.editor.IStandaloneCodeEditor, + editor: monaco.editor.ICodeEditor, statusBarElement: Element ) => monaco.IDisposable export const VimMode: { diff --git a/src/types/monaco.d.ts b/src/types/monaco.d.ts index 802e09b..14364f3 100644 --- a/src/types/monaco.d.ts +++ b/src/types/monaco.d.ts @@ -2,7 +2,7 @@ import { IRange } from 'monaco-editor' declare module 'monaco-editor' { namespace editor { - interface IStandaloneCodeEditor { + interface ICodeEditor { // This method is internal and is supposed to be used by the folding feature // We still use it to hide parts of the code in the `hideCodeWithoutDecoration` function setHiddenAreas(ranges: IRange[]): void From ef097eb884cf314b19285f19639cc92bace1a778 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Mangeonjean?= Date: Mon, 5 Feb 2024 22:44:52 +0100 Subject: [PATCH 2/5] fix: replace ts-ignore by proper types --- src/tools.ts | 3 +-- src/types/monaco.d.ts | 4 ++++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/tools.ts b/src/tools.ts index 5104ca0..d09a401 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -156,8 +156,7 @@ export function lockCodeWithoutDecoration ( } export function hideCodeWithoutDecoration (editor: monaco.editor.IStandaloneCodeEditor, decorations: string[]): () => void { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let otherHiddenAreas: monaco.IRange[] = (editor as any)._getViewModel()._lines.getHiddenAreas() + let otherHiddenAreas: monaco.IRange[] = editor._getViewModel()?.getHiddenAreas() ?? [] function getHiddenAreas () { const model = editor.getModel()! diff --git a/src/types/monaco.d.ts b/src/types/monaco.d.ts index 14364f3..2ca02f2 100644 --- a/src/types/monaco.d.ts +++ b/src/types/monaco.d.ts @@ -6,6 +6,10 @@ declare module 'monaco-editor' { // This method is internal and is supposed to be used by the folding feature // We still use it to hide parts of the code in the `hideCodeWithoutDecoration` function setHiddenAreas(ranges: IRange[]): void + + _getViewModel(): { + getHiddenAreas(): IRange[] + } | undefined } } } From 8f4e492e412dcddbea79f890c2d9d102d15b9732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Mangeonjean?= Date: Mon, 5 Feb 2024 22:48:28 +0100 Subject: [PATCH 3/5] fix: make tools return Disposables --- src/tools.ts | 68 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 39 insertions(+), 29 deletions(-) diff --git a/src/tools.ts b/src/tools.ts index d09a401..e4a4f0f 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -16,7 +16,8 @@ export function lockCodeWithoutDecoration ( decorations: string[], allowChangeFromSources: string[] = [], errorMessage?: string -): () => void { +): monaco.IDisposable { + const disposableStore = new DisposableStore() function displayLockedCodeError (position: monaco.Position) { if (errorMessage == null) { return @@ -124,38 +125,42 @@ export function lockCodeWithoutDecoration ( model.applyEdits = originalApplyEdit as typeof model.applyEdits } } - const editorChangeModelDisposable = editor.onDidChangeModel(lockModel) + disposableStore.add(editor.onDidChangeModel(lockModel)) lockModel() // Handle selection of the last line of an editable range - const selectionDisposable = editor.onDidChangeCursorSelection(e => { - if (canEditRange(e.selection)) { - return - } - const model = editor.getModel() - if (model == null) { - return - } - const shiftedRange = monaco.Range.fromPositions( - model.getPositionAt(model.getOffsetAt(e.selection.getStartPosition()) - 1), - model.getPositionAt(model.getOffsetAt(e.selection.getEndPosition()) - 1) - ) - if (canEditRange(shiftedRange)) { - editor.setSelection(shiftedRange) + disposableStore.add( + editor.onDidChangeCursorSelection((e) => { + if (canEditRange(e.selection)) { + return + } + const model = editor.getModel() + if (model == null) { + return + } + const shiftedRange = monaco.Range.fromPositions( + model.getPositionAt(model.getOffsetAt(e.selection.getStartPosition()) - 1), + model.getPositionAt(model.getOffsetAt(e.selection.getEndPosition()) - 1) + ) + if (canEditRange(shiftedRange)) { + editor.setSelection(shiftedRange) + } + }) + ) + + disposableStore.add({ + dispose () { + restoreModelApplyEdit() + editor.executeEdits = originalExecuteEdit + editor.executeCommands = originalExecuteCommands + editor.trigger = originalTrigger } }) - return () => { - selectionDisposable.dispose() - restoreModelApplyEdit() - editorChangeModelDisposable.dispose() - editor.executeEdits = originalExecuteEdit - editor.executeCommands = originalExecuteCommands - editor.trigger = originalTrigger - } + return disposableStore } -export function hideCodeWithoutDecoration (editor: monaco.editor.IStandaloneCodeEditor, decorations: string[]): () => void { +export function hideCodeWithoutDecoration (editor: monaco.editor.IStandaloneCodeEditor, decorations: string[]): monaco.IDisposable { let otherHiddenAreas: monaco.IRange[] = editor._getViewModel()?.getHiddenAreas() ?? [] function getHiddenAreas () { const model = editor.getModel()! @@ -212,12 +217,17 @@ export function hideCodeWithoutDecoration (editor: monaco.editor.IStandaloneCode updateHiddenAreas() } + const disposableStore = new DisposableStore() updateHiddenAreas() - return () => { - editor.setHiddenAreas = originalSetHiddenAreas - editor.setHiddenAreas(otherHiddenAreas) - } + disposableStore.add({ + dispose () { + editor.setHiddenAreas = originalSetHiddenAreas + editor.setHiddenAreas(otherHiddenAreas) + } + }) + + return disposableStore } /** From 3d540f4fc95590534411691b15c0b7ac09fe28a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Mangeonjean?= Date: Mon, 5 Feb 2024 22:49:37 +0100 Subject: [PATCH 4/5] feat!: switch from decoration ids to decoration filter and make tools more robust --- src/tools.ts | 70 ++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 54 insertions(+), 16 deletions(-) diff --git a/src/tools.ts b/src/tools.ts index e4a4f0f..de1513f 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -12,8 +12,8 @@ function isPasteAction (handlerId: string, payload: unknown): payload is PastePa } export function lockCodeWithoutDecoration ( - editor: monaco.editor.IStandaloneCodeEditor, - decorations: string[], + editor: monaco.editor.ICodeEditor, + decorationFilter: (decoration: monaco.editor.IModelDecoration) => boolean, allowChangeFromSources: string[] = [], errorMessage?: string ): monaco.IDisposable { @@ -30,8 +30,14 @@ export function lockCodeWithoutDecoration ( function canEditRange (range: monaco.IRange) { const model = editor.getModel() if (model != null) { - const editableRanges = decorations.map(decoration => model.getDecorationRange(decoration)) - return editableRanges.some(editableRange => editableRange?.containsRange(range) ?? false) + const editableRanges = model + .getAllDecorations() + .filter(decorationFilter) + .map((decoration) => decoration.range) + if (editableRanges.length === 0) { + return true + } + return editableRanges.some((editableRange) => editableRange.containsRange(range)) } return false } @@ -52,7 +58,13 @@ export function lockCodeWithoutDecoration ( const originalTrigger = editor.trigger editor.trigger = function (source, handlerId, payload) { // Try to transform whole file pasting into a paste in the editable area only - const lastEditableRange = decorations.length > 0 ? editor.getModel()?.getDecorationRange(decorations[decorations.length - 1]!) : null + const editableRanges = editor + .getModel()! + .getAllDecorations() + .filter(decorationFilter) + .map((decoration) => decoration.range) + const lastEditableRange = + editableRanges.length > 0 ? editableRanges[editableRanges.length - 1] : undefined if (isPasteAction(handlerId, payload) && lastEditableRange != null) { const selections = editor.getSelections() const model = editor.getModel()! @@ -63,10 +75,12 @@ export function lockCodeWithoutDecoration ( if (wholeFileSelected) { const currentEditorValue = editor.getValue() const before = model.getOffsetAt(lastEditableRange.getStartPosition()) - const after = currentEditorValue.length - model.getOffsetAt(lastEditableRange.getEndPosition()) + const after = + currentEditorValue.length - model.getOffsetAt(lastEditableRange.getEndPosition()) if ( currentEditorValue.slice(0, before) === payload.text.slice(0, before) && - currentEditorValue.slice(currentEditorValue.length - after) === payload.text.slice(payload.text.length - after) + currentEditorValue.slice(currentEditorValue.length - after) === + payload.text.slice(payload.text.length - after) ) { editor.setSelection(lastEditableRange) const newPayload: PastePayload = { @@ -81,7 +95,7 @@ export function lockCodeWithoutDecoration ( if (['type', 'paste', 'cut'].includes(handlerId)) { const selections = editor.getSelections() - if (selections != null && selections.some(range => !canEditRange(range))) { + if (selections != null && selections.some((range) => !canEditRange(range))) { displayLockedCodeError(editor.getPosition()!) return } @@ -107,16 +121,23 @@ export function lockCodeWithoutDecoration ( if (model == null) { return } - const originalApplyEdit: (operations: monaco.editor.IIdentifiedSingleEditOperation[], computeUndoEdits?: boolean) => void = model.applyEdits - model.applyEdits = ((operations: monaco.editor.IIdentifiedSingleEditOperation[], computeUndoEdits?: boolean) => { + const originalApplyEdit: ( + operations: monaco.editor.IIdentifiedSingleEditOperation[], + computeUndoEdits?: boolean + ) => void = model.applyEdits + model.applyEdits = (( + operations: monaco.editor.IIdentifiedSingleEditOperation[], + computeUndoEdits?: boolean + ) => { if (currentEditSource != null && allowChangeFromSources.includes(currentEditSource)) { return originalApplyEdit.call(model, operations, computeUndoEdits!) } - const filteredOperations = operations - .filter(operation => canEditRange(operation.range)) + const filteredOperations = operations.filter((operation) => canEditRange(operation.range)) if (filteredOperations.length === 0 && operations.length > 0) { const firstRange = operations[0]!.range - displayLockedCodeError(new monaco.Position(firstRange.startLineNumber, firstRange.startColumn)) + displayLockedCodeError( + new monaco.Position(firstRange.startLineNumber, firstRange.startColumn) + ) } return originalApplyEdit.call(model, filteredOperations, computeUndoEdits!) }) as typeof model.applyEdits @@ -160,13 +181,22 @@ export function lockCodeWithoutDecoration ( return disposableStore } -export function hideCodeWithoutDecoration (editor: monaco.editor.IStandaloneCodeEditor, decorations: string[]): monaco.IDisposable { +export function hideCodeWithoutDecoration (editor: monaco.editor.ICodeEditor, decorationFilter: (decoration: monaco.editor.IModelDecoration) => boolean): monaco.IDisposable { let otherHiddenAreas: monaco.IRange[] = editor._getViewModel()?.getHiddenAreas() ?? [] function getHiddenAreas () { - const model = editor.getModel()! + const model = editor.getModel() + if (model == null) { + return [] + } + + const decorations = model.getAllDecorations() + .filter(decorationFilter) + if (decorations.length === 0) { + return otherHiddenAreas + } const ranges = decorations - .map(decoration => model.getDecorationRange(decoration)!) + .map(decoration => decoration.range) .sort((a, b) => a.startLineNumber - b.startLineNumber) // merge ranges .reduce((acc, range) => { @@ -218,6 +248,14 @@ export function hideCodeWithoutDecoration (editor: monaco.editor.IStandaloneCode } const disposableStore = new DisposableStore() + + disposableStore.add(editor.onDidChangeModel(() => { + otherHiddenAreas = editor._getViewModel()?.getHiddenAreas() ?? [] + updateHiddenAreas() + })) + disposableStore.add(editor.onDidChangeModelDecorations(() => { + updateHiddenAreas() + })) updateHiddenAreas() disposableStore.add({ From 5cd633ee7e3ebc62e3f43c3aca6f7fe84f99a638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Mangeonjean?= Date: Mon, 5 Feb 2024 22:50:09 +0100 Subject: [PATCH 5/5] feat: add more tools --- src/tools.ts | 275 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) diff --git a/src/tools.ts b/src/tools.ts index de1513f..897e575 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -1,4 +1,5 @@ import * as monaco from 'monaco-editor' +import { ContextKeyExpr, DisposableStore, KeybindingsRegistry } from 'vscode/monaco' interface PastePayload { text: string @@ -316,3 +317,277 @@ export async function collapseCodeSections (editor: monaco.editor.ICodeEditor, s } } } + +interface IDecorationProvider { + provideDecorations (model: monaco.editor.ITextModel): monaco.editor.IModelDeltaDecoration[] +} + +export function registerTextDecorationProvider (provider: IDecorationProvider): monaco.IDisposable { + const disposableStore = new DisposableStore() + + const watchEditor = (editor: monaco.editor.ICodeEditor): monaco.IDisposable => { + const disposableStore = new DisposableStore() + const decorationCollection = editor.createDecorationsCollection() + + const checkEditor = () => { + const model = editor.getModel() + if (model != null) { + decorationCollection.set(provider.provideDecorations(model)) + } else { + decorationCollection.clear() + } + } + + disposableStore.add(editor.onDidChangeModel(checkEditor)) + disposableStore.add(editor.onDidChangeModelContent(checkEditor)) + disposableStore.add({ + dispose () { + decorationCollection.clear() + } + }) + checkEditor() + return disposableStore + } + + monaco.editor.getEditors().forEach(editor => disposableStore.add(watchEditor(editor))) + disposableStore.add(monaco.editor.onDidCreateEditor(editor => disposableStore.add(watchEditor(editor)))) + + return disposableStore +} + +export function runOnAllEditors (cb: (editor: monaco.editor.ICodeEditor) => monaco.IDisposable): monaco.IDisposable { + const disposableStore = new DisposableStore() + + const handleEditor = (editor: monaco.editor.ICodeEditor) => { + const disposable = cb(editor) + disposableStore.add(disposable) + const disposeEventDisposable = editor.onDidDispose(() => { + disposableStore.delete(disposable) + disposableStore.delete(disposeEventDisposable) + }) + disposableStore.add(disposeEventDisposable) + } + monaco.editor.getEditors().forEach(handleEditor) + disposableStore.add(monaco.editor.onDidCreateEditor(handleEditor)) + + return disposableStore +} + +export function preventAlwaysConsumeTouchEvent (editor: monaco.editor.ICodeEditor): void { + let firstX = 0 + let firstY = 0 + let atTop = false + let atBottom = false + let atLeft = false + let atRight = false + let useBrowserBehavior: null | boolean = null + + editor.onDidChangeModel(() => { + const domNode = editor.getDomNode() + if (domNode == null) { + return + } + domNode.addEventListener('touchstart', (e) => { + const firstTouch = e.targetTouches.item(0) + if (firstTouch == null) { + return + } + + // Prevent monaco-editor from trying to call preventDefault on the touchstart event + // so we'll be able to use the default behavior of the touchmove event + e.preventDefault = () => {} + + firstX = firstTouch.clientX + firstY = firstTouch.clientY + + const layoutInfo = editor.getLayoutInfo() + atTop = editor.getScrollTop() <= 0 + atBottom = editor.getScrollTop() >= editor.getContentHeight() - layoutInfo.height + atLeft = editor.getScrollLeft() <= 0 + atRight = editor.getScrollLeft() >= editor.getContentWidth() - layoutInfo.width + useBrowserBehavior = null + }) + domNode.addEventListener('touchmove', (e) => { + const firstTouch = e.changedTouches.item(0) + if (firstTouch == null) { + return + } + + if (useBrowserBehavior == null) { + const dx = firstTouch.clientX - firstX + const dy = firstTouch.clientY - firstY + if (Math.abs(dx) > Math.abs(dy)) { + // It's an horizontal scroll + useBrowserBehavior = (dx < 0 && atRight) || (dx > 0 && atLeft) + } else { + // It's a vertical scroll + useBrowserBehavior = (dy < 0 && atBottom) || (dy > 0 && atTop) + } + } + if (useBrowserBehavior) { + // Stop the event before monaco tries to preventDefault on it + e.stopPropagation() + } + }) + domNode.addEventListener('touchend', (e) => { + if (useBrowserBehavior ?? false) { + // Prevent monaco from trying to open its context menu + // It thinks it's a long press because it didn't receive the move events + e.stopPropagation() + } + }) + }) +} + +// https://github.com/microsoft/monaco-editor/issues/568 +class PlaceholderContentWidget implements monaco.editor.IContentWidget { + private static readonly ID = 'editor.widget.placeholderHint' + + private domNode: HTMLElement | undefined + + constructor ( + private readonly editor: monaco.editor.ICodeEditor, + private readonly placeholder: string + ) {} + + getId (): string { + return PlaceholderContentWidget.ID + } + + getDomNode (): HTMLElement { + if (this.domNode == null) { + this.domNode = document.createElement('pre') + this.domNode.style.width = 'max-content' + this.domNode.textContent = this.placeholder + this.domNode.style.pointerEvents = 'none' + this.domNode.style.color = '#aaa' + this.domNode.style.margin = '0' + + this.editor.applyFontInfo(this.domNode) + } + + return this.domNode + } + + getPosition (): monaco.editor.IContentWidgetPosition | null { + return { + position: { lineNumber: 1, column: 1 }, + preference: [monaco.editor.ContentWidgetPositionPreference.EXACT] + } + } +} + +export function addPlaceholder ( + editor: monaco.editor.ICodeEditor, + placeholder: string +): monaco.IDisposable { + const widget = new PlaceholderContentWidget(editor, placeholder) + + function onDidChangeModelContent (): void { + if (editor.getValue() === '') { + editor.addContentWidget(widget) + } else { + editor.removeContentWidget(widget) + } + } + + onDidChangeModelContent() + const changeDisposable = editor.onDidChangeModelContent(() => onDidChangeModelContent()) + return { + dispose () { + changeDisposable.dispose() + editor.removeContentWidget(widget) + } + } +} + +export function mapClipboard ( + editor: monaco.editor.ICodeEditor, + { + toClipboard, + fromClipboard + }: { + toClipboard: (data: string) => string + fromClipboard: (data: string) => string + } +): monaco.IDisposable { + const disposableStore = new DisposableStore() + let copiedText = '' + + disposableStore.add( + KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: 'customCopy', + weight: 1000, + handler: () => { + copiedText = editor.getModel()!.getValueInRange(editor.getSelection()!) + document.execCommand('copy') + }, + when: ContextKeyExpr.equals('editorId', editor.getId()), + primary: monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyC + }) + ) + + disposableStore.add( + KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: 'customCut', + weight: 1000, + handler: () => { + copiedText = editor.getModel()!.getValueInRange(editor.getSelection()!) + document.execCommand('copy') + }, + when: ContextKeyExpr.equals('editorId', editor.getId()), + primary: monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyX + }) + ) + + const originalTrigger = editor.trigger + editor.trigger = function (source, handlerId, payload) { + if (handlerId === 'editor.action.clipboardCopyAction') { + copiedText = editor.getModel()!.getValueInRange(editor.getSelection()!) + } else if (handlerId === 'editor.action.clipboardCutAction') { + copiedText = editor.getModel()!.getValueInRange(editor.getSelection()!) + } else if (handlerId === 'paste') { + const newText = fromClipboard(payload.text) + if (newText !== payload.text) { + payload = { + ...payload, + text: newText + } + } + } + originalTrigger.call(this, source, handlerId, payload) + } + disposableStore.add({ + dispose () { + editor.trigger = originalTrigger + } + }) + + function mapCopy (event: ClipboardEvent): void { + const clipdata = event.clipboardData ?? (window as unknown as { clipboardData: DataTransfer }).clipboardData + let content = clipdata.getData('Text') + if (content.length === 0) { + content = copiedText + } + const transformed = toClipboard(content) + if (transformed !== content) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (clipdata.types != null) { + clipdata.types.forEach(type => clipdata.setData(type, toClipboard(content))) + } else { + clipdata.setData('text/plain', toClipboard(content)) + } + } + } + const editorDomNode = editor.getContainerDomNode() + editorDomNode.addEventListener('copy', mapCopy) + editorDomNode.addEventListener('cut', mapCopy) + disposableStore.add({ + dispose () { + editorDomNode.removeEventListener('copy', mapCopy) + editorDomNode.removeEventListener('cut', mapCopy) + } + }) + + return disposableStore +}