fix: stabilize ai mode paste editing

This commit is contained in:
lucaronin
2026-04-18 16:52:36 +02:00
parent 502ecf0572
commit bc11c869aa
5 changed files with 189 additions and 0 deletions

View File

@@ -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<HTMLDivElement>) => void
onPaste: (event: React.ClipboardEvent<HTMLDivElement>) => void
onSelectionChange: () => void
segments: ReturnType<typeof buildInlineWikilinkSegments>
typeEntryMap: Record<string, VaultEntry>
@@ -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<HTMLDivElement>) => {
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<HTMLDivElement>) =>
@@ -232,6 +246,7 @@ export function InlineWikilinkInput({
editorClassName,
onInput: commitValueFromEditor,
onKeyDown: handleKeyDown,
onPaste: handlePaste,
onSelectionChange: syncSelectionRange,
segments,
typeEntryMap,

View File

@@ -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<HTMLDivElement>) => void
onPaste: (event: React.ClipboardEvent<HTMLDivElement>) => void
onSelectionChange: () => void
segments: InlineWikilinkSegment[]
typeEntryMap: Record<string, VaultEntry>
@@ -204,6 +206,7 @@ export function InlineWikilinkEditorField({
)}
onInput={onInput}
onKeyDown={onKeyDown}
onPaste={onPaste}
onClick={onSelectionChange}
onKeyUp={onSelectionChange}
onMouseUp={onSelectionChange}

View File

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

View File

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

View File

@@ -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<void> {
.toEqual([])
}
export async function writeClipboardText(
page: Page,
{ text }: ClipboardWriteArgs,
): Promise<void> {
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<void> {
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,
}
}