diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 6d8e6d6757..4079a5f536 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index f5254e2090..e23abc3ab2 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -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. ----- diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 35e15005fe..d4dcd12c73 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -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 接口。 ----- diff --git a/packages/client/ui-primitives/src/ZoomControls.tsx b/packages/client/ui-primitives/src/ZoomControls.tsx index d99c2ab345..2a1843e50d 100644 --- a/packages/client/ui-primitives/src/ZoomControls.tsx +++ b/packages/client/ui-primitives/src/ZoomControls.tsx @@ -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(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
value === displayZoom) ? String(displayZoom) : undefined + return
{ if (event.pointerType !== 'touch') setHovered(true) }} onPointerLeave={() => { setHovered(false) }} onFocusCapture={() => { setFocused(true) }} onBlurCapture={(event) => { @@ -57,7 +57,7 @@ export const ZoomControls = forwardRef diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index b4f6fa6f93..1d9c2f6673 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -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' diff --git a/packages/client/ui-primitives/tests/zoom-controls.client.spec.tsx b/packages/client/ui-primitives/tests/zoom-controls.client.spec.tsx index 8c97343423..d8193f8a91 100644 --- a/packages/client/ui-primitives/tests/zoom-controls.client.spec.tsx +++ b/packages/client/ui-primitives/tests/zoom-controls.client.spec.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() const props = { zoom: 1.13, visible: true, labels, onZoom: zoom, onActiveChange: active, fit: { label: 'Fit page', selected: true, onSelect: fit } } - const view = render() - expect(view.container.querySelector('[role="toolbar"]')?.classList.contains('owner-position')).toBe(true) + const view = render() 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() expect(screen.getByRole('button', { name: 'Zoom in' }).hasAttribute('disabled')).toBe(true) view.rerender() 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() 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() + 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() + expect(screen.getByRole('button', { name: 'Choose zoom' }).textContent).toContain('113%') + expect(screen.getAllByRole('menuitem').filter(item => item.querySelector('svg') !== null)).toEqual([]) +}) diff --git a/packages/client/ui-sidebar-documentpreview/src/client/zoom/ZoomViewport.tsx b/packages/client/ui-sidebar-documentpreview/src/client/zoom/ZoomViewport.tsx index 132e242c19..06306204d1 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/zoom/ZoomViewport.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/zoom/ZoomViewport.tsx @@ -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 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/zoom/types.ts b/packages/client/ui-sidebar-documentpreview/src/client/zoom/types.ts index c9b10a4517..9d69604623 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/zoom/types.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/zoom/types.ts @@ -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