Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/tab-accepts-completion-in-lists.md
Original file line number Diff line number Diff line change
@@ -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
69 changes: 53 additions & 16 deletions packages/rendering/dom/src/field-editor/keyHandling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
}

Expand Down Expand Up @@ -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<typeof getAutocompleteController>,
): 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createEditor> | null = null;
Expand Down
1 change: 1 addition & 0 deletions scripts/rule-coverage-claimed.txt
Original file line number Diff line number Diff line change
Expand Up @@ -304,3 +304,4 @@ rules/dom#FE2
rules/dom#RI6
rules/dom#OV4
rules/selection#P4
rules/commands#K5
1 change: 1 addition & 0 deletions spec/rules/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).

Expand Down
Loading