diff --git a/README.md b/README.md index 7bac394..c11adba 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,8 @@ 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 +- **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"`; 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"`), `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/docs/features.md b/docs/features.md index a8cbb6e..215373e 100644 --- a/docs/features.md +++ b/docs/features.md @@ -170,11 +170,11 @@ 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 -`theme="dark"`, or `"auto"` to follow the OS / browser setting (`prefers-color-scheme`), darkens the chrome: bars, toolbar, menus, dialogs, sheet headers and tabs, thumbnails, popovers, scrollbars. Pages, slides and cells keep the colors the document gives them, like paper in a dark PDF viewer. Everything is driven by CSS variables on `.de-root`, so a host can also tune the palette. +`theme="dark"`, or `"auto"` to follow the OS / browser setting (`prefers-color-scheme`), darkens the chrome: bars, toolbar, menus, dialogs, sheet headers and tabs, thumbnails, popovers, scrollbars. Pages, slides and cells keep the colors the document gives them, like paper in a dark PDF viewer. Everything is driven by design tokens (CSS variables on `.de-root`), which a host sets through a theme object or its own CSS: see [Theming](theming.md). ## Comments diff --git a/docs/theming.md b/docs/theming.md new file mode 100644 index 0000000..648b0ed --- /dev/null +++ b/docs/theming.md @@ -0,0 +1,90 @@ +# Theming + +The viewer's chrome (bars, toolbars, menus, dialogs, panels, sheet headers, tabs) takes every color, font, radius and +shadow from design tokens: CSS custom properties named `--de-*` on the viewer root (`.de-root`). The document itself +(pages, slides, cells) keeps its own colors in every theme. + +## The `theme` prop + +```tsx + // light, dark, or following the OS + + +``` + +`tokens` apply in both modes, `light` and `dark` go over them in their mode, and anything left out keeps the built-in +value of that mode. Names are camelCase (`accentHover` sets `--de-accent-hover`); values are any CSS value, so a token can +point at the host's own variables: `{ accent: 'var(--brand-500)' }`. `createTheme()` types a theme object written apart +from the prop, and `THEME_TOKENS` lists every token. + +`mount()` takes the same `theme` option; `` takes a mode. + +## Plain CSS + +The tokens are ordinary custom properties, so a stylesheet can set them as well: + +```css +.my-app .de-root { --de-accent: #7c3aed; --de-font: Inter, sans-serif; } +.my-app .de-root.de-dark { --de-accent: #a78bfa; } +``` + +Class names starting with `de-` are stable and can be styled further; the tokens are the supported way to change +colors, type and shape. + +## Loading the stylesheet yourself + +By default the viewer injects its stylesheet once into the document (or the shadow root in `styleTarget`). To load it +yourself, for a Content Security Policy without inline styles, a bundler that extracts CSS, or a cascade layer of your own, +turn injection off and import the file: + +```tsx +import '@p10node/office-engine/styles.css' + +``` + +```css +/* the viewer's rules below the host's own, whatever their specificity */ +@layer office-engine, app; +@import '@p10node/office-engine/styles.css' layer(office-engine); +``` + +The stylesheet is not put in a layer of its own: rules in a layer lose to every unlayered rule, so a host's element +reset (`button { … }`) would restyle the viewer's controls. Importing it into a layer is the host's choice. + +## Your own root + +The views (`DocxView`, `XlsxView`, `PptxView`) can be mounted without `DocViewer` (see [API](api.md)); `useThemeRoot()` +gives the class name and style their root needs: + +```tsx +const root = useThemeRoot(theme) +return
+``` + +## Tokens + +| Group | Tokens | +|---|---| +| Surfaces and text | `bg` (around the document), `chrome` (bars), `pop` (menus, dialogs), `ebar` (formatting toolbar), `text`, `muted`, `ink`, `inkStrong`, `inkSoft`, `border`, `sep`, `tbSep` | +| Accent and states | `accent`, `accentHover`, `accentInk` (text on the accent), `accentSoft`, `on`, `onInk`, `onInkStrong` (pressed buttons), `hover`, `hoverSolid`, `hoverSoft`, `disabled`, `danger`, `dangerBorder`, `ok` | +| Inputs and tabs | `inputBg`, `inputBorder`, `swatchBorder`, `tabHover`, `tabOn`, `tabInk` | +| Overlays | `popShadow`, `shadow`, `shadowSm`, `shadowMd`, `shadowLg`, `backdrop`, `toastBg`, `toastInk`, `tipBg`, `tipInk` | +| Type and shape | `font`, `fontMono`, `fontSize`, `radiusSm`, `radius`, `radiusLg`, `radiusXl` | +| Sheets | `grid`, `head`, `headInk`, `headBorder`, `cornerBorder`, `freeze` | +| Comments | `cmBg`, `cmOn`, `cmLine`, `cmLineOn`, `cmCard`, `cmQuote` | +| Find | `find`, `findOn` (text matches), `findCell`, `findCellOn`, `findCellLine` (cell matches) | +| Files, people, connection | `kindDocx`, `kindXlsx`, `kindPptx`, `kindInk` (badges), `avatarInk` (text on a person's color), `statusIdle`, `statusLive`, `statusBusy`, `statusError` | +| Slideshow | `presentBg`, `presentInk` | +| Paper | `page`, `pageInk`, `sheetInk`, `skeleton` (loading pages), `phBorder`, `phA`, `phB`, `phInk` (placeholders), `phOutline`, `phHint` (empty text boxes) | +| Source editor | `srcBg`, `srcGutter`, `srcSel`, `srcFont`, `srcComment`, `srcKeyword`, `srcString`, `srcOperator`, `srcNumber`, `srcHeading`, `srcAttr`, `srcTag`, `srcMeta` | + +The paper tokens are the same in both modes: a document is drawn on white paper with black ink unless it says +otherwise. Built-in values: `src/react/styles.css`, the `.de-root` and `.de-root.de-dark` blocks. diff --git a/e2e/app/main.tsx b/e2e/app/main.tsx index bb6a98a..e1b6a41 100644 --- a/e2e/app/main.tsx +++ b/e2e/app/main.tsx @@ -2,6 +2,8 @@ * 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 `` + * texts: &locale=de, &fileLabel=… (a partial `strings` override of the File menu's label) */ import { useState } from 'react' import { createRoot } from 'react-dom/client' @@ -46,6 +48,9 @@ const initial: DocViewerProps = { thumbnails: flag('thumbnails'), 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 }), @@ -62,6 +67,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..2b50a9c --- /dev/null +++ b/e2e/icons.spec.ts @@ -0,0 +1,73 @@ +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) + + // 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(5) + 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/e2e/viewer.spec.ts b/e2e/viewer.spec.ts index 97f9c24..96e0c65 100644 --- a/e2e/viewer.spec.ts +++ b/e2e/viewer.spec.ts @@ -440,6 +440,35 @@ describe('viewing', () => { expect(v.errors).toEqual([]) }) + test('theme object: tokens for both modes and per mode, pages keep paper colors', async () => { + const v = await viewer({ src: 'samples/sample.docx' }) + const { page } = v + const bg = (sel: string) => page.locator(sel).first().evaluate((el) => getComputedStyle(el).backgroundColor) + const theme = { tokens: { chrome: 'rgb(1, 2, 3)', font: 'serif' }, light: { bg: 'rgb(10, 20, 30)' }, dark: { bg: 'rgb(40, 50, 60)', chrome: 'rgb(4, 5, 6)' } } + await page.evaluate((t) => window.__setProps({ theme: t }), theme) + await page.waitForFunction(() => getComputedStyle(document.querySelector('.de-bar')!).backgroundColor === 'rgb(1, 2, 3)') + expect(await bg('.de-root')).toBe('rgb(10, 20, 30)') + expect(await page.locator('.de-bar').evaluate((el) => getComputedStyle(el).fontFamily)).toBe('serif') + expect(await bg('.de-page')).toBe('rgb(255, 255, 255)') + + await page.evaluate((t) => window.__setProps({ theme: { ...t, mode: 'dark' } }), theme) + await page.waitForFunction(() => document.querySelector('.de-root')!.classList.contains('de-dark')) + expect(await bg('.de-bar')).toBe('rgb(4, 5, 6)') + expect(await bg('.de-root')).toBe('rgb(40, 50, 60)') + // a token the theme leaves alone keeps the dark default + await v.menu() + expect(await bg('.de-dd-pop')).toBe('rgb(46, 49, 54)') + await page.keyboard.press('Escape') + expect(await bg('.de-page')).toBe('rgb(255, 255, 255)') + expect(v.errors).toEqual([]) + }) + + test('injectStyles=false: no stylesheet is added', async () => { + const v = await viewer({ src: 'samples/sample.docx', injectStyles: false }, { wait: false }) + await v.page.waitForSelector('.de-root') + expect(await v.page.locator('style#office-engine-styles').count()).toBe(0) + }) + test('links: a script or data link is shown as text, not followed', async () => { // the builder refuses script links: write safe placeholders, then put the bad targets in the relationships const docx = await parasToDocx([ diff --git a/package.json b/package.json index 3519541..bd00820 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "./collab": { "types": "./dist/types/collab/index.d.ts", "import": "./dist/collab.js" - } + }, + "./styles.css": "./dist/styles.css" }, "sideEffects": [ "./src/core/default-worker.ts", @@ -41,7 +42,8 @@ ], "scripts": { "dev": "vite --config vite.playground.config.ts", - "build": "bun run clean && bun run build:lib && bun run build:embed && bun run build:worker && bun run build:types", + "build": "bun run clean && bun run build:lib && bun run build:embed && bun run build:worker && bun run build:types && bun run build:css", + "build:css": "cp src/react/styles.css dist/styles.css", "build:lib": "vite build", "build:embed": "vite build --config vite.embed.config.ts", "build:types": "tsc -p tsconfig.build.json", diff --git a/playground/main.tsx b/playground/main.tsx index 7142edc..759427e 100644 --- a/playground/main.tsx +++ b/playground/main.tsx @@ -1,6 +1,6 @@ import { StrictMode, useCallback, useEffect, useMemo, useState } from 'react' import { createRoot } from 'react-dom/client' -import { DocViewer, type DocSource, type Theme } from '../src' +import { DocViewer, type DocSource, type ThemeMode as Theme } from '../src' import type { CollabOptions } from '../src/collab' import { OPEN_ACCEPT } from '../src/react/file/importFile' diff --git a/src/embed.ts b/src/embed.ts index 4b41b0c..afab05b 100644 --- a/src/embed.ts +++ b/src/embed.ts @@ -2,10 +2,13 @@ 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' +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 @@ -35,14 +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). - * Property: `file` (File/Blob/ArrayBuffer) for local data. + * 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 @@ -52,6 +58,21 @@ export class OfficeViewerElement extends HTMLElement { this.sync() } + get icons() { + return this._icons + } + set icons(v: Icons | undefined) { + 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 const shadow = this.shadowRoot ?? this.attachShadow({ mode: 'open' }) @@ -85,6 +106,9 @@ 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, + 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 f1fb2e3..e90e0e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,9 @@ export { SlideView } from './react/pptx/Slide' export { useDocument, type DocumentState } from './react/useDocument' export { injectStyles, stylesText } from './react/injectStyles' export type { Zoom } from './react/common/ZoomControl' -export type { Theme } from './react/common/hooks' +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 26063bf..3ec9030 100644 --- a/src/react/DocViewer.tsx +++ b/src/react/DocViewer.tsx @@ -6,16 +6,16 @@ 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 { 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, useDarkTheme, useSize, type Theme } from './common/hooks' +import { isTextEntry, useSize } from './common/hooks' +import { useThemeRoot, type Theme } from './theme' import { SourceEditor } from './source/SourceEditor' import { PictureView } from './picture/PictureView' import { PptxView } from './pptx/PptxView' @@ -25,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 { /** @@ -52,8 +53,14 @@ export interface DocViewerProps { /** Where to inject the stylesheet (document or a shadow root). */ styleTarget?: Document | ShadowRoot /** - * Color scheme of the viewer chrome (bars, menus, dialogs): 'light', 'dark' or 'auto' (follows the OS / browser setting). - * The document itself keeps its own colors. Default 'light'. + * Inject the stylesheet. Default true. Set false when the page loads `@p10node/office-engine/styles.css` itself + * (a CSP without inline styles, a cascade layer of its own, a bundler that extracts CSS). + */ + injectStyles?: boolean + /** + * Color scheme of the viewer chrome (bars, menus, dialogs): 'light', 'dark' or 'auto' (follows the OS / browser setting), + * or a theme object with that mode and design token overrides (see docs/theming.md). The document itself keeps its own + * colors. Default 'light'. */ theme?: Theme onLoad?: (client: DocumentClient) => void @@ -98,38 +105,26 @@ 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 + /** 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() @@ -140,15 +135,19 @@ 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 dark = useDarkTheme(theme) + const root = useThemeRoot(theme, style) const { allowEdit = true, allowSave = true, allowDownload = true, fileMenu = true, allowOpen = true, captureSelectAll = true, onSave, onDirtyChange } = props const [editing, setEditing] = useState(!!props.editable) useEffect(() => { @@ -157,7 +156,10 @@ export function DocViewer(props: DocViewerProps) { const [dirty, setDirtyState] = useState(false) const [saving, setSaving] = useState(false) const [barEl, setBarEl] = useState(null) - useInsertionEffect(() => injectStyles(styleTarget), [styleTarget]) + const inject = props.injectStyles ?? true + useInsertionEffect(() => { + if (inject) injectStyles(styleTarget) + }, [styleTarget, inject]) // File → New / Open: a local file shown instead of `src` until the host passes another `src` const [opened, setOpened] = useState(null) const [renamed, setRenamed] = useState(null) @@ -449,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, @@ -466,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), } @@ -511,7 +513,7 @@ export function DocViewer(props: DocViewerProps) { body = (
- Cannot open document. + {t.bar.cannotOpen}
{error.message}
@@ -520,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 = @@ -531,7 +533,8 @@ export function DocViewer(props: DocViewerProps) { else body = return ( -
+ +
{toolbar && (
{client && {client.info?.from ?? client.info?.converted ?? (client.info?.imported ? IMPORTED_BADGE[client.info.imported] : client.kind)}} @@ -549,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 && ( @@ -561,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) && ( - )}
@@ -601,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} @@ -620,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} @@ -645,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 @@ -667,24 +670,25 @@ 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/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/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/common/hooks.ts b/src/react/common/hooks.ts index e28af92..5b8bfae 100644 --- a/src/react/common/hooks.ts +++ b/src/react/common/hooks.ts @@ -5,13 +5,13 @@ import { isWebImage } from '../../core/opc' export const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect /** Color scheme of the viewer chrome: 'auto' follows the browser / OS setting (prefers-color-scheme). */ -export type Theme = 'light' | 'dark' | 'auto' +export type ThemeMode = 'light' | 'dark' | 'auto' const DARK_MQ = '(prefers-color-scheme: dark)' const prefersDark = () => typeof matchMedia === 'function' && matchMedia(DARK_MQ).matches /** Whether the dark theme applies for `theme`; 'auto' tracks prefers-color-scheme live. */ -export function useDarkTheme(theme: Theme = 'light'): boolean { +export function useDarkTheme(theme: ThemeMode = 'light'): boolean { const [dark, setDark] = useState(() => theme === 'auto' && prefersDark()) useEffect(() => { if (theme !== 'auto' || typeof matchMedia !== 'function') return 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..bd17e3e 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, @@ -13,10 +16,11 @@ import { type RefObject, } from 'react' import { cssColor } from '../../core/color' +import { useStrings } from '../strings' const BORDER_GRID = -const P: Record = { +const P = { undo: , redo: , bold: , @@ -145,9 +149,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; `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] + 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 +240,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 ( - - {open && ( @@ -503,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) @@ -566,7 +617,7 @@ export function ToolbarRow({ children }: { children: ReactNode }) { ))} {rest.length > 0 && ( - {more && ( @@ -628,7 +679,7 @@ const subscribeCustom = (f: () => void) => { } } -export function ColorBtn(props: { icon: string; title: string; value?: string; onPick: (c: string | null) => void; palette?: string[]; noneLabel?: string }) { +export function ColorBtn(props: { icon: IconName; title: string; value?: string; onPick: (c: string | null) => void; palette?: string[]; noneLabel?: string }) { const [open, setOpen] = useState(false) const ref = useRef(null) const pickerRef = useRef(null) @@ -637,6 +688,7 @@ export function ColorBtn(props: { icon: string; title: string; value?: string; o 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(() => { @@ -677,13 +729,13 @@ export function ColorBtn(props: { icon: string; title: string; value?: string; o {open && (
{(props.palette ?? PALETTE).map(swatch)}
-
Custom
+
{t.toolbar.custom}
{custom.map(swatch)} -
@@ -721,12 +773,12 @@ export function download(blob: Blob, name: string) { } /** Command menu: fixed label, items with optional icons. */ -export function Menu(props: { title: string; label: string; icon?: string; items: { value: string; label: string; icon?: string; disabled?: boolean; sep?: boolean; hint?: string }[]; onPick: (v: string) => void; width?: number }) { +export function Menu(props: { title: string; label: string; icon?: IconName; items: { value: string; label: string; icon?: IconName; disabled?: boolean; sep?: boolean; hint?: string }[]; onPick: (v: string) => void; width?: number }) { return } /** Small popover anchored under a toolbar button. */ -export function Pop(props: { icon: string; title: string; children: (close: () => void) => ReactNode; active?: boolean; disabled?: boolean; chevron?: boolean }) { +export function Pop(props: { icon: IconName; title: string; children: (close: () => void) => ReactNode; active?: boolean; disabled?: boolean; chevron?: boolean }) { const [open, setOpen] = useState(false) const ref = useRef(null) useDismiss(ref, open, () => setOpen(false)) @@ -757,6 +809,7 @@ export function Pop(props: { icon: string; 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])}> @@ -766,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/file/FileMenu.tsx b/src/react/file/FileMenu.tsx index 4199917..65631b7 100644 --- a/src/react/file/FileMenu.tsx +++ b/src/react/file/FileMenu.tsx @@ -2,7 +2,7 @@ import { useRef, useState } from 'react' import type { DocKind } from '../../core/opc' import { MOD } from '../common/ContextMenu' import type { FileOps } from '../common/Slots' -import { download, Dropdown, pickFiles, type DropOption } from '../edit/Toolbar' +import { download, Dropdown, pickFiles, type DropOption, type IconName } from '../edit/Toolbar' import { OPEN_ACCEPT } from './importFile' import { exportHtml, printDocument } from './print' @@ -29,7 +29,7 @@ const TEXT_FORMATS: Record ({ value: 'text:' + f.id, label: f.label, icon: 'doc', hint: f.ext }))]), + ? [{ value: 'picture', label: o.picture.label, icon: 'image', hint: o.picture.ext } as const] + : [{ value: 'html', label: 'Web page', icon: 'html', hint: '.html' } as const, ...TEXT_FORMATS[kind].map((f) => ({ value: 'text:' + f.id, label: f.label, icon: 'doc', hint: f.ext }) as const)]), ]) } const tail: DropOption[] = [] 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/pptx/PptxToolbar.tsx b/src/react/pptx/PptxToolbar.tsx index d1c8a18..74a02b7 100644 --- a/src/react/pptx/PptxToolbar.tsx +++ b/src/react/pptx/PptxToolbar.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' import type { ShapeKind } from '../../core/pptx/types' import { MOD } from '../common/ContextMenu' import { EditorBar } from '../common/Slots' -import { Btn, ColorBtn, Dropdown, fontName, fontOptions, Menu, Select, Sep, SizeBox, type DropOption } from '../edit/Toolbar' +import { Btn, ColorBtn, Dropdown, fontName, fontOptions, Menu, Select, Sep, SizeBox, type DropOption, type IconName } from '../edit/Toolbar' import type { PptxEditor, PptxFormat } from './PptxEditor' const HIGHLIGHTS = ['#ffff00', '#00ff00', '#00ffff', '#ff00ff', '#ff0000', '#0000ff', '#ffc000', '#92d050', '#c0c0c0', '#000000'] @@ -98,7 +98,7 @@ export function PptxToolbar(props: { (text ? editor.undo() : props.onUndo(false))} /> (text ? editor.redo() : props.onUndo(true))} /> - props.onInsert(v)} /> + props.onInsert(v)} /> + if (!model) return
if ('error' in model) { return (
@@ -788,7 +788,8 @@ export function PptxView({ client, onStatus, initialZoom = 'fit', thumbnails = t {!viewer.editable && ( )} diff --git a/src/react/pptx/Slide.tsx b/src/react/pptx/Slide.tsx index 233e76e..acd39ce 100644 --- a/src/react/pptx/Slide.tsx +++ b/src/react/pptx/Slide.tsx @@ -245,7 +245,7 @@ function PicView({ el, client, live }: { el: Extract; cli {el.target?.split('.').pop()?.toUpperCase()} image
) : null} - {el.video &&
▶ media
} + {el.video &&
▶ media
}
) } @@ -318,7 +318,7 @@ export const SlideView = memo(function SlideView({ model, client, scale, live = const bg = useFillCss(client, model.bg, live) return (
-
+
{model.els.map((el, i) => ( ))} 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/src/react/styles.css b/src/react/styles.css index 0a07b1d..511a60c 100644 --- a/src/react/styles.css +++ b/src/react/styles.css @@ -48,6 +48,66 @@ --de-cm-line: #f29900; --de-cm-card: #ffffff; --de-cm-quote: #f29900; + --de-cm-line-on: #c26401; + /* type and shape */ + --de-font: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --de-font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --de-font-size: 13px; + --de-radius-sm: 4px; + --de-radius: 6px; + --de-radius-lg: 8px; + --de-radius-xl: 12px; + --de-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.15); + --de-shadow-md: 0 2px 8px rgba(0, 0, 0, 0.3); + --de-shadow-lg: 0 8px 28px rgba(0, 0, 0, 0.28); + /* file kind badges */ + --de-kind-docx: #2b579a; + --de-kind-xlsx: #217346; + --de-kind-pptx: #c43e1c; + --de-kind-ink: #fff; + /* white text on a person's color (avatars, cursors, comment pins) */ + --de-avatar-ink: #fff; + /* find */ + --de-find: #fde293; + --de-find-on: #ff9632; + --de-find-cell: rgba(251, 188, 4, 0.28); + --de-find-cell-on: rgba(255, 150, 50, 0.45); + --de-find-cell-line: #e37400; + /* tooltips over the document (picture size), the slideshow */ + --de-tip-bg: rgba(32, 33, 36, 0.85); + --de-tip-ink: #fff; + --de-present-bg: #000; + --de-present-ink: rgba(255, 255, 255, 0.5); + /* connection state (collaboration) */ + --de-status-idle: #9aa0a6; + --de-status-live: #1e8e3e; + --de-status-busy: #f9ab00; + --de-status-error: #d93025; + /* paper: pages, slides and sheets keep these in the dark theme too (documents keep their own colors) */ + --de-page: #fff; + --de-page-ink: #000; + --de-sheet-ink: #1d2127; + --de-skeleton: #eef0f2; + --de-ph-border: #b8bec6; + --de-ph-a: #f6f7f9; + --de-ph-b: #eff1f4; + --de-ph-ink: #6a737d; + --de-ph-outline: rgba(60, 64, 67, 0.4); + --de-ph-hint: #80868b; + /* source editor (Markdown, HTML, diagrams) */ + --de-src-bg: #ffffff; + --de-src-gutter: #f6f7f8; + --de-src-sel: color-mix(in srgb, var(--de-accent) 25%, transparent); + --de-src-font: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + --de-src-comment: #6a737d; + --de-src-keyword: #8e3bc7; + --de-src-string: #1a7f37; + --de-src-operator: #c2410c; + --de-src-number: #0b7a8a; + --de-src-heading: #0b57d0; + --de-src-attr: #0b57d0; + --de-src-tag: #b42318; + --de-src-meta: #8a5a00; color-scheme: light; position: relative; display: flex; @@ -58,7 +118,7 @@ overflow: hidden; background: var(--de-bg); color: var(--de-text); - font: 13px/1.4 system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font: var(--de-font-size)/1.4 var(--de-font); text-align: left; isolation: isolate; } @@ -116,6 +176,18 @@ --de-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 2px 10px rgba(0, 0, 0, 0.45); --de-cm-card: #2b2e33; --de-cm-quote: #fdd663; + --de-src-bg: #1b1d20; + --de-src-gutter: #202327; + --de-src-sel: color-mix(in srgb, var(--de-accent) 35%, transparent); + --de-src-comment: #8b949e; + --de-src-keyword: #d2a8ff; + --de-src-string: #7ee787; + --de-src-operator: #ffa657; + --de-src-number: #79c0ff; + --de-src-heading: #8ab4f8; + --de-src-attr: #8ab4f8; + --de-src-tag: #ff7b72; + --de-src-meta: #e3b341; color-scheme: dark; } @@ -135,16 +207,16 @@ } .de-badge { flex: none; - font: 600 10px/1 system-ui, sans-serif; + font: 600 10px/1 var(--de-font); letter-spacing: 0.06em; text-transform: uppercase; padding: 4px 6px; - border-radius: 4px; - color: #fff; + border-radius: var(--de-radius-sm); + color: var(--de-kind-ink); } -.de-badge-docx { background: #2b579a; } -.de-badge-xlsx { background: #217346; } -.de-badge-pptx { background: #c43e1c; } +.de-badge-docx { background: var(--de-kind-docx); } +.de-badge-xlsx { background: var(--de-kind-xlsx); } +.de-badge-pptx { background: var(--de-kind-pptx); } .de-title { flex: 1; min-width: 0; @@ -206,7 +278,7 @@ color: var(--de-danger); background: var(--de-pop); border: 1px solid var(--de-danger-border); - border-radius: 8px; + border-radius: var(--de-radius-lg); padding: 14px 18px; box-shadow: var(--de-shadow); } @@ -219,7 +291,7 @@ align-items: center; background: var(--de-chrome); border: 1px solid var(--de-border); - border-radius: 8px; + border-radius: var(--de-radius-lg); box-shadow: var(--de-shadow); overflow: hidden; } @@ -234,6 +306,9 @@ cursor: pointer; } .de-zoom button { + display: inline-flex; + align-items: center; + justify-content: center; width: 32px; height: 30px; font-size: 16px; @@ -250,14 +325,14 @@ } .de-btn { padding: 4px 8px; - border-radius: 6px; + border-radius: var(--de-radius); } /* ---------------------------------------------------------------- docx */ .de-pages { position: relative; } .de-page { position: absolute; - background: #fff; + background: var(--de-page, #fff); box-shadow: var(--de-shadow); overflow: hidden; } @@ -267,7 +342,7 @@ } .de-page-skeleton { position: absolute; - background: #fff; + background: var(--de-page); box-shadow: var(--de-shadow); opacity: 0.6; } @@ -275,12 +350,12 @@ content: ''; position: absolute; inset: 12% 12%; - background: repeating-linear-gradient(#eef0f2 0 10px, transparent 10px 24px); + background: repeating-linear-gradient(var(--de-skeleton) 0 10px, transparent 10px 24px); animation: de-pulse 1.4s ease-in-out infinite; } @keyframes de-pulse { 50% { opacity: 0.45; } } .de-doc { - color: #000; + color: var(--de-page-ink, #000); font-kerning: normal; text-rendering: optimizeLegibility; } @@ -316,8 +391,8 @@ .de-tbl-wrap { position: relative; } .de-tbl-wrap > .de-tbl { margin: 0 !important; } .de-col-resize { position: absolute; top: 0; bottom: 0; width: 6px; cursor: col-resize; z-index: 2; touch-action: none; } -.de-col-resize:hover { background: rgba(26, 115, 232, 0.35); } -.de-tbl td.de-cell-sel { box-shadow: inset 0 0 0 9999px rgba(26, 115, 232, 0.16); } +.de-col-resize:hover { background: color-mix(in srgb, var(--de-accent) 35%, transparent); } +.de-tbl td.de-cell-sel { box-shadow: inset 0 0 0 9999px color-mix(in srgb, var(--de-accent) 16%, transparent); } .de-tbl td { vertical-align: top; overflow-wrap: break-word; @@ -329,10 +404,10 @@ display: inline-flex; align-items: center; justify-content: center; - border: 1px dashed #b8bec6; - background: repeating-linear-gradient(45deg, #f6f7f9 0 8px, #eff1f4 8px 16px); - color: #6a737d; - font: 12px/1.2 system-ui, sans-serif; + border: 1px dashed var(--de-ph-border, #b8bec6); + background: repeating-linear-gradient(45deg, var(--de-ph-a, #f6f7f9) 0 8px, var(--de-ph-b, #eff1f4) 8px 16px); + color: var(--de-ph-ink, #6a737d); + font: 12px/1.2 var(--de-font, sans-serif); text-indent: 0; vertical-align: bottom; overflow: hidden; @@ -359,7 +434,7 @@ flex: none; background: var(--de-chrome); border-bottom: 1px solid var(--de-border); - font: 12px/30px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font: 12px/30px var(--de-font-mono); } .de-fx-ref { width: 88px; @@ -382,8 +457,8 @@ min-height: 0; overflow: auto; /* the sheet keeps its own colors in the dark theme */ - background: #fff; - color: #1d2127; + background: var(--de-page); + color: var(--de-sheet-ink); outline: none; } .de-grid-view { @@ -423,7 +498,7 @@ justify-content: center; background: var(--de-head); color: var(--de-head-ink); - font: 11px/1 system-ui, sans-serif; + font: 11px/1 var(--de-font); font-variant-numeric: tabular-nums; border-right: 1px solid var(--de-head-border); border-bottom: 1px solid var(--de-head-border); @@ -509,7 +584,7 @@ .de-slide { position: relative; overflow: hidden; - background: #fff; + background: var(--de-page, #fff); } .de-slide-frame { position: absolute; @@ -521,7 +596,7 @@ top: 0; transform-origin: 0 0; overflow: hidden; - color: #000; + color: var(--de-page-ink, #000); } .de-el { position: absolute; } .de-txt { @@ -536,11 +611,11 @@ } .de-txt.de-nowrap > div { white-space: pre; } /* empty placeholders while editing (new slides, blank presentations) */ -.de-slide-editable .de-el > .de-txt:empty { outline: 1.5px dashed rgba(60, 64, 67, 0.4); outline-offset: -1px; } +.de-slide-editable .de-el > .de-txt:empty { outline: 1.5px dashed var(--de-ph-outline); outline-offset: -1px; } .de-slide-editable .de-el > .de-txt:empty::after { content: 'Click to add text'; - color: #80868b; - font: 28px/1.2 system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif; + color: var(--de-ph-hint); + font: 28px/1.2 var(--de-font); text-align: center; pointer-events: none; } @@ -565,7 +640,7 @@ height: 28px; padding: 0 10px; border: 1px solid var(--de-border); - border-radius: 6px; + border-radius: var(--de-radius); background: var(--de-chrome); color: inherit; font: inherit; @@ -612,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; @@ -622,10 +709,10 @@ height: 28px; padding: 0; border: 0; - border-radius: 4px; + border-radius: var(--de-radius-sm); background: transparent; color: var(--de-ink); - font: 600 14px/1 system-ui, sans-serif; + font: 600 14px/1 var(--de-font); cursor: pointer; transition: background-color 0.1s; } @@ -643,7 +730,7 @@ bottom: 3px; height: 3px; border-radius: 1px; - box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12); + box-shadow: inset 0 0 0 1px var(--de-swatch-border); } .de-tb-pop { position: absolute; @@ -652,7 +739,7 @@ z-index: 30; padding: 10px; background: var(--de-pop); - border-radius: 8px; + border-radius: var(--de-radius-lg); box-shadow: var(--de-pop-shadow); } .de-tb-none { @@ -660,7 +747,7 @@ margin-bottom: 8px; padding: 5px; border: 1px solid var(--de-border); - border-radius: 4px; + border-radius: var(--de-radius-sm); background: var(--de-pop); color: inherit; font: inherit; @@ -677,16 +764,16 @@ height: 28px; padding: 0 2px 0 6px; border: 0; - border-radius: 4px; + border-radius: var(--de-radius-sm); background: transparent; color: var(--de-ink-strong); - font: 13px/1 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font: 13px/1 var(--de-font); cursor: pointer; transition: background-color 0.1s; } .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 { @@ -699,10 +786,10 @@ overflow-y: auto; padding: 6px 0; background: var(--de-pop); - border-radius: 8px; + border-radius: var(--de-radius-lg); box-shadow: var(--de-pop-shadow); color: var(--de-ink-strong); - font: 13px/1.3 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font: 13px/1.3 var(--de-font); } .de-dd-opt { display: flex; @@ -714,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); } @@ -740,10 +827,10 @@ height: 26px; padding: 0; border: 1px solid var(--de-input-border); - border-radius: 4px; + border-radius: var(--de-radius-sm); background: transparent; color: var(--de-ink-strong); - font: 13px/1 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font: 13px/1 var(--de-font); text-align: center; outline: none; } @@ -792,7 +879,7 @@ opacity: 0; pointer-events: none; } -.de-editing { outline: none; caret-color: #000; } +.de-editing { outline: none; caret-color: var(--de-page-ink); } .de-editing .de-page-inner { cursor: text; } .de-editing [contenteditable='false'] { cursor: default; } .de-find { @@ -806,7 +893,7 @@ padding: 6px; background: var(--de-pop); border: 1px solid var(--de-border); - border-radius: 8px; + border-radius: var(--de-radius-lg); box-shadow: var(--de-shadow); } .de-find-row { display: flex; align-items: center; gap: 2px; } @@ -815,7 +902,7 @@ height: 28px; padding: 0 8px; border: 1px solid var(--de-border); - border-radius: 6px; + border-radius: var(--de-radius); background: var(--de-input-bg); color: var(--de-text); font: inherit; @@ -823,13 +910,13 @@ } .de-find input:focus { border-color: var(--de-accent); } .de-find-count { min-width: 56px; text-align: center; color: var(--de-muted); font-size: 12px; font-variant-numeric: tabular-nums; } -::highlight(de-find) { background-color: #fde293; } -::highlight(de-find-cur) { background-color: #ff9632; } -::highlight(de-cm-draft) { background-color: rgba(251, 188, 4, 0.6); } +::highlight(de-find) { background-color: var(--de-find); } +::highlight(de-find-cur) { background-color: var(--de-find-on); } +::highlight(de-cm-draft) { background-color: var(--de-cm-on); } .de-range { position: absolute; - background: rgba(26, 115, 232, 0.1); - border: 1px solid rgba(26, 115, 232, 0.6); + background: color-mix(in srgb, var(--de-accent) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--de-accent) 60%, transparent); pointer-events: none; z-index: 2; } @@ -842,8 +929,8 @@ outline: none; resize: none; overflow: hidden; - background: #fff; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + background: var(--de-page); + box-shadow: var(--de-shadow-sm); line-height: 1.2; white-space: pre-wrap; } @@ -851,18 +938,18 @@ .de-fx-input:focus { background: var(--de-input-bg); box-shadow: inset 0 -2px 0 var(--de-accent); } .de-tb-hint { margin-left: 8px; color: var(--de-muted); font-size: 12px; } .de-slide-editable [data-sid] { cursor: text; } -.de-slide-editable [data-sid]:hover { outline: 1px dashed rgba(26, 115, 232, 0.6); } -.de-txt-edit { outline: 2px solid var(--de-accent); outline-offset: 0; caret-color: #000; cursor: text; z-index: 2; } +.de-slide-editable [data-sid]:hover { outline: 1px dashed color-mix(in srgb, var(--de-accent) 60%, transparent); } +.de-txt-edit { outline: 2px solid var(--de-accent); outline-offset: 0; caret-color: var(--de-page-ink); cursor: text; z-index: 2; } .de-gridpick { display: grid; grid-template-columns: repeat(8, 16px); gap: 3px; } .de-gridpick button { width: 16px; height: 16px; padding: 0; border: 1px solid var(--de-border); border-radius: 2px; background: var(--de-pop); cursor: pointer; } .de-gridpick button.on { background: var(--de-on); border-color: var(--de-accent); } .de-gridpick-label { margin-top: 6px; text-align: center; color: var(--de-muted); font-size: 12px; } .de-prompt { display: flex; gap: 6px; align-items: center; } -.de-prompt input { width: 240px; height: 28px; padding: 0 8px; border: 1px solid var(--de-border); border-radius: 6px; background: var(--de-input-bg); color: var(--de-text); font: inherit; outline: none; } +.de-prompt input { width: 240px; height: 28px; padding: 0 8px; border: 1px solid var(--de-border); border-radius: var(--de-radius); background: var(--de-input-bg); color: var(--de-text); font: inherit; outline: none; } .de-prompt input:focus { border-color: var(--de-accent); } .de-prompt .de-btn { border: 1px solid var(--de-border); background: var(--de-pop); } .de-shape-sel { position: absolute; border: 1.5px solid var(--de-accent); pointer-events: none; z-index: 5; transform-origin: 50% 50%; } -.de-handle { position: absolute; background: #fff; border: 1px solid var(--de-accent); border-radius: 2px; pointer-events: auto; } +.de-handle { position: absolute; background: var(--de-page); border: 1px solid var(--de-accent); border-radius: 2px; pointer-events: auto; } .de-h-nw, .de-h-se { cursor: nwse-resize; } .de-h-ne, .de-h-sw { cursor: nesw-resize; } .de-h-n, .de-h-s { cursor: ns-resize; } @@ -876,12 +963,12 @@ display: flex; align-items: center; justify-content: center; - background: #000; + background: var(--de-present-bg); outline: none; cursor: none; } .de-present .de-slide { box-shadow: none; } -.de-present-n { position: absolute; right: 16px; bottom: 12px; color: rgba(255, 255, 255, 0.5); font: 12px system-ui, sans-serif; } +.de-present-n { position: absolute; right: 16px; bottom: 12px; color: var(--de-present-ink); font: 12px var(--de-font); } .de-present-btn { position: absolute; right: 16px; @@ -890,7 +977,7 @@ } .de-grip-c { position: absolute; top: 0; width: 6px; cursor: col-resize; z-index: 3; } .de-grip-r { position: absolute; left: 0; height: 6px; cursor: row-resize; z-index: 3; } -.de-grip-c:hover, .de-grip-r:hover { background: rgba(26, 115, 232, 0.35); } +.de-grip-c:hover, .de-grip-r:hover { background: color-mix(in srgb, var(--de-accent) 35%, transparent); } .de-ctx { position: fixed; z-index: 50; @@ -898,9 +985,9 @@ max-width: 340px; padding: 6px 0; background: var(--de-pop); - border-radius: 8px; + border-radius: var(--de-radius-lg); box-shadow: var(--de-pop-shadow); - font: 13px/1.3 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font: 13px/1.3 var(--de-font); color: var(--de-ink-strong); user-select: none; } @@ -929,16 +1016,16 @@ .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-input { width: 120px; margin: 4px 2px; padding: 0 6px; border: 1px solid var(--de-accent); border-radius: 4px; background: var(--de-input-bg); color: var(--de-text); font: inherit; } +.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; } .de-fref { position: absolute; border: 2px solid; pointer-events: none; z-index: 2; } .de-fref-pt { border-style: dashed; } -.de-cell-hl { z-index: 4; pointer-events: none; color: #202124; overflow: hidden; overflow-wrap: break-word; } +.de-cell-hl { z-index: 4; pointer-events: none; color: var(--de-sheet-ink); overflow: hidden; overflow-wrap: break-word; } .de-cell-edit { overflow-wrap: break-word; } -.de-cell-edit-fx { background: transparent; color: transparent; caret-color: #202124; box-shadow: none; } -.de-cell-edit-fx::selection { color: transparent; background: rgba(26, 115, 232, 0.25); } +.de-cell-edit-fx { background: transparent; color: transparent; caret-color: var(--de-sheet-ink); box-shadow: none; } +.de-cell-edit-fx::selection { color: transparent; background: color-mix(in srgb, var(--de-accent) 25%, transparent); } .de-fassist-at { position: absolute; z-index: 30; } .de-fassist { min-width: 240px; @@ -946,9 +1033,9 @@ padding: 4px 0; background: var(--de-pop); border: 1px solid var(--de-border); - border-radius: 6px; + border-radius: var(--de-radius); box-shadow: var(--de-pop-shadow); - font: 12px/1.45 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font: 12px/1.45 var(--de-font); color: var(--de-text); text-align: left; white-space: normal; @@ -958,8 +1045,8 @@ .de-fsug-name { font-weight: 600; } .de-fsug-args { margin-left: 2px; color: var(--de-muted); } .de-fsug-desc { margin-top: 2px; color: var(--de-muted); font-size: 11.5px; white-space: normal; } -.de-fhint { padding: 6px 10px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } -.de-fhint .de-fsug-desc { font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; } +.de-fhint { padding: 6px 10px; font-family: var(--de-font-mono); } +.de-fhint .de-fsug-desc { font-family: var(--de-font); } .de-fhint b { color: var(--de-accent); } /* docx: picture selection, outline, dialogs */ @@ -969,7 +1056,7 @@ width: 9px; height: 9px; margin: -5px 0 0 -5px; - background: #fff; + background: var(--de-page); border: 1.5px solid var(--de-accent); border-radius: 2px; pointer-events: auto; @@ -988,10 +1075,10 @@ bottom: -28px; transform: translateX(-50%); padding: 2px 6px; - border-radius: 4px; - background: rgba(32, 33, 36, 0.85); - color: #fff; - font: 11px/1.4 system-ui, sans-serif; + border-radius: var(--de-radius-sm); + background: var(--de-tip-bg); + color: var(--de-tip-ink); + font: 11px/1.4 var(--de-font); white-space: nowrap; } .de-outline { @@ -1008,7 +1095,7 @@ background: var(--de-pop); border-radius: 10px; box-shadow: var(--de-pop-shadow); - font: 13px/1.35 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font: 13px/1.35 var(--de-font); } .de-outline-head { flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; padding: 4px 4px 6px 10px; font-weight: 600; color: var(--de-ink-strong); } .de-outline-item { @@ -1017,7 +1104,7 @@ width: 100%; padding: 5px 10px; border: 0; - border-radius: 6px; + border-radius: var(--de-radius); background: transparent; color: var(--de-ink-soft); font: inherit; @@ -1035,10 +1122,13 @@ 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); - border-radius: 8px; + border-radius: var(--de-radius-lg); background: var(--de-pop); color: inherit; box-shadow: var(--de-shadow); @@ -1052,9 +1142,9 @@ height: 26px; padding: 0; border: 0; - border-radius: 4px; + border-radius: var(--de-radius-sm); background: transparent; - font: 15px/1 system-ui, sans-serif; + font: 15px/1 var(--de-font); color: var(--de-ink-strong); cursor: pointer; } @@ -1076,9 +1166,9 @@ width: min(420px, calc(100vw - 32px)); padding: 20px; background: var(--de-pop); - border-radius: 12px; - box-shadow: 0 8px 28px rgba(0, 0, 0, 0.28); - font: 14px/1.4 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + border-radius: var(--de-radius-xl); + box-shadow: var(--de-shadow-lg); + font: 14px/1.4 var(--de-font); color: var(--de-ink-strong); } .de-dialog-title { font-size: 16px; font-weight: 600; } @@ -1087,7 +1177,7 @@ width: 100%; padding: 8px 10px; border: 1px solid var(--de-input-border); - border-radius: 6px; + border-radius: var(--de-radius); background: var(--de-input-bg); color: var(--de-text); font: inherit; @@ -1096,29 +1186,29 @@ } .de-dialog input:focus, .de-dialog textarea:focus { border-color: var(--de-accent); box-shadow: inset 0 0 0 1px var(--de-accent); } .de-hf-zone { position: absolute; left: 0; right: 0; z-index: 1; cursor: default; } -.de-hf-zone:hover { background: rgba(26, 115, 232, 0.05); } +.de-hf-zone:hover { background: color-mix(in srgb, var(--de-accent) 5%, transparent); } .de-hfd { width: 460px; } .de-hfd textarea { min-height: 70px; } .de-hfd-tabs { display: flex; gap: 4px; flex-wrap: wrap; } .de-hfd-tabs button { padding: 5px 10px; border: 1px solid var(--de-border); border-radius: 14px; background: var(--de-pop); color: var(--de-text); font: inherit; font-size: 12.5px; cursor: pointer; } .de-hfd-tabs button.on { background: var(--de-accent); border-color: var(--de-accent); color: var(--de-accent-ink); } .de-ps { width: 380px; max-width: calc(100vw - 32px); gap: 10px; } -.de-ps-note { font-size: 12px; color: var(--de-muted, #5f6368); margin-top: -4px; } +.de-ps-note { font-size: 12px; color: var(--de-muted); margin-top: -4px; } .de-ps-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } .de-ps-head { font-size: 12px; font-weight: 600; display: flex; align-items: center; gap: 10px; min-width: 72px; } .de-ps-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 16px; } .de-ps-field { display: flex; align-items: center; gap: 6px; font-size: 13px; } .de-ps-field > span { min-width: 48px; } .de-ps-field input { width: 64px; } -.de-ps-field em { font-style: normal; color: var(--de-muted, #5f6368); font-size: 12px; } +.de-ps-field em { font-style: normal; color: var(--de-muted); font-size: 12px; } .de-ps-wide { flex: 1; } .de-ps-wide select { flex: 1; } .de-ps-radio { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; cursor: pointer; } .de-ps-radio input { width: auto; padding: 0; margin: 0; } .de-ps-preset { font-weight: 400; font-size: 12px; } -.de-ps select, .de-hfd select { padding: 6px 8px; border: 1px solid var(--de-input-border); border-radius: 6px; background: var(--de-input-bg); color: var(--de-text); font: inherit; } +.de-ps select, .de-hfd select { padding: 6px 8px; border: 1px solid var(--de-input-border); border-radius: var(--de-radius); background: var(--de-input-bg); color: var(--de-text); font: inherit; } .de-page-cols > * { break-inside: avoid; } -.de-ps-bad { color: #c5221f; } +.de-ps-bad { color: var(--de-danger); } .de-dialog-btns .de-btn:disabled { opacity: 0.5; cursor: default; } .de-dialog-btns { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; } .de-dialog-btns .de-btn { padding: 7px 14px; border: 1px solid var(--de-border); border-radius: 18px; background: var(--de-pop); } @@ -1136,11 +1226,11 @@ z-index: 60; transform: translateX(-50%); padding: 10px 16px; - border-radius: 6px; + border-radius: var(--de-radius); background: var(--de-toast-bg); color: var(--de-toast-ink); - font: 13px/1.4 system-ui, sans-serif; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + font: 13px/1.4 var(--de-font); + box-shadow: var(--de-shadow-md); } /* xlsx: find, borders popover */ @@ -1148,8 +1238,8 @@ .de-xlsx .de-find { top: 40px; } .de-find-opts { gap: 10px; padding: 2px 4px; color: var(--de-ink-soft); font-size: 12px; } .de-find-opt { display: inline-flex; align-items: center; gap: 4px; cursor: pointer; user-select: none; } -.de-findcell { position: absolute; z-index: 1; background: rgba(251, 188, 4, 0.28); pointer-events: none; } -.de-findcell.on { background: rgba(255, 150, 50, 0.45); box-shadow: inset 0 0 0 1.5px #e37400; } +.de-findcell { position: absolute; z-index: 1; background: var(--de-find-cell); pointer-events: none; } +.de-findcell.on { background: var(--de-find-cell-on); box-shadow: inset 0 0 0 1.5px var(--de-find-cell-line); } .de-borders-pop { display: flex; gap: 8px; align-items: flex-start; } .de-borders-side { display: flex; flex-direction: column; gap: 4px; padding-left: 8px; border-left: 1px solid var(--de-sep); } .de-find .de-find-opt input { width: auto; height: auto; margin: 0; padding: 0; accent-color: var(--de-accent); } @@ -1157,21 +1247,21 @@ /* ---------------------------------------------------------------- collaboration */ .de-collab { display: inline-flex; align-items: center; gap: 8px; flex: none; } -.de-collab-status { display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 9px; border-radius: 12px; font-size: 12px; color: var(--de-muted); background: var(--de-head); white-space: nowrap; } -.de-collab-dot { width: 7px; height: 7px; border-radius: 50%; background: #9aa0a6; flex: none; } -.de-collab-live .de-collab-dot { background: #1e8e3e; box-shadow: 0 0 0 3px rgba(30, 142, 62, 0.18); } +.de-collab-status { display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 9px; border-radius: var(--de-radius-xl); font-size: 12px; color: var(--de-muted); background: var(--de-head); white-space: nowrap; } +.de-collab-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--de-status-idle); flex: none; } +.de-collab-live .de-collab-dot { background: var(--de-status-live); box-shadow: 0 0 0 3px color-mix(in srgb, var(--de-status-live) 18%, transparent); } .de-collab-live { color: var(--de-ok); } .de-collab-connecting .de-collab-dot, -.de-collab-syncing .de-collab-dot { background: #f9ab00; animation: de-collab-pulse 1s ease-in-out infinite alternate; } -.de-collab-offline .de-collab-dot { background: #9aa0a6; } +.de-collab-syncing .de-collab-dot { background: var(--de-status-busy); animation: de-collab-pulse 1s ease-in-out infinite alternate; } +.de-collab-offline .de-collab-dot { background: var(--de-status-idle); } .de-collab-error { color: var(--de-danger); } -.de-collab-error .de-collab-dot { background: #d93025; } +.de-collab-error .de-collab-dot { background: var(--de-status-error); } @keyframes de-collab-pulse { to { opacity: 0.35; } } @media (max-width: 640px) { .de-collab-label { display: none; } } .de-peers { display: inline-flex; align-items: center; } .de-peer { display: inline-grid; place-items: center; width: 26px; height: 26px; margin-left: -6px; border-radius: 50%; - border: 2px solid var(--de-chrome); color: #fff; font-size: 11px; font-weight: 600; letter-spacing: 0.02em; cursor: default; user-select: none; + border: 2px solid var(--de-chrome); color: var(--de-avatar-ink); font-size: 11px; font-weight: 600; letter-spacing: 0.02em; cursor: default; user-select: none; } .de-peer:first-child { margin-left: 0; } .de-peer-more { background: var(--de-muted); } @@ -1180,15 +1270,15 @@ .de-peer-caret::before { content: ''; position: absolute; top: -3px; left: -2px; width: 6px; height: 6px; border-radius: 50%; background: var(--peer); } .de-peer-flag { position: absolute; bottom: 100%; left: -1px; margin-bottom: 2px; padding: 1px 6px; border-radius: 4px 4px 4px 0; background: var(--peer); - color: #fff; font: 600 11px/16px system-ui, -apple-system, 'Segoe UI', sans-serif; white-space: nowrap; + color: var(--de-avatar-ink); font: 600 11px/16px var(--de-font); white-space: nowrap; } .de-peer-sel { position: absolute; background: var(--peer); opacity: 0.18; border-radius: 1px; } .de-peer-outline { position: absolute; border: 2px solid var(--peer); border-radius: 3px; } .de-peer-outline > .de-peer-flag { left: -2px; margin-bottom: 1px; } .de-peer-range { position: absolute; border: 2px solid; pointer-events: none; z-index: 2; } .de-peer-tag { - position: absolute; top: -17px; left: -2px; padding: 0 5px; border-radius: 3px 3px 3px 0; color: #fff; - font: 600 10px/15px system-ui, -apple-system, 'Segoe UI', sans-serif; white-space: nowrap; + position: absolute; top: -17px; left: -2px; padding: 0 5px; border-radius: 3px 3px 3px 0; color: var(--de-avatar-ink); + font: 600 10px/15px var(--de-font); white-space: nowrap; } /* ---------------------------------------------------------------- comments */ @@ -1201,7 +1291,7 @@ min-height: 0; background: var(--de-chrome); border-left: 1px solid var(--de-border); - font: 13px/1.4 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font: 13px/1.4 var(--de-font); color: var(--de-text); position: relative; z-index: 7; @@ -1211,17 +1301,17 @@ } .de-cm-bar { display: flex; align-items: center; gap: 6px; padding: 8px 8px 8px 14px; border-bottom: 1px solid var(--de-border); } .de-cm-title { flex: 1; display: flex; align-items: center; gap: 6px; font-weight: 600; color: var(--de-ink-strong); } -.de-cm-badge { min-width: 18px; padding: 1px 6px; border-radius: 9px; background: var(--de-accent-soft); color: var(--de-accent); font: 600 11px/16px system-ui, sans-serif; text-align: center; } +.de-cm-badge { min-width: 18px; padding: 1px 6px; border-radius: 9px; background: var(--de-accent-soft); color: var(--de-accent); font: 600 11px/16px var(--de-font); text-align: center; } .de-cm-filter { font-size: 12px; color: var(--de-muted); } .de-cm-filter.on { color: var(--de-accent); } .de-cm-list { flex: 1; min-height: 0; overflow-y: auto; padding: 10px; display: flex; flex-direction: column; gap: 10px; } .de-cm-new { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 10px; - border: 1px dashed var(--de-border); border-radius: 8px; background: transparent; color: var(--de-ink); font: inherit; cursor: pointer; text-align: left; + border: 1px dashed var(--de-border); border-radius: var(--de-radius-lg); background: transparent; color: var(--de-ink); font: inherit; cursor: pointer; text-align: left; } .de-cm-new:hover { background: var(--de-hover); border-color: var(--de-accent); color: var(--de-accent); } .de-cm-keys { margin-left: auto; color: var(--de-muted); font-size: 11px; } -.de-cm-notice { display: flex; align-items: center; gap: 6px; padding: 6px 6px 6px 10px; border-radius: 8px; background: var(--de-hover-soft); color: var(--de-ink-soft); font-size: 12px; } +.de-cm-notice { display: flex; align-items: center; gap: 6px; padding: 6px 6px 6px 10px; border-radius: var(--de-radius-lg); background: var(--de-hover-soft); color: var(--de-ink-soft); font-size: 12px; } .de-cm-notice span { flex: 1; } .de-cm-card { flex: none; @@ -1247,7 +1337,7 @@ .de-cm-head { display: flex; align-items: center; gap: 8px; } .de-cm-ava { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 50%; - color: #fff; font: 600 11px/1 system-ui, sans-serif; text-transform: uppercase; + color: var(--de-avatar-ink); font: 600 11px/1 var(--de-font); text-transform: uppercase; } .de-cm-who { flex: 1; min-width: 0; display: flex; flex-direction: column; } .de-cm-name { font-weight: 600; color: var(--de-ink-strong); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -1262,7 +1352,7 @@ .de-cm-item .de-cm-compose { margin-left: 36px; } .de-cm-compose textarea { display: block; width: 100%; min-height: 34px; max-height: 200px; padding: 7px 10px; resize: none; - border: 1px solid var(--de-border); border-radius: 8px; background: var(--de-input-bg); color: var(--de-text); font: inherit; line-height: 1.4; outline: none; + border: 1px solid var(--de-border); border-radius: var(--de-radius-lg); background: var(--de-input-bg); color: var(--de-text); font: inherit; line-height: 1.4; outline: none; } .de-cm-compose textarea:focus { border-color: var(--de-accent); box-shadow: 0 0 0 1px var(--de-accent); } .de-cm-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; margin-top: 6px; } @@ -1273,21 +1363,21 @@ .de-cm-actions .de-btn:disabled { opacity: 0.5; cursor: default; } .de-cm-pop { position: absolute; left: 0; right: 0; top: calc(100% - 32px); z-index: 30; max-height: 220px; overflow-y: auto; padding: 4px; - background: var(--de-pop); border-radius: 8px; box-shadow: var(--de-pop-shadow); + background: var(--de-pop); border-radius: var(--de-radius-lg); box-shadow: var(--de-pop-shadow); } -.de-cm-pop button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 5px 8px; border: 0; border-radius: 6px; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; } +.de-cm-pop button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 5px 8px; border: 0; border-radius: var(--de-radius); background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; } .de-cm-pop button.on, .de-cm-pop button:hover { background: var(--de-hover-soft); } .de-cm-pop .de-cm-ava { width: 22px; height: 22px; font-size: 10px; } .de-cm-empty { padding: 24px 8px; color: var(--de-muted); text-align: center; } -.de-cm-toggle .de-cm-n { min-width: 16px; padding: 0 5px; border-radius: 8px; background: var(--de-accent); color: var(--de-accent-ink); font: 600 10px/16px system-ui, sans-serif; } +.de-cm-toggle .de-cm-n { min-width: 16px; padding: 0 5px; border-radius: var(--de-radius-lg); background: var(--de-accent); color: var(--de-accent-ink); font: 600 10px/16px var(--de-font); } /* XLSX: a cell with a comment */ .de-cm-mark { position: absolute; z-index: 4; width: 0; height: 0; border-style: solid; border-width: 0 8px 8px 0; border-color: transparent var(--de-cm-line) transparent transparent; pointer-events: none; } -.de-cm-mark.on { border-right-color: #c26401; } +.de-cm-mark.on { border-right-color: var(--de-cm-line-on); } /* PPTX: a comment on a slide */ .de-cm-pin { position: absolute; z-index: 4; display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; margin: -26px 0 0 0; padding: 0; - border: 2px solid #fff; border-radius: 13px 13px 13px 2px; background: var(--de-cm-line); color: #fff; font: 600 11px/1 system-ui, sans-serif; cursor: pointer; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35); + border: 2px solid var(--de-avatar-ink); border-radius: 13px 13px 13px 2px; background: var(--de-cm-line); color: var(--de-avatar-ink); font: 600 11px/1 var(--de-font); cursor: pointer; + box-shadow: var(--de-shadow-md); } .de-cm-pin.on { background: var(--de-accent); transform: scale(1.12); } @@ -1303,10 +1393,6 @@ .de-split-handle:hover, .de-split-handle:focus-visible { background: var(--de-accent); outline: none; } .de-body-stacked .de-split-handle { width: auto; height: 6px; cursor: row-resize; } .de-src { - --de-src-bg: #ffffff; - --de-src-gutter: #f6f7f8; - --de-src-sel: rgba(26, 115, 232, 0.25); - --de-src-font: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; flex: 1; min-width: 0; min-height: 0; @@ -1314,11 +1400,10 @@ flex-direction: column; color: var(--de-text); } -.de-dark .de-src { --de-src-bg: #1b1d20; --de-src-gutter: #202327; --de-src-sel: rgba(102, 157, 246, 0.35); } .de-src-head { flex: none; display: flex; align-items: center; gap: 10px; height: 36px; padding: 0 6px 0 12px; border-bottom: 1px solid var(--de-border); font-size: 12px; } .de-src-title { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; } .de-src-lines { color: var(--de-muted); } -.de-src-btn { display: inline-grid; place-items: center; width: 28px; height: 28px; border: 0; border-radius: 6px; background: none; color: var(--de-ink); cursor: pointer; } +.de-src-btn { display: inline-grid; place-items: center; width: 28px; height: 28px; border: 0; border-radius: var(--de-radius); background: none; color: var(--de-ink); cursor: pointer; } .de-dark .de-src-btn { color: var(--de-text); } .de-src-btn:hover { background: var(--de-hover); } .de-src-notice { flex: none; padding: 6px 12px; font-size: 12px; line-height: 1.4; color: var(--de-muted); background: var(--de-head); border-bottom: 1px solid var(--de-border); } @@ -1356,22 +1441,14 @@ caret-color: var(--de-text); } .de-src-input::selection { background: var(--de-src-sel); } -.de-tk-c { color: #6a737d; font-style: italic; } -.de-tk-k { color: #8e3bc7; } -.de-tk-s { color: #1a7f37; } -.de-tk-o { color: #c2410c; } -.de-tk-n { color: #0b7a8a; } -.de-tk-h { color: #0b57d0; font-weight: 600; } -.de-tk-a { color: #0b57d0; } -.de-tk-t { color: #b42318; } -.de-tk-m { color: #8a5a00; } +.de-tk-c { color: var(--de-src-comment); font-style: italic; } +.de-tk-k { color: var(--de-src-keyword); } +.de-tk-s { color: var(--de-src-string); } +.de-tk-o { color: var(--de-src-operator); } +.de-tk-n { color: var(--de-src-number); } +.de-tk-h { color: var(--de-src-heading); font-weight: 600; } +.de-tk-a { color: var(--de-src-attr); } +.de-tk-t { color: var(--de-src-tag); } +.de-tk-m { color: var(--de-src-meta); } .de-tk-b { font-weight: 700; } .de-tk-i { font-style: italic; } -.de-dark .de-tk-c { color: #8b949e; } -.de-dark .de-tk-k { color: #d2a8ff; } -.de-dark .de-tk-s { color: #7ee787; } -.de-dark .de-tk-o { color: #ffa657; } -.de-dark .de-tk-n { color: #79c0ff; } -.de-dark .de-tk-h, .de-dark .de-tk-a { color: #8ab4f8; } -.de-dark .de-tk-t { color: #ff7b72; } -.de-dark .de-tk-m { color: #e3b341; } diff --git a/src/react/theme.ts b/src/react/theme.ts new file mode 100644 index 0000000..edf07ff --- /dev/null +++ b/src/react/theme.ts @@ -0,0 +1,81 @@ +import type { CSSProperties } from 'react' +import { useDarkTheme, type ThemeMode } from './common/hooks' + +export type { ThemeMode } + +/** + * Every design token of the viewer, as named in the stylesheet without its `--de-` prefix. The stylesheet defines each + * on `.de-root` (light) and the colors again on `.de-root.de-dark`; `test/theme.test.ts` keeps this list and the + * stylesheet in step. + */ +export const THEME_TOKENS = [ + // chrome + 'bg', 'chrome', 'border', 'text', 'muted', 'accent', 'accent-hover', 'accent-ink', 'accent-soft', + 'on', 'on-ink', 'on-ink-strong', 'ink', 'ink-strong', 'ink-soft', 'hover', 'hover-solid', 'hover-soft', + 'ebar', 'tb-sep', 'pop', 'pop-shadow', 'sep', 'input-bg', 'input-border', 'swatch-border', 'disabled', + 'danger', 'danger-border', 'ok', 'tab-hover', 'tab-on', 'tab-ink', 'toast-bg', 'toast-ink', 'backdrop', 'shadow', + // sheet headers and grid + 'grid', 'head', 'head-ink', 'head-border', 'corner-border', 'freeze', + // comments + 'cm-bg', 'cm-on', 'cm-line', 'cm-line-on', 'cm-card', 'cm-quote', + // type and shape + 'font', 'font-mono', 'font-size', 'radius-sm', 'radius', 'radius-lg', 'radius-xl', 'shadow-sm', 'shadow-md', 'shadow-lg', + // file kind badges, people's colors, find, tooltips, slideshow, connection state + 'kind-docx', 'kind-xlsx', 'kind-pptx', 'kind-ink', 'avatar-ink', + 'find', 'find-on', 'find-cell', 'find-cell-on', 'find-cell-line', + 'tip-bg', 'tip-ink', 'present-bg', 'present-ink', + 'status-idle', 'status-live', 'status-busy', 'status-error', + // paper: pages, slides and sheets (the same in both modes, as documents keep their own colors) + 'page', 'page-ink', 'sheet-ink', 'skeleton', 'ph-border', 'ph-a', 'ph-b', 'ph-ink', 'ph-outline', 'ph-hint', + // source editor (Markdown, HTML, diagrams) + 'src-bg', 'src-gutter', 'src-sel', 'src-font', 'src-comment', 'src-keyword', 'src-string', 'src-operator', + 'src-number', 'src-heading', 'src-attr', 'src-tag', 'src-meta', +] as const + +export type ThemeToken = (typeof THEME_TOKENS)[number] + +type Camel = S extends `${infer H}-${infer T}` ? `${H}${Capitalize>}` : S + +/** Token values by camelCase name (`accentHover` for `--de-accent-hover`); any CSS value, `var()` included. */ +export type ThemeTokens = { [K in ThemeToken as Camel]?: string } + +export interface ThemeOptions { + /** Light, dark, or following the OS. Default 'light'. */ + mode?: ThemeMode + /** Tokens for both modes: type, shape, a brand accent that works on both. */ + tokens?: ThemeTokens + /** Tokens for the light mode only, over `tokens`. */ + light?: ThemeTokens + /** Tokens for the dark mode only, over `tokens`. */ + dark?: ThemeTokens +} + +/** The `theme` prop: a mode, or a mode with token overrides. */ +export type Theme = ThemeMode | ThemeOptions + +/** Types a theme object (no runtime work: for editor completion where the object is written apart from the prop). */ +export const createTheme = (theme: ThemeOptions): ThemeOptions => theme + +const kebab = (name: string) => name.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()) + +export const themeMode = (theme: Theme | undefined): ThemeMode => (typeof theme === 'object' ? (theme.mode ?? 'light') : (theme ?? 'light')) + +/** The custom properties a theme sets on the viewer root in the given mode. */ +export function themeStyle(theme: Theme | undefined, dark: boolean): CSSProperties { + if (!theme || typeof theme !== 'object') return {} + const out: Record = {} + for (const set of [theme.tokens, dark ? theme.dark : theme.light]) { + if (!set) continue + for (const [k, v] of Object.entries(set)) if (v != null && v !== '') out['--de-' + kebab(k)] = v + } + return out as CSSProperties +} + +/** + * The class name and style of a viewer root for `theme`: what `DocViewer` puts on its root element, for hosts that + * mount the views (`DocxView`, `XlsxView`, `PptxView`) in a root of their own. + */ +export function useThemeRoot(theme?: Theme, style?: CSSProperties): { className: string; style: CSSProperties; dark: boolean } { + const dark = useDarkTheme(themeMode(theme)) + return { className: dark ? 'de-root de-dark' : 'de-root', style: { ...themeStyle(theme, dark), ...style }, dark } +} 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..9e61931 --- /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 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) + }) + + 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(' (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') + }) +}) diff --git a/test/theme.test.ts b/test/theme.test.ts new file mode 100644 index 0000000..86482ad --- /dev/null +++ b/test/theme.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test' +import { readFileSync } from 'node:fs' +import { THEME_TOKENS, themeMode, themeStyle } from '../src/react/theme' + +const css = readFileSync(`${import.meta.dir}/../src/react/styles.css`, 'utf8') +const block = (selector: string) => { + const at = css.indexOf(`${selector} {\n`) + return css.slice(at, css.indexOf('\n}', at)) +} +const defined = (text: string) => [...text.matchAll(/^\s*--de-([a-z0-9-]+):/gm)].map((m) => m[1]) +const COLOR = /#[0-9a-f]{3,8}\b|rgba?\(|hsla?\(/i + +describe('theme tokens', () => { + test('THEME_TOKENS lists exactly the tokens the stylesheet defines on .de-root', () => { + expect([...defined(block('.de-root'))].sort()).toEqual([...THEME_TOKENS].sort()) + }) + + test('the dark theme redefines only known tokens', () => { + const dark = defined(block('.de-root.de-dark')) + expect(dark.length).toBeGreaterThan(40) + for (const t of dark) expect(THEME_TOKENS as readonly string[]).toContain(t) + }) + + test('every token the stylesheet uses is defined', () => { + const used = new Set([...css.matchAll(/var\(--de-([a-z0-9-]+)/g)].map((m) => m[1])) + const known = new Set(THEME_TOKENS) + expect([...used].filter((t) => !known.has(t))).toEqual([]) + }) + + test('no literal color outside the token definitions and var() fallbacks', () => { + const stray = css + .split('\n') + .map((line, i) => ({ line: line.replace(/var\(--de-[a-z0-9-]+,[^)]*\)?\)/g, ''), n: i + 1 })) + .filter(({ line }) => COLOR.test(line) && !/^\s*--de-[a-z0-9-]+:/.test(line)) + expect(stray).toEqual([]) + }) +}) + +describe('themeStyle', () => { + const theme = { + mode: 'auto' as const, + tokens: { accent: '#7c3aed', font: 'Inter, sans-serif', radiusLg: '4px' }, + light: { bg: '#fafafa' }, + dark: { bg: '#111', accent: '#a78bfa' }, + } + + test('maps camelCase names to the custom properties', () => { + expect(themeStyle(theme, false)).toEqual({ '--de-accent': '#7c3aed', '--de-font': 'Inter, sans-serif', '--de-radius-lg': '4px', '--de-bg': '#fafafa' } as never) + }) + + test('the mode set goes over the shared tokens', () => { + expect(themeStyle(theme, true)).toEqual({ '--de-accent': '#a78bfa', '--de-font': 'Inter, sans-serif', '--de-radius-lg': '4px', '--de-bg': '#111' } as never) + }) + + test('a plain mode sets no properties', () => { + expect(themeStyle('dark', true)).toEqual({}) + expect(themeMode('dark')).toBe('dark') + expect(themeMode(theme)).toBe('auto') + expect(themeMode({ tokens: {} })).toBe('light') + expect(themeMode(undefined)).toBe('light') + }) +})