From 8ba9fa006dedcd64cb91eafa97f982112d757da6 Mon Sep 17 00:00:00 2001 From: Markus Date: Sat, 14 Nov 2026 10:43:37 +0100 Subject: [PATCH 1/7] feat(theme): design tokens for every color, type and shape; theme objects Every color in the stylesheet is now a --de-* token on .de-root, with the dark values on .de-root.de-dark: find highlights, collaboration status, avatars, slideshow, tooltips, file badges, paper and placeholders, and the source editor's syntax colors (moved to the root so a theme reaches them). Fonts, radii and shadows are tokens too. Rendering is unchanged: a computed-style comparison against the previous stylesheet over DOCX, XLSX, PPTX and Markdown, light and dark, differs only in equivalent font stacks and the color-button swatch edge, which now follows swatch-border (visible in dark). - theme accepts { mode, tokens, light, dark } with typed camelCase token names (ThemeTokens); THEME_TOKENS, createTheme, themeStyle, useThemeRoot - injectStyles={false} and @p10node/office-engine/styles.css for hosts that load the stylesheet themselves (CSP, extracted CSS, cascade layers) - docs/theming.md; unit tests keep THEME_TOKENS and the stylesheet in step and reject literal colors outside token definitions; e2e for theme objects in both modes and for injectStyles=false Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- docs/api.md | 7 +- docs/features.md | 2 +- docs/theming.md | 90 ++++++++++ e2e/app/main.tsx | 1 + e2e/viewer.spec.ts | 29 ++++ package.json | 6 +- playground/main.tsx | 2 +- src/index.ts | 2 +- src/react/DocViewer.tsx | 22 ++- src/react/common/hooks.ts | 4 +- src/react/pptx/PptxView.tsx | 2 +- src/react/pptx/Slide.tsx | 4 +- src/react/styles.css | 333 +++++++++++++++++++++--------------- src/react/theme.ts | 81 +++++++++ test/theme.test.ts | 62 +++++++ 16 files changed, 493 insertions(+), 157 deletions(-) create mode 100644 docs/theming.md create mode 100644 src/react/theme.ts create mode 100644 test/theme.test.ts diff --git a/README.md b/README.md index 7bac394..45ca7a7 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ Also online at [p10node.github.io/office-engine](https://p10node.github.io/offic - [Features](docs/features.md): format coverage, editing, saving, limitations - [API](docs/api.md): props, custom views, `mount()`, ``, core API, CSP +- [Theming](docs/theming.md): design tokens, light / dark, loading the stylesheet yourself - [Real-time collaboration](docs/collaboration.md) - [Development](docs/development.md): playground, architecture, demo deploy, release @@ -75,7 +76,7 @@ Also online at [p10node.github.io/office-engine](https://p10node.github.io/offic - **File menu**: new, open (also DOC / XLS / PPT / RTF / OpenDocument / Pages / Keynote / Numbers / CSV / text / Markdown / HTML / SVG / diagrams), import CSV, rename, download as PDF / HTML / text / CSV, document details, print - **Comments**: threads with replies, resolve, edit / delete, @mentions; on DOCX text, XLSX cells and PPTX slides, stored in the file the way Word, Excel and PowerPoint store them - **Real-time collaboration**: live edits, avatars, collaborators' cursors, offline edits sync on reconnect -- **Dark mode**: `theme="dark"` or `"auto"` (follows the OS); documents keep their own colors +- **Dark mode and theming**: `theme="dark"` or `"auto"` (follows the OS), or a theme object with design tokens for colors, type and shape ([Theming](docs/theming.md)); documents keep their own colors - **Embed anywhere**: React component, hook, vanilla `mount()`, `` web component, single ` ``` -``: attributes `src`, `name`, `editable`, `toolbar="false"`, `thumbnails="false"`, `save="false"`, `download="false"`, `file-menu="false"`, `open="false"`, `capture-select-all="false"`, `theme="dark"` (or `"auto"`), `collab` / `room` / `user` / `base` (real-time collaboration; `user` also writes the comments), `comments="false"`; property `.file = File`; events `load`, `error`, `comment` (`detail`: a `CommentEvent`). +``: attributes `src`, `name`, `editable`, `toolbar="false"`, `thumbnails="false"`, `save="false"`, `download="false"`, `file-menu="false"`, `open="false"`, `capture-select-all="false"`, `theme="dark"` (or `"auto"`), `collab` / `room` / `user` / `base` (real-time collaboration; `user` also writes the comments), `comments="false"`; properties `.file = File` and `.icons` (map or resolver, as `icons` above); events `load`, `error`, `comment` (`detail`: a `CommentEvent`). ## Core API (no React) diff --git a/docs/features.md b/docs/features.md index 505502c..215373e 100644 --- a/docs/features.md +++ b/docs/features.md @@ -170,7 +170,7 @@ Diagram sources open as a document holding the drawing (an SVG picture, editable ### Toolbar -Single row, buttons that don't fit collapse into `⋮`; custom-drawn dropdowns (font/style preview, number formats with examples, ↑↓/Enter/Esc/type-ahead keys, never steals focus from the document); font size box `− 11 +` accepts direct input; cell borders picked from an icon grid; color pickers have a palette plus a **Custom** row (`+` opens the system color picker, recent custom colors are remembered across sessions). +Single row, buttons that don't fit collapse into `⋮`; custom-drawn dropdowns (font/style preview, number formats with examples, ↑↓/Enter/Esc/type-ahead keys, never steals focus from the document); font size box `− 11 +` accepts direct input; cell borders picked from an icon grid; color pickers have a palette plus a **Custom** row (`+` opens the system color picker, recent custom colors are remembered across sessions). Every icon can be replaced with the host's own set (Lucide, Phosphor or any other) through the `icons` prop, see [API](api.md#icons). ### Dark mode diff --git a/e2e/app/main.tsx b/e2e/app/main.tsx index 79fa95b..be08f97 100644 --- a/e2e/app/main.tsx +++ b/e2e/app/main.tsx @@ -2,6 +2,7 @@ * Fixture for the browser tests: DocViewer configured from the query string, callbacks recorded on window. * ?src=samples/sample.docx&editable=1&allowSave=0&onSave=1&items=1&close=1&collab=ws://…&room=r * comments: &me=Ann&meId=u1 (author), &people=1 (mentions: Ann, Bob Stone, Cid), &comments=0 (hidden), &cmOpen=1 + * &icons=1: every icon from a host resolver, as `` */ import { useState } from 'react' import { createRoot } from 'react-dom/client' @@ -63,6 +64,13 @@ const initial: DocViewerProps = { open: flag('cmOpen'), onEvent: (e) => log({ type: 'comment', action: e.type, id: e.comment.id, text: e.comment.text, thread: e.thread.id, quote: e.quote, mentioned: e.mentioned.map((m) => m.id ?? m.name), participants: e.participants.map((m) => m.id ?? m.name) }), }, + icons: q.has('icons') + ? (name) => ( + + + + ) + : undefined, collab: q.get('collab') ? { transport: q.get('collab')!, room: q.get('room') ?? 'e2e', base: Number(q.get('base')) || 0, user: { name: q.get('user') ?? 'Tester' } } : undefined, } diff --git a/e2e/icons.spec.ts b/e2e/icons.spec.ts new file mode 100644 index 0000000..14892ee --- /dev/null +++ b/e2e/icons.spec.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from 'bun:test' +import { useBrowser, viewer, type Viewer } from './harness' + +useBrowser() + +/** host icons (`.de-icon`) and built-in icon svgs left in the chrome matching `sel` */ +async function icons(v: Viewer, sel: string) { + return v.page.evaluate((s) => { + const roots = [...document.querySelectorAll(s)] + const svgs = roots.flatMap((r) => [...r.querySelectorAll('svg')]) + return { + host: roots.reduce((n, r) => n + r.querySelectorAll('.de-icon > svg[data-host-icon]').length, 0), + builtIn: svgs.filter((x) => !x.closest('.de-icon')).length, + } + }, sel) +} + +const hostIcon = (v: Viewer, button: string, name: string) => v.page.locator(`${button} .de-icon > svg[data-host-icon="${name}"]`) + +describe('host icons', () => { + test('docx: zoom, outline toggle and close, toolbar, find & replace', async () => { + const v = await viewer({ src: 'samples/sample.docx', icons: true }) + expect(await hostIcon(v, 'button[aria-label="Zoom in"]', 'zoomIn').count()).toBe(1) + expect(await hostIcon(v, 'button[aria-label="Zoom out"]', 'zoomOut').count()).toBe(1) + expect(await hostIcon(v, '.de-outline-toggle', 'outline').count()).toBe(1) + await v.page.locator('.de-outline-toggle').click() + expect(await hostIcon(v, 'button[aria-label="Close outline"]', 'close').count()).toBe(1) + await v.page.locator('button[aria-label="Close outline"]').click() + + await v.edit() + const bar = await icons(v, '.de-ebar') + expect(bar.host).toBeGreaterThan(20) + expect(bar.builtIn).toBe(0) + await v.btn('Find & replace').click() + expect(await hostIcon(v, '.de-find button[aria-label="Replace"]', 'replace').count()).toBe(1) + expect((await icons(v, '.de-find')).builtIn).toBe(0) + expect(v.errors).toEqual([]) + }) + + test('xlsx: toolbar and the add-sheet tab', async () => { + const v = await viewer({ src: 'samples/sample.xlsx', editable: true, icons: true }) + await v.page.locator('.de-ebar').first().waitFor() + const bar = await icons(v, '.de-ebar') + expect(bar.host).toBeGreaterThan(20) + expect(bar.builtIn).toBe(0) + expect(await hostIcon(v, '.de-tab-add', 'sheetAdd').count()).toBe(1) + expect(await v.page.locator('.de-tab-add').getAttribute('title')).toBe('Add sheet') + expect(v.errors).toEqual([]) + }) + + test('pptx: Present, zoom, toolbar', async () => { + const v = await viewer({ src: 'samples/sample.pptx', icons: true }) + expect(await hostIcon(v, '.de-present-btn', 'present').count()).toBe(1) + expect(await v.page.locator('.de-present-btn').innerText()).toContain('Present') + expect(await hostIcon(v, 'button[aria-label="Zoom in"]', 'zoomIn').count()).toBe(1) + + await v.edit() + const bar = await icons(v, '.de-ebar') + expect(bar.host).toBeGreaterThan(20) + expect(bar.builtIn).toBe(0) + expect(v.errors).toEqual([]) + }) + + test('without icons the built-in svgs stay', async () => { + const v = await viewer({ src: 'samples/sample.docx', editable: true }) + await v.page.locator('.de-ebar').first().waitFor() + const bar = await icons(v, '.de-ebar') + expect(bar.host).toBe(0) + expect(bar.builtIn).toBeGreaterThan(20) + expect(await v.page.locator('button[aria-label="Zoom in"] > svg').count()).toBe(1) + }) +}) diff --git a/src/embed.ts b/src/embed.ts index 4b41b0c..81318e0 100644 --- a/src/embed.ts +++ b/src/embed.ts @@ -2,6 +2,7 @@ import { createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import './core/default-worker' import { DocViewer, type DocViewerProps } from './react/DocViewer' +import type { Icons } from './react/edit/Toolbar' export type MountOptions = Omit @@ -37,12 +38,13 @@ export function mount(el: HTMLElement, options: MountOptions): MountedViewer { * Attributes: src, name, editable, toolbar / thumbnails / save / download / file-menu / open / capture-select-all ("false" to turn off), * theme ("light" | "dark" | "auto"); * real-time collaboration: collab (hub WebSocket URL), room, user (display name), base (snapshot seq `src` was opened at). - * Property: `file` (File/Blob/ArrayBuffer) for local data. + * Properties: `file` (File/Blob/ArrayBuffer) for local data; `icons` (map or resolver, as DocViewer's `icons`). */ export class OfficeViewerElement extends HTMLElement { static observedAttributes = ['src', 'name', 'toolbar', 'thumbnails', 'editable', 'save', 'download', 'file-menu', 'open', 'capture-select-all', 'theme', 'collab', 'room', 'user', 'base', 'comments'] private viewer?: MountedViewer private _file?: Blob | ArrayBuffer + private _icons?: Icons get file() { return this._file @@ -52,6 +54,14 @@ export class OfficeViewerElement extends HTMLElement { this.sync() } + get icons() { + return this._icons + } + set icons(v: Icons | undefined) { + this._icons = v + this.sync() + } + connectedCallback() { if (this.viewer) return const shadow = this.shadowRoot ?? this.attachShadow({ mode: 'open' }) @@ -85,6 +95,7 @@ export class OfficeViewerElement extends HTMLElement { allowOpen: this.getAttribute('open') !== 'false', captureSelectAll: this.getAttribute('capture-select-all') !== 'false', theme: THEMES.find((t) => t === this.getAttribute('theme')), + icons: this._icons, collab: this.getAttribute('collab') && this.getAttribute('room') ? { diff --git a/src/index.ts b/src/index.ts index 0acc1e9..9d018d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ export { useDocument, type DocumentState } from './react/useDocument' export { injectStyles, stylesText } from './react/injectStyles' export type { Zoom } from './react/common/ZoomControl' export { THEME_TOKENS, createTheme, themeStyle, useThemeRoot, type Theme, type ThemeMode, type ThemeOptions, type ThemeToken, type ThemeTokens } from './react/theme' +export { IconsProvider, ICON_NAMES, type IconName, type Icons } from './react/edit/Toolbar' export { DocumentClient, setDefaultWorker, type OpenOptions } from './core/worker/client' export type { DocSource } from './core/source' diff --git a/src/react/DocViewer.tsx b/src/react/DocViewer.tsx index 63865be..8d1f37c 100644 --- a/src/react/DocViewer.tsx +++ b/src/react/DocViewer.tsx @@ -6,7 +6,7 @@ import { DocxView } from './docx/DocxView' import { ViewerContext, type FileOps, type ViewerApi } from './common/Slots' import { StatusContext } from './common/Status' import { ConfirmDialog, PromptDialog, Toast } from './common/Dialog' -import { download, Icon } from './edit/Toolbar' +import { download, Icon, IconsProvider, type Icons } from './edit/Toolbar' import { DetailsDialog } from './file/DetailsDialog' import { FileMenu, KIND_LABEL, useFileActions, type FileMenuItem } from './file/FileMenu' import { importFile, type Imported } from './file/importFile' @@ -105,6 +105,12 @@ export interface DocViewerProps { * host hears about. Shown by default, written while editing; `false` hides them. */ comments?: CommentOptions | false + /** + * The host's icons, shown instead of the built-in ones: a map by name (see `ICON_NAMES`) or a resolver + * `(name, { size }) => node`; a missing entry, or `undefined` / `null` from the resolver, keeps the built-in icon. + * Each is rendered in `span.de-icon`, sized by font-size (1em). + */ + icons?: Icons } const EXT: Record = { docx: '.docx', xlsx: '.xlsx', pptx: '.pptx' } @@ -541,6 +547,7 @@ export function DocViewer(props: DocViewerProps) { else body = return ( +
{toolbar && (
@@ -696,5 +703,6 @@ export function DocViewer(props: DocViewerProps) { /> )}
+ ) } diff --git a/src/react/common/ContextMenu.tsx b/src/react/common/ContextMenu.tsx index d342000..15bc2ce 100644 --- a/src/react/common/ContextMenu.tsx +++ b/src/react/common/ContextMenu.tsx @@ -1,5 +1,5 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' -import { Icon } from '../edit/Toolbar' +import { Icon, type IconName } from '../edit/Toolbar' export interface MenuItem { label: string @@ -10,7 +10,7 @@ export interface MenuItem { sep?: boolean /** shortcut / secondary text */ hint?: string - icon?: string + icon?: IconName /** small section title above this item */ head?: string /** submenu: the item opens these beside it */ diff --git a/src/react/common/ZoomControl.tsx b/src/react/common/ZoomControl.tsx index 219d1e2..808aa3f 100644 --- a/src/react/common/ZoomControl.tsx +++ b/src/react/common/ZoomControl.tsx @@ -1,3 +1,5 @@ +import { Icon } from '../edit/Toolbar' + export type Zoom = number | 'fit' const STEPS = [0.25, 0.33, 0.5, 0.67, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3] @@ -11,13 +13,13 @@ export function ZoomControl({ zoom, effective, onChange }: { zoom: Zoom; effecti return (
onChange(zoom === 'fit' ? 1 : 'fit')}> {zoom === 'fit' ? 'Fit' : `${Math.round(effective * 100)}%`}
) diff --git a/src/react/docx/DocxToolbar.tsx b/src/react/docx/DocxToolbar.tsx index 54da9b9..ba88d7c 100644 --- a/src/react/docx/DocxToolbar.tsx +++ b/src/react/docx/DocxToolbar.tsx @@ -2,13 +2,13 @@ import { useEffect, useState, type CSSProperties } from 'react' import type { CanonStyle, StyleInfo } from '../../core/docx/types' import { MOD } from '../common/ContextMenu' import { EditorBar } from '../common/Slots' -import { Btn, ColorBtn, Dropdown, fontName, fontOptions, GridPicker, Icon, Menu, pickFiles, Pop, Select, Sep, SizeBox, type DropOption } from '../edit/Toolbar' +import { Btn, ColorBtn, Dropdown, fontName, fontOptions, GridPicker, Icon, Menu, pickFiles, Pop, Select, Sep, SizeBox, type DropOption, type IconName } from '../edit/Toolbar' import type { BorderPreset } from './tables' import type { DocxEditor, FormatState } from './DocxEditor' const HIGHLIGHTS = ['#ffff00', '#00ff00', '#00ffff', '#ff00ff', '#0000ff', '#ff0000', '#000080', '#008080', '#008000', '#800080', '#800000', '#808000', '#808080', '#c0c0c0', '#000000'] -const CELL_BORDERS: { k: BorderPreset; icon: string; title: string }[] = [ +const CELL_BORDERS: { k: BorderPreset; icon: IconName; title: string }[] = [ { k: 'all', icon: 'bAll', title: 'All borders' }, { k: 'inner', icon: 'bInner', title: 'Inner borders' }, { k: 'outer', icon: 'bOuter', title: 'Outer borders' }, @@ -27,7 +27,7 @@ export const CELL_LINES = [ { value: '0.5pt dotted', label: 'Dotted', hint: '·····' }, { value: '2.25pt double', label: 'Double', hint: '═══' }, ] -const VALIGN = [ +const VALIGN: DropOption[] = [ { value: 'top', label: 'Top', icon: 'vTop' }, { value: 'middle', label: 'Middle', icon: 'vMiddle' }, { value: 'bottom', label: 'Bottom', icon: 'vBottom' }, diff --git a/src/react/docx/DocxView.tsx b/src/react/docx/DocxView.tsx index eccaaad..de64edf 100644 --- a/src/react/docx/DocxView.tsx +++ b/src/react/docx/DocxView.tsx @@ -11,7 +11,7 @@ import { ZoomControl, type Zoom } from '../common/ZoomControl' import { ContextMenu, MOD, type MenuItem } from '../common/ContextMenu' import { PromptDialog, Toast, type PromptSpec } from '../common/Dialog' import { domFromPos, getSel, opInsert, posFromDom } from '../edit/input' -import { download, pickFiles } from '../edit/Toolbar' +import { download, Icon, pickFiles } from '../edit/Toolbar' import { runsText, type RRun } from '../edit/rich' import { DocxEditor, type CommentRange, type DocState, type FormatState } from './DocxEditor' import { markCss, useCommentsCtl, useCommentsState, type CommentHost, type PlacedThread } from '../comments/Comments' @@ -839,7 +839,7 @@ export function DocxView({ client, onStatus, initialZoom = 'fit' }: DocxViewProp
Outline
{headings.length ? ( @@ -886,7 +886,7 @@ export function DocxView({ client, onStatus, initialZoom = 'fit' }: DocxViewProp )} {!isEditing && done && ( )} {pageDlg && setPageDlg(false)} onOk={(set) => void editor.setPage(set).catch((e) => console.error('[office-engine] page setup failed', e))} />} diff --git a/src/react/docx/FindBar.tsx b/src/react/docx/FindBar.tsx index 090624a..b28b5a6 100644 --- a/src/react/docx/FindBar.tsx +++ b/src/react/docx/FindBar.tsx @@ -49,7 +49,7 @@ export function FindBar(props: { {props.query ? (props.count ? `${Math.min(props.index + 1, props.count)} / ${props.count}` : '0 / 0') : ''} props.onNav(-1)} disabled={!props.count} /> props.onNav(1)} disabled={!props.count} /> - {props.editable && setShowRep((v) => !v)} />} + {props.editable && setShowRep((v) => !v)} />}
{props.options &&
{props.options}
} diff --git a/src/react/edit/Toolbar.tsx b/src/react/edit/Toolbar.tsx index e49e36a..55ab133 100644 --- a/src/react/edit/Toolbar.tsx +++ b/src/react/edit/Toolbar.tsx @@ -1,9 +1,12 @@ import { Children, + createContext, Fragment, isValidElement, + useContext, useEffect, useLayoutEffect, + useMemo, useRef, useState, useSyncExternalStore, @@ -16,7 +19,7 @@ import { cssColor } from '../../core/color' const BORDER_GRID = -const P: Record = { +const P = { undo: , redo: , bold: , @@ -145,9 +148,54 @@ const P: Record = { info: , comment: , commentAdd: , + zoomIn: , + zoomOut: , + present: , + sheetAdd: , + replace: , +} satisfies Record + +/** the name of one of the viewer's icons */ +export type IconName = keyof typeof P + +/** the names of the viewer's icons, for a host's own set (``) */ +export const ICON_NAMES = Object.keys(P) as IconName[] + +/** + * A host's icons: a map by name, or a resolver called for every icon. A missing entry, or a resolver + * returning `undefined` / `null`, keeps the built-in icon. Host icons are sized by font-size (1em). + */ +export type Icons = Partial> | ((name: IconName, props: { size: number }) => ReactNode | undefined | null) + +type Resolve = (name: IconName, size: number) => ReactNode | undefined + +const IconsContext = createContext(null) + +/** the host's icon for `name`, or `undefined` for the built-in one */ +export function hostIcon(icons: Icons | undefined, name: IconName, size: number): ReactNode | undefined { + if (!icons) return undefined + const own = typeof icons === 'function' ? icons(name, { size }) : icons[name] + return own === null || own === false ? undefined : own +} + +/** + * Gives the views inside it the host's icons; DocViewer applies its `icons` prop through one. + * Nested providers fall back to the enclosing one for names they leave out. + */ +export function IconsProvider({ icons, children }: { icons?: Icons; children?: ReactNode }) { + const outer = useContext(IconsContext) + const resolve = useMemo(() => (icons ? (name, size) => hostIcon(icons, name, size) ?? outer?.(name, size) : outer), [icons, outer]) + return {children} } -export function Icon({ name, size = 18 }: { name: string; size?: number }) { +export function Icon({ name, size = 18 }: { name: IconName; size?: number }) { + const own = useContext(IconsContext)?.(name, size) + if (own !== undefined) + return ( + + {own} + + ) return ( {P[name]} @@ -191,7 +239,7 @@ export function useDismiss(ref: RefObject, open: boolean, cl }, [open, ref]) } -export function Btn(props: { icon?: string; label?: ReactNode; title: string; active?: boolean; disabled?: boolean; onClick: () => void; wide?: boolean }) { +export function Btn(props: { icon?: IconName; label?: ReactNode; title: string; active?: boolean; disabled?: boolean; onClick: () => void; wide?: boolean }) { return ( props.onInsert(v)} /> + props.onInsert(v)} /> {!viewer.editable && ( )} diff --git a/src/react/styles.css b/src/react/styles.css index 6b49dd5..511a60c 100644 --- a/src/react/styles.css +++ b/src/react/styles.css @@ -306,6 +306,9 @@ cursor: pointer; } .de-zoom button { + display: inline-flex; + align-items: center; + justify-content: center; width: 32px; height: 30px; font-size: 16px; @@ -684,6 +687,18 @@ padding: 6px; background: var(--de-ebar); } +/* a host's icon (`icons` prop / IconsProvider), sized by font-size */ +.de-icon { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + line-height: 1; +} +.de-icon > svg { + width: 1em; + height: 1em; +} .de-tb { appearance: none; position: relative; @@ -758,7 +773,7 @@ } .de-dd:hover:not(:disabled), .de-dd-open { background: var(--de-hover); } .de-dd:disabled { opacity: 0.38; cursor: default; } -.de-dd > svg { flex: none; color: var(--de-ink); } +.de-dd > svg, .de-dd > .de-icon { flex: none; color: var(--de-ink); } .de-dd-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; } .de-dd-ph { color: var(--de-muted); } .de-dd-pop { @@ -786,7 +801,7 @@ cursor: pointer; user-select: none; } -.de-dd-opt > svg { flex: none; color: var(--de-ink); } +.de-dd-opt > svg, .de-dd-opt > .de-icon { flex: none; color: var(--de-ink); } .de-dd-hi { background: var(--de-hover-solid); } .de-dd-dis { opacity: 0.4; cursor: default; } .de-dd-check { display: inline-flex; width: 16px; flex: none; color: var(--de-on-ink); } @@ -1001,7 +1016,7 @@ .de-ctx-sub { min-width: 200px; max-height: calc(100vh - 8px); overflow-y: auto; } .de-ctx-sep { height: 1px; margin: 6px 0; background: var(--de-sep); } .de-ctx-head { padding: 6px 16px 2px 38px; color: var(--de-muted); font-size: 11px; font-weight: 600; letter-spacing: 0.03em; text-transform: uppercase; } -.de-tab-add { font-size: 18px; padding: 0 10px; } +.de-tab-add { display: inline-flex; align-items: center; padding: 0 10px; } .de-tab-input { width: 120px; margin: 4px 2px; padding: 0 6px; border: 1px solid var(--de-accent); border-radius: var(--de-radius-sm); background: var(--de-input-bg); color: var(--de-text); font: inherit; } /* formula editing: reference boxes, colored references, autocomplete */ .de-fx { position: relative; } @@ -1107,6 +1122,9 @@ top: 12px; left: 12px; z-index: 6; + display: inline-flex; + align-items: center; + justify-content: center; width: 34px; height: 34px; border: 1px solid var(--de-border); diff --git a/src/react/xlsx/XlsxToolbar.tsx b/src/react/xlsx/XlsxToolbar.tsx index 6c6c9e9..145f968 100644 --- a/src/react/xlsx/XlsxToolbar.tsx +++ b/src/react/xlsx/XlsxToolbar.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import type { BorderKind, FmtPatch } from '../../core/xlsx/types' import { MOD } from '../common/ContextMenu' import { EditorBar } from '../common/Slots' -import { Btn, ColorBtn, Dropdown, fontName, fontOptions, Icon, Menu, Pop, Select, Sep, SizeBox } from '../edit/Toolbar' +import { Btn, ColorBtn, Dropdown, fontName, fontOptions, Icon, Menu, Pop, Select, Sep, SizeBox, type DropOption, type IconName } from '../edit/Toolbar' const FORMATS = [ { value: 'General', label: 'Automatic', hint: '1000.12' }, @@ -28,7 +28,7 @@ const FORMATS = [ { value: '[h]:mm:ss', label: 'Duration', hint: '24:01:00' }, ] const SIZES = [6, 7, 8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 36, 48] -const BORDERS: { k: BorderKind; icon: string; title: string }[] = [ +const BORDERS: { k: BorderKind; icon: IconName; title: string }[] = [ { k: 'all', icon: 'bAll', title: 'All borders' }, { k: 'inner', icon: 'bInner', title: 'Inner borders' }, { k: 'outer', icon: 'bOuter', title: 'Outer borders' }, @@ -63,7 +63,7 @@ const ROTATE = [ { value: 255, label: 'Stack vertically', icon: 'rotStack' }, { value: 90, label: 'Rotate up', icon: 'rotUp90' }, { value: -90, label: 'Rotate down', icon: 'rotDown90' }, -] +] satisfies DropOption[] const FUNCS = ['SUM', 'AVERAGE', 'COUNT', 'MAX', 'MIN'] export type MergeMode = 'all' | 'across' | 'down' | 'unmerge' diff --git a/src/react/xlsx/XlsxView.tsx b/src/react/xlsx/XlsxView.tsx index 7cc6335..957cb56 100644 --- a/src/react/xlsx/XlsxView.tsx +++ b/src/react/xlsx/XlsxView.tsx @@ -14,6 +14,7 @@ import { ContextMenu, MOD, type MenuItem } from '../common/ContextMenu' import { Toast } from '../common/Dialog' import { FindBar } from '../docx/FindBar' import { XlsxToolbar, type MergeMode } from './XlsxToolbar' +import { Icon } from '../edit/Toolbar' import { Axis } from './axis' import { fitNumber, fontPx } from './fit' import { sheetPrintable } from './print' @@ -374,7 +375,7 @@ export function XlsxView({ client, onStatus, initialSheet }: XlsxViewProps) {
{viewer.editable && ( )} {meta.sheets.filter((s) => !s.hidden).map((s) => diff --git a/test/icons.test.tsx b/test/icons.test.tsx new file mode 100644 index 0000000..e2a30eb --- /dev/null +++ b/test/icons.test.tsx @@ -0,0 +1,75 @@ +import { describe, expect, test } from 'bun:test' +import { renderToStaticMarkup } from 'react-dom/server' +import { hostIcon, Icon, ICON_NAMES, IconsProvider, type IconName, type Icons } from '../src/react/edit/Toolbar' + +const html = (icons: Icons | undefined, name: IconName, size?: number) => + renderToStaticMarkup( + + + , + ) + +describe('icons', () => { + test('names cover the controls that had text glyphs', () => { + for (const n of ['zoomIn', 'zoomOut', 'outline', 'present', 'sheetAdd', 'replace', 'close'] as const) expect(ICON_NAMES).toContain(n) + }) + + test('built-in svg without host icons', () => { + const out = html(undefined, 'bold') + expect(out).toStartWith(' { + const icons = { bold: B } + expect(html(icons, 'bold', 20)).toBe('') + expect(html(icons, 'italic')).toStartWith(' { + const seen: [IconName, number][] = [] + const icons: Icons = (name, { size }) => { + seen.push([name, size]) + if (name === 'undo') return {size} + if (name === 'redo') return null + if (name === 'zoomIn') return false + return undefined + } + expect(html(icons, 'undo', 16)).toContain('16') + expect(html(icons, 'redo')).toStartWith(' { + expect(hostIcon({ bold: '' }, 'bold', 18)).toBe('') + expect(hostIcon(() => 0, 'bold', 18)).toBe(0) + }) + + test('nested providers: inner first, then the enclosing one, then built-in', () => { + const out = (name: IconName) => + renderToStaticMarkup( + outer}> + inner }}> + + + , + ) + expect(out('bold')).toContain('inner') + expect(out('italic')).toContain('outer') + const none = renderToStaticMarkup( + outer }}> + + + + , + ) + expect(none).toStartWith(' Date: Sat, 14 Nov 2026 10:45:07 +0100 Subject: [PATCH 3/7] test(viewer): icon test names and the false fallback describe the code Co-Authored-By: Claude Opus 5.5 --- src/react/edit/Toolbar.tsx | 2 +- test/icons.test.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/react/edit/Toolbar.tsx b/src/react/edit/Toolbar.tsx index 55ab133..55240ec 100644 --- a/src/react/edit/Toolbar.tsx +++ b/src/react/edit/Toolbar.tsx @@ -171,7 +171,7 @@ type Resolve = (name: IconName, size: number) => ReactNode | undefined const IconsContext = createContext(null) -/** the host's icon for `name`, or `undefined` for the built-in one */ +/** the host's icon for `name`, or `undefined` for the built-in one; `false` counts as none, as a resolver written `name === 'x' && ` returns it */ export function hostIcon(icons: Icons | undefined, name: IconName, size: number): ReactNode | undefined { if (!icons) return undefined const own = typeof icons === 'function' ? icons(name, { size }) : icons[name] diff --git a/test/icons.test.tsx b/test/icons.test.tsx index e2a30eb..9e61931 100644 --- a/test/icons.test.tsx +++ b/test/icons.test.tsx @@ -10,7 +10,7 @@ const html = (icons: Icons | undefined, name: IconName, size?: number) => ) describe('icons', () => { - test('names cover the controls that had text glyphs', () => { + test('names exist for zoom, outline, Present, add sheet and Replace', () => { for (const n of ['zoomIn', 'zoomOut', 'outline', 'present', 'sheetAdd', 'replace', 'close'] as const) expect(ICON_NAMES).toContain(n) }) From 3fe433bd504e5978f613f4e2172d5c8736eadb32 Mon Sep 17 00:00:00 2001 From: Markus Date: Sat, 14 Nov 2026 10:52:13 +0100 Subject: [PATCH 4/7] test(viewer): the PPTX toolbar has fewer icons with nothing selected Co-Authored-By: Claude Opus 5.5 --- e2e/icons.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e/icons.spec.ts b/e2e/icons.spec.ts index 14892ee..2b50a9c 100644 --- a/e2e/icons.spec.ts +++ b/e2e/icons.spec.ts @@ -54,9 +54,10 @@ describe('host icons', () => { expect(await v.page.locator('.de-present-btn').innerText()).toContain('Present') expect(await hostIcon(v, 'button[aria-label="Zoom in"]', 'zoomIn').count()).toBe(1) + // with nothing selected the PPTX toolbar shows only its slide and insert controls await v.edit() const bar = await icons(v, '.de-ebar') - expect(bar.host).toBeGreaterThan(20) + expect(bar.host).toBeGreaterThan(5) expect(bar.builtIn).toBe(0) expect(v.errors).toEqual([]) }) From 1c0faf07f6747a41c07e2031c7d77d78acefe503 Mon Sep 17 00:00:00 2001 From: Markus Date: Sat, 14 Nov 2026 11:08:48 +0100 Subject: [PATCH 5/7] feat(viewer): translatable UI texts: strings table, locale and strings props The viewer's texts come from one typed table, `Strings` (src/react/strings/en.ts), with a complete German table next to it (de.ts, Swiss spelling, Word / Excel / PowerPoint terms). `DocViewer`, `mount()` and `` take `locale` ('en' | 'de', default 'en') and `strings`, a partial table merged over the locale key by key; `StringsProvider` / `useStrings()` give standalone views the same texts. Numbers and dates in the UI go through Intl with the table's locale; the English output is unchanged. This change converts the top bar, the details rows, the common dialogs, the zoom control, the shared toolbar controls, the source pane and the picture view. The shared groups (`edit`, `find`, `table`, `format`) are in the table already, so the document, sheet and slide views can be converted in separate changes. A unit test checks that every locale has all keys of `en`, no empty text and no sharp s in German. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + docs/api.md | 45 ++++- e2e/app/main.tsx | 3 + src/embed.ts | 19 ++- src/index.ts | 1 + src/react/DocViewer.tsx | 106 +++++------- src/react/common/Dialog.tsx | 9 +- src/react/common/ZoomControl.tsx | 12 +- src/react/edit/Toolbar.tsx | 21 ++- src/react/picture/PictureView.tsx | 8 +- src/react/source/SourceEditor.tsx | 12 +- src/react/strings/de.ts | 256 ++++++++++++++++++++++++++++ src/react/strings/en.ts | 267 ++++++++++++++++++++++++++++++ src/react/strings/index.tsx | 66 ++++++++ test/strings.test.ts | 63 +++++++ 15 files changed, 801 insertions(+), 88 deletions(-) create mode 100644 src/react/strings/de.ts create mode 100644 src/react/strings/en.ts create mode 100644 src/react/strings/index.tsx create mode 100644 test/strings.test.ts diff --git a/README.md b/README.md index 45ca7a7..c11adba 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ Also online at [p10node.github.io/office-engine](https://p10node.github.io/offic - **Comments**: threads with replies, resolve, edit / delete, @mentions; on DOCX text, XLSX cells and PPTX slides, stored in the file the way Word, Excel and PowerPoint store them - **Real-time collaboration**: live edits, avatars, collaborators' cursors, offline edits sync on reconnect - **Dark mode and theming**: `theme="dark"` or `"auto"` (follows the OS), or a theme object with design tokens for colors, type and shape ([Theming](docs/theming.md)); documents keep their own colors +- **Translatable UI**: every text of the bars, menus and dialogs from one typed table; English by default, German shipped (`locale="de"`), any text replaceable through `strings` - **Embed anywhere**: React component, hook, vanilla `mount()`, `` web component, single ` ``` -``: attributes `src`, `name`, `editable`, `toolbar="false"`, `thumbnails="false"`, `save="false"`, `download="false"`, `file-menu="false"`, `open="false"`, `capture-select-all="false"`, `theme="dark"` (or `"auto"`), `collab` / `room` / `user` / `base` (real-time collaboration; `user` also writes the comments), `comments="false"`; properties `.file = File` and `.icons` (map or resolver, as `icons` above); events `load`, `error`, `comment` (`detail`: a `CommentEvent`). +``: attributes `src`, `name`, `editable`, `toolbar="false"`, `thumbnails="false"`, `save="false"`, `download="false"`, `file-menu="false"`, `open="false"`, `capture-select-all="false"`, `theme="dark"` (or `"auto"`), `locale="de"`, `collab` / `room` / `user` / `base` (real-time collaboration; `user` also writes the comments), `comments="false"`; properties `.file = File`, `.icons` (map or resolver, as `icons` above) and `.strings = { … }` (see [UI texts](#ui-texts)); events `load`, `error`, `comment` (`detail`: a `CommentEvent`). ## Core API (no React) diff --git a/e2e/app/main.tsx b/e2e/app/main.tsx index be08f97..e1b6a41 100644 --- a/e2e/app/main.tsx +++ b/e2e/app/main.tsx @@ -3,6 +3,7 @@ * ?src=samples/sample.docx&editable=1&allowSave=0&onSave=1&items=1&close=1&collab=ws://…&room=r * comments: &me=Ann&meId=u1 (author), &people=1 (mentions: Ann, Bob Stone, Cid), &comments=0 (hidden), &cmOpen=1 * &icons=1: every icon from a host resolver, as `` + * texts: &locale=de, &fileLabel=… (a partial `strings` override of the File menu's label) */ import { useState } from 'react' import { createRoot } from 'react-dom/client' @@ -48,6 +49,8 @@ const initial: DocViewerProps = { captureSelectAll: flag('captureSelectAll'), theme: (q.get('theme') as DocViewerProps['theme']) ?? undefined, injectStyles: flag('injectStyles'), + locale: (q.get('locale') as DocViewerProps['locale']) ?? undefined, + strings: q.has('fileLabel') ? { file: { menu: q.get('fileLabel')! } } : undefined, onSave: q.has('onSave') ? async (blob, name) => void log({ type: 'save', name, b64: await b64(blob) }) : undefined, onDirtyChange: (dirty) => log({ type: 'dirty', dirty }), onRename: (name) => log({ type: 'rename', name }), diff --git a/src/embed.ts b/src/embed.ts index 81318e0..afab05b 100644 --- a/src/embed.ts +++ b/src/embed.ts @@ -3,10 +3,12 @@ import { createRoot, type Root } from 'react-dom/client' import './core/default-worker' import { DocViewer, type DocViewerProps } from './react/DocViewer' import type { Icons } from './react/edit/Toolbar' +import type { StringsOverride } from './react/strings' export type MountOptions = Omit const THEMES = ['light', 'dark', 'auto'] as const +const LOCALE_NAMES = ['en', 'de'] as const export interface MountedViewer { update(next: Partial): void @@ -36,15 +38,17 @@ export function mount(el: HTMLElement, options: MountOptions): MountedViewer { * `` * Rendered inside a shadow root so host page CSS cannot leak in. * Attributes: src, name, editable, toolbar / thumbnails / save / download / file-menu / open / capture-select-all ("false" to turn off), - * theme ("light" | "dark" | "auto"); + * theme ("light" | "dark" | "auto"), locale ("en" | "de"); * real-time collaboration: collab (hub WebSocket URL), room, user (display name), base (snapshot seq `src` was opened at). - * Properties: `file` (File/Blob/ArrayBuffer) for local data; `icons` (map or resolver, as DocViewer's `icons`). + * Properties: `file` (File/Blob/ArrayBuffer) for local data; `icons` (map or resolver, as DocViewer's `icons`); + * `strings` (texts replacing the locale's). */ export class OfficeViewerElement extends HTMLElement { - static observedAttributes = ['src', 'name', 'toolbar', 'thumbnails', 'editable', 'save', 'download', 'file-menu', 'open', 'capture-select-all', 'theme', 'collab', 'room', 'user', 'base', 'comments'] + static observedAttributes = ['src', 'name', 'toolbar', 'thumbnails', 'editable', 'save', 'download', 'file-menu', 'open', 'capture-select-all', 'theme', 'collab', 'room', 'user', 'base', 'comments', 'locale'] private viewer?: MountedViewer private _file?: Blob | ArrayBuffer private _icons?: Icons + private _strings?: StringsOverride get file() { return this._file @@ -61,6 +65,13 @@ export class OfficeViewerElement extends HTMLElement { this._icons = v this.sync() } + get strings() { + return this._strings + } + set strings(v: StringsOverride | undefined) { + this._strings = v + this.sync() + } connectedCallback() { if (this.viewer) return @@ -96,6 +107,8 @@ export class OfficeViewerElement extends HTMLElement { captureSelectAll: this.getAttribute('capture-select-all') !== 'false', theme: THEMES.find((t) => t === this.getAttribute('theme')), icons: this._icons, + locale: LOCALE_NAMES.find((l) => l === this.getAttribute('locale')), + strings: this._strings, collab: this.getAttribute('collab') && this.getAttribute('room') ? { diff --git a/src/index.ts b/src/index.ts index 9d018d0..e90e0e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ export { injectStyles, stylesText } from './react/injectStyles' export type { Zoom } from './react/common/ZoomControl' export { THEME_TOKENS, createTheme, themeStyle, useThemeRoot, type Theme, type ThemeMode, type ThemeOptions, type ThemeToken, type ThemeTokens } from './react/theme' export { IconsProvider, ICON_NAMES, type IconName, type Icons } from './react/edit/Toolbar' +export { StringsProvider, useStrings, resolveStrings, LOCALES, en, de, type Strings, type StringsOverride, type Locale } from './react/strings' export { DocumentClient, setDefaultWorker, type OpenOptions } from './core/worker/client' export type { DocSource } from './core/source' diff --git a/src/react/DocViewer.tsx b/src/react/DocViewer.tsx index 8d1f37c..3ec9030 100644 --- a/src/react/DocViewer.tsx +++ b/src/react/DocViewer.tsx @@ -8,12 +8,11 @@ import { StatusContext } from './common/Status' import { ConfirmDialog, PromptDialog, Toast } from './common/Dialog' import { download, Icon, IconsProvider, type Icons } from './edit/Toolbar' import { DetailsDialog } from './file/DetailsDialog' -import { FileMenu, KIND_LABEL, useFileActions, type FileMenuItem } from './file/FileMenu' +import { FileMenu, useFileActions, type FileMenuItem } from './file/FileMenu' import { importFile, type Imported } from './file/importFile' import { blankPptx, parasToDocx, rowsToXlsx } from '../core/convert' import type { DocKind } from '../core/opc' import { extOf, HAS_SOURCE, PLAIN_EXT, PLAIN_FILE, unpackedName, type PlainFormat } from '../core/plain' -import type { LegacyFormat } from '../core/legacy' import { injectStyles } from './injectStyles' import { isTextEntry, useSize } from './common/hooks' import { useThemeRoot, type Theme } from './theme' @@ -26,6 +25,7 @@ import type { CollabOptions } from '../collab' import { CollabBar, PeerOverlay, useCollab } from '../collab/react' import { CommentsContext, CommentsCtl, useCommentsState, type CommentOptions } from './comments/Comments' import { COMMENT_KEYS, CommentsPanel } from './comments/CommentsPanel' +import { formatBytes, StringsProvider, useStrings, type Locale, type StringsOverride } from './strings' export interface DocViewerProps { /** @@ -111,38 +111,20 @@ export interface DocViewerProps { * Each is rendered in `span.de-icon`, sized by font-size (1em). */ icons?: Icons + /** Language of the viewer's texts (bars, menus, dialogs, messages): 'en' or 'de'. Default 'en'. */ + locale?: Locale + /** Texts replacing the locale's, key by key (see `Strings`): a group may be given in part, e.g. `{ file: { menu: 'Datei' } }`. */ + strings?: StringsOverride } const EXT: Record = { docx: '.docx', xlsx: '.xlsx', pptx: '.pptx' } -const FROM_LABEL: Record<'pages' | 'keynote' | 'numbers', string> = { pages: 'Apple Pages document', keynote: 'Apple Keynote presentation', numbers: 'Apple Numbers spreadsheet' } -const CONVERTED_LABEL: Record = { - doc: 'Word 97-2003 document', - xls: 'Excel 97-2003 workbook', - ppt: 'PowerPoint 97-2003 presentation', - rtf: 'Rich Text Format document', - odt: 'OpenDocument text', - ods: 'OpenDocument spreadsheet', - odp: 'OpenDocument presentation', -} const IMPORTED_BADGE: Record = { csv: 'csv', tsv: 'tsv', text: 'txt', markdown: 'md', html: 'html', svg: 'svg', mermaid: 'mmd', plantuml: 'puml', graphviz: 'dot' } -const IMPORTED_LABEL: Record = { - csv: 'CSV file', - tsv: 'Tab-separated values', - text: 'Plain text', - markdown: 'Markdown', - html: 'HTML page', - svg: 'SVG picture', - mermaid: 'Mermaid diagram', - plantuml: 'PlantUML diagram', - graphviz: 'Graphviz diagram', -} /** plain formats saved back as themselves: Markdown and HTML built again from the document when it was edited, SVG from its text */ const SAVES_SOURCE = new Set(['markdown', 'html', 'svg']) const savesSource = (name: string) => { const f = PLAIN_EXT[extOf(unpackedName(name) ?? name)] return !!f && SAVES_SOURCE.has(f) } -const UNTITLED: Record = { docx: 'Untitled document', xlsx: 'Untitled spreadsheet', pptx: 'Untitled presentation' } /** a key per opened client: a view is built again for each (the edited source's previews have one name) */ const clientIds = new WeakMap() @@ -153,13 +135,17 @@ const clientKey = (c: object) => { return id } -function fmtBytes(n: number) { - if (n < 1024) return `${n} B` - if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB` - return `${(n / 1024 / 1024).toFixed(1)} MB` +export function DocViewer(props: DocViewerProps) { + return ( + + + + ) } -export function DocViewer(props: DocViewerProps) { +function Viewer(props: DocViewerProps) { + const t = useStrings() + const fmtBytes = (n: number) => formatBytes(t, n) const { src, name, className, style, toolbar = true, toolbarExtra, zoom, thumbnails, headers, credentials, worker, styleTarget, theme, onLoad, onError } = props const root = useThemeRoot(theme, style) const { allowEdit = true, allowSave = true, allowDownload = true, fileMenu = true, allowOpen = true, captureSelectAll = true, onSave, onDirtyChange } = props @@ -465,12 +451,12 @@ export function DocViewer(props: DocViewerProps) { canDownload: fileMenu && allowDownload && !!client, ops: fileOps, create: (kind: DocKind) => - replace(`A new ${UNTITLED[kind].split(' ')[1]} replaces the current document. Your changes are not saved.`, () => { + replace(t.bar.replaceNew(t.newWhat[kind]), () => { const blank = kind === 'docx' ? parasToDocx([]) : kind === 'xlsx' ? rowsToXlsx([], 'Sheet1') : blankPptx() - blank.then((src) => show({ src, name: UNTITLED[kind] + EXT[kind], converted: false }, allowEdit), (e: Error) => setToast(e.message)) + blank.then((src) => show({ src, name: t.untitled[kind] + EXT[kind], converted: false }, allowEdit), (e: Error) => setToast(e.message)) }), open: (file) => - replace(`Opening “${file.name}” replaces the current document. Your changes are not saved.`, () => { + replace(t.bar.replaceOpen(file.name), () => { importFile(file).then(show, (e: Error) => setToast(e.message)) }), save, @@ -482,7 +468,7 @@ export function DocViewer(props: DocViewerProps) { }, picture: isPicture ? { - label: 'SVG picture', + label: t.file.picture, ext: '.svg', download: async () => download(new Blob([await sourceText()], { type: 'image/svg+xml' }), saveName), } @@ -527,7 +513,7 @@ export function DocViewer(props: DocViewerProps) { body = (
- Cannot open document. + {t.bar.cannotOpen}
{error.message}
@@ -536,10 +522,10 @@ export function DocViewer(props: DocViewerProps) { body = src || opened ? (
-
{progress?.phase === 'download' && progress.total ? `Downloading… ${fmtBytes(progress.loaded)} / ${fmtBytes(progress.total)}` : 'Opening…'}
+
{progress?.phase === 'download' && progress.total ? t.bar.downloading(fmtBytes(progress.loaded), fmtBytes(progress.total)) : t.bar.opening}
) : ( -
No document
+
{t.bar.noDocument}
) } else if (isPicture) body = else if (client.kind === 'docx') body = @@ -566,11 +552,11 @@ export function DocViewer(props: DocViewerProps) { className={'de-mode' + (codeShown ? ' de-mode-on' : '')} onClick={() => setCodeOpen((v) => !v)} aria-pressed={codeShown} - title={codeShown ? 'Hide the source' : 'Edit the source side by side with the document'} + title={codeShown ? t.bar.codeHide : t.bar.codeShow} data-testid="de-code-toggle" > - Code + {t.bar.code} )} {cmCtl && ( @@ -578,25 +564,25 @@ export function DocViewer(props: DocViewerProps) { type="button" className={'de-mode de-cm-toggle' + (cmState.open ? ' de-mode-on' : '')} onClick={() => cmCtl.toggle()} - title={cmCtl.canComment ? `Comments (${COMMENT_KEYS} to comment on the selection)` : 'Comments'} + title={cmCtl.canComment ? t.bar.commentsTitle(COMMENT_KEYS) : t.bar.comments} aria-pressed={cmState.open} data-testid="de-comments-toggle" > - Comments + {t.bar.comments} {cmCount > 0 && {cmCount}} )} {client && allowEdit && !readOnly && !codeShown && ( - )} {client && canSave && ((editing && !readOnly) || dirty) && ( - )}
@@ -618,13 +604,13 @@ export function DocViewer(props: DocViewerProps) { readOnly={!allowEdit || !!props.collab} notice={ dirty && !sourceEdited.current && !rebuilds - ? 'Changes made in the document are not in this source: editing it builds the document again from the source.' + ? t.bar.sourceStale : undefined } onCopy={() => navigator.clipboard?.writeText(code).then( - () => setToast('Source copied'), - () => setToast('Cannot copy here'), + () => setToast(t.bar.sourceCopied), + () => setToast(t.bar.cannotCopy), ) } onDownload={allowDownload ? () => download(new Blob([code], { type: PLAIN_FILE[srcFormat!].mime }), sourceName()) : undefined} @@ -637,7 +623,7 @@ export function DocViewer(props: DocViewerProps) { className="de-split-handle" role="separator" aria-orientation={stacked ? 'horizontal' : 'vertical'} - aria-label="Resize the source" + aria-label={t.bar.resizeSource} aria-valuenow={Math.round(split * 100)} aria-valuemin={15} aria-valuemax={85} @@ -662,14 +648,14 @@ export function DocViewer(props: DocViewerProps) { {cmCtl && cmState.open && }
{toast && } - {confirm && setConfirm(null)} />} + {confirm && setConfirm(null)} />} {renaming && client && ( { const next = v.trim().replace(/[\\/:*?"<>|]+/g, ' ').replace(new RegExp(`(\\${EXT[client.kind]}|\\${saveExt})$`, 'i'), '') if (!next) return @@ -684,19 +670,19 @@ export function DocViewer(props: DocViewerProps) { 0 ? [['File size', fmtBytes(client.size)] as [string, string]] : []), - ...(dirty ? [['Status', 'Unsaved changes'] as [string, string]] : []), + ...(client.size > 0 ? [[t.details.fileSize, fmtBytes(client.size)] as [string, string]] : []), + ...(dirty ? [[t.details.status, t.details.unsaved] as [string, string]] : []), ]} stats={fileOps.current?.stats() ?? []} onClose={() => setDetails(false)} diff --git a/src/react/common/Dialog.tsx b/src/react/common/Dialog.tsx index 74678b7..6489f77 100644 --- a/src/react/common/Dialog.tsx +++ b/src/react/common/Dialog.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, type ReactNode } from 'react' +import { useStrings } from '../strings' export interface PromptSpec { title: string @@ -16,6 +17,7 @@ export interface PromptSpec { /** Small modal prompt (no window.prompt: works in sandboxed / embedded pages). */ export function PromptDialog({ spec, onClose }: { spec: PromptSpec; onClose: () => void }) { + const t = useStrings() const [v, setV] = useState(spec.initial ?? '') const ref = useRef(null) useEffect(() => { @@ -69,10 +71,10 @@ export function PromptDialog({ spec, onClose }: { spec: PromptSpec; onClose: () )}
@@ -82,6 +84,7 @@ export function PromptDialog({ spec, onClose }: { spec: PromptSpec; onClose: () /** Yes / no question (no window.confirm: works in sandboxed / embedded pages). */ export function ConfirmDialog(props: { title: string; message: ReactNode; ok: string; onOk: () => void; onClose: () => void }) { + const t = useStrings() const okRef = useRef(null) useEffect(() => okRef.current?.focus(), []) return ( @@ -100,7 +103,7 @@ export function ConfirmDialog(props: { title: string; message: ReactNode; ok: st
{props.message}
- onChange(zoom === 'fit' ? 1 : 'fit')}> - {zoom === 'fit' ? 'Fit' : `${Math.round(effective * 100)}%`} + onChange(zoom === 'fit' ? 1 : 'fit')}> + {zoom === 'fit' ? t.zoom.fit : formatNumber(t, effective, { style: 'percent' })} -
diff --git a/src/react/edit/Toolbar.tsx b/src/react/edit/Toolbar.tsx index 55240ec..bd17e3e 100644 --- a/src/react/edit/Toolbar.tsx +++ b/src/react/edit/Toolbar.tsx @@ -16,6 +16,7 @@ import { type RefObject, } from 'react' import { cssColor } from '../../core/color' +import { useStrings } from '../strings' const BORDER_GRID = @@ -471,10 +472,11 @@ export function SizeBox(props: { value?: number; placeholder?: number; sizes?: n if (text !== null) set(parseFloat(text)) inputRef.current?.blur() } - const title = props.title ?? 'Font size' + const t = useStrings() + const title = props.title ?? t.toolbar.fontSize return ( - - {open && ( @@ -551,6 +553,7 @@ const signature = (el: ReactElement) => { * Item widths are measured once per set of items and cached for resizes. */ export function ToolbarRow({ children }: { children: ReactNode }) { + const t = useStrings() const items = flatten(children) const sig = items.map(signature).join('|') const rowRef = useRef(null) @@ -614,7 +617,7 @@ export function ToolbarRow({ children }: { children: ReactNode }) { ))} {rest.length > 0 && ( - {more && ( @@ -685,6 +688,7 @@ export function ColorBtn(props: { icon: IconName; title: string; value?: string; const onPick = useRef(props.onPick) onPick.current = props.onPick const cur = props.value?.toLowerCase() + const t = useStrings() // native 'change' fires once the OS picker is confirmed (React's onChange would fire while dragging) useEffect(() => { @@ -725,13 +729,13 @@ export function ColorBtn(props: { icon: IconName; title: string; value?: string; {open && (
{(props.palette ?? PALETTE).map(swatch)}
-
Custom
+
{t.toolbar.custom}
{custom.map(swatch)} -
@@ -805,6 +809,7 @@ export function Pop(props: { icon: IconName; title: string; children: (close: () /** rows × cols picker for new tables */ export function GridPicker({ onPick }: { onPick: (r: number, c: number) => void }) { const [hover, setHover] = useState<[number, number]>([0, 0]) + const t = useStrings() return (
setHover([0, 0])}> @@ -814,7 +819,7 @@ export function GridPicker({ onPick }: { onPick: (r: number, c: number) => void return
-
{hover[0] ? `${hover[0]} × ${hover[1]}` : 'Table size'}
+
{hover[0] ? `${hover[0]} × ${hover[1]}` : t.toolbar.tableSize}
) } diff --git a/src/react/picture/PictureView.tsx b/src/react/picture/PictureView.tsx index e963819..4b05eb0 100644 --- a/src/react/picture/PictureView.tsx +++ b/src/react/picture/PictureView.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { svgImage } from '../../core/svgfile' import type { DocumentClient } from '../../core/worker/client' import { useFileOps } from '../common/Slots' +import { formatNumber, useStrings } from '../strings' type Pic = { url: string; w: number; h: number; alt: string } | { error: string } @@ -10,6 +11,7 @@ type Pic = { url: string; w: number; h: number; alt: string } | { error: string * Drawn from the file's text (the Code pane edits it); the document built from it is what Download as Word gives. */ export function PictureView({ client }: { client: DocumentClient }) { + const t = useStrings() const [pic, setPic] = useState(null) useEffect(() => { let live = true @@ -37,11 +39,11 @@ export function PictureView({ client }: { client: DocumentClient }) { const shown = pic && 'url' in pic ? pic : null useFileOps({ printable: async () => { - if (!shown) throw new Error('The picture is not shown') + if (!shown) throw new Error(t.picture.notShown) return { size: 'auto', margin: '1cm', node: {shown.alt} } }, text: async () => ((await client.call('plain.source')) as string | null) ?? '', - stats: () => (shown ? [['Picture size', `${Math.round(shown.w)} × ${Math.round(shown.h)} px`]] : []), + stats: () => (shown ? [[t.picture.size, `${formatNumber(t, Math.round(shown.w))} × ${formatNumber(t, Math.round(shown.h))} px`]] : []), }) if (!pic) return ( @@ -53,7 +55,7 @@ export function PictureView({ client }: { client: DocumentClient }) { return (
- SVG picture not drawn. + {t.picture.notDrawn}
{pic.error}
diff --git a/src/react/source/SourceEditor.tsx b/src/react/source/SourceEditor.tsx index c28d72c..a58f492 100644 --- a/src/react/source/SourceEditor.tsx +++ b/src/react/source/SourceEditor.tsx @@ -2,6 +2,7 @@ import { useMemo, useRef, type KeyboardEvent } from 'react' import type { PlainFormat } from '../../core/plain' import { Icon } from '../edit/Toolbar' import { highlight } from './highlight' +import { useStrings } from '../strings' const LABEL: Partial> = { markdown: 'Markdown', html: 'HTML', svg: 'SVG', mermaid: 'Mermaid', plantuml: 'PlantUML', graphviz: 'Graphviz' } @@ -21,6 +22,7 @@ export interface SourceEditorProps { * textarea over the colored text, both sized by their content in one grid cell (they scroll together). */ export function SourceEditor({ text, format, onChange, readOnly, notice, onCopy, onDownload }: SourceEditorProps) { + const t = useStrings() const tokens = useMemo(() => highlight(format, text), [format, text]) const lines = useMemo(() => { let n = 1 @@ -63,19 +65,19 @@ export function SourceEditor({ text, format, onChange, readOnly, notice, onCopy,
- {LABEL[format] ?? 'Source'} + {LABEL[format] ?? t.source.source} - {lines} {lines === 1 ? 'line' : 'lines'} + {lines} {t.source.lines(lines)} {onCopy && ( - )} {onDownload && ( - )} @@ -103,7 +105,7 @@ export function SourceEditor({ text, format, onChange, readOnly, notice, onCopy, wrap="off" rows={1} cols={1} - aria-label={`${LABEL[format] ?? 'Source'} source`} + aria-label={t.source.input(LABEL[format] ?? t.source.source)} data-testid="de-source-input" />
diff --git a/src/react/strings/de.ts b/src/react/strings/de.ts new file mode 100644 index 0000000..ac7232e --- /dev/null +++ b/src/react/strings/de.ts @@ -0,0 +1,256 @@ +import type { Strings } from './en' + +/** German UI texts (Swiss spelling: ss, never the sharp s; terms as in the German Word / Excel / PowerPoint). */ +export const de: Strings = { + locale: 'de-CH', + + common: { + ok: 'OK', + cancel: 'Abbrechen', + close: 'Schliessen', + save: 'Speichern', + delete: 'Löschen', + edit: 'Bearbeiten', + none: 'Keine', + apply: 'Übernehmen', + remove: 'Entfernen', + error: (message: string) => `Fehler: ${message}`, + stillLoading: 'Das Dokument wird noch geladen. Bitte gleich nochmals versuchen.', + }, + + bar: { + cannotOpen: 'Das Dokument kann nicht geöffnet werden.', + downloading: (loaded: string, total: string) => `Herunterladen… ${loaded} / ${total}`, + opening: 'Öffnen…', + noDocument: 'Kein Dokument', + code: 'Code', + codeHide: 'Quelltext ausblenden', + codeShow: 'Quelltext neben dem Dokument bearbeiten', + comments: 'Kommentare', + commentsTitle: (keys: string) => `Kommentare (${keys} kommentiert die Auswahl)`, + view: 'Ansicht', + edit: 'Bearbeiten', + toView: 'Zur Ansicht wechseln', + toEdit: 'Dokument bearbeiten', + saveTitle: 'Kopie speichern (Ctrl+S)', + saving: 'Speichern…', + save: 'Speichern', + download: 'Herunterladen', + sourceStale: 'Änderungen im Dokument sind nicht in diesem Quelltext: Wird er bearbeitet, entsteht das Dokument neu aus dem Quelltext.', + sourceCopied: 'Quelltext kopiert', + cannotCopy: 'Kopieren ist hier nicht möglich', + resizeSource: 'Grösse des Quelltexts ändern', + discardTitle: 'Nicht gespeicherte Änderungen verwerfen?', + discardOk: 'Weiter', + replaceNew: (what: string) => `${what} ersetzt das aktuelle Dokument. Die Änderungen sind nicht gespeichert.`, + replaceOpen: (name: string) => `„${name}“ ersetzt das aktuelle Dokument. Die Änderungen sind nicht gespeichert.`, + renameTitle: 'Umbenennen', + renameLabel: 'Dateiname', + renameOk: 'Umbenennen', + }, + + untitled: { docx: 'Unbenanntes Dokument', xlsx: 'Unbenannte Tabelle', pptx: 'Unbenannte Präsentation' }, + newWhat: { docx: 'Ein neues Dokument', xlsx: 'Eine neue Tabelle', pptx: 'Eine neue Präsentation' }, + + kind: { docx: 'Word-Dokument', xlsx: 'Excel-Arbeitsmappe', pptx: 'PowerPoint-Präsentation' }, + from: { pages: 'Apple-Pages-Dokument', keynote: 'Apple-Keynote-Präsentation', numbers: 'Apple-Numbers-Tabelle' }, + converted: { + doc: 'Word-97-2003-Dokument', + xls: 'Excel-97-2003-Arbeitsmappe', + ppt: 'PowerPoint-97-2003-Präsentation', + rtf: 'Rich-Text-Format-Dokument', + odt: 'OpenDocument-Text', + ods: 'OpenDocument-Tabelle', + odp: 'OpenDocument-Präsentation', + }, + imported: { + csv: 'CSV-Datei', + tsv: 'Tabulatorgetrennte Werte', + text: 'Nur-Text', + markdown: 'Markdown', + html: 'HTML-Seite', + svg: 'SVG-Bild', + mermaid: 'Mermaid-Diagramm', + plantuml: 'PlantUML-Diagramm', + graphviz: 'Graphviz-Diagramm', + }, + + details: { + title: 'Dokumenteigenschaften', + name: 'Name', + type: 'Typ', + viewOnly: (what: string) => `${what} (nur Ansicht)`, + openedAs: (what: string, kind: string) => `${what}, geöffnet als ${kind}`, + fileSize: 'Dateigrösse', + status: 'Status', + unsaved: 'Nicht gespeicherte Änderungen', + core: { + title: 'Titel', + subject: 'Thema', + creator: 'Autor', + keywords: 'Stichwörter', + description: 'Kommentare', + lastModifiedBy: 'Zuletzt geändert von', + created: 'Erstellt', + modified: 'Geändert', + }, + }, + + file: { + menu: 'Datei', + new: 'Neu', + newDocx: 'Dokument', + newXlsx: 'Tabelle', + newPptx: 'Präsentation', + open: 'Öffnen…', + importCsv: 'CSV in neues Blatt importieren…', + save: 'Speichern', + rename: 'Umbenennen…', + downloadAs: 'Herunterladen als', + pdf: 'PDF-Dokument', + html: 'Webseite', + txt: 'Nur-Text', + md: 'Markdown', + csv: 'CSV (aktuelles Blatt)', + tsv: 'TSV (aktuelles Blatt)', + picture: 'SVG-Bild', + details: 'Dokumenteigenschaften', + print: 'Drucken', + close: 'Schliessen', + preparingPdf: 'PDF wird vorbereitet… im Druckdialog „Als PDF speichern“ wählen', + preparing: 'Wird vorbereitet…', + unsupported: (name: string) => + `„${name}“ kann nicht geöffnet werden: unterstützt werden Word, Excel, PowerPoint (auch 97-2003), OpenDocument, RTF, Pages, Keynote, Numbers, CSV, Text, Markdown, HTML, SVG, Mermaid, PlantUML und Graphviz (auch gzip-komprimiert: .svgz, .csv.gz)`, + }, + + zoom: { + group: 'Zoom', + out: 'Verkleinern', + in: 'Vergrössern', + fit: 'Breite', + fitTitle: 'An Breite angepasst (Klick für 100%)', + toFit: 'Klicken, um an die Breite anzupassen', + }, + + toolbar: { + fontSize: 'Schriftgrad', + fontSizeDown: 'Schriftgrad verkleinern', + fontSizeUp: 'Schriftgrad vergrössern', + more: 'Mehr', + moreTools: 'Weitere Werkzeuge', + custom: 'Benutzerdefiniert', + customColor: 'Benutzerdefinierte Farbe', + customColorTitle: 'Benutzerdefinierte Farbe…', + tableSize: 'Tabellengrösse', + }, + + edit: { + undo: 'Rückgängig', + redo: 'Wiederholen', + bold: 'Fett', + italic: 'Kursiv', + underline: 'Unterstrichen', + strike: 'Durchgestrichen', + textColor: 'Schriftfarbe', + highlight: 'Texthervorhebungsfarbe', + automatic: 'Automatisch', + font: 'Schriftart', + alignLeft: 'Linksbündig', + center: 'Zentriert', + alignRight: 'Rechtsbündig', + justify: 'Blocksatz', + bullets: 'Aufzählungszeichen', + numbers: 'Nummerierung', + indentLess: 'Einzug verkleinern', + indentMore: 'Einzug vergrössern', + sup: 'Hochgestellt', + sub: 'Tiefgestellt', + clearFormat: 'Formatierung löschen', + link: 'Link einfügen', + editLink: 'Link bearbeiten', + linkPlaceholder: 'https://…', + openLink: 'Link öffnen', + editLinkMenu: 'Link bearbeiten…', + insertLinkMenu: 'Link einfügen…', + removeLink: 'Link entfernen', + image: 'Bild einfügen', + imageMenu: 'Bild einfügen…', + altText: 'Alternativtext', + altTextMenu: 'Alternativtext…', + altLabel: 'Bild für Bildschirmleseprogramme beschreiben', + cut: 'Ausschneiden', + copy: 'Kopieren', + paste: 'Einfügen', + pastePlain: 'Ohne Formatierung einfügen', + selectAll: 'Alles auswählen', + clipboardBlocked: (keys: string) => `Der Browser hat den Zugriff auf die Zwischenablage blockiert. Zum Einfügen ${keys} verwenden.`, + useKeys: (keys: string) => `${keys} verwenden`, + comment: 'Kommentar', + showComment: 'Kommentar anzeigen', + delKey: 'Entf', + findReplace: 'Suchen und Ersetzen', + keyed: (label: string, keys: string) => `${label} (${keys})`, + }, + + find: { + placeholder: 'Im Dokument suchen', + label: 'Suchen', + prev: 'Vorheriges (Umschalt+Eingabe)', + next: 'Nächstes (Eingabe)', + replace: 'Ersetzen', + replaceWith: 'Ersetzen durch', + replaceAll: 'Alle', + close: 'Schliessen (Esc)', + }, + + table: { + menu: 'Tabelle', + rowAbove: 'Zeile oberhalb einfügen', + rowBelow: 'Zeile unterhalb einfügen', + colLeft: 'Spalte links einfügen', + colRight: 'Spalte rechts einfügen', + delRow: 'Zeile löschen', + delCol: 'Spalte löschen', + delTable: 'Tabelle löschen', + distribute: 'Spalten gleichmässig verteilen', + merge: 'Zellen verbinden', + unmerge: 'Zellen teilen', + insert: 'Tabelle einfügen', + }, + + format: { + noFill: 'Keine Füllung', + borderColor: 'Rahmenfarbe', + black: 'Schwarz', + borderStyle: 'Linienart', + borders: { + all: 'Alle Rahmenlinien', + inner: 'Rahmenlinien innen', + outer: 'Rahmenlinien aussen', + none: 'Kein Rahmen', + top: 'Rahmenlinie oben', + bottom: 'Rahmenlinie unten', + left: 'Rahmenlinie links', + right: 'Rahmenlinie rechts', + }, + lines: { thin: 'Dünn', medium: 'Mittel', thick: 'Dick', dashed: 'Gestrichelt', dotted: 'Gepunktet', double: 'Doppelt' }, + vAlign: 'Vertikale Ausrichtung', + vAligns: { top: 'Oben', middle: 'Mitte', bottom: 'Unten' }, + spacings: { single: 'Einfach', double: 'Doppelt', triple: 'Dreifach' }, + inches: (w: string, h: string) => `${w} × ${h} Zoll`, + }, + + source: { + source: 'Quelltext', + lines: (n: number) => (n === 1 ? 'Zeile' : 'Zeilen'), + copy: 'Quelltext kopieren', + download: 'Quelltext herunterladen', + input: (format: string) => `${format}-Quelltext`, + }, + + picture: { + notShown: 'Das Bild wird nicht angezeigt', + size: 'Bildgrösse', + notDrawn: 'Das SVG-Bild konnte nicht dargestellt werden.', + }, +} diff --git a/src/react/strings/en.ts b/src/react/strings/en.ts new file mode 100644 index 0000000..7bbc6f5 --- /dev/null +++ b/src/react/strings/en.ts @@ -0,0 +1,267 @@ +/** + * The viewer's UI texts in English (the default locale). Every other locale has the same keys (`Strings`); + * values are plain strings, or functions where a text takes numbers or names. + */ +export const en = { + /** BCP 47 tag used for numbers and dates (Intl) */ + locale: 'en-US', + + common: { + ok: 'OK', + cancel: 'Cancel', + close: 'Close', + save: 'Save', + delete: 'Delete', + edit: 'Edit', + none: 'None', + apply: 'Apply', + remove: 'Remove', + error: (message: string) => `Error: ${message}`, + stillLoading: 'The document is still loading — try again in a moment', + }, + + bar: { + cannotOpen: 'Cannot open document.', + downloading: (loaded: string, total: string) => `Downloading… ${loaded} / ${total}`, + opening: 'Opening…', + noDocument: 'No document', + code: 'Code', + codeHide: 'Hide the source', + codeShow: 'Edit the source side by side with the document', + comments: 'Comments', + commentsTitle: (keys: string) => `Comments (${keys} to comment on the selection)`, + view: 'View', + edit: 'Edit', + toView: 'Switch to viewing', + toEdit: 'Edit document', + saveTitle: 'Save a copy (Ctrl+S)', + saving: 'Saving…', + save: 'Save', + download: 'Download', + sourceStale: 'Changes made in the document are not in this source: editing it builds the document again from the source.', + sourceCopied: 'Source copied', + cannotCopy: 'Cannot copy here', + resizeSource: 'Resize the source', + discardTitle: 'Discard unsaved changes?', + discardOk: 'Continue', + replaceNew: (what: string) => `A new ${what} replaces the current document. Your changes are not saved.`, + replaceOpen: (name: string) => `Opening “${name}” replaces the current document. Your changes are not saved.`, + renameTitle: 'Rename', + renameLabel: 'File name', + renameOk: 'Rename', + }, + + /** file names of new documents (the extension is added) */ + untitled: { docx: 'Untitled document', xlsx: 'Untitled spreadsheet', pptx: 'Untitled presentation' }, + /** what File → New makes: the subject of `bar.replaceNew` */ + newWhat: { docx: 'document', xlsx: 'spreadsheet', pptx: 'presentation' }, + + kind: { docx: 'Word document', xlsx: 'Excel workbook', pptx: 'PowerPoint presentation' }, + from: { pages: 'Apple Pages document', keynote: 'Apple Keynote presentation', numbers: 'Apple Numbers spreadsheet' }, + converted: { + doc: 'Word 97-2003 document', + xls: 'Excel 97-2003 workbook', + ppt: 'PowerPoint 97-2003 presentation', + rtf: 'Rich Text Format document', + odt: 'OpenDocument text', + ods: 'OpenDocument spreadsheet', + odp: 'OpenDocument presentation', + }, + imported: { + csv: 'CSV file', + tsv: 'Tab-separated values', + text: 'Plain text', + markdown: 'Markdown', + html: 'HTML page', + svg: 'SVG picture', + mermaid: 'Mermaid diagram', + plantuml: 'PlantUML diagram', + graphviz: 'Graphviz diagram', + }, + + details: { + title: 'Document details', + name: 'Name', + type: 'Type', + viewOnly: (what: string) => `${what} (view only)`, + openedAs: (what: string, kind: string) => `${what}, opened as ${kind}`, + fileSize: 'File size', + status: 'Status', + unsaved: 'Unsaved changes', + core: { + title: 'Title', + subject: 'Subject', + creator: 'Author', + keywords: 'Keywords', + description: 'Comments', + lastModifiedBy: 'Last modified by', + created: 'Created', + modified: 'Modified', + }, + }, + + file: { + menu: 'File', + new: 'New', + newDocx: 'Document', + newXlsx: 'Spreadsheet', + newPptx: 'Presentation', + open: 'Open…', + importCsv: 'Import CSV into new sheet…', + save: 'Save', + rename: 'Rename…', + downloadAs: 'Download as', + pdf: 'PDF document', + html: 'Web page', + txt: 'Plain text', + md: 'Markdown', + csv: 'CSV (current sheet)', + tsv: 'TSV (current sheet)', + picture: 'SVG picture', + details: 'Document details', + print: 'Print', + close: 'Close', + preparingPdf: 'Preparing PDF… choose “Save as PDF” in the print dialog', + preparing: 'Preparing…', + unsupported: (name: string) => + `Cannot open “${name}”: supported files are Word, Excel, PowerPoint (also 97-2003), OpenDocument, RTF, Pages, Keynote, Numbers, CSV, text, Markdown, HTML, SVG, Mermaid, PlantUML and Graphviz (also gzip-compressed: .svgz, .csv.gz)`, + }, + + zoom: { + group: 'Zoom', + out: 'Zoom out', + in: 'Zoom in', + fit: 'Fit', + fitTitle: 'Fit width (click for 100%)', + toFit: 'Click to fit width', + }, + + toolbar: { + fontSize: 'Font size', + fontSizeDown: 'Decrease font size', + fontSizeUp: 'Increase font size', + more: 'More', + moreTools: 'More tools', + custom: 'Custom', + customColor: 'Custom color', + customColorTitle: 'Custom color…', + tableSize: 'Table size', + }, + + /** text editing shared by the document, cell and slide editors */ + edit: { + undo: 'Undo', + redo: 'Redo', + bold: 'Bold', + italic: 'Italic', + underline: 'Underline', + strike: 'Strikethrough', + textColor: 'Text color', + highlight: 'Highlight color', + automatic: 'Automatic', + font: 'Font', + alignLeft: 'Align left', + center: 'Center', + alignRight: 'Align right', + justify: 'Justify', + bullets: 'Bulleted list', + numbers: 'Numbered list', + indentLess: 'Decrease indent', + indentMore: 'Increase indent', + sup: 'Superscript', + sub: 'Subscript', + clearFormat: 'Clear formatting', + link: 'Insert link', + editLink: 'Edit link', + linkPlaceholder: 'https://…', + openLink: 'Open link', + editLinkMenu: 'Edit link…', + insertLinkMenu: 'Insert link…', + removeLink: 'Remove link', + image: 'Insert image', + imageMenu: 'Insert image…', + altText: 'Alt text', + altTextMenu: 'Alt text…', + altLabel: 'Describe the picture for screen readers', + cut: 'Cut', + copy: 'Copy', + paste: 'Paste', + pastePlain: 'Paste without formatting', + selectAll: 'Select all', + clipboardBlocked: (keys: string) => `The browser blocked clipboard access — use ${keys} to paste`, + useKeys: (keys: string) => `Use ${keys}`, + /** context menu entry that starts a comment */ + comment: 'Comment', + showComment: 'Show comment', + /** the Delete key, as a menu hint */ + delKey: 'Del', + findReplace: 'Find & replace', + /** a menu entry or button with its shortcut */ + keyed: (label: string, keys: string) => `${label} (${keys})`, + }, + + find: { + placeholder: 'Find in document', + label: 'Find', + prev: 'Previous (Shift+Enter)', + next: 'Next (Enter)', + replace: 'Replace', + replaceWith: 'Replace with', + replaceAll: 'All', + close: 'Close (Esc)', + }, + + table: { + menu: 'Table', + rowAbove: 'Insert row above', + rowBelow: 'Insert row below', + colLeft: 'Insert column left', + colRight: 'Insert column right', + delRow: 'Delete row', + delCol: 'Delete column', + delTable: 'Delete table', + distribute: 'Distribute columns evenly', + merge: 'Merge cells', + unmerge: 'Unmerge cells', + insert: 'Insert table', + }, + + /** cell and shape formatting shared by the document, sheet and slide toolbars */ + format: { + noFill: 'No fill', + borderColor: 'Border color', + black: 'Black', + borderStyle: 'Border style', + borders: { + all: 'All borders', + inner: 'Inner borders', + outer: 'Outer borders', + none: 'No borders', + top: 'Top border', + bottom: 'Bottom border', + left: 'Left border', + right: 'Right border', + }, + lines: { thin: 'Thin', medium: 'Medium', thick: 'Thick', dashed: 'Dashed', dotted: 'Dotted', double: 'Double' }, + vAlign: 'Vertical align', + vAligns: { top: 'Top', middle: 'Middle', bottom: 'Bottom' }, + spacings: { single: 'Single', double: 'Double', triple: 'Triple' }, + inches: (w: string, h: string) => `${w} × ${h} in`, + }, + + source: { + source: 'Source', + lines: (n: number): string => (n === 1 ? 'line' : 'lines'), + copy: 'Copy the source', + download: 'Download the source', + input: (format: string) => `${format} source`, + }, + + picture: { + notShown: 'The picture is not shown', + size: 'Picture size', + notDrawn: 'SVG picture not drawn.', + }, +} + +export type Strings = typeof en diff --git a/src/react/strings/index.tsx b/src/react/strings/index.tsx new file mode 100644 index 0000000..3396dd6 --- /dev/null +++ b/src/react/strings/index.tsx @@ -0,0 +1,66 @@ +import { createContext, useContext, useMemo, type ReactNode } from 'react' +import { en, type Strings } from './en' +import { de } from './de' + +export type { Strings } from './en' +export { en } from './en' +export { de } from './de' + +/** Locales shipped with the engine. */ +export type Locale = 'en' | 'de' + +export const LOCALES: Record = { en, de } + +type DeepPartial = { [K in keyof T]?: T[K] extends string | ((...args: never[]) => unknown) ? T[K] : DeepPartial } + +/** A partial table: groups merge key by key over the locale's texts. */ +export type StringsOverride = DeepPartial + +const isGroup = (v: unknown): v is Record => !!v && typeof v === 'object' + +function merge(base: Record, over: Record): Record { + const out = { ...base } + for (const [k, v] of Object.entries(over)) { + if (v === undefined) continue + const cur = out[k] + out[k] = isGroup(cur) && isGroup(v) ? merge(cur, v) : v + } + return out +} + +/** The texts for `locale` with `override` merged over them (unknown locales fall back to English). */ +export function resolveStrings(locale: Locale | string = 'en', override?: StringsOverride): Strings { + const base = LOCALES[locale as Locale] ?? en + return override ? (merge(base, override as Record) as Strings) : base +} + +const formats = new Map() + +/** A number in the texts' locale, without digit grouping unless `opts` asks for it. */ +export function formatNumber(s: Strings, n: number, opts?: Intl.NumberFormatOptions): string { + const key = s.locale + JSON.stringify(opts ?? {}) + let f = formats.get(key) + if (!f) formats.set(key, (f = new Intl.NumberFormat(s.locale, { useGrouping: false, ...opts }))) + return f.format(n) +} + +/** A count (pages, words, rows) with digit grouping. */ +export const formatCount = (s: Strings, n: number) => formatNumber(s, n, { useGrouping: true }) + +/** A byte count as B / KB / MB. */ +export function formatBytes(s: Strings, n: number): string { + if (n < 1024) return `${formatNumber(s, n)} B` + if (n < 1024 * 1024) return `${formatNumber(s, n / 1024, { maximumFractionDigits: 0 })} KB` + return `${formatNumber(s, n / 1024 / 1024, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} MB` +} + +const StringsContext = createContext(en) + +/** Provides the UI texts to the views below it (`DocViewer` provides its own from `locale` / `strings`). */ +export function StringsProvider({ locale, strings, children }: { locale?: Locale | string; strings?: StringsOverride; children?: ReactNode }) { + const value = useMemo(() => resolveStrings(locale, strings), [locale, strings]) + return {children} +} + +/** The UI texts of the nearest `StringsProvider` (English without one). */ +export const useStrings = () => useContext(StringsContext) diff --git a/test/strings.test.ts b/test/strings.test.ts new file mode 100644 index 0000000..64df67d --- /dev/null +++ b/test/strings.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from 'bun:test' +import { de, en, formatBytes, formatCount, LOCALES, resolveStrings, type Strings } from '../src/react/strings' + +type Entry = [path: string, value: unknown] + +/** every leaf of a table, with its dotted key */ +function leaves(table: object, prefix = ''): Entry[] { + return Object.entries(table).flatMap(([k, v]): Entry[] => (v && typeof v === 'object' ? leaves(v, `${prefix}${k}.`) : [[prefix + k, v]])) +} + +/** a sample call of a text function: numbers for counts, names otherwise */ +const sample = (fn: (...args: never[]) => unknown, n: number) => (fn as (...a: unknown[]) => unknown)(...Array.from({ length: fn.length }, () => n), 'x') + +describe('UI strings', () => { + const enLeaves = leaves(en) + + test('every locale has exactly the keys of en, with the same kind of value', () => { + for (const [name, table] of Object.entries(LOCALES)) { + const got = new Map(leaves(table)) + expect([...got.keys()].sort(), name).toEqual(enLeaves.map(([k]) => k).sort()) + for (const [k, v] of enLeaves) expect(typeof got.get(k), `${name}.${k}`).toBe(typeof v) + } + }) + + test('no text is empty, also for 1 and for many', () => { + for (const [name, table] of Object.entries(LOCALES)) { + for (const [k, v] of leaves(table)) { + const texts = typeof v === 'function' ? [sample(v as never, 1), sample(v as never, 3)] : [v] + for (const t of texts) { + expect(typeof t, `${name}.${k}`).toBe('string') + expect((t as string).trim().length, `${name}.${k}`).toBeGreaterThan(0) + } + } + } + }) + + test('German is Swiss: no sharp s', () => { + for (const [k, v] of leaves(de)) { + const texts = typeof v === 'function' ? [sample(v as never, 1), sample(v as never, 3)] : [v] + for (const t of texts) expect(String(t).includes('ß'), `de.${k}`).toBe(false) + } + }) + + test('an override merges key by key over the locale', () => { + const s = resolveStrings('de', { file: { menu: 'Ablage' }, zoom: { in: 'Grösser' } }) + expect(s.file.menu).toBe('Ablage') + expect(s.file.print).toBe(de.file.print) + expect(s.zoom.in).toBe('Grösser') + expect(s.zoom.out).toBe(de.zoom.out) + expect(s.details.core.title).toBe(de.details.core.title) + // the shipped tables are left alone + expect(de.file.menu).toBe('Datei') + expect(resolveStrings('fr' as never)).toBe(en as Strings) + }) + + test('numbers keep the English output and follow the locale', () => { + expect(formatBytes(en, 512)).toBe('512 B') + expect(formatBytes(en, 2048)).toBe('2 KB') + expect(formatBytes(en, 1536 * 1024 * 1024)).toBe('1536.0 MB') + expect(formatCount(en, 1234567)).toBe('1,234,567') + expect(formatCount(de, 1234567)).not.toBe('1,234,567') + }) +}) From 5d98742a6865398a41aca7d5a2e17b4ae13725b6 Mon Sep 17 00:00:00 2001 From: Markus Date: Sat, 14 Nov 2026 11:30:19 +0100 Subject: [PATCH 6/7] feat(viewer): replaceable UI components for toolbars and dialogs A host can render its own Button, Select, Separator, Dialog and Toast in the engine's toolbars and dialogs (`components` prop, UIComponentsProvider), for a design system's controls where design tokens are not enough. Each one left out keeps the built-in one, whose markup is unchanged. - every toolbar button goes through Button: Btn, the color, picker and overflow toggles, the font size steps; Dropdown/Select through Select - PromptDialog and ConfirmDialog build their actions and body and render them through Dialog; Toast keeps its timer and renders through Toast - OfficeUIProvider carries icons, components, locale and strings to views mounted without DocViewer - the overflow button's title is its accessible name ("More tools"), so the unused "More" text goes - docs/theming.md "Your own components"; unit tests for each component and nested providers; e2e with host components: toolbar, formatting keeps the selection, overflow at a narrow width, the link prompt Co-Authored-By: Claude Opus 5.5 --- docs/api.md | 1 + docs/theming.md | 46 +++++++++- e2e/app/main.tsx | 47 +++++++++- e2e/components.spec.ts | 60 +++++++++++++ src/index.ts | 3 +- src/react/DocViewer.tsx | 8 ++ src/react/common/Dialog.tsx | 131 ++++++++++------------------ src/react/edit/Toolbar.tsx | 101 +++++++++++----------- src/react/strings/de.ts | 1 - src/react/strings/en.ts | 1 - src/react/ui.tsx | 168 ++++++++++++++++++++++++++++++++++++ test/ui.test.tsx | 108 +++++++++++++++++++++++ 12 files changed, 534 insertions(+), 141 deletions(-) create mode 100644 e2e/components.spec.ts create mode 100644 src/react/ui.tsx create mode 100644 test/ui.test.tsx diff --git a/docs/api.md b/docs/api.md index 6e5cd39..9f509fc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -74,6 +74,7 @@ CSS is auto-injected, no import needed (or import `@p10node/office-engine/styles | `captureSelectAll` | `boolean` | `true` | Ctrl/⌘+A from anywhere on the page selects the document; `false`: from the viewer only | | `fileMenuItems` | `FileMenuItem[]` | – | Extra File menu entries (`label`, `onClick`, `icon`, `hint`) | | `icons` | `Icons` | built-in | Your own icons: a map by `IconName` or a resolver `(name, { size }) => node`; see [Icons](#icons) | +| `components` | `Partial` | built-in | Your own `Button`, `Select`, `Separator`, `Dialog`, `Toast` in the toolbars and dialogs; see [Theming](theming.md#your-own-components) | | `onRename` | `(name) => void` | – | File → Rename result (extension included) | | `onClose` | `() => void` | – | Adds File → Close | | `onSave` | `(blob, name) => void` | download | Receives saved file (downloads by default): the Office file, or a Markdown / HTML / SVG file as itself | diff --git a/docs/theming.md b/docs/theming.md index 648b0ed..0ff91a9 100644 --- a/docs/theming.md +++ b/docs/theming.md @@ -66,9 +66,53 @@ gives the class name and style their root needs: ```tsx const root = useThemeRoot(theme) -return
+return ( + +
+
+) ``` +`OfficeUIProvider` carries what `DocViewer` takes as `icons`, `components`, `locale` and `strings` to views mounted on +their own. + +## Your own components + +Where tokens are not enough, the toolbars and dialogs render the host's own components: a design system's buttons, +selects and dialogs instead of the built-in ones. Each one left out keeps the built-in one. + +```tsx + ( + + {icon}{label}{children} + + ), + Separator: () => , + }} +/> +``` + +| Component | Used for | Props | +|---|---|---| +| `Button` | every toolbar button, the toggles that open color, table and border pickers, the overflow button | `ButtonProps`: `title`, `icon` (resolved through `icons`), `iconName`, `label`, `children`, `active`, `expanded`, `disabled`, `wide`, `onClick`, `onMouseDown`, `className` (the built-in classes) | +| `Select` | font, size list, style, format and menu dropdowns | `SelectProps`: `title`, `options` (`DropOption`: `value`, `label`, `hint`, `icon`, `disabled`, `sep`, `head`, `style`), `value` (undefined for a command menu), `onChange`, `label`, `placeholder`, `width`, `menuWidth`, `disabled`, `alignRight` | +| `Separator` | between toolbar groups | none | +| `Dialog` | prompts (link, rename, …) and confirmations | `DialogProps`: `title`, `kind` (`'alert'` for a question), `children` (the body), `actions` (`label`, `onClick`, `primary`, `submit`, `autoFocus`), `onSubmit` (the body is a form), `onClose` (Escape, backdrop) | +| `Toast` | short messages at the bottom | `text` (the viewer removes it after a few seconds) | + +The document must keep the focus while a toolbar control is used, or the command loses its selection: + +- `Button`: pass `onMouseDown` to the element. It prevents the default, which keeps the focus. +- `Select`: open on mouse down with the default prevented, and handle its keys without moving the focus into the list. + The built-in one captures the keys at the window while open. + +The overflow menu measures each toolbar item from a wrapper of its own, so a host component needs no ref. + ## Tokens | Group | Tokens | diff --git a/e2e/app/main.tsx b/e2e/app/main.tsx index e1b6a41..f9eebb0 100644 --- a/e2e/app/main.tsx +++ b/e2e/app/main.tsx @@ -4,10 +4,11 @@ * comments: &me=Ann&meId=u1 (author), &people=1 (mentions: Ann, Bob Stone, Cid), &comments=0 (hidden), &cmOpen=1 * &icons=1: every icon from a host resolver, as `` * texts: &locale=de, &fileLabel=… (a partial `strings` override of the File menu's label) + * &components=1: the host's own Button, Separator, Dialog and Toast (`data-host-*` markers) */ import { useState } from 'react' import { createRoot } from 'react-dom/client' -import { DocViewer, type DocViewerProps, type FileMenuItem } from '../../src' +import { DocViewer, type DocViewerProps, type FileMenuItem, type UIComponents } from '../../src' type Ev = { type: string; [k: string]: unknown } declare global { @@ -34,6 +35,49 @@ const items: FileMenuItem[] = [ { label: 'Version history', hint: 'beta', onClick: () => log({ type: 'item', label: 'Version history' }) }, ] +// plain host components: no engine classes, so nothing of the engine's CSS styles them +const components: Partial = { + Button: (p) => ( + + ), + Separator: () => , + Dialog: (p) => { + const actions = p.actions.map((a, i) => ( + + )) + const body = ( + <> +

{p.title}

+ {p.children} + {actions} + + ) + return ( +
e.key === 'Escape' && p.onClose()}> + {p.onSubmit ? ( +
{ + e.preventDefault() + p.onSubmit!() + }} + > + {body} +
+ ) : ( + body + )} +
+ ) + }, + Toast: ({ text }) => {text}, +} + const initial: DocViewerProps = { src: q.get('src'), name: q.get('name') ?? undefined, @@ -67,6 +111,7 @@ const initial: DocViewerProps = { open: flag('cmOpen'), onEvent: (e) => log({ type: 'comment', action: e.type, id: e.comment.id, text: e.comment.text, thread: e.thread.id, quote: e.quote, mentioned: e.mentioned.map((m) => m.id ?? m.name), participants: e.participants.map((m) => m.id ?? m.name) }), }, + components: q.has('components') ? components : undefined, icons: q.has('icons') ? (name) => ( diff --git a/e2e/components.spec.ts b/e2e/components.spec.ts new file mode 100644 index 0000000..e194eaf --- /dev/null +++ b/e2e/components.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'bun:test' +import { docxParas, paraText, runsOf, useBrowser, viewer } from './harness' + +useBrowser() + +const open = async (viewport?: { width: number; height: number }) => { + const v = await viewer({ src: 'samples/sample.docx', editable: true, onSave: true, components: true }, { viewport }) + await v.page.locator('.de-ebar').waitFor() + return v +} + +describe('host UI components', () => { + test('docx: every toolbar button is the host Button, and it keeps the selection for formatting', async () => { + const v = await open() + const counts = await v.page.evaluate(() => { + const bar = document.querySelector('.de-ebar')! + return { + host: bar.querySelectorAll('button[data-host-button]').length, + engine: bar.querySelectorAll('button.de-tb').length, + seps: bar.querySelectorAll('[data-host-sep]').length, + engineSeps: bar.querySelectorAll('.de-tb-sep').length, + } + }) + expect(counts.host).toBeGreaterThan(10) + expect(counts.engine).toBe(0) + expect(counts.seps).toBeGreaterThan(0) + expect(counts.engineSeps).toBe(0) + + await v.selectText('document') + await v.btn('Bold').click() + await v.page.waitForFunction(() => document.querySelector('button[data-host-button][aria-label^="Bold"]')?.hasAttribute('data-active')) + const p = (await docxParas((await v.save()).bytes)).find((x) => paraText(x).startsWith('This document'))! + expect(runsOf(p).find((r) => r.text.includes('document'))!.s.b).toBe(true) + expect(v.errors).toEqual([]) + }) + + test('docx: a narrow toolbar still moves what does not fit under the host More button', async () => { + const v = await open({ width: 640, height: 900 }) + const more = v.page.locator('.de-ebar button[data-host-button][aria-label="More tools"]') + await more.waitFor() + await more.click() + expect(await v.page.locator('.de-ebar-more button[data-host-button]').count()).toBeGreaterThan(0) + expect(v.errors).toEqual([]) + }) + + test('docx: the link prompt is the host Dialog and submits', async () => { + const v = await open() + await v.selectText('document') + await v.page.keyboard.press('Control+k') + const dialog = v.page.locator('[data-host-dialog][role="dialog"]') + await dialog.waitFor() + expect(await v.page.locator('.de-dialog').count()).toBe(0) + await dialog.locator('input').fill('https://example.org/docs') + await v.page.keyboard.press('Enter') + await dialog.waitFor({ state: 'detached' }) + const p = (await docxParas((await v.save()).bytes)).find((x) => paraText(x).startsWith('This document'))! + expect(runsOf(p).find((r) => r.text.includes('document'))!.href).toBe('https://example.org/docs') + expect(v.errors).toEqual([]) + }) +}) diff --git a/src/index.ts b/src/index.ts index e90e0e8..7b7435d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,8 @@ export { useDocument, type DocumentState } from './react/useDocument' export { injectStyles, stylesText } from './react/injectStyles' export type { Zoom } from './react/common/ZoomControl' export { THEME_TOKENS, createTheme, themeStyle, useThemeRoot, type Theme, type ThemeMode, type ThemeOptions, type ThemeToken, type ThemeTokens } from './react/theme' -export { IconsProvider, ICON_NAMES, type IconName, type Icons } from './react/edit/Toolbar' +export { IconsProvider, ICON_NAMES, type DropOption, type IconName, type Icons } from './react/edit/Toolbar' +export { OfficeUIProvider, UIComponentsProvider, type ButtonProps, type DialogAction, type DialogProps, type SelectProps, type ToastProps, type UIComponents } from './react/ui' export { StringsProvider, useStrings, resolveStrings, LOCALES, en, de, type Strings, type StringsOverride, type Locale } from './react/strings' export { DocumentClient, setDefaultWorker, type OpenOptions } from './core/worker/client' diff --git a/src/react/DocViewer.tsx b/src/react/DocViewer.tsx index 3ec9030..2e8f0bf 100644 --- a/src/react/DocViewer.tsx +++ b/src/react/DocViewer.tsx @@ -7,6 +7,7 @@ import { ViewerContext, type FileOps, type ViewerApi } from './common/Slots' import { StatusContext } from './common/Status' import { ConfirmDialog, PromptDialog, Toast } from './common/Dialog' import { download, Icon, IconsProvider, type Icons } from './edit/Toolbar' +import { UIComponentsProvider, type UIComponents } from './ui' import { DetailsDialog } from './file/DetailsDialog' import { FileMenu, useFileActions, type FileMenuItem } from './file/FileMenu' import { importFile, type Imported } from './file/importFile' @@ -111,6 +112,11 @@ export interface DocViewerProps { * Each is rendered in `span.de-icon`, sized by font-size (1em). */ icons?: Icons + /** + * The host's own UI components (`Button`, `Select`, `Separator`, `Dialog`, `Toast`), rendered in the toolbars and + * dialogs instead of the built-in ones; each one left out keeps the built-in one. See docs/theming.md. + */ + components?: Partial /** Language of the viewer's texts (bars, menus, dialogs, messages): 'en' or 'de'. Default 'en'. */ locale?: Locale /** Texts replacing the locale's, key by key (see `Strings`): a group may be given in part, e.g. `{ file: { menu: 'Datei' } }`. */ @@ -534,6 +540,7 @@ function Viewer(props: DocViewerProps) { return ( +
{toolbar && (
@@ -689,6 +696,7 @@ function Viewer(props: DocViewerProps) { /> )}
+ ) } diff --git a/src/react/common/Dialog.tsx b/src/react/common/Dialog.tsx index 6489f77..c2b8785 100644 --- a/src/react/common/Dialog.tsx +++ b/src/react/common/Dialog.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState, type ReactNode } from 'react' import { useStrings } from '../strings' +import { DefaultDialog, DefaultToast, useHostUI, type DialogAction } from '../ui' export interface PromptSpec { title: string @@ -18,6 +19,7 @@ export interface PromptSpec { /** Small modal prompt (no window.prompt: works in sandboxed / embedded pages). */ export function PromptDialog({ spec, onClose }: { spec: PromptSpec; onClose: () => void }) { const t = useStrings() + const Dialog = useHostUI().Dialog ?? DefaultDialog const [v, setV] = useState(spec.initial ?? '') const ref = useRef(null) useEffect(() => { @@ -29,108 +31,63 @@ export function PromptDialog({ spec, onClose }: { spec: PromptSpec; onClose: () onClose() spec.onOk(v) } + const actions: DialogAction[] = [ + ...(spec.extra + ? [ + { + label: spec.extra.label, + onClick: () => { + onClose() + spec.extra!.run() + }, + }, + ] + : []), + { label: t.common.cancel, onClick: onClose }, + { label: spec.ok ?? t.common.ok, primary: true, submit: true }, + ] return ( -
e.target === e.currentTarget && onClose()} - onKeyDown={(e) => { - if (e.key === 'Escape') { - e.stopPropagation() - onClose() - } - }} - > -
{ - e.preventDefault() - done() - }} - > -
{spec.title}
- {spec.label && } - {spec.area ? ( -