diff --git a/.changeset/tab-accepts-completion-in-lists.md b/.changeset/tab-accepts-completion-in-lists.md new file mode 100644 index 00000000..aec71fd7 --- /dev/null +++ b/.changeset/tab-accepts-completion-in-lists.md @@ -0,0 +1,7 @@ +--- +"@input/pen-dom": patch +--- + +Tab accepts a visible inline completion inside a list item instead of nesting the item. The completion is checked before the default keymap, so `pen.indent` no longer wins the key. Shift-Tab still outdents while a completion is visible, and Tab still nests when none is. + +Breaking: no diff --git a/packages/rendering/dom/src/field-editor/keyHandling.ts b/packages/rendering/dom/src/field-editor/keyHandling.ts index 46ec4fb4..08565600 100644 --- a/packages/rendering/dom/src/field-editor/keyHandling.ts +++ b/packages/rendering/dom/src/field-editor/keyHandling.ts @@ -91,6 +91,20 @@ export function handleFieldEditorKeyDown(options: { ensureLineEdgeMeasure(editor, keyDocument); } + // K5: a visible ghost owns Tab, so the keymap's list indent must not win + // it. Shift-Tab goes to the keymap first so outdent works under a ghost. + if (isTabOrShiftTab(event) && !event.shiftKey) { + const accepted = acceptVisibleInlineCompletion( + event, + editor, + fieldEditor, + autocomplete, + ); + if (accepted !== null) { + return accepted; + } + } + if ( dispatchKeymapEvent(editor, event, { composing: event.isComposing === true, @@ -105,22 +119,14 @@ export function handleFieldEditorKeyDown(options: { return true; } - if ( - event.key === "Tab" && - !event.metaKey && - !event.ctrlKey && - !event.altKey - ) { - const inlineCompletion = getInlineCompletionController(editor); - if (inlineCompletion?.hasVisibleSuggestion()) { - event.preventDefault(); - if (autocomplete?.hasVisibleSuggestion()) { - return autocomplete.acceptVisibleSuggestion(); - } - const accepted = inlineCompletion.acceptSuggestion(); - if (accepted) { - syncAcceptedInlineCompletionSelection(editor, fieldEditor); - } + if (isTabOrShiftTab(event)) { + const accepted = acceptVisibleInlineCompletion( + event, + editor, + fieldEditor, + autocomplete, + ); + if (accepted !== null) { return accepted; } @@ -155,6 +161,37 @@ export function handleFieldEditorKeyDown(options: { return false; } +function isTabOrShiftTab(event: KeyboardEvent): boolean { + return ( + event.key === "Tab" && + !event.metaKey && + !event.ctrlKey && + !event.altKey + ); +} + +/** Returns null when no inline completion is visible, so the key falls through. */ +function acceptVisibleInlineCompletion( + event: KeyboardEvent, + editor: Editor, + fieldEditor: FieldEditorKeyboardController, + autocomplete: ReturnType, +): boolean | null { + const inlineCompletion = getInlineCompletionController(editor); + if (!inlineCompletion?.hasVisibleSuggestion()) { + return null; + } + event.preventDefault(); + if (autocomplete?.hasVisibleSuggestion()) { + return autocomplete.acceptVisibleSuggestion(); + } + const accepted = inlineCompletion.acceptSuggestion(); + if (accepted) { + syncAcceptedInlineCompletionSelection(editor, fieldEditor); + } + return accepted; +} + function handleTableCellKey( event: KeyboardEvent, editor: Editor, diff --git a/packages/rendering/react/src/__tests__/tabHandling.autocompleteCommit.test.ts b/packages/rendering/react/src/__tests__/tabHandling.autocompleteCommit.test.ts index d154fad6..2d542804 100644 --- a/packages/rendering/react/src/__tests__/tabHandling.autocompleteCommit.test.ts +++ b/packages/rendering/react/src/__tests__/tabHandling.autocompleteCommit.test.ts @@ -53,6 +53,118 @@ describe("@input/pen-react field editor Tab handling: autocomplete commit", () = editor.destroy(); }); + it("K5: accepts a visible inline completion instead of nesting a list item", () => { + const editor = createPresetEditor({ + preset: { + shortcuts: false, + }, + extensions: [aiExtension()], + }); + const firstBlockId = editor.firstBlock()!.id; + const secondBlockId = crypto.randomUUID(); + editor.apply([ + { + type: "set-props", + blockId: firstBlockId, + props: { type: "bulletListItem" }, + }, + { + type: "insert-block", + blockId: secondBlockId, + blockType: "bulletListItem", + props: { indent: 0 }, + position: { after: firstBlockId }, + }, + { + type: "splice-text", + blockId: secondBlockId, + from: 0, + to: 0, + insert: "Hello", + }, + ]); + editor.selectText(secondBlockId, 5, 5); + const fieldEditor = createFieldEditorMock(secondBlockId); + getInlineCompletionController(editor)?.showSuggestion({ + id: "suggestion-1", + blockId: secondBlockId, + offset: 5, + text: " world", + type: "inline", + }); + + const handled = handleFieldEditorKeyDown({ + event: createKeyEvent("Tab"), + editor, + fieldEditor: fieldEditor.controller, + ytext: getYText(editor, secondBlockId), + range: { start: 5, end: 5 }, + }); + + expect(handled).toBe(true); + expect(editor.getBlock(secondBlockId)?.textContent()).toBe( + "Hello world", + ); + expect(editor.getBlock(secondBlockId)?.props.indent).toBe(0); + + editor.destroy(); + }); + + it("K5: outdents a list item on Shift-Tab while an inline completion is visible", () => { + const editor = createPresetEditor({ + preset: { + shortcuts: false, + }, + extensions: [aiExtension()], + }); + const firstBlockId = editor.firstBlock()!.id; + const secondBlockId = crypto.randomUUID(); + editor.apply([ + { + type: "set-props", + blockId: firstBlockId, + props: { type: "bulletListItem" }, + }, + { + type: "insert-block", + blockId: secondBlockId, + blockType: "bulletListItem", + props: { indent: 1 }, + position: { after: firstBlockId }, + }, + { + type: "splice-text", + blockId: secondBlockId, + from: 0, + to: 0, + insert: "Hello", + }, + ]); + editor.selectText(secondBlockId, 5, 5); + const fieldEditor = createFieldEditorMock(secondBlockId); + getInlineCompletionController(editor)?.showSuggestion({ + id: "suggestion-1", + blockId: secondBlockId, + offset: 5, + text: " world", + type: "inline", + }); + + const handled = handleFieldEditorKeyDown({ + event: createKeyEvent("Tab", { shiftKey: true }), + editor, + fieldEditor: fieldEditor.controller, + ytext: getYText(editor, secondBlockId), + range: { start: 5, end: 5 }, + }); + + expect(handled).toBe(true); + expect(editor.getBlock(secondBlockId)?.textContent()).toBe("Hello"); + expect(editor.getBlock(secondBlockId)?.props.indent).toBe(0); + + editor.destroy(); + }); + it("dismisses visible autocomplete on typing without handling the key event", () => { let dismissReason: string | null = null; let activeEditor: ReturnType | null = null; diff --git a/scripts/rule-coverage-claimed.txt b/scripts/rule-coverage-claimed.txt index 9522455b..0686c0e2 100644 --- a/scripts/rule-coverage-claimed.txt +++ b/scripts/rule-coverage-claimed.txt @@ -304,3 +304,4 @@ rules/dom#FE2 rules/dom#RI6 rules/dom#OV4 rules/selection#P4 +rules/commands#K5 diff --git a/spec/rules/commands.md b/spec/rules/commands.md index bf5d9ee5..45b6d61b 100644 --- a/spec/rules/commands.md +++ b/spec/rules/commands.md @@ -19,6 +19,7 @@ Command names are frozen API. Async command handlers are not permitted: an async - K2. The default keymap in `packages/core/src/commands/defaultKeymap.ts` is normative, platform-conditional entries included: arrows with and without `Shift`; platform word and line modifiers (macOS `Alt-Arrow` for word and `Cmd-Arrow` for line and document, Windows and Linux `Ctrl-Arrow` for word and `Ctrl-Home`/`Ctrl-End` for document); `Home` and `End` bound on every platform, because Mac keyboards that have the keys send them and leaving them unbound moves nothing; `Backspace` and `Delete` with and without word modifiers, plus the macOS line deletes `Cmd-Backspace` and `Ctrl-k`, which mirror that platform's line motion and must be bound for the same reason `Home`/`End` are — a line delete the keymap does not claim is one the browser performs against the DOM alone; `Enter` and `Shift-Enter`; `Tab` and `Shift-Tab`; `Mod-a`; `Mod-b`, `Mod-i`, `Mod-u`; and the undo and redo bindings. - K3. Undo, redo, and select-all are plain bindings at `default` precedence, overridable like every other binding. There are no `isUndoShortcut`-style predicates and no history-override interception in the key path. - K4. During IME composition the keymap is bypassed except for `Escape` (see C1 in `spec/rules/selection.md`). +- K5. A visible autocomplete ghost (RS1 in `spec/rules/ai.md`) owns unmodified `Tab`: the key handler accepts the ghost before resolving the keymap, so `pen.indent` cannot claim the key and nest a list item under the hint. `Shift-Tab` is not claimed and reaches the keymap first, so outdent works under a ghost. With no ghost visible, `Tab` resolves through K1. A binding carries a key string, a command, an optional param or param factory, and a binding context (`text`, `cell`, `block`, or `any`, defaulting to `text`).