🐛 fix(query-editor): 修复选区输入首字符丢失

- 为 Monaco 可打印输入兜底补充非空选区替换恢复
- 区分原生成功、仅删除选区和完全丢失,避免重复插入
- 覆盖连续输入、重选文本和模型事件光标竞态
- 新增 &lt; 单次替换为 < 的回归测试
This commit is contained in:
Syngnat
2026-07-17 17:39:02 +08:00
parent ca8be18531
commit 8f52cbc319
2 changed files with 357 additions and 4 deletions

View File

@@ -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 &lt;= 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 &lt;= 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 &lt;= 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<d';
setSingleLineSelection(2, 3);
input.dispatchPrintableBeforeInput('<');
vi.advanceTimersByTime(80);
expect(value).toBe('a<d');
expect(editor.getSelection()).toEqual({
startLineNumber: 1,
startColumn: 3,
endLineNumber: 1,
endColumn: 3,
});
});
it('does not restore an old caret after a native replacement model event', () => {
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 });
});
});

View File

@@ -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<typeof pendingInput>) => {
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<typeof pendingSelectionInput>,
text: string,
): string => (
pending.valueBefore.slice(0, pending.startOffset)
+ text
+ pending.valueBefore.slice(pending.endOffset)
);
const hasSelectionInputValueApplied = (
pending: NonNullable<typeof pendingSelectionInput>,
): boolean => (
String(editor.getValue?.() ?? '') === getSelectionReplacementValue(pending, pending.text)
);
const hasNativeSelectionInputApplied = (
pending: NonNullable<typeof pendingSelectionInput>,
): 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<typeof pendingSelectionInput>,
): 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);
});