fix(ui-conversation): step across chips without a keyboard-selected state
isKeyboardSelectable() defaulted to true, so an arrow key at a chip edge created a NodeSelection whose DOM projection collapses to an element point; the plain-text binding's arrow/delete/insert handlers all bail on non-Range selections, deadlocking arrows, typing, and Backspace at the chip until a pointer click. False restores the placeholder semantics: arrows cross the chip in one move and Backspace/Delete remove it whole. Reproduced and verified with real-key Playwright probes (CDP raw keydowns carry no engine default and cannot reproduce it); the reference-composer e2e pins the gesture in the browser lane.
This commit is contained in:
1 parent
315fc9b16e
commit
c8b4ec73a0
6 files changed
+42
-5
No files matched your search
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-20-web-composer-lexical-editor.md
|
||||
2026-08-20-web-composer-lexical-editor.md: 7c8ae00e028d8deaf4a24733d67451bd01998eb9
|
||||
2026-08-20-web-composer-lexical-editor.zh.md: 07c86a2917409adfdf1453702ea552505fe823d0
|
||||
2026-08-20-web-composer-lexical-editor.md: ddb04fd97a653dac9e1cbef83e432875b1e40280
|
||||
2026-08-20-web-composer-lexical-editor.zh.md: f4435ed0ec3f9b9acf6acd72b0d7a9c6bfc3f28a
|
||||
@@ -58,3 +58,4 @@ The mirror/backdrop layers and their CSS coupling rules; the Safari soft-wrap re
|
||||
- A history restore (`UNDO_COMMAND`) commits on the next flush, not synchronously inside the dispatch.
|
||||
- The client bundle needs the `production`/`development` exports condition pinned (tsdown preset `inputOptions.resolve.conditionNames`): lexical's `node` condition file selects its flavor with a top-level await a CJS bundle cannot carry.
|
||||
- `registerHistory`'s merge delay reads `Date.now` at call time; fake-timer tests must install the mock before shell construction or advance past the window.
|
||||
- `isKeyboardSelectable()` must be **false** on the chip. With the default `true`, an arrow at the chip edge creates a NodeSelection whose DOM projection collapses to an element point, and the plain-text binding's arrow/delete/insert handlers all bail on non-Range selections — arrows, typing, and Backspace deadlock at the chip until a pointer click. False restores the placeholder semantics: arrows step across in one move, Backspace/Delete remove the chip whole (browser-lane e2e pins the gesture; only a real key event reproduces it — CDP raw keydowns carry no engine default).
|
||||
@@ -58,3 +58,4 @@ mirror/backdrop 层及其 CSS 耦合规则;Safari 软换行修复(2026-08-13
|
||||
- 历史恢复(`UNDO_COMMAND`)在下一次 flush 才提交,不在 dispatch 内同步生效。
|
||||
- client bundle 需钉住 `production`/`development` exports 条件(tsdown preset 的 `inputOptions.resolve.conditionNames`):lexical 的 `node` 条件文件用顶层 await 选择口味,CJS bundle 载不动。
|
||||
- `registerHistory` 的合并延时在调用时捕获 `Date.now`;fake-timer 测试要么在 shell 构造前装好 mock,要么推进越过窗口。
|
||||
- chip 的 `isKeyboardSelectable()` 必须为 **false**。取默认值 `true` 时,方向键落在 chip 边缘会创建 NodeSelection,其 DOM 投影坍塌为 element point,而 plain-text binding 的方向键/删除/插入 handler 全都对非 Range selection 直接放弃——方向键、打字与退格在 chip 边死锁,直到鼠标点击才能解除。false 恢复占位符语义:方向键一步跨过,Backspace/Delete 整颗删除(浏览器 lane e2e 钉住该手势;只有真实按键事件能复现——CDP 裸 keydown 不携带引擎默认行为)。
|
||||
@@ -200,6 +200,35 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('arrows step across a chip in one move and Backspace removes it whole', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-keyboard'))
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
|
||||
|
||||
await input.fill('@reference')
|
||||
await menu.getByRole('option', { name: /File · reference\.txt/ }).click()
|
||||
await expect.poll(() => input.locator('[data-composer-chip]').count()).toBe(1)
|
||||
|
||||
// First ArrowLeft crosses the trailing space; the second steps across the
|
||||
// chip in one move — no keyboard-selected intermediate state — and typing
|
||||
// continues normally on the far side.
|
||||
await page.keyboard.press('ArrowLeft')
|
||||
await page.keyboard.press('ArrowLeft')
|
||||
await page.keyboard.type('pre')
|
||||
await expect.poll(() => input.textContent()).toBe('prereference.txt ')
|
||||
await expect.poll(() => input.locator('[data-composer-chip]').count()).toBe(1)
|
||||
|
||||
// ArrowRight steps back across the chip; Backspace directly behind it
|
||||
// removes the whole chip in one keystroke.
|
||||
await page.keyboard.press('ArrowRight')
|
||||
await page.keyboard.press('Backspace')
|
||||
await expect.poll(() => input.locator('[data-composer-chip]').count()).toBe(0)
|
||||
await expect.poll(() => input.textContent()).toBe('pre ')
|
||||
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('renders the durable direct-message then recall order', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-order'))
|
||||
const group = page.getByRole('treeitem', { name: /Ungrouped/ })
|
||||
|
||||
@@ -133,9 +133,15 @@ export class ReferenceChipNode extends DecoratorNode<JSX.Element> {
|
||||
return true
|
||||
}
|
||||
|
||||
/** Arrow keys and Backspace address the chip as one unit. */
|
||||
/**
|
||||
* No keyboard-selected intermediate state: arrows step across the chip in
|
||||
* one move and Backspace/Delete remove it whole (the placeholder semantics
|
||||
* of the old textarea). `true` would put a NodeSelection between the
|
||||
* keystroke and the caret — a state the plain-text binding's handlers all
|
||||
* ignore, deadlocking arrows, typing, and deletion at the chip edge.
|
||||
*/
|
||||
override isKeyboardSelectable(): boolean {
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Clipboard / persistence projection (native copy reads this). */
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('ReferenceChipNode', () => {
|
||||
expect(chip.getTextContent()).toBe('@session:随意回复不调用工具')
|
||||
expect(chip.isInvalid()).toBe(false)
|
||||
expect(chip.isInline()).toBe(true)
|
||||
expect(chip.isKeyboardSelectable()).toBe(true)
|
||||
expect(chip.isKeyboardSelectable()).toBe(false)
|
||||
expect($isReferenceChipNode(chip)).toBe(true)
|
||||
expect($isReferenceChipNode($createTextNode('x'))).toBe(false)
|
||||
}, { discrete: true })
|
||||
|
||||
Reference in new issue
Block a user