fix: stabilize command palette ai mode editing

This commit is contained in:
lucaronin
2026-04-13 13:26:38 +02:00
parent 9623203756
commit 91ab5be914
10 changed files with 523 additions and 163 deletions

View File

@@ -314,6 +314,7 @@ function OpenCommandPalette({
entries={entries}
value={aiValue}
claudeCodeReady={claudeCodeReady}
inputRef={aiInputRef}
onChange={handleAiValueChange}
onSubmit={handleSubmitAiPrompt}
/>

View File

@@ -7,6 +7,7 @@ interface CommandPaletteAiModeProps {
entries: VaultEntry[]
value: string
claudeCodeReady: boolean
inputRef?: React.RefObject<HTMLDivElement | null>
onChange: (value: string) => void
onSubmit: (text: string, references: NoteReference[]) => void
}
@@ -19,6 +20,7 @@ export function CommandPaletteAiMode({
entries,
value,
claudeCodeReady,
inputRef,
onChange,
onSubmit,
}: CommandPaletteAiModeProps) {
@@ -26,6 +28,7 @@ export function CommandPaletteAiMode({
<InlineWikilinkInput
entries={entries}
value={value}
inputRef={inputRef}
onChange={onChange}
onSubmit={(text, references) => onSubmit(stripLeadingSpace(text), references)}
submitOnEmpty={true}

View File

@@ -5,11 +5,14 @@ import {
import type { VaultEntry } from '../types'
import type { NoteReference } from '../utils/ai-context'
import { buildTypeEntryMap } from '../utils/typeColors'
import {
deleteInlineSelection,
replaceInlineSelection,
} from './inlineWikilinkEdits'
import {
buildInlineWikilinkSegments,
extractInlineWikilinkReferences,
findActiveWikilinkQuery,
findInlineChipDeletionRange,
} from './inlineWikilinkText'
import {
InlineWikilinkEditorField,
@@ -39,27 +42,11 @@ interface InlineWikilinkInputProps {
paletteFooter?: ReactNode
}
function deleteInlineChip({
direction,
segments,
selectionIndex,
value,
onChange,
onSelectionIndexChange,
}: {
direction: 'backward' | 'forward'
segments: ReturnType<typeof buildInlineWikilinkSegments>
selectionIndex: number
value: string
onChange: (value: string) => void
onSelectionIndexChange: (selectionIndex: number) => void
}) {
const deletionRange = findInlineChipDeletionRange(segments, selectionIndex, direction)
if (!deletionRange) return false
onChange(value.slice(0, deletionRange.start) + value.slice(deletionRange.end))
onSelectionIndexChange(deletionRange.start)
return true
function collapseSelectionRange(nextSelectionIndex: number) {
return {
start: nextSelectionIndex,
end: nextSelectionIndex,
}
}
function submitInlineValue({
@@ -79,6 +66,80 @@ function submitInlineValue({
onSubmit(normalizedValue, references)
}
function renderInlineEditorField({
value,
placeholder,
disabled,
inputRef,
dataTestId,
editorClassName,
onInput,
onKeyDown,
onSelectionChange,
segments,
typeEntryMap,
}: {
value: string
placeholder?: string
disabled: boolean
inputRef: React.Ref<HTMLDivElement>
dataTestId: string
editorClassName?: string
onInput: () => void
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
onSelectionChange: () => void
segments: ReturnType<typeof buildInlineWikilinkSegments>
typeEntryMap: Record<string, VaultEntry>
}) {
return (
<InlineWikilinkEditorField
value={value}
placeholder={placeholder}
disabled={disabled}
inputRef={inputRef}
dataTestId={dataTestId}
editorClassName={editorClassName}
onInput={onInput}
onKeyDown={onKeyDown}
onSelectionChange={onSelectionChange}
segments={segments}
typeEntryMap={typeEntryMap}
/>
)
}
function renderInlineSuggestionList({
suggestions,
selectedSuggestionIndex,
setSuggestionIndex,
selectSuggestion,
typeEntryMap,
suggestionListVariant,
suggestionEmptyLabel,
}: {
suggestions: ReturnType<typeof useInlineWikilinkSuggestionsState>['suggestions']
selectedSuggestionIndex: number
setSuggestionIndex: (index: number) => void
selectSuggestion: (index: number) => void
typeEntryMap: Record<string, VaultEntry>
suggestionListVariant: 'floating' | 'palette'
suggestionEmptyLabel: string
}) {
if (suggestions.length === 0) return null
return (
<InlineWikilinkSuggestionList
suggestions={suggestions}
selectedIndex={selectedSuggestionIndex}
onHover={setSuggestionIndex}
onSelect={selectSuggestion}
typeEntryMap={typeEntryMap}
variant={suggestionListVariant}
emptyLabel={suggestionEmptyLabel}
/>
)
}
export function InlineWikilinkInput({
entries,
value,
@@ -102,20 +163,23 @@ export function InlineWikilinkInput({
)
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const {
selectionRange,
selectionIndex,
setSelectionIndex,
setSelectionRange,
setCombinedRef,
syncSelectionIndex,
syncSelectionRange,
commitValueFromEditor,
focusSelectionAt,
focusSelectionRange,
} = useInlineWikilinkSelection({
value,
onChange,
inputRef,
})
const activeQuery = useMemo(
() => findActiveWikilinkQuery(value, selectionIndex),
[selectionIndex, value],
() => selectionRange.start === selectionRange.end
? findActiveWikilinkQuery(value, selectionIndex)
: null,
[selectionIndex, selectionRange.end, selectionRange.start, value],
)
const references = useMemo(() => extractInlineWikilinkReferences(value, entries), [entries, value])
const {
@@ -131,18 +195,19 @@ export function InlineWikilinkInput({
value,
selectionIndex,
onChange,
onSelectionIndexChange: setSelectionIndex,
focusSelectionAt,
onSelectionIndexChange: (nextSelectionIndex) => setSelectionRange(collapseSelectionRange(nextSelectionIndex)),
focusSelectionAt: (nextSelectionIndex) => focusSelectionRange(collapseSelectionRange(nextSelectionIndex)),
})
const deleteAdjacentChip = (direction: 'backward' | 'forward') => {
return deleteInlineChip({
direction,
segments,
selectionIndex,
value,
onChange,
onSelectionIndexChange: setSelectionIndex,
})
const insertText = (text: string) => {
const nextState = replaceInlineSelection(value, selectionRange, text)
onChange(nextState.value)
setSelectionRange(nextState.selection)
}
const deleteContent = (direction: 'backward' | 'forward') => {
const nextState = deleteInlineSelection(value, selectionRange, segments, direction)
if (!nextState) return
onChange(nextState.value)
setSelectionRange(nextState.selection)
}
const submitValue = () =>
submitInlineValue({ onSubmit, submitOnEmpty, value, references })
@@ -153,36 +218,33 @@ export function InlineWikilinkInput({
suggestionsOpen: suggestions.length > 0,
onCycleSuggestions: cycleSuggestions,
onSelectSuggestion: () => selectSuggestion(selectedSuggestionIndex),
onDeleteAdjacentChip: deleteAdjacentChip,
onDeleteContent: deleteContent,
onInsertText: insertText,
canSubmit: onSubmit !== undefined,
onSubmit: submitValue,
})
const editor = (
<InlineWikilinkEditorField
value={value}
placeholder={placeholder}
disabled={disabled}
inputRef={setCombinedRef}
dataTestId={dataTestId}
editorClassName={editorClassName}
onInput={commitValueFromEditor}
onKeyDown={handleKeyDown}
onSelectionChange={syncSelectionIndex}
segments={segments}
typeEntryMap={typeEntryMap}
/>
)
const suggestionList = suggestions.length > 0 ? (
<InlineWikilinkSuggestionList
suggestions={suggestions}
selectedIndex={selectedSuggestionIndex}
onHover={setSuggestionIndex}
onSelect={selectSuggestion}
typeEntryMap={typeEntryMap}
variant={suggestionListVariant}
emptyLabel={suggestionEmptyLabel}
/>
) : null
const editor = renderInlineEditorField({
value,
placeholder,
disabled,
inputRef: setCombinedRef,
dataTestId,
editorClassName,
onInput: commitValueFromEditor,
onKeyDown: handleKeyDown,
onSelectionChange: syncSelectionRange,
segments,
typeEntryMap,
})
const suggestionList = renderInlineSuggestionList({
suggestions,
selectedSuggestionIndex,
setSuggestionIndex,
selectSuggestion,
typeEntryMap,
suggestionListVariant,
suggestionEmptyLabel,
})
if (suggestionListVariant === 'palette') {
return (
<InlineWikilinkPaletteLayout

View File

@@ -3,6 +3,16 @@ import {
normalizeInlineWikilinkValue,
} from './inlineWikilinkTokens'
export interface InlineSelectionRange {
start: number
end: number
}
interface SelectionBoundary {
container: Node
offset: number
}
export function serializeInlineNode(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) {
return normalizeInlineWikilinkValue(node.textContent ?? '')
@@ -23,112 +33,147 @@ function selectionFallsOutsideEditor(
selection: Selection | null,
root: HTMLDivElement,
): boolean {
return (
!selection ||
selection.rangeCount === 0 ||
!selection.anchorNode ||
!root.contains(selection.anchorNode)
)
if (!selection || selection.rangeCount === 0) return true
const range = selection.getRangeAt(0)
return !root.contains(range.startContainer) || !root.contains(range.endContainer)
}
export function readSelectionIndex(root: HTMLDivElement): number {
const currentSelection = window.getSelection()
if (
!currentSelection ||
selectionFallsOutsideEditor(currentSelection, root)
) {
return serializeInlineNode(root).length
}
const range = currentSelection.getRangeAt(0).cloneRange()
function serializedSelectionBoundary(
root: HTMLDivElement,
container: Node,
offset: number,
): number {
const range = document.createRange()
range.setStart(root, 0)
range.setEnd(container, offset)
return serializeInlineNode(range.cloneContents()).length
}
export function applySelectionIndex(
root: HTMLDivElement,
selectionIndex: number,
) {
const selection = window.getSelection()
if (!selection) return
const range = document.createRange()
const clampedIndex = Math.max(0, selectionIndex)
const found = placeRangeAtIndex(root, clampedIndex, range)
if (!found) {
range.selectNodeContents(root)
range.collapse(false)
export function readSelectionRange(root: HTMLDivElement): InlineSelectionRange {
const currentSelection = window.getSelection()
if (!currentSelection || selectionFallsOutsideEditor(currentSelection, root)) {
const index = serializeInlineNode(root).length
return { start: index, end: index }
}
selection.removeAllRanges()
selection.addRange(range)
const range = currentSelection.getRangeAt(0)
return {
start: serializedSelectionBoundary(root, range.startContainer, range.startOffset),
end: serializedSelectionBoundary(root, range.endContainer, range.endOffset),
}
}
function placeRangeAtIndex(
export function readSelectionIndex(root: HTMLDivElement): number {
return readSelectionRange(root).end
}
function boundaryAtEditorEnd(root: HTMLDivElement): SelectionBoundary {
return { container: root, offset: root.childNodes.length }
}
function boundaryForTextNode(
child: Node,
remaining: number,
): { boundary: SelectionBoundary | null; remaining: number } {
const textLength = normalizeInlineWikilinkValue(child.textContent ?? '').length
if (remaining <= textLength) {
return {
boundary: { container: child, offset: remaining },
remaining: 0,
}
}
return {
boundary: null,
remaining: remaining - textLength,
}
}
function boundaryForChip(
node: Node,
child: HTMLElement,
index: number,
remaining: number,
): { boundary: SelectionBoundary | null; remaining: number } {
const tokenLength = chipToken(child.dataset.chipTarget).length
if (remaining <= 0) {
return {
boundary: { container: node, offset: index },
remaining: 0,
}
}
if (remaining <= tokenLength) {
return {
boundary: { container: node, offset: index + 1 },
remaining: 0,
}
}
return {
boundary: null,
remaining: remaining - tokenLength,
}
}
function findSelectionBoundary(
node: Node,
selectionIndex: number,
range: Range,
): boolean {
let remaining = selectionIndex
): SelectionBoundary | null {
let remaining = Math.max(0, selectionIndex)
const children = Array.from(node.childNodes)
for (const child of Array.from(node.childNodes)) {
for (const [index, child] of children.entries()) {
if (child.nodeType === Node.TEXT_NODE) {
const textPlacement = placeRangeInTextNode(child, remaining, range)
if (textPlacement.placed) return true
remaining = textPlacement.remaining
const textBoundary = boundaryForTextNode(child, remaining)
if (textBoundary.boundary) return textBoundary.boundary
remaining = textBoundary.remaining
continue
}
if (!(child instanceof HTMLElement)) continue
if (child.dataset.chipTarget) {
const chipPlacement = placeRangeAroundChip(child, remaining, range)
if (chipPlacement.placed) return true
remaining = chipPlacement.remaining
const chipBoundary = boundaryForChip(node, child, index, remaining)
if (chipBoundary.boundary) return chipBoundary.boundary
remaining = chipBoundary.remaining
continue
}
if (placeRangeAtIndex(child, remaining, range)) return true
remaining -= serializeInlineNode(child).length
const childLength = serializeInlineNode(child).length
if (remaining <= childLength) {
return findSelectionBoundary(child, remaining)
}
remaining -= childLength
}
return false
return null
}
function placeRangeInTextNode(
node: Node,
remaining: number,
range: Range,
export function applySelectionRange(
root: HTMLDivElement,
selectionRange: InlineSelectionRange,
) {
const textLength = normalizeInlineWikilinkValue(node.textContent ?? '').length
if (remaining <= textLength) {
range.setStart(node, remaining)
range.collapse(true)
return { placed: true, remaining: 0 }
}
const selection = window.getSelection()
if (!selection) return
return { placed: false, remaining: remaining - textLength }
const range = document.createRange()
const serializedLength = serializeInlineNode(root).length
const start = Math.max(0, Math.min(selectionRange.start, selectionRange.end, serializedLength))
const end = Math.max(start, Math.min(Math.max(selectionRange.start, selectionRange.end), serializedLength))
const startBoundary = findSelectionBoundary(root, start) ?? boundaryAtEditorEnd(root)
const endBoundary = findSelectionBoundary(root, end) ?? boundaryAtEditorEnd(root)
range.setStart(startBoundary.container, startBoundary.offset)
range.setEnd(endBoundary.container, endBoundary.offset)
selection.removeAllRanges()
selection.addRange(range)
}
function placeRangeAroundChip(
node: HTMLElement,
remaining: number,
range: Range,
export function applySelectionIndex(
root: HTMLDivElement,
selectionIndex: number,
) {
const tokenLength = chipToken(node.dataset.chipTarget ?? '').length
if (remaining <= 0) {
range.setStartBefore(node)
range.collapse(true)
return { placed: true, remaining: 0 }
}
if (remaining <= tokenLength) {
range.setStartAfter(node)
range.collapse(true)
return { placed: true, remaining: 0 }
}
return { placed: false, remaining: remaining - tokenLength }
applySelectionRange(root, { start: selectionIndex, end: selectionIndex })
}

View File

@@ -0,0 +1,76 @@
import type { InlineSelectionRange } from './inlineWikilinkDom'
import { findInlineChipDeletionRange, type InlineWikilinkSegment } from './inlineWikilinkText'
function normalizeSelectionRange(
selection: InlineSelectionRange,
valueLength: number,
): InlineSelectionRange {
const start = Math.max(0, Math.min(selection.start, selection.end, valueLength))
const end = Math.max(start, Math.min(Math.max(selection.start, selection.end), valueLength))
return { start, end }
}
function collapseSelection(index: number): InlineSelectionRange {
return { start: index, end: index }
}
export function replaceInlineSelection(
value: string,
selection: InlineSelectionRange,
text: string,
): { value: string; selection: InlineSelectionRange } {
const normalizedSelection = normalizeSelectionRange(selection, value.length)
const nextValue = (
value.slice(0, normalizedSelection.start) +
text +
value.slice(normalizedSelection.end)
)
const nextIndex = normalizedSelection.start + text.length
return {
value: nextValue,
selection: collapseSelection(nextIndex),
}
}
export function deleteInlineSelection(
value: string,
selection: InlineSelectionRange,
segments: InlineWikilinkSegment[],
direction: 'backward' | 'forward',
): { value: string; selection: InlineSelectionRange } | null {
const normalizedSelection = normalizeSelectionRange(selection, value.length)
if (normalizedSelection.start !== normalizedSelection.end) {
return {
value: value.slice(0, normalizedSelection.start) + value.slice(normalizedSelection.end),
selection: collapseSelection(normalizedSelection.start),
}
}
const deletionRange = findInlineChipDeletionRange(
segments,
normalizedSelection.start,
direction,
)
if (deletionRange) {
return {
value: value.slice(0, deletionRange.start) + value.slice(deletionRange.end),
selection: collapseSelection(deletionRange.start),
}
}
if (direction === 'backward') {
if (normalizedSelection.start === 0) return null
const nextIndex = normalizedSelection.start - 1
return {
value: value.slice(0, nextIndex) + value.slice(normalizedSelection.start),
selection: collapseSelection(nextIndex),
}
}
if (normalizedSelection.start >= value.length) return null
return {
value: value.slice(0, normalizedSelection.start) + value.slice(normalizedSelection.start + 1),
selection: collapseSelection(normalizedSelection.start),
}
}

View File

@@ -38,26 +38,46 @@ function handleSuggestionKeys({
interface HandleDeleteKeysArgs {
event: React.KeyboardEvent<HTMLDivElement>
onDeleteAdjacentChip: (direction: 'backward' | 'forward') => boolean
onDeleteContent: (direction: 'backward' | 'forward') => void
}
function handleDeleteKeys({
event,
onDeleteAdjacentChip,
onDeleteContent,
}: HandleDeleteKeysArgs): boolean {
if (event.key === 'Backspace' && onDeleteAdjacentChip('backward')) {
if (event.key === 'Backspace') {
event.preventDefault()
onDeleteContent('backward')
return true
}
if (event.key === 'Delete' && onDeleteAdjacentChip('forward')) {
if (event.key === 'Delete') {
event.preventDefault()
onDeleteContent('forward')
return true
}
return false
}
interface HandleInsertTextArgs {
event: React.KeyboardEvent<HTMLDivElement>
onInsertText: (text: string) => void
}
function handleInsertText({
event,
onInsertText,
}: HandleInsertTextArgs): boolean {
if (event.metaKey || event.ctrlKey || event.altKey) return false
if (event.isComposing) return false
if (event.key.length !== 1) return false
event.preventDefault()
onInsertText(event.key)
return true
}
interface HandleSubmitKeyArgs {
event: React.KeyboardEvent<HTMLDivElement>
canSubmit: boolean
@@ -83,7 +103,8 @@ interface HandleInlineWikilinkKeyDownArgs {
suggestionsOpen: boolean
onCycleSuggestions: (direction: 1 | -1) => void
onSelectSuggestion: () => void
onDeleteAdjacentChip: (direction: 'backward' | 'forward') => boolean
onDeleteContent: (direction: 'backward' | 'forward') => void
onInsertText: (text: string) => void
canSubmit: boolean
onSubmit: () => void
}
@@ -94,7 +115,8 @@ export function handleInlineWikilinkKeyDown({
suggestionsOpen,
onCycleSuggestions,
onSelectSuggestion,
onDeleteAdjacentChip,
onDeleteContent,
onInsertText,
canSubmit,
onSubmit,
}: HandleInlineWikilinkKeyDownArgs) {
@@ -109,7 +131,11 @@ export function handleInlineWikilinkKeyDown({
return
}
if (handleDeleteKeys({ event, onDeleteAdjacentChip })) {
if (handleDeleteKeys({ event, onDeleteContent })) {
return
}
if (handleInsertText({ event, onInsertText })) {
return
}

View File

@@ -5,9 +5,10 @@ import {
useState,
} from 'react'
import {
applySelectionIndex,
readSelectionIndex,
applySelectionRange,
readSelectionRange,
serializeInlineNode,
type InlineSelectionRange,
} from './inlineWikilinkDom'
import { normalizeInlineWikilinkValue } from './inlineWikilinkTokens'
@@ -23,7 +24,10 @@ export function useInlineWikilinkSelection({
inputRef,
}: UseInlineWikilinkSelectionArgs) {
const editorRef = useRef<HTMLDivElement | null>(null)
const [selectionIndex, setSelectionIndex] = useState(value.length)
const [selectionRange, setSelectionRange] = useState<InlineSelectionRange>({
start: value.length,
end: value.length,
})
const setCombinedRef = useCallback((node: HTMLDivElement | null) => {
editorRef.current = node
@@ -32,41 +36,45 @@ export function useInlineWikilinkSelection({
}
}, [inputRef])
const syncSelectionIndex = useCallback(() => {
const syncSelectionRange = useCallback(() => {
if (!editorRef.current) return
setSelectionIndex(readSelectionIndex(editorRef.current))
setSelectionRange(readSelectionRange(editorRef.current))
}, [])
const focusSelectionAt = useCallback((nextSelectionIndex: number) => {
const focusSelectionRange = useCallback((nextSelectionRange: InlineSelectionRange) => {
const editor = editorRef.current
if (!editor) return
editor.focus()
applySelectionIndex(editor, nextSelectionIndex)
applySelectionRange(editor, nextSelectionRange)
}, [])
const commitValueFromEditor = useCallback(() => {
if (!editorRef.current) return
const nextValue = normalizeInlineWikilinkValue(serializeInlineNode(editorRef.current))
const nextSelectionIndex = readSelectionIndex(editorRef.current)
const nextSelectionRange = readSelectionRange(editorRef.current)
onChange(nextValue)
setSelectionIndex(Math.min(nextSelectionIndex, nextValue.length))
setSelectionRange({
start: Math.min(nextSelectionRange.start, nextValue.length),
end: Math.min(nextSelectionRange.end, nextValue.length),
})
}, [onChange])
useLayoutEffect(() => {
const editor = editorRef.current
if (!editor) return
if (document.activeElement !== editor) return
applySelectionIndex(editor, selectionIndex)
}, [selectionIndex, value])
applySelectionRange(editor, selectionRange)
}, [selectionRange, value])
return {
selectionIndex,
setSelectionIndex,
selectionRange,
selectionIndex: selectionRange.end,
setSelectionRange,
setCombinedRef,
syncSelectionIndex,
focusSelectionAt,
syncSelectionRange,
focusSelectionRange,
commitValueFromEditor,
}
}

View File

@@ -0,0 +1,41 @@
import { test, expect } from '@playwright/test'
import { openCommandPalette } from './helpers'
import {
expectNoPageErrors,
expectNormalizedEditorText,
selectEditorTextRange,
trackPageErrors,
} from './inlineWikilinkEditorHelpers'
test.describe('Command palette AI mode regression', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/vault/ping', route => route.fulfill({ status: 503 }))
await page.goto('/', { waitUntil: 'domcontentloaded' })
await expect(page.getByTestId('note-list-container')).toBeVisible({ timeout: 5_000 })
})
test('keeps focus, supports inline chip edits, and survives selection deletion', async ({ page }) => {
const pageErrors = trackPageErrors(page)
await openCommandPalette(page)
await page.locator('input[placeholder="Type a command..."]').pressSequentially(' ')
const aiInput = page.getByTestId('command-palette-ai-input')
await expect(aiInput).toBeVisible()
await expect(aiInput).toBeFocused()
await page.keyboard.type('edit my [[b')
await expect(page.getByTestId('wikilink-menu')).toContainText('Build Laputa App')
await page.getByTestId('wikilink-menu').getByText('Build Laputa App').click()
await expect(aiInput.getByTestId('inline-wikilink-chip')).toContainText('Build Laputa App')
await page.keyboard.type(' essay')
await expectNormalizedEditorText(aiInput, 'edit my Build Laputa App essay')
await selectEditorTextRange(page, 'command-palette-ai-input', 5)
await page.keyboard.press('Backspace')
await expect(aiInput).toBeVisible()
await expectNoPageErrors(pageErrors)
})
})

View File

@@ -0,0 +1,41 @@
import { test, expect } from '@playwright/test'
import {
expectNoPageErrors,
expectNormalizedEditorText,
selectEditorTextRange,
trackPageErrors,
} from './inlineWikilinkEditorHelpers'
test.describe('Inline wikilink editor regression', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/vault/ping', route => route.fulfill({ status: 503 }))
await page.goto('/', { waitUntil: 'domcontentloaded' })
await expect(page.getByTestId('note-list-container')).toBeVisible({ timeout: 5_000 })
await page.locator('.app__note-list .cursor-pointer').first().click()
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 3_000 })
await page.getByTitle('Open AI Chat').click()
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3_000 })
})
test('keeps inline chip editing stable after insertion and range deletion', async ({ page }) => {
const pageErrors = trackPageErrors(page)
const editor = page.getByTestId('agent-input')
await expect(editor).toBeFocused()
await page.keyboard.type('edit my [[b')
await expect(page.getByTestId('wikilink-menu')).toContainText('Build Laputa App')
await page.getByTestId('wikilink-menu').getByText('Build Laputa App').click()
await expect(editor.getByTestId('inline-wikilink-chip')).toContainText('Build Laputa App')
await page.keyboard.type(' essay')
await expectNormalizedEditorText(editor, 'edit my Build Laputa App essay')
await selectEditorTextRange(page, 'agent-input', 5)
await page.keyboard.press('Backspace')
await expect(editor).toBeVisible()
await expectNoPageErrors(pageErrors)
})
})

View File

@@ -0,0 +1,57 @@
import { expect, type Locator, type Page } from '@playwright/test'
interface SelectEditorTextRangeArgs {
dataTestId: string
startOffset: number
}
export function trackPageErrors(page: Page): string[] {
const pageErrors: string[] = []
page.on('pageerror', (error) => pageErrors.push(error.message))
return pageErrors
}
export async function expectNormalizedEditorText(
editor: Locator,
expectedText: string,
): Promise<void> {
await expect
.poll(async () => normalizeEditorText(await editor.textContent()))
.toBe(expectedText)
}
export async function selectEditorTextRange(
page: Page,
dataTestId: string,
startOffset: number,
): Promise<void> {
await page.evaluate(selectEditorTextRangeInBrowser, { dataTestId, startOffset })
}
export async function expectNoPageErrors(pageErrors: string[]): Promise<void> {
await expect
.poll(async () => pageErrors, { timeout: 2_000 })
.toEqual([])
}
function normalizeEditorText(value: string | null): string {
return value?.replace(/\s+/g, ' ').trim() ?? ''
}
function selectEditorTextRangeInBrowser({
dataTestId,
startOffset,
}: SelectEditorTextRangeArgs): void {
const editor = document.querySelector(`[data-testid="${dataTestId}"]`) as HTMLDivElement | null
const selection = window.getSelection()
const firstText = editor?.firstElementChild?.firstChild as Text | null
const lastText = editor?.lastElementChild?.firstChild as Text | null
const prerequisites = [selection, firstText, lastText]
if (prerequisites.some(value => !value)) return
const range = document.createRange()
range.setStart(firstText, startOffset)
range.setEnd(lastText, lastText.textContent?.length ?? 0)
selection.removeAllRanges()
selection.addRange(range)
}