diff --git a/README.md b/README.md index 7bac394..45ca7a7 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,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()`, `` 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"`), `collab` / `room` / `user` / `base` (real-time collaboration; `user` also writes the comments), `comments="false"`; properties `.file = File` and `.icons` (map or resolver, as `icons` above); events `load`, `error`, `comment` (`detail`: a `CommentEvent`). ## Core API (no React) diff --git a/docs/features.md b/docs/features.md index a8cbb6e..215373e 100644 --- a/docs/features.md +++ b/docs/features.md @@ -170,11 +170,11 @@ Diagram sources open as a document holding the drawing (an SVG picture, editable ### Toolbar -Single row, buttons that don't fit collapse into `⋮`; custom-drawn dropdowns (font/style preview, number formats with examples, ↑↓/Enter/Esc/type-ahead keys, never steals focus from the document); font size box `− 11 +` accepts direct input; cell borders picked from an icon grid; color pickers have a palette plus a **Custom** row (`+` opens the system color picker, recent custom colors are remembered across sessions). +Single row, buttons that don't fit collapse into `⋮`; custom-drawn dropdowns (font/style preview, number formats with examples, ↑↓/Enter/Esc/type-ahead keys, never steals focus from the document); font size box `− 11 +` accepts direct input; cell borders picked from an icon grid; color pickers have a palette plus a **Custom** row (`+` opens the system color picker, recent custom colors are remembered across sessions). Every icon can be replaced with the host's own set (Lucide, Phosphor or any other) through the `icons` prop, see [API](api.md#icons). ### Dark mode -`theme="dark"`, or `"auto"` to follow the OS / browser setting (`prefers-color-scheme`), darkens the chrome: bars, toolbar, menus, dialogs, sheet headers and tabs, thumbnails, popovers, scrollbars. Pages, slides and cells keep the colors the document gives them, like paper in a dark PDF viewer. Everything is driven by CSS variables on `.de-root`, so a host can also tune the palette. +`theme="dark"`, or `"auto"` to follow the OS / browser setting (`prefers-color-scheme`), darkens the chrome: bars, toolbar, menus, dialogs, sheet headers and tabs, thumbnails, popovers, scrollbars. Pages, slides and cells keep the colors the document gives them, like paper in a dark PDF viewer. Everything is driven by design tokens (CSS variables on `.de-root`), which a host sets through a theme object or its own CSS: see [Theming](theming.md). ## Comments diff --git a/docs/theming.md b/docs/theming.md new file mode 100644 index 0000000..648b0ed --- /dev/null +++ b/docs/theming.md @@ -0,0 +1,90 @@ +# Theming + +The viewer's chrome (bars, toolbars, menus, dialogs, panels, sheet headers, tabs) takes every color, font, radius and +shadow from design tokens: CSS custom properties named `--de-*` on the viewer root (`.de-root`). The document itself +(pages, slides, cells) keeps its own colors in every theme. + +## The `theme` prop + +```tsx + // light, dark, or following the OS + + +``` + +`tokens` apply in both modes, `light` and `dark` go over them in their mode, and anything left out keeps the built-in +value of that mode. Names are camelCase (`accentHover` sets `--de-accent-hover`); values are any CSS value, so a token can +point at the host's own variables: `{ accent: 'var(--brand-500)' }`. `createTheme()` types a theme object written apart +from the prop, and `THEME_TOKENS` lists every token. + +`mount()` takes the same `theme` option; `` takes a mode. + +## Plain CSS + +The tokens are ordinary custom properties, so a stylesheet can set them as well: + +```css +.my-app .de-root { --de-accent: #7c3aed; --de-font: Inter, sans-serif; } +.my-app .de-root.de-dark { --de-accent: #a78bfa; } +``` + +Class names starting with `de-` are stable and can be styled further; the tokens are the supported way to change +colors, type and shape. + +## Loading the stylesheet yourself + +By default the viewer injects its stylesheet once into the document (or the shadow root in `styleTarget`). To load it +yourself, for a Content Security Policy without inline styles, a bundler that extracts CSS, or a cascade layer of your own, +turn injection off and import the file: + +```tsx +import '@p10node/office-engine/styles.css' + +``` + +```css +/* the viewer's rules below the host's own, whatever their specificity */ +@layer office-engine, app; +@import '@p10node/office-engine/styles.css' layer(office-engine); +``` + +The stylesheet is not put in a layer of its own: rules in a layer lose to every unlayered rule, so a host's element +reset (`button { … }`) would restyle the viewer's controls. Importing it into a layer is the host's choice. + +## Your own root + +The views (`DocxView`, `XlsxView`, `PptxView`) can be mounted without `DocViewer` (see [API](api.md)); `useThemeRoot()` +gives the class name and style their root needs: + +```tsx +const root = useThemeRoot(theme) +return
+``` + +## Tokens + +| Group | Tokens | +|---|---| +| Surfaces and text | `bg` (around the document), `chrome` (bars), `pop` (menus, dialogs), `ebar` (formatting toolbar), `text`, `muted`, `ink`, `inkStrong`, `inkSoft`, `border`, `sep`, `tbSep` | +| Accent and states | `accent`, `accentHover`, `accentInk` (text on the accent), `accentSoft`, `on`, `onInk`, `onInkStrong` (pressed buttons), `hover`, `hoverSolid`, `hoverSoft`, `disabled`, `danger`, `dangerBorder`, `ok` | +| Inputs and tabs | `inputBg`, `inputBorder`, `swatchBorder`, `tabHover`, `tabOn`, `tabInk` | +| Overlays | `popShadow`, `shadow`, `shadowSm`, `shadowMd`, `shadowLg`, `backdrop`, `toastBg`, `toastInk`, `tipBg`, `tipInk` | +| Type and shape | `font`, `fontMono`, `fontSize`, `radiusSm`, `radius`, `radiusLg`, `radiusXl` | +| Sheets | `grid`, `head`, `headInk`, `headBorder`, `cornerBorder`, `freeze` | +| Comments | `cmBg`, `cmOn`, `cmLine`, `cmLineOn`, `cmCard`, `cmQuote` | +| Find | `find`, `findOn` (text matches), `findCell`, `findCellOn`, `findCellLine` (cell matches) | +| Files, people, connection | `kindDocx`, `kindXlsx`, `kindPptx`, `kindInk` (badges), `avatarInk` (text on a person's color), `statusIdle`, `statusLive`, `statusBusy`, `statusError` | +| Slideshow | `presentBg`, `presentInk` | +| Paper | `page`, `pageInk`, `sheetInk`, `skeleton` (loading pages), `phBorder`, `phA`, `phB`, `phInk` (placeholders), `phOutline`, `phHint` (empty text boxes) | +| Source editor | `srcBg`, `srcGutter`, `srcSel`, `srcFont`, `srcComment`, `srcKeyword`, `srcString`, `srcOperator`, `srcNumber`, `srcHeading`, `srcAttr`, `srcTag`, `srcMeta` | + +The paper tokens are the same in both modes: a document is drawn on white paper with black ink unless it says +otherwise. Built-in values: `src/react/styles.css`, the `.de-root` and `.de-root.de-dark` blocks. diff --git a/e2e/app/main.tsx b/e2e/app/main.tsx index bb6a98a..be08f97 100644 --- a/e2e/app/main.tsx +++ b/e2e/app/main.tsx @@ -2,6 +2,7 @@ * 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 `` */ import { useState } from 'react' import { createRoot } from 'react-dom/client' @@ -46,6 +47,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 }), @@ -62,6 +64,13 @@ const initial: DocViewerProps = { open: flag('cmOpen'), onEvent: (e) => log({ type: 'comment', action: e.type, id: e.comment.id, text: e.comment.text, thread: e.thread.id, quote: e.quote, mentioned: e.mentioned.map((m) => m.id ?? m.name), participants: e.participants.map((m) => m.id ?? m.name) }), }, + icons: q.has('icons') + ? (name) => ( + + + + ) + : undefined, collab: q.get('collab') ? { transport: q.get('collab')!, room: q.get('room') ?? 'e2e', base: Number(q.get('base')) || 0, user: { name: q.get('user') ?? 'Tester' } } : undefined, } diff --git a/e2e/icons.spec.ts b/e2e/icons.spec.ts new file mode 100644 index 0000000..2b50a9c --- /dev/null +++ b/e2e/icons.spec.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'bun:test' +import { useBrowser, viewer, type Viewer } from './harness' + +useBrowser() + +/** host icons (`.de-icon`) and built-in icon svgs left in the chrome matching `sel` */ +async function icons(v: Viewer, sel: string) { + return v.page.evaluate((s) => { + const roots = [...document.querySelectorAll(s)] + const svgs = roots.flatMap((r) => [...r.querySelectorAll('svg')]) + return { + host: roots.reduce((n, r) => n + r.querySelectorAll('.de-icon > svg[data-host-icon]').length, 0), + builtIn: svgs.filter((x) => !x.closest('.de-icon')).length, + } + }, sel) +} + +const hostIcon = (v: Viewer, button: string, name: string) => v.page.locator(`${button} .de-icon > svg[data-host-icon="${name}"]`) + +describe('host icons', () => { + test('docx: zoom, outline toggle and close, toolbar, find & replace', async () => { + const v = await viewer({ src: 'samples/sample.docx', icons: true }) + expect(await hostIcon(v, 'button[aria-label="Zoom in"]', 'zoomIn').count()).toBe(1) + expect(await hostIcon(v, 'button[aria-label="Zoom out"]', 'zoomOut').count()).toBe(1) + expect(await hostIcon(v, '.de-outline-toggle', 'outline').count()).toBe(1) + await v.page.locator('.de-outline-toggle').click() + expect(await hostIcon(v, 'button[aria-label="Close outline"]', 'close').count()).toBe(1) + await v.page.locator('button[aria-label="Close outline"]').click() + + await v.edit() + const bar = await icons(v, '.de-ebar') + expect(bar.host).toBeGreaterThan(20) + expect(bar.builtIn).toBe(0) + await v.btn('Find & replace').click() + expect(await hostIcon(v, '.de-find button[aria-label="Replace"]', 'replace').count()).toBe(1) + expect((await icons(v, '.de-find')).builtIn).toBe(0) + expect(v.errors).toEqual([]) + }) + + test('xlsx: toolbar and the add-sheet tab', async () => { + const v = await viewer({ src: 'samples/sample.xlsx', editable: true, icons: true }) + await v.page.locator('.de-ebar').first().waitFor() + const bar = await icons(v, '.de-ebar') + expect(bar.host).toBeGreaterThan(20) + expect(bar.builtIn).toBe(0) + expect(await hostIcon(v, '.de-tab-add', 'sheetAdd').count()).toBe(1) + expect(await v.page.locator('.de-tab-add').getAttribute('title')).toBe('Add sheet') + expect(v.errors).toEqual([]) + }) + + test('pptx: Present, zoom, toolbar', async () => { + const v = await viewer({ src: 'samples/sample.pptx', icons: true }) + expect(await hostIcon(v, '.de-present-btn', 'present').count()).toBe(1) + expect(await v.page.locator('.de-present-btn').innerText()).toContain('Present') + expect(await hostIcon(v, 'button[aria-label="Zoom in"]', 'zoomIn').count()).toBe(1) + + // with nothing selected the PPTX toolbar shows only its slide and insert controls + await v.edit() + const bar = await icons(v, '.de-ebar') + expect(bar.host).toBeGreaterThan(5) + expect(bar.builtIn).toBe(0) + expect(v.errors).toEqual([]) + }) + + test('without icons the built-in svgs stay', async () => { + const v = await viewer({ src: 'samples/sample.docx', editable: true }) + await v.page.locator('.de-ebar').first().waitFor() + const bar = await icons(v, '.de-ebar') + expect(bar.host).toBe(0) + expect(bar.builtIn).toBeGreaterThan(20) + expect(await v.page.locator('button[aria-label="Zoom in"] > svg').count()).toBe(1) + }) +}) diff --git a/e2e/viewer.spec.ts b/e2e/viewer.spec.ts index 97f9c24..96e0c65 100644 --- a/e2e/viewer.spec.ts +++ b/e2e/viewer.spec.ts @@ -440,6 +440,35 @@ describe('viewing', () => { expect(v.errors).toEqual([]) }) + test('theme object: tokens for both modes and per mode, pages keep paper colors', async () => { + const v = await viewer({ src: 'samples/sample.docx' }) + const { page } = v + const bg = (sel: string) => page.locator(sel).first().evaluate((el) => getComputedStyle(el).backgroundColor) + const theme = { tokens: { chrome: 'rgb(1, 2, 3)', font: 'serif' }, light: { bg: 'rgb(10, 20, 30)' }, dark: { bg: 'rgb(40, 50, 60)', chrome: 'rgb(4, 5, 6)' } } + await page.evaluate((t) => window.__setProps({ theme: t }), theme) + await page.waitForFunction(() => getComputedStyle(document.querySelector('.de-bar')!).backgroundColor === 'rgb(1, 2, 3)') + expect(await bg('.de-root')).toBe('rgb(10, 20, 30)') + expect(await page.locator('.de-bar').evaluate((el) => getComputedStyle(el).fontFamily)).toBe('serif') + expect(await bg('.de-page')).toBe('rgb(255, 255, 255)') + + await page.evaluate((t) => window.__setProps({ theme: { ...t, mode: 'dark' } }), theme) + await page.waitForFunction(() => document.querySelector('.de-root')!.classList.contains('de-dark')) + expect(await bg('.de-bar')).toBe('rgb(4, 5, 6)') + expect(await bg('.de-root')).toBe('rgb(40, 50, 60)') + // a token the theme leaves alone keeps the dark default + await v.menu() + expect(await bg('.de-dd-pop')).toBe('rgb(46, 49, 54)') + await page.keyboard.press('Escape') + expect(await bg('.de-page')).toBe('rgb(255, 255, 255)') + expect(v.errors).toEqual([]) + }) + + test('injectStyles=false: no stylesheet is added', async () => { + const v = await viewer({ src: 'samples/sample.docx', injectStyles: false }, { wait: false }) + await v.page.waitForSelector('.de-root') + expect(await v.page.locator('style#office-engine-styles').count()).toBe(0) + }) + test('links: a script or data link is shown as text, not followed', async () => { // the builder refuses script links: write safe placeholders, then put the bad targets in the relationships const docx = await parasToDocx([ diff --git a/package.json b/package.json index 3519541..bd00820 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "./collab": { "types": "./dist/types/collab/index.d.ts", "import": "./dist/collab.js" - } + }, + "./styles.css": "./dist/styles.css" }, "sideEffects": [ "./src/core/default-worker.ts", @@ -41,7 +42,8 @@ ], "scripts": { "dev": "vite --config vite.playground.config.ts", - "build": "bun run clean && bun run build:lib && bun run build:embed && bun run build:worker && bun run build:types", + "build": "bun run clean && bun run build:lib && bun run build:embed && bun run build:worker && bun run build:types && bun run build:css", + "build:css": "cp src/react/styles.css dist/styles.css", "build:lib": "vite build", "build:embed": "vite build --config vite.embed.config.ts", "build:types": "tsc -p tsconfig.build.json", diff --git a/playground/main.tsx b/playground/main.tsx index 7142edc..759427e 100644 --- a/playground/main.tsx +++ b/playground/main.tsx @@ -1,6 +1,6 @@ import { StrictMode, useCallback, useEffect, useMemo, useState } from 'react' import { createRoot } from 'react-dom/client' -import { DocViewer, type DocSource, type Theme } from '../src' +import { DocViewer, type DocSource, type ThemeMode as Theme } from '../src' import type { CollabOptions } from '../src/collab' import { OPEN_ACCEPT } from '../src/react/file/importFile' diff --git a/src/embed.ts b/src/embed.ts index 4b41b0c..81318e0 100644 --- a/src/embed.ts +++ b/src/embed.ts @@ -2,6 +2,7 @@ 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' export type MountOptions = Omit @@ -37,12 +38,13 @@ export function mount(el: HTMLElement, options: MountOptions): MountedViewer { * Attributes: src, name, editable, toolbar / thumbnails / save / download / file-menu / open / capture-select-all ("false" to turn off), * theme ("light" | "dark" | "auto"); * 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`). */ 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'] private viewer?: MountedViewer private _file?: Blob | ArrayBuffer + private _icons?: Icons get file() { return this._file @@ -52,6 +54,14 @@ export class OfficeViewerElement extends HTMLElement { this.sync() } + get icons() { + return this._icons + } + set icons(v: Icons | undefined) { + this._icons = v + this.sync() + } + connectedCallback() { if (this.viewer) return const shadow = this.shadowRoot ?? this.attachShadow({ mode: 'open' }) @@ -85,6 +95,7 @@ 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, collab: this.getAttribute('collab') && this.getAttribute('room') ? { diff --git a/src/index.ts b/src/index.ts index f1fb2e3..9d018d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,8 @@ export { SlideView } from './react/pptx/Slide' export { useDocument, type DocumentState } from './react/useDocument' export { injectStyles, stylesText } from './react/injectStyles' export type { Zoom } from './react/common/ZoomControl' -export type { Theme } from './react/common/hooks' +export { THEME_TOKENS, createTheme, themeStyle, useThemeRoot, type Theme, type ThemeMode, type ThemeOptions, type ThemeToken, type ThemeTokens } from './react/theme' +export { IconsProvider, ICON_NAMES, type IconName, type Icons } from './react/edit/Toolbar' export { 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..8d1f37c 100644 --- a/src/react/DocViewer.tsx +++ b/src/react/DocViewer.tsx @@ -6,7 +6,7 @@ import { DocxView } from './docx/DocxView' import { ViewerContext, type FileOps, type ViewerApi } from './common/Slots' import { StatusContext } from './common/Status' import { ConfirmDialog, PromptDialog, Toast } from './common/Dialog' -import { download, Icon } from './edit/Toolbar' +import { download, Icon, IconsProvider, type Icons } from './edit/Toolbar' import { DetailsDialog } from './file/DetailsDialog' import { FileMenu, KIND_LABEL, useFileActions, type FileMenuItem } from './file/FileMenu' import { importFile, type Imported } from './file/importFile' @@ -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' @@ -52,8 +53,14 @@ export interface DocViewerProps { /** Where to inject the stylesheet (document or a shadow root). */ styleTarget?: Document | ShadowRoot /** - * Color scheme of the viewer chrome (bars, menus, dialogs): 'light', 'dark' or 'auto' (follows the OS / browser setting). - * The document itself keeps its own colors. Default 'light'. + * Inject the stylesheet. Default true. Set false when the page loads `@p10node/office-engine/styles.css` itself + * (a CSP without inline styles, a cascade layer of its own, a bundler that extracts CSS). + */ + injectStyles?: boolean + /** + * Color scheme of the viewer chrome (bars, menus, dialogs): 'light', 'dark' or 'auto' (follows the OS / browser setting), + * or a theme object with that mode and design token overrides (see docs/theming.md). The document itself keeps its own + * colors. Default 'light'. */ theme?: Theme onLoad?: (client: DocumentClient) => void @@ -98,6 +105,12 @@ 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 } const EXT: Record = { docx: '.docx', xlsx: '.xlsx', pptx: '.pptx' } @@ -148,7 +161,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(() => { @@ -157,7 +170,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) @@ -531,7 +547,8 @@ export function DocViewer(props: DocViewerProps) { else body = return ( -
+ +
{toolbar && (
{client && {client.info?.from ?? client.info?.converted ?? (client.info?.imported ? IMPORTED_BADGE[client.info.imported] : client.kind)}} @@ -686,5 +703,6 @@ export function DocViewer(props: DocViewerProps) { /> )}
+ ) } 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/ZoomControl.tsx b/src/react/common/ZoomControl.tsx index 219d1e2..808aa3f 100644 --- a/src/react/common/ZoomControl.tsx +++ b/src/react/common/ZoomControl.tsx @@ -1,3 +1,5 @@ +import { Icon } from '../edit/Toolbar' + export type Zoom = number | 'fit' const STEPS = [0.25, 0.33, 0.5, 0.67, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3] @@ -11,13 +13,13 @@ export function ZoomControl({ zoom, effective, onChange }: { zoom: Zoom; effecti return (
onChange(zoom === 'fit' ? 1 : 'fit')}> {zoom === 'fit' ? 'Fit' : `${Math.round(effective * 100)}%`}
) diff --git a/src/react/common/hooks.ts b/src/react/common/hooks.ts index e28af92..5b8bfae 100644 --- a/src/react/common/hooks.ts +++ b/src/react/common/hooks.ts @@ -5,13 +5,13 @@ import { isWebImage } from '../../core/opc' export const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect /** Color scheme of the viewer chrome: 'auto' follows the browser / OS setting (prefers-color-scheme). */ -export type Theme = 'light' | 'dark' | 'auto' +export type ThemeMode = 'light' | 'dark' | 'auto' const DARK_MQ = '(prefers-color-scheme: dark)' const prefersDark = () => typeof matchMedia === 'function' && matchMedia(DARK_MQ).matches /** Whether the dark theme applies for `theme`; 'auto' tracks prefers-color-scheme live. */ -export function useDarkTheme(theme: Theme = 'light'): boolean { +export function useDarkTheme(theme: ThemeMode = 'light'): boolean { const [dark, setDark] = useState(() => theme === 'auto' && prefersDark()) useEffect(() => { if (theme !== 'auto' || typeof matchMedia !== 'function') return diff --git a/src/react/docx/DocxToolbar.tsx b/src/react/docx/DocxToolbar.tsx index 54da9b9..ba88d7c 100644 --- a/src/react/docx/DocxToolbar.tsx +++ b/src/react/docx/DocxToolbar.tsx @@ -2,13 +2,13 @@ import { useEffect, useState, type CSSProperties } from 'react' import type { CanonStyle, StyleInfo } from '../../core/docx/types' import { MOD } from '../common/ContextMenu' import { EditorBar } from '../common/Slots' -import { Btn, ColorBtn, Dropdown, fontName, fontOptions, GridPicker, Icon, Menu, pickFiles, Pop, Select, Sep, SizeBox, type DropOption } from '../edit/Toolbar' +import { Btn, ColorBtn, Dropdown, fontName, fontOptions, GridPicker, Icon, Menu, pickFiles, Pop, Select, Sep, SizeBox, type DropOption, type IconName } from '../edit/Toolbar' import type { BorderPreset } from './tables' import type { DocxEditor, FormatState } from './DocxEditor' const HIGHLIGHTS = ['#ffff00', '#00ff00', '#00ffff', '#ff00ff', '#0000ff', '#ff0000', '#000080', '#008080', '#008000', '#800080', '#800000', '#808000', '#808080', '#c0c0c0', '#000000'] -const CELL_BORDERS: { k: BorderPreset; icon: string; title: string }[] = [ +const CELL_BORDERS: { k: BorderPreset; icon: IconName; title: string }[] = [ { k: 'all', icon: 'bAll', title: 'All borders' }, { k: 'inner', icon: 'bInner', title: 'Inner borders' }, { k: 'outer', icon: 'bOuter', title: 'Outer borders' }, @@ -27,7 +27,7 @@ export const CELL_LINES = [ { value: '0.5pt dotted', label: 'Dotted', hint: '·····' }, { value: '2.25pt double', label: 'Double', hint: '═══' }, ] -const VALIGN = [ +const VALIGN: DropOption[] = [ { value: 'top', label: 'Top', icon: 'vTop' }, { value: 'middle', label: 'Middle', icon: 'vMiddle' }, { value: 'bottom', label: 'Bottom', icon: 'vBottom' }, diff --git a/src/react/docx/DocxView.tsx b/src/react/docx/DocxView.tsx index eccaaad..de64edf 100644 --- a/src/react/docx/DocxView.tsx +++ b/src/react/docx/DocxView.tsx @@ -11,7 +11,7 @@ import { ZoomControl, type Zoom } from '../common/ZoomControl' import { ContextMenu, MOD, type MenuItem } from '../common/ContextMenu' import { PromptDialog, Toast, type PromptSpec } from '../common/Dialog' import { domFromPos, getSel, opInsert, posFromDom } from '../edit/input' -import { download, pickFiles } from '../edit/Toolbar' +import { download, Icon, pickFiles } from '../edit/Toolbar' import { runsText, type RRun } from '../edit/rich' import { DocxEditor, type CommentRange, type DocState, type FormatState } from './DocxEditor' import { markCss, useCommentsCtl, useCommentsState, type CommentHost, type PlacedThread } from '../comments/Comments' @@ -839,7 +839,7 @@ export function DocxView({ client, onStatus, initialZoom = 'fit' }: DocxViewProp
Outline
{headings.length ? ( @@ -886,7 +886,7 @@ export function DocxView({ client, onStatus, initialZoom = 'fit' }: DocxViewProp )} {!isEditing && done && ( )} {pageDlg && setPageDlg(false)} onOk={(set) => void editor.setPage(set).catch((e) => console.error('[office-engine] page setup failed', e))} />} diff --git a/src/react/docx/FindBar.tsx b/src/react/docx/FindBar.tsx index 090624a..b28b5a6 100644 --- a/src/react/docx/FindBar.tsx +++ b/src/react/docx/FindBar.tsx @@ -49,7 +49,7 @@ export function FindBar(props: { {props.query ? (props.count ? `${Math.min(props.index + 1, props.count)} / ${props.count}` : '0 / 0') : ''} props.onNav(-1)} disabled={!props.count} /> props.onNav(1)} disabled={!props.count} /> - {props.editable && setShowRep((v) => !v)} />} + {props.editable && setShowRep((v) => !v)} />}
{props.options &&
{props.options}
} diff --git a/src/react/edit/Toolbar.tsx b/src/react/edit/Toolbar.tsx index e49e36a..55240ec 100644 --- a/src/react/edit/Toolbar.tsx +++ b/src/react/edit/Toolbar.tsx @@ -1,9 +1,12 @@ import { Children, + createContext, Fragment, isValidElement, + useContext, useEffect, useLayoutEffect, + useMemo, useRef, useState, useSyncExternalStore, @@ -16,7 +19,7 @@ import { cssColor } from '../../core/color' const BORDER_GRID = -const P: Record = { +const P = { undo: , redo: , bold: , @@ -145,9 +148,54 @@ const P: Record = { info: , comment: , commentAdd: , + zoomIn: , + zoomOut: , + present: , + sheetAdd: , + replace: , +} satisfies Record + +/** the name of one of the viewer's icons */ +export type IconName = keyof typeof P + +/** the names of the viewer's icons, for a host's own set (``) */ +export const ICON_NAMES = Object.keys(P) as IconName[] + +/** + * A host's icons: a map by name, or a resolver called for every icon. A missing entry, or a resolver + * returning `undefined` / `null`, keeps the built-in icon. Host icons are sized by font-size (1em). + */ +export type Icons = Partial> | ((name: IconName, props: { size: number }) => ReactNode | undefined | null) + +type Resolve = (name: IconName, size: number) => ReactNode | undefined + +const IconsContext = createContext(null) + +/** the host's icon for `name`, or `undefined` for the built-in one; `false` counts as none, as a resolver written `name === 'x' && ` returns it */ +export function hostIcon(icons: Icons | undefined, name: IconName, size: number): ReactNode | undefined { + if (!icons) return undefined + const own = typeof icons === 'function' ? icons(name, { size }) : icons[name] + return own === null || own === false ? undefined : own +} + +/** + * Gives the views inside it the host's icons; DocViewer applies its `icons` prop through one. + * Nested providers fall back to the enclosing one for names they leave out. + */ +export function IconsProvider({ icons, children }: { icons?: Icons; children?: ReactNode }) { + const outer = useContext(IconsContext) + const resolve = useMemo(() => (icons ? (name, size) => hostIcon(icons, name, size) ?? outer?.(name, size) : outer), [icons, outer]) + return {children} } -export function Icon({ name, size = 18 }: { name: string; size?: number }) { +export function Icon({ name, size = 18 }: { name: IconName; size?: number }) { + const own = useContext(IconsContext)?.(name, size) + if (own !== undefined) + return ( + + {own} + + ) return ( {P[name]} @@ -191,7 +239,7 @@ export function useDismiss(ref: RefObject, open: boolean, cl }, [open, ref]) } -export function Btn(props: { icon?: string; label?: ReactNode; title: string; active?: boolean; disabled?: boolean; onClick: () => void; wide?: boolean }) { +export function Btn(props: { icon?: IconName; label?: ReactNode; title: string; active?: boolean; disabled?: boolean; onClick: () => void; wide?: boolean }) { return ( props.onInsert(v)} /> + props.onInsert(v)} /> + if (!model) return
if ('error' in model) { return (
@@ -788,7 +788,8 @@ export function PptxView({ client, onStatus, initialZoom = 'fit', thumbnails = t {!viewer.editable && ( )} diff --git a/src/react/pptx/Slide.tsx b/src/react/pptx/Slide.tsx index 233e76e..acd39ce 100644 --- a/src/react/pptx/Slide.tsx +++ b/src/react/pptx/Slide.tsx @@ -245,7 +245,7 @@ function PicView({ el, client, live }: { el: Extract; cli {el.target?.split('.').pop()?.toUpperCase()} image
) : null} - {el.video &&
▶ media
} + {el.video &&
▶ media
}
) } @@ -318,7 +318,7 @@ export const SlideView = memo(function SlideView({ model, client, scale, live = const bg = useFillCss(client, model.bg, live) return (
-
+
{model.els.map((el, i) => ( ))} diff --git a/src/react/styles.css b/src/react/styles.css index 0a07b1d..511a60c 100644 --- a/src/react/styles.css +++ b/src/react/styles.css @@ -48,6 +48,66 @@ --de-cm-line: #f29900; --de-cm-card: #ffffff; --de-cm-quote: #f29900; + --de-cm-line-on: #c26401; + /* type and shape */ + --de-font: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --de-font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --de-font-size: 13px; + --de-radius-sm: 4px; + --de-radius: 6px; + --de-radius-lg: 8px; + --de-radius-xl: 12px; + --de-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.15); + --de-shadow-md: 0 2px 8px rgba(0, 0, 0, 0.3); + --de-shadow-lg: 0 8px 28px rgba(0, 0, 0, 0.28); + /* file kind badges */ + --de-kind-docx: #2b579a; + --de-kind-xlsx: #217346; + --de-kind-pptx: #c43e1c; + --de-kind-ink: #fff; + /* white text on a person's color (avatars, cursors, comment pins) */ + --de-avatar-ink: #fff; + /* find */ + --de-find: #fde293; + --de-find-on: #ff9632; + --de-find-cell: rgba(251, 188, 4, 0.28); + --de-find-cell-on: rgba(255, 150, 50, 0.45); + --de-find-cell-line: #e37400; + /* tooltips over the document (picture size), the slideshow */ + --de-tip-bg: rgba(32, 33, 36, 0.85); + --de-tip-ink: #fff; + --de-present-bg: #000; + --de-present-ink: rgba(255, 255, 255, 0.5); + /* connection state (collaboration) */ + --de-status-idle: #9aa0a6; + --de-status-live: #1e8e3e; + --de-status-busy: #f9ab00; + --de-status-error: #d93025; + /* paper: pages, slides and sheets keep these in the dark theme too (documents keep their own colors) */ + --de-page: #fff; + --de-page-ink: #000; + --de-sheet-ink: #1d2127; + --de-skeleton: #eef0f2; + --de-ph-border: #b8bec6; + --de-ph-a: #f6f7f9; + --de-ph-b: #eff1f4; + --de-ph-ink: #6a737d; + --de-ph-outline: rgba(60, 64, 67, 0.4); + --de-ph-hint: #80868b; + /* source editor (Markdown, HTML, diagrams) */ + --de-src-bg: #ffffff; + --de-src-gutter: #f6f7f8; + --de-src-sel: color-mix(in srgb, var(--de-accent) 25%, transparent); + --de-src-font: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + --de-src-comment: #6a737d; + --de-src-keyword: #8e3bc7; + --de-src-string: #1a7f37; + --de-src-operator: #c2410c; + --de-src-number: #0b7a8a; + --de-src-heading: #0b57d0; + --de-src-attr: #0b57d0; + --de-src-tag: #b42318; + --de-src-meta: #8a5a00; color-scheme: light; position: relative; display: flex; @@ -58,7 +118,7 @@ overflow: hidden; background: var(--de-bg); color: var(--de-text); - font: 13px/1.4 system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font: var(--de-font-size)/1.4 var(--de-font); text-align: left; isolation: isolate; } @@ -116,6 +176,18 @@ --de-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 2px 10px rgba(0, 0, 0, 0.45); --de-cm-card: #2b2e33; --de-cm-quote: #fdd663; + --de-src-bg: #1b1d20; + --de-src-gutter: #202327; + --de-src-sel: color-mix(in srgb, var(--de-accent) 35%, transparent); + --de-src-comment: #8b949e; + --de-src-keyword: #d2a8ff; + --de-src-string: #7ee787; + --de-src-operator: #ffa657; + --de-src-number: #79c0ff; + --de-src-heading: #8ab4f8; + --de-src-attr: #8ab4f8; + --de-src-tag: #ff7b72; + --de-src-meta: #e3b341; color-scheme: dark; } @@ -135,16 +207,16 @@ } .de-badge { flex: none; - font: 600 10px/1 system-ui, sans-serif; + font: 600 10px/1 var(--de-font); letter-spacing: 0.06em; text-transform: uppercase; padding: 4px 6px; - border-radius: 4px; - color: #fff; + border-radius: var(--de-radius-sm); + color: var(--de-kind-ink); } -.de-badge-docx { background: #2b579a; } -.de-badge-xlsx { background: #217346; } -.de-badge-pptx { background: #c43e1c; } +.de-badge-docx { background: var(--de-kind-docx); } +.de-badge-xlsx { background: var(--de-kind-xlsx); } +.de-badge-pptx { background: var(--de-kind-pptx); } .de-title { flex: 1; min-width: 0; @@ -206,7 +278,7 @@ color: var(--de-danger); background: var(--de-pop); border: 1px solid var(--de-danger-border); - border-radius: 8px; + border-radius: var(--de-radius-lg); padding: 14px 18px; box-shadow: var(--de-shadow); } @@ -219,7 +291,7 @@ align-items: center; background: var(--de-chrome); border: 1px solid var(--de-border); - border-radius: 8px; + border-radius: var(--de-radius-lg); box-shadow: var(--de-shadow); overflow: hidden; } @@ -234,6 +306,9 @@ cursor: pointer; } .de-zoom button { + display: inline-flex; + align-items: center; + justify-content: center; width: 32px; height: 30px; font-size: 16px; @@ -250,14 +325,14 @@ } .de-btn { padding: 4px 8px; - border-radius: 6px; + border-radius: var(--de-radius); } /* ---------------------------------------------------------------- docx */ .de-pages { position: relative; } .de-page { position: absolute; - background: #fff; + background: var(--de-page, #fff); box-shadow: var(--de-shadow); overflow: hidden; } @@ -267,7 +342,7 @@ } .de-page-skeleton { position: absolute; - background: #fff; + background: var(--de-page); box-shadow: var(--de-shadow); opacity: 0.6; } @@ -275,12 +350,12 @@ content: ''; position: absolute; inset: 12% 12%; - background: repeating-linear-gradient(#eef0f2 0 10px, transparent 10px 24px); + background: repeating-linear-gradient(var(--de-skeleton) 0 10px, transparent 10px 24px); animation: de-pulse 1.4s ease-in-out infinite; } @keyframes de-pulse { 50% { opacity: 0.45; } } .de-doc { - color: #000; + color: var(--de-page-ink, #000); font-kerning: normal; text-rendering: optimizeLegibility; } @@ -316,8 +391,8 @@ .de-tbl-wrap { position: relative; } .de-tbl-wrap > .de-tbl { margin: 0 !important; } .de-col-resize { position: absolute; top: 0; bottom: 0; width: 6px; cursor: col-resize; z-index: 2; touch-action: none; } -.de-col-resize:hover { background: rgba(26, 115, 232, 0.35); } -.de-tbl td.de-cell-sel { box-shadow: inset 0 0 0 9999px rgba(26, 115, 232, 0.16); } +.de-col-resize:hover { background: color-mix(in srgb, var(--de-accent) 35%, transparent); } +.de-tbl td.de-cell-sel { box-shadow: inset 0 0 0 9999px color-mix(in srgb, var(--de-accent) 16%, transparent); } .de-tbl td { vertical-align: top; overflow-wrap: break-word; @@ -329,10 +404,10 @@ display: inline-flex; align-items: center; justify-content: center; - border: 1px dashed #b8bec6; - background: repeating-linear-gradient(45deg, #f6f7f9 0 8px, #eff1f4 8px 16px); - color: #6a737d; - font: 12px/1.2 system-ui, sans-serif; + border: 1px dashed var(--de-ph-border, #b8bec6); + background: repeating-linear-gradient(45deg, var(--de-ph-a, #f6f7f9) 0 8px, var(--de-ph-b, #eff1f4) 8px 16px); + color: var(--de-ph-ink, #6a737d); + font: 12px/1.2 var(--de-font, sans-serif); text-indent: 0; vertical-align: bottom; overflow: hidden; @@ -359,7 +434,7 @@ flex: none; background: var(--de-chrome); border-bottom: 1px solid var(--de-border); - font: 12px/30px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font: 12px/30px var(--de-font-mono); } .de-fx-ref { width: 88px; @@ -382,8 +457,8 @@ min-height: 0; overflow: auto; /* the sheet keeps its own colors in the dark theme */ - background: #fff; - color: #1d2127; + background: var(--de-page); + color: var(--de-sheet-ink); outline: none; } .de-grid-view { @@ -423,7 +498,7 @@ justify-content: center; background: var(--de-head); color: var(--de-head-ink); - font: 11px/1 system-ui, sans-serif; + font: 11px/1 var(--de-font); font-variant-numeric: tabular-nums; border-right: 1px solid var(--de-head-border); border-bottom: 1px solid var(--de-head-border); @@ -509,7 +584,7 @@ .de-slide { position: relative; overflow: hidden; - background: #fff; + background: var(--de-page, #fff); } .de-slide-frame { position: absolute; @@ -521,7 +596,7 @@ top: 0; transform-origin: 0 0; overflow: hidden; - color: #000; + color: var(--de-page-ink, #000); } .de-el { position: absolute; } .de-txt { @@ -536,11 +611,11 @@ } .de-txt.de-nowrap > div { white-space: pre; } /* empty placeholders while editing (new slides, blank presentations) */ -.de-slide-editable .de-el > .de-txt:empty { outline: 1.5px dashed rgba(60, 64, 67, 0.4); outline-offset: -1px; } +.de-slide-editable .de-el > .de-txt:empty { outline: 1.5px dashed var(--de-ph-outline); outline-offset: -1px; } .de-slide-editable .de-el > .de-txt:empty::after { content: 'Click to add text'; - color: #80868b; - font: 28px/1.2 system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif; + color: var(--de-ph-hint); + font: 28px/1.2 var(--de-font); text-align: center; pointer-events: none; } @@ -565,7 +640,7 @@ height: 28px; padding: 0 10px; border: 1px solid var(--de-border); - border-radius: 6px; + border-radius: var(--de-radius); background: var(--de-chrome); color: inherit; font: inherit; @@ -612,6 +687,18 @@ padding: 6px; background: var(--de-ebar); } +/* a host's icon (`icons` prop / IconsProvider), sized by font-size */ +.de-icon { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + line-height: 1; +} +.de-icon > svg { + width: 1em; + height: 1em; +} .de-tb { appearance: none; position: relative; @@ -622,10 +709,10 @@ height: 28px; padding: 0; border: 0; - border-radius: 4px; + border-radius: var(--de-radius-sm); background: transparent; color: var(--de-ink); - font: 600 14px/1 system-ui, sans-serif; + font: 600 14px/1 var(--de-font); cursor: pointer; transition: background-color 0.1s; } @@ -643,7 +730,7 @@ bottom: 3px; height: 3px; border-radius: 1px; - box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12); + box-shadow: inset 0 0 0 1px var(--de-swatch-border); } .de-tb-pop { position: absolute; @@ -652,7 +739,7 @@ z-index: 30; padding: 10px; background: var(--de-pop); - border-radius: 8px; + border-radius: var(--de-radius-lg); box-shadow: var(--de-pop-shadow); } .de-tb-none { @@ -660,7 +747,7 @@ margin-bottom: 8px; padding: 5px; border: 1px solid var(--de-border); - border-radius: 4px; + border-radius: var(--de-radius-sm); background: var(--de-pop); color: inherit; font: inherit; @@ -677,16 +764,16 @@ height: 28px; padding: 0 2px 0 6px; border: 0; - border-radius: 4px; + border-radius: var(--de-radius-sm); background: transparent; color: var(--de-ink-strong); - font: 13px/1 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font: 13px/1 var(--de-font); cursor: pointer; transition: background-color 0.1s; } .de-dd:hover:not(:disabled), .de-dd-open { background: var(--de-hover); } .de-dd:disabled { opacity: 0.38; cursor: default; } -.de-dd > svg { flex: none; color: var(--de-ink); } +.de-dd > svg, .de-dd > .de-icon { flex: none; color: var(--de-ink); } .de-dd-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; } .de-dd-ph { color: var(--de-muted); } .de-dd-pop { @@ -699,10 +786,10 @@ overflow-y: auto; padding: 6px 0; background: var(--de-pop); - border-radius: 8px; + border-radius: var(--de-radius-lg); box-shadow: var(--de-pop-shadow); color: var(--de-ink-strong); - font: 13px/1.3 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font: 13px/1.3 var(--de-font); } .de-dd-opt { display: flex; @@ -714,7 +801,7 @@ cursor: pointer; user-select: none; } -.de-dd-opt > svg { flex: none; color: var(--de-ink); } +.de-dd-opt > svg, .de-dd-opt > .de-icon { flex: none; color: var(--de-ink); } .de-dd-hi { background: var(--de-hover-solid); } .de-dd-dis { opacity: 0.4; cursor: default; } .de-dd-check { display: inline-flex; width: 16px; flex: none; color: var(--de-on-ink); } @@ -740,10 +827,10 @@ height: 26px; padding: 0; border: 1px solid var(--de-input-border); - border-radius: 4px; + border-radius: var(--de-radius-sm); background: transparent; color: var(--de-ink-strong); - font: 13px/1 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font: 13px/1 var(--de-font); text-align: center; outline: none; } @@ -792,7 +879,7 @@ opacity: 0; pointer-events: none; } -.de-editing { outline: none; caret-color: #000; } +.de-editing { outline: none; caret-color: var(--de-page-ink); } .de-editing .de-page-inner { cursor: text; } .de-editing [contenteditable='false'] { cursor: default; } .de-find { @@ -806,7 +893,7 @@ padding: 6px; background: var(--de-pop); border: 1px solid var(--de-border); - border-radius: 8px; + border-radius: var(--de-radius-lg); box-shadow: var(--de-shadow); } .de-find-row { display: flex; align-items: center; gap: 2px; } @@ -815,7 +902,7 @@ height: 28px; padding: 0 8px; border: 1px solid var(--de-border); - border-radius: 6px; + border-radius: var(--de-radius); background: var(--de-input-bg); color: var(--de-text); font: inherit; @@ -823,13 +910,13 @@ } .de-find input:focus { border-color: var(--de-accent); } .de-find-count { min-width: 56px; text-align: center; color: var(--de-muted); font-size: 12px; font-variant-numeric: tabular-nums; } -::highlight(de-find) { background-color: #fde293; } -::highlight(de-find-cur) { background-color: #ff9632; } -::highlight(de-cm-draft) { background-color: rgba(251, 188, 4, 0.6); } +::highlight(de-find) { background-color: var(--de-find); } +::highlight(de-find-cur) { background-color: var(--de-find-on); } +::highlight(de-cm-draft) { background-color: var(--de-cm-on); } .de-range { position: absolute; - background: rgba(26, 115, 232, 0.1); - border: 1px solid rgba(26, 115, 232, 0.6); + background: color-mix(in srgb, var(--de-accent) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--de-accent) 60%, transparent); pointer-events: none; z-index: 2; } @@ -842,8 +929,8 @@ outline: none; resize: none; overflow: hidden; - background: #fff; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + background: var(--de-page); + box-shadow: var(--de-shadow-sm); line-height: 1.2; white-space: pre-wrap; } @@ -851,18 +938,18 @@ .de-fx-input:focus { background: var(--de-input-bg); box-shadow: inset 0 -2px 0 var(--de-accent); } .de-tb-hint { margin-left: 8px; color: var(--de-muted); font-size: 12px; } .de-slide-editable [data-sid] { cursor: text; } -.de-slide-editable [data-sid]:hover { outline: 1px dashed rgba(26, 115, 232, 0.6); } -.de-txt-edit { outline: 2px solid var(--de-accent); outline-offset: 0; caret-color: #000; cursor: text; z-index: 2; } +.de-slide-editable [data-sid]:hover { outline: 1px dashed color-mix(in srgb, var(--de-accent) 60%, transparent); } +.de-txt-edit { outline: 2px solid var(--de-accent); outline-offset: 0; caret-color: var(--de-page-ink); cursor: text; z-index: 2; } .de-gridpick { display: grid; grid-template-columns: repeat(8, 16px); gap: 3px; } .de-gridpick button { width: 16px; height: 16px; padding: 0; border: 1px solid var(--de-border); border-radius: 2px; background: var(--de-pop); cursor: pointer; } .de-gridpick button.on { background: var(--de-on); border-color: var(--de-accent); } .de-gridpick-label { margin-top: 6px; text-align: center; color: var(--de-muted); font-size: 12px; } .de-prompt { display: flex; gap: 6px; align-items: center; } -.de-prompt input { width: 240px; height: 28px; padding: 0 8px; border: 1px solid var(--de-border); border-radius: 6px; background: var(--de-input-bg); color: var(--de-text); font: inherit; outline: none; } +.de-prompt input { width: 240px; height: 28px; padding: 0 8px; border: 1px solid var(--de-border); border-radius: var(--de-radius); background: var(--de-input-bg); color: var(--de-text); font: inherit; outline: none; } .de-prompt input:focus { border-color: var(--de-accent); } .de-prompt .de-btn { border: 1px solid var(--de-border); background: var(--de-pop); } .de-shape-sel { position: absolute; border: 1.5px solid var(--de-accent); pointer-events: none; z-index: 5; transform-origin: 50% 50%; } -.de-handle { position: absolute; background: #fff; border: 1px solid var(--de-accent); border-radius: 2px; pointer-events: auto; } +.de-handle { position: absolute; background: var(--de-page); border: 1px solid var(--de-accent); border-radius: 2px; pointer-events: auto; } .de-h-nw, .de-h-se { cursor: nwse-resize; } .de-h-ne, .de-h-sw { cursor: nesw-resize; } .de-h-n, .de-h-s { cursor: ns-resize; } @@ -876,12 +963,12 @@ display: flex; align-items: center; justify-content: center; - background: #000; + background: var(--de-present-bg); outline: none; cursor: none; } .de-present .de-slide { box-shadow: none; } -.de-present-n { position: absolute; right: 16px; bottom: 12px; color: rgba(255, 255, 255, 0.5); font: 12px system-ui, sans-serif; } +.de-present-n { position: absolute; right: 16px; bottom: 12px; color: var(--de-present-ink); font: 12px var(--de-font); } .de-present-btn { position: absolute; right: 16px; @@ -890,7 +977,7 @@ } .de-grip-c { position: absolute; top: 0; width: 6px; cursor: col-resize; z-index: 3; } .de-grip-r { position: absolute; left: 0; height: 6px; cursor: row-resize; z-index: 3; } -.de-grip-c:hover, .de-grip-r:hover { background: rgba(26, 115, 232, 0.35); } +.de-grip-c:hover, .de-grip-r:hover { background: color-mix(in srgb, var(--de-accent) 35%, transparent); } .de-ctx { position: fixed; z-index: 50; @@ -898,9 +985,9 @@ max-width: 340px; padding: 6px 0; background: var(--de-pop); - border-radius: 8px; + border-radius: var(--de-radius-lg); box-shadow: var(--de-pop-shadow); - font: 13px/1.3 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font: 13px/1.3 var(--de-font); color: var(--de-ink-strong); user-select: none; } @@ -929,16 +1016,16 @@ .de-ctx-sub { min-width: 200px; max-height: calc(100vh - 8px); overflow-y: auto; } .de-ctx-sep { height: 1px; margin: 6px 0; background: var(--de-sep); } .de-ctx-head { padding: 6px 16px 2px 38px; color: var(--de-muted); font-size: 11px; font-weight: 600; letter-spacing: 0.03em; text-transform: uppercase; } -.de-tab-add { font-size: 18px; padding: 0 10px; } -.de-tab-input { width: 120px; margin: 4px 2px; padding: 0 6px; border: 1px solid var(--de-accent); border-radius: 4px; background: var(--de-input-bg); color: var(--de-text); font: inherit; } +.de-tab-add { display: inline-flex; align-items: center; padding: 0 10px; } +.de-tab-input { width: 120px; margin: 4px 2px; padding: 0 6px; border: 1px solid var(--de-accent); border-radius: var(--de-radius-sm); background: var(--de-input-bg); color: var(--de-text); font: inherit; } /* formula editing: reference boxes, colored references, autocomplete */ .de-fx { position: relative; } .de-fref { position: absolute; border: 2px solid; pointer-events: none; z-index: 2; } .de-fref-pt { border-style: dashed; } -.de-cell-hl { z-index: 4; pointer-events: none; color: #202124; overflow: hidden; overflow-wrap: break-word; } +.de-cell-hl { z-index: 4; pointer-events: none; color: var(--de-sheet-ink); overflow: hidden; overflow-wrap: break-word; } .de-cell-edit { overflow-wrap: break-word; } -.de-cell-edit-fx { background: transparent; color: transparent; caret-color: #202124; box-shadow: none; } -.de-cell-edit-fx::selection { color: transparent; background: rgba(26, 115, 232, 0.25); } +.de-cell-edit-fx { background: transparent; color: transparent; caret-color: var(--de-sheet-ink); box-shadow: none; } +.de-cell-edit-fx::selection { color: transparent; background: color-mix(in srgb, var(--de-accent) 25%, transparent); } .de-fassist-at { position: absolute; z-index: 30; } .de-fassist { min-width: 240px; @@ -946,9 +1033,9 @@ padding: 4px 0; background: var(--de-pop); border: 1px solid var(--de-border); - border-radius: 6px; + border-radius: var(--de-radius); box-shadow: var(--de-pop-shadow); - font: 12px/1.45 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font: 12px/1.45 var(--de-font); color: var(--de-text); text-align: left; white-space: normal; @@ -958,8 +1045,8 @@ .de-fsug-name { font-weight: 600; } .de-fsug-args { margin-left: 2px; color: var(--de-muted); } .de-fsug-desc { margin-top: 2px; color: var(--de-muted); font-size: 11.5px; white-space: normal; } -.de-fhint { padding: 6px 10px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } -.de-fhint .de-fsug-desc { font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; } +.de-fhint { padding: 6px 10px; font-family: var(--de-font-mono); } +.de-fhint .de-fsug-desc { font-family: var(--de-font); } .de-fhint b { color: var(--de-accent); } /* docx: picture selection, outline, dialogs */ @@ -969,7 +1056,7 @@ width: 9px; height: 9px; margin: -5px 0 0 -5px; - background: #fff; + background: var(--de-page); border: 1.5px solid var(--de-accent); border-radius: 2px; pointer-events: auto; @@ -988,10 +1075,10 @@ bottom: -28px; transform: translateX(-50%); padding: 2px 6px; - border-radius: 4px; - background: rgba(32, 33, 36, 0.85); - color: #fff; - font: 11px/1.4 system-ui, sans-serif; + border-radius: var(--de-radius-sm); + background: var(--de-tip-bg); + color: var(--de-tip-ink); + font: 11px/1.4 var(--de-font); white-space: nowrap; } .de-outline { @@ -1008,7 +1095,7 @@ background: var(--de-pop); border-radius: 10px; box-shadow: var(--de-pop-shadow); - font: 13px/1.35 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font: 13px/1.35 var(--de-font); } .de-outline-head { flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; padding: 4px 4px 6px 10px; font-weight: 600; color: var(--de-ink-strong); } .de-outline-item { @@ -1017,7 +1104,7 @@ width: 100%; padding: 5px 10px; border: 0; - border-radius: 6px; + border-radius: var(--de-radius); background: transparent; color: var(--de-ink-soft); font: inherit; @@ -1035,10 +1122,13 @@ top: 12px; left: 12px; z-index: 6; + display: inline-flex; + align-items: center; + justify-content: center; width: 34px; height: 34px; border: 1px solid var(--de-border); - border-radius: 8px; + border-radius: var(--de-radius-lg); background: var(--de-pop); color: inherit; box-shadow: var(--de-shadow); @@ -1052,9 +1142,9 @@ height: 26px; padding: 0; border: 0; - border-radius: 4px; + border-radius: var(--de-radius-sm); background: transparent; - font: 15px/1 system-ui, sans-serif; + font: 15px/1 var(--de-font); color: var(--de-ink-strong); cursor: pointer; } @@ -1076,9 +1166,9 @@ width: min(420px, calc(100vw - 32px)); padding: 20px; background: var(--de-pop); - border-radius: 12px; - box-shadow: 0 8px 28px rgba(0, 0, 0, 0.28); - font: 14px/1.4 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + border-radius: var(--de-radius-xl); + box-shadow: var(--de-shadow-lg); + font: 14px/1.4 var(--de-font); color: var(--de-ink-strong); } .de-dialog-title { font-size: 16px; font-weight: 600; } @@ -1087,7 +1177,7 @@ width: 100%; padding: 8px 10px; border: 1px solid var(--de-input-border); - border-radius: 6px; + border-radius: var(--de-radius); background: var(--de-input-bg); color: var(--de-text); font: inherit; @@ -1096,29 +1186,29 @@ } .de-dialog input:focus, .de-dialog textarea:focus { border-color: var(--de-accent); box-shadow: inset 0 0 0 1px var(--de-accent); } .de-hf-zone { position: absolute; left: 0; right: 0; z-index: 1; cursor: default; } -.de-hf-zone:hover { background: rgba(26, 115, 232, 0.05); } +.de-hf-zone:hover { background: color-mix(in srgb, var(--de-accent) 5%, transparent); } .de-hfd { width: 460px; } .de-hfd textarea { min-height: 70px; } .de-hfd-tabs { display: flex; gap: 4px; flex-wrap: wrap; } .de-hfd-tabs button { padding: 5px 10px; border: 1px solid var(--de-border); border-radius: 14px; background: var(--de-pop); color: var(--de-text); font: inherit; font-size: 12.5px; cursor: pointer; } .de-hfd-tabs button.on { background: var(--de-accent); border-color: var(--de-accent); color: var(--de-accent-ink); } .de-ps { width: 380px; max-width: calc(100vw - 32px); gap: 10px; } -.de-ps-note { font-size: 12px; color: var(--de-muted, #5f6368); margin-top: -4px; } +.de-ps-note { font-size: 12px; color: var(--de-muted); margin-top: -4px; } .de-ps-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } .de-ps-head { font-size: 12px; font-weight: 600; display: flex; align-items: center; gap: 10px; min-width: 72px; } .de-ps-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 16px; } .de-ps-field { display: flex; align-items: center; gap: 6px; font-size: 13px; } .de-ps-field > span { min-width: 48px; } .de-ps-field input { width: 64px; } -.de-ps-field em { font-style: normal; color: var(--de-muted, #5f6368); font-size: 12px; } +.de-ps-field em { font-style: normal; color: var(--de-muted); font-size: 12px; } .de-ps-wide { flex: 1; } .de-ps-wide select { flex: 1; } .de-ps-radio { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; cursor: pointer; } .de-ps-radio input { width: auto; padding: 0; margin: 0; } .de-ps-preset { font-weight: 400; font-size: 12px; } -.de-ps select, .de-hfd select { padding: 6px 8px; border: 1px solid var(--de-input-border); border-radius: 6px; background: var(--de-input-bg); color: var(--de-text); font: inherit; } +.de-ps select, .de-hfd select { padding: 6px 8px; border: 1px solid var(--de-input-border); border-radius: var(--de-radius); background: var(--de-input-bg); color: var(--de-text); font: inherit; } .de-page-cols > * { break-inside: avoid; } -.de-ps-bad { color: #c5221f; } +.de-ps-bad { color: var(--de-danger); } .de-dialog-btns .de-btn:disabled { opacity: 0.5; cursor: default; } .de-dialog-btns { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; } .de-dialog-btns .de-btn { padding: 7px 14px; border: 1px solid var(--de-border); border-radius: 18px; background: var(--de-pop); } @@ -1136,11 +1226,11 @@ z-index: 60; transform: translateX(-50%); padding: 10px 16px; - border-radius: 6px; + border-radius: var(--de-radius); background: var(--de-toast-bg); color: var(--de-toast-ink); - font: 13px/1.4 system-ui, sans-serif; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + font: 13px/1.4 var(--de-font); + box-shadow: var(--de-shadow-md); } /* xlsx: find, borders popover */ @@ -1148,8 +1238,8 @@ .de-xlsx .de-find { top: 40px; } .de-find-opts { gap: 10px; padding: 2px 4px; color: var(--de-ink-soft); font-size: 12px; } .de-find-opt { display: inline-flex; align-items: center; gap: 4px; cursor: pointer; user-select: none; } -.de-findcell { position: absolute; z-index: 1; background: rgba(251, 188, 4, 0.28); pointer-events: none; } -.de-findcell.on { background: rgba(255, 150, 50, 0.45); box-shadow: inset 0 0 0 1.5px #e37400; } +.de-findcell { position: absolute; z-index: 1; background: var(--de-find-cell); pointer-events: none; } +.de-findcell.on { background: var(--de-find-cell-on); box-shadow: inset 0 0 0 1.5px var(--de-find-cell-line); } .de-borders-pop { display: flex; gap: 8px; align-items: flex-start; } .de-borders-side { display: flex; flex-direction: column; gap: 4px; padding-left: 8px; border-left: 1px solid var(--de-sep); } .de-find .de-find-opt input { width: auto; height: auto; margin: 0; padding: 0; accent-color: var(--de-accent); } @@ -1157,21 +1247,21 @@ /* ---------------------------------------------------------------- collaboration */ .de-collab { display: inline-flex; align-items: center; gap: 8px; flex: none; } -.de-collab-status { display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 9px; border-radius: 12px; font-size: 12px; color: var(--de-muted); background: var(--de-head); white-space: nowrap; } -.de-collab-dot { width: 7px; height: 7px; border-radius: 50%; background: #9aa0a6; flex: none; } -.de-collab-live .de-collab-dot { background: #1e8e3e; box-shadow: 0 0 0 3px rgba(30, 142, 62, 0.18); } +.de-collab-status { display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 9px; border-radius: var(--de-radius-xl); font-size: 12px; color: var(--de-muted); background: var(--de-head); white-space: nowrap; } +.de-collab-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--de-status-idle); flex: none; } +.de-collab-live .de-collab-dot { background: var(--de-status-live); box-shadow: 0 0 0 3px color-mix(in srgb, var(--de-status-live) 18%, transparent); } .de-collab-live { color: var(--de-ok); } .de-collab-connecting .de-collab-dot, -.de-collab-syncing .de-collab-dot { background: #f9ab00; animation: de-collab-pulse 1s ease-in-out infinite alternate; } -.de-collab-offline .de-collab-dot { background: #9aa0a6; } +.de-collab-syncing .de-collab-dot { background: var(--de-status-busy); animation: de-collab-pulse 1s ease-in-out infinite alternate; } +.de-collab-offline .de-collab-dot { background: var(--de-status-idle); } .de-collab-error { color: var(--de-danger); } -.de-collab-error .de-collab-dot { background: #d93025; } +.de-collab-error .de-collab-dot { background: var(--de-status-error); } @keyframes de-collab-pulse { to { opacity: 0.35; } } @media (max-width: 640px) { .de-collab-label { display: none; } } .de-peers { display: inline-flex; align-items: center; } .de-peer { display: inline-grid; place-items: center; width: 26px; height: 26px; margin-left: -6px; border-radius: 50%; - border: 2px solid var(--de-chrome); color: #fff; font-size: 11px; font-weight: 600; letter-spacing: 0.02em; cursor: default; user-select: none; + border: 2px solid var(--de-chrome); color: var(--de-avatar-ink); font-size: 11px; font-weight: 600; letter-spacing: 0.02em; cursor: default; user-select: none; } .de-peer:first-child { margin-left: 0; } .de-peer-more { background: var(--de-muted); } @@ -1180,15 +1270,15 @@ .de-peer-caret::before { content: ''; position: absolute; top: -3px; left: -2px; width: 6px; height: 6px; border-radius: 50%; background: var(--peer); } .de-peer-flag { position: absolute; bottom: 100%; left: -1px; margin-bottom: 2px; padding: 1px 6px; border-radius: 4px 4px 4px 0; background: var(--peer); - color: #fff; font: 600 11px/16px system-ui, -apple-system, 'Segoe UI', sans-serif; white-space: nowrap; + color: var(--de-avatar-ink); font: 600 11px/16px var(--de-font); white-space: nowrap; } .de-peer-sel { position: absolute; background: var(--peer); opacity: 0.18; border-radius: 1px; } .de-peer-outline { position: absolute; border: 2px solid var(--peer); border-radius: 3px; } .de-peer-outline > .de-peer-flag { left: -2px; margin-bottom: 1px; } .de-peer-range { position: absolute; border: 2px solid; pointer-events: none; z-index: 2; } .de-peer-tag { - position: absolute; top: -17px; left: -2px; padding: 0 5px; border-radius: 3px 3px 3px 0; color: #fff; - font: 600 10px/15px system-ui, -apple-system, 'Segoe UI', sans-serif; white-space: nowrap; + position: absolute; top: -17px; left: -2px; padding: 0 5px; border-radius: 3px 3px 3px 0; color: var(--de-avatar-ink); + font: 600 10px/15px var(--de-font); white-space: nowrap; } /* ---------------------------------------------------------------- comments */ @@ -1201,7 +1291,7 @@ min-height: 0; background: var(--de-chrome); border-left: 1px solid var(--de-border); - font: 13px/1.4 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font: 13px/1.4 var(--de-font); color: var(--de-text); position: relative; z-index: 7; @@ -1211,17 +1301,17 @@ } .de-cm-bar { display: flex; align-items: center; gap: 6px; padding: 8px 8px 8px 14px; border-bottom: 1px solid var(--de-border); } .de-cm-title { flex: 1; display: flex; align-items: center; gap: 6px; font-weight: 600; color: var(--de-ink-strong); } -.de-cm-badge { min-width: 18px; padding: 1px 6px; border-radius: 9px; background: var(--de-accent-soft); color: var(--de-accent); font: 600 11px/16px system-ui, sans-serif; text-align: center; } +.de-cm-badge { min-width: 18px; padding: 1px 6px; border-radius: 9px; background: var(--de-accent-soft); color: var(--de-accent); font: 600 11px/16px var(--de-font); text-align: center; } .de-cm-filter { font-size: 12px; color: var(--de-muted); } .de-cm-filter.on { color: var(--de-accent); } .de-cm-list { flex: 1; min-height: 0; overflow-y: auto; padding: 10px; display: flex; flex-direction: column; gap: 10px; } .de-cm-new { display: flex; align-items: center; gap: 8px; width: 100%; padding: 8px 10px; - border: 1px dashed var(--de-border); border-radius: 8px; background: transparent; color: var(--de-ink); font: inherit; cursor: pointer; text-align: left; + border: 1px dashed var(--de-border); border-radius: var(--de-radius-lg); background: transparent; color: var(--de-ink); font: inherit; cursor: pointer; text-align: left; } .de-cm-new:hover { background: var(--de-hover); border-color: var(--de-accent); color: var(--de-accent); } .de-cm-keys { margin-left: auto; color: var(--de-muted); font-size: 11px; } -.de-cm-notice { display: flex; align-items: center; gap: 6px; padding: 6px 6px 6px 10px; border-radius: 8px; background: var(--de-hover-soft); color: var(--de-ink-soft); font-size: 12px; } +.de-cm-notice { display: flex; align-items: center; gap: 6px; padding: 6px 6px 6px 10px; border-radius: var(--de-radius-lg); background: var(--de-hover-soft); color: var(--de-ink-soft); font-size: 12px; } .de-cm-notice span { flex: 1; } .de-cm-card { flex: none; @@ -1247,7 +1337,7 @@ .de-cm-head { display: flex; align-items: center; gap: 8px; } .de-cm-ava { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 50%; - color: #fff; font: 600 11px/1 system-ui, sans-serif; text-transform: uppercase; + color: var(--de-avatar-ink); font: 600 11px/1 var(--de-font); text-transform: uppercase; } .de-cm-who { flex: 1; min-width: 0; display: flex; flex-direction: column; } .de-cm-name { font-weight: 600; color: var(--de-ink-strong); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -1262,7 +1352,7 @@ .de-cm-item .de-cm-compose { margin-left: 36px; } .de-cm-compose textarea { display: block; width: 100%; min-height: 34px; max-height: 200px; padding: 7px 10px; resize: none; - border: 1px solid var(--de-border); border-radius: 8px; background: var(--de-input-bg); color: var(--de-text); font: inherit; line-height: 1.4; outline: none; + border: 1px solid var(--de-border); border-radius: var(--de-radius-lg); background: var(--de-input-bg); color: var(--de-text); font: inherit; line-height: 1.4; outline: none; } .de-cm-compose textarea:focus { border-color: var(--de-accent); box-shadow: 0 0 0 1px var(--de-accent); } .de-cm-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; margin-top: 6px; } @@ -1273,21 +1363,21 @@ .de-cm-actions .de-btn:disabled { opacity: 0.5; cursor: default; } .de-cm-pop { position: absolute; left: 0; right: 0; top: calc(100% - 32px); z-index: 30; max-height: 220px; overflow-y: auto; padding: 4px; - background: var(--de-pop); border-radius: 8px; box-shadow: var(--de-pop-shadow); + background: var(--de-pop); border-radius: var(--de-radius-lg); box-shadow: var(--de-pop-shadow); } -.de-cm-pop button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 5px 8px; border: 0; border-radius: 6px; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; } +.de-cm-pop button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 5px 8px; border: 0; border-radius: var(--de-radius); background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; } .de-cm-pop button.on, .de-cm-pop button:hover { background: var(--de-hover-soft); } .de-cm-pop .de-cm-ava { width: 22px; height: 22px; font-size: 10px; } .de-cm-empty { padding: 24px 8px; color: var(--de-muted); text-align: center; } -.de-cm-toggle .de-cm-n { min-width: 16px; padding: 0 5px; border-radius: 8px; background: var(--de-accent); color: var(--de-accent-ink); font: 600 10px/16px system-ui, sans-serif; } +.de-cm-toggle .de-cm-n { min-width: 16px; padding: 0 5px; border-radius: var(--de-radius-lg); background: var(--de-accent); color: var(--de-accent-ink); font: 600 10px/16px var(--de-font); } /* XLSX: a cell with a comment */ .de-cm-mark { position: absolute; z-index: 4; width: 0; height: 0; border-style: solid; border-width: 0 8px 8px 0; border-color: transparent var(--de-cm-line) transparent transparent; pointer-events: none; } -.de-cm-mark.on { border-right-color: #c26401; } +.de-cm-mark.on { border-right-color: var(--de-cm-line-on); } /* PPTX: a comment on a slide */ .de-cm-pin { position: absolute; z-index: 4; display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; margin: -26px 0 0 0; padding: 0; - border: 2px solid #fff; border-radius: 13px 13px 13px 2px; background: var(--de-cm-line); color: #fff; font: 600 11px/1 system-ui, sans-serif; cursor: pointer; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35); + border: 2px solid var(--de-avatar-ink); border-radius: 13px 13px 13px 2px; background: var(--de-cm-line); color: var(--de-avatar-ink); font: 600 11px/1 var(--de-font); cursor: pointer; + box-shadow: var(--de-shadow-md); } .de-cm-pin.on { background: var(--de-accent); transform: scale(1.12); } @@ -1303,10 +1393,6 @@ .de-split-handle:hover, .de-split-handle:focus-visible { background: var(--de-accent); outline: none; } .de-body-stacked .de-split-handle { width: auto; height: 6px; cursor: row-resize; } .de-src { - --de-src-bg: #ffffff; - --de-src-gutter: #f6f7f8; - --de-src-sel: rgba(26, 115, 232, 0.25); - --de-src-font: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; flex: 1; min-width: 0; min-height: 0; @@ -1314,11 +1400,10 @@ flex-direction: column; color: var(--de-text); } -.de-dark .de-src { --de-src-bg: #1b1d20; --de-src-gutter: #202327; --de-src-sel: rgba(102, 157, 246, 0.35); } .de-src-head { flex: none; display: flex; align-items: center; gap: 10px; height: 36px; padding: 0 6px 0 12px; border-bottom: 1px solid var(--de-border); font-size: 12px; } .de-src-title { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; } .de-src-lines { color: var(--de-muted); } -.de-src-btn { display: inline-grid; place-items: center; width: 28px; height: 28px; border: 0; border-radius: 6px; background: none; color: var(--de-ink); cursor: pointer; } +.de-src-btn { display: inline-grid; place-items: center; width: 28px; height: 28px; border: 0; border-radius: var(--de-radius); background: none; color: var(--de-ink); cursor: pointer; } .de-dark .de-src-btn { color: var(--de-text); } .de-src-btn:hover { background: var(--de-hover); } .de-src-notice { flex: none; padding: 6px 12px; font-size: 12px; line-height: 1.4; color: var(--de-muted); background: var(--de-head); border-bottom: 1px solid var(--de-border); } @@ -1356,22 +1441,14 @@ caret-color: var(--de-text); } .de-src-input::selection { background: var(--de-src-sel); } -.de-tk-c { color: #6a737d; font-style: italic; } -.de-tk-k { color: #8e3bc7; } -.de-tk-s { color: #1a7f37; } -.de-tk-o { color: #c2410c; } -.de-tk-n { color: #0b7a8a; } -.de-tk-h { color: #0b57d0; font-weight: 600; } -.de-tk-a { color: #0b57d0; } -.de-tk-t { color: #b42318; } -.de-tk-m { color: #8a5a00; } +.de-tk-c { color: var(--de-src-comment); font-style: italic; } +.de-tk-k { color: var(--de-src-keyword); } +.de-tk-s { color: var(--de-src-string); } +.de-tk-o { color: var(--de-src-operator); } +.de-tk-n { color: var(--de-src-number); } +.de-tk-h { color: var(--de-src-heading); font-weight: 600; } +.de-tk-a { color: var(--de-src-attr); } +.de-tk-t { color: var(--de-src-tag); } +.de-tk-m { color: var(--de-src-meta); } .de-tk-b { font-weight: 700; } .de-tk-i { font-style: italic; } -.de-dark .de-tk-c { color: #8b949e; } -.de-dark .de-tk-k { color: #d2a8ff; } -.de-dark .de-tk-s { color: #7ee787; } -.de-dark .de-tk-o { color: #ffa657; } -.de-dark .de-tk-n { color: #79c0ff; } -.de-dark .de-tk-h, .de-dark .de-tk-a { color: #8ab4f8; } -.de-dark .de-tk-t { color: #ff7b72; } -.de-dark .de-tk-m { color: #e3b341; } diff --git a/src/react/theme.ts b/src/react/theme.ts new file mode 100644 index 0000000..edf07ff --- /dev/null +++ b/src/react/theme.ts @@ -0,0 +1,81 @@ +import type { CSSProperties } from 'react' +import { useDarkTheme, type ThemeMode } from './common/hooks' + +export type { ThemeMode } + +/** + * Every design token of the viewer, as named in the stylesheet without its `--de-` prefix. The stylesheet defines each + * on `.de-root` (light) and the colors again on `.de-root.de-dark`; `test/theme.test.ts` keeps this list and the + * stylesheet in step. + */ +export const THEME_TOKENS = [ + // chrome + 'bg', 'chrome', 'border', 'text', 'muted', 'accent', 'accent-hover', 'accent-ink', 'accent-soft', + 'on', 'on-ink', 'on-ink-strong', 'ink', 'ink-strong', 'ink-soft', 'hover', 'hover-solid', 'hover-soft', + 'ebar', 'tb-sep', 'pop', 'pop-shadow', 'sep', 'input-bg', 'input-border', 'swatch-border', 'disabled', + 'danger', 'danger-border', 'ok', 'tab-hover', 'tab-on', 'tab-ink', 'toast-bg', 'toast-ink', 'backdrop', 'shadow', + // sheet headers and grid + 'grid', 'head', 'head-ink', 'head-border', 'corner-border', 'freeze', + // comments + 'cm-bg', 'cm-on', 'cm-line', 'cm-line-on', 'cm-card', 'cm-quote', + // type and shape + 'font', 'font-mono', 'font-size', 'radius-sm', 'radius', 'radius-lg', 'radius-xl', 'shadow-sm', 'shadow-md', 'shadow-lg', + // file kind badges, people's colors, find, tooltips, slideshow, connection state + 'kind-docx', 'kind-xlsx', 'kind-pptx', 'kind-ink', 'avatar-ink', + 'find', 'find-on', 'find-cell', 'find-cell-on', 'find-cell-line', + 'tip-bg', 'tip-ink', 'present-bg', 'present-ink', + 'status-idle', 'status-live', 'status-busy', 'status-error', + // paper: pages, slides and sheets (the same in both modes, as documents keep their own colors) + 'page', 'page-ink', 'sheet-ink', 'skeleton', 'ph-border', 'ph-a', 'ph-b', 'ph-ink', 'ph-outline', 'ph-hint', + // source editor (Markdown, HTML, diagrams) + 'src-bg', 'src-gutter', 'src-sel', 'src-font', 'src-comment', 'src-keyword', 'src-string', 'src-operator', + 'src-number', 'src-heading', 'src-attr', 'src-tag', 'src-meta', +] as const + +export type ThemeToken = (typeof THEME_TOKENS)[number] + +type Camel = S extends `${infer H}-${infer T}` ? `${H}${Capitalize>}` : S + +/** Token values by camelCase name (`accentHover` for `--de-accent-hover`); any CSS value, `var()` included. */ +export type ThemeTokens = { [K in ThemeToken as Camel]?: string } + +export interface ThemeOptions { + /** Light, dark, or following the OS. Default 'light'. */ + mode?: ThemeMode + /** Tokens for both modes: type, shape, a brand accent that works on both. */ + tokens?: ThemeTokens + /** Tokens for the light mode only, over `tokens`. */ + light?: ThemeTokens + /** Tokens for the dark mode only, over `tokens`. */ + dark?: ThemeTokens +} + +/** The `theme` prop: a mode, or a mode with token overrides. */ +export type Theme = ThemeMode | ThemeOptions + +/** Types a theme object (no runtime work: for editor completion where the object is written apart from the prop). */ +export const createTheme = (theme: ThemeOptions): ThemeOptions => theme + +const kebab = (name: string) => name.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()) + +export const themeMode = (theme: Theme | undefined): ThemeMode => (typeof theme === 'object' ? (theme.mode ?? 'light') : (theme ?? 'light')) + +/** The custom properties a theme sets on the viewer root in the given mode. */ +export function themeStyle(theme: Theme | undefined, dark: boolean): CSSProperties { + if (!theme || typeof theme !== 'object') return {} + const out: Record = {} + for (const set of [theme.tokens, dark ? theme.dark : theme.light]) { + if (!set) continue + for (const [k, v] of Object.entries(set)) if (v != null && v !== '') out['--de-' + kebab(k)] = v + } + return out as CSSProperties +} + +/** + * The class name and style of a viewer root for `theme`: what `DocViewer` puts on its root element, for hosts that + * mount the views (`DocxView`, `XlsxView`, `PptxView`) in a root of their own. + */ +export function useThemeRoot(theme?: Theme, style?: CSSProperties): { className: string; style: CSSProperties; dark: boolean } { + const dark = useDarkTheme(themeMode(theme)) + return { className: dark ? 'de-root de-dark' : 'de-root', style: { ...themeStyle(theme, dark), ...style }, dark } +} diff --git a/src/react/xlsx/XlsxToolbar.tsx b/src/react/xlsx/XlsxToolbar.tsx index 6c6c9e9..145f968 100644 --- a/src/react/xlsx/XlsxToolbar.tsx +++ b/src/react/xlsx/XlsxToolbar.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import type { BorderKind, FmtPatch } from '../../core/xlsx/types' import { MOD } from '../common/ContextMenu' import { EditorBar } from '../common/Slots' -import { Btn, ColorBtn, Dropdown, fontName, fontOptions, Icon, Menu, Pop, Select, Sep, SizeBox } from '../edit/Toolbar' +import { Btn, ColorBtn, Dropdown, fontName, fontOptions, Icon, Menu, Pop, Select, Sep, SizeBox, type DropOption, type IconName } from '../edit/Toolbar' const FORMATS = [ { value: 'General', label: 'Automatic', hint: '1000.12' }, @@ -28,7 +28,7 @@ const FORMATS = [ { value: '[h]:mm:ss', label: 'Duration', hint: '24:01:00' }, ] const SIZES = [6, 7, 8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 36, 48] -const BORDERS: { k: BorderKind; icon: string; title: string }[] = [ +const BORDERS: { k: BorderKind; icon: IconName; title: string }[] = [ { k: 'all', icon: 'bAll', title: 'All borders' }, { k: 'inner', icon: 'bInner', title: 'Inner borders' }, { k: 'outer', icon: 'bOuter', title: 'Outer borders' }, @@ -63,7 +63,7 @@ const ROTATE = [ { value: 255, label: 'Stack vertically', icon: 'rotStack' }, { value: 90, label: 'Rotate up', icon: 'rotUp90' }, { value: -90, label: 'Rotate down', icon: 'rotDown90' }, -] +] satisfies DropOption[] const FUNCS = ['SUM', 'AVERAGE', 'COUNT', 'MAX', 'MIN'] export type MergeMode = 'all' | 'across' | 'down' | 'unmerge' diff --git a/src/react/xlsx/XlsxView.tsx b/src/react/xlsx/XlsxView.tsx index 7cc6335..957cb56 100644 --- a/src/react/xlsx/XlsxView.tsx +++ b/src/react/xlsx/XlsxView.tsx @@ -14,6 +14,7 @@ import { ContextMenu, MOD, type MenuItem } from '../common/ContextMenu' import { Toast } from '../common/Dialog' import { FindBar } from '../docx/FindBar' import { XlsxToolbar, type MergeMode } from './XlsxToolbar' +import { Icon } from '../edit/Toolbar' import { Axis } from './axis' import { fitNumber, fontPx } from './fit' import { sheetPrintable } from './print' @@ -374,7 +375,7 @@ export function XlsxView({ client, onStatus, initialSheet }: XlsxViewProps) {
{viewer.editable && ( )} {meta.sheets.filter((s) => !s.hidden).map((s) => diff --git a/test/icons.test.tsx b/test/icons.test.tsx new file mode 100644 index 0000000..9e61931 --- /dev/null +++ b/test/icons.test.tsx @@ -0,0 +1,75 @@ +import { describe, expect, test } from 'bun:test' +import { renderToStaticMarkup } from 'react-dom/server' +import { hostIcon, Icon, ICON_NAMES, IconsProvider, type IconName, type Icons } from '../src/react/edit/Toolbar' + +const html = (icons: Icons | undefined, name: IconName, size?: number) => + renderToStaticMarkup( + + + , + ) + +describe('icons', () => { + test('names exist for zoom, outline, Present, add sheet and Replace', () => { + for (const n of ['zoomIn', 'zoomOut', 'outline', 'present', 'sheetAdd', 'replace', 'close'] as const) expect(ICON_NAMES).toContain(n) + }) + + test('built-in svg without host icons', () => { + const out = html(undefined, 'bold') + expect(out).toStartWith(' { + const icons = { bold: B } + expect(html(icons, 'bold', 20)).toBe('') + expect(html(icons, 'italic')).toStartWith(' { + const seen: [IconName, number][] = [] + const icons: Icons = (name, { size }) => { + seen.push([name, size]) + if (name === 'undo') return {size} + if (name === 'redo') return null + if (name === 'zoomIn') return false + return undefined + } + expect(html(icons, 'undo', 16)).toContain('16') + expect(html(icons, 'redo')).toStartWith(' { + expect(hostIcon({ bold: '' }, 'bold', 18)).toBe('') + expect(hostIcon(() => 0, 'bold', 18)).toBe(0) + }) + + test('nested providers: inner first, then the enclosing one, then built-in', () => { + const out = (name: IconName) => + renderToStaticMarkup( + outer}> + inner }}> + + + , + ) + expect(out('bold')).toContain('inner') + expect(out('italic')).toContain('outer') + const none = renderToStaticMarkup( + outer }}> + + + + , + ) + expect(none).toStartWith(' { + const at = css.indexOf(`${selector} {\n`) + return css.slice(at, css.indexOf('\n}', at)) +} +const defined = (text: string) => [...text.matchAll(/^\s*--de-([a-z0-9-]+):/gm)].map((m) => m[1]) +const COLOR = /#[0-9a-f]{3,8}\b|rgba?\(|hsla?\(/i + +describe('theme tokens', () => { + test('THEME_TOKENS lists exactly the tokens the stylesheet defines on .de-root', () => { + expect([...defined(block('.de-root'))].sort()).toEqual([...THEME_TOKENS].sort()) + }) + + test('the dark theme redefines only known tokens', () => { + const dark = defined(block('.de-root.de-dark')) + expect(dark.length).toBeGreaterThan(40) + for (const t of dark) expect(THEME_TOKENS as readonly string[]).toContain(t) + }) + + test('every token the stylesheet uses is defined', () => { + const used = new Set([...css.matchAll(/var\(--de-([a-z0-9-]+)/g)].map((m) => m[1])) + const known = new Set(THEME_TOKENS) + expect([...used].filter((t) => !known.has(t))).toEqual([]) + }) + + test('no literal color outside the token definitions and var() fallbacks', () => { + const stray = css + .split('\n') + .map((line, i) => ({ line: line.replace(/var\(--de-[a-z0-9-]+,[^)]*\)?\)/g, ''), n: i + 1 })) + .filter(({ line }) => COLOR.test(line) && !/^\s*--de-[a-z0-9-]+:/.test(line)) + expect(stray).toEqual([]) + }) +}) + +describe('themeStyle', () => { + const theme = { + mode: 'auto' as const, + tokens: { accent: '#7c3aed', font: 'Inter, sans-serif', radiusLg: '4px' }, + light: { bg: '#fafafa' }, + dark: { bg: '#111', accent: '#a78bfa' }, + } + + test('maps camelCase names to the custom properties', () => { + expect(themeStyle(theme, false)).toEqual({ '--de-accent': '#7c3aed', '--de-font': 'Inter, sans-serif', '--de-radius-lg': '4px', '--de-bg': '#fafafa' } as never) + }) + + test('the mode set goes over the shared tokens', () => { + expect(themeStyle(theme, true)).toEqual({ '--de-accent': '#a78bfa', '--de-font': 'Inter, sans-serif', '--de-radius-lg': '4px', '--de-bg': '#111' } as never) + }) + + test('a plain mode sets no properties', () => { + expect(themeStyle('dark', true)).toEqual({}) + expect(themeMode('dark')).toBe('dark') + expect(themeMode(theme)).toBe('auto') + expect(themeMode({ tokens: {} })).toBe('light') + expect(themeMode(undefined)).toBe('light') + }) +})