diff --git a/frontend/src/components/MonacoEditor.input-fallback.test.tsx b/frontend/src/components/MonacoEditor.input-fallback.test.tsx index 4d689288..d9b36391 100644 --- a/frontend/src/components/MonacoEditor.input-fallback.test.tsx +++ b/frontend/src/components/MonacoEditor.input-fallback.test.tsx @@ -39,9 +39,33 @@ describe('MonacoEditor printable input fallback', () => { let input: FakeTextAreaElement; let value: string; let position: { lineNumber: number; column: number }; + let selection: { + startLineNumber: number; + startColumn: number; + endLineNumber: number; + endColumn: number; + } | null; let modelContentListener: (() => void) | null; let editor: any; + const installFallback = () => installPrintableInputFallback(editor, { + editor: { EditorOption: { readOnly: 1 } }, + }); + + const setSingleLineSelection = ( + startColumn: number, + endColumn: number, + activeColumn = endColumn, + ) => { + selection = { + startLineNumber: 1, + startColumn, + endLineNumber: 1, + endColumn, + }; + position = { lineNumber: 1, column: activeColumn }; + }; + beforeEach(() => { vi.useFakeTimers(); input = new FakeTextAreaElement(); @@ -56,6 +80,7 @@ describe('MonacoEditor printable input fallback', () => { value = ''; position = { lineNumber: 1, column: 1 }; + selection = null; modelContentListener = null; const offsetAt = (target: { lineNumber: number; column: number }) => ( Math.max(0, Math.min(value.length, Number(target.column || 1) - 1)) @@ -66,7 +91,7 @@ describe('MonacoEditor printable input fallback', () => { }); editor = { getDomNode: () => editorDomNode, - getSelection: () => ({ + getSelection: () => selection || ({ startLineNumber: position.lineNumber, startColumn: position.column, endLineNumber: position.lineNumber, @@ -80,8 +105,23 @@ describe('MonacoEditor printable input fallback', () => { getPosition: () => position, getOption: () => false, trigger: vi.fn((_source: string, _command: string, payload: { text: string }) => { - value += payload.text; - position = { lineNumber: 1, column: position.column + payload.text.length }; + const activeSelection = selection || { + startLineNumber: position.lineNumber, + startColumn: position.column, + endLineNumber: position.lineNumber, + endColumn: position.column, + }; + const startOffset = offsetAt({ + lineNumber: activeSelection.startLineNumber, + column: activeSelection.startColumn, + }); + const endOffset = offsetAt({ + lineNumber: activeSelection.endLineNumber, + column: activeSelection.endColumn, + }); + value = value.slice(0, startOffset) + payload.text + value.slice(endOffset); + position = positionAt(startOffset + payload.text.length); + selection = null; }), executeEdits: vi.fn((_source: string, edits: Array<{ range: any; text: string }>) => { for (const edit of edits) { @@ -98,6 +138,7 @@ describe('MonacoEditor printable input fallback', () => { }), setPosition: vi.fn((nextPosition: { lineNumber: number; column: number }) => { position = nextPosition; + selection = null; }), onDidChangeModelContent: vi.fn((listener: () => void) => { modelContentListener = listener; @@ -217,4 +258,132 @@ describe('MonacoEditor printable input fallback', () => { expect(value).toBe('xayb'); expect(position).toEqual({ lineNumber: 1, column: 5 }); }); + + it('replaces selected text after one printable input when native input is dropped', () => { + installFallback(); + + value = 'and task_datetime <= least(sysdate)'; + setSingleLineSelection(19, 23); + + input.dispatchPrintableBeforeInput('<'); + vi.advanceTimersByTime(80); + + expect(value).toBe('and task_datetime <= least(sysdate)'); + expect(position).toEqual({ lineNumber: 1, column: 20 }); + }); + + it('does not duplicate printable text when Monaco replaces the selection natively', () => { + installFallback(); + + value = 'and task_datetime <= least(sysdate)'; + setSingleLineSelection(19, 23); + + input.dispatchPrintableBeforeInput('<'); + value = 'and task_datetime <= least(sysdate)'; + selection = null; + position = { lineNumber: 1, column: 20 }; + modelContentListener?.(); + vi.advanceTimersByTime(200); + + expect(value).toBe('and task_datetime <= least(sysdate)'); + expect(editor.executeEdits).not.toHaveBeenCalled(); + expect(position).toEqual({ lineNumber: 1, column: 20 }); + }); + + it('recovers printable text when native input only deletes a reverse selection', () => { + installFallback(); + + value = 'and task_datetime <= least(sysdate)'; + setSingleLineSelection(19, 23, 19); + + input.dispatchPrintableBeforeInput('<'); + value = 'and task_datetime = least(sysdate)'; + selection = null; + position = { lineNumber: 1, column: 19 }; + modelContentListener?.(); + vi.advanceTimersByTime(80); + + expect(value).toBe('and task_datetime <= least(sysdate)'); + expect(position).toEqual({ lineNumber: 1, column: 20 }); + }); + + it('does not let an older cursor fallback consume a new selection replacement', () => { + installFallback(); + + value = 'abcd'; + position = { lineNumber: 1, column: 1 }; + input.dispatchPrintableBeforeInput('x'); + setSingleLineSelection(3, 4); + + input.dispatchPrintableBeforeInput('y'); + vi.advanceTimersByTime(80); + + expect(value).toBe('abyd'); + expect(position).toEqual({ lineNumber: 1, column: 4 }); + }); + + it('does not let an older selection fallback consume a newer selection replacement', () => { + installFallback(); + + value = 'abcd'; + setSingleLineSelection(1, 2); + input.dispatchPrintableBeforeInput('x'); + setSingleLineSelection(3, 4); + + input.dispatchPrintableBeforeInput('y'); + vi.advanceTimersByTime(80); + + expect(value).toBe('abyd'); + expect(position).toEqual({ lineNumber: 1, column: 4 }); + }); + + it('keeps consecutive dropped input when the original selection is still active', () => { + installFallback(); + + value = 'abcd'; + setSingleLineSelection(2, 4); + + input.dispatchPrintableBeforeInput('x'); + input.dispatchPrintableBeforeInput('y'); + vi.advanceTimersByTime(80); + + expect(value).toBe('axyd'); + expect(position).toEqual({ lineNumber: 1, column: 4 }); + }); + + it('collapses a same-text selection when its native replacement is dropped', () => { + installFallback(); + + value = 'a { + installFallback(); + + value = 'abcd'; + setSingleLineSelection(2, 4); + input.dispatchPrintableBeforeInput('x'); + + value = 'axd'; + modelContentListener?.(); + selection = null; + position = { lineNumber: 1, column: 4 }; + input.dispatchPrintableBeforeInput('y'); + vi.advanceTimersByTime(80); + + expect(value).toBe('axdy'); + expect(position).toEqual({ lineNumber: 1, column: 5 }); + }); }); diff --git a/frontend/src/components/MonacoEditor.tsx b/frontend/src/components/MonacoEditor.tsx index 6be3494b..76bf4162 100644 --- a/frontend/src/components/MonacoEditor.tsx +++ b/frontend/src/components/MonacoEditor.tsx @@ -51,6 +51,13 @@ const sameEditorPosition = (left: any, right: any): boolean => ( && Number(left?.column) === Number(right?.column) ); +const sameEditorRange = (left: any, right: any): boolean => ( + Number(left?.startLineNumber) === Number(right?.startLineNumber) + && Number(left?.startColumn) === Number(right?.startColumn) + && Number(left?.endLineNumber) === Number(right?.endLineNumber) + && Number(left?.endColumn) === Number(right?.endColumn) +); + const isSelectionEmpty = (selection: any): boolean => ( !selection || ( @@ -278,6 +285,19 @@ export const installPrintableInputFallback = (editor: any, monaco: any) => { text: string; timer: number | null; } | null = null; + let pendingSelectionInput: { + valueBefore: string; + rangeBefore: { + startLineNumber: number; + startColumn: number; + endLineNumber: number; + endColumn: number; + }; + startOffset: number; + endOffset: number; + text: string; + timer: number | null; + } | null = null; const clearPendingInput = () => { if (!pendingInput) { @@ -289,6 +309,16 @@ export const installPrintableInputFallback = (editor: any, monaco: any) => { pendingInput = null; }; + const clearPendingSelectionInput = () => { + if (!pendingSelectionInput) { + return; + } + if (pendingSelectionInput.timer !== null) { + clearTimeout(pendingSelectionInput.timer); + } + pendingSelectionInput = null; + }; + const getPendingNativeInputDelta = (pending: NonNullable) => { const afterValue = String(editor.getValue?.() ?? ''); if (afterValue === pending.valueBefore) { @@ -408,6 +438,98 @@ export const installPrintableInputFallback = (editor: any, monaco: any) => { return true; }; + const getSelectionReplacementValue = ( + pending: NonNullable, + text: string, + ): string => ( + pending.valueBefore.slice(0, pending.startOffset) + + text + + pending.valueBefore.slice(pending.endOffset) + ); + + const hasSelectionInputValueApplied = ( + pending: NonNullable, + ): boolean => ( + String(editor.getValue?.() ?? '') === getSelectionReplacementValue(pending, pending.text) + ); + + const hasNativeSelectionInputApplied = ( + pending: NonNullable, + ): boolean => { + if (!hasSelectionInputValueApplied(pending)) { + return false; + } + const expectedPosition = editor.getModel?.()?.getPositionAt?.( + pending.startOffset + pending.text.length, + ); + return isSelectionEmpty(editor.getSelection?.()) + && sameEditorPosition(editor.getPosition?.(), expectedPosition); + }; + + const recoverPendingSelectionInput = ( + pending: NonNullable, + ): boolean => { + const afterValue = String(editor.getValue?.() ?? ''); + const expectedValue = getSelectionReplacementValue(pending, pending.text); + const model = editor.getModel?.(); + if (afterValue === expectedValue) { + const expectedPosition = model?.getPositionAt?.( + pending.startOffset + pending.text.length, + ); + if (expectedPosition) { + editor.setPosition?.(expectedPosition); + } + return true; + } + const valueAfterDeletion = getSelectionReplacementValue(pending, ''); + if ( + (afterValue !== pending.valueBefore && afterValue !== valueAfterDeletion) + || typeof editor.executeEdits !== 'function' + ) { + return false; + } + + const range = afterValue === pending.valueBefore + ? pending.rangeBefore + : (() => { + const startPosition = model?.getPositionAt?.(pending.startOffset); + if (!startPosition) { + return null; + } + return { + startLineNumber: startPosition.lineNumber, + startColumn: startPosition.column, + endLineNumber: startPosition.lineNumber, + endColumn: startPosition.column, + }; + })(); + if (!range) { + return false; + } + + editor.executeEdits('gonavi-printable-selection-fallback', [{ + range, + text: pending.text, + forceMoveMarkers: true, + }]); + const nextPosition = model?.getPositionAt?.(pending.startOffset + pending.text.length); + if (nextPosition) { + editor.setPosition?.(nextPosition); + } + return true; + }; + + const settlePendingSelectionInput = () => { + const pending = pendingSelectionInput; + if (!pending) { + return; + } + clearPendingSelectionInput(); + if (!hasNativeSelectionInputApplied(pending)) { + recoverPendingSelectionInput(pending); + } + }; + const isReadOnly = (): boolean => { try { const optionId = monaco?.editor?.EditorOption?.readOnly; @@ -429,8 +551,66 @@ export const installPrintableInputFallback = (editor: any, monaco: any) => { return; } - const selectionBefore = editor.getSelection?.(); + let selectionBefore = editor.getSelection?.(); + if (pendingSelectionInput) { + if ( + isSelectionEmpty(selectionBefore) + || sameEditorRange(selectionBefore, pendingSelectionInput.rangeBefore) + ) { + settlePendingSelectionInput(); + selectionBefore = editor.getSelection?.(); + } else { + clearPendingSelectionInput(); + } + } if (!isSelectionEmpty(selectionBefore)) { + if (pendingInput) { + clearPendingInput(); + } + + const model = editor.getModel?.(); + const startOffset = Number(model?.getOffsetAt?.({ + lineNumber: selectionBefore.startLineNumber, + column: selectionBefore.startColumn, + })); + const endOffset = Number(model?.getOffsetAt?.({ + lineNumber: selectionBefore.endLineNumber, + column: selectionBefore.endColumn, + })); + if (!Number.isFinite(startOffset) || !Number.isFinite(endOffset) || startOffset >= endOffset) { + return; + } + + const pending = { + valueBefore: String(editor.getValue?.() ?? ''), + rangeBefore: { + startLineNumber: selectionBefore.startLineNumber, + startColumn: selectionBefore.startColumn, + endLineNumber: selectionBefore.endLineNumber, + endColumn: selectionBefore.endColumn, + }, + startOffset, + endOffset, + text, + timer: null as number | null, + }; + pendingSelectionInput = pending; + pending.timer = window.setTimeout(() => { + if (pendingSelectionInput !== pending) { + return; + } + pendingSelectionInput = null; + const domNode = editor.getDomNode?.(); + if (!(domNode instanceof HTMLElement) || !domNode.isConnected || isReadOnly()) { + return; + } + if (document.activeElement && !domNode.contains(document.activeElement)) { + return; + } + if (!hasNativeSelectionInputApplied(pending)) { + recoverPendingSelectionInput(pending); + } + }, PRINTABLE_INPUT_FALLBACK_DELAY_MS); return; } let beforeValue = String(editor.getValue?.() ?? ''); @@ -504,9 +684,13 @@ export const installPrintableInputFallback = (editor: any, monaco: any) => { if (pendingInput && hasNativeInputApplied(pendingInput)) { clearPendingInput(); } + if (pendingSelectionInput && hasSelectionInputValueApplied(pendingSelectionInput)) { + clearPendingSelectionInput(); + } }); editor.onDidDispose?.(() => { clearPendingInput(); + clearPendingSelectionInput(); modelContentDisposable?.dispose?.(); input.removeEventListener('beforeinput', handleBeforeInput); });