Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`, `<office-viewer>`, 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

Expand All @@ -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()`, `<office-viewer>` web component, single `<script>` file

Full list, format coverage and limitations: [Features](docs/features.md).
Expand Down
184 changes: 179 additions & 5 deletions docs/api.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
134 changes: 134 additions & 0 deletions docs/theming.md
Original file line number Diff line number Diff line change
@@ -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
<DocViewer src={file} theme="auto" /> // light, dark, or following the OS

<DocViewer
src={file}
theme={{
mode: 'auto',
tokens: { accent: '#7c3aed', accentHover: '#6d28d9', font: 'Inter, sans-serif', radius: '4px' }, // both modes
light: { bg: '#f4f4f5', chrome: '#ffffff' },
dark: { bg: '#18181b', chrome: '#27272a', accent: '#a78bfa' },
}}
/>
```

`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; `<office-viewer theme="dark">` 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'
<DocViewer src={file} injectStyles={false} />
```

```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 icons={icons} components={components} locale="de">
<div className={root.className} style={root.style}><XlsxView client={client} /></div>
</OfficeUIProvider>
)
```

`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
<DocViewer
src={file}
editable
components={{
Button: ({ title, icon, label, children, active, expanded, disabled, onClick, onMouseDown }) => (
<MyIconButton tooltip={title} aria-label={title} pressed={active} aria-expanded={expanded}
disabled={disabled} onClick={onClick} onMouseDown={onMouseDown}>
{icon}{label}{children}
</MyIconButton>
),
Separator: () => <MyDivider vertical />,
}}
/>
```

| 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.
66 changes: 65 additions & 1 deletion e2e/app/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<svg data-host-icon={name}>`
* 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<DocViewerProps>) => 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)
Expand All @@ -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<UIComponents> = {
Button: (p) => (
<button type="button" data-host-button="" data-active={p.active || undefined} title={p.title} aria-label={p.title} aria-expanded={p.expanded} disabled={p.disabled} onMouseDown={p.onMouseDown} onClick={p.onClick}>
{p.icon}
{p.label}
{p.children}
</button>
),
Separator: () => <i data-host-sep="" />,
Dialog: (p) => {
const actions = p.actions.map((a, i) => (
<button key={i} type={a.submit ? 'submit' : 'button'} data-primary={a.primary || undefined} autoFocus={a.autoFocus} onClick={a.onClick}>
{a.label}
</button>
))
const body = (
<>
<h2>{p.title}</h2>
{p.children}
{actions}
</>
)
return (
<div data-host-dialog="" role={p.kind === 'alert' ? 'alertdialog' : 'dialog'} aria-label={p.title} onKeyDown={(e) => e.key === 'Escape' && p.onClose()}>
{p.onSubmit ? (
<form
onSubmit={(e) => {
e.preventDefault()
p.onSubmit!()
}}
>
{body}
</form>
) : (
body
)}
</div>
)
},
Toast: ({ text }) => <output data-host-toast="">{text}</output>,
}

const initial: DocViewerProps = {
src: q.get('src'),
name: q.get('name') ?? undefined,
Expand All @@ -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 }),
Expand All @@ -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) => (
<svg data-host-icon={name} viewBox="0 0 24 24">
<circle cx={12} cy={12} r={8} fill="currentColor" />
</svg>
)
: 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,
}

Expand Down
60 changes: 60 additions & 0 deletions e2e/components.spec.ts
Original file line number Diff line number Diff line change
@@ -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([])
})
})
Loading