refactor(client): address zoom controls review

Derive the displayed zoom during render instead of syncing the prop through
an effect, reference DOCUMENT_ZOOM directly in both packages, cover the
unfitted non-preset state, and list ZoomControls among localized primitives.
This commit is contained in:
yudshj committed 2026-10-22 12:46:05 +08:00
1 parent f70a511cca
commit 0b5ff6ff02
8 files changed
+44 -39

No files matched your search

@@ -18,8 +18,8 @@
en: d2f5701274be5110
zh: d60e2617136a6f26
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
en: 0e5a5bd929dca916
zh: bf123db2eac3dd97
en: 14f83bcaf1bd2c39
zh: 0ee691179bcb6d1e
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
en: fff68b60f4abdc7e
zh: 5cf5952ec78f7f91
@@ -27,8 +27,8 @@
en: 02f310acbae29955
zh: 1e41393d5d714b2d
/deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy:
en: 4aef65dea11de083
zh: abc22cd7c65cdf38
en: a943603b74080ef5
zh: 43f7a0188aff271b
/deepseek-ai-dsh-client-ui-primitives/understand-the-implementation:
en: 846ac0f563a53a64
zh: bb89d7647bdcc1ab
+2 -2
View File
@@ -46,7 +46,7 @@ Check this table before writing a control in a feature package. A plugin cannot
| `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. |
| `Input` | Single-line text entry for search boxes and inline forms. Its ref targets the native input for focus and is cleared on unmount. |
| `InlineEditor` | Autofocused multi-line inline editor for short drafts such as queued messages and goals. It grows with its text and its width up to six lines, then scrolls; Enter saves, Shift+Enter breaks the line, Enter during IME composition does nothing, and Escape cancels. The caller supplies the localized `label` and rebinds the scrollbar on an elevated surface. |
| `ZoomControls`, `DOCUMENT_ZOOM` | Floating document zoom controls with localized labels, optional fitting, fixed presets, and visibility/activity callbacks. Renderers share the published scale limits. |
| `ZoomControls`, `DOCUMENT_ZOOM` | Floating document zoom controls with localized labels, fixed presets, and visibility/activity callbacks. An omitted `fit` hides the fitting row. Renderers share the published scale limits. |
| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. |
| `MenuGroup`, `observeStickyMenuGroups` | Localized, accessible groups for custom menus and listboxes, with shared sticky-heading styling and native intersection/size observation; the caller owns the observer's lifetime. |
| `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. |
@@ -124,7 +124,7 @@ The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and
### Localizing copy
The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface.
The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, and `ZoomControls` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface.
-----
+2 -2
View File
@@ -46,7 +46,7 @@ kind: "package-library"
| `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 |
| `Input` | 单行文本输入,用于搜索框与行内表单。ref 指向原生输入框,供焦点控制使用,并在卸载时清空。 |
| `InlineEditor` | 自动聚焦的多行行内编辑器,用于排队消息、goal 等短草稿。它随文本和宽度增高,最多六行,之后滚动;Enter 保存,Shift+Enter 换行,输入法组合中的 Enter 不生效,Escape 取消。调用方提供本地化 `label`,并在 elevated 表面上重绑滚动条。 |
| `ZoomControls`, `DOCUMENT_ZOOM` | 悬浮文档缩放控件,接收本地化标签、可选适配操作、固定预设,以及可见性和交互状态回调。渲染器共用导出的缩放范围。 |
| `ZoomControls`, `DOCUMENT_ZOOM` | 悬浮文档缩放控件,接收本地化标签、固定预设,以及可见性和交互状态回调。省略 `fit` 时不显示适配项。渲染器共用导出的缩放范围。 |
| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 |
| `MenuGroup`、`observeStickyMenuGroups` | 为自定义菜单与列表框提供本地化、可访问的分组,共用吸顶标题样式与原生交叉/尺寸观察;调用方负责观察器的生命周期。 |
| `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 |
@@ -124,7 +124,7 @@ kind: "package-library"
### 本地化文案
这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 与 `WebBlock` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。
这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock` 与 `ZoomControls` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。
-----
@@ -1,4 +1,5 @@
/** Floating controls shared by every zoomable document renderer. */
import clsx from 'clsx'
import { forwardRef, useEffect, useImperativeHandle, useState, type ReactNode } from 'react'
import { Button } from './Button.tsx'
import { IconChevronDownOutlineRegular, IconPlusOutlineRegular } from './icons/index.tsx'
@@ -7,11 +8,9 @@ import { Tooltip } from './Tooltip.tsx'
import { DOCUMENT_ZOOM, type DocumentZoomLabels } from './document-zoom.ts'
import css from './ZoomControls.module.css'
const { minimum: MIN_FIXED_ZOOM, maximum: MAX_FIXED_ZOOM, step: ZOOM_STEP, presets: ZOOM_OPTIONS } = DOCUMENT_ZOOM
function steppedZoom(zoom: number, direction: -1 | 1): number {
const step = direction === -1 ? Math.ceil(zoom / ZOOM_STEP) - 1 : Math.floor(zoom / ZOOM_STEP) + 1
return Math.min(MAX_FIXED_ZOOM, Math.max(MIN_FIXED_ZOOM, step * ZOOM_STEP))
const step = direction === -1 ? Math.ceil(zoom / DOCUMENT_ZOOM.step) - 1 : Math.floor(zoom / DOCUMENT_ZOOM.step) + 1
return Math.min(DOCUMENT_ZOOM.maximum, Math.max(DOCUMENT_ZOOM.minimum, step * DOCUMENT_ZOOM.step))
}
/** Live percentage display updated independently from the document tree. */
@@ -22,7 +21,8 @@ export interface ZoomControlsHandle {
/**
* Present optional fitting, fixed presets, and incremental controls.
* @param props - current resolved zoom, preference mode, labels, and callbacks.
* @param props - committed `zoom`, optional `fit` row (omitted: no fitting row), localized labels, `visible`, optional
* owner `className`, and zoom and activity callbacks. `showZoom` displays a transient scale until `zoom` changes.
* @returns a localized zoom toolbar.
*/
export const ZoomControls = forwardRef<ZoomControlsHandle, {
@@ -36,20 +36,20 @@ export const ZoomControls = forwardRef<ZoomControlsHandle, {
}>(function ZoomControls({
zoom, fit, className, labels, visible, onZoom, onActiveChange,
}, ref): ReactNode {
const [displayZoom, setDisplayZoom] = useState(zoom)
const [shown, setShown] = useState<{ readonly from: number; readonly zoom: number }>()
const [open, setOpen] = useState(false)
const [hovered, setHovered] = useState(false)
const [focused, setFocused] = useState(false)
useEffect(() => { setDisplayZoom(zoom) }, [zoom])
useImperativeHandle(ref, () => ({ showZoom: setDisplayZoom }), [])
const displayZoom = shown?.from === zoom ? shown.zoom : zoom
useImperativeHandle(ref, () => ({ showZoom: (next) => { setShown({ from: zoom, zoom: next }) } }), [zoom])
useEffect(() => { onActiveChange(open || hovered || focused) }, [focused, hovered, onActiveChange, open])
const items: readonly MenuEntry[] = [
...(fit === undefined ? [] : [{ id: 'fit', label: fit.label }, { type: 'separator' as const, id: 'fit-scale' }]),
...ZOOM_OPTIONS.map(value => ({ id: String(value), label: labels.value(value * 100) })),
...DOCUMENT_ZOOM.presets.map(value => ({ id: String(value), label: labels.value(value * 100) })),
]
const selectedId = fit?.selected === true ? 'fit'
: ZOOM_OPTIONS.some(value => value === displayZoom) ? String(displayZoom) : undefined
return <div className={`${css.panel} ${visible ? css.visible : ''} ${className ?? ''}`} role="toolbar" aria-label={labels.controls}
: DOCUMENT_ZOOM.presets.some(value => value === displayZoom) ? String(displayZoom) : undefined
return <div className={clsx(css.panel, visible && css.visible, className)} role="toolbar" aria-label={labels.controls}
data-document-zoom-controls data-document-zoom-visible={visible || undefined}
onPointerEnter={(event) => { if (event.pointerType !== 'touch') setHovered(true) }} onPointerLeave={() => { setHovered(false) }}
onFocusCapture={() => { setFocused(true) }} onBlurCapture={(event) => {
@@ -57,7 +57,7 @@ export const ZoomControls = forwardRef<ZoomControlsHandle, {
}}>
<Tooltip label={labels.out} side="top">
<span className={css.controlAnchor}><Button size="sm" className={css.zoomButton} aria-label={labels.out}
disabled={displayZoom <= MIN_FIXED_ZOOM} onMouseDown={(event) => { event.preventDefault() }}
disabled={displayZoom <= DOCUMENT_ZOOM.minimum} onMouseDown={(event) => { event.preventDefault() }}
onClick={() => { onZoom(steppedZoom(displayZoom, -1)) }}>
<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true">
<path d="M2 8h12" fill="none" stroke="currentColor" strokeWidth="1.5" />
@@ -77,7 +77,7 @@ export const ZoomControls = forwardRef<ZoomControlsHandle, {
}} />
<Tooltip label={labels.into} side="top">
<span className={css.controlAnchor}><Button size="sm" className={css.zoomButton} aria-label={labels.into}
disabled={displayZoom >= MAX_FIXED_ZOOM} onMouseDown={(event) => { event.preventDefault() }}
disabled={displayZoom >= DOCUMENT_ZOOM.maximum} onMouseDown={(event) => { event.preventDefault() }}
onClick={() => { onZoom(steppedZoom(displayZoom, 1)) }}>
<IconPlusOutlineRegular size={14} />
</Button></span>
+1 -1
View File
@@ -15,11 +15,11 @@ export { useRowDragPreview } from './useRowDragPreview.ts'
export { TextShimmer } from './TextShimmer.tsx'
export type { TextShimmerProps } from './TextShimmer.tsx'
export { Button } from './Button.tsx'
export type { ButtonVariant } from './Button.tsx'
export { ZoomControls } from './ZoomControls.tsx'
export type { ZoomControlsHandle } from './ZoomControls.tsx'
export { DOCUMENT_ZOOM } from './document-zoom.ts'
export type { DocumentZoomLabels } from './document-zoom.ts'
export type { ButtonVariant } from './Button.tsx'
export { Pill } from './Pill.tsx'
export { SegmentedTabs } from './SegmentedTabs.tsx'
export type { SegmentedTab } from './SegmentedTabs.tsx'
@@ -13,27 +13,26 @@ test('shows owner-specific fitting, changes scale, and follows gesture updates w
const zoom = vi.fn(), fit = vi.fn(), active = vi.fn(), ref = createRef<ZoomControlsHandle>()
const props = { zoom: 1.13, visible: true, labels, onZoom: zoom, onActiveChange: active,
fit: { label: 'Fit page', selected: true, onSelect: fit } }
const view = render(<ZoomControls {...props} ref={ref} className="owner-position" />)
expect(view.container.querySelector('[role="toolbar"]')?.classList.contains('owner-position')).toBe(true)
const view = render(<ZoomControls {...props} ref={ref} />)
fireEvent.click(screen.getByRole('button', { name: 'Choose zoom' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Fit page' }))
expect(fit).toHaveBeenCalledOnce()
fireEvent.click(screen.getByRole('button', { name: 'Choose zoom' }))
fireEvent.click(screen.getByRole('menuitem', { name: '150%' }))
expect(zoom).toHaveBeenLastCalledWith(1.5)
act(() => { ref.current!.showZoom(1.13) })
expect(screen.getByRole('button', { name: 'Choose zoom' }).textContent).toContain('113%')
act(() => { ref.current!.showZoom(0.8) })
expect(screen.getByRole('button', { name: 'Choose zoom' }).textContent).toContain('80%')
fireEvent.click(screen.getByRole('button', { name: 'Zoom out' }))
expect(zoom).toHaveBeenLastCalledWith(1)
expect(zoom).toHaveBeenLastCalledWith(0.75)
fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
expect(zoom).toHaveBeenLastCalledWith(1.25)
expect(zoom).toHaveBeenLastCalledWith(1)
view.rerender(<ZoomControls {...props} zoom={4} ref={ref} />)
expect(screen.getByRole('button', { name: 'Zoom in' }).hasAttribute('disabled')).toBe(true)
view.rerender(<ZoomControls {...props} zoom={0.25} ref={ref} />)
expect(screen.getByRole('button', { name: 'Zoom out' }).hasAttribute('disabled')).toBe(true)
})
test('omits fitting for spreadsheets and keeps the toolbar active during pointer, focus, and menu interaction', () => {
test('without fit, lists only presets and keeps the toolbar active during pointer, focus, and menu interaction', () => {
const active = vi.fn(), zoom = vi.fn()
const view = render(<ZoomControls zoom={1} visible={false} labels={labels} onZoom={zoom} onActiveChange={active} />)
const panel = screen.getByRole('toolbar'), button = screen.getByRole('button', { name: 'Choose zoom' })
@@ -58,3 +57,14 @@ test('omits fitting for spreadsheets and keeps the toolbar active during pointer
expect(screen.queryByRole('menu')).toBeNull()
view.unmount()
})
test('without fit, marks the matching preset and no row for an unlisted scale', () => {
const props = { visible: true, labels, onZoom: vi.fn(), onActiveChange: vi.fn() }
const view = render(<ZoomControls {...props} zoom={1} />)
fireEvent.click(screen.getByRole('button', { name: 'Choose zoom' }))
expect(screen.getAllByRole('menuitem').filter(item => item.querySelector('svg') !== null).map(item => item.textContent))
.toEqual(['100%'])
view.rerender(<ZoomControls {...props} zoom={1.13} />)
expect(screen.getByRole('button', { name: 'Choose zoom' }).textContent).toContain('113%')
expect(screen.getAllByRole('menuitem').filter(item => item.querySelector('svg') !== null)).toEqual([])
})
@@ -1,8 +1,8 @@
/** Shared zoom viewport, gesture handling, anchoring, and fit-width measurement. */
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent,
type ReactNode, type RefCallback } from 'react'
import { ZoomControls, type ZoomControlsHandle } from '@deepseek-ai/dsh-client-ui-primitives'
import { FIT_WIDTH, MAX_FIXED_ZOOM, MIN_FIXED_ZOOM, type ZoomLabels, type ZoomPreference } from './types.ts'
import { DOCUMENT_ZOOM, ZoomControls, type ZoomControlsHandle } from '@deepseek-ai/dsh-client-ui-primitives'
import { FIT_WIDTH, type ZoomLabels, type ZoomPreference } from './types.ts'
import css from './ZoomViewport.module.css'
const PINCH_SETTLE_DELAY_MS = 120
@@ -109,7 +109,7 @@ export function ZoomViewport(props: ZoomViewportProps): ReactNode {
const showZoom = useCallback((next: number, clientX?: number, clientY?: number): number => {
const node = scrollport.current as HTMLDivElement
const previous = zoomRef.current
next = Math.min(MAX_FIXED_ZOOM, Math.max(MIN_FIXED_ZOOM, next))
next = Math.min(DOCUMENT_ZOOM.maximum, Math.max(DOCUMENT_ZOOM.minimum, next))
if (next === previous) return previous
const bounds = node.getBoundingClientRect()
const x = clientX === undefined ? node.clientWidth / 2 : clientX - bounds.left
@@ -1,4 +1,5 @@
import { DOCUMENT_ZOOM, type DocumentZoomLabels } from '@deepseek-ai/dsh-client-ui-primitives'
import type { DocumentZoomLabels } from '@deepseek-ai/dsh-client-ui-primitives'
/** Automatic fit or an explicit actual-size scale selected by the reader. */
export type ZoomPreference =
| { readonly kind: 'fit-width' }
@@ -7,12 +8,6 @@ export type ZoomPreference =
/** Default preference for a newly opened zoomable preview. */
export const FIT_WIDTH: ZoomPreference = { kind: 'fit-width' }
/** Minimum scale retained by a fixed zoom preference. */
export const MIN_FIXED_ZOOM = DOCUMENT_ZOOM.minimum
/** Maximum scale retained by a fixed zoom preference. */
export const MAX_FIXED_ZOOM = DOCUMENT_ZOOM.maximum
/** Localized copy consumed by the framework-free zoom controls. */
export interface ZoomLabels extends DocumentZoomLabels {
readonly fitWidth: string