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
5 changes: 5 additions & 0 deletions .changeset/reliable-arrow-descent-into-compound-components.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkeep/open-knowledge": patch
---

Pressing an arrow key to move the cursor into a Callout or Accordion body now lands reliably. Previously the descent across the component boundary relied on the browser's native caret motion, which under load could intermittently fail to place the cursor inside the body (leaving the selection stuck outside). A deterministic handler now drives the descent for all four arrow directions (down, up, left, right).
61 changes: 59 additions & 2 deletions packages/app/src/editor/block-ux/keyboard-nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,59 @@ function tryExitCompoundJsxUp(editor: Editor): boolean {
}
}

function tryEnterCompoundJsx(editor: Editor, dir: ArrowDirection): boolean {
const { state, view } = editor;
if (!(state.selection instanceof TextSelection)) return false;
if (!state.selection.empty) return false;
if (!view.endOfTextblock(dir)) return false;

const $head = state.selection.$head;
const isForward = dir === 'down' || dir === 'right';

let adj: ReturnType<typeof state.doc.nodeAt> | null = null;
let adjPos = -1;
if (isForward) {
const afterPos = $head.after();
if (afterPos >= state.doc.content.size) return false;
adj = state.doc.nodeAt(afterPos);
adjPos = afterPos;
} else {
const beforePos = $head.before();
if (beforePos <= 0) return false;
const $beforePos = state.doc.resolve(beforePos);
adj = $beforePos.nodeBefore;
if (!adj) return false;
adjPos = beforePos - adj.nodeSize;
}

if (!adj) return false;
if (adj.type.name !== 'jsxComponent') return false;
if (adj.childCount === 0) return false;

const adjEnd = adjPos + adj.nodeSize;

try {
const fromPos = isForward ? adjPos + 1 : adjEnd - 1;
const found = Selection.findFrom(state.doc.resolve(fromPos), isForward ? 1 : -1, true);
if (!found || !(found instanceof TextSelection)) return false;
if (found.$head.pos <= adjPos || found.$head.pos >= adjEnd) return false;
editor.view.dispatch(state.tr.setSelection(found).scrollIntoView());
return true;
} catch (err) {
if (!(err instanceof RangeError)) throw err;
incrementJsxArrowNodeSelectFailed(dir);
console.warn(
JSON.stringify({
event: 'jsx-component-arrow-node-select-failed',
direction: dir,
tier: 'L2d',
reason: err.message.slice(0, 500),
}),
);
return true;
}
}

export const KeyboardNav = Extension.create({
name: 'keyboardNav',
priority: 50, // lower than Suggestion plugins so they intercept Escape first (L4)
Expand Down Expand Up @@ -124,6 +177,7 @@ export const KeyboardNav = Extension.create({
ArrowUp: ({ editor }) => {
if (tryL0NodeSelect(editor, 'up')) return true;
if (tryExitCompoundJsxUp(editor)) return true;
if (tryEnterCompoundJsx(editor, 'up')) return true;

const { state } = editor;
if (!(state.selection instanceof NodeSelection)) return false;
Expand Down Expand Up @@ -163,6 +217,7 @@ export const KeyboardNav = Extension.create({

ArrowDown: ({ editor }) => {
if (tryL0NodeSelect(editor, 'down')) return true;
if (tryEnterCompoundJsx(editor, 'down')) return true;

const { state } = editor;
if (!(state.selection instanceof NodeSelection)) return false;
Expand Down Expand Up @@ -194,9 +249,11 @@ export const KeyboardNav = Extension.create({
}
},

ArrowLeft: ({ editor }) => tryL0NodeSelect(editor, 'left'),
ArrowLeft: ({ editor }) =>
tryL0NodeSelect(editor, 'left') || tryEnterCompoundJsx(editor, 'left'),

ArrowRight: ({ editor }) => tryL0NodeSelect(editor, 'right'),
ArrowRight: ({ editor }) =>
tryL0NodeSelect(editor, 'right') || tryEnterCompoundJsx(editor, 'right'),

Enter: ({ editor }) => {
const { state } = editor;
Expand Down
17 changes: 14 additions & 3 deletions packages/app/tests/integration/keyboard-nav-catch-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ describe('KeyboardNav catch-path structural contract (precedent #48)', () => {
});

test('L2 ArrowUp catch narrows RangeError + emits counter + structured warn with tier:L2', () => {
const body = extractCatchBody(source, '// L0 + L2: Arrow Up');
const body = extractCatchBody(source, '// L0 + L2c + L2d + L2: Arrow Up');

expect(body).toContain('err instanceof RangeError');
expect(body).toContain("incrementJsxArrowNodeSelectFailed('up')");
Expand All @@ -54,7 +54,7 @@ describe('KeyboardNav catch-path structural contract (precedent #48)', () => {
});

test('L2 ArrowDown catch narrows RangeError + emits counter + structured warn with tier:L2', () => {
const body = extractCatchBody(source, '// L0 + L2: Arrow Down');
const body = extractCatchBody(source, '// L0 + L2d + L2: Arrow Down');

expect(body).toContain('err instanceof RangeError');
expect(body).toContain("incrementJsxArrowNodeSelectFailed('down')");
Expand All @@ -75,10 +75,21 @@ describe('KeyboardNav catch-path structural contract (precedent #48)', () => {
expect(body).toContain('reason:');
});

test('L2d tryEnterCompoundJsx catch narrows RangeError + emits counter + structured warn with tier:L2d', () => {
const body = extractCatchBody(source, 'function tryEnterCompoundJsx');

expect(body).toContain('err instanceof RangeError');
expect(body).toContain('incrementJsxArrowNodeSelectFailed(dir)');
expect(body).toContain("'jsx-component-arrow-node-select-failed'");
expect(body).toContain('direction: dir,');
expect(body).toContain("tier: 'L2d',");
expect(body).toContain('reason:');
});

test('every catch in keyboard-nav.ts narrows to RangeError (no bare catch widening)', () => {
const catchPattern = /catch\s*(?:\(\s*\w+\s*\)\s*)?\{/g;
const matches = [...source.matchAll(catchPattern)];
expect(matches.length).toBeGreaterThanOrEqual(4); // L0 + L2c + L2 up + L2 down
expect(matches.length).toBeGreaterThanOrEqual(5); // L0 + L2 up + L2 down + L2c + L2d

for (const m of matches) {
const window = source.slice(m.index ?? 0, (m.index ?? 0) + 1000);
Expand Down
100 changes: 100 additions & 0 deletions packages/app/tests/stress/selection-indicator.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,46 @@ async function selectFirstJsxComponent(page: Page, componentName: string) {
}, componentName);
}

/** True when ProseMirror's selection head sits inside a jsxComponent — i.e. the
* caret descended into a compound block's body. Mirrors the inline walk S1c
* uses; shared by the L2d descent-parity tests (S1c-R/L/U/ACC). */
async function caretInsideCompound(page: Page): Promise<boolean> {
return await page.evaluate(() => {
const editor = window.__activeEditor;
if (!editor) return false;
const sel = editor.state.selection;
if (!('$head' in sel)) return false;
const $head = sel.$head as { depth: number; node: (d: number) => { type: { name: string } } };
for (let d = $head.depth; d >= 0; d--) {
if ($head.node(d).type.name === 'jsxComponent') return true;
}
return false;
});
}

/** Place the caret at the start or end of the first textblock whose text equals
* `text`, via the editor API + DOM-focus commit (the S1f/S1g pattern — click +
* Home/End was flaky on loaded CI workers). */
async function caretAtTextblock(page: Page, text: string, edge: 'start' | 'end'): Promise<void> {
await page.evaluate(
({ text, edge }) => {
const editor = window.__activeEditor;
if (!editor) return;
let pos = -1;
editor.state.doc.descendants((node, p) => {
if (node.type.name === 'heading' && node.textContent === text) {
pos = edge === 'start' ? p + 1 : p + 1 + node.textContent.length;
}
return true;
});
if (pos >= 0) editor.chain().focus().setTextSelection(pos).run();
},
{ text, edge },
);
await focusEditor(page);
await waitForPmSelectionInNode(page, 'heading');
}

test('S1: ArrowDown auto-NodeSelects self-closing Callout below the cursor', async ({
page,
api,
Expand Down Expand Up @@ -223,6 +263,66 @@ test('S1g: ArrowLeft auto-NodeSelects self-closing Callout to the left of the cu
await expect(callout).toHaveAttribute('data-selection-origin', 'keyboard');
});

test('S1c-R: ArrowRight descends into compound Callout body (L2d horizontal)', async ({
page,
api,
}) => {
await setupDoc(page, api, '# Title\n\n<Callout type="note">\n\nbody content\n\n</Callout>\n');
await page.waitForSelector('.jsx-component-wrapper[data-component-type="callout"]');
await caretAtTextblock(page, 'Title', 'end');

await page.keyboard.press('ArrowRight');

await expect(page.locator('.jsx-component-wrapper[data-selected="true"]')).toHaveCount(0);
await waitForPmSelectionInNode(page, 'jsxComponent');
expect(await caretInsideCompound(page)).toBe(true);
});

test('S1c-L: ArrowLeft descends into compound Callout body (L2d horizontal)', async ({
page,
api,
}) => {
await setupDoc(page, api, '<Callout type="note">\n\nbody content\n\n</Callout>\n\n# Footer\n');
await page.waitForSelector('.jsx-component-wrapper[data-component-type="callout"]');
await caretAtTextblock(page, 'Footer', 'start');

await page.keyboard.press('ArrowLeft');

await expect(page.locator('.jsx-component-wrapper[data-selected="true"]')).toHaveCount(0);
await waitForPmSelectionInNode(page, 'jsxComponent');
expect(await caretInsideCompound(page)).toBe(true);
});

test('S1c-U: ArrowUp descends into compound Callout body from below (L2d vertical)', async ({
page,
api,
}) => {
await setupDoc(page, api, '<Callout type="note">\n\nbody content\n\n</Callout>\n\n# Footer\n');
await page.waitForSelector('.jsx-component-wrapper[data-component-type="callout"]');
await caretAtTextblock(page, 'Footer', 'start');

await page.keyboard.press('ArrowUp');

await expect(page.locator('.jsx-component-wrapper[data-selected="true"]')).toHaveCount(0);
await waitForPmSelectionInNode(page, 'jsxComponent');
expect(await caretInsideCompound(page)).toBe(true);
});

test('S1c-ACC: ArrowDown descends into compound Accordion body (L2d type parity)', async ({
page,
api,
}) => {
await setupDoc(page, api, '# Title\n\n<Accordion title="X">\n\nbody content\n\n</Accordion>\n');
await page.waitForSelector('.jsx-component-wrapper[data-component-type="accordion"]');
await caretAtTextblock(page, 'Title', 'end');

await page.keyboard.press('ArrowDown');

await expect(page.locator('.jsx-component-wrapper[data-selected="true"]')).toHaveCount(0);
await waitForPmSelectionInNode(page, 'jsxComponent');
expect(await caretInsideCompound(page)).toBe(true);
});

test('S2: NodeSelection on a Callout emits data-selected=true on its wrapper', async ({
page,
api,
Expand Down