fix: stabilize ai mode paste editing
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user