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"`, `headless`; properties `.file = File`, `.icons` (map or resolver, as `icons` above), `.strings = { … }` (see [UI texts](#ui-texts)), `.controller` (see [headless mode](#headless-mode)); 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..6625797 100644 --- a/e2e/app/main.tsx +++ b/e2e/app/main.tsx @@ -2,19 +2,27 @@ * 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) + * &headless=1: the document alone; the viewer is driven through window.__ctl (its controller) either way */ import { useState } from 'react' import { createRoot } from 'react-dom/client' -import { DocViewer, type DocViewerProps, type FileMenuItem } from '../../src' +import { createViewerController, DocViewer, type DocViewerProps, type FileMenuItem, type UIComponents, type ViewerController } from '../../src' type Ev = { type: string; [k: string]: unknown } declare global { interface Window { __events: Ev[] __setProps: (p: Partial) => void + __ctl: ViewerController } } window.__events = [] +window.__ctl = createViewerController() +// a host reading the state, as the tests do through snapshot(): views read slide titles only for one +window.__ctl.subscribe(() => {}) const log = (e: Ev) => window.__events.push(e) const q = new URLSearchParams(location.search) @@ -32,6 +40,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 +97,11 @@ 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, + headless: flag('headless'), + controller: window.__ctl, 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 +118,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/controller.spec.ts b/e2e/controller.spec.ts new file mode 100644 index 0000000..ea4d760 --- /dev/null +++ b/e2e/controller.spec.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from 'bun:test' +import type { ViewerState } from '../src' +import { useBrowser, viewer, type Viewer } from './harness' + +useBrowser() + +const state = (v: Viewer) => v.page.evaluate(() => window.__ctl.snapshot()) as Promise +/** waits until the controller's state passes `check` (run in the page) */ +const until = (v: Viewer, check: string) => v.page.waitForFunction(`(() => { const s = window.__ctl.snapshot(); return ${check} })()`, null, { timeout: 15_000 }) + +describe('controller and headless mode', () => { + test('docx: zoom, pages and headings through the controller; no chrome of its own', async () => { + const v = await viewer({ src: 'samples/sample.docx', headless: true }) + const { page } = v + expect(await page.locator('.de-bar').count()).toBe(0) + expect(await page.locator('.de-zoom').count()).toBe(0) + expect(await page.locator('.de-outline-toggle').count()).toBe(0) + + await until(v, "s.kind === 'docx' && s.page && s.page.count > 0 && s.outline && s.outline.items.length > 0") + const s = await state(v) + expect(s.zoom?.value).toBe('fit') + expect(s.status).toMatch(/Page 1 of \d+/) + expect(s.outline?.kind).toBe('headings') + expect(s.outline?.items.map((i) => i.title)).toContain('1. Highlights') + + // a heading further down: the view scrolls there and marks it current + const table = s.outline!.items.find((i) => /Table/.test(i.title))! + await page.evaluate((id) => window.__ctl.goto(id), table.id) + await until(v, `s.outline.items.find((i) => i.id === ${JSON.stringify(table.id)}).current`) + + // zoom in widens the page + const w0 = (await page.locator('.de-page').first().boundingBox())!.width + await page.evaluate(() => window.__ctl.zoomIn()) + await until(v, "typeof s.zoom.value === 'number'") + await page.waitForTimeout(200) + expect((await page.locator('.de-page').first().boundingBox())!.width).toBeGreaterThan(w0) + expect(v.errors).toEqual([]) + }) + + test('docx: editing and saving through the controller', async () => { + const v = await viewer({ src: 'samples/sample.docx', headless: true, onSave: true }) + const { page } = v + await until(v, 's.canEdit') + await page.evaluate(() => window.__ctl.setEditing(true)) + await until(v, 's.editing') + // the formatting toolbar keeps its row in a headless viewer, without the outline button + await page.locator('.de-ebar').waitFor() + expect(await page.locator('.de-ebar [title="Document outline"]').count()).toBe(0) + await page.evaluate(() => window.__ctl.save()) + const saved = await v.waitEvent('save') + expect(saved.name).toBe('sample.docx') + await page.evaluate(() => window.__ctl.setEditing(false)) + await until(v, '!s.editing') + expect(v.errors).toEqual([]) + }) + + test('docx: undo is offered only while editing', async () => { + const v = await viewer({ src: 'samples/sample.docx', headless: true }) + await until(v, 's.canEdit') + await v.page.evaluate(() => window.__ctl.setEditing(true)) + await until(v, 's.editing') + await v.selectText('Quarterly Report', 'end') + await v.page.keyboard.type('!!') + await until(v, 's.canUndo') + await v.page.evaluate(() => window.__ctl.setEditing(false)) + await until(v, '!s.editing && !s.canUndo && !s.canRedo') + await v.page.evaluate(() => window.__ctl.setEditing(true)) + await until(v, 's.editing && s.canUndo') + expect(v.errors).toEqual([]) + }) + + test('xlsx: the sheets as the outline, no tabs of its own', async () => { + const v = await viewer({ src: 'samples/sample.xlsx', headless: true }) + const { page } = v + expect(await page.locator('.de-tabs').isVisible()).toBe(false) + await until(v, "s.kind === 'xlsx' && s.outline && s.outline.kind === 'sheets'") + const s = await state(v) + expect(s.zoom).toBeNull() + expect(s.outline!.items.map((i) => i.title)).toEqual(['Sales', 'Notes', 'Data']) + expect(s.outline!.items[0].current).toBe(true) + await page.evaluate((id) => window.__ctl.goto(id), s.outline!.items[1].id) + await until(v, 's.outline.items[1].current') + expect(v.errors).toEqual([]) + }) + + test('pptx: slides, zoom and the slide in view; no thumbnails, zoom or Present button', async () => { + const v = await viewer({ src: 'samples/sample.pptx', headless: true }) + const { page } = v + expect(await page.locator('.de-thumbs').count()).toBe(0) + expect(await page.locator('.de-zoom').count()).toBe(0) + expect(await page.locator('.de-present-btn').count()).toBe(0) + await until(v, "s.kind === 'pptx' && s.page && s.outline && s.outline.kind === 'slides'") + const s = await state(v) + expect(s.page!.current).toBe(1) + expect(s.outline!.items.length).toBe(s.page!.count) + await page.evaluate(() => window.__ctl.gotoPage(3)) + await until(v, 's.page.current === 3') + // slide titles arrive from the slides' title placeholders + await until(v, "s.outline.items.some((i) => !/^Slide \\d+$/.test(i.title))") + expect(v.errors).toEqual([]) + }) + + test('with chrome: the controller still follows the view', async () => { + const v = await viewer({ src: 'samples/sample.docx' }) + const { page } = v + expect(await page.locator('.de-bar').count()).toBe(1) + await until(v, "s.kind === 'docx' && s.zoom") + await page.locator('button[aria-label="Zoom in"]').click() + await until(v, "typeof s.zoom.value === 'number'") + expect(v.errors).toEqual([]) + }) +}) diff --git a/e2e/harness.ts b/e2e/harness.ts index c2e8ce6..a9443e1 100644 --- a/e2e/harness.ts +++ b/e2e/harness.ts @@ -38,7 +38,8 @@ export function useBrowser() { beforeAll(async () => { server = await createServer({ configFile: 'vite.e2e.config.ts', server: { port: 0, strictPort: false } }) await server.listen() - const browser = await chromium.launch() + // PLAYWRIGHT_CHANNEL=chrome / msedge: an installed browser instead of Playwright's own Chromium + const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || undefined }) shared = { base: server.resolvedUrls!.local[0], browser } }) afterEach(async () => { 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/locale-docx.spec.ts b/e2e/locale-docx.spec.ts new file mode 100644 index 0000000..dc8576c --- /dev/null +++ b/e2e/locale-docx.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'bun:test' +import { de } from '../src/react/strings' +import { useBrowser, viewer } from './harness' + +useBrowser() + +describe('locale: DOCX', () => { + test('status, toolbar and page setup in German', async () => { + const v = await viewer({ src: 'samples/sample.docx', locale: 'de', editable: true }) + await v.page.locator('.de-ebar').first().waitFor() + expect(await v.status()).toMatch(/Seite 1 von \d+/) + expect(await v.btn('Fett').count()).toBe(1) + expect(await v.btn('Rückgängig').count()).toBe(1) + + await v.btn(de.docx.pageSetup).click() + const ps = v.page.locator('[role=dialog][aria-label="Seite einrichten"]') + await ps.waitFor() + expect(await ps.innerText()).toContain('Querformat') + await ps.locator('button', { hasText: 'Abbrechen' }).click() + await ps.waitFor({ state: 'detached' }) + expect(v.errors).toEqual([]) + }) +}) diff --git a/e2e/locale-xlsx.spec.ts b/e2e/locale-xlsx.spec.ts new file mode 100644 index 0000000..ae6009e --- /dev/null +++ b/e2e/locale-xlsx.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'bun:test' +import { useBrowser, viewer } from './harness' + +useBrowser() + +describe('locale: XLSX', () => { + test('status, toolbar and sheet tab menu in German', async () => { + const v = await viewer({ src: 'samples/sample.xlsx', locale: 'de', editable: true }) + await v.page.locator('.de-ebar').first().waitFor() + expect(await v.status()).toMatch(/Zeilen × \d+ Spalten/) + expect(await v.btn('Fett').count()).toBe(1) + expect(await v.btn('Rahmenlinien').count()).toBe(1) + + await v.page.locator('.de-tab[role=tab]').first().click({ button: 'right' }) + const menu = v.page.locator('.de-ctx') + await menu.waitFor() + expect(await menu.innerText()).toContain('Blatt löschen') + await v.page.keyboard.press('Escape') + expect(v.errors).toEqual([]) + }) +}) diff --git a/e2e/locale.spec.ts b/e2e/locale.spec.ts new file mode 100644 index 0000000..ca9ae71 --- /dev/null +++ b/e2e/locale.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'bun:test' +import { useBrowser, viewer } from './harness' + +useBrowser() + +describe('locale', () => { + test('bar, File menu and details dialog in German', async () => { + const v = await viewer({ src: 'samples/sample.docx', locale: 'de' }) + expect(await v.page.locator('.de-bar .de-file').innerText()).toContain('Datei') + expect(await v.barButton('Bearbeiten').count()).toBe(1) + + const labels = await v.menuLabels() + expect(labels).toContain('Öffnen…') + expect(labels).toContain('PDF-Dokument') + expect(labels).toContain('Dokumenteigenschaften') + + await v.menu('Dokumenteigenschaften') + const dlg = v.page.locator('[role=dialog][aria-label="Dokumenteigenschaften"]') + await dlg.waitFor() + expect(await dlg.innerText()).toContain('Dateigrösse') + await dlg.locator('button', { hasText: 'Schliessen' }).click() + await dlg.waitFor({ state: 'detached' }) + + await v.barButton('Bearbeiten').click() + expect(await v.barButton('Ansicht').count()).toBe(1) + expect(v.errors).toEqual([]) + }) + + test('PPTX in German: status, zoom, present', async () => { + const v = await viewer({ src: 'samples/sample.pptx', locale: 'de' }) + expect(await v.status()).toMatch(/Folie 1 von \d+/) + expect(await v.page.locator('.de-zoom[aria-label="Zoom"] button[aria-label="Vergrössern"]').count()).toBe(1) + expect(await v.page.locator('.de-present-btn').innerText()).toContain('Bildschirmpräsentation') + expect(v.errors).toEqual([]) + }) + + test('a partial override keeps the other texts', async () => { + const v = await viewer({ src: 'samples/sample.docx', locale: 'de', fileLabel: 'Ablage' }) + expect(await v.page.locator('.de-bar .de-file').innerText()).toContain('Ablage') + expect(await v.barButton('Bearbeiten').count()).toBe(1) + }) + + test('English stays the default', async () => { + const v = await viewer({ src: 'samples/sample.pptx' }) + expect(await v.page.locator('.de-bar .de-file').innerText()).toContain('File') + expect(await v.status()).toMatch(/Slide 1 of \d+/) + }) +}) 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/collab/react.tsx b/src/collab/react.tsx index ffc37b7..bc869f5 100644 --- a/src/collab/react.tsx +++ b/src/collab/react.tsx @@ -6,6 +6,7 @@ import type { DocumentClient } from '../core/worker/client' import { getPeers, subscribePeers, type PeerView } from './hosts' import { startCollab, type Collab, type CollabOptions } from './index' import type { CollabStatus } from './session' +import { useStrings } from '../react/strings' const transportIds = new WeakMap() let nextTransportId = 0 @@ -66,16 +67,10 @@ const initials = (name: string) => .map((w) => w[0]!.toUpperCase()) .join('') || '?' -const LABEL: Record = { - connecting: 'Connecting…', - syncing: 'Syncing…', - live: 'Live', - offline: 'Offline · changes will sync', - error: 'Not synced', -} - /** status chip + collaborators' avatars (top bar) */ export function CollabBar({ client, state }: { client: DocumentClient; state: CollabState }) { + const t = useStrings() + const LABEL: Record = t.collab const peers = useSyncExternalStore( (fn) => subscribePeers(client, fn), () => getPeers(client), @@ -88,12 +83,12 @@ export function CollabBar({ client, state }: { client: DocumentClient; state: Co return () => clearInterval(t) }, [state.collab]) if (!state.status) return null - const title = state.status === 'error' ? state.error ?? 'Collaboration stopped' : state.status === 'live' && pending ? `Saving ${pending} change${pending > 1 ? 's' : ''}…` : LABEL[state.status] + const title = state.status === 'error' ? state.error ?? t.collab.stopped : state.status === 'live' && pending ? t.collab.saving(pending) : LABEL[state.status] return ( - {state.status === 'live' ? (peers.length ? `${peers.length + 1} editing` : 'Live') : LABEL[state.status]} + {state.status === 'live' ? (peers.length ? t.collab.editing(peers.length + 1) : LABEL.live) : LABEL[state.status]} {peers.length > 0 && ( diff --git a/src/core/pptx/parser.ts b/src/core/pptx/parser.ts index 8eec960..5cd636f 100644 --- a/src/core/pptx/parser.ts +++ b/src/core/pptx/parser.ts @@ -157,6 +157,22 @@ function phOf(el: XEl): { type: string; idx?: string } | undefined { return { type: ph.a.type ?? 'body', idx: ph.a.idx } } +// A slide title as plain text: symbol-font characters (Wingdings arrows, check marks) have no glyph +// outside their font, so the usual ones become their Unicode look-alikes and the rest are dropped. +const TITLE_SYMBOLS: Record = { + '\uF0E0': '➔', '\uF0E8': '➔', '\uF0F0': '⇨', '\uF0D8': '➢', '\uF0FC': '✓', '\uF0FB': '✗', '\uF0B7': '•', '\uF0A7': '▪', '\uF06C': '●', +} +const plainTitle = (paras: string[]) => paras.join(' ').replace(/[\uF000-\uF0FF]/g, (c) => TITLE_SYMBOLS[c] ?? '').replace(/\s+/g, ' ').trim() + +/** the text of the title placeholder in a slide's shape tree (groups included), or '' */ +function titleOf(tree: XEl | undefined): string { + for (const c of tree?.c ?? []) { + const t = c.n === 'grpSp' ? titleOf(c) : c.n === 'sp' && ['title', 'ctrTitle'].includes(phOf(c)?.type ?? '') ? plainTitle(kids(kid(c, 'txBody'), 'p').map(textOf)) : '' + if (t) return t + } + return '' +} + const masterType = (t: string) => (t === 'ctrTitle' ? 'title' : t === 'subTitle' || t === 'obj' ? 'body' : t) function indexPlaceholders(spTree: XEl | undefined) { @@ -310,9 +326,17 @@ export class PptxPresentation { bg: r.background(), els, hidden: slide.root.a.show === '0', + title: r.title, } } + /** every slide's title placeholder text ('' where there is none): its XML only, nothing laid out */ + async titles(): Promise { + const out: string[] = [] + for (const s of this.meta.slides) out.push(titleOf((await this.part(s.path)).spTree)) + return out + } + get defaults() { return this.defaultTextStyle } @@ -456,6 +480,8 @@ function readXfrm(x: XEl | undefined): Box | undefined { } class SlideRenderer { + /** the text of the slide's title placeholder, once walked */ + title: string | undefined constructor( private pres: PptxPresentation, private slide: Part, @@ -871,6 +897,11 @@ class SlideRenderer { const idAttr = path(el, 'nvSpPr', 'cNvPr')?.a.id const sid = part === this.slide && el.n === 'sp' && idAttr ? Number(idAttr) : undefined const text = el.n === 'sp' ? this.textBody(kid(el, 'txBody'), chain, ph, kid(style, 'fontRef'), part, sid) : undefined + // the slide's own title placeholder names the slide (outline, slide list) + if (text && part === this.slide && this.title === undefined && (ph?.type === 'title' || ph?.type === 'ctrTitle')) { + const t = plainTitle(text.paras.map((p) => p.runs.map((r) => r.text).join(''))) + if (t) this.title = t + } if ((!fill || fill.t === 'none') && !stroke && !text) return out.push({ t: 'shape', box, geom, adj, fill, stroke, text, path: pathD, sid }) } diff --git a/src/core/pptx/types.ts b/src/core/pptx/types.ts index 810a008..b7513c1 100644 --- a/src/core/pptx/types.ts +++ b/src/core/pptx/types.ts @@ -124,6 +124,8 @@ export interface SlideModel { els: SlideEl[] notes?: string hidden?: boolean + /** the text of its title placeholder */ + title?: string } export interface PresentationMeta { diff --git a/src/core/worker/engine.ts b/src/core/worker/engine.ts index 772d840..625185f 100644 --- a/src/core/worker/engine.ts +++ b/src/core/worker/engine.ts @@ -221,6 +221,8 @@ export class Engine { return addSlideImage(this.need(this.pptx), args[0] as number, new Uint8Array(args[1] as ArrayBuffer), args[2] as string) case 'pptx.slide': return this.need(this.pptx).slide(args[0] as number) + case 'pptx.titles': + return this.need(this.pptx).titles() case 'pptx.undo': return undoShapes(this.need(this.pptx), !!args[0]) } diff --git a/src/core/worker/protocol.ts b/src/core/worker/protocol.ts index dca9261..26dce2d 100644 --- a/src/core/worker/protocol.ts +++ b/src/core/worker/protocol.ts @@ -87,6 +87,8 @@ export interface Methods { 'xlsx.undo'(): XlsxEditResult | null 'xlsx.redo'(): XlsxEditResult | null 'pptx.slide'(index: number): SlideModel + /** every slide's title placeholder text ('' where there is none), read without laying out the slides */ + 'pptx.titles'(): string[] 'pptx.setText'(index: number, sid: number, paras: PParaSpec[]): SlideModel 'pptx.shapes'(index: number, ops: ShapeOp[]): SlideModel 'pptx.slides'(op: SlideOp): PresentationMeta diff --git a/src/embed.ts b/src/embed.ts index 4b41b0c..5053a85 100644 --- a/src/embed.ts +++ b/src/embed.ts @@ -2,12 +2,18 @@ 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' +import { ViewerController } from './react/controller' export type MountOptions = Omit const THEMES = ['light', 'dark', 'auto'] as const +const LOCALE_NAMES = ['en', 'de'] as const export interface MountedViewer { + /** the viewer's state and commands (the one passed in `options.controller`, else its own) */ + controller: ViewerController update(next: Partial): void destroy(): void } @@ -16,10 +22,12 @@ export interface MountedViewer { export function mount(el: HTMLElement, options: MountOptions): MountedViewer { let root: Root | null = createRoot(el) let props = options + const controller = options.controller ?? new ViewerController() const shadow = el.getRootNode() instanceof ShadowRoot ? (el.getRootNode() as ShadowRoot) : undefined - const render = () => root?.render(createElement(DocViewer, { ...props, styleTarget: shadow })) + const render = () => root?.render(createElement(DocViewer, { ...props, controller, styleTarget: shadow })) render() return { + controller, update(next) { props = { ...props, ...next } render() @@ -35,14 +43,20 @@ 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"), headless (the document alone, driven through `controller`); * 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); `controller` (ViewerController) for the viewer's + * state and commands. */ 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', 'headless'] private viewer?: MountedViewer private _file?: Blob | ArrayBuffer + private _icons?: Icons + private _strings?: StringsOverride + /** the viewer's state and commands, the same object for the element's whole life */ + readonly controller = new ViewerController() get file() { return this._file @@ -52,6 +66,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' }) @@ -76,7 +105,7 @@ export class OfficeViewerElement extends HTMLElement { return { src: this._file ?? this.getAttribute('src'), name: this.getAttribute('name') ?? undefined, - toolbar: this.getAttribute('toolbar') !== 'false', + toolbar: this.hasAttribute('toolbar') ? this.getAttribute('toolbar') !== 'false' : undefined, thumbnails: this.getAttribute('thumbnails') !== 'false', editable: this.hasAttribute('editable') && this.getAttribute('editable') !== 'false', allowSave: this.getAttribute('save') !== 'false', @@ -85,6 +114,11 @@ 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, + headless: this.hasAttribute('headless') && this.getAttribute('headless') !== 'false', + controller: this.controller, collab: this.getAttribute('collab') && this.getAttribute('room') ? { diff --git a/src/index.ts b/src/index.ts index f1fb2e3..f740a4a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,11 @@ 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 { ViewerController, createViewerController, useViewerState, type ViewerState, type ViewerOutline, type OutlineItem } from './react/controller' 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..3ee34ea 100644 --- a/src/react/DocViewer.tsx +++ b/src/react/DocViewer.tsx @@ -6,16 +6,18 @@ 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 { ViewerController } from './controller' 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 +27,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 +55,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 +107,42 @@ 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 + /** + * The viewer's state and commands for the host (`createViewerController()`): zoom, page, outline, editing, unsaved + * changes, save, undo… Subscribe to it to draw your own controls; see `headless`. + */ + controller?: ViewerController + /** + * The document alone: no top bar, zoom control, outline panel, sheet tabs or slide list (the host draws its own, + * driven by `controller`). While editing, the formatting toolbar keeps its row. The top bar shows when `toolbar` is + * set explicitly. Default false. + */ + headless?: boolean } 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 +153,24 @@ 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) { - const { src, name, className, style, toolbar = true, toolbarExtra, zoom, thumbnails, headers, credentials, worker, styleTarget, theme, onLoad, onError } = props - const dark = useDarkTheme(theme) +function Viewer(props: DocViewerProps) { + const t = useStrings() + const fmtBytes = (n: number) => formatBytes(t, n) + const { src, name, className, style, toolbarExtra, zoom, thumbnails, headers, credentials, worker, styleTarget, theme, onLoad, onError } = props + const headless = !!props.headless + const toolbar = props.toolbar ?? !headless + // without a controller from the host the viewer keeps its own: the view publishes to one either way + const ownCtl = useMemo(() => new ViewerController(), []) + const ctl = props.controller ?? ownCtl + 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 +179,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) @@ -256,6 +281,7 @@ export function DocViewer(props: DocViewerProps) { const cmOpts = props.comments || {} if (cmCtl) { cmCtl.opts = cmOpts + cmCtl.t = t cmCtl.me = cmOpts.user ?? props.collab?.user ?? { name: 'Me' } cmCtl.canComment = !readOnly && (cmOpts.canComment ?? (editing && canEdit)) } @@ -279,9 +305,12 @@ export function DocViewer(props: DocViewerProps) { cmCtl.select(root) }, [cmCtl, cmFocus, cmState.rev]) + // once per opened document, also when the host passes a new callback on every render + const onLoadRef = useRef(onLoad) + onLoadRef.current = onLoad useEffect(() => { - if (client) onLoad?.(client) - }, [client, onLoad]) + if (client) onLoadRef.current?.(client) + }, [client]) useEffect(() => { if (error) onError?.(error) }, [error, onError]) @@ -346,8 +375,8 @@ export function DocViewer(props: DocViewerProps) { // next to its source, the document is a preview: it is edited through the source const docEditable = editing && canEdit && !codeShown const api = useMemo( - () => ({ bar: barEl, editable: docEditable, setDirty: setDocDirty, save, canDownload: allowDownload, client, fileOps, selectAll }), - [barEl, docEditable, setDocDirty, save, allowDownload, client], + () => ({ bar: barEl, editable: docEditable, setDirty: setDocDirty, save, canDownload: allowDownload, client, fileOps, selectAll, ctl, headless }), + [barEl, docEditable, setDocDirty, save, allowDownload, client, ctl, headless], ) const editSource = (text: string) => { @@ -449,13 +478,13 @@ 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.`, () => { - importFile(file).then(show, (e: Error) => setToast(e.message)) + replace(t.bar.replaceOpen(file.name), () => { + importFile(file, t.file.unsupported).then(show, (e: Error) => setToast(e.message)) }), save, downloadOriginal: async () => { @@ -466,7 +495,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), } @@ -506,12 +535,31 @@ export function DocViewer(props: DocViewerProps) { const busy = loading || (!!progress && pct < 100) const title = renamed ?? client?.name ?? docName ?? (typeof src === 'string' ? decodeURIComponent(src.split(/[?#]/)[0].split('/').pop() ?? '') : (src as File | undefined)?.name) ?? '' + // ---- controller: the viewer's part of the state, and its commands ---- + const editAllowed = allowEdit && !readOnly && !codeShown + useEffect(() => { + ctl.update({ kind: client?.kind ?? null, loading: busy, editing: docEditable, canEdit: editAllowed && canEdit, dirty, saving }) + }, [ctl, client, busy, docEditable, editAllowed, canEdit, dirty, saving]) + const commands = useRef({ editAllowed, save, fileActions, cmCtl }) + commands.current = { editAllowed, save, fileActions, cmCtl } + useEffect(() => { + ctl.attachViewer({ + setEditing: (on) => { + if (!on || commands.current.editAllowed) setEditing(on) + }, + save: () => commands.current.save(), + fileAction: (id) => commands.current.fileActions.run(id), + toggleComments: (open) => commands.current.cmCtl?.toggle(open), + }) + return () => ctl.attachViewer(null) + }, [ctl]) + let body: ReactNode if (error) { body = (
- Cannot open document. + {t.bar.cannotOpen}
{error.message}
@@ -520,10 +568,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 +579,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 +599,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,31 +611,31 @@ 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) && ( - )}
)} - {toolbar && client && docEditable &&
} + {(toolbar || headless) && client && docEditable &&
}
{codeShown && (
@@ -601,13 +651,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 +670,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 +695,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 +717,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/comments/Comments.ts b/src/react/comments/Comments.ts index 060c64c..3bca2fc 100644 --- a/src/react/comments/Comments.ts +++ b/src/react/comments/Comments.ts @@ -7,6 +7,7 @@ import { createContext, useContext, useSyncExternalStore } from 'react' import { initialsOf, nowIso, newCommentId, threadsOf, type CommentAnchor, type CommentThread, type CommentUser, type DocComment } from '../../core/comments' import type { DocKind } from '../../core/opc' import type { DocumentClient } from '../../core/worker/client' +import { en, type Strings } from '../strings/en' export interface CommentOptions { /** who writes the comments made here (default: the collaboration user, else "Me") */ @@ -87,6 +88,8 @@ export class CommentsCtl { host: CommentHost | null = null opts: CommentOptions = {} me: CommentUser = { name: 'Me' } + /** texts of the notices */ + t: Strings = en canComment = false loaded = false private subs = new Set<() => void>() @@ -222,7 +225,7 @@ export class CommentsCtl { await this.client.call('comments.set', id, c) return true } catch (e) { - this.set({ notice: `Couldn't save the comment: ${(e as Error).message}` }) + this.set({ notice: this.t.comments.saveFailed((e as Error).message) }) return false } } @@ -263,7 +266,7 @@ export class CommentsCtl { const mine = () => (this.state.draft === draft ? { draft: null } : {}) if (host.attach && !(await host.attach(id, draft))) { await this.write(id, null) - this.set({ ...mine(), notice: 'The commented text is no longer there' }) + this.set({ ...mine(), notice: this.t.comments.textGone }) return false } this.set(this.state.draft === draft ? { draft: null, active: id } : {}) diff --git a/src/react/comments/CommentsPanel.tsx b/src/react/comments/CommentsPanel.tsx index 28f4238..7672471 100644 --- a/src/react/comments/CommentsPanel.tsx +++ b/src/react/comments/CommentsPanel.tsx @@ -5,6 +5,7 @@ import { ContextMenu, MOD, type MenuItem } from '../common/ContextMenu' import { ConfirmDialog } from '../common/Dialog' import { Icon } from '../edit/Toolbar' import { useCommentsState, type CommentsCtl, type PlacedThread } from './Comments' +import { useStrings, type Strings } from '../strings' const ALT = MOD === '⌘' ? '⌥' : 'Alt+' /** shortcut that starts a comment on the selection */ @@ -17,16 +18,16 @@ function colorOf(u: CommentUser): string { return PEER_COLORS[h % PEER_COLORS.length] } -function ago(iso: string, now: number): string { +function ago(iso: string, now: number, str: Strings): string { const t = Date.parse(iso) if (!Number.isFinite(t)) return '' const s = Math.max(0, (now - t) / 1000) - if (s < 45) return 'just now' - if (s < 3600) return `${Math.round(s / 60)} min ago` - if (s < 86400) return `${Math.round(s / 3600)} h ago` - if (s < 7 * 86400) return `${Math.round(s / 86400)} d ago` + if (s < 45) return str.comments.justNow + if (s < 3600) return str.comments.minutesAgo(Math.round(s / 60)) + if (s < 86400) return str.comments.hoursAgo(Math.round(s / 3600)) + if (s < 7 * 86400) return str.comments.daysAgo(Math.round(s / 86400)) const d = new Date(t) - return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', ...(d.getFullYear() !== new Date(now).getFullYear() ? { year: 'numeric' } : {}) }) + return d.toLocaleDateString(str.locale, { month: 'short', day: 'numeric', ...(d.getFullYear() !== new Date(now).getFullYear() ? { year: 'numeric' } : {}) }) } /** comment text with its mentions set apart */ @@ -80,6 +81,7 @@ export function Composer(props: { onCancel?: () => void }) { const { ctl } = props + const str = useStrings() const [text, setText] = useState(props.initial ?? '') const [mentions, setMentions] = useState(props.mentions ?? []) const [pop, setPop] = useState<{ at: number; end: number; list: CommentUser[]; hi: number } | null>(null) @@ -204,10 +206,10 @@ export function Composer(props: {
)}
- {canMention && @ to mention} + {canMention && {str.comments.mentionTip}} {props.onCancel && ( )} )} -
{ctl.canComment && !st.draft && ( - )} {st.notice && (
{st.notice} -
@@ -395,7 +401,7 @@ export function CommentsPanel({ ctl }: { ctl: CommentsCtl }) { {ctl.me.name}
- ctl.post(t, m)} onCancel={() => ctl.cancelDraft()} /> + ctl.post(t, m)} onCancel={() => ctl.cancelDraft()} />
)} {shown.map((t) => ( @@ -403,16 +409,16 @@ export function CommentsPanel({ ctl }: { ctl: CommentsCtl }) { ))} {!shown.length && !st.draft && (
- {all.length ? 'Every comment is resolved.' : ctl.canComment ? `No comments yet. ${ctl.host?.hint ?? ''}` : 'No comments.'} + {all.length ? str.comments.allResolved : ctl.canComment ? str.comments.noneYet(ctl.host?.hint ?? '') : str.comments.none}
)}
{menu && setMenu(null)} />} {confirm && ( void ctl.remove(confirm.id)} onClose={() => setConfirm(null)} /> 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 ? ( -