diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index b02eb6c3..58702f0f 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -314,6 +314,7 @@ function OpenCommandPalette({ entries={entries} value={aiValue} claudeCodeReady={claudeCodeReady} + inputRef={aiInputRef} onChange={handleAiValueChange} onSubmit={handleSubmitAiPrompt} /> diff --git a/src/components/CommandPaletteAiMode.tsx b/src/components/CommandPaletteAiMode.tsx index ddc3977a..0330ecb8 100644 --- a/src/components/CommandPaletteAiMode.tsx +++ b/src/components/CommandPaletteAiMode.tsx @@ -7,6 +7,7 @@ interface CommandPaletteAiModeProps { entries: VaultEntry[] value: string claudeCodeReady: boolean + inputRef?: React.RefObject 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({ onSubmit(stripLeadingSpace(text), references)} submitOnEmpty={true} diff --git a/src/components/InlineWikilinkInput.tsx b/src/components/InlineWikilinkInput.tsx index a6fdb887..8881ad2f 100644 --- a/src/components/InlineWikilinkInput.tsx +++ b/src/components/InlineWikilinkInput.tsx @@ -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 - 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 + dataTestId: string + editorClassName?: string + onInput: () => void + onKeyDown: (event: React.KeyboardEvent) => void + onSelectionChange: () => void + segments: ReturnType + typeEntryMap: Record +}) { + return ( + + ) +} + +function renderInlineSuggestionList({ + suggestions, + selectedSuggestionIndex, + setSuggestionIndex, + selectSuggestion, + typeEntryMap, + suggestionListVariant, + suggestionEmptyLabel, +}: { + suggestions: ReturnType['suggestions'] + selectedSuggestionIndex: number + setSuggestionIndex: (index: number) => void + selectSuggestion: (index: number) => void + typeEntryMap: Record + suggestionListVariant: 'floating' | 'palette' + suggestionEmptyLabel: string +}) { + if (suggestions.length === 0) return null + + return ( + + ) +} + 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 = ( - - ) - const suggestionList = suggestions.length > 0 ? ( - - ) : 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 ( = value.length) return null + return { + value: value.slice(0, normalizedSelection.start) + value.slice(normalizedSelection.start + 1), + selection: collapseSelection(normalizedSelection.start), + } +} diff --git a/src/components/inlineWikilinkKeydown.ts b/src/components/inlineWikilinkKeydown.ts index 122849c5..28faad62 100644 --- a/src/components/inlineWikilinkKeydown.ts +++ b/src/components/inlineWikilinkKeydown.ts @@ -38,26 +38,46 @@ function handleSuggestionKeys({ interface HandleDeleteKeysArgs { event: React.KeyboardEvent - 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 + 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 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 } diff --git a/src/components/useInlineWikilinkSelection.ts b/src/components/useInlineWikilinkSelection.ts index 6e4eb178..80ac0e45 100644 --- a/src/components/useInlineWikilinkSelection.ts +++ b/src/components/useInlineWikilinkSelection.ts @@ -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(null) - const [selectionIndex, setSelectionIndex] = useState(value.length) + const [selectionRange, setSelectionRange] = useState({ + 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, } } diff --git a/tests/smoke/command-palette-ai-mode-regression.spec.ts b/tests/smoke/command-palette-ai-mode-regression.spec.ts new file mode 100644 index 00000000..161b296c --- /dev/null +++ b/tests/smoke/command-palette-ai-mode-regression.spec.ts @@ -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) + }) +}) diff --git a/tests/smoke/inline-wikilink-editor-regression.spec.ts b/tests/smoke/inline-wikilink-editor-regression.spec.ts new file mode 100644 index 00000000..f68345c9 --- /dev/null +++ b/tests/smoke/inline-wikilink-editor-regression.spec.ts @@ -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) + }) +}) diff --git a/tests/smoke/inlineWikilinkEditorHelpers.ts b/tests/smoke/inlineWikilinkEditorHelpers.ts new file mode 100644 index 00000000..3457a2a6 --- /dev/null +++ b/tests/smoke/inlineWikilinkEditorHelpers.ts @@ -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 { + await expect + .poll(async () => normalizeEditorText(await editor.textContent())) + .toBe(expectedText) +} + +export async function selectEditorTextRange( + page: Page, + dataTestId: string, + startOffset: number, +): Promise { + await page.evaluate(selectEditorTextRangeInBrowser, { dataTestId, startOffset }) +} + +export async function expectNoPageErrors(pageErrors: string[]): Promise { + 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) +}