fix: anchor ai mode caret after wikilinks

This commit is contained in:
lucaronin
2026-04-14 11:35:38 +02:00
parent 60da17e13b
commit 70252b6324
3 changed files with 63 additions and 4 deletions

View File

@@ -176,6 +176,8 @@ export function InlineWikilinkEditorField({
segments: InlineWikilinkSegment[]
typeEntryMap: Record<string, VaultEntry>
}) {
const needsTrailingCaretAnchor = segments[segments.length - 1]?.kind === 'chip'
return (
<div className="relative">
{value.length === 0 && placeholder && (
@@ -218,6 +220,7 @@ export function InlineWikilinkEditorField({
/>
)
))}
{needsTrailingCaretAnchor ? '\u200B' : null}
</div>
</div>
)

View File

@@ -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,
}
}

View File

@@ -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')