Merge pull request #6664 from deepseek-harness/feat/mobile-input-viewport

fix(client): improve mobile input focus and caret scrolling
This commit is contained in:
Feiyu Xia authored and GitHub committed 2026-10-22 15:58:15 +08:00
commit 904a1caa2d
43 files changed
+845 -72

No files matched your search

+1
View File
@@ -160,6 +160,7 @@ pnpm applies local patches to the following packages at install time, so shipped
- `@yao-pkg/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
- `[email protected]` — [`patches/[email protected]`](patches/[email protected])
- `[email protected]` — [`patches/[email protected]`](patches/[email protected])
- `[email protected]` — [`patches/[email protected]`](patches/[email protected])
- `[email protected]` — [`patches/[email protected]`](patches/[email protected])
## Chrome DevTools frontend
@@ -16,6 +16,7 @@ export const DESKTOP_RUNTIME_PATCHES: Readonly<Record<string, DesktopRuntimePatc
'@yao-pkg/[email protected]': { scope: 'workspace-only', reason: 'The executable packer executes on the build host.' },
'[email protected]': { scope: 'workspace-only', reason: 'DMG format validation executes on the build host.' },
'[email protected]': { scope: 'workspace-only', reason: 'The patched parser is embedded in the client bundle and worker.' },
'[email protected]': { scope: 'workspace-only', reason: 'The patched editor is embedded in the client bundle.' },
'[email protected]': { scope: 'shared', reason: 'Runtime terminals need helper resolution and Windows worker cleanup.' },
}
@@ -43,8 +43,9 @@ it('classifies the current workspace and shares only runtime patch bytes', () =>
const before = readFileSync(join(repository, 'pnpm-lock.yaml'))
const plan = prepareRuntimePatches(project, repository)
expect(plan.patches.map(entry => entry.spec)).toEqual(['@earendil-works/[email protected]', '[email protected]'])
expect(plan.workspaceOnly).toHaveLength(7)
expect(plan.workspaceOnly).toHaveLength(8)
expect(plan.workspaceOnly).toContain('@botiverse/oar')
expect(plan.workspaceOnly).toContain('lexical')
const settings = yaml.load(readFileSync(join(project, 'pnpm-workspace.yaml'), 'utf8')) as { overrides: Record<string, string> }
expect(settings.overrides['@earendil-works/pi-ai']).toBeUndefined()
expect(settings.overrides['@earendil-works/pi-ai@^1.3.0']).toBe('1.3.0')
+140 -2
View File
@@ -1,7 +1,7 @@
/** Browser selection and focus after presses in the composer's unused space. */
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { chromium, type Page } from 'playwright'
import { chromium, webkit, type Page } from 'playwright'
import { expect, it, onTestFailed, onTestFinished } from 'vitest'
import {
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode,
@@ -39,6 +39,55 @@ 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`)
const input = document.querySelector<HTMLElement>('[data-composer-input]')!
window.scrollTo(0, input.getBoundingClientRect().top + scroll - window.innerHeight / 4)
// 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 })
window.dispatchEvent(new Event('scroll'))
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)
window.dispatchEvent(new Event('scroll'))
}
})
try {
const before = await input.evaluate(el => ({ top: el.getBoundingClientRect().top, scroll: window.scrollY }))
expect(before.scroll).toBeGreaterThan(0)
expect(before.top).toBeGreaterThanOrEqual(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 +162,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
}
@@ -145,7 +203,87 @@ it('restores the previous caret or selection in fresh and existing sessions', as
'# Composer whitespace focus', '', '## Fresh session', '', ...hero,
'', '## Existing session', '', ...docked,
].join('\n'), webSnapshotMode())
await assertFixtureInventory(SNAPSHOT_DIR, ['focus.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, ['focus.expected.md', 'submission.expected.md'])
if (webSnapshotMode() !== 'record') expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
}, 180_000)
it('keeps mobile submission keyboard navigation in the composer while retaining desktop focus', async () => {
const observations: string[] = []
for (const { engine, mobile, submission } of [
{ engine: chromium, mobile: false, submission: 'Send' },
{ engine: chromium, mobile: true, submission: 'Send' },
{ engine: chromium, mobile: true, submission: 'Enter' },
{ engine: webkit, mobile: true, submission: 'Send' },
{ engine: webkit, mobile: true, submission: 'Enter' },
]) {
const scaffold = await launchWebScaffold({
replayFixture: fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v3.jsonl', import.meta.url)),
compareReplaySession: false,
})
try {
const browser = await engine.launch()
try {
const page = await browser.newPage({
viewport: { width: mobile ? 390 : 1280, height: 844 },
locale: 'en-US', timezoneId: 'Asia/Shanghai',
...mobile ? {
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1',
isMobile: true, hasTouch: true,
} : {},
})
const tripwire = watchConsole(page)
const platform = `${engine.name()} ${mobile ? 'mobile' : 'desktop'} ${submission}`
let finished = false
const off = scaffold.ctx.on('session/event', (_session, event) => {
if (event.type === 'turn/end') finished = true
})
try {
await page.goto(scaffold.authenticatedUrl)
await connectFreshWorkspace(page, scaffold.workspaceCwd, `submission-${platform}`)
const input = page.locator('[data-composer-input][contenteditable="true"]')
await input.fill('Reply with a one-sentence description of event sourcing, then stop.')
expect(await input.evaluate(element => document.activeElement === element)).toBe(true)
if (submission === 'Enter') await page.keyboard.press('Enter')
else {
const send = page.getByRole('button', { name: 'Send message', exact: true })
if (mobile) await send.tap()
else await send.click()
}
await expect.poll(() => input.textContent()).toBe('')
await expect.poll(() => input.evaluate(element => document.activeElement === element)).toBe(!mobile)
await expect.poll(() => finished, { timeout: 60_000 }).toBe(true)
expect(await input.evaluate(element => document.activeElement === element)).toBe(!mobile)
if (mobile) {
const card = page.locator('[data-composer-card]')
expect(await card.evaluate(element => document.activeElement === element)).toBe(true)
// Option+Tab includes buttons in WebKit's keyboard navigation mode.
const next = engine === webkit ? 'Alt+Tab' : 'Tab'
await page.keyboard.press(next)
expect(await input.evaluate(element => document.activeElement === element)).toBe(true)
await page.keyboard.press(next)
const attachment = page.getByRole('button', { name: 'Add files or run commands', exact: true })
expect(await attachment.evaluate(element => document.activeElement === element)).toBe(true)
observations.push(`- ${platform}: clears the draft; focus remains on the composer card through response completion, then advances to the editor and attachment button`)
} else {
observations.push(`- ${platform}: clears the draft; editor stays focused through response completion`)
}
expect(tripwire.pageErrors).toEqual([])
if (webSnapshotMode() !== 'record') expect(tripwire.warnings).toEqual([])
} catch (error) {
await saveFailureShot(page, `web-e2e-composer-submission-${platform}`)
throw error
} finally {
off()
await page.close()
}
} finally {
await browser.close()
}
} finally {
await scaffold.close()
}
}
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'submission.expected.md'), observations.join('\n'), webSnapshotMode())
await assertFixtureInventory(SNAPSHOT_DIR, ['focus.expected.md', 'submission.expected.md'])
}, 180_000)
+93
View File
@@ -199,6 +199,99 @@ describe('web e2e: the composer model switch is the default for later sessions',
}
})
it.each([
{ device: 'iPhone', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1', automatic: false },
{ device: 'Android Chrome', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/119.0.0.0 Mobile Safari/537.36', automatic: false },
{ device: 'iPhone Chrome', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 CriOS/139.0.0.0 Mobile/15E148 Safari/604.1', automatic: false },
{ device: 'narrow desktop', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', automatic: true },
])('respects $device composer sizing and search focus when opening /model', async ({ userAgent, automatic }) => {
const context = await browser.newContext({
userAgent, viewport: { width: 390, height: 844 }, locale: ZH_BROWSER_LOCALE, hasTouch: !automatic,
})
try {
const devicePage = await context.newPage()
const consoleWatch = watchConsole(devicePage)
await devicePage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await devicePage.getByRole('textbox', { name: '选择工作区' }).click()
const composer = devicePage.locator('[data-composer-input][contenteditable="true"]').first()
await composer.waitFor()
expect(await composer.evaluate(element => getComputedStyle(element).fontSize)).toBe(automatic ? '14px' : '16px')
await composer.fill('/model')
await devicePage.getByRole('option', { name: /^模型/ }).click()
const search = devicePage.getByRole('textbox', { name: '筛选选项', exact: true })
await search.waitFor()
await devicePage.getByRole('option', { name: /Origin Large/ }).waitFor()
expect(await search.evaluate(input => document.activeElement === input)).toBe(automatic)
await search.click()
expect(await search.evaluate(input => document.activeElement === input)).toBe(true)
await search.fill('Origin')
await devicePage.getByRole('option', { name: /Origin Large/ }).waitFor()
expect(await devicePage.getByRole('option', { name: /Acme/ }).count()).toBe(0)
await search.press('Escape')
await composer.fill('')
expect(consoleWatch.pageErrors).toEqual([])
} finally {
await context.close()
}
})
it.each([
{ device: 'iPhone', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1', platform: 'iPhone' },
{ device: 'desktop-mode iPad', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel' },
])('retains model menu keyboard focus on $device', async ({ userAgent, platform }) => {
const context = await browser.newContext({
userAgent, viewport: { width: 390, height: 844 }, locale: ZH_BROWSER_LOCALE, hasTouch: true,
})
try {
await context.addInitScript(({ platform }) => {
Object.defineProperty(navigator, 'platform', { configurable: true, value: platform })
Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: 5 })
}, { platform })
const devicePage = await context.newPage()
const consoleWatch = watchConsole(devicePage)
await devicePage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await devicePage.getByRole('textbox', { name: '选择工作区' }).click()
const trigger = devicePage.getByRole('button', { name: /^选择模型/ })
const observations: string[] = []
const observeFocus = async (action: string): Promise<void> => {
const focused = await devicePage.evaluate(() => {
const element = document.activeElement!
const role = element.getAttribute('role') ?? element.tagName.toLowerCase()
const label = element.getAttribute('aria-label') ?? element.textContent?.replace(/\s+/g, ' ').trim()
return `${role}: ${label}`
})
observations.push(`${action}: ${focused}`)
}
await trigger.click()
await expect.poll(() => trigger.evaluate(element => document.activeElement === element)).toBe(true)
await observeFocus('Open')
await devicePage.keyboard.press('ArrowDown')
await devicePage.keyboard.press('Enter')
const model = devicePage.getByRole('menuitemradio', { name: 'Origin Large', exact: true })
await expect.poll(() => model.evaluate(element => document.activeElement === element)).toBe(true)
await observeFocus('Enter model pane')
await devicePage.keyboard.press('ArrowDown')
const search = devicePage.getByRole('searchbox', { name: '搜索模型…' })
await expect.poll(() => search.evaluate(element => document.activeElement === element)).toBe(true)
await observeFocus('Navigate models')
await devicePage.keyboard.press('Escape')
const modelCell = devicePage.getByRole('menuitem', { name: /^模型/ })
await expect.poll(() => modelCell.evaluate(element => document.activeElement === element)).toBe(true)
await observeFocus('Return to root')
await devicePage.keyboard.press('Escape')
await expect.poll(() => trigger.evaluate(element => document.activeElement === element)).toBe(true)
await observeFocus('Close')
expect(await trigger.getAttribute('aria-expanded')).toBe('false')
expect(consoleWatch.pageErrors).toEqual([])
await compareOrRefreshGolden(
fileURLToPath(new URL('./expected/default-model/mobile-keyboard.expected.md', import.meta.url)),
observations.join('\n'), webSnapshotMode(),
)
} finally {
await context.close()
}
})
it('shares provider order, fuzzy result order and sticky header material between both model pickers', async () => {
const readGroups = (surface: Locator, role: 'option' | 'menuitemradio') => surface.locator('[data-menu-group]')
.evaluateAll((groups, rowRole) => groups.map(group => ({
@@ -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
@@ -0,0 +1,5 @@
- chromium desktop Send: clears the draft; editor stays focused through response completion
- chromium mobile Send: clears the draft; focus remains on the composer card through response completion, then advances to the editor and attachment button
- chromium mobile Enter: clears the draft; focus remains on the composer card through response completion, then advances to the editor and attachment button
- webkit mobile Send: clears the draft; focus remains on the composer card through response completion, then advances to the editor and attachment button
- webkit mobile Enter: clears the draft; focus remains on the composer card through response completion, then advances to the editor and attachment button
@@ -0,0 +1,5 @@
Open: button: 选择模型,当前 Origin Large
Enter model pane: menuitemradio: Origin Large
Navigate models: searchbox: 搜索模型…
Return to root: menuitem: 模型Origin Large
Close: button: 选择模型,当前 Origin Large
+2 -2
View File
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-commands/use-this-package:
en: 01adce72791579fa
zh: 70eb55a1529038e7
en: c325c9eaca4efe47
zh: dbbc29cc9cec54bc
/deepseek-ai-dsh-client-ui-commands/use-this-package/kinds-and-decorations:
en: 57679a20574d8b3f
zh: 98d5e5a7781e6de1
+1 -1
View File
@@ -25,7 +25,7 @@ Typing a `/` command opens a registered popup, a client action, a host command's
<a id="use-this-package"></a>
## Use this package
Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` source then appears in the trigger menu, and business packages register their command surfaces through `ctx.commandUi`. Typing `/model` opens the registered popup; a host command with an argument claim opens its input or executes directly. The popup holds composer focus: typing filters the loaded rows locally, `↑`/`↓` walk them, Enter and `Tab` accept the highlighted row, and Escape and `Shift+Tab` return to the composer. The highlight opens on the row the options mark as the session's current value, so accepting on a freshly opened panel confirms it. The composer's `+` button and a typed `/` open the same menu: an Add section (File, Goal, Plan, Feedback) and a Commands section (Compact, Permission, Model, Export) in usage order, each row with a glyph, a localized title and description, and the command name as an alias where the localized title differs from it.
Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` source then appears in the trigger menu, and business packages register their command surfaces through `ctx.commandUi`. Typing `/model` opens the registered popup; a host command with an argument claim opens its input or executes directly. The popup automatically focuses its search input on desktop. Phones and tablets require tapping the search input, following the shared [text-input focus policy](../ui-primitives/README.md#use-this-package). With search focused, typing filters the loaded rows locally, `↑`/`↓` walk them, Enter and `Tab` accept the highlighted row, and Escape and `Shift+Tab` return to the composer. The highlight opens on the row the options mark as the session's current value, so accepting on a freshly opened panel confirms it. The composer's `+` button and a typed `/` open the same menu: an Add section (File, Goal, Plan, Feedback) and a Commands section (Compact, Permission, Model, Export) in usage order, each row with a glyph, a localized title and description, and the command name as an alias where the localized title differs from it.
### Kinds and decorations
+1 -1
View File
@@ -25,7 +25,7 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
与 `ui-input-trigger` 及 `ui-conversation` 一起挂载本插件;`/` source 随即出现在触发菜单中,业务包经 `ctx.commandUi` 注册自己的命令表面。键入 `/model` 打开已注册的弹窗;带参数声明的宿主命令打开其输入或直接执行。弹窗持有 composer 焦点:键入即在已加载的行上本地筛选,`↑`/`↓` 在行间移动,回车与 `Tab` 接受高亮行,Escape 与 `Shift+Tab` 把焦点还给 composer。高亮落在选项标记为会话当前值的行上,因此在刚打开的弹窗上接受即确认当前值。composer 的 `+` 按钮与键入的 `/` 打开同一个菜单:「添加」小节(文件、目标、计划、反馈)与「指令」小节(压缩、权限、模型、下载日志)按使用频次排列,每行带图标、本地化的标题与说明,本地化标题与命令名不同时还显示命令名作为别名。
与 `ui-input-trigger` 及 `ui-conversation` 一起挂载本插件;`/` source 随即出现在触发菜单中,业务包经 `ctx.commandUi` 注册自己的命令表面。键入 `/model` 打开已注册的弹窗;带参数声明的宿主命令打开其输入或直接执行。桌面端的弹窗自动聚焦搜索框。手机和平板需要点击搜索框,遵循共享的[文字输入框聚焦策略](../ui-primitives/README.zh.md#use-this-package)。搜索框聚焦后,键入即在已加载的行上本地筛选,`↑`/`↓` 在行间移动,回车与 `Tab` 接受高亮行,Escape 与 `Shift+Tab` 把焦点还给 composer。高亮落在选项标记为会话当前值的行上,因此在刚打开的弹窗上接受即确认当前值。composer 的 `+` 按钮与键入的 `/` 打开同一个菜单:「添加」小节(文件、目标、计划、反馈)与「指令」小节(压缩、权限、模型、下载日志)按使用频次排列,每行带图标、本地化的标题与说明,本地化标题与命令名不同时还显示命令名作为别名。
### 种类与装饰
@@ -1,9 +1,10 @@
/**
* Official popupSelect shell: renders one session's PopupSelectController
* store into the conversation.input.overlay anchor. Unlike the slash menu
* (combobox — textarea keeps focus), this shell HOLDS focus while open: the
* inner search input takes focus, plain typing filters the loaded options
* locally, Enter and Tab accept the filtered highlight, ↑↓ walk it (wrapping,
* (combobox — textarea keeps focus), this shell focuses its search input on
* desktop; phones and tablets require explicit search focus. Typing filters
* the loaded options locally, Enter and Tab accept the filtered highlight,
* ↑↓ walk it (wrapping,
* scrolled into view), and Escape and Shift+Tab dismiss back to the composer.
* ←→ keep the search input's native caret. Any pointer interaction outside the
* box dismisses (the click's own target takes focus). Closed state renders
@@ -14,7 +15,7 @@ import { MenuGroup, MenuSurface, observeStickyMenuGroups } from '@deepseek-ai/ds
import { Fragment, useEffect, useMemo, useRef } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { IconCheckOutlineRegular, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconCheckOutlineRegular, isMobileDevice, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import { filterOptions } from './popup.ts'
import type { SelectOption } from './contract.ts'
@@ -67,8 +68,7 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) {
cardRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' })
}, [active, rows, state.confirming])
// Focus ownership: the search input grabs on open, and ANY outside
// pointer interaction dismisses —
// Any outside pointer interaction dismisses —
// capture phase so a click landing anywhere else (textarea included)
// closes the shell before its own handlers run; that click's target then
// takes focus naturally, so no focusComposer here.
@@ -84,7 +84,9 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) {
// Focus the search input after it mounts (separate effect so the ref is populated).
useEffect(() => {
if (state.open && state.confirming === null) searchRef.current?.focus()
if (!state.open || state.confirming !== null) return
if (isMobileDevice()) return
searchRef.current?.focus()
}, [state.open, state.confirming])
if (!state.open) return null
@@ -1,7 +1,8 @@
// @vitest-environment jsdom
/**
* PopupSelectView interaction spec: the search input takes
* focus on open and plain typing filters locally, ↑↓ move the filtered
* focus on desktop open; mobile search requires explicit focus. Typing filters
* locally, ↑↓ move the filtered
* highlight while ←→ stay native to the input, Enter and Tab select
* single-flight, Escape and Shift+Tab dismiss back through focusComposer,
* outside pointerdown dismisses
@@ -9,7 +10,8 @@
* retry button, the highlighted row scrolls into view, and the card height
* clamps to the space above the composer.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import * as uiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { SelectOption } from '../src/client/contract.ts'
import type { PopupSpec, TokenSegment } from '../src/client/popup.ts'
@@ -140,6 +142,40 @@ describe('PopupSelectView', () => {
expect(rowLabels()).toEqual(['Dark', 'Light', 'Sepia'])
})
it.each([
{ name: 'iPhone', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1', platform: 'iPhone', maxTouchPoints: 5, automatic: false },
{ name: 'Android phone', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/119.0.0.0 Mobile Safari/537.36', platform: 'Linux armv8l', maxTouchPoints: 0, automatic: false },
{ name: 'Android tablet', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/119.0.0.0 Safari/537.36', platform: 'Linux armv8l', maxTouchPoints: 5, automatic: false },
{ name: 'desktop-mode iPad', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel', maxTouchPoints: 5, automatic: false },
{ name: 'Mac', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel', maxTouchPoints: 0, automatic: true },
{ name: 'Windows touchscreen', userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/119.0.0.0 Safari/537.36', platform: 'Win32', maxTouchPoints: 5, automatic: true },
])('automatic search focus respects device classification: $name', async ({ userAgent, platform, maxTouchPoints, automatic }) => {
for (const [name, value] of Object.entries({ userAgent, platform, maxTouchPoints })) {
const descriptor = Object.getOwnPropertyDescriptor(navigator, name)
Object.defineProperty(navigator, name, { configurable: true, value })
onTestFinished(() => {
if (descriptor === undefined) Reflect.deleteProperty(navigator, name)
else Object.defineProperty(navigator, name, descriptor)
})
}
vi.resetModules()
const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts')
const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice)
onTestFinished(() => { device.mockRestore() })
const { popup, search } = await mountOpen()
expect(document.activeElement === search).toBe(automatic)
act(() => { search.focus() })
fireEvent.change(search, { target: { value: 'dark' } })
expect(document.activeElement).toBe(search)
expect(rowLabels()).toEqual(['Dark'])
act(() => { popup.dismiss() })
await act(async () => {
popup.open('theme', spec(), 'ctx-A', SEGMENT)
await Promise.resolve()
})
expect(document.activeElement === screen.getByRole('textbox', { name: '筛选选项' })).toBe(automatic)
})
it('renders an optional option badge as a superscript marker', async () => {
await mountOpen({
options: () => Promise.resolve([{ id: 'auto', label: 'Auto review', badge: 'EXP' }]),
@@ -21,8 +21,8 @@
en: 7871f9fe6bcc4459
zh: 0d51565c600b3958
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
en: 1d70d520d4a7537c
zh: 83febffaf90e0ac5
en: 6ceacacf8770eec9
zh: 4864c23171b99a82
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
en: bb1244310f5c0382
zh: 824b84077432b559
@@ -70,10 +70,14 @@ The existing semantic `DraftSnapshot` retains quote text and clipboard-coordinat
The shared image slot props keep display choices separate from durable references: `thumbnail` requests a contained attachment-list thumbnail, while `compact` requests a cropped gallery tile. An optional per-image `label` supplies the accessible display name; loading and cache identity still use the original attachment reference. `MessageImagesContentInput` adds optional complete `MessageImageLabels` for the root-scoped `conversation.images.content` Factory. Message, tool, and Factory inputs carry the view's authorized `loadImage` callback explicitly; loading and URL caching remain with the resource owner. [ui-attachment](../ui-attachment/README.md) provides this Factory and owns rendering and the lightbox.
The composer uses a minimum 16px draft font on coarse-pointer devices to avoid focus-triggered browser zoom; larger font preferences still apply.
The composer measures its expanded control groups after size, content, visibility, or font-loading changes. If they cannot share a line, the row sets `--dsh-composer-model-text-display: none` and `--dsh-composer-model-icon-display: block` for the model seat; their defaults are `block` and `none`. Wrapping remains available if even the icon cannot fit.
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. On these mobile devices, a submission that empties the focused draft moves focus to the non-text composer card, dismissing the keyboard while retaining its navigation position. Rejected submissions, retained newer draft text, and another control already holding focus preserve focus. The [Lexical patch](../../../patches/[email protected]) 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.
With Coding Tools off, the shell hides only Trajectory; other plugin-contributed Views remain available. The View tab bar is hidden when fewer than two Views are available. Turning Coding Tools off while Trajectory is active returns to Chat; stored View preferences and recorded Session data remain intact. Turning it on makes Trajectory available again. View owners receive the available roster so their navigation actions can follow the same visibility.
@@ -70,10 +70,14 @@ Queue 预览启用空白折叠和文档边缘 trim,保留 emoji,并使用 CS
共享图片插槽属性将展示选择与持久化引用分开:`thumbnail` 请求完整缩放的附件列表缩略图,`compact` 请求裁剪的图片方块。每张图片可通过可选的 `label` 提供无障碍展示名称;加载和缓存标识仍使用原始附件引用。`MessageImagesContentInput` 为 root 作用域的 `conversation.images.content` Factory 增加可选的完整 `MessageImageLabels`。消息、工具及 Factory 输入显式携带视图提供的授权 `loadImage` 回调;读取和 URL 缓存仍由资源所有者负责。[ui-attachment](../ui-attachment/README.zh.md) 提供该 Factory,负责渲染与灯箱。
在粗指针设备上,composer 的草稿字号至少为 16px,以避免获得焦点时浏览器自动放大;更大的字号偏好仍然生效。
控件组的尺寸、内容、可见性或字体加载状态变化后,composer 测量展开状态下的控件组。若无法排在同一行,控制栏为模型位设置 `--dsh-composer-model-text-display: none` 和 `--dsh-composer-model-icon-display: block`;两者默认值分别为 `block` 和 `none`。若连图标也放不下,仍允许换行。
上下文占用率入口遵循[统计控件圆角规则](../../../docs/ui-radius.zh.md#radius-scale)。
composer 在挂载、切换 Session 和解除锁定时使用共享的[文字输入框聚焦策略](../ui-primitives/README.zh.md#use-this-package)。识别出的手机和平板需要点击输入框;桌面设备保留自动聚焦。在这些移动设备上,提交清空正在聚焦的草稿后,焦点移到不接受文字输入的 composer 卡片,收起键盘并保留键盘导航位置。被拒绝的提交、保留的新草稿文字,以及已经聚焦的其他控件均保留原有焦点。移动端键盘平移页面时,[Lexical 补丁](../../../patches/[email protected])让光标滚动计算与 DOM 几何使用同一坐标系。
上下文占用按钮在输入卡片下方、会话统计右侧显示圆环和百分比。点击按钮可在视口内的面板查看 token 构成,没有统计项时面板也不会越界;上下文用量和容量尚不可用时,按钮保持隐藏。
关闭代码工作工具时,外壳仅隐藏 Trajectory;其他插件贡献的 View 仍然可用。可用 View 少于两个时隐藏 View 标签栏。在 Trajectory 激活时关闭代码工作工具会返回 Chat;已保存的 View 偏好和 Session 记录保持不变。开启后,Trajectory 恢复可用。View 所有者接收可用列表,使导航操作跟随相同的可见性。
@@ -62,6 +62,9 @@ function $appendParagraphs(text: string): void {
/** Undo merge window for contiguous typing, in ms (the old machine's mergeWindowMs). */
const HISTORY_MERGE_DELAY_MS = 1000
/** Editor update that removes submitted content while retaining any newer draft suffix. */
export const DRAFT_SUBMITTED_TAG = 'dsh-draft-submitted'
/** One model-owned editor; registration and disposal remain with its model. */
export class DraftEditorRuntime {
/** The editor bound by the Composer's contenteditable host. */
@@ -412,7 +415,7 @@ export class DraftEditorRuntime {
const root = $getRoot()
root.clear()
root.selectEnd()
}, { discrete: true, tag: HISTORY_MERGE_TAG })
}, { discrete: true, tag: [HISTORY_MERGE_TAG, DRAFT_SUBMITTED_TAG] })
}
/**
@@ -1,10 +1,31 @@
/** DOM and keymap bindings installed by the InputBar's existing effects. */
import type { MouseEvent, MutableRefObject, RefObject } from 'react'
import type { LexicalEditor } from 'lexical'
import { $getRoot } from 'lexical'
import { focusWithoutRing, isMobileDevice } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ComposerKeyboard } from '../../contract/draft-editor.ts'
import type { BusyEnterBehavior } from '../../contract/composer-submission.ts'
import { resolveSubmitMode } from '../submission-policy.ts'
import { registerComposerKeymap } from './keymap.ts'
import { DRAFT_SUBMITTED_TAG } from './runtime.ts'
/**
* Transfer mobile editor focus after a submission empties the draft.
* A control the user already focused retains focus.
* @param editor - the Session-owned editor.
* @param destination - stable, focusable non-text element that retains the composer's keyboard position.
* @returns the update-listener cleanup.
*/
export function installDraftKeyboardDismissal(editor: LexicalEditor, destination: RefObject<HTMLElement>): () => void {
return editor.registerUpdateListener(({ editorState, tags }) => {
if (!tags.has(DRAFT_SUBMITTED_TAG) || !isMobileDevice()) return
const input = editor.getRootElement()
if (input === null || input.ownerDocument.activeElement !== input) return
if (editorState.read(() => $getRoot().getTextContentSize() === 0) && destination.current !== null) {
focusWithoutRing(destination.current, { preventScroll: true })
}
})
}
interface DraftViewGate {
locked: boolean
@@ -138,12 +138,13 @@ export function HeroShell({ t, renderSlot, children }: HeroShellProps) {
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */}
<span
className={css.fishHitbox}
onMouseEnter={() => {
if (window.matchMedia('(hover: hover) and (prefers-reduced-motion: no-preference)').matches) {
onPointerEnter={(event) => {
if (event.pointerType !== 'touch'
&& window.matchMedia('(hover: hover) and (prefers-reduced-motion: no-preference)').matches) {
setHovering(true)
}
}}
onMouseLeave={() => { setHovering(false) }}
onPointerLeave={() => { setHovering(false) }}
>
{renderSlot('conversation.hero.brand.mark', { size: 34, className: css.fish }, {
fallback: <HeroFish hovering={hovering} />,
@@ -76,6 +76,12 @@
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
@media (pointer: coarse) {
.card {
font-size: max(16px, var(--dsh-content-font-size, 14px));
}
}
/* No-workspace trigger state: an inset 1px dashed outline marks the card as a
workspace picker rather than a live composer. Keeping the complete stroke
inside the rounded box prevents clipped corner pixels from reading heavier. */
@@ -17,7 +17,7 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat
import type { ChangeEvent, KeyboardEvent, MouseEvent } from 'react'
import clsx from 'clsx'
import {
IconPlusOutlineMedium, IconWarningOutlineRegular, Toast, Tooltip,
IconPlusOutlineMedium, IconWarningOutlineRegular, isMobileDevice, Toast, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
// Type-only: the `plan` projection key merge (the TodoDock posture — the
// composer reads a host-computed value; the domain owns the key).
@@ -31,7 +31,7 @@ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
import type { ComposerBarProps } from '../contract/slots.ts'
import { DraftEditor } from '../input/editor/DraftEditor.tsx'
import {
focusDraftEditor, installDraftFilePicker, installDraftKeymap, installDraftWheel,
focusDraftEditor, installDraftFilePicker, installDraftKeyboardDismissal, installDraftKeymap, installDraftWheel,
keepDraftFocus, revealDraftSelection,
} from '../input/editor/view-binding.ts'
import { resolveSubmitMode } from '../input/submission-policy.ts'
@@ -178,13 +178,11 @@ export const InputBar = memo(function InputBar({
revealDraftSelection(scrollRef)
}
// Unlock (mount / session switch) returns focus to the box, and owns the
// reveal that comes with it. Lexical's focus() suppresses the browser's
// scroll walk (preventScroll inside), so the reveal in our own scrollport
// is ours to perform — switching to a longer draft otherwise leaves the
// caret (restored at the draft's end) off screen.
// Automatic desktop focus restores and reveals the draft selection without
// scrolling the conversation.
useEffect(() => {
if (locked || editor === null) return
if (isMobileDevice()) return
focusDraftEditor(editor, revealSelection)
}, [locked, sessionId, editor])
@@ -275,6 +273,11 @@ export const InputBar = memo(function InputBar({
return installDraftKeymap(editor, keyboard, gate)
}, [editor, keyboard])
useEffect(() => {
if (editor === null) return
return installDraftKeyboardDismissal(editor, cardRef)
}, [editor])
// Button presses steal focus from the editor; suppress at mousedown so
// typing continues seamlessly. Lexical's focus() carries preventScroll and
// restores the previous selection, so no reveal is needed: the caret has
@@ -290,7 +293,7 @@ export const InputBar = memo(function InputBar({
if (target.closest('[data-composer-overlay]') !== null) return
if (scrollRef.current?.contains(target)) return
const control = target.closest('button, a, input, select, textarea, [role="button"], [contenteditable], [tabindex]')
if (control !== null && e.currentTarget.contains(control)) return
if (control !== null && control !== e.currentTarget && e.currentTarget.contains(control)) return
e.preventDefault()
// Lexical's stored selection can lag behind native caret moves, so calling
// editor.focus() on a focused editor can restore an older caret.
@@ -400,6 +403,7 @@ export const InputBar = memo(function InputBar({
click's reopen (close-then-open flickers the chip's open echo). */}
<div
ref={cardRef}
tabIndex={-1}
className={clsx(css.card, workspaceTrigger && css.cardWorkspaceTrigger)}
data-composer-card
onClick={workspaceTrigger ? onRequestWorkspace : undefined}
@@ -1,9 +1,10 @@
// @vitest-environment jsdom
/** Composer selection survives focus transfers and background editor reconciliation. */
import * as uiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
import { expect, it, onTestFinished, vi } from 'vitest'
import { $getRoot, $getSelection, $isRangeSelection, SELECTION_CHANGE_COMMAND, UNDO_COMMAND } from 'lexical'
import { DraftEditorRuntime } from '../src/client/input/editor/runtime.ts'
import { focusDraftEditor } from '../src/client/input/editor/view-binding.ts'
import { focusDraftEditor, installDraftKeyboardDismissal } from '../src/client/input/editor/view-binding.ts'
function fixture(start = 1, end = 4) {
// jsdom has no Range geometry; these cases observe selection and focus, not scrolling.
@@ -16,9 +17,12 @@ function fixture(start = 1, end = 4) {
const root = document.createElement('div')
root.setAttribute('contenteditable', 'true')
root.tabIndex = 0
const card = document.createElement('div')
card.tabIndex = -1
card.append(root)
const outside = document.createElement('button')
document.body.append(root, outside)
onTestFinished(() => { root.remove(); outside.remove() })
document.body.append(card, outside)
onTestFinished(() => { card.remove(); outside.remove() })
// jsdom collapses document Selection on every focus(); Chromium retains the
// editor range here. Keep native focus events and activeElement changes.
for (const element of [root, outside]) {
@@ -66,7 +70,7 @@ function fixture(start = 1, end = 4) {
editor.read(() => {})
return reveal
}
return { runtime, editor, root, outside, onUpdate, dispose, selection, domSelection, focusComposer }
return { runtime, editor, root, card, outside, onUpdate, dispose, selection, domSelection, focusComposer }
}
it.each([
@@ -138,6 +142,48 @@ it('restores a pending selection when blur and explicit focus share one update b
expect(reveal).toHaveBeenCalledOnce()
})
it('hands mobile submission focus to the composer without stealing focus from another control', async () => {
const userAgent = vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1')
onTestFinished(() => { userAgent.mockRestore() })
vi.resetModules()
const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts')
const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice)
onTestFinished(() => { device.mockRestore() })
const f = fixture()
const dispose = installDraftKeyboardDismissal(f.editor, { current: f.card })
onTestFinished(dispose)
f.runtime.clearCommittedDraft(() => null)
expect(f.root.textContent).toBe('')
expect(document.activeElement).toBe(f.card)
f.runtime.setDraft('next draft')
f.outside.focus()
f.runtime.clearCommittedDraft(() => null)
expect(document.activeElement).toBe(f.outside)
})
it('retains mobile focus for a newer draft suffix and removes dismissal with the view', async () => {
const userAgent = vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1')
onTestFinished(() => { userAgent.mockRestore() })
vi.resetModules()
const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts')
const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice)
onTestFinished(() => { device.mockRestore() })
const f = fixture()
const dispose = installDraftKeyboardDismissal(f.editor, { current: f.card })
onTestFinished(dispose)
f.runtime.clearCommittedDraft(() => 6)
expect(f.root.textContent).toBe('beta')
expect(document.activeElement).toBe(f.root)
dispose()
f.runtime.clearCommittedDraft(() => null)
expect(f.root.textContent).toBe('')
expect(document.activeElement).toBe(f.root)
})
it('removes focus preservation and update subscriptions when registration is disposed', () => {
const f = fixture()
f.dispose()
@@ -0,0 +1,73 @@
// @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() },
quoteLabels: () => ({ remove: 'Remove quote' }),
openReference: () => false,
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)
})
@@ -8,6 +8,7 @@
// root listener routes them through the keymap commands); draft writes drive
// the shell (jsdom's beforeinput lacks the ranges Lexical needs).
import * as uiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
import './control-row-dom.ts'
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
@@ -1468,6 +1469,81 @@ describe('running and lock semantics', () => {
expect(focused).toEqual([true])
})
it.each([
{ name: 'iPhone', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1', platform: 'iPhone', maxTouchPoints: 5, automatic: false },
{ name: 'Android phone', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/119.0.0.0 Mobile Safari/537.36', platform: 'Linux armv8l', maxTouchPoints: 0, automatic: false },
{ name: 'Android tablet', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/119.0.0.0 Safari/537.36', platform: 'Linux armv8l', maxTouchPoints: 5, automatic: false },
{ name: 'desktop-mode iPad', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel', maxTouchPoints: 5, automatic: false },
{ name: 'Mac', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel', maxTouchPoints: 0, automatic: true },
{ name: 'Windows touchscreen', userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/119.0.0.0 Safari/537.36', platform: 'Win32', maxTouchPoints: 5, automatic: true },
])('composer focus and submission respect device classification: $name', async ({ userAgent, platform, maxTouchPoints, automatic }) => {
for (const [name, value] of Object.entries({ userAgent, platform, maxTouchPoints })) {
const descriptor = Object.getOwnPropertyDescriptor(navigator, name)
Object.defineProperty(navigator, name, { configurable: true, value })
onTestFinished(() => {
if (descriptor === undefined) Reflect.deleteProperty(navigator, name)
else Object.defineProperty(navigator, name, descriptor)
})
}
vi.resetModules()
const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts')
const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice)
onTestFinished(() => { device.mockRestore() })
const outside = document.createElement('input')
document.body.append(outside)
onTestFinished(() => { outside.remove() })
outside.focus()
const { view, textarea, props, shell, button, sink } = bench({ draft: 'draft' })
expect(document.activeElement).toBe(automatic ? textarea : outside)
act(() => { outside.focus() })
act(() => { view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />) })
expect(document.activeElement).toBe(automatic ? textarea : outside)
act(() => { outside.focus() })
act(() => { view.rerender(<InputBar {...props} disabled />) })
act(() => { view.rerender(<InputBar {...props} />) })
expect(document.activeElement).toBe(automatic ? textarea : outside)
act(() => { outside.focus() })
fireEvent.mouseDown(view.container.querySelector('[data-composer-card]')!, { button: 0 })
expect(document.activeElement).toBe(textarea)
writeDraft(shell, '')
expect(document.activeElement).toBe(textarea)
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).not.toHaveBeenCalled()
expect(document.activeElement).toBe(textarea)
for (const gesture of ['click', 'enter'] as const) {
writeDraft(shell, `send by ${gesture}`)
act(() => { textarea.focus() })
if (gesture === 'click') {
fireEvent.mouseDown(button, { button: 0 })
fireEvent.click(button)
} else {
fireEvent.keyDown(textarea, { key: 'Enter' })
}
await act(async () => {})
expect(sink).toHaveBeenLastCalledWith(`send by ${gesture}`, [], 'queue', expect.any(AbortSignal))
expect(shell.snapshot.draft).toBe('')
expect(document.activeElement === textarea).toBe(automatic)
}
writeDraft(shell, '/goal ')
act(() => {
shell.beginCommand(
{ name: 'goal', token: '/goal ', submit: () => Promise.resolve({ kind: 'error', text: 'Missing goal' }) },
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
)
textarea.focus()
})
fireEvent.keyDown(textarea, { key: 'Enter' })
await act(async () => {})
expect(shell.snapshot.phase).toBe('claimed')
expect(shell.snapshot.draft).toBe('/goal ')
expect(document.activeElement).toBe(textarea)
})
it('a persisted draft adopted after mount does not steal focus from another control', () => {
const { shell } = bench()
const other = document.createElement('input')
@@ -15,11 +15,11 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-model-selection/use-this-package:
en: ef50e68e7493d0a9
zh: 3b1faea17b606173
en: 9b1eca0aec81ac29
zh: 9a1599c3abf76513
/deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-and-effort:
en: 48f8157ffdfe6e69
zh: 64ec9a533e2aa8f4
en: 2e6d9965e01dc0ce
zh: 332cc82233eb02f3
/deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-label-extensions:
en: bc47871cf8b61451
zh: 17d21da46604f071
+2 -2
View File
@@ -31,13 +31,13 @@ DeepSeek account and API-key routes appear as separate provider groups, each exp
When no model is selected or the saved selection is absent from the catalog, the composer shows “Select model” in regular weight and hides the reasoning effort caption. Clicking it opens the model list directly; Escape and Shift+Tab close it. An empty catalog displays “No models available.”
Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus through root and effort rows, or move the model-list highlight without leaving search. Enter and Tab settle the highlighted model or focused row; Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling focuses model search when shown, otherwise the current model or effort row, and going back lands on the cell that opened the pane left. The composer shows the catalog name while the selected model is available. Removing a model or provider, including account sign-out, preserves the stored provider, model, and reasoning effort without displaying their IDs. Restoring the catalog entry restores its name and effort caption.
Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus through root and effort rows, or move the model-list highlight without leaving search. Enter and Tab settle the highlighted model or focused row; Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling focuses model search when shown on desktop devices; otherwise it focuses the current model or effort row, or the first enabled row when none is selected. Going back focuses the cell that opened the pane. Phones and tablets leave search unfocused until the user taps it or uses keyboard navigation. The composer shows the catalog name while the selected model is available. Removing a model or provider, including account sign-out, preserves the stored provider, model, and reasoning effort without displaying their IDs. Restoring the catalog entry restores its name and effort caption.
Mouse selection uses native browser clicks, including their cancellation behavior; a press alone never selects. Opening the root menu focuses its trigger, and clicking the trigger again closes the menu and returns focus there. Root rows and the search-clear button show the same fill for keyboard focus and hover, without a native outline. Selecting a model or effort returns focus to the trigger without a focus ring; leaving the trigger or reopening the menu restores its normal focus indication. While a selection from either entry is pending, focus stays on the trigger, the trigger shows a spinner in place of its chevron, and each row whose value the selection carries shows one in place of its check; a rejected selection leaves the menu open. Tab returns to model search with the current model highlighted when search is shown, otherwise to the current row.
### Model and effort
The button's model menu shows search only when its full catalog contains more than four models; smaller catalogs focus the current model or first row and support keyboard navigation directly. The `/model` command always keeps its search field. Both search fields match model names case-insensitively, including nonconsecutive characters in order; leading and trailing spaces are ignored. Within each provider, prefix matches come first, followed by alignment score and then catalog order. Empty groups are hidden. Empty catalogs and queries without matches are announced through a status region. Search keeps focus while arrow keys cycle the highlighted result across provider groups; Enter or Tab selects it. Left and right arrows keep their native caret behavior. Opening highlights the current model or the first available row, and editing the query resets the highlight to the first result. Reopening the model pane clears the query.
The button's model menu shows search only when its full catalog contains more than four models; smaller catalogs support keyboard navigation directly and focus the current model or first enabled row on all devices. The `/model` command always keeps its search field. Both search fields match model names case-insensitively, including nonconsecutive characters in order; leading and trailing spaces are ignored. Within each provider, prefix matches come first, followed by alignment score and then catalog order. Empty groups are hidden. Empty catalogs and queries without matches are announced through a status region. Search keeps focus while arrow keys cycle the highlighted result across provider groups; Enter or Tab selects it. Left and right arrows keep their native caret behavior. Opening highlights the current model or the first available row, and editing the query resets the highlight to the first result. Reopening the model pane clears the query.
Model and reasoning-effort names use weight 400 (regular) in the composer menu, including the selected item. The search field follows the command popup's compact treatment with transparent background and border in both palettes, with no leading icon and a caption-tone placeholder. The clear button appears for a nonempty query and restores the full list with focus in the search field.
@@ -31,13 +31,13 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示
未选择模型或已保存的选择不在目录中时,composer 以常规字重显示“请选择模型”,并隐藏推理强度文案。点击后直接打开模型列表,Escape 和 Shift+Tab 关闭列表。目录为空时显示“暂无可用模型”
与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在根菜单和推理等级行间移动焦点,或在模型列表中移动高亮并让焦点保留在搜索框。Enter 和 Tab 选定高亮模型或聚焦行;Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻时若显示模型搜索框则聚焦搜索框,否则聚焦当前模型或推理强度行,返回则落在打开该面板的格子上。所选模型可用时,composer 显示目录中的模型名称。模型或提供方被删除时(包括账号退登),保留已保存的提供方、模型和推理强度,但不展示其 ID。目录条目恢复后重新显示名称和推理强度。
与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在根菜单和推理等级行间移动焦点,或在模型列表中移动高亮并让焦点保留在搜索框。Enter 和 Tab 选定高亮模型或聚焦行;Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻时,桌面设备在显示模型搜索框时聚焦搜索框;其他情况聚焦当前模型或推理强度行,没有选中项时聚焦首个可用行。返回时聚焦打开该面板的格子。手机和平板在用户主动点按搜索框或使用键盘导航前,不会聚焦搜索框。所选模型可用时,composer 显示目录中的模型名称。模型或提供方被删除时(包括账号退登),保留已保存的提供方、模型和推理强度,但不展示其 ID。目录条目恢复后重新显示名称和推理强度。
鼠标选择沿用浏览器原生点击及其取消行为;仅按下按钮不会选定。打开根菜单时聚焦触发按钮,再次点击触发按钮会关闭菜单并把焦点还给它。根菜单行和搜索清除按钮的键盘焦点与悬停使用相同底色,不显示原生焦点轮廓。选定模型或推理等级后,焦点返回触发按钮但不显示焦点环;焦点离开按钮或重新打开菜单后恢复正常焦点提示。等待任一入口发起的选择结果时,焦点停在触发按钮,触发按钮以加载图标代替下拉箭头,该选择包含的值所在行以加载图标代替勾选标记;选择被拒绝后菜单保持打开。显示模型搜索框时,Tab 返回搜索框并高亮当前模型,否则返回当前选中行。
### 模型与推理强度
按钮模型菜单仅在完整目录超过四个模型时显示搜索框;较小目录聚焦当前模型或首行,直接支持键盘导航。`/model` 命令始终保留搜索框。两个搜索框均按模型名称进行不区分大小写的匹配,支持按顺序输入不连续字符,并忽略首尾空格。每个提供方内的结果优先按前缀匹配、其次按匹配得分、最后按目录顺序排列。没有匹配项的分组会隐藏。空目录和无匹配结果均通过状态区域通知屏幕阅读器。方向键在各提供方分组的结果间循环移动高亮,焦点保留在搜索框;Enter 或 Tab 选定高亮项。左右方向键仍用于移动输入光标。打开时高亮当前模型或首个可用选项,修改搜索内容后高亮首个结果。重新打开模型面板会清空搜索内容。
按钮模型菜单仅在完整目录超过四个模型时显示搜索框;较小目录直接支持键盘导航,并在所有设备上聚焦当前模型或首个可用行。`/model` 命令始终保留搜索框。两个搜索框均按模型名称进行不区分大小写的匹配,支持按顺序输入不连续字符,并忽略首尾空格。每个提供方内的结果优先按前缀匹配、其次按匹配得分、最后按目录顺序排列。没有匹配项的分组会隐藏。空目录和无匹配结果均通过状态区域通知屏幕阅读器。方向键在各提供方分组的结果间循环移动高亮,焦点保留在搜索框;Enter 或 Tab 选定高亮项。左右方向键仍用于移动输入光标。打开时高亮当前模型或首个可用选项,修改搜索内容后高亮首个结果。重新打开模型面板会清空搜索内容。
composer 菜单中的模型名称和推理等级均使用 400(regular)字重,包括选中项。搜索框沿用 command 弹窗的紧凑样式,在浅/深色主题下均保持背景和边框透明,不显示前置图标,占位文字使用 caption 色。搜索内容非空时显示清除按钮;点击后恢复完整列表,并将焦点留在搜索框。
@@ -12,7 +12,9 @@
* search field. Provider headings paint their background only while pinned
* by scrolling. Clearing a query restores the full list and search focus.
* Selecting restores trigger focus without a ring until the trigger loses focus
* or the menu reopens. Model names match a case-insensitive ordered subsequence
* or the menu reopens. Mobile devices restore button focus when opening or
* drilling; model search takes focus only after explicit user input.
* Model names match a case-insensitive ordered subsequence
* within each provider group, ranked by
* prefix, alignment score, then catalog order. Returning to the root pane
* hands focus back to the cell that opened it. Data and submission ride the
@@ -35,7 +37,7 @@ import clsx from 'clsx'
import type { ModelReasoningEffort, ModelSelection } from '@deepseek-ai/dsh-api-remotes/client'
import {
IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, IconCloseFillRegular,
IconDataOutlineRegular, IconWarningOutlineRegular, Input, rankByName, StateDot, Toast,
IconDataOutlineRegular, IconWarningOutlineRegular, Input, isMobileDevice, rankByName, StateDot, Toast,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { ModelLabelOwner, ModelSelectInjected } from './slots.ts'
@@ -184,7 +186,7 @@ export function ModelSelect(
paneFocus.current = null
if (!open || intent === null) return
if (intent === 'drill') {
if (pane === 'model' && showSearch) {
if (pane === 'model' && showSearch && !isMobileDevice()) {
searchRef.current?.focus()
return
}
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import * as uiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { RemoteError, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
@@ -457,6 +458,71 @@ describe('ModelSelect keyboard walk', () => {
return select
}
it.each([
{ device: 'iPhone', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1', platform: 'iPhone', maxTouchPoints: 5 },
{ device: 'desktop-mode iPad', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel', maxTouchPoints: 5 },
].flatMap(device => [1, 5].map(modelCount => ({ ...device, modelCount }))))(
'retains menu keyboard focus on $device with $modelCount models without automatically focusing search',
async ({ userAgent, platform, maxTouchPoints, modelCount }) => {
for (const [name, value] of Object.entries({ userAgent, platform, maxTouchPoints })) {
const descriptor = Object.getOwnPropertyDescriptor(navigator, name)
Object.defineProperty(navigator, name, { configurable: true, value })
onTestFinished(() => {
if (descriptor === undefined) Reflect.deleteProperty(navigator, name)
else Object.defineProperty(navigator, name, descriptor)
})
}
vi.resetModules()
const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts')
const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice)
onTestFinished(() => { device.mockRestore() })
const select = vi.fn()
render(<ModelSelect renderSlot={renderLabel} locked={false} available
directory={createSnapshotStore(state({ groups: modelGroups(modelCount) }))}
load={vi.fn()} select={select} t={t}
/>)
const trigger = screen.getByRole('button', { name: /选择模型/ })
fireEvent.click(trigger)
expect(document.activeElement).toBe(trigger)
const modelCell = screen.getByRole('menuitem', { name: /^模型/ })
const effortCell = screen.getByRole('menuitem', { name: /推理等级/ })
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
fireEvent.keyDown(modelCell, { key: 'ArrowDown' })
expect(document.activeElement).toBe(effortCell)
fireEvent.keyDown(effortCell, { key: 'Tab' })
const high = screen.getByRole('menuitemradio', { name: 'High' })
const max = screen.getByRole('menuitemradio', { name: /Max/ })
expect(document.activeElement).toBe(high)
fireEvent.keyDown(high, { key: 'ArrowDown' })
expect(document.activeElement).toBe(max)
fireEvent.keyDown(max, { key: 'Escape' })
const restoredEffortCell = screen.getByRole('menuitem', { name: /推理等级/ })
expect(document.activeElement).toBe(restoredEffortCell)
fireEvent.keyDown(restoredEffortCell, { key: 'ArrowUp' })
const restoredModelCell = screen.getByRole('menuitem', { name: /^模型/ })
expect(document.activeElement).toBe(restoredModelCell)
fireEvent.keyDown(restoredModelCell, { key: 'Tab' })
const model = screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })
expect(document.activeElement).toBe(model)
if (modelCount > 4) {
const search = screen.getByRole('searchbox')
expect(document.activeElement).not.toBe(search)
fireEvent.keyDown(model, { key: 'ArrowDown' })
expect(document.activeElement).toBe(search)
} else {
expect(screen.queryByRole('searchbox')).toBeNull()
}
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: /^模型/ }))
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
await waitFor(() => { expect(document.activeElement).toBe(trigger) })
expect(select).not.toHaveBeenCalled()
},
)
it.each(['model', 'effort'])('prevents button mousedown defaults in the %s pane without selecting it', (pane) => {
const select = mountOpen()
const cell = screen.getByRole('menuitem', { name: pane === 'model' ? /^模型/ : /推理等级/ })
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-primitives/use-this-package:
en: d2f5701274be5110
zh: d60e2617136a6f26
en: 10be7f75a1576575
zh: abf2905d89b0da48
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
en: 75e1681dcfc61a14
zh: 3eda3143730f83e1
+2
View File
@@ -27,6 +27,8 @@ Use `dsh-client-ui-primitives` to build web-client controls and render agent out
`Toast` uses the theme’s toast background and label colors in both light and dark modes. `Menu` data rows and `MenuItemButton` component rows accept owner-provided effective shortcuts and align their keys at the trailing edge as muted text without a background, including submenus. Menus hide these hints when the primary pointer is coarse (`pointer: coarse`) and [keyboard navigation](#input-modality) is inactive. Keyboard navigation reveals them until the next pointer press; keyboard bindings remain active. `ShortcutKeys` defaults to unboxed keys for menu and inline hints. Its `tooltip` variant uses lighter keycaps over the dark bubble and groups plus-separated combinations into one filled block. `Tooltip.shortcutKeys` vertically centers keycaps beside optional localized action text, or shows only keys when the label is empty. The optional `className` supplies owner interaction styles. `Modal` uses the frame’s shared overlay inset, with a minimum 24px viewport margin. Its mask leaves the Windows caption unpainted while the full-viewport layer blocks background clicks. `Modal` and the settings shell share top-layer Escape and Tab handling through `useModalLayer`, restoring the previous focus on close. `closeTopModal(document)` requests the foreground modal’s current close action; newer menus or unregistered dialogs block dismissal of the modal behind them. Components use `isBehindModal` to avoid moving focus behind a nested dialog. Menus consume their local Escape before modal dismissal. `observeComposition` supplies the same composition-end and legacy IME guard to local modal and recording handlers; callers dispose its document listeners with their interaction lifetime.
`isMobileDevice()` identifies iOS and Android phones and tablets for automatic text-input focus and keyboard dismissal. It recognizes `Android`, `iPhone`, `iPad`, or `iPod` in the UA, and iPads reporting `MacIntel` with more than one touch point. Other devices, including Windows touchscreen computers, return false regardless of window width. The function caches its first result for all callers in the page; component remounts reuse that result, and reloading the page reads the browser environment again. Identified mobile devices require explicit text-input focus. Browser modes or custom UAs that conceal the mobile platform can prevent identification; unrecognized UAs return false. A desktop UA alone cannot distinguish Android tablets from Linux computers.
Automatic modal entry and modal/menu return focus, including after Escape and application close shortcuts, use `focusWithoutRing(element, options?)` to suppress the outline until Tab or directional navigation resumes normal focus styling. Dialog containers keep their outline-free styling. Existing borders, shadows, and error states remain intact. Mark a dialog's initial control with `data-modal-autofocus` so the modal captures its invoking control before moving focus. Controls mounted with the dialog must not use React `autoFocus`, which runs before that capture. Tab and Shift+Tab from the dialog container enter its first and last focusable controls.
This package is a Web-shell build input. Its static ESM retains third-party imports and styles for Vite; independent consumers supply its development dependencies ([dependency rules](../AGENTS.md#dependency-declaration)).
@@ -27,6 +27,8 @@ kind: "package-library"
`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。主指针为粗指针(`pointer: coarse`)且未使用[键盘导航](#input-modality)时,菜单隐藏这些提示。键盘导航会显示提示,直到下一次指针按下;快捷键绑定始终生效。`ShortcutKeys` 默认以无方块的按键文字显示菜单和行内提示。`tooltip` 变体在深色气泡上使用稍浅的键帽,以加号连接的组合则共用一个填充色块。`Tooltip.shortcutKeys` 将键帽与可选的本地化操作文本垂直居中排列,标签为空时只显示按键。可选的 `className` 供调用方设置交互状态样式。`Modal` 使用框架共享的浮层起点,视口边距至少为 24px。其遮罩不绘制 Windows 顶栏区域,覆盖整个视口的模态层仍阻挡背景点击。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。`closeTopModal(document)` 请求前台弹窗当前的关闭操作;上层菜单或未注册的对话框会阻止关闭其后方弹窗。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。
`isMobileDevice()` 识别 iOS 和 Android 手机及平板,供自动聚焦和收起键盘使用。它识别 UA 中的 `Android`、`iPhone`、`iPad` 或 `iPod`,以及报告 `MacIntel` 且触摸点数大于一的 iPad。其他设备(包括 Windows 触屏电脑)返回 false,不受窗口宽度影响。函数缓存首次判断结果,供页面内的所有调用方共享;组件重新挂载会复用该结果,重新加载页面时会再次读取浏览器环境。识别出的移动设备需要用户主动聚焦文本输入框。隐藏移动平台信息的浏览器模式或自定义 UA 可能导致识别失败;未识别的 UA 返回 false。仅凭桌面 UA 无法区分 Android 平板与 Linux 电脑。
弹窗自动进入及弹窗、菜单回焦,包括通过 Esc 和应用关闭快捷键触发的回焦,均使用 `focusWithoutRing(element, options?)`,在 Tab 或方向键导航恢复正常焦点样式前抑制外轮廓线。弹窗容器仍不绘制焦点外框。原有边框、阴影和错误状态保持不变。用 `data-modal-autofocus` 标记弹窗的初始控件,让模态层先保存触发控件,再移动焦点。随弹窗挂载的控件不得使用 React `autoFocus`,因为它会在保存触发控件前执行。弹窗容器获得焦点时,Tab 和 Shift+Tab 分别进入第一个和最后一个可聚焦控件。
本包是 Web 壳的构建输入。静态 ESM 为 Vite 保留第三方导入和样式;独立消费方自行提供开发依赖([依赖规则](../AGENTS.md#dependency-declaration))。
+16 -1
View File
@@ -1,6 +1,21 @@
/** Focus presentation for automatic entry and restoration. */
/** Mobile-device detection and automatic focus presentation. */
const releases = new WeakMap<HTMLElement, () => void>()
const navigationKeys = new Set(['Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'])
let mobileDevice: boolean | undefined
/**
* Identify iOS and Android phones and tablets from the current browser's device information.
* MacIntel with multiple touch points identifies desktop-mode iPads.
* The first result is shared by all callers until the page reloads.
* @returns true for recognized mobile devices; false for other devices, including unrecognized UAs.
*/
export function isMobileDevice(): boolean {
if (mobileDevice !== undefined) return mobileDevice
const { userAgent, platform, maxTouchPoints } = navigator
mobileDevice = /Android|iPhone|iPad|iPod/i.test(userAgent)
|| (platform === 'MacIntel' && maxTouchPoints > 1)
return mobileDevice
}
/**
* Focus an automatic destination without a focus outline until keyboard navigation or blur.
+1 -1
View File
@@ -45,7 +45,7 @@ export { HoverCard } from './HoverCard.tsx'
export { HoverCardGroup } from './HoverCardGroup.tsx'
export { ShortcutKeys } from './ShortcutKeys.tsx'
export { observeComposition } from './keyboard-composition.ts'
export { focusWithoutRing } from './focus.ts'
export { focusWithoutRing, isMobileDevice } from './focus.ts'
export { closeTopModal, isBehindModal, modalSelector, useModalLayer } from './useModalLayer.ts'
export { Modal } from './Modal.tsx'
export { RiskConfirmation } from './RiskConfirmation.tsx'
@@ -1,13 +1,71 @@
// @vitest-environment jsdom
import { createRef, useState } from 'react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { Button, ConnectionIndicator, Input, Menu, MenuItemButton, Modal, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
import { POINTER_GRACE_MS } from '../src/pointer-grace.ts'
import { focusWithoutRing } from '../src/focus.ts'
import buttonCss from '../src/Button.module.css'
afterEach(cleanup)
describe('isMobileDevice', () => {
beforeEach(() => { vi.resetModules() })
it.each([
{ name: 'iPhone', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X)', platform: 'iPhone', maxTouchPoints: 5, mobile: true },
{ name: 'iPad', userAgent: 'Mozilla/5.0 (iPad; CPU OS 18_6 like Mac OS X)', platform: 'iPad', maxTouchPoints: 5, mobile: true },
{ name: 'iPod', userAgent: 'Mozilla/5.0 (iPod; U; CPU like Mac OS X; en)', platform: 'iPod', maxTouchPoints: 5, mobile: true },
{ name: 'Android phone', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) Mobile', platform: 'Linux armv8l', maxTouchPoints: 0, mobile: true },
{ name: 'Android tablet', userAgent: 'Mozilla/5.0 (Linux; Android 10; K)', platform: 'Linux armv8l', maxTouchPoints: 5, mobile: true },
{ name: 'desktop-mode iPad', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', platform: 'MacIntel', maxTouchPoints: 5, mobile: true },
{ name: 'Mac', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', platform: 'MacIntel', maxTouchPoints: 0, mobile: false },
{ name: 'Windows touchscreen', userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', platform: 'Win32', maxTouchPoints: 5, mobile: false },
{ name: 'native networking UA', userAgent: 'DeepSeek%20Chat/0 CFNetwork/3860.400.51 Darwin/25.3.0', platform: '', maxTouchPoints: 0, mobile: false },
{ name: 'unknown UA', userAgent: 'Unrecognized/1.0', platform: '', maxTouchPoints: 0, mobile: false },
])('classifies $name', async ({ userAgent, platform, maxTouchPoints, mobile }) => {
for (const [name, value] of Object.entries({ userAgent, platform, maxTouchPoints })) {
const descriptor = Object.getOwnPropertyDescriptor(navigator, name)
Object.defineProperty(navigator, name, { configurable: true, value })
onTestFinished(() => {
if (descriptor === undefined) Reflect.deleteProperty(navigator, name)
else Object.defineProperty(navigator, name, descriptor)
})
}
const { isMobileDevice } = await import('../src/focus.ts')
expect(isMobileDevice()).toBe(mobile)
})
it.each([true, false])('shares a cached %s result between callers until the module reloads', async (mobile) => {
const mobileUA = 'Mozilla/5.0 (iPhone)'
const desktopUA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'
const ua = vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(mobile ? mobileUA : desktopUA)
onTestFinished(() => { ua.mockRestore() })
const first = await import('../src/focus.ts')
expect(first.isMobileDevice()).toBe(mobile)
ua.mockReturnValue(mobile ? desktopUA : mobileUA)
const other = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts')
expect(other.isMobileDevice()).toBe(mobile)
expect(ua).toHaveBeenCalledTimes(1)
vi.resetModules()
const reloaded = await import('../src/focus.ts')
expect(reloaded.isMobileDevice()).toBe(!mobile)
expect(ua).toHaveBeenCalledTimes(2)
})
})
it('retains automatic focus styling while typing and restores it for keyboard navigation', () => {
render(<button>Automatic destination</button>)
const button = screen.getByRole('button')
focusWithoutRing(button)
fireEvent.keyDown(button, { key: 'a' })
expect(button.hasAttribute('data-dsh-automatic-focus')).toBe(true)
fireEvent.keyDown(button, { key: 'ArrowDown' })
expect(button.hasAttribute('data-dsh-automatic-focus')).toBe(false)
})
describe('Button', () => {
it('exposes its native button for focus and releases the ref on unmount', () => {
const ref = createRef<HTMLButtonElement>()
+2 -2
View File
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 405b30feaf48c087
/deepseek-ai-dsh-client-ui-schedule/use-this-package:
en: 4c0dca55e4e9866d
zh: 9ee6b565c7a81353
en: babac791422f1b8f
zh: f633dbfc1b7bb4bd
/deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source:
en: 47cd16c142e82385
zh: 181849a3c7a167fd
+1 -1
View File
@@ -31,7 +31,7 @@ The page header keeps the title at its natural width and moves New to another li
Selecting a task from the list opens Rules; refreshing that task preserves the selected tab. Rules edits an active task's name, instruction, and run time; the edits stay in a local draft until saved, and inactive tasks remain read-only. Unmodified Left/Right arrows cycle the tabs, Home/End select the first/last tab, and keyboard focus follows the selection. The tabs do not consume these keys with Alt, Ctrl, Meta, or Shift. The linked-Session control in the detail tab strip stays available on the Rules tab, and confirmed deletion is available from either view.
Rules shows the rule in a Run time card whose rows edit a local draft: Repeat is a menu of Weekly, Monday to Friday, Every day, Every N hours, Every N minutes, Every N seconds, Once, and Custom (cron), a weekly rule adds a Weekday row, a fixed-rate choice edits only the interval quantity in the unit it names, a one-shot rule edits separate Date and Time rows, and Custom (cron) edits one Cron expression row holding a five-field expression, interpreted in the selected time zone. The card parses that expression in the browser and, under the row, shows a localized sentence describing it; an expression the Host would reject shows the localized validation message in place of that sentence and blocks Save. A stored weekly rule whose weekday set is exactly Monday through Friday selects the Monday to Friday choice. The Date row is a read-only trigger showing the staged date as `YYYY/MM/DD` and opening a month calendar with localized weekday headings, month navigation, and a marked today; the draft and the submitted text keep the ISO form; the Time row is a read-only trigger showing one 24-hour clock and opening three scrolling columns of hours, minutes, and seconds with the staged value marked and scrolled into view. A calendar or clock pick stages the draft under the same staged save as every other row, arrow keys walk the open column or grid, Enter stages the marked value, Escape closes the panel and returns focus to its row, and a disabled row cannot open one. Clock rows show whole seconds, an untouched row keeps a stored millisecond value for its submit, and editing a time submits whole-second precision. Time zone is a searchable, height-bounded menu. An unlabeled top block keeps the five most recently selected exact zones in browser-local storage; first use seeds the actual system zone and UTC rather than inferring a location from UI language. The system zone is marked, and the remaining inventory is ordered by current UTC offset and canonical IANA id. Each visible label puts `UTC±HH:MM` before the localized zone name; canonical ids stay internal but remain searchable. Zones that ICU renders with the same offset and localized name share one visible row; every grouped id remains searchable, and an already stored id remains selected instead of being rewritten. The inventory comes from `Intl.supportedValuesOf('timeZone')`, and names come from `Intl.DateTimeFormat(..., { timeZoneName: 'longGeneric' })`: both are browser ICU/CLDR data, not a scraped or product-maintained translation table. There is therefore no crawler or generated zone-name resource to refresh. A third-party locale supplies its ordinary `time.locale` value and automatically receives the corresponding ICU names; only the selector's surrounding UI copy needs translation. When the runtime cannot enumerate zones, a short identifier fallback is offered, and a stored zone outside the offered set is kept. Daily, Weekly, and Cron keep their stored rule and IANA zone, and each frequency line names the zone only when it differs from the host's system zone. A one-shot After/At seeds the date and time from the record's stored zone, or this device's zone when the record stores none, so the seeded clock names the stored instant; changing that zone keeps the entered clock values and changes the instant. A kind the card seeds on selection uses the committed occurrence's clock and the choice's zone, which is the record's stored zone or this device's zone, a seeded weekly rule starts from that occurrence's weekday in that zone, and a seeded cron rule states that occurrence's clock as a daily expression. Every accepts at least 60 whole seconds, independent of time zones. One Save submits the complete expected record with the staged change and preserves the id, original Session, latest receipt, and saved history; [Schedule](../../schedule/schedule/README.md#use-this-package) defines future-target selection, reanchoring, and unchanged-rule no-ops.
Rules shows the rule in a Run time card whose rows edit a local draft: Repeat is a menu of Weekly, Monday to Friday, Every day, Every N hours, Every N minutes, Every N seconds, Once, and Custom (cron), a weekly rule adds a Weekday row, a fixed-rate choice edits only the interval quantity in the unit it names, a one-shot rule edits separate Date and Time rows, and Custom (cron) edits one Cron expression row holding a five-field expression, interpreted in the selected time zone. The card parses that expression in the browser and, under the row, shows a localized sentence describing it; an expression the Host would reject shows the localized validation message in place of that sentence and blocks Save. A stored weekly rule whose weekday set is exactly Monday through Friday selects the Monday to Friday choice. The Date row is a read-only trigger showing the staged date as `YYYY/MM/DD` and opening a month calendar with localized weekday headings, month navigation, and a marked today; the draft and the submitted text keep the ISO form; the Time row is a read-only trigger showing one 24-hour clock and opening three scrolling columns of hours, minutes, and seconds with the staged value marked and scrolled into view. A calendar or clock pick stages the draft under the same staged save as every other row, arrow keys walk the open column or grid, Enter stages the marked value, Escape closes the panel and returns focus to its row, and a disabled row cannot open one. Clock rows show whole seconds, an untouched row keeps a stored millisecond value for its submit, and editing a time submits whole-second precision. Time zone is a searchable, height-bounded menu. Its search field takes focus automatically on desktop; phones and tablets require explicit focus. An unlabeled top block keeps the five most recently selected exact zones in browser-local storage; first use seeds the actual system zone and UTC rather than inferring a location from UI language. The system zone is marked, and the remaining inventory is ordered by current UTC offset and canonical IANA id. Each visible label puts `UTC±HH:MM` before the localized zone name; canonical ids stay internal but remain searchable. Zones that ICU renders with the same offset and localized name share one visible row; every grouped id remains searchable, and an already stored id remains selected instead of being rewritten. The inventory comes from `Intl.supportedValuesOf('timeZone')`, and names come from `Intl.DateTimeFormat(..., { timeZoneName: 'longGeneric' })`: both are browser ICU/CLDR data, not a scraped or product-maintained translation table. There is therefore no crawler or generated zone-name resource to refresh. A third-party locale supplies its ordinary `time.locale` value and automatically receives the corresponding ICU names; only the selector's surrounding UI copy needs translation. When the runtime cannot enumerate zones, a short identifier fallback is offered, and a stored zone outside the offered set is kept. Daily, Weekly, and Cron keep their stored rule and IANA zone, and each frequency line names the zone only when it differs from the host's system zone. A one-shot After/At seeds the date and time from the record's stored zone, or this device's zone when the record stores none, so the seeded clock names the stored instant; changing that zone keeps the entered clock values and changes the instant. A kind the card seeds on selection uses the committed occurrence's clock and the choice's zone, which is the record's stored zone or this device's zone, a seeded weekly rule starts from that occurrence's weekday in that zone, and a seeded cron rule states that occurrence's clock as a daily expression. Every accepts at least 60 whole seconds, independent of time zones. One Save submits the complete expected record with the staged change and preserves the id, original Session, latest receipt, and saved history; [Schedule](../../schedule/schedule/README.md#use-this-package) defines future-target selection, reanchoring, and unchanged-rule no-ops.
The rows are disabled while a save is in flight, while the catalog is loading, during a deletion, and for an inactive task. A save bar appears only while the local draft differs from the stored task: it shows Unsaved changes with Cancel and Save changes, and Save changes becomes Saving… while the save is in flight. Cancel restores the stored values. A catalog refresh becomes the shown values only while the draft has no unsaved edit, so an unsaved draft survives an authoritative refresh. A failed save keeps the draft and shows a localized message; after a conflict, retry against the refreshed record. If the task disappears from the catalog, Delete is disabled until the task returns. A successful update refreshes the authoritative catalog. A rejected or unconfirmed update is distinct from a catalog-read failure: Retry reloads the catalog without presenting the update as failed. If the update itself cannot be confirmed, check the task before retrying. Late responses after switching tasks or closing details do not replace the current view, and no failure rolls back a Host write already begun.
+1 -1
View File
@@ -31,7 +31,7 @@ kind: "package-reference"
从列表选择任务时默认打开“规则”;刷新同一任务会保留当前页签。活动任务的“规则”修改名称、指令和运行时间;这些修改保留在本地草稿中直到保存,未运行任务保持只读。不带修饰键的左右方向键循环切换页签,Home/End 选择第一个/最后一个页签,键盘焦点随选择移动。页签不会拦截这些键与 Alt、Ctrl、Meta 或 Shift 的组合。共用的会话入口位于详情页签条内,在“规则”页签可见;确认删除操作在两种页签下都可用。
“规则”在“运行时间”卡片中以行编辑本地草稿:“重复”是含每周、周一至周五、每天、每 N 小时、每 N 分钟、每 N 秒、仅一次和自定义(cron)的菜单,每周规则增加“星期”行,固定速率选项只修改它所属单位的间隔数量,一次性规则用独立的“日期”和“时间”两行编辑目标,自定义(cron)用一行“Cron 表达式”编辑按所选时区解释的 5 字段表达式。卡片在浏览器中解析该表达式,并在该行下方显示描述它的本地化语句;Host 会拒绝的表达式改为显示本地化校验信息,并在解析通过前阻止保存。已存储的每周规则若星期集合恰好为周一至周五,则选中“周一至周五”选项。“日期”行是只读触发器,以 `YYYY/MM/DD` 显示暂存的日期并展开带本地化星期表头、月份切换和今天标记的月历;草稿与提交文本保持 ISO 形态;“时间”行是只读触发器,显示统一的 24 小时时钟,并展开时、分、秒三列可滚动列表,当前值高亮并在打开时滚动到可见。在月历或时钟上点选即按与其他行相同的分阶段保存写入草稿;方向键在打开的列或网格内移动,Enter 选定当前值,Escape 关闭面板并把焦点还给所在行,被禁用的行无法打开面板。时钟行显示到秒,未改动的行在提交时保留已存储的毫秒值,编辑时间时按整秒精度提交。“时区”使用高度受限的可搜索菜单。顶部无标题区域保留浏览器本地最近选过的 5 个准确时区;首次使用按真实系统时区和 UTC 初始化,而不根据界面语言猜测所在地。系统时区带有标记,其余清单按当前 UTC 偏移、再按规范 IANA 标识排序。可见文案把 `UTC±HH:MM` 放在本地化时区名称之前;IANA 标识仅在内部使用,但可用于搜索。ICU 显示成相同偏移和本地化名称的多个时区只占一行;所有被合并的标识仍可用于搜索,任务已保存的标识也会继续作为选中值,不会被改写。时区清单来自 `Intl.supportedValuesOf('timeZone')`,名称来自 `Intl.DateTimeFormat(..., { timeZoneName: 'longGeneric' })`:两者都是浏览器内置的 ICU/CLDR 数据,不是抓取或产品自维护的翻译表。因此没有需要刷新的爬虫或生成时区资源;第三方语言包只需提供常规的 `time.locale` 值,即可自动获得对应的 ICU 名称,仅需翻译选择器周边的 UI 文案。运行时无法枚举时改用简短的标识候选列表,已存储但不在其中的时区会保留。每天、每周和 cron 保留各自已存储的规则与时区,其频率行仅在时区与宿主系统时区不同时标出时区。一次性 After/At 以记录已存储的时区初始化日期和时间;记录未保存时区时取本设备时区,初始钟表值在该时区下仍表示同一瞬时;更改时区会保留输入的钟表值并改变时点。因选择种类而重新初始化的种类使用已提交发生时点的钟表值和该选项的时区,该时区取记录已存储的时区或本设备时区:每周的星期取自该发生时点在该时区下的星期,cron 则把该发生时点的钟表值写成每天执行的表达式。固定间隔至少为 60 整秒,不受时区影响。一次保存以分阶段的变更提交完整预期记录,并保留 id、原会话、最近一次回执及已保存历史;未来目标选择、重新确定起算时间和未变更规则的无写入语义见 [Schedule](../../schedule/schedule/README.zh.md#use-this-package)。
“规则”在“运行时间”卡片中以行编辑本地草稿:“重复”是含每周、周一至周五、每天、每 N 小时、每 N 分钟、每 N 秒、仅一次和自定义(cron)的菜单,每周规则增加“星期”行,固定速率选项只修改它所属单位的间隔数量,一次性规则用独立的“日期”和“时间”两行编辑目标,自定义(cron)用一行“Cron 表达式”编辑按所选时区解释的 5 字段表达式。卡片在浏览器中解析该表达式,并在该行下方显示描述它的本地化语句;Host 会拒绝的表达式改为显示本地化校验信息,并在解析通过前阻止保存。已存储的每周规则若星期集合恰好为周一至周五,则选中“周一至周五”选项。“日期”行是只读触发器,以 `YYYY/MM/DD` 显示暂存的日期并展开带本地化星期表头、月份切换和今天标记的月历;草稿与提交文本保持 ISO 形态;“时间”行是只读触发器,显示统一的 24 小时时钟,并展开时、分、秒三列可滚动列表,当前值高亮并在打开时滚动到可见。在月历或时钟上点选即按与其他行相同的分阶段保存写入草稿;方向键在打开的列或网格内移动,Enter 选定当前值,Escape 关闭面板并把焦点还给所在行,被禁用的行无法打开面板。时钟行显示到秒,未改动的行在提交时保留已存储的毫秒值,编辑时间时按整秒精度提交。“时区”使用高度受限的可搜索菜单。桌面端打开时自动聚焦搜索框;手机和平板需要手动聚焦。顶部无标题区域保留浏览器本地最近选过的 5 个准确时区;首次使用按真实系统时区和 UTC 初始化,而不根据界面语言猜测所在地。系统时区带有标记,其余清单按当前 UTC 偏移、再按规范 IANA 标识排序。可见文案把 `UTC±HH:MM` 放在本地化时区名称之前;IANA 标识仅在内部使用,但可用于搜索。ICU 显示成相同偏移和本地化名称的多个时区只占一行;所有被合并的标识仍可用于搜索,任务已保存的标识也会继续作为选中值,不会被改写。时区清单来自 `Intl.supportedValuesOf('timeZone')`,名称来自 `Intl.DateTimeFormat(..., { timeZoneName: 'longGeneric' })`:两者都是浏览器内置的 ICU/CLDR 数据,不是抓取或产品自维护的翻译表。因此没有需要刷新的爬虫或生成时区资源;第三方语言包只需提供常规的 `time.locale` 值,即可自动获得对应的 ICU 名称,仅需翻译选择器周边的 UI 文案。运行时无法枚举时改用简短的标识候选列表,已存储但不在其中的时区会保留。每天、每周和 cron 保留各自已存储的规则与时区,其频率行仅在时区与宿主系统时区不同时标出时区。一次性 After/At 以记录已存储的时区初始化日期和时间;记录未保存时区时取本设备时区,初始钟表值在该时区下仍表示同一瞬时;更改时区会保留输入的钟表值并改变时点。因选择种类而重新初始化的种类使用已提交发生时点的钟表值和该选项的时区,该时区取记录已存储的时区或本设备时区:每周的星期取自该发生时点在该时区下的星期,cron 则把该发生时点的钟表值写成每天执行的表达式。固定间隔至少为 60 整秒,不受时区影响。一次保存以分阶段的变更提交完整预期记录,并保留 id、原会话、最近一次回执及已保存历史;未来目标选择、重新确定起算时间和未变更规则的无写入语义见 [Schedule](../../schedule/schedule/README.zh.md#use-this-package)。
行在保存进行中、目录加载中、删除期间以及任务未运行时禁用。仅当本地草稿与已存储任务不同时才出现保存栏:显示“有未保存的修改”及“取消”和“保存修改”,保存期间“保存修改”变为“保存中…”。“取消”恢复已存储的值。仅当草稿没有未保存修改时,目录刷新才会成为显示值,因此未保存的草稿会保留到权威刷新之后。保存失败保留草稿并显示本地化提示;冲突后针对刷新后的记录重试。任务从目录消失时,“删除”会禁用,直到任务重新出现。更新成功后刷新权威目录。更新被拒绝或无法确认与目录读取失败相互区分:“重试”重新加载目录,不将该更新显示为失败。若更新本身无法确认,应在重试前检查任务。切换任务或关闭详情后才到达的响应不会替换当前视图,任何失败都不会回滚宿主已开始的写入。
@@ -11,7 +11,7 @@ import type { CSSProperties, ReactNode } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import {
IconCheckOutlineRegular, useAnchoredPosition, useDismissOnOutsidePointer,
IconCheckOutlineRegular, isMobileDevice, useAnchoredPosition, useDismissOnOutsidePointer,
} from '@deepseek-ai/dsh-client-ui-primitives'
import css from './TaskMenu.module.css'
@@ -114,8 +114,9 @@ function isLabel(entry: TaskMenuEntry): entry is TaskMenuLabel {
* @param props.portal - render the list into document.body, fixed-positioned
* from the anchor rect (follows scroll and resize while open).
* @param props.header - owner content pinned above the scrolling rows; a control
* it marks `data-menu-field` takes the keyboard once the list is placed, which
* is the frame a portaled list becomes focusable in.
* it marks `data-menu-field` takes the keyboard on desktop once the list is placed,
* which is the frame a portaled list becomes focusable in. Phones and tablets
* require explicit field focus.
* @param props.className - extra class on the anchor wrapper span.
* @param props.listClassName - extra class on the dropdown card itself.
* @returns the anchor wrapper with the conditional list.
@@ -175,7 +176,7 @@ export function TaskMenu({ open, anchor, items = [], selectedId, onSelect, onClo
handedOver.current = false
return
}
if (handedOver.current) return
if (handedOver.current || isMobileDevice()) return
const list = listRef.current
/* v8 ignore next -- the list is rendered in the same commit that sets `open`, before this effect runs. */
if (list === null) return
@@ -4,9 +4,10 @@
* handover a portaled list performs once it is placed, the row walk, and the
* dismissal and focus-return paths the task manager's four menus rely on.
*/
import * as uiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
import { useState } from 'react'
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { TaskMenu } from '../src/client/TaskMenu.tsx'
import type { TaskMenuEntry } from '../src/client/TaskMenu.tsx'
import taskMenuCss from '../src/client/TaskMenu.module.css'
@@ -142,6 +143,48 @@ describe('TaskMenu', () => {
expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'Filter' }))
})
it.each([
{ name: 'iPhone', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1', platform: 'iPhone', maxTouchPoints: 5 },
{ name: 'Android tablet', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/119.0.0.0 Safari/537.36', platform: 'Linux armv8l', maxTouchPoints: 5 },
{ name: 'desktop-mode iPad', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel', maxTouchPoints: 5 },
])('leaves header search focus to the user on $name', async ({ userAgent, platform, maxTouchPoints }) => {
for (const [name, value] of Object.entries({ userAgent, platform, maxTouchPoints })) {
const descriptor = Object.getOwnPropertyDescriptor(navigator, name)
Object.defineProperty(navigator, name, { configurable: true, value })
onTestFinished(() => {
if (descriptor === undefined) Reflect.deleteProperty(navigator, name)
else Object.defineProperty(navigator, name, descriptor)
})
}
vi.resetModules()
const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts')
const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice)
onTestFinished(() => { device.mockRestore() })
const menu = (open: boolean) => <TaskMenu
open={open}
portal
anchor={<button type="button">trigger</button>}
header={<input aria-label="Filter" data-menu-field="" />}
items={ITEMS}
onSelect={() => {}}
onClose={() => {}}
/>
const { rerender } = render(menu(false))
const trigger = screen.getByRole('button', { name: 'trigger' })
trigger.focus()
rerender(menu(true))
const field = screen.getByRole('textbox', { name: 'Filter' })
expect(document.activeElement).toBe(trigger)
field.focus()
expect(document.activeElement).toBe(field)
fireEvent.keyDown(field, { key: 'ArrowDown' })
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Alpha' }))
rerender(menu(false))
trigger.focus()
rerender(menu(true))
expect(document.activeElement).toBe(trigger)
})
it('keeps the keyboard on the row when a placed portal list repositions', () => {
render(<TaskMenu
open
+58
View File
@@ -0,0 +1,58 @@
diff --git a/dist/Lexical.dev.js b/dist/Lexical.dev.js
index 7c6fbcfbbfdda3f79d3b41bec1640e60e1df479c..5423063a9ce8e4a8f9da6fc25ee9d59c4997a7ac 100644
--- a/dist/Lexical.dev.js
+++ b/dist/Lexical.dev.js
@@ -16346,7 +16346,8 @@ function scrollIntoViewIfNeeded(editor, selectionRect, rootElement) {
// or the caret stays behind the keyboard.
const visualViewport = defaultView.visualViewport;
if (visualViewport) {
- const offsetTop = visualViewport.offsetTop;
+ // Match getBoundingClientRect coordinates when a keyboard pans the page.
+ const offsetTop = visualViewport.pageTop - defaultView.scrollY;
targetTop = offsetTop;
targetBottom = offsetTop + visualViewport.height;
} else {
diff --git a/dist/Lexical.dev.mjs b/dist/Lexical.dev.mjs
index 32aeb4eb929ac82d05988992e97ab3204bf698b4..6390ccb612b7363c8fa959c3b880bd8785f210a0 100644
--- a/dist/Lexical.dev.mjs
+++ b/dist/Lexical.dev.mjs
@@ -16344,7 +16344,8 @@ function scrollIntoViewIfNeeded(editor, selectionRect, rootElement) {
// or the caret stays behind the keyboard.
const visualViewport = defaultView.visualViewport;
if (visualViewport) {
- const offsetTop = visualViewport.offsetTop;
+ // Match getBoundingClientRect coordinates when a keyboard pans the page.
+ const offsetTop = visualViewport.pageTop - defaultView.scrollY;
targetTop = offsetTop;
targetBottom = offsetTop + visualViewport.height;
} else {
diff --git a/dist/Lexical.prod.js b/dist/Lexical.prod.js
index ab333382f19e0e5dd6a8b0529f565f1731f275a0..e1e5b60124ed3daace2310c75f2720ec67d6c1d9 100644
--- a/dist/Lexical.prod.js
+++ b/dist/Lexical.prod.js
@@ -9,2 +9,2 @@
"use strict";function t(t,...e){const n=new URL("https://lexical.dev/docs/error"),o=new URLSearchParams;o.append("code",t);for(const t of e)o.append("v",t);throw n.search=o.toString(),Error(`Minified Lexical error #${t}; visit ${n.toString()} for the full message or use the non-minified dev environment for full errors and additional helpful warnings.`)}function e(t,...e){const n=new URL("https://lexical.dev/docs/error"),o=new URLSearchParams;o.append("code",t);for(const t of e)o.append("v",t);n.search=o.toString(),console.warn(`Minified Lexical warning #${t}; visit ${n.toString()} for the full message or use the non-minified dev environment for full errors and additional helpful warnings.`)}const n="undefined"!=typeof window&&void 0!==window.document&&void 0!==window.document.createElement,o=n&&"documentMode"in document?document.documentMode:null,r=n&&/Mac|iPod|iPhone|iPad/.test(navigator.platform),s=n&&/^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent),i=!(!n||!("InputEvent"in window)||o)&&"getTargetRanges"in new window.InputEvent("input"),l=n&&/iPad|iPhone|iPod/.test(navigator.userAgent)&&!window.MSStream,c=n&&/Android/.test(navigator.userAgent),a=n&&/Version\/[\d.]+.*Safari/.test(navigator.userAgent)&&!c,u=n&&/^(?=.*Chrome).*/i.test(navigator.userAgent),f=n&&c&&u,d=n&&/AppleWebKit\/[\d.]+/.test(navigator.userAgent)&&r&&!u,h=0,g=1,_=2,p=128,m=1,y=2,x=3,C=4,S=5,T=6,N=a||l||d?"\xa0":"\u200b",v="\n\n",E=s?"\xa0":N,k="\u0591-\u07ff\ufb1d-\ufdfd\ufe70-\ufefc",O="A-Za-z\xc0-\xd6\xd8-\xf6\xf8-\u02b8\u0300-\u0590\u0800-\u1fff\u200e\u2c00-\ufb1c\ufe00-\ufe6f\ufefd-\uffff",b=new RegExp("^[^"+O+"]*["+k+"]"),M=new RegExp("^[^"+k+"]*["+O+"]"),A={bold:1,capitalize:1024,code:16,highlight:p,italic:2,lowercase:256,strikethrough:4,subscript:32,superscript:64,underline:8,uppercase:512},D={directionless:1,unmergeable:2},w={center:2,end:6,justify:4,left:1,right:3,start:5},I={[y]:"center",[T]:"end",[C]:"justify",[m]:"left",[x]:"right",[S]:"start"},P={normal:0,segmented:2,token:1},F={[h]:"normal",[_]:"segmented",[g]:"token"},R="$config";function L(){return Ml()._blockCursorElement}function K(t){return null!==t&&1===t.nodeType&&t.hasAttribute("data-lexical-slot")}class B{element;before;after;constructor(t,e,n){this.element=t,this.before=e||null,this.after=n||null}withBefore(t){return new B(this.element,t,this.after)}withAfter(t){return new B(this.element,this.before,t)}withElement(t){return this.element===t?this:new B(t,this.before,this.after)}insertChild(e){const n=this.getInsertionAnchor();return null!==n&&n.parentElement!==this.element&&t(357),this.element.insertBefore(e,n),this}removeChild(e){return e.parentElement!==this.element&&t(358),this.element.removeChild(e),this}replaceChild(e,n){return n.parentElement!==this.element&&t(359),this.element.replaceChild(e,n),this}getFirstChild(){const t=this.getFirstChildAnchor(),e=t?t.nextSibling:this.element.firstChild;return e===this.getInsertionAnchor()?null:e}getFirstChildAnchor(){return this.after}resolveLeafPosition(t,e,n){if(this.element===t)return e===t&&0===n?"before":"after";const o=$(t,this.element);if(null===o)return"after";const r=Array.prototype.indexOf.call(t.childNodes,o);if(r<0)return"after";if(e===t)return n<=r?"before":"after";const s=$(t,e);if(null===s)return"after";const i=Array.prototype.indexOf.call(t.childNodes,s);return i>=0&&i<=r?"before":"after"}getInsertionAnchor(){return this.before}}function $(t,e){let n=e;for(;null!==n&&n.parentNode!==t;)n=n.parentNode;return n}class z extends B{withBefore(t){return new z(this.element,t,this.after)}withAfter(t){return new z(this.element,this.before,t)}withElement(t){return this.element===t?this:new z(t,this.before,this.after)}getInsertionAnchor(){return super.getInsertionAnchor()||this.getManagedLineBreak()}getFirstChildAnchor(){let t=super.getFirstChildAnchor(),e=t?t.nextSibling:this.element.firstChild;for(;K(e);)t=e,e=e.nextSibling;const n=t?t.nextSibling:this.element.firstChild;return null!==n&&n===L()?n:t}getManagedLineBreak(){return this.element.__lexicalLineBreak||null}setManagedLineBreak(t){if(this.element.__lexicalLastChildKind=t,null===t)this.removeManagedLineBreak();else{const e="decorator"===t&&(d||l||a);this.insertManagedLineBreak(e)}}removeManagedLineBreak(){const t=this.getManagedLineBreak();if(t){const e=this.element,n="IMG"===t.nodeName?t.nextSibling:null;n&&e.removeChild(n),e.removeChild(t),e.__lexicalLineBreak=void 0}}insertManagedLineBreak(t){const e=this.getManagedLineBreak();if(e){if(t===("IMG"===e.nodeName))return;this.removeManagedLineBreak()}const n=this.element,o=this.before,r=fl().createElement("br");if(r.setAttribute("data-lexical-managed-linebreak","true"),n.insertBefore(r,o),t){const t=fl().createElement("img");t.setAttribute("data-lexical-managed-linebreak","true"),t.style.setProperty("display","inline","important"),t.style.setProperty("border","0px","important"),t.style.setProperty("margin","0px","important"),t.alt="",n.insertBefore(t,r),n.__lexicalLineBreak=t}else n.__lexicalLin Line truncated
-/*@__INLINE__*/function Te(t){return{type:t}}const Ne=/* @__PURE__ */Te("SELECTION_CHANGE_COMMAND"),ve=/* @__PURE__ */Te("SELECTION_INSERT_CLIPBOARD_NODES_COMMAND"),Ee=/* @__PURE__ */Te("CLICK_COMMAND"),ke=/* @__PURE__ */Te("BEFORE_INPUT_COMMAND"),Oe=/* @__PURE__ */Te("INPUT_COMMAND"),be=/* @__PURE__ */Te("COMPOSITION_START_COMMAND"),Me=/* @__PURE__ */Te("COMPOSITION_END_COMMAND"),Ae=/* @__PURE__ */Te("DELETE_CHARACTER_COMMAND"),De=/* @__PURE__ */Te("INSERT_LINE_BREAK_COMMAND"),we=/* @__PURE__ */Te("INSERT_PARAGRAPH_COMMAND"),Ie=/* @__PURE__ */Te("CONTROLLED_TEXT_INSERTION_COMMAND"),Pe=/* @__PURE__ */Te("PASTE_COMMAND"),Fe=/* @__PURE__ */Te("REMOVE_TEXT_COMMAND"),Re=/* @__PURE__ */Te("DELETE_WORD_COMMAND"),Le=/* @__PURE__ */Te("DELETE_LINE_COMMAND"),Ke=/* @__PURE__ */Te("FORMAT_TEXT_COMMAND"),Be=/* @__PURE__ */Te("SET_TEXT_FORMAT_COMMAND"),$e=/* @__PURE__ */Te("UNDO_COMMAND"),ze=/* @__PURE__ */Te("REDO_COMMAND"),We=/* @__PURE__ */Te("KEYDOWN_COMMAND"),Ue=/* @__PURE__ */Te("KEY_ARROW_RIGHT_COMMAND"),He=/* @__PURE__ */Te("MOVE_TO_END"),je=/* @__PURE__ */Te("KEY_ARROW_LEFT_COMMAND"),Ye=/* @__PURE__ */Te("MOVE_TO_START"),Ge=/* @__PURE__ */Te("KEY_ARROW_UP_COMMAND"),Je=/* @__PURE__ */Te("KEY_ARROW_DOWN_COMMAND"),Ve=/* @__PURE__ */Te("KEY_ENTER_COMMAND"),qe=/* @__PURE__ */Te("KEY_SPACE_COMMAND"),Xe=/* @__PURE__ */Te("KEY_BACKSPACE_COMMAND"),Qe=/* @__PURE__ */Te("KEY_ESCAPE_COMMAND"),Ze=/* @__PURE__ */Te("KEY_DELETE_COMMAND"),tn=/* @__PURE__ */Te("KEY_TAB_COMMAND"),en=/* @__PURE__ */Te("INSERT_TAB_COMMAND"),nn=/* @__PURE__ */Te("INDENT_CONTENT_COMMAND"),on=/* @__PURE__ */Te("OUTDENT_CONTENT_COMMAND"),rn=/* @__PURE__ */Te("DROP_COMMAND"),sn=/* @__PURE__ */Te("FORMAT_ELEMENT_COMMAND"),ln=/* @__PURE__ */Te("DRAGSTART_COMMAND"),cn=/* @__PURE__ */Te("DRAGOVER_COMMAND"),an=/* @__PURE__ */Te("DRAGEND_COMMAND"),un=/* @__PURE__ */Te("COPY_COMMAND"),fn=/* @__PURE__ */Te("CUT_COMMAND"),dn=/* @__PURE__ */Te("SELECT_ALL_COMMAND"),hn=/* @__PURE__ */Te("CLEAR_EDITOR_COMMAND"),gn=/* @__PURE__ */Te("CLEAR_HISTORY_COMMAND"),_n=/* @__PURE__ */Te("CAN_REDO_COMMAND"),pn=/* @__PURE__ */Te("CAN_UNDO_COMMAND"),mn=/* @__PURE__ */Te("FOCUS_COMMAND"),yn=/* @__PURE__ */Te("BLUR_COMMAND"),xn=/* @__PURE__ */Te("KEY_MODIFIER_COMMAND");function Cn(t){const e=new Map;return{dispose(){for(const t of e.values())t.dispose();e.clear()},register(n,o){let r=e.get(n);void 0===r&&(r={dispose:t(n,o),holders:new Set},e.set(n,r));const s=()=>{const t=e.get(n);t&&t.holders.delete(s)&&0===t.holders.size&&(e.delete(n),t.dispose())};return r.holders.add(s),s}}}function Sn(t,e,n,o){return t.addEventListener(e,n,o),t.removeEventListener.bind(t,e,n,o)}const Tn=Object.freeze({}),Nn=[["keydown",function(t,e){const n=e._inputState;n.lastKeyDownTimeStamp=t.timeStamp,n.lastKeyCode=t.key,"Backspace"!==t.key&&Pn(n);if(e.isComposing())return;Bi(e,We,t)}],["pointerdown",function(t,e){const n=Cl(t),o=t.pointerType;Tl(n)&&"touch"!==o&&"pen"!==o&&0===t.button&&is(e,()=>{Hl(n,e)||(e._inputState.isSelectionChangeFromMouseDown=!0)})}],["compositionstart",function(t,e){Bi(e,be,t)}],["compositionend",function(t,e){const n=e._inputState;s?n.compositionPhase="ending-firefox":l||!a&&!d?Bi(e,Me,t):(n.compositionPhase="ending-safari",n.compositionEndData=t.data)}],["input",function(t,e){t.stopPropagation();const n=e._inputState;Pn(n),is(e,()=>{Rn(t,e)||e.dispatchCommand(Oe,t)},{event:t}),n.unprocessedBeforeInputData=null}],["click",function(t,e){is(e,()=>{const n=pr(),o=rl(Gi(e)),r=mr();if(o)if(jo(n)){const t=n.anchor,e=t.getNode();"element"===t.type&&0===t.offset&&n.isCollapsed()&&!gs(e)&&1===mi().getChildrenSize()&&e.getTopLevelElementOrThrow().isEmpty()&&null!==r&&n.is(r)&&(o.removeAllRanges(),n.dirty=!0)}else if("touch"===t.pointerType||"pen"===t.pointerType){const n=gl(o,e._rootElement).anchorNode;if(Sl(n)||ti(n)){yi(_r(r,o,e,t))}}if(s&&null!==o&&0===o.rangeCount){const n=e._rootElement;if(null!==n&&t.target===n){const s=t.clientY;let i=n.childNodes.length;for(let t=0;t<n.childNodes.length;t++){const e=n.childNodes[t];if(Sl(e)){const n=e.getBoundingClientRect();if(s<=(n.top+n.bottom)/2){i=t;break}}}o.setBaseAndExtent(n,i,n,i);const l=_r(r,o,e,t);null!==l?yi(l):o.removeAllRanges()}}Bi(e,Ee,t)})}],["cut",Tn],["copy",Tn],["dragstart",Tn],["dragover",Tn],["dragend",Tn],["paste",Tn],["focus",Tn],["blur",Tn],["drop",Tn]];i&&Nn.push(["beforeinput",(t,e)=>function(t,e){const n=t.inputType;if("deleteCompositionText"===n||s&&Ki(e))return;if("insertCompositionText"===n)return;is(e,()=>{Rn(t,e)||Bi(e,ke,t)},{event:t})}(t,e)]);const vn=new WeakMap,En=new WeakMap,kn=Cn(t=>(t.addEventListener("selectionchange",Yn),()=>t.removeEventListener("selectionchange",Yn)));function On(t,e,n,o,r,s){const l=t.anchor,c=t.focus,a=l.getNode(),u=jr();let f;if(void 0!==s)f=s;else{const t=rl(Gi(u));f=null!==t?gl(t,u._rootElement):null}const d=null!==f?f.anchorNode:null,h=l.key,g=u.getElementByKey(h),_=n.length;return h!==c.key||!wo(a)||(!r&&(!i||u._inputState.lastBeforeInputInsertTextTimeStamp<o+50)||a.isDirty( Line truncated
+/*@__INLINE__*/function Te(t){return{type:t}}const Ne=/* @__PURE__ */Te("SELECTION_CHANGE_COMMAND"),ve=/* @__PURE__ */Te("SELECTION_INSERT_CLIPBOARD_NODES_COMMAND"),Ee=/* @__PURE__ */Te("CLICK_COMMAND"),ke=/* @__PURE__ */Te("BEFORE_INPUT_COMMAND"),Oe=/* @__PURE__ */Te("INPUT_COMMAND"),be=/* @__PURE__ */Te("COMPOSITION_START_COMMAND"),Me=/* @__PURE__ */Te("COMPOSITION_END_COMMAND"),Ae=/* @__PURE__ */Te("DELETE_CHARACTER_COMMAND"),De=/* @__PURE__ */Te("INSERT_LINE_BREAK_COMMAND"),we=/* @__PURE__ */Te("INSERT_PARAGRAPH_COMMAND"),Ie=/* @__PURE__ */Te("CONTROLLED_TEXT_INSERTION_COMMAND"),Pe=/* @__PURE__ */Te("PASTE_COMMAND"),Fe=/* @__PURE__ */Te("REMOVE_TEXT_COMMAND"),Re=/* @__PURE__ */Te("DELETE_WORD_COMMAND"),Le=/* @__PURE__ */Te("DELETE_LINE_COMMAND"),Ke=/* @__PURE__ */Te("FORMAT_TEXT_COMMAND"),Be=/* @__PURE__ */Te("SET_TEXT_FORMAT_COMMAND"),$e=/* @__PURE__ */Te("UNDO_COMMAND"),ze=/* @__PURE__ */Te("REDO_COMMAND"),We=/* @__PURE__ */Te("KEYDOWN_COMMAND"),Ue=/* @__PURE__ */Te("KEY_ARROW_RIGHT_COMMAND"),He=/* @__PURE__ */Te("MOVE_TO_END"),je=/* @__PURE__ */Te("KEY_ARROW_LEFT_COMMAND"),Ye=/* @__PURE__ */Te("MOVE_TO_START"),Ge=/* @__PURE__ */Te("KEY_ARROW_UP_COMMAND"),Je=/* @__PURE__ */Te("KEY_ARROW_DOWN_COMMAND"),Ve=/* @__PURE__ */Te("KEY_ENTER_COMMAND"),qe=/* @__PURE__ */Te("KEY_SPACE_COMMAND"),Xe=/* @__PURE__ */Te("KEY_BACKSPACE_COMMAND"),Qe=/* @__PURE__ */Te("KEY_ESCAPE_COMMAND"),Ze=/* @__PURE__ */Te("KEY_DELETE_COMMAND"),tn=/* @__PURE__ */Te("KEY_TAB_COMMAND"),en=/* @__PURE__ */Te("INSERT_TAB_COMMAND"),nn=/* @__PURE__ */Te("INDENT_CONTENT_COMMAND"),on=/* @__PURE__ */Te("OUTDENT_CONTENT_COMMAND"),rn=/* @__PURE__ */Te("DROP_COMMAND"),sn=/* @__PURE__ */Te("FORMAT_ELEMENT_COMMAND"),ln=/* @__PURE__ */Te("DRAGSTART_COMMAND"),cn=/* @__PURE__ */Te("DRAGOVER_COMMAND"),an=/* @__PURE__ */Te("DRAGEND_COMMAND"),un=/* @__PURE__ */Te("COPY_COMMAND"),fn=/* @__PURE__ */Te("CUT_COMMAND"),dn=/* @__PURE__ */Te("SELECT_ALL_COMMAND"),hn=/* @__PURE__ */Te("CLEAR_EDITOR_COMMAND"),gn=/* @__PURE__ */Te("CLEAR_HISTORY_COMMAND"),_n=/* @__PURE__ */Te("CAN_REDO_COMMAND"),pn=/* @__PURE__ */Te("CAN_UNDO_COMMAND"),mn=/* @__PURE__ */Te("FOCUS_COMMAND"),yn=/* @__PURE__ */Te("BLUR_COMMAND"),xn=/* @__PURE__ */Te("KEY_MODIFIER_COMMAND");function Cn(t){const e=new Map;return{dispose(){for(const t of e.values())t.dispose();e.clear()},register(n,o){let r=e.get(n);void 0===r&&(r={dispose:t(n,o),holders:new Set},e.set(n,r));const s=()=>{const t=e.get(n);t&&t.holders.delete(s)&&0===t.holders.size&&(e.delete(n),t.dispose())};return r.holders.add(s),s}}}function Sn(t,e,n,o){return t.addEventListener(e,n,o),t.removeEventListener.bind(t,e,n,o)}const Tn=Object.freeze({}),Nn=[["keydown",function(t,e){const n=e._inputState;n.lastKeyDownTimeStamp=t.timeStamp,n.lastKeyCode=t.key,"Backspace"!==t.key&&Pn(n);if(e.isComposing())return;Bi(e,We,t)}],["pointerdown",function(t,e){const n=Cl(t),o=t.pointerType;Tl(n)&&"touch"!==o&&"pen"!==o&&0===t.button&&is(e,()=>{Hl(n,e)||(e._inputState.isSelectionChangeFromMouseDown=!0)})}],["compositionstart",function(t,e){Bi(e,be,t)}],["compositionend",function(t,e){const n=e._inputState;s?n.compositionPhase="ending-firefox":l||!a&&!d?Bi(e,Me,t):(n.compositionPhase="ending-safari",n.compositionEndData=t.data)}],["input",function(t,e){t.stopPropagation();const n=e._inputState;Pn(n),is(e,()=>{Rn(t,e)||e.dispatchCommand(Oe,t)},{event:t}),n.unprocessedBeforeInputData=null}],["click",function(t,e){is(e,()=>{const n=pr(),o=rl(Gi(e)),r=mr();if(o)if(jo(n)){const t=n.anchor,e=t.getNode();"element"===t.type&&0===t.offset&&n.isCollapsed()&&!gs(e)&&1===mi().getChildrenSize()&&e.getTopLevelElementOrThrow().isEmpty()&&null!==r&&n.is(r)&&(o.removeAllRanges(),n.dirty=!0)}else if("touch"===t.pointerType||"pen"===t.pointerType){const n=gl(o,e._rootElement).anchorNode;if(Sl(n)||ti(n)){yi(_r(r,o,e,t))}}if(s&&null!==o&&0===o.rangeCount){const n=e._rootElement;if(null!==n&&t.target===n){const s=t.clientY;let i=n.childNodes.length;for(let t=0;t<n.childNodes.length;t++){const e=n.childNodes[t];if(Sl(e)){const n=e.getBoundingClientRect();if(s<=(n.top+n.bottom)/2){i=t;break}}}o.setBaseAndExtent(n,i,n,i);const l=_r(r,o,e,t);null!==l?yi(l):o.removeAllRanges()}}Bi(e,Ee,t)})}],["cut",Tn],["copy",Tn],["dragstart",Tn],["dragover",Tn],["dragend",Tn],["paste",Tn],["focus",Tn],["blur",Tn],["drop",Tn]];i&&Nn.push(["beforeinput",(t,e)=>function(t,e){const n=t.inputType;if("deleteCompositionText"===n||s&&Ki(e))return;if("insertCompositionText"===n)return;is(e,()=>{Rn(t,e)||Bi(e,ke,t)},{event:t})}(t,e)]);const vn=new WeakMap,En=new WeakMap,kn=Cn(t=>(t.addEventListener("selectionchange",Yn),()=>t.removeEventListener("selectionchange",Yn)));function On(t,e,n,o,r,s){const l=t.anchor,c=t.focus,a=l.getNode(),u=jr();let f;if(void 0!==s)f=s;else{const t=rl(Gi(u));f=null!==t?gl(t,u._rootElement):null}const d=null!==f?f.anchorNode:null,h=l.key,g=u.getElementByKey(h),_=n.length;return h!==c.key||!wo(a)||(!r&&(!i||u._inputState.lastBeforeInputInsertTextTimeStamp<o+50)||a.isDirty( Line truncated
diff --git a/dist/Lexical.prod.mjs b/dist/Lexical.prod.mjs
index e2ef2d833f8403e4288764ee109759431c236481..38adb8318370c61ea67a32275227a863b6f68052 100644
--- a/dist/Lexical.prod.mjs
+++ b/dist/Lexical.prod.mjs
@@ -9,2 +9,2 @@
function t(t,...e){const n=new URL("https://lexical.dev/docs/error"),o=new URLSearchParams;o.append("code",t);for(const t of e)o.append("v",t);throw n.search=o.toString(),Error(`Minified Lexical error #${t}; visit ${n.toString()} for the full message or use the non-minified dev environment for full errors and additional helpful warnings.`)}function e(t,...e){const n=new URL("https://lexical.dev/docs/error"),o=new URLSearchParams;o.append("code",t);for(const t of e)o.append("v",t);n.search=o.toString(),console.warn(`Minified Lexical warning #${t}; visit ${n.toString()} for the full message or use the non-minified dev environment for full errors and additional helpful warnings.`)}const n="undefined"!=typeof window&&void 0!==window.document&&void 0!==window.document.createElement,o=n&&"documentMode"in document?document.documentMode:null,r=n&&/Mac|iPod|iPhone|iPad/.test(navigator.platform),i=n&&/^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent),s=!(!n||!("InputEvent"in window)||o)&&"getTargetRanges"in new window.InputEvent("input"),l=n&&/iPad|iPhone|iPod/.test(navigator.userAgent)&&!window.MSStream,c=n&&/Android/.test(navigator.userAgent),a=n&&/Version\/[\d.]+.*Safari/.test(navigator.userAgent)&&!c,u=n&&/^(?=.*Chrome).*/i.test(navigator.userAgent),f=n&&c&&u,d=n&&/AppleWebKit\/[\d.]+/.test(navigator.userAgent)&&r&&!u,h=0,g=1,_=2,p=1,y=2,m=4,x=8,C=16,S=32,v=64,T=128,k=2047,b=1,N=2,E=3,w=4,O=5,M=6,A=a||l||d?"\xa0":"\u200b",D="\n\n",F=i?"\xa0":A,P="\u0591-\u07ff\ufb1d-\ufdfd\ufe70-\ufefc",I="A-Za-z\xc0-\xd6\xd8-\xf6\xf8-\u02b8\u0300-\u0590\u0800-\u1fff\u200e\u2c00-\ufb1c\ufe00-\ufe6f\ufefd-\uffff",L=new RegExp("^[^"+I+"]*["+P+"]"),K=new RegExp("^[^"+P+"]*["+I+"]"),z={bold:1,capitalize:1024,code:16,highlight:T,italic:2,lowercase:256,strikethrough:4,subscript:32,superscript:64,underline:8,uppercase:512},B={directionless:1,unmergeable:2},R={center:2,end:6,justify:4,left:1,right:3,start:5},W={[N]:"center",[M]:"end",[w]:"justify",[b]:"left",[E]:"right",[O]:"start"},$={normal:0,segmented:2,token:1},U={[h]:"normal",[_]:"segmented",[g]:"token"},H="$",j="$config";function J(){return vc()._blockCursorElement}function V(t){return null!==t&&1===t.nodeType&&t.hasAttribute("data-lexical-slot")}class q{element;before;after;constructor(t,e,n){this.element=t,this.before=e||null,this.after=n||null}withBefore(t){return new q(this.element,t,this.after)}withAfter(t){return new q(this.element,this.before,t)}withElement(t){return this.element===t?this:new q(t,this.before,this.after)}insertChild(e){const n=this.getInsertionAnchor();return null!==n&&n.parentElement!==this.element&&t(357),this.element.insertBefore(e,n),this}removeChild(e){return e.parentElement!==this.element&&t(358),this.element.removeChild(e),this}replaceChild(e,n){return n.parentElement!==this.element&&t(359),this.element.replaceChild(e,n),this}getFirstChild(){const t=this.getFirstChildAnchor(),e=t?t.nextSibling:this.element.firstChild;return e===this.getInsertionAnchor()?null:e}getFirstChildAnchor(){return this.after}resolveLeafPosition(t,e,n){if(this.element===t)return e===t&&0===n?"before":"after";const o=Y(t,this.element);if(null===o)return"after";const r=Array.prototype.indexOf.call(t.childNodes,o);if(r<0)return"after";if(e===t)return n<=r?"before":"after";const i=Y(t,e);if(null===i)return"after";const s=Array.prototype.indexOf.call(t.childNodes,i);return s>=0&&s<=r?"before":"after"}getInsertionAnchor(){return this.before}}function Y(t,e){let n=e;for(;null!==n&&n.parentNode!==t;)n=n.parentNode;return n}class G extends q{withBefore(t){return new G(this.element,t,this.after)}withAfter(t){return new G(this.element,this.before,t)}withElement(t){return this.element===t?this:new G(t,this.before,this.after)}getInsertionAnchor(){return super.getInsertionAnchor()||this.getManagedLineBreak()}getFirstChildAnchor(){let t=super.getFirstChildAnchor(),e=t?t.nextSibling:this.element.firstChild;for(;V(e);)t=e,e=e.nextSibling;const n=t?t.nextSibling:this.element.firstChild;return null!==n&&n===J()?n:t}getManagedLineBreak(){return this.element.__lexicalLineBreak||null}setManagedLineBreak(t){if(this.element.__lexicalLastChildKind=t,null===t)this.removeManagedLineBreak();else{const e="decorator"===t&&(d||l||a);this.insertManagedLineBreak(e)}}removeManagedLineBreak(){const t=this.getManagedLineBreak();if(t){const e=this.element,n="IMG"===t.nodeName?t.nextSibling:null;n&&e.removeChild(n),e.removeChild(t),e.__lexicalLineBreak=void 0}}insertManagedLineBreak(t){const e=this.getManagedLineBreak();if(e){if(t===("IMG"===e.nodeName))return;this.removeManagedLineBreak()}const n=this.element,o=this.before,r=Zl().createElement("br");if(r.setAttribute("data-lexical-managed-linebreak","true"),n.insertBefore(r,o),t){const t=Zl().createElement("img");t.setAttribute("data-lexical-managed-linebreak","true"),t.style.setProperty("display","inline","important"),t.style.setProperty("border","0px","important"),t.style.setProperty("margin","0px","important"),t.alt="",n.insertBefore(t,r),n.__lexical Line truncated
-/*@__INLINE__*/function Pe(t){return{type:t}}const Ie=/* @__PURE__ */Pe("SELECTION_CHANGE_COMMAND"),Le=/* @__PURE__ */Pe("SELECTION_INSERT_CLIPBOARD_NODES_COMMAND"),Ke=/* @__PURE__ */Pe("CLICK_COMMAND"),ze=/* @__PURE__ */Pe("BEFORE_INPUT_COMMAND"),Be=/* @__PURE__ */Pe("INPUT_COMMAND"),Re=/* @__PURE__ */Pe("COMPOSITION_START_COMMAND"),We=/* @__PURE__ */Pe("COMPOSITION_END_COMMAND"),$e=/* @__PURE__ */Pe("DELETE_CHARACTER_COMMAND"),Ue=/* @__PURE__ */Pe("INSERT_LINE_BREAK_COMMAND"),He=/* @__PURE__ */Pe("INSERT_PARAGRAPH_COMMAND"),je=/* @__PURE__ */Pe("CONTROLLED_TEXT_INSERTION_COMMAND"),Je=/* @__PURE__ */Pe("PASTE_COMMAND"),Ve=/* @__PURE__ */Pe("REMOVE_TEXT_COMMAND"),qe=/* @__PURE__ */Pe("DELETE_WORD_COMMAND"),Ye=/* @__PURE__ */Pe("DELETE_LINE_COMMAND"),Ge=/* @__PURE__ */Pe("FORMAT_TEXT_COMMAND"),Xe=/* @__PURE__ */Pe("SET_TEXT_FORMAT_COMMAND"),Qe=/* @__PURE__ */Pe("UNDO_COMMAND"),Ze=/* @__PURE__ */Pe("REDO_COMMAND"),tn=/* @__PURE__ */Pe("KEYDOWN_COMMAND"),en=/* @__PURE__ */Pe("KEY_ARROW_RIGHT_COMMAND"),nn=/* @__PURE__ */Pe("MOVE_TO_END"),on=/* @__PURE__ */Pe("KEY_ARROW_LEFT_COMMAND"),rn=/* @__PURE__ */Pe("MOVE_TO_START"),sn=/* @__PURE__ */Pe("KEY_ARROW_UP_COMMAND"),ln=/* @__PURE__ */Pe("KEY_ARROW_DOWN_COMMAND"),cn=/* @__PURE__ */Pe("KEY_ENTER_COMMAND"),an=/* @__PURE__ */Pe("KEY_SPACE_COMMAND"),un=/* @__PURE__ */Pe("KEY_BACKSPACE_COMMAND"),fn=/* @__PURE__ */Pe("KEY_ESCAPE_COMMAND"),dn=/* @__PURE__ */Pe("KEY_DELETE_COMMAND"),hn=/* @__PURE__ */Pe("KEY_TAB_COMMAND"),gn=/* @__PURE__ */Pe("INSERT_TAB_COMMAND"),_n=/* @__PURE__ */Pe("INDENT_CONTENT_COMMAND"),pn=/* @__PURE__ */Pe("OUTDENT_CONTENT_COMMAND"),yn=/* @__PURE__ */Pe("DROP_COMMAND"),mn=/* @__PURE__ */Pe("FORMAT_ELEMENT_COMMAND"),xn=/* @__PURE__ */Pe("DRAGSTART_COMMAND"),Cn=/* @__PURE__ */Pe("DRAGOVER_COMMAND"),Sn=/* @__PURE__ */Pe("DRAGEND_COMMAND"),vn=/* @__PURE__ */Pe("COPY_COMMAND"),Tn=/* @__PURE__ */Pe("CUT_COMMAND"),kn=/* @__PURE__ */Pe("SELECT_ALL_COMMAND"),bn=/* @__PURE__ */Pe("CLEAR_EDITOR_COMMAND"),Nn=/* @__PURE__ */Pe("CLEAR_HISTORY_COMMAND"),En=/* @__PURE__ */Pe("CAN_REDO_COMMAND"),wn=/* @__PURE__ */Pe("CAN_UNDO_COMMAND"),On=/* @__PURE__ */Pe("FOCUS_COMMAND"),Mn=/* @__PURE__ */Pe("BLUR_COMMAND"),An=/* @__PURE__ */Pe("KEY_MODIFIER_COMMAND");function Dn(t){const e=new Map;return{dispose(){for(const t of e.values())t.dispose();e.clear()},register(n,o){let r=e.get(n);void 0===r&&(r={dispose:t(n,o),holders:new Set},e.set(n,r));const i=()=>{const t=e.get(n);t&&t.holders.delete(i)&&0===t.holders.size&&(e.delete(n),t.dispose())};return r.holders.add(i),i}}}function Fn(t,e,n,o){return t.addEventListener(e,n,o),t.removeEventListener.bind(t,e,n,o)}const Pn=Object.freeze({}),In=[["keydown",function(t,e){const n=e._inputState;n.lastKeyDownTimeStamp=t.timeStamp,n.lastKeyCode=t.key,"Backspace"!==t.key&&Jn(n);if(e.isComposing())return;Nl(e,tn,t)}],["pointerdown",function(t,e){const n=ac(t),o=t.pointerType;_c(n)&&"touch"!==o&&"pen"!==o&&0===t.button&&Ai(e,()=>{Rc(n,e)||(e._inputState.isSelectionChangeFromMouseDown=!0)})}],["compositionstart",function(t,e){Nl(e,Re,t)}],["compositionend",function(t,e){const n=e._inputState;i?n.compositionPhase="ending-firefox":l||!a&&!d?Nl(e,We,t):(n.compositionPhase="ending-safari",n.compositionEndData=t.data)}],["input",function(t,e){t.stopPropagation();const n=e._inputState;Jn(n),Ai(e,()=>{qn(t,e)||e.dispatchCommand(Be,t)},{event:t}),n.unprocessedBeforeInputData=null}],["click",function(t,e){Ai(e,()=>{const n=Kr(),o=Jl(Il(e)),r=zr();if(o)if(ur(n)){const t=n.anchor,e=t.getNode();"element"===t.type&&0===t.offset&&n.isCollapsed()&&!Bi(e)&&1===nl().getChildrenSize()&&e.getTopLevelElementOrThrow().isEmpty()&&null!==r&&n.is(r)&&(o.removeAllRanges(),n.dirty=!0)}else if("touch"===t.pointerType||"pen"===t.pointerType){const n=nc(o,e._rootElement).anchorNode;if(gc(n)||zs(n)){ol(Lr(r,o,e,t))}}if(i&&null!==o&&0===o.rangeCount){const n=e._rootElement;if(null!==n&&t.target===n){const i=t.clientY;let s=n.childNodes.length;for(let t=0;t<n.childNodes.length;t++){const e=n.childNodes[t];if(gc(e)){const n=e.getBoundingClientRect();if(i<=(n.top+n.bottom)/2){s=t;break}}}o.setBaseAndExtent(n,s,n,s);const l=Lr(r,o,e,t);null!==l?ol(l):o.removeAllRanges()}}Nl(e,Ke,t)})}],["cut",Pn],["copy",Pn],["dragstart",Pn],["dragover",Pn],["dragend",Pn],["paste",Pn],["focus",Pn],["blur",Pn],["drop",Pn]];s&&In.push(["beforeinput",(t,e)=>function(t,e){const n=t.inputType;if("deleteCompositionText"===n||i&&bl(e))return;if("insertCompositionText"===n)return;Ai(e,()=>{qn(t,e)||Nl(e,ze,t)},{event:t})}(t,e)]);const Ln=new WeakMap,Kn=new WeakMap,zn=Dn(t=>(t.addEventListener("selectionchange",ro),()=>t.removeEventListener("selectionchange",ro)));function Bn(t,e,n,o,r,i){const l=t.anchor,c=t.focus,a=l.getNode(),u=_i();let f;if(void 0!==i)f=i;else{const t=Jl(Il(u));f=null!==t?nc(t,u._rootElement):null}const d=null!==f?f.anchorNode:null,h=l.key,g=u.getElementByKey(h),_=n.length;return h!==c.key||!Xo(a)||(!r&&(!s||u._inputState.lastBeforeInputInsertTextTimeStamp<o+50)||a.isDirty( Line truncated
+/*@__INLINE__*/function Pe(t){return{type:t}}const Ie=/* @__PURE__ */Pe("SELECTION_CHANGE_COMMAND"),Le=/* @__PURE__ */Pe("SELECTION_INSERT_CLIPBOARD_NODES_COMMAND"),Ke=/* @__PURE__ */Pe("CLICK_COMMAND"),ze=/* @__PURE__ */Pe("BEFORE_INPUT_COMMAND"),Be=/* @__PURE__ */Pe("INPUT_COMMAND"),Re=/* @__PURE__ */Pe("COMPOSITION_START_COMMAND"),We=/* @__PURE__ */Pe("COMPOSITION_END_COMMAND"),$e=/* @__PURE__ */Pe("DELETE_CHARACTER_COMMAND"),Ue=/* @__PURE__ */Pe("INSERT_LINE_BREAK_COMMAND"),He=/* @__PURE__ */Pe("INSERT_PARAGRAPH_COMMAND"),je=/* @__PURE__ */Pe("CONTROLLED_TEXT_INSERTION_COMMAND"),Je=/* @__PURE__ */Pe("PASTE_COMMAND"),Ve=/* @__PURE__ */Pe("REMOVE_TEXT_COMMAND"),qe=/* @__PURE__ */Pe("DELETE_WORD_COMMAND"),Ye=/* @__PURE__ */Pe("DELETE_LINE_COMMAND"),Ge=/* @__PURE__ */Pe("FORMAT_TEXT_COMMAND"),Xe=/* @__PURE__ */Pe("SET_TEXT_FORMAT_COMMAND"),Qe=/* @__PURE__ */Pe("UNDO_COMMAND"),Ze=/* @__PURE__ */Pe("REDO_COMMAND"),tn=/* @__PURE__ */Pe("KEYDOWN_COMMAND"),en=/* @__PURE__ */Pe("KEY_ARROW_RIGHT_COMMAND"),nn=/* @__PURE__ */Pe("MOVE_TO_END"),on=/* @__PURE__ */Pe("KEY_ARROW_LEFT_COMMAND"),rn=/* @__PURE__ */Pe("MOVE_TO_START"),sn=/* @__PURE__ */Pe("KEY_ARROW_UP_COMMAND"),ln=/* @__PURE__ */Pe("KEY_ARROW_DOWN_COMMAND"),cn=/* @__PURE__ */Pe("KEY_ENTER_COMMAND"),an=/* @__PURE__ */Pe("KEY_SPACE_COMMAND"),un=/* @__PURE__ */Pe("KEY_BACKSPACE_COMMAND"),fn=/* @__PURE__ */Pe("KEY_ESCAPE_COMMAND"),dn=/* @__PURE__ */Pe("KEY_DELETE_COMMAND"),hn=/* @__PURE__ */Pe("KEY_TAB_COMMAND"),gn=/* @__PURE__ */Pe("INSERT_TAB_COMMAND"),_n=/* @__PURE__ */Pe("INDENT_CONTENT_COMMAND"),pn=/* @__PURE__ */Pe("OUTDENT_CONTENT_COMMAND"),yn=/* @__PURE__ */Pe("DROP_COMMAND"),mn=/* @__PURE__ */Pe("FORMAT_ELEMENT_COMMAND"),xn=/* @__PURE__ */Pe("DRAGSTART_COMMAND"),Cn=/* @__PURE__ */Pe("DRAGOVER_COMMAND"),Sn=/* @__PURE__ */Pe("DRAGEND_COMMAND"),vn=/* @__PURE__ */Pe("COPY_COMMAND"),Tn=/* @__PURE__ */Pe("CUT_COMMAND"),kn=/* @__PURE__ */Pe("SELECT_ALL_COMMAND"),bn=/* @__PURE__ */Pe("CLEAR_EDITOR_COMMAND"),Nn=/* @__PURE__ */Pe("CLEAR_HISTORY_COMMAND"),En=/* @__PURE__ */Pe("CAN_REDO_COMMAND"),wn=/* @__PURE__ */Pe("CAN_UNDO_COMMAND"),On=/* @__PURE__ */Pe("FOCUS_COMMAND"),Mn=/* @__PURE__ */Pe("BLUR_COMMAND"),An=/* @__PURE__ */Pe("KEY_MODIFIER_COMMAND");function Dn(t){const e=new Map;return{dispose(){for(const t of e.values())t.dispose();e.clear()},register(n,o){let r=e.get(n);void 0===r&&(r={dispose:t(n,o),holders:new Set},e.set(n,r));const i=()=>{const t=e.get(n);t&&t.holders.delete(i)&&0===t.holders.size&&(e.delete(n),t.dispose())};return r.holders.add(i),i}}}function Fn(t,e,n,o){return t.addEventListener(e,n,o),t.removeEventListener.bind(t,e,n,o)}const Pn=Object.freeze({}),In=[["keydown",function(t,e){const n=e._inputState;n.lastKeyDownTimeStamp=t.timeStamp,n.lastKeyCode=t.key,"Backspace"!==t.key&&Jn(n);if(e.isComposing())return;Nl(e,tn,t)}],["pointerdown",function(t,e){const n=ac(t),o=t.pointerType;_c(n)&&"touch"!==o&&"pen"!==o&&0===t.button&&Ai(e,()=>{Rc(n,e)||(e._inputState.isSelectionChangeFromMouseDown=!0)})}],["compositionstart",function(t,e){Nl(e,Re,t)}],["compositionend",function(t,e){const n=e._inputState;i?n.compositionPhase="ending-firefox":l||!a&&!d?Nl(e,We,t):(n.compositionPhase="ending-safari",n.compositionEndData=t.data)}],["input",function(t,e){t.stopPropagation();const n=e._inputState;Jn(n),Ai(e,()=>{qn(t,e)||e.dispatchCommand(Be,t)},{event:t}),n.unprocessedBeforeInputData=null}],["click",function(t,e){Ai(e,()=>{const n=Kr(),o=Jl(Il(e)),r=zr();if(o)if(ur(n)){const t=n.anchor,e=t.getNode();"element"===t.type&&0===t.offset&&n.isCollapsed()&&!Bi(e)&&1===nl().getChildrenSize()&&e.getTopLevelElementOrThrow().isEmpty()&&null!==r&&n.is(r)&&(o.removeAllRanges(),n.dirty=!0)}else if("touch"===t.pointerType||"pen"===t.pointerType){const n=nc(o,e._rootElement).anchorNode;if(gc(n)||zs(n)){ol(Lr(r,o,e,t))}}if(i&&null!==o&&0===o.rangeCount){const n=e._rootElement;if(null!==n&&t.target===n){const i=t.clientY;let s=n.childNodes.length;for(let t=0;t<n.childNodes.length;t++){const e=n.childNodes[t];if(gc(e)){const n=e.getBoundingClientRect();if(i<=(n.top+n.bottom)/2){s=t;break}}}o.setBaseAndExtent(n,s,n,s);const l=Lr(r,o,e,t);null!==l?ol(l):o.removeAllRanges()}}Nl(e,Ke,t)})}],["cut",Pn],["copy",Pn],["dragstart",Pn],["dragover",Pn],["dragend",Pn],["paste",Pn],["focus",Pn],["blur",Pn],["drop",Pn]];s&&In.push(["beforeinput",(t,e)=>function(t,e){const n=t.inputType;if("deleteCompositionText"===n||i&&bl(e))return;if("insertCompositionText"===n)return;Ai(e,()=>{qn(t,e)||Nl(e,ze,t)},{event:t})}(t,e)]);const Ln=new WeakMap,Kn=new WeakMap,zn=Dn(t=>(t.addEventListener("selectionchange",ro),()=>t.removeEventListener("selectionchange",ro)));function Bn(t,e,n,o,r,i){const l=t.anchor,c=t.focus,a=l.getNode(),u=_i();let f;if(void 0!==i)f=i;else{const t=Jl(Il(u));f=null!==t?nc(t,u._rootElement):null}const d=null!==f?f.anchorNode:null,h=l.key,g=u.getElementByKey(h),_=n.length;return h!==c.key||!Xo(a)||(!r&&(!s||u._inputState.lastBeforeInputInsertTextTimeStamp<o+50)||a.isDirty( Line truncated
diff --git a/src/LexicalUtils.ts b/src/LexicalUtils.ts
index 68509f1a85808d87999aa52183157258e39f97a1..661334b9d5ae3f67d25cef64527939db2e16e706 100644
--- a/src/LexicalUtils.ts
+++ b/src/LexicalUtils.ts
@@ -1638,7 +1638,8 @@ export function scrollIntoViewIfNeeded(
// or the caret stays behind the keyboard.
const visualViewport = defaultView.visualViewport;
if (visualViewport) {
- const offsetTop = visualViewport.offsetTop;
+ // Match getBoundingClientRect coordinates when a keyboard pans the page.
+ const offsetTop = visualViewport.pageTop - defaultView.scrollY;
targetTop = offsetTop;
targetBottom = offsetTop + visualViewport.height;
} else {
+14 -13
View File
@@ -18,6 +18,7 @@ patchedDependencies:
'@yao-pkg/[email protected]': 4d4861969595629311f7d90a0b34b15481c96d5682d828a151a104c69d4616d3
[email protected]: b70dc2a2a78ffabb80a4f14fafc7c103237f2b0a207e1f296fa1c1387ea29348
[email protected]: c1bf8ac8f319dab382f08e3a669b715e691d46c703ac5a4163eb383fbedd1d48
[email protected]: 54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356
[email protected]: 9d30053266d0f39d3debe894c675867f6782da6bcadbc94a693b3cfb9eff8ed0
importers:
@@ -4118,7 +4119,7 @@ importers:
version: 2.1.1
lexical:
specifier: ^0.49.0
version: 0.49.0([email protected])
version: 0.49.0(patch_hash=54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356)([email protected])
react:
specifier: ^18.2.0
version: 18.3.1
@@ -26462,14 +26463,14 @@ snapshots:
'@lexical/selection': 0.49.0([email protected])
'@lexical/utils': 0.49.0([email protected])
'@types/trusted-types': 2.0.7
lexical: 0.49.0([email protected])
lexical: 0.49.0(patch_hash=54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356)([email protected])
optionalDependencies:
typescript: 6.0.3
'@lexical/[email protected]([email protected])':
dependencies:
'@lexical/extension': 0.49.0([email protected])
lexical: 0.49.0([email protected])
lexical: 0.49.0(patch_hash=54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356)([email protected])
optionalDependencies:
typescript: 6.0.3
@@ -26478,7 +26479,7 @@ snapshots:
'@lexical/internal': 0.49.0([email protected])
'@lexical/utils': 0.49.0([email protected])
'@preact/signals-core': 1.14.4
lexical: 0.49.0([email protected])
lexical: 0.49.0(patch_hash=54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356)([email protected])
optionalDependencies:
typescript: 6.0.3
@@ -26486,7 +26487,7 @@ snapshots:
dependencies:
'@lexical/internal': 0.49.0([email protected])
happy-dom: 20.11.6
lexical: 0.49.0([email protected])
lexical: 0.49.0(patch_hash=54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356)([email protected])
optionalDependencies:
typescript: 6.0.3
transitivePeerDependencies:
@@ -26497,7 +26498,7 @@ snapshots:
dependencies:
'@lexical/extension': 0.49.0([email protected])
'@lexical/utils': 0.49.0([email protected])
lexical: 0.49.0([email protected])
lexical: 0.49.0(patch_hash=54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356)([email protected])
optionalDependencies:
typescript: 6.0.3
@@ -26507,7 +26508,7 @@ snapshots:
'@lexical/internal': 0.49.0([email protected])
'@lexical/selection': 0.49.0([email protected])
'@lexical/utils': 0.49.0([email protected])
lexical: 0.49.0([email protected])
lexical: 0.49.0(patch_hash=54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356)([email protected])
optionalDependencies:
typescript: 6.0.3
@@ -26521,7 +26522,7 @@ snapshots:
'@lexical/html': 0.49.0([email protected])
'@lexical/internal': 0.49.0([email protected])
'@lexical/utils': 0.49.0([email protected])
lexical: 0.49.0([email protected])
lexical: 0.49.0(patch_hash=54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356)([email protected])
optionalDependencies:
typescript: 6.0.3
@@ -26532,21 +26533,21 @@ snapshots:
'@lexical/extension': 0.49.0([email protected])
'@lexical/selection': 0.49.0([email protected])
'@lexical/utils': 0.49.0([email protected])
lexical: 0.49.0([email protected])
lexical: 0.49.0(patch_hash=54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356)([email protected])
optionalDependencies:
typescript: 6.0.3
'@lexical/[email protected]([email protected])':
dependencies:
'@lexical/internal': 0.49.0([email protected])
lexical: 0.49.0([email protected])
lexical: 0.49.0(patch_hash=54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356)([email protected])
optionalDependencies:
typescript: 6.0.3
'@lexical/[email protected]([email protected])':
dependencies:
'@lexical/internal': 0.49.0([email protected])
lexical: 0.49.0([email protected])
lexical: 0.49.0(patch_hash=54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356)([email protected])
optionalDependencies:
typescript: 6.0.3
@@ -26554,7 +26555,7 @@ snapshots:
dependencies:
'@lexical/internal': 0.49.0([email protected])
'@lexical/selection': 0.49.0([email protected])
lexical: 0.49.0([email protected])
lexical: 0.49.0(patch_hash=54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356)([email protected])
optionalDependencies:
typescript: 6.0.3
@@ -30420,7 +30421,7 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
[email protected]([email protected]):
[email protected](patch_hash=54c4413db208cf234cb68c6a837f2523d3b319aa17911f1da385af08362e1356)([email protected]):
dependencies:
'@lexical/internal': 0.49.0([email protected])
optionalDependencies:
+1
View File