diff --git a/src/components/InlineWikilinkParts.tsx b/src/components/InlineWikilinkParts.tsx index d26a1802..7fe2a40e 100644 --- a/src/components/InlineWikilinkParts.tsx +++ b/src/components/InlineWikilinkParts.tsx @@ -176,6 +176,8 @@ export function InlineWikilinkEditorField({ segments: InlineWikilinkSegment[] typeEntryMap: Record }) { + const needsTrailingCaretAnchor = segments[segments.length - 1]?.kind === 'chip' + return (
{value.length === 0 && placeholder && ( @@ -218,6 +220,7 @@ export function InlineWikilinkEditorField({ /> ) ))} + {needsTrailingCaretAnchor ? '\u200B' : null}
) diff --git a/src/components/inlineWikilinkDom.ts b/src/components/inlineWikilinkDom.ts index 08f4115f..f32c7773 100644 --- a/src/components/inlineWikilinkDom.ts +++ b/src/components/inlineWikilinkDom.ts @@ -68,7 +68,30 @@ export function readSelectionIndex(root: HTMLDivElement): number { } function boundaryAtEditorEnd(root: HTMLDivElement): SelectionBoundary { - return { container: root, offset: root.childNodes.length } + const endBoundary = findTextBoundaryAtEdge(root, 'end') + return endBoundary ?? { container: root, offset: root.childNodes.length } +} + +function findTextBoundaryAtEdge( + node: Node, + edge: 'start' | 'end', +): SelectionBoundary | null { + if (node.nodeType === Node.TEXT_NODE) { + return { + container: node, + offset: edge === 'start' ? 0 : node.textContent?.length ?? 0, + } + } + + const children = Array.from(node.childNodes) + const orderedChildren = edge === 'start' ? children : children.toReversed() + + for (const child of orderedChildren) { + const boundary = findTextBoundaryAtEdge(child, edge) + if (boundary) return boundary + } + + return null } function boundaryForTextNode( @@ -97,15 +120,21 @@ function boundaryForChip( ): { boundary: SelectionBoundary | null; remaining: number } { const tokenLength = chipToken(child.dataset.chipTarget ?? '').length if (remaining <= 0) { + const previousSibling = node.childNodes.item(index - 1) return { - boundary: { container: node, offset: index }, + boundary: previousSibling + ? findTextBoundaryAtEdge(previousSibling, 'end') ?? { container: node, offset: index } + : { container: node, offset: index }, remaining: 0, } } if (remaining <= tokenLength) { + const nextSibling = node.childNodes.item(index + 1) return { - boundary: { container: node, offset: index + 1 }, + boundary: nextSibling + ? findTextBoundaryAtEdge(nextSibling, 'start') ?? { container: node, offset: index + 1 } + : { container: node, offset: index + 1 }, remaining: 0, } } diff --git a/tests/smoke/command-palette-ai-mode-regression.spec.ts b/tests/smoke/command-palette-ai-mode-regression.spec.ts index c5c60711..b30e2999 100644 --- a/tests/smoke/command-palette-ai-mode-regression.spec.ts +++ b/tests/smoke/command-palette-ai-mode-regression.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '@playwright/test' +import { test, expect, type Page } from '@playwright/test' import { openCommandPalette } from './helpers' import { expectNoPageErrors, @@ -7,6 +7,32 @@ import { trackPageErrors, } from './inlineWikilinkEditorHelpers' +async function readCaretGapAfterChip(page: Page) { + return page.evaluate(() => { + const editor = document.querySelector('[data-testid="command-palette-ai-input"]') + if (!(editor instanceof HTMLElement)) return null + + const chip = editor.querySelector('[data-testid="inline-wikilink-chip"]') + if (!(chip instanceof HTMLElement)) return null + + const selection = window.getSelection() + if (selection === null) return null + if (selection.rangeCount === 0) return null + + const range = selection.getRangeAt(0).cloneRange() + range.collapse(true) + + const caretRect = range.getBoundingClientRect() + if (caretRect.height === 0) return null + + return caretRect.left - chip.getBoundingClientRect().right + }) +} + +async function expectCaretAfterChip(page: Page) { + await expect.poll(() => readCaretGapAfterChip(page)).toBeLessThan(24) +} + test.describe('Command palette AI mode regression', () => { test.beforeEach(async ({ page }) => { await page.route('**/api/vault/ping', route => route.fulfill({ status: 503 })) @@ -35,6 +61,7 @@ test.describe('Command palette AI mode regression', () => { await page.getByTestId('wikilink-menu').getByText('Build Laputa App').click() await expect(aiInput.getByTestId('inline-wikilink-chip')).toContainText('Build Laputa App') + await expectCaretAfterChip(page) await page.keyboard.type(' essay') await expectNormalizedEditorText(aiInput, 'edit my Build Laputa App essay')