From 9f6e6ed1bc66ba8c26a1ade4e0fbf2b61ec5b051 Mon Sep 17 00:00:00 2001 From: janglad Date: Tue, 6 Oct 2026 18:33:28 +0200 Subject: [PATCH] Implement Tab key handling for inline completions in list items This update modifies the key handling in the field editor to allow the Tab key to accept visible inline completions within list items, preventing the default behavior of nesting items. Shift-Tab continues to outdent while a completion is visible. New tests have been added to verify this behavior, ensuring that inline completions are correctly accepted and that list item indentation works as expected. Breaking changes: None. --- .changeset/tab-accepts-completion-in-lists.md | 7 ++ .../dom/src/field-editor/keyHandling.ts | 69 ++++++++--- .../tabHandling.autocompleteCommit.test.ts | 112 ++++++++++++++++++ scripts/rule-coverage-claimed.txt | 1 + spec/rules/commands.md | 1 + 5 files changed, 174 insertions(+), 16 deletions(-) create mode 100644 .changeset/tab-accepts-completion-in-lists.md diff --git a/.changeset/tab-accepts-completion-in-lists.md b/.changeset/tab-accepts-completion-in-lists.md new file mode 100644 index 000000000..aec71fd7f --- /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 46ec4fb49..085656003 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 d154fad65..2d5428048 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 9522455b7..0686c0e26 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 bf5d9ee5e..45b6d61b4 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`).