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
3 changes: 2 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,7 @@ Also online at [p10node.github.io/office-engine](https://p10node.github.io/offic
- **File menu**: new, open (also DOC / XLS / PPT / RTF / OpenDocument / Pages / Keynote / Numbers / CSV / text / Markdown / HTML / SVG / diagrams), import CSV, rename, download as PDF / HTML / text / CSV, document details, print
- **Comments**: threads with replies, resolve, edit / delete, @mentions; on DOCX text, XLSX cells and PPTX slides, stored in the file the way Word, Excel and PowerPoint store them
- **Real-time collaboration**: live edits, avatars, collaborators' cursors, offline edits sync on reconnect
- **Dark mode**: `theme="dark"` or `"auto"` (follows the OS); documents keep their own colors
- **Dark mode and theming**: `theme="dark"` or `"auto"` (follows the OS), or a theme object with design tokens for colors, type and shape ([Theming](docs/theming.md)); documents keep their own colors
- **Embed anywhere**: React component, hook, vanilla `mount()`, `<office-viewer>` web component, single `<script>` file

Full list, format coverage and limitations: [Features](docs/features.md).
Expand Down
7 changes: 4 additions & 3 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ PDF goes through the browser print dialog ("Save as PDF"), so text stays selecta
/>
```

CSS is auto-injected, no import needed. Container must have a height.
CSS is auto-injected, no import needed (or import `@p10node/office-engine/styles.css` with `injectStyles={false}`). Container must have a height. Colors, type and shape: [Theming](theming.md).

| Prop | Type | Default | Notes |
|--------------------|------------------------------------------------|-----------|--------------------------------------------------------------------------------------------------------------------------|
Expand All @@ -60,7 +60,8 @@ CSS is auto-injected, no import needed. Container must have a height.
| `credentials` | `RequestCredentials` | – | Cross-origin cookies |
| `worker` | `() => Worker \| false` | inline | Custom worker (CSP), `false` = main thread |
| `styleTarget` | `Document \| ShadowRoot` | document | Where CSS is injected |
| `theme` | `'light' \| 'dark' \| 'auto'` | `'light'` | Color scheme of the chrome; `'auto'` follows `prefers-color-scheme`. Documents keep their own colors |
| `injectStyles` | `boolean` | `true` | Inject the stylesheet; false when the page loads `@p10node/office-engine/styles.css` itself |
| `theme` | `'light' \| 'dark' \| 'auto' \| ThemeOptions` | `'light'` | Color scheme of the chrome; `'auto'` follows `prefers-color-scheme`; an object adds token overrides ([Theming](theming.md)). Documents keep their own colors |
| `editable` | `boolean` | `false` | Open in edit mode (Pages, Keynote and Numbers files stay view only) |
| `allowEdit` | `boolean` | `true` | Show Edit/View button |
| `allowSave` | `boolean` | `true` | Show Save/Download button, enable Ctrl+S |
Expand Down Expand Up @@ -126,7 +127,7 @@ const { client, error, progress } = useDocument(src)
if (client?.kind === 'xlsx') return <div className="de-root"><XlsxView client={client} /></div>
```

The views style themselves from CSS variables on `.de-root` (`--de-bg`, `--de-chrome`, `--de-accent`…): override them to match your app. Add `de-dark` to the root element for the dark theme.
The views style themselves from design tokens on their root: `useThemeRoot(theme)` gives its class name and style (see [Theming](theming.md)).

## Without a React build

Expand Down
2 changes: 1 addition & 1 deletion docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ Single row, buttons that don't fit collapse into `⋮`; custom-drawn dropdowns (

### 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
90 changes: 90 additions & 0 deletions docs/theming.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Theming

The viewer's chrome (bars, toolbars, menus, dialogs, panels, sheet headers, tabs) takes every color, font, radius and
shadow from design tokens: CSS custom properties named `--de-*` on the viewer root (`.de-root`). The document itself
(pages, slides, cells) keeps its own colors in every theme.

## The `theme` prop

```tsx
<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 <div className={root.className} style={root.style}><XlsxView client={client} /></div>
```

## 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.
1 change: 1 addition & 0 deletions e2e/app/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const initial: DocViewerProps = {
thumbnails: flag('thumbnails'),
captureSelectAll: flag('captureSelectAll'),
theme: (q.get('theme') as DocViewerProps['theme']) ?? undefined,
injectStyles: flag('injectStyles'),
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 Down
29 changes: 29 additions & 0 deletions e2e/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion playground/main.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ 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 { DocumentClient, setDefaultWorker, type OpenOptions } from './core/worker/client'
export type { DocSource } from './core/source'
Expand Down
22 changes: 16 additions & 6 deletions src/react/DocViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ 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'
Expand Down Expand Up @@ -52,8 +53,14 @@ export interface DocViewerProps {
/** Where to inject the stylesheet (document or a shadow root). */
styleTarget?: Document | ShadowRoot
/**
* Color scheme of the viewer chrome (bars, menus, dialogs): 'light', 'dark' or 'auto' (follows the OS / browser setting).
* The document itself keeps its own colors. Default 'light'.
* Inject the stylesheet. Default true. Set false when the page loads `@p10node/office-engine/styles.css` itself
* (a CSP without inline styles, a cascade layer of its own, a bundler that extracts CSS).
*/
injectStyles?: boolean
/**
* Color scheme of the viewer chrome (bars, menus, dialogs): 'light', 'dark' or 'auto' (follows the OS / browser setting),
* or a theme object with that mode and design token overrides (see docs/theming.md). The document itself keeps its own
* colors. Default 'light'.
*/
theme?: Theme
onLoad?: (client: DocumentClient) => void
Expand Down Expand Up @@ -148,7 +155,7 @@ function fmtBytes(n: number) {

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)
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(() => {
Expand All @@ -157,7 +164,10 @@ export function DocViewer(props: DocViewerProps) {
const [dirty, setDirtyState] = useState(false)
const [saving, setSaving] = useState(false)
const [barEl, setBarEl] = useState<HTMLDivElement | null>(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<Imported | null>(null)
const [renamed, setRenamed] = useState<string | null>(null)
Expand Down Expand Up @@ -531,7 +541,7 @@ export function DocViewer(props: DocViewerProps) {
else body = <PptxView client={client} initialZoom={zoom} thumbnails={thumbnails} />

return (
<div ref={rootRef} className={'de-root' + (dark ? ' de-dark' : '') + (className ? ` ${className}` : '')} style={style} onKeyDown={onKeyDown}>
<div ref={rootRef} className={root.className + (className ? ` ${className}` : '')} style={root.style} onKeyDown={onKeyDown}>
{toolbar && (
<div className="de-bar">
{client && <span className={`de-badge de-badge-${client.kind}`}>{client.info?.from ?? client.info?.converted ?? (client.info?.imported ? IMPORTED_BADGE[client.info.imported] : client.kind)}</span>}
Expand Down
4 changes: 2 additions & 2 deletions src/react/common/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import { isWebImage } from '../../core/opc'
export const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect

/** Color scheme of the viewer chrome: 'auto' follows the browser / OS setting (prefers-color-scheme). */
export type Theme = 'light' | 'dark' | 'auto'
export type ThemeMode = 'light' | 'dark' | 'auto'

const DARK_MQ = '(prefers-color-scheme: dark)'
const prefersDark = () => typeof matchMedia === 'function' && matchMedia(DARK_MQ).matches

/** Whether the dark theme applies for `theme`; 'auto' tracks prefers-color-scheme live. */
export function useDarkTheme(theme: Theme = 'light'): boolean {
export function useDarkTheme(theme: ThemeMode = 'light'): boolean {
const [dark, setDark] = useState(() => theme === 'auto' && prefersDark())
useEffect(() => {
if (theme !== 'auto' || typeof matchMedia !== 'function') return
Expand Down
2 changes: 1 addition & 1 deletion src/react/pptx/PptxView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export function useSlide(client: DocumentClient, index: number): SlideModel | {
export function SlideSlot(props: { client: DocumentClient; index: number; meta: PresentationMeta; scale: number; live: boolean; editing?: SlideEditing; overlay?: ReactNode }) {
const { client, index, meta, scale, live, editing, overlay } = props
const model = useSlide(client, index)
if (!model) return <div className="de-slide" style={{ width: meta.w * scale, height: meta.h * scale, background: '#fff' }} />
if (!model) return <div className="de-slide" style={{ width: meta.w * scale, height: meta.h * scale, background: 'var(--de-page, #fff)' }} />
if ('error' in model) {
return (
<div className="de-slide de-ph" style={{ width: meta.w * scale, height: meta.h * scale, display: 'flex' }}>
Expand Down
Loading