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..0ff91a9 --- /dev/null +++ b/docs/theming.md @@ -0,0 +1,134 @@ +# 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 ( + +
+
+) +``` + +`OfficeUIProvider` carries what `DocViewer` takes as `icons`, `components`, `locale` and `strings` to views mounted on +their own. + +## Your own components + +Where tokens are not enough, the toolbars and dialogs render the host's own components: a design system's buttons, +selects and dialogs instead of the built-in ones. Each one left out keeps the built-in one. + +```tsx + ( + + {icon}{label}{children} + + ), + Separator: () => , + }} +/> +``` + +| Component | Used for | Props | +|---|---|---| +| `Button` | every toolbar button, the toggles that open color, table and border pickers, the overflow button | `ButtonProps`: `title`, `icon` (resolved through `icons`), `iconName`, `label`, `children`, `active`, `expanded`, `disabled`, `wide`, `onClick`, `onMouseDown`, `className` (the built-in classes) | +| `Select` | font, size list, style, format and menu dropdowns | `SelectProps`: `title`, `options` (`DropOption`: `value`, `label`, `hint`, `icon`, `disabled`, `sep`, `head`, `style`), `value` (undefined for a command menu), `onChange`, `label`, `placeholder`, `width`, `menuWidth`, `disabled`, `alignRight` | +| `Separator` | between toolbar groups | none | +| `Dialog` | prompts (link, rename, …) and confirmations | `DialogProps`: `title`, `kind` (`'alert'` for a question), `children` (the body), `actions` (`label`, `onClick`, `primary`, `submit`, `autoFocus`), `onSubmit` (the body is a form), `onClose` (Escape, backdrop) | +| `Toast` | short messages at the bottom | `text` (the viewer removes it after a few seconds) | + +The document must keep the focus while a toolbar control is used, or the command loses its selection: + +- `Button`: pass `onMouseDown` to the element. It prevents the default, which keeps the focus. +- `Select`: open on mouse down with the default prevented, and handle its keys without moving the focus into the list. + The built-in one captures the keys at the window while open. + +The overflow menu measures each toolbar item from a wrapper of its own, so a host component needs no ref. + +## Tokens + +| Group | Tokens | +|---|---| +| 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..f9eebb0 100644 --- a/e2e/app/main.tsx +++ b/e2e/app/main.tsx @@ -2,10 +2,13 @@ * 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) + * &components=1: the host's own Button, Separator, Dialog and Toast (`data-host-*` markers) */ import { useState } from 'react' import { createRoot } from 'react-dom/client' -import { DocViewer, type DocViewerProps, type FileMenuItem } from '../../src' +import { DocViewer, type DocViewerProps, type FileMenuItem, type UIComponents } from '../../src' type Ev = { type: string; [k: string]: unknown } declare global { @@ -32,6 +35,49 @@ const items: FileMenuItem[] = [ { label: 'Version history', hint: 'beta', onClick: () => log({ type: 'item', label: 'Version history' }) }, ] +// plain host components: no engine classes, so nothing of the engine's CSS styles them +const components: Partial = { + Button: (p) => ( + + ), + Separator: () => , + Dialog: (p) => { + const actions = p.actions.map((a, i) => ( + + )) + const body = ( + <> +

{p.title}

+ {p.children} + {actions} + + ) + return ( +
e.key === 'Escape' && p.onClose()}> + {p.onSubmit ? ( +
{ + e.preventDefault() + p.onSubmit!() + }} + > + {body} +
+ ) : ( + body + )} +
+ ) + }, + Toast: ({ text }) => {text}, +} + const initial: DocViewerProps = { src: q.get('src'), name: q.get('name') ?? undefined, @@ -46,6 +92,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 +111,14 @@ const initial: DocViewerProps = { open: flag('cmOpen'), onEvent: (e) => log({ type: 'comment', action: e.type, id: e.comment.id, text: e.comment.text, thread: e.thread.id, quote: e.quote, mentioned: e.mentioned.map((m) => m.id ?? m.name), participants: e.participants.map((m) => m.id ?? m.name) }), }, + components: q.has('components') ? components : undefined, + icons: q.has('icons') + ? (name) => ( + + + + ) + : 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/components.spec.ts b/e2e/components.spec.ts new file mode 100644 index 0000000..e194eaf --- /dev/null +++ b/e2e/components.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'bun:test' +import { docxParas, paraText, runsOf, useBrowser, viewer } from './harness' + +useBrowser() + +const open = async (viewport?: { width: number; height: number }) => { + const v = await viewer({ src: 'samples/sample.docx', editable: true, onSave: true, components: true }, { viewport }) + await v.page.locator('.de-ebar').waitFor() + return v +} + +describe('host UI components', () => { + test('docx: every toolbar button is the host Button, and it keeps the selection for formatting', async () => { + const v = await open() + const counts = await v.page.evaluate(() => { + const bar = document.querySelector('.de-ebar')! + return { + host: bar.querySelectorAll('button[data-host-button]').length, + engine: bar.querySelectorAll('button.de-tb').length, + seps: bar.querySelectorAll('[data-host-sep]').length, + engineSeps: bar.querySelectorAll('.de-tb-sep').length, + } + }) + expect(counts.host).toBeGreaterThan(10) + expect(counts.engine).toBe(0) + expect(counts.seps).toBeGreaterThan(0) + expect(counts.engineSeps).toBe(0) + + await v.selectText('document') + await v.btn('Bold').click() + await v.page.waitForFunction(() => document.querySelector('button[data-host-button][aria-label^="Bold"]')?.hasAttribute('data-active')) + const p = (await docxParas((await v.save()).bytes)).find((x) => paraText(x).startsWith('This document'))! + expect(runsOf(p).find((r) => r.text.includes('document'))!.s.b).toBe(true) + expect(v.errors).toEqual([]) + }) + + test('docx: a narrow toolbar still moves what does not fit under the host More button', async () => { + const v = await open({ width: 640, height: 900 }) + const more = v.page.locator('.de-ebar button[data-host-button][aria-label="More tools"]') + await more.waitFor() + await more.click() + expect(await v.page.locator('.de-ebar-more button[data-host-button]').count()).toBeGreaterThan(0) + expect(v.errors).toEqual([]) + }) + + test('docx: the link prompt is the host Dialog and submits', async () => { + const v = await open() + await v.selectText('document') + await v.page.keyboard.press('Control+k') + const dialog = v.page.locator('[data-host-dialog][role="dialog"]') + await dialog.waitFor() + expect(await v.page.locator('.de-dialog').count()).toBe(0) + await dialog.locator('input').fill('https://example.org/docs') + await v.page.keyboard.press('Enter') + await dialog.waitFor({ state: 'detached' }) + const p = (await docxParas((await v.save()).bytes)).find((x) => paraText(x).startsWith('This document'))! + expect(runsOf(p).find((r) => r.text.includes('document'))!.href).toBe('https://example.org/docs') + expect(v.errors).toEqual([]) + }) +}) diff --git a/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..7b7435d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,10 @@ 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 DropOption, type IconName, type Icons } from './react/edit/Toolbar' +export { OfficeUIProvider, UIComponentsProvider, type ButtonProps, type DialogAction, type DialogProps, type SelectProps, type ToastProps, type UIComponents } from './react/ui' +export { StringsProvider, useStrings, resolveStrings, LOCALES, en, de, type Strings, type StringsOverride, type Locale } from './react/strings' export { DocumentClient, setDefaultWorker, type OpenOptions } from './core/worker/client' export type { DocSource } from './core/source' diff --git a/src/react/DocViewer.tsx b/src/react/DocViewer.tsx index 26063bf..2e8f0bf 100644 --- a/src/react/DocViewer.tsx +++ b/src/react/DocViewer.tsx @@ -6,16 +6,17 @@ 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 { UIComponentsProvider, type UIComponents } from './ui' 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 +26,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 +54,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 +106,31 @@ 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 + /** + * The host's own UI components (`Button`, `Select`, `Separator`, `Dialog`, `Toast`), rendered in the toolbars and + * dialogs instead of the built-in ones; each one left out keeps the built-in one. See docs/theming.md. + */ + components?: Partial + /** Language of the viewer's texts (bars, menus, dialogs, messages): 'en' or 'de'. Default 'en'. */ + locale?: Locale + /** Texts replacing the locale's, key by key (see `Strings`): a group may be given in part, e.g. `{ file: { menu: 'Datei' } }`. */ + 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 +141,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 +162,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 +457,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 +474,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 +519,7 @@ export function DocViewer(props: DocViewerProps) { body = (
- Cannot open document. + {t.bar.cannotOpen}
{error.message}
@@ -520,10 +528,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 +539,9 @@ 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 +559,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 +571,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 +611,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 +630,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 +655,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 +677,26 @@ 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..c2b8785 100644 --- a/src/react/common/Dialog.tsx +++ b/src/react/common/Dialog.tsx @@ -1,4 +1,6 @@ import { useEffect, useRef, useState, type ReactNode } from 'react' +import { useStrings } from '../strings' +import { DefaultDialog, DefaultToast, useHostUI, type DialogAction } from '../ui' export interface PromptSpec { title: string @@ -16,6 +18,8 @@ export interface PromptSpec { /** Small modal prompt (no window.prompt: works in sandboxed / embedded pages). */ export function PromptDialog({ spec, onClose }: { spec: PromptSpec; onClose: () => void }) { + const t = useStrings() + const Dialog = useHostUI().Dialog ?? DefaultDialog const [v, setV] = useState(spec.initial ?? '') const ref = useRef(null) useEffect(() => { @@ -27,107 +31,63 @@ export function PromptDialog({ spec, onClose }: { spec: PromptSpec; onClose: () onClose() spec.onOk(v) } + const actions: DialogAction[] = [ + ...(spec.extra + ? [ + { + label: spec.extra.label, + onClick: () => { + onClose() + spec.extra!.run() + }, + }, + ] + : []), + { label: t.common.cancel, onClick: onClose }, + { label: spec.ok ?? t.common.ok, primary: true, submit: true }, + ] return ( -
e.target === e.currentTarget && onClose()} - onKeyDown={(e) => { - if (e.key === 'Escape') { - e.stopPropagation() - onClose() - } - }} - > -
{ - e.preventDefault() - done() - }} - > -
{spec.title}
- {spec.label && } - {spec.area ? ( -