fix(client): preserve composer position above mobile keyboards

Patch Lexical viewport coordinates and cover keyboard panning in editor and browser regressions. Update the patch disclosure.
This commit is contained in:
Stevring
2026-10-20 12:10:15 +08:00
parent c29a313379
commit c503b51a94
10 changed files with 209 additions and 17 deletions
+2
View File
@@ -41,6 +41,7 @@ External packages installed for runtime use or embedded in distributed Host or b
| [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT |
| [`@earendil-works/pi-coding-agent`](https://github.com/earendil-works/pi) | MIT |
| [`@eslint-community/regexpp`](https://github.com/eslint-community/regexpp) | MIT |
| [`@floating-ui/dom`](https://github.com/floating-ui/floating-ui) | MIT |
| [`@fortune-sheet/core`](https://github.com/ruilisi/fortune-sheet) | MIT |
| [`@fortune-sheet/react`](https://github.com/ruilisi/fortune-sheet) | MIT |
| [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT |
@@ -159,6 +160,7 @@ pnpm applies local patches to the following packages at install time, so shipped
- `@yao-pkg/pkg@6.21.0` — [`patches/@yao-pkg__pkg@6.21.0.patch`](patches/@yao-pkg__pkg@6.21.0.patch)
- `app-builder-lib@26.17.0` — [`patches/app-builder-lib@26.17.0.patch`](patches/app-builder-lib@26.17.0.patch)
- `exceljs@4.4.0` — [`patches/exceljs@4.4.0.patch`](patches/exceljs@4.4.0.patch)
- `lexical@0.49.0` — [`patches/lexical@0.49.0.patch`](patches/lexical@0.49.0.patch)
- `node-pty@1.2.0-beta.15` — [`patches/node-pty@1.2.0-beta.15.patch`](patches/node-pty@1.2.0-beta.15.patch)
## Chrome DevTools frontend
+54
View File
@@ -39,6 +39,51 @@ function describeSelection(state: Awaited<ReturnType<typeof selectionState>>): s
return `${state.focused ? 'focused' : 'unfocused'}; selection ${JSON.stringify(state.text)} (${state.anchor} → ${state.focus})`
}
/**
* Keep the visible composer still when keyboard panning changes viewport coordinates.
* @param page - Page with the focused composer.
* @returns The observed page and composer scroll behavior.
*/
async function exerciseKeyboardViewport(page: Page): Promise<string> {
const input = page.locator('[data-composer-input][contenteditable="true"]')
const restore = await page.evaluateHandle(() => {
const viewport = window.visualViewport
if (viewport === null) throw new Error('Expected a visual viewport')
const style = document.documentElement.style
const height = style.getPropertyValue('height')
const priority = style.getPropertyPriority('height')
const scroll = window.scrollY
const offset = Object.getOwnPropertyDescriptor(viewport, 'offsetTop')
style.setProperty('height', `${window.innerHeight * 2}px`)
window.scrollTo(0, window.innerHeight)
// Safari can report the keyboard's page displacement as offsetTop even
// though DOM rectangles already account for that page scroll.
Object.defineProperty(viewport, 'offsetTop', { configurable: true, get: () => window.scrollY })
return () => {
if (offset === undefined) Reflect.deleteProperty(viewport, 'offsetTop')
else Object.defineProperty(viewport, 'offsetTop', offset)
if (height === '') style.removeProperty('height')
else style.setProperty('height', height, priority)
window.scrollTo(0, scroll)
}
})
try {
const before = await input.evaluate(el => ({ top: el.getBoundingClientRect().top, scroll: window.scrollY }))
expect(before.scroll).toBeGreaterThan(0)
await page.keyboard.insertText('z')
const after = await input.evaluate(el => new Promise<{ top: number; scroll: number }>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => {
resolve({ top: el.getBoundingClientRect().top, scroll: window.scrollY })
}))
}))
expect(after).toEqual(before)
return '- typing with a keyboard-panned viewport preserves page scroll and composer position'
} finally {
await restore.evaluate((dispose) => { dispose() })
await restore.dispose()
}
}
/**
* Exercise the card's top, inter-row gap, toolbar gap and bottom padding.
* @param page - Page containing an editable composer.
@@ -113,6 +158,15 @@ async function exerciseComposer(page: Page): Promise<string[]> {
await page.keyboard.insertText('!')
await expect.poll(() => input.textContent()).toBe('alpha BET!A gamma')
observations.push(`- typing replaces the restored selection and resumes at the restored caret: ${await input.textContent()}`)
const retained = await input.elementHandle()
const beforeResize = await selectionState(page)
for (const width of [390, 768, 1680]) {
await page.setViewportSize({ width, height: 1000 })
expect(await retained!.evaluate(element => element.isConnected)).toBe(true)
expect(await selectionState(page)).toEqual(beforeResize)
}
observations.push('- resizing through 390px, 768px and 1680px preserves the editor, focus, draft and caret')
observations.push(await exerciseKeyboardViewport(page))
return observations
}
@@ -8,6 +8,8 @@
- bottom padding: focused; selection "beta" (10 → 6)
- return from outside: focused; selection "beta" (10 → 6)
- typing replaces the restored selection and resumes at the restored caret: alpha BET!A gamma
- resizing through 390px, 768px and 1680px preserves the editor, focus, draft and caret
- typing with a keyboard-panned viewport preserves page scroll and composer position
## Existing session
@@ -17,3 +19,5 @@
- bottom padding: focused; selection "beta" (10 → 6)
- return from outside: focused; selection "beta" (10 → 6)
- typing replaces the restored selection and resumes at the restored caret: alpha BET!A gamma
- resizing through 390px, 768px and 1680px preserves the editor, focus, draft and caret
- typing with a keyboard-panned viewport preserves page scroll and composer position
@@ -21,8 +21,8 @@
en: 9912b286384c41d7
zh: 3e994887c1948677
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
en: fce20a7d042b1d29
zh: 0f8e4faff447df6f
en: 74566fff43564bfe
zh: 4944512f461a08b0
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
en: bb1244310f5c0382
zh: 824b84077432b559
+1 -1
View File
@@ -68,7 +68,7 @@ The composer measures its expanded control groups after size, content, visibilit
The context meter follows the [statistics-control radius](../../../docs/ui-radius.md#radius-scale).
The composer uses the shared [text-input focus policy](../ui-primitives/README.md#use-this-package) on mount, Session switching, and unlock. Identified phones and tablets require tapping the input; desktop devices retain automatic focus.
The composer uses the shared [text-input focus policy](../ui-primitives/README.md#use-this-package) on mount, Session switching, and unlock. Identified phones and tablets require tapping the input; desktop devices retain automatic focus. The [Lexical patch](../../../patches/lexical@0.49.0.patch) keeps caret scrolling in the same coordinate system as DOM geometry when a mobile keyboard pans the page.
The context-occupancy button shows a ring and percentage below the input card, after the Session statistics. Clicking it opens the token breakdown in a panel kept inside the viewport, including when no statistics are shown; the button stays hidden until context usage and capacity are available.
+1 -1
View File
@@ -68,7 +68,7 @@ Queue 预览启用空白折叠和文档边缘 trim,保留 emoji,并使用 CS
上下文占用率入口遵循[统计控件圆角规则](../../../docs/ui-radius.zh.md#radius-scale)。
composer 在挂载、切换 Session 和解除锁定时使用共享的[文字输入框聚焦策略](../ui-primitives/README.zh.md#use-this-package)。识别出的手机和平板需要点击输入框;桌面设备保留自动聚焦。
composer 在挂载、切换 Session 和解除锁定时使用共享的[文字输入框聚焦策略](../ui-primitives/README.zh.md#use-this-package)。识别出的手机和平板需要点击输入框;桌面设备保留自动聚焦。移动端键盘平移页面时,[Lexical 补丁](../../../patches/lexical@0.49.0.patch)让光标滚动计算与 DOM 几何使用同一坐标系。
上下文占用按钮在输入卡片下方、会话统计右侧显示圆环和百分比。点击按钮可在视口内的面板查看 token 构成,没有统计项时面板也不会越界;上下文用量和容量尚不可用时,按钮保持隐藏。
@@ -0,0 +1,72 @@
// @vitest-environment jsdom
/** Composer caret visibility across page scrolling and virtual-keyboard viewport changes. */
import { expect, it, onTestFinished, vi } from 'vitest'
import { DraftEditorRuntime } from '../src/client/input/editor/runtime.ts'
function fixture(viewport: { height: number; offsetTop: number; pageTop: number } | undefined, pageScroll: number) {
// jsdom has no visual viewport or Range geometry; use the browser measurements
// while leaving Lexical's DOM selection and scrolling decisions intact.
vi.stubGlobal('visualViewport', viewport)
vi.stubGlobal('scrollY', pageScroll)
vi.stubGlobal('innerHeight', 600)
const scrollBy = vi.spyOn(window, 'scrollBy').mockImplementation(() => {})
const geometry = Object.getOwnPropertyDescriptor(Range.prototype, 'getBoundingClientRect')
let caret = new DOMRect(20, 169, 0, 26)
Object.defineProperty(Range.prototype, 'getBoundingClientRect', {
configurable: true,
value: () => caret,
})
const root = document.createElement('div')
root.setAttribute('contenteditable', 'true')
root.tabIndex = 0
root.getBoundingClientRect = () => new DOMRect(0, 0, 400, 1000)
document.body.append(root)
const runtime: DraftEditorRuntime = new DraftEditorRuntime({
onUpdate: () => { runtime.refreshProjection() },
openReference: () => false, activeClaimToken: () => null,
lexicon: () => new Map(), resolveLexicon: () => undefined,
})
const dispose = runtime.register()
onTestFinished(() => {
dispose()
root.remove()
scrollBy.mockRestore()
if (geometry === undefined) Reflect.deleteProperty(Range.prototype, 'getBoundingClientRect')
else Object.defineProperty(Range.prototype, 'getBoundingClientRect', geometry)
vi.unstubAllGlobals()
})
runtime.editor.setRootElement(root)
root.focus({ preventScroll: true })
scrollBy.mockClear()
return {
scrollBy,
typeAt(top: number): void {
caret = new DOMRect(20, top, 0, 26)
runtime.setDraft('a')
},
}
}
it('keeps a visible caret still after Safari pans the page above the keyboard', () => {
const f = fixture({ height: 376, offsetTop: 196, pageTop: 196 }, 196)
f.typeAt(169)
expect(f.scrollBy).not.toHaveBeenCalled()
})
it('reveals a caret below the keyboard after the page has scrolled', () => {
const f = fixture({ height: 376, offsetTop: 196, pageTop: 196 }, 196)
f.typeAt(410)
expect(f.scrollBy).toHaveBeenCalledExactlyOnceWith(0, 60)
})
it('retains visual viewport panning relative to an already scrolled page', () => {
const f = fixture({ height: 376, offsetTop: 150, pageTop: 350 }, 200)
f.typeAt(100)
expect(f.scrollBy).toHaveBeenCalledExactlyOnceWith(0, -50)
})
it('uses the window height when the browser has no visual viewport', () => {
const f = fixture(undefined, 200)
f.typeAt(630)
expect(f.scrollBy).toHaveBeenCalledExactlyOnceWith(0, 56)
})
File diff suppressed because one or more lines are too long
+14 -13
View File
@@ -18,6 +18,7 @@ patchedDependencies:
'@yao-pkg/pkg@6.21.0': 4d4861969595629311f7d90a0b34b15481c96d5682d828a151a104c69d4616d3
app-builder-lib@26.17.0: b70dc2a2a78ffabb80a4f14fafc7c103237f2b0a207e1f296fa1c1387ea29348
exceljs@4.4.0: c1bf8ac8f319dab382f08e3a669b715e691d46c703ac5a4163eb383fbedd1d48
lexical@0.49.0: 54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356
node-pty@1.2.0-beta.15: 9d30053266d0f39d3debe894c675867f6782da6bcadbc94a693b3cfb9eff8ed0
importers:
@@ -3993,7 +3994,7 @@ importers:
version: 2.1.1
lexical:
specifier: ^0.49.0
version: 0.49.0(typescript@6.0.3)
version: 0.49.0(patch_hash=54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356)(typescript@6.0.3)
react:
specifier: ^18.2.0
version: 18.3.1
@@ -25690,14 +25691,14 @@ snapshots:
'@lexical/selection': 0.49.0(typescript@6.0.3)
'@lexical/utils': 0.49.0(typescript@6.0.3)
'@types/trusted-types': 2.0.7
lexical: 0.49.0(typescript@6.0.3)
lexical: 0.49.0(patch_hash=54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356)(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
'@lexical/dragon@0.49.0(typescript@6.0.3)':
dependencies:
'@lexical/extension': 0.49.0(typescript@6.0.3)
lexical: 0.49.0(typescript@6.0.3)
lexical: 0.49.0(patch_hash=54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356)(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
@@ -25706,7 +25707,7 @@ snapshots:
'@lexical/internal': 0.49.0(typescript@6.0.3)
'@lexical/utils': 0.49.0(typescript@6.0.3)
'@preact/signals-core': 1.14.4
lexical: 0.49.0(typescript@6.0.3)
lexical: 0.49.0(patch_hash=54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356)(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
@@ -25714,7 +25715,7 @@ snapshots:
dependencies:
'@lexical/internal': 0.49.0(typescript@6.0.3)
happy-dom: 20.11.6
lexical: 0.49.0(typescript@6.0.3)
lexical: 0.49.0(patch_hash=54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356)(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
transitivePeerDependencies:
@@ -25725,7 +25726,7 @@ snapshots:
dependencies:
'@lexical/extension': 0.49.0(typescript@6.0.3)
'@lexical/utils': 0.49.0(typescript@6.0.3)
lexical: 0.49.0(typescript@6.0.3)
lexical: 0.49.0(patch_hash=54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356)(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
@@ -25735,7 +25736,7 @@ snapshots:
'@lexical/internal': 0.49.0(typescript@6.0.3)
'@lexical/selection': 0.49.0(typescript@6.0.3)
'@lexical/utils': 0.49.0(typescript@6.0.3)
lexical: 0.49.0(typescript@6.0.3)
lexical: 0.49.0(patch_hash=54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356)(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
@@ -25749,7 +25750,7 @@ snapshots:
'@lexical/html': 0.49.0(typescript@6.0.3)
'@lexical/internal': 0.49.0(typescript@6.0.3)
'@lexical/utils': 0.49.0(typescript@6.0.3)
lexical: 0.49.0(typescript@6.0.3)
lexical: 0.49.0(patch_hash=54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356)(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
@@ -25760,21 +25761,21 @@ snapshots:
'@lexical/extension': 0.49.0(typescript@6.0.3)
'@lexical/selection': 0.49.0(typescript@6.0.3)
'@lexical/utils': 0.49.0(typescript@6.0.3)
lexical: 0.49.0(typescript@6.0.3)
lexical: 0.49.0(patch_hash=54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356)(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
'@lexical/selection@0.49.0(typescript@6.0.3)':
dependencies:
'@lexical/internal': 0.49.0(typescript@6.0.3)
lexical: 0.49.0(typescript@6.0.3)
lexical: 0.49.0(patch_hash=54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356)(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
'@lexical/text@0.49.0(typescript@6.0.3)':
dependencies:
'@lexical/internal': 0.49.0(typescript@6.0.3)
lexical: 0.49.0(typescript@6.0.3)
lexical: 0.49.0(patch_hash=54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356)(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
@@ -25782,7 +25783,7 @@ snapshots:
dependencies:
'@lexical/internal': 0.49.0(typescript@6.0.3)
'@lexical/selection': 0.49.0(typescript@6.0.3)
lexical: 0.49.0(typescript@6.0.3)
lexical: 0.49.0(patch_hash=54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356)(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
@@ -29646,7 +29647,7 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
lexical@0.49.0(typescript@6.0.3):
lexical@0.49.0(patch_hash=54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356)(typescript@6.0.3):
dependencies:
'@lexical/internal': 0.49.0(typescript@6.0.3)
optionalDependencies:
+1
View File
@@ -116,4 +116,5 @@ patchedDependencies:
'@yao-pkg/pkg@6.21.0': patches/@yao-pkg__pkg@6.21.0.patch
app-builder-lib@26.17.0: patches/app-builder-lib@26.17.0.patch
exceljs@4.4.0: patches/exceljs@4.4.0.patch
lexical@0.49.0: patches/lexical@0.49.0.patch
node-pty@1.2.0-beta.15: patches/node-pty@1.2.0-beta.15.patch