mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 16:53:35 +08:00
🐛 fix(query-editor): 修复选区输入首字符丢失
- 为 Monaco 可打印输入兜底补充非空选区替换恢复 - 区分原生成功、仅删除选区和完全丢失,避免重复插入 - 覆盖连续输入、重选文本和模型事件光标竞态 - 新增 < 单次替换为 < 的回归测试
This commit is contained in:
@@ -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<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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user