diff --git a/src/components/InlineWikilinkInput.tsx b/src/components/InlineWikilinkInput.tsx index 8881ad2f..02f27a93 100644 --- a/src/components/InlineWikilinkInput.tsx +++ b/src/components/InlineWikilinkInput.tsx @@ -75,6 +75,7 @@ function renderInlineEditorField({ editorClassName, onInput, onKeyDown, + onPaste, onSelectionChange, segments, typeEntryMap, @@ -87,6 +88,7 @@ function renderInlineEditorField({ editorClassName?: string onInput: () => void onKeyDown: (event: React.KeyboardEvent) => void + onPaste: (event: React.ClipboardEvent) => void onSelectionChange: () => void segments: ReturnType typeEntryMap: Record @@ -101,6 +103,7 @@ function renderInlineEditorField({ editorClassName={editorClassName} onInput={onInput} onKeyDown={onKeyDown} + onPaste={onPaste} onSelectionChange={onSelectionChange} segments={segments} typeEntryMap={typeEntryMap} @@ -209,6 +212,17 @@ export function InlineWikilinkInput({ onChange(nextState.value) setSelectionRange(nextState.selection) } + const handlePaste = (event: React.ClipboardEvent) => { + if (disabled) return + + const pastedText = normalizeInlineWikilinkValue(event.clipboardData.getData('text/plain')) + if (!pastedText) return + + event.preventDefault() + const nextState = replaceInlineSelection(value, selectionRange, pastedText) + onChange(nextState.value) + setSelectionRange(nextState.selection) + } const submitValue = () => submitInlineValue({ onSubmit, submitOnEmpty, value, references }) const handleKeyDown = (event: React.KeyboardEvent) => @@ -232,6 +246,7 @@ export function InlineWikilinkInput({ editorClassName, onInput: commitValueFromEditor, onKeyDown: handleKeyDown, + onPaste: handlePaste, onSelectionChange: syncSelectionRange, segments, typeEntryMap, diff --git a/src/components/InlineWikilinkParts.tsx b/src/components/InlineWikilinkParts.tsx index 7fe2a40e..26960e7d 100644 --- a/src/components/InlineWikilinkParts.tsx +++ b/src/components/InlineWikilinkParts.tsx @@ -160,6 +160,7 @@ export function InlineWikilinkEditorField({ editorClassName, onInput, onKeyDown, + onPaste, onSelectionChange, segments, typeEntryMap, @@ -172,6 +173,7 @@ export function InlineWikilinkEditorField({ editorClassName?: string onInput: () => void onKeyDown: (event: React.KeyboardEvent) => void + onPaste: (event: React.ClipboardEvent) => void onSelectionChange: () => void segments: InlineWikilinkSegment[] typeEntryMap: Record @@ -204,6 +206,7 @@ export function InlineWikilinkEditorField({ )} onInput={onInput} onKeyDown={onKeyDown} + onPaste={onPaste} onClick={onSelectionChange} onKeyUp={onSelectionChange} onMouseUp={onSelectionChange} diff --git a/tests/smoke/command-palette-ai-mode-regression.spec.ts b/tests/smoke/command-palette-ai-mode-regression.spec.ts index b30e2999..855e5070 100644 --- a/tests/smoke/command-palette-ai-mode-regression.spec.ts +++ b/tests/smoke/command-palette-ai-mode-regression.spec.ts @@ -1,10 +1,12 @@ import { test, expect, type Page } from '@playwright/test' import { openCommandPalette } from './helpers' import { + expectEditorSelectionRange, expectNoPageErrors, expectNormalizedEditorText, selectEditorTextRange, trackPageErrors, + writeClipboardText, } from './inlineWikilinkEditorHelpers' async function readCaretGapAfterChip(page: Page) { @@ -72,4 +74,57 @@ test.describe('Command palette AI mode regression', () => { await expect(aiInput).toBeVisible() await expectNoPageErrors(pageErrors) }) + + test('keeps pasted text, caret movement, and selection replacement stable in AI mode', async ({ page }) => { + const pageErrors = trackPageErrors(page) + const aiInputTarget = { dataTestId: 'command-palette-ai-input' } + 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 writeClipboardText(page, { text: 'hello world' }) + await page.keyboard.press('Meta+V') + await expectNormalizedEditorText(aiInput, 'hello world') + await expectEditorSelectionRange(page, { + expectedRange: { start: 12, end: 12 }, + target: aiInputTarget, + }) + + for (let i = 0; i < 5; i += 1) { + await page.keyboard.press('ArrowLeft') + } + await expectEditorSelectionRange(page, { + expectedRange: { start: 7, end: 7 }, + target: aiInputTarget, + }) + + await page.keyboard.press('Shift+ArrowRight') + await page.keyboard.press('Shift+ArrowRight') + await expectEditorSelectionRange(page, { + expectedRange: { start: 7, end: 9 }, + target: aiInputTarget, + }) + + await page.keyboard.type('XY') + await expectNormalizedEditorText(aiInput, 'hello XYrld') + await expectEditorSelectionRange(page, { + expectedRange: { start: 9, end: 9 }, + target: aiInputTarget, + }) + + for (let i = 0; i < 3; i += 1) { + await page.keyboard.press('ArrowRight') + } + await expectEditorSelectionRange(page, { + expectedRange: { start: 12, end: 12 }, + target: aiInputTarget, + }) + + await page.keyboard.press('Backspace') + await expectNormalizedEditorText(aiInput, 'hello XYrl') + await expectNoPageErrors(pageErrors) + }) }) diff --git a/tests/smoke/inline-wikilink-editor-regression.spec.ts b/tests/smoke/inline-wikilink-editor-regression.spec.ts index 85b204ea..bc5e427b 100644 --- a/tests/smoke/inline-wikilink-editor-regression.spec.ts +++ b/tests/smoke/inline-wikilink-editor-regression.spec.ts @@ -1,9 +1,11 @@ import { test, expect } from '@playwright/test' import { + expectEditorSelectionRange, expectNoPageErrors, expectNormalizedEditorText, selectEditorTextRange, trackPageErrors, + writeClipboardText, } from './inlineWikilinkEditorHelpers' test.describe('Inline wikilink editor regression', () => { @@ -38,4 +40,53 @@ test.describe('Inline wikilink editor regression', () => { await expect(editor).toBeVisible() await expectNoPageErrors(pageErrors) }) + + test('keeps pasted text, caret movement, and selection replacement stable in the AI panel chat input', async ({ page }) => { + const pageErrors = trackPageErrors(page) + const agentInputTarget = { dataTestId: 'agent-input' } + const editor = page.getByTestId('agent-input') + await expect(editor).toBeFocused() + + await writeClipboardText(page, { text: 'hello world' }) + await page.keyboard.press('Meta+V') + await expectNormalizedEditorText(editor, 'hello world') + await expectEditorSelectionRange(page, { + expectedRange: { start: 11, end: 11 }, + target: agentInputTarget, + }) + + for (let i = 0; i < 5; i += 1) { + await page.keyboard.press('ArrowLeft') + } + await expectEditorSelectionRange(page, { + expectedRange: { start: 6, end: 6 }, + target: agentInputTarget, + }) + + await page.keyboard.press('Shift+ArrowRight') + await page.keyboard.press('Shift+ArrowRight') + await expectEditorSelectionRange(page, { + expectedRange: { start: 6, end: 8 }, + target: agentInputTarget, + }) + + await page.keyboard.type('XY') + await expectNormalizedEditorText(editor, 'hello XYrld') + await expectEditorSelectionRange(page, { + expectedRange: { start: 8, end: 8 }, + target: agentInputTarget, + }) + + for (let i = 0; i < 3; i += 1) { + await page.keyboard.press('ArrowRight') + } + await expectEditorSelectionRange(page, { + expectedRange: { start: 11, end: 11 }, + target: agentInputTarget, + }) + + await page.keyboard.press('Backspace') + await expectNormalizedEditorText(editor, 'hello XYrl') + await expectNoPageErrors(pageErrors) + }) }) diff --git a/tests/smoke/inlineWikilinkEditorHelpers.ts b/tests/smoke/inlineWikilinkEditorHelpers.ts index 3457a2a6..83df90a2 100644 --- a/tests/smoke/inlineWikilinkEditorHelpers.ts +++ b/tests/smoke/inlineWikilinkEditorHelpers.ts @@ -5,6 +5,24 @@ interface SelectEditorTextRangeArgs { startOffset: number } +interface ClipboardWriteArgs { + text: string +} + +interface EditorSelectionRange { + start: number + end: number +} + +interface EditorTarget { + dataTestId: string +} + +interface ExpectEditorSelectionRangeArgs { + expectedRange: EditorSelectionRange + target: EditorTarget +} + export function trackPageErrors(page: Page): string[] { const pageErrors: string[] = [] page.on('pageerror', (error) => pageErrors.push(error.message)) @@ -34,6 +52,28 @@ export async function expectNoPageErrors(pageErrors: string[]): Promise { .toEqual([]) } +export async function writeClipboardText( + page: Page, + { text }: ClipboardWriteArgs, +): Promise { + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) + await page.evaluate(async ({ text: nextText }) => { + await navigator.clipboard.writeText(nextText) + }, { text }) +} + +export async function expectEditorSelectionRange( + page: Page, + { expectedRange, target }: ExpectEditorSelectionRangeArgs, +): Promise { + await expect + .poll( + async () => page.evaluate(readEditorSelectionRangeInBrowser, target), + { timeout: 2_000 }, + ) + .toEqual(expectedRange) +} + function normalizeEditorText(value: string | null): string { return value?.replace(/\s+/g, ' ').trim() ?? '' } @@ -55,3 +95,28 @@ function selectEditorTextRangeInBrowser({ selection.removeAllRanges() selection.addRange(range) } + +function readEditorSelectionRangeInBrowser({ + dataTestId, +}: EditorTarget): EditorSelectionRange | null { + const editor = document.querySelector(`[data-testid="${dataTestId}"]`) as HTMLDivElement | null + const selection = window.getSelection() + if (!editor || !selection || selection.rangeCount === 0) return null + + const range = selection.getRangeAt(0) + if (!editor.contains(range.startContainer) || !editor.contains(range.endContainer)) return null + + const startRange = document.createRange() + startRange.selectNodeContents(editor) + startRange.setEnd(range.startContainer, range.startOffset) + + const endRange = document.createRange() + endRange.selectNodeContents(editor) + endRange.setEnd(range.endContainer, range.endOffset) + const normalizeSelectionText = (value: string) => value.replace(/\u200B/g, '') + + return { + start: normalizeSelectionText(startRange.toString()).length, + end: normalizeSelectionText(endRange.toString()).length, + } +}