@openpresentation/opf-editor 0.28.0 → 0.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -6
- package/dist/canvas.d.ts +13 -1
- package/dist/canvas.js +39 -9
- package/dist/chart-options-panel.js +16 -16
- package/dist/codemirror-guard.js +69 -0
- package/dist/content-controls.js +127 -74
- package/dist/data-grid.js +15 -7
- package/dist/design-controls.d.ts +13 -0
- package/dist/design-controls.js +134 -124
- package/dist/exact-source.d.ts +30 -0
- package/dist/font-gate.js +20 -1
- package/dist/json-editor.js +8 -2
- package/dist/json-options.d.ts +2 -1
- package/dist/json-options.js +7 -6
- package/dist/layout-templates.d.ts +8 -3
- package/dist/layout-templates.js +30 -16
- package/dist/numbering-panel.js +2 -3
- package/dist/react.d.ts +12 -4
- package/dist/react.js +39 -5
- package/dist/slide-adapter.d.ts +49 -0
- package/dist/slide-adapter.js +289 -0
- package/dist/slide-edits.d.ts +46 -0
- package/dist/slide-edits.js +629 -0
- package/dist/slide-manager.js +2 -1
- package/dist/slide-scene.d.ts +96 -0
- package/dist/slide-scene.js +413 -0
- package/dist/source-checker.js +45 -6
- package/dist/source-completions.js +6 -4
- package/dist/source-pane.d.ts +18 -6
- package/dist/source-pane.js +45 -0
- package/dist/source-worker.js +8 -4
- package/dist/studio-canvas.d.ts +34 -0
- package/dist/studio-canvas.js +586 -0
- package/dist/studio-commands.d.ts +15 -0
- package/dist/studio-commands.js +155 -0
- package/dist/studio-design.d.ts +16 -0
- package/dist/studio-design.js +354 -0
- package/dist/studio-kit/dom.d.ts +16 -0
- package/dist/studio-kit/dom.js +42 -6
- package/dist/studio-layers.d.ts +12 -0
- package/dist/studio-layers.js +218 -0
- package/dist/studio.d.ts +31 -0
- package/dist/studio.js +1608 -0
- package/dist/styles.css +1546 -117
- package/dist/thumbnail-cache.d.ts +2 -0
- package/dist/thumbnail-cache.js +10 -1
- package/dist/workspace-measure.js +6 -4
- package/dist/workspace-parts.js +20 -0
- package/dist/workspace-shell.js +25 -15
- package/dist/workspace-theme.js +3 -0
- package/dist/workspace.d.ts +97 -9
- package/dist/workspace.js +325 -59
- package/package.json +29 -18
package/README.md
CHANGED
|
@@ -32,8 +32,22 @@ import { OpfEditor, useOpfDocument } from "@openpresentation/opf-editor/react";
|
|
|
32
32
|
const document = useOpfDocument(editor); // also useOpfSelection(editor), useOpfCursor(editor), useOpfEditor(options)
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
+
A source-only host (`layout: "source"`, or `slots` with only `source`) loads no renderer: the slide parts and the renderer are one chunk the entry point imports on demand, only for a layout that shows slides (measured with esbuild: 1,914 KiB minified up front for a source-only host, against 3,086 KiB before; the bare source pane is 1,728 KiB). `editor.setSourceText(text, { format })` loads raw text (a file as read, a draft that does not parse) into the pane without converting it, and `@openpresentation/opf-editor/exact-source` exports the writer that puts an edited document back into its JSON text with the author's bytes (`updateJsonSource`, `createSourceMemory`).
|
|
36
|
+
|
|
35
37
|
[docs/embedding.md](docs/embedding.md) lists the layouts, `panels`, `slots` (your own arrangement), every option, the handle, the events and the React bindings; [docs/theming.md](docs/theming.md) is the token contract (surface, text, accent, border, radius and font, light and dark) and the `theme` option. `examples/playground.js` is a thin user of it.
|
|
36
38
|
|
|
39
|
+
## The Studio (CE-20)
|
|
40
|
+
|
|
41
|
+
`createOpfEditor(element, { layout: "studio" })` is the Presentation and Slide Studio, a Figma Auto Layout-style editor over the same parts: a filmstrip over a layers tree, the slide in a zoomable viewport with selection, drop lines and track dividers, a Design panel bound to the selection, Insert by kind and + Slide by layout, the source as a split synchronized both ways, Present, a context menu, a shortcut sheet (`?`) and one undo history. Slides stay auto-layout: no free positioning. `layout: "playground"` arranges the same editor for first contact, and `editor.setLayout("studio" | "playground")` switches in place.
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
const editor = createOpfEditor(element, { layout: "studio", document, catalogs: [gallery], fonts, studio: { hostPanels: [{ id: "agent", label: "Agent", mount(el) {} }] } });
|
|
45
|
+
editor.selection; // [{ key, path, idPath, kind }]
|
|
46
|
+
editor.commands.run("studio.group");
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
React: `<OpfEditor layout="studio" hostPanels={[{ id, label, render }]} />`, `useOpfSelection(editor)` and `useOpfCommands(editor)`. [docs/studio.md](docs/studio.md) has the shell, the keyboard map, the API and the known limits; `examples/studio.html` (built by `pnpm run build:playground`) tries it.
|
|
50
|
+
|
|
37
51
|
## Install the editor and optional export dependencies (RR-63)
|
|
38
52
|
|
|
39
53
|
`@openpresentation/opf-render` is an optional peer of the editor, and from render 0.16 its heavy pieces are optional peers too, so install only what you use. The editor itself draws with `@openpresentation/opf-render/svg` and loads fonts with `/fonts-browser`, which need nothing else. Add:
|
|
@@ -142,11 +156,11 @@ const pane = createSourcePane(container, {
|
|
|
142
156
|
pane.setFormat('yaml'); pane.focusPath('/slides/1/title'); pane.getText(); pane.destroy();
|
|
143
157
|
```
|
|
144
158
|
|
|
145
|
-
Methods: `replaceDocument(document, { source?, format?, name?, meta? })` (below), `setFindings(list | null)`, `setFormat(format)`, `focusPath(pointer)` (moves the cursor to the line of a JSON Pointer in any format, found with core's `locate`), `getText()`, `getCursor()` and the `onCursor` option (the path under the caret), `revealPath(pointer | range, { focus?, select?, align?, flash?, part? })` (scrolls without moving the caret or the focus), `setDecorations(list, { group? })`, `clearDecorations(group?)` and `getDecorations(group?)` (a host's own marks, by path or range; see "The source pane's hooks" in [docs/embedding.md](docs/embedding.md)), `refresh()`, `setName`, `setDensity`, `setReadOnly` and `destroy()`. A finding is `{ path, message, severity, line? }` (core `Finding` objects fit). Rows show the path in mono and the line at the right; a click moves the cursor and calls `onFindingSelect`.
|
|
159
|
+
Methods: `replaceDocument(document, { source?, format?, name?, meta? })` (below), `setSourceText(text, { format?, name? })` (raw text as the whole source, without converting it: true when it applied, false when it stays a draft with its problems), `setFindings(list | null)`, `setFormat(format)`, `focusPath(pointer)` (moves the cursor to the line of a JSON Pointer in any format, found with core's `locate`), `getText()`, `getCursor()` and the `onCursor` option (the path under the caret), `revealPath(pointer | range, { focus?, select?, align?, flash?, part? })` (scrolls without moving the caret or the focus), `setDecorations(list, { group? })`, `clearDecorations(group?)` and `getDecorations(group?)` (a host's own marks, by path or range; see "The source pane's hooks" in [docs/embedding.md](docs/embedding.md)), `refresh()`, `setName`, `setDensity`, `setReadOnly` and `destroy()`. A finding is `{ path, message, severity, line? }` (core `Finding` objects fit). Rows show the path in mono and the line at the right; a click moves the cursor and calls `onFindingSelect`.
|
|
146
160
|
|
|
147
161
|
- **Opening a document: `pane.replaceDocument(document, { source, format, name })`.** Loading an example or a file into the pane (never by writing its text into the code editor, which the pane applies as a typed edit and forks the catalog records the two documents share) calls `editor.replaceDocument`: one undoable change, no catalog record forked. `document` is a parsed document or JSON text; `name` is the file name. `source` is the text the document was read from, in any of the three formats: the pane shows it as written (the JSON's formatting, the YAML's comments, the Markdown's layout) when the pane is in that format, and core's rewrite of the document otherwise. `format` (`"json"`, `"yaml"` or `"md"`) says which format `source` is; left out, it is inferred as the first of JSON, the pane's format, YAML and Markdown that reads as exactly this document (a `source` that reads as no format, or as another document, is ignored; an unknown `format` throws a `TypeError` before anything changes). The original text of a format other than the one shown is kept too: switching the pane to it shows those bytes, not a rewrite. Undo and Redo bring back each document's text exactly in the format shown (the opened text as written, the replaced document's text as it was, including YAML comments), so one Undo returns to the document and the text the visitor had. The pane keeps the last 8 such texts, which are also what a later load of the same document shows. A refused document throws (`invalid-opf-edit`, with `issues`) and changes nothing, and works in a read-only pane. A diff typed or pasted into the pane is still an edit, and editing a catalog record in it forks the record.
|
|
148
162
|
- **Keyboard Undo and Redo in the pane (one model).** Cmd/Ctrl+Z and Cmd/Ctrl+Shift+Z (Ctrl+Y) work in two layers, in this order. First CodeMirror's history, which holds only the text typed in the pane since its last whole-document change (a `replaceDocument`, or a session Undo, Redo or restore, each of which clears it because the typed edits no longer fit the text). When that has nothing left, the key steps the session over a whole-document replace, exactly as its Undo and Redo do, so the text comes back byte for byte: Undo steps back over the nearest `replaceDocument` together with the pane's own edits typed on it (the typing was already undone, step by step, by CodeMirror's history); Redo steps forward over the replace. The key never reaches over an edit made elsewhere (the canvas, a panel): Undo stops at it, and the host's Undo button (or `editor.undo()`) undoes that edit, after which the key reaches the load again. A read-only pane takes neither key. `mountJsonCodeEditor` exposes the same seam as `historyFallback: { undo(), redo() }` (answer `true` when you did something) plus `resetHistory()` and `undoDepth()` on the control.
|
|
149
|
-
- **JSON is canonical** and keeps the author's bytes: a YAML or Markdown edit rewrites only the changed JSON tokens (`exact-source`), and Undo restores the exact spelling.
|
|
163
|
+
- **JSON is canonical** and keeps the author's bytes: a YAML or Markdown edit rewrites only the changed JSON tokens (`exact-source`), and Undo restores the exact spelling. The writer is a public entry point, `@openpresentation/opf-editor/exact-source`: `updateJsonSource(source, document, memory?)` returns `source` with only the changed values rewritten (indentation, key order, escapes and line endings kept; the whole document is written out only when an edit in place cannot reproduce it), `createSourceMemory()` gives a document its own memory of authored spellings (so a value that comes back with Undo comes back as it was written), and `findDuplicateKey(source)` finds a key JSON.parse would silently collapse. A host that keeps the JSON text of a deck itself uses it instead of a JSON token parser of its own.
|
|
150
164
|
- **YAML and Markdown** are core's browser-safe converters, `@openpresentation/opf/yaml` and `/markdown`. Switching format writes the session's document in the new format. An edit parses back to the document and reaches the session as one `replace` patch, after a short delay (`applyDelay`, default 250 ms) so one burst of typing is one undo step. A syntax error, or an OPF rule the session refuses, is listed with its line and never reaches the document; Undo, Redo and outside edits replace a draft that did not parse.
|
|
151
165
|
- **Nothing is lost silently.** Markdown writes what its dialect has no syntax for (a `design`, region keys, nested groups) as YAML in `opf-slide` and `opf-block` fences; each such part is a warning finding with its JSON Pointer and reason. Comments in YAML cannot survive a rewrite from the document: outside edits drop them and say how many. Format (below) never rewrites from the document, so it keeps them. A document that is not valid OPF cannot be written as YAML or Markdown; the pane stays in JSON and says so.
|
|
152
166
|
- **Completions.** In JSON, YAML and Markdown the pane offers what the schema and the catalogs say belongs at the caret (`completions: false` keeps the field menu only). *Keys*: the properties of the object at the caret, the required ones first, then the common ones in a curated order (a slide: `title`, `subtitle`, `layout`, `items`, `text`, `notes`, ...), then the rest; the promoted region keys of a slide (`top+middle:left+center`) show only once you type three characters of one. Keys already in the object are not offered. Accepting one writes the whole pair: a string key gets `"key": "|"`, a number `"key": |`, an array `"key": [|]`, an object `"key": {|}`, and an object with required members (`organization`: `id`, `name`) writes them one per line, each a tab stop that `Tab` walks (the last `Tab` leaves the braces). A key with a closed set of values (an enum, a catalog id, a boolean) opens the list of its values at once. A comma is added when a property follows, and not before `}`. *Values*: enum values and booleans, and catalog ids (layouts, themes, color and font schemes, narratives, audiences, purposes, tones: the document's own records first, then the registered catalogs', layouts in the order of their intents, each with its description and regions in the documentation panel); after a colon, `{}` and `[]` templates. *Snippets*: `slide`, `slide-text`, `slide-image`, `slide-chart`, `slide-quote`, `slide-metric`, `slide-table`, `slide-code`, `slide-two-column` and `section` in `slides`, `reference` in `references`, `dataset` in `datasets` and `deck` in a blank document, with tab stops (the chart type stop opens the list of chart types). *Triggers*: a quote or a word at a key or value place, `:` and the space after it, and `Ctrl+Space` (`Cmd+I` on macOS) anywhere the schema has something to offer, also right after a finished string; free text (titles, notes, alt text) and numbers never open a list on their own. *Keys in the list*: `Tab` and `Enter` accept (at once, right after a keystroke too), `Shift+Tab` and `Shift+Enter` accept too (an accepted item always replaces the whole token), `Escape` closes it, `Left`, `Right` and `Backspace` inside the word keep it open and filter by the text before the caret, `Ctrl+Space` with the list open shows or hides the documentation panel. With several cursors the accepted item goes in at each of them (as plain text). The data is core's (`describeField` and `catalogKindAt` of `@openpresentation/opf/schema`; `getJsonCompletions` of `/json-options` remains for hosts that want the plain list). `mountJsonCodeEditor` takes the same `completions` option, off by default there.
|
|
@@ -163,10 +177,10 @@ Methods: `replaceDocument(document, { source?, format?, name?, meta? })` (below)
|
|
|
163
177
|
- **Go to definition, peek, references and rename (phase 4).** The ids of a document are symbols, in JSON, YAML and Markdown, also while the text is broken: assets (`asset:logo`, a Markdown ``), variables (`var:brand`, `{{name}}`, `{{name|format}}`), datasets and their columns, references (`cite`), slide ids (`"id"`, `id:`, `<!-- slide: id=... -->`), the beats and regions of a narrative or layout, and every catalog id (layout, theme, color scheme, font scheme, narrative, audience, purpose, tone, also as `name:id`). `F12` (and Cmd/Ctrl+click) jumps to the declaration and selects it; a record only a registered catalog has (the app's) opens a read-only card in the widget (name, description, regions, "Provided by app", and the preview from the host's `resolvePreview(kind, id)` option of the pane or the code editor, an element or an `https:` or `data:image/` address); a built-in variable (`organization.name`) or an id nothing declares says so and does not move. On a declaration `F12` lists its uses instead, as VS Code does. `Alt+F12` peeks the declaration (its lines with a line of context) in a widget under the line, with the focus in it: `Enter` goes to the definition, `Escape` closes it and returns to the editor. `Shift+F12` lists the declaration first (marked "Declaration") and then every use with its line, slide number and text: Up, Down, Home and End move, `Enter` (or a click) jumps and selects the use, `Escape` closes. `F2` opens an input at the symbol, filled with the id and selected; the name is checked as you type (not empty, the schema's pattern for that kind of id, no collision with another id of the same kind) and the message appears in the widget and is announced; `Enter` renames the declaration and every use in one transaction, so one undo restores them all, and keeps the selection on the renamed symbol. Renamable: assets, variables (a `{{name|format}}` keeps its format), datasets, references, slide ids and the ids of records embedded in `catalogs` (with the uses of a `name:id` reference of that group). Not renamable, with the reason said: a record only the app provides, a built-in variable, a dataset column, a beat or a region (they are named in their record), an id nothing declares, and anything in a read-only editor. Every command announces what it did in the live region; the widgets are named (`role`, `aria-label`), operable with the keyboard alone and return the focus to the editor; the declaration is marked with the word and an arrow, not by color alone. The widgets move with the edits above them. The work is one read of the text per edit (a big YAML text is read in pieces with the main thread given back between them): on a 1000-slide deck each command answers well under 100 ms (10 to 30 ms measured) and never holds the main thread for 50 ms.
|
|
164
178
|
- **Format document.** `Shift+Alt+F` (and `Cmd/Ctrl+Shift+F`) and the Format button do the same in JSON, YAML and Markdown, and change whitespace only, as one undo step with the cursor kept on its token. JSON is laid out token by token with the document's indentation, also while it does not parse; YAML keeps its comments, blank lines, quote styles, number spellings and key order (the pane says so and does nothing when the YAML has errors); Markdown gets at most one blank line between blocks (code fences untouched), its front matter formatted as YAML, and one newline at the end. A format that would change the document is refused. The pane does not rewrite the text from the document (`toYaml`, `toMarkdown`) any more, so it no longer drops comments.
|
|
165
179
|
- **Canonicalize with OPF.** The old behavior of Format (the text rewritten from the document with `toYaml` or `toMarkdown`, in the order and style OPF writes) is its own button in YAML and Markdown, `pane.canonicalize()`. It drops comments, so when there are any it asks first, names how many (YAML `#` comments; Markdown `<!-- -->` comments other than slide and block options, and the YAML comments of the front matter and fences), and waits for "Canonicalize anyway" or Cancel (`Escape`); undo brings the comments back. It is one undo step.
|
|
166
|
-
- **Checking in a Web Worker (`worker`).** Reading the text (YAML, Markdown), the quick syntax pass of JSON and validating the document take 50 to 100 ms on a thousand slides, so with `worker` the pane does that off the main thread: typing in a 1000-slide deck never blocks the page for 50 ms (the budget is tested in Chromium and WebKit). The worker runs the same functions as the pane's own path (`source-check.js`) and hands back the deck read, the findings and the validation the session would compute, which the session accepts through `editor.applyPatch(operations, meta, { validated })` only for exactly that document and its current `catalogs` (it never lands in the undo history). The
|
|
180
|
+
- **Checking in a Web Worker (`worker`).** Reading the text (YAML, Markdown), the quick syntax pass of JSON and validating the document take 50 to 100 ms on a thousand slides, so with `worker` the pane does that off the main thread: typing in a 1000-slide deck never blocks the page for 50 ms (the budget is tested in Chromium and WebKit). The worker runs the same functions as the pane's own path (`source-check.js`) and hands back the deck read, the findings and the validation the session would compute, which the session accepts through `editor.applyPatch(operations, meta, { validated })` only for exactly that document and its current `catalogs` (it never lands in the undo history). The worker script is `@openpresentation/opf-editor/source-worker` (an ES module with no exports that imports core, so it runs only bundled). `worker: true` starts it with `new Worker(new URL('./source-worker.js', import.meta.url), { type: 'module' })`, which Next.js (Turbopack and webpack, dev and build), webpack and Vite builds bundle; `worker: () => new Worker(new URL('@openpresentation/opf-editor/source-worker', import.meta.url), { type: 'module' })` works there too and in the Vite dev server with core in `optimizeDeps.include`; with esbuild, build the worker entry as its own entry point and pass its URL (`worker: new URL('./source-worker.js', import.meta.url)`). [The source worker](docs/embedding.md#the-source-worker) has the snippet for each bundler. The worker says it is ready as soon as it runs: one that loads but never does (a script a bundler emptied) is given up after 5 s. Without `worker` (the default) nothing changes: everything runs on the main thread. Only the typing delay (`applyDelay`) uses the worker; `getText()`, switching the format, `replaceDocument`, Format, Undo and `destroy` apply a pending edit on the main thread at once, as before. If the worker cannot start (a CSP, a wrong URL, a bundler), cannot clone a message, throws or stops answering, the pane checks on the main thread from then on, `onError` is called once, and no edit is lost. Results are identical either way (a test compares the problems, squiggles, document and validation for the same texts). `pane.whenIdle()` resolves when no edit waits for its delay and no check runs; `pane.checkerStats()` is `{ worker, inline, available }` (how the applied edits were checked); `data-opf-checking` is on the pane's root while a worker check is running. While you type faster than the worker answers, older answers are dropped and the newest text is applied when you pause.
|
|
167
181
|
- **Accessibility.** One polite live region (`.opf-source-live`) says how many suggestions there are when the list opens, the selected item and the first sentence of its documentation as you arrow (after a short pause), `Inserted design` when you accept, `Tab stop 2 of 3: ...` in a snippet, the problem when the caret enters a squiggle or `F8` reaches it, and `2 problems: 1 error, 1 warning` once the problems stop changing for 600 ms; hosts get every message through the `announce` option. Every suggestion has an accessible name with its kind and its place (`design, property, Design. 1 of 3`), the editor's `aria-description` says how to open the list and how to leave the editor (`Escape` then `Tab`, or `Ctrl+M`; `Ctrl+Shift+M` on macOS), and the problem rows start with the severity as text as well as a shape. The editor's `aria-invalid` is `true` while the findings include an error and `false` otherwise. The format button (`aria-label="Source format"`) opens a `role="menu"` of `menuitemradio` items: Arrow keys, Home, End, Enter, Escape (focus returns to the button) and Tab. The file name is a button that becomes a text input; Enter or Escape commits. Controls show a 2px `--opf-ui-ring` focus outline.
|
|
168
182
|
- **Styles.** `src/styles/source-pane.css` uses only `--opf-ui-*` tokens under the `opf-source-` prefix. It ships in the editor's `styles.css` (`@openpresentation/opf-editor/styles.css`), which also defines the tokens.
|
|
169
|
-
- **Dependency.** `yaml` (ISC; core already depends on it) formats YAML while keeping its comments. `@codemirror/lang-yaml` (MIT) highlights YAML; Markdown uses a small stream tokenizer built on `@codemirror/language`, so no Markdown, HTML, CSS or JavaScript grammar is added.
|
|
183
|
+
- **Dependency.** `yaml` (ISC; core already depends on it) formats YAML while keeping its comments. The `@codemirror/*` and `@lezer/*` packages are caret ranges, so an app that uses CodeMirror itself shares one copy; two copies of `@codemirror/view` silently break the pane's tooltips, completions, problems and find, and the pane reports them once through `onError` (code `duplicate-codemirror`) or the console ([One copy of CodeMirror](docs/embedding.md#one-copy-of-codemirror)). `@codemirror/lang-yaml` (MIT) highlights YAML; Markdown uses a small stream tokenizer built on `@codemirror/language`, so no Markdown, HTML, CSS or JavaScript grammar is added.
|
|
170
184
|
|
|
171
185
|
Icons are inline paths from [Lucide](https://lucide.dev) (ISC license). The harness is `examples/source-pane.html` (built by `pnpm run build:playground`); `pnpm run test:source-pane-browser` drives it, and `pnpm run test:source-keyboard-browser` and `pnpm run test:source-keyboard-json-browser` run the keyboard cases (real key events; `OPF_BROWSER_ENGINE=webkit` and `OPF_KEYBOARD_PLATFORM=win32|linux|darwin` pick the browser and the key map).
|
|
172
186
|
|
|
@@ -724,7 +738,7 @@ const selectionControls = createDesignControls(contentPanel, { editor, getSlideI
|
|
|
724
738
|
controls.refresh(); // when the slide or the selection changes
|
|
725
739
|
```
|
|
726
740
|
|
|
727
|
-
Sections: `look` (theme, color scheme, font scheme, language, this slide's layout), `background` (type, theme slot, solid color, gradient with its stops, image with fit, focus, alt text, recolor and overlay, any of the 54 patterns, opacity; a draft until Apply, with Remove), `header-footer` (choose header or footer and a zone, then every part the zone supports: a text box with an "Insert value" menu that puts the slide number, slide count, section, organization, speaker or deck name at the cursor as a `{{ }}` variable (Enter commits, Shift+Enter starts a new line), an image with an "Insert logo" action (a shape and, for several organizations, an organization; it writes the logo reference as the zone's image), social profiles, and the current or fixed date and format, with a summary of the zones in use), `brand` (the organization logo: one image for every shape, or each of the four shapes for both, light and dark backgrounds, for the organization you choose; which organization's logo and shape covers, sections and picture bullets draw, or none; picture bullets, accent font, watermark), `layout-options` (alignment, direction, primary chart, content box, default image fit), `info` (narrative, tone, audience, socials), `selection` (content type with a loss report, list levels, group of metrics, group and ungroup, image to background or watermark, replacement, chart type), `image` (the selected image block's fit, focus, shape, corner radius, border, opacity, recolor, overlay, aspect ratio and placement), `catalog` (reference findings and the explicit "Update from catalog"), `slide-content` (blocks to regions and back, design images back into the content) and `table` (style, merge, split, cell fill, alignment and bold). The panel is native `details`, `fieldset`, `select`, checkbox and text controls with a label on each, so it works with the keyboard and with screen readers: groups open with Enter or Space, a select changes with the arrow keys, text fields commit on Enter or when you leave them, and results and refusals are announced in `role="status"` and `role="alert"` regions. A refused change (an invalid value, text a merge would hide) is explained, changes nothing, and puts the field back. "Applies to" switches the design controls between the whole presentation and the current slide, and a control shows when a slide value is "set on this slide" or "from the presentation". The panel offers only conversions that exist
|
|
741
|
+
Sections: `look` (theme, color scheme, font scheme, language, this slide's layout), `background` (type, theme slot, solid color, gradient with its stops, image with fit, focus, alt text, recolor and overlay, any of the 54 patterns, opacity; a draft until Apply, with Remove), `header-footer` (choose header or footer and a zone, then every part the zone supports: a text box with an "Insert value" menu that puts the slide number, slide count, section, organization, speaker or deck name at the cursor as a `{{ }}` variable (Enter commits, Shift+Enter starts a new line), an image with an "Insert logo" action (a shape and, for several organizations, an organization; it writes the logo reference as the zone's image), social profiles, and the current or fixed date and format, with a summary of the zones in use), `brand` (the organization logo: one image for every shape, or each of the four shapes for both, light and dark backgrounds, for the organization you choose; which organization's logo and shape covers, sections and picture bullets draw, or none; picture bullets, accent font, watermark), `layout-options` (alignment, direction, primary chart, content box, default image fit), `info` (narrative, tone, audience, socials), `selection` (content type with a loss report, list levels, group of metrics, group and ungroup, image to background or watermark, replacement, chart type), `image` (the selected image block's fit, focus, shape, corner radius, border, opacity, recolor, overlay, aspect ratio and placement), `catalog` (reference findings and the explicit "Update from catalog"), `slide-content` (blocks to regions and back, design images back into the content) and `table` (style, merge, split, cell fill, alignment and bold). The panel is native `details`, `fieldset`, `select`, checkbox and text controls with a label on each, so it works with the keyboard and with screen readers: groups open with Enter or Space, a select changes with the arrow keys, text fields commit on Enter or when you leave them, and results and refusals are announced in `role="status"` and `role="alert"` regions. A refused change (an invalid value, text a merge would hide) is explained, changes nothing, and puts the field back. "Applies to" switches the design controls between the whole presentation and the current slide, and a control shows when a slide value is "set on this slide" or "from the presentation". The panel shows options, not explanations: every option is a control with a short label, and a hint (a unit, a range, a consequence) is the control's tooltip and accessible description, never a paragraph under it. It offers only conversions that exist and names what a conversion loses on its option; a type the content cannot become is listed but disabled, with the reason as its tooltip and accessible description. The selected block's structural actions (group with the next block, ungroup the selected group or the group around the block, plus a host's own through `blockActions`, such as the Studio's move up, move down, duplicate and delete) are one row of named icon buttons; a disabled one stays focusable (`aria-disabled`) and names why in its tooltip, and an available one shows its shortcut. The selected block's path is a copyable chip under the section's Advanced (`showPath: false` leaves it to a host with its own Advanced).
|
|
728
742
|
|
|
729
743
|
In React (or Svelte, or anything else) mount it into a ref and destroy it on unmount; it needs no framework runtime:
|
|
730
744
|
|
|
@@ -768,7 +782,7 @@ await persistence.markSaved(); // after the host saved the document somew
|
|
|
768
782
|
- **Restore** is offered when a stored copy differs from the document the session starts with. `restore()` puts the copy back as one undoable step; into a session nobody has edited it restores the undo history too (`restoreState`, which replays the history against the document and refuses one that does not belong to it, so a stale copy never corrupts the session). If the person keeps editing while the offer is open, the offered copy is first moved aside, so ignoring the prompt never loses it; after a reload the offer mentions the older copy (`restoreEarlier()`). `discard()` deletes the stored copy.
|
|
769
783
|
- **Unsaved changes.** `dirty` is true when the document differs from the last `markSaved()` (or from how the session started); `beforeunload` warns while it is true (`warnOnUnload: false` to opt out) and a final write is started on unload, `pagehide` and when the tab is hidden. A host that loads a document into the editor (the gallery hands a snippet over) calls `rebase()` so that document is neither autosaved nor warned about until the person changes it. The playground marks the document saved on Save OPF, not on a PowerPoint export (a PPTX is not the OPF document).
|
|
770
784
|
- **Degradation.** Private browsing, blocked site data, a failing read or a full store never throw: `status` says `unavailable` or `error` with a sentence the host shows ("... download it to keep it", "browser storage is full ..."), a full store first drops the undo history and then reports, and the next change tries again. Dirty tracking and the unload warning keep working without storage.
|
|
771
|
-
- The playground enables it with the key `opf-editor-playground`. A host page sets `globalThis.OPF_EDITOR_HOST = { persistence: { key, storage, onRestorePrompt } }` (or `persistence: false`) before the script runs; a frame can pass `?persist=<key>` or `?persist=0`. The footer text of the inspector is the autosave indicator and a prompt appears under the document bar.
|
|
785
|
+
- The playground enables it with the key `opf-editor-playground`. A host page sets `globalThis.OPF_EDITOR_HOST = { persistence: { key, storage, onRestorePrompt } }` (or `persistence: false`; `document` starts it with the host's own deck instead of the sample, and `onReady({ editor, session, fonts })` hands the host the live editor and fonts handle) before the script runs; a frame can pass `?persist=<key>` or `?persist=0`. The footer text of the inspector is the autosave indicator and a prompt appears under the document bar.
|
|
772
786
|
## Fill template panel (RR-32)
|
|
773
787
|
|
|
774
788
|
A template is an OPF file with variables (`{{id}}` tokens and `var:id` references, root `"template": true`; see [templates and variables](https://github.com/OpenPresentation/opf/blob/main/docs/templates-and-variables.md)). `/templates` is the headless model and `/template-panel` the DOM panel over it. Both need the core release that ships `resolveVariables` (older cores load them and throw `templates-unavailable`).
|
package/dist/canvas.d.ts
CHANGED
|
@@ -58,6 +58,13 @@ export interface CanvasEditorOptions {
|
|
|
58
58
|
* their properties on double-click.
|
|
59
59
|
*/
|
|
60
60
|
textEntry?: "click" | "dblclick";
|
|
61
|
+
/**
|
|
62
|
+
* CE-20: "interactive" (default) makes each piece of content a focusable button on the slide. "passive" is for a host that draws its own
|
|
63
|
+
* selection over the slide (the Studio): the slide SVG is `aria-hidden` and its targets leave the tab order; editing works through `beginEdit`.
|
|
64
|
+
*/
|
|
65
|
+
targets?: "interactive" | "passive";
|
|
66
|
+
/** CE-20: what Escape does in an inline text field: "cancel" (default) drops the draft, "commit" keeps it and leaves. */
|
|
67
|
+
escape?: "cancel" | "commit";
|
|
61
68
|
/** Picture tools (default true): a selected picture shows a "Crop picture" button that opens the crop and focal point layer. */
|
|
62
69
|
imageTools?: boolean;
|
|
63
70
|
/** Optional empty host for property forms; defaults to a floating canvas panel. */
|
|
@@ -106,7 +113,12 @@ export interface CanvasEditor {
|
|
|
106
113
|
openInsertMenu(containerPath?: string,index?: number): void;
|
|
107
114
|
select(path: string): void;
|
|
108
115
|
/** Start editing with all text selected (keyboard entry). Pointer entry places the caret at the pointer instead. */
|
|
109
|
-
beginEdit(path: string): void;
|
|
116
|
+
beginEdit(path: string, entry?: { point?: { clientX: number; clientY: number } }): void;
|
|
117
|
+
/** How a pointer enters text editing now; `setTextEntry` changes it without remounting. */
|
|
118
|
+
readonly textEntry: "click" | "dblclick";
|
|
119
|
+
setTextEntry(mode: "click" | "dblclick"): void;
|
|
120
|
+
readonly targets: "interactive" | "passive";
|
|
121
|
+
setTargets(mode: "interactive" | "passive"): void;
|
|
110
122
|
/** Open structured controls, including rich run fields. */
|
|
111
123
|
editProperties(path: string): boolean;
|
|
112
124
|
commit(): boolean;
|
package/dist/canvas.js
CHANGED
|
@@ -73,6 +73,9 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
73
73
|
// are not loaded yet is never rendered: the canvas has the handle load them first and shows "Loading fonts…" meanwhile. Without a
|
|
74
74
|
// handle every document renders at once, with core's portable text estimate.
|
|
75
75
|
const handle = options.fonts, fonts = fontGate(handle);
|
|
76
|
+
// The document as the session holds it now: its frozen snapshot, one object per version, so the font gate's answer for it is reused
|
|
77
|
+
// (CE-20; the session's \`presentation\` copies the whole deck on every read).
|
|
78
|
+
const current = () => editor.snapshot?.().presentation ?? editor.presentation;
|
|
76
79
|
// The renderer draws with the session's registered catalogs (plus any `renderOptions.catalogs`): the same list core resolves with.
|
|
77
80
|
const resolveOptions = () => ({ ...renderOptions, catalogs: mergeCatalogs(editor.catalogs, renderOptions.catalogs) });
|
|
78
81
|
// RR-61: the handle measures, and the canvas embeds no face: the browser handle already added its faces to the page, so
|
|
@@ -95,9 +98,16 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
95
98
|
// How a pointer enters text editing: "click" (default; PowerPoint / Google Slides) puts the caret at the pressed
|
|
96
99
|
// character on a single press and lets a press-drag select a range; "dblclick" keeps the older double-click gesture
|
|
97
100
|
// but still places the caret at the pointer. Keyboard entry (Enter, Space, F2) always selects all text.
|
|
98
|
-
|
|
101
|
+
let textEntry = options.textEntry ?? "click";
|
|
99
102
|
if (textEntry !== "click" && textEntry !== "dblclick")
|
|
100
103
|
throw new RangeError('textEntry must be "click" or "dblclick".');
|
|
104
|
+
// CE-20: "passive" targets are for a host that draws its own selection over the slide (the Studio's overlay): the slide is aria-hidden and
|
|
105
|
+
// its targets leave the tab order, while editing, crop and properties work as before through `beginEdit` and the other calls.
|
|
106
|
+
let targetMode = options.targets ?? "interactive";
|
|
107
|
+
if (targetMode !== "interactive" && targetMode !== "passive")
|
|
108
|
+
throw new RangeError('targets must be "interactive" or "passive".');
|
|
109
|
+
// CE-20: what Escape does in an inline text field: "cancel" (default) drops the draft; "commit" keeps it and leaves (the Studio, as Figma).
|
|
110
|
+
const escapeAction = options.escape ?? "cancel";
|
|
101
111
|
const root = doc.createElement("div"),
|
|
102
112
|
preview = doc.createElement("div"),
|
|
103
113
|
overlay = doc.createElement("div"),
|
|
@@ -236,7 +246,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
236
246
|
function show(target) {
|
|
237
247
|
if (disposed) return;
|
|
238
248
|
const token = ++showToken;
|
|
239
|
-
return whenFontsReady(fonts, target ??
|
|
249
|
+
return whenFontsReady(fonts, target ?? current(), {
|
|
240
250
|
renderOptions: resolveOptions(),
|
|
241
251
|
isCurrent: () => !disposed && token === showToken,
|
|
242
252
|
loading: (pending) => fontsState("loading", pending),
|
|
@@ -253,7 +263,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
253
263
|
}
|
|
254
264
|
// Renders now when the document's fonts are loaded (errors throw to the caller), otherwise after loading them.
|
|
255
265
|
function renderFor(presentation) {
|
|
256
|
-
if (fonts?.pending(presentation ??
|
|
266
|
+
if (fonts?.pending(presentation ?? current(), resolveOptions()).length) show(presentation);
|
|
257
267
|
else render(presentation);
|
|
258
268
|
}
|
|
259
269
|
// The text an editing target draws: its outermost <text> elements, whitespace collapsed (the renderer's <title> and <desc> are not drawn).
|
|
@@ -265,7 +275,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
265
275
|
.replace(/\s+/g, " ")
|
|
266
276
|
.trim();
|
|
267
277
|
}
|
|
268
|
-
function render(presentation =
|
|
278
|
+
function render(presentation = current()) {
|
|
269
279
|
if (disposed) return;
|
|
270
280
|
requireFonts(presentation);
|
|
271
281
|
showToken++;
|
|
@@ -276,11 +286,13 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
276
286
|
const svg = preview.querySelector("svg");
|
|
277
287
|
svg.setAttribute("role", "group");
|
|
278
288
|
svg.setAttribute("aria-label", "Editable slide");
|
|
289
|
+
if (targetMode === "passive") svg.setAttribute("aria-hidden", "true");
|
|
279
290
|
// FA-30: the renderer labels its slide root a "slide" group named by the title; the canvas is named for editing instead, so it drops
|
|
280
291
|
// the roledescription rather than announce "Editable slide, slide".
|
|
281
292
|
svg.removeAttribute("aria-roledescription");
|
|
282
293
|
svg.removeAttribute("aria-labelledby");
|
|
283
|
-
|
|
294
|
+
// Only the slide on show is bound (CE-20): a long deck is not composed slide by slide for one draw.
|
|
295
|
+
const geometry = resolvePresentation(presentation, drawOptions(), { slides: [slideIndex] }).slides[
|
|
284
296
|
slideIndex
|
|
285
297
|
].geometry;
|
|
286
298
|
const candidates = [...svg.querySelectorAll("[data-opf-path]")];
|
|
@@ -396,6 +408,8 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
396
408
|
node.removeAttribute("tabindex");
|
|
397
409
|
node.setAttribute("aria-label", `${field.charAt(0).toUpperCase()}${field.slice(1)}`);
|
|
398
410
|
}
|
|
411
|
+
if (targetMode === "passive")
|
|
412
|
+
for (const node of svg.querySelectorAll("[tabindex]")) node.setAttribute("tabindex", "-1");
|
|
399
413
|
layoutHandles.update(presentation, geometry);
|
|
400
414
|
blockControls.update(presentation, geometry);
|
|
401
415
|
imageCropper?.update();
|
|
@@ -491,6 +505,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
491
505
|
geometry = resolvePresentation(
|
|
492
506
|
active.draft ?? editor.presentation,
|
|
493
507
|
drawOptions(),
|
|
508
|
+
{ slides: [slideIndex] },
|
|
494
509
|
).slides[slideIndex].geometry;
|
|
495
510
|
const item = geometry.items.find((item) => item.path === active.path);
|
|
496
511
|
const metricLayout=geometry.items.find(item=>item.metricLayout?.parts.some(part=>part.path===active.path))?.metricLayout;
|
|
@@ -605,8 +620,9 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
605
620
|
if (active && !commit()) return;
|
|
606
621
|
startEdit(path, { point: event, drag: true });
|
|
607
622
|
}
|
|
608
|
-
|
|
609
|
-
|
|
623
|
+
// `entry.point` ({ clientX, clientY }) puts the caret there (a double-click in the Studio); without it all text is selected.
|
|
624
|
+
function beginEdit(path, entry) {
|
|
625
|
+
startEdit(path, entry?.point ? { point: entry.point } : undefined);
|
|
610
626
|
}
|
|
611
627
|
// entry: undefined selects all text (keyboard and programmatic entry); {point} places the caret at a client point;
|
|
612
628
|
// {point, drag} also selects while the pressed pointer moves.
|
|
@@ -702,7 +718,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
702
718
|
} else if (event.key === "Escape") {
|
|
703
719
|
event.preventDefault();
|
|
704
720
|
event.stopPropagation();
|
|
705
|
-
cancel();
|
|
721
|
+
if (escapeAction === "commit") commit(); else cancel();
|
|
706
722
|
} else if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
|
707
723
|
event.preventDefault();
|
|
708
724
|
commit();
|
|
@@ -721,7 +737,7 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
721
737
|
active = {kind:"rich-text",path,base:JSON.stringify(value),valid:true};
|
|
722
738
|
active.rich = createRichTextInput(root, overlay, {
|
|
723
739
|
path, value, getTarget, selectAll: !entry?.point,
|
|
724
|
-
onInput: queueDraft, onCommit: commit, onCancel: cancel, onError: report,
|
|
740
|
+
onInput: queueDraft, onCommit: commit, onCancel: () => (escapeAction === "commit" ? commit() : cancel()), onError: report,
|
|
725
741
|
onFormat(start,end) {
|
|
726
742
|
if (!commit()) return;
|
|
727
743
|
if(start===end)richToolbar.selectAll(path);else richToolbar.selectRange(path,start,end);
|
|
@@ -1113,6 +1129,20 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
1113
1129
|
render,
|
|
1114
1130
|
setSlide,
|
|
1115
1131
|
reveal,
|
|
1132
|
+
get textEntry() { return textEntry; },
|
|
1133
|
+
/** CE-20: switch how a pointer enters text editing ("click" or "dblclick") without remounting (the Studio's presets). */
|
|
1134
|
+
setTextEntry(mode) {
|
|
1135
|
+
if (mode !== "click" && mode !== "dblclick") throw new RangeError('textEntry must be "click" or "dblclick".');
|
|
1136
|
+
textEntry = mode;
|
|
1137
|
+
},
|
|
1138
|
+
get targets() { return targetMode; },
|
|
1139
|
+
/** CE-20: "interactive" targets (buttons in the tab order) or "passive" ones (the host draws selection); redraws. */
|
|
1140
|
+
setTargets(mode) {
|
|
1141
|
+
if (mode !== "interactive" && mode !== "passive") throw new RangeError('targets must be "interactive" or "passive".');
|
|
1142
|
+
if (mode === targetMode) return;
|
|
1143
|
+
targetMode = mode;
|
|
1144
|
+
if (!active) renderFor();
|
|
1145
|
+
},
|
|
1116
1146
|
setRenderOptions(next) {
|
|
1117
1147
|
if (!commit()) return false;
|
|
1118
1148
|
richToolbar.hide();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The chart options panel (RR-35): axis titles, legend position and data labels for the selected chart, plus (FA-09) its alt text and
|
|
2
2
|
// (FA-14) which series and categories to highlight.
|
|
3
3
|
// It mounts into any host element over an editor session; every control commits one undoable change through
|
|
4
|
-
// `setChartOptions` (src/chart-options.js), the canvas redraws, and
|
|
4
|
+
// `setChartOptions` (src/chart-options.js), the canvas redraws, and The panel shows only what
|
|
5
5
|
// the selected chart type can show (core's support table), and hides itself when no chart is selected. On a combo chart
|
|
6
6
|
// (FA-15) a "Series" group sets which series are lines and which lines use the secondary value axis, and a secondary axis
|
|
7
7
|
// title field appears while a line uses that axis. (RR-96) A "Colors" group sets the color of each series (or slice) from the deck's accent
|
|
@@ -222,25 +222,25 @@ export function createChartOptionsPanel(host, { editor, getSelectedPath, onStatu
|
|
|
222
222
|
const checkedNames = (part) => [...fields.highlight[part].querySelectorAll("input:checked")].map((input) => input.value);
|
|
223
223
|
const selectedContent = () => fields.contents.filter((input) => input.checked && !input.closest("label").hidden).map((input) => input.value);
|
|
224
224
|
const listeners = [
|
|
225
|
-
[fields.alt, "change", () => commit({ alt: fields.alt.value }, "Changed the chart's alt text.
|
|
226
|
-
[fields.decorative, "change", () => commit({ decorative: fields.decorative.checked }, fields.decorative.checked ? "Marked the chart decorative.
|
|
227
|
-
[fields.category, "change", () => commit({ axisTitles: { category: fields.category.value } }, "Changed the category axis title.
|
|
228
|
-
[fields.value, "change", () => commit({ axisTitles: { value: fields.value.value } }, "Changed the value axis title.
|
|
229
|
-
[fields.secondary, "change", () => commit({ axisTitles: { secondary: fields.secondary.value } }, "Changed the secondary axis title.
|
|
225
|
+
[fields.alt, "change", () => commit({ alt: fields.alt.value }, "Changed the chart's alt text.")],
|
|
226
|
+
[fields.decorative, "change", () => commit({ decorative: fields.decorative.checked }, fields.decorative.checked ? "Marked the chart decorative." : "The chart is no longer decorative.")],
|
|
227
|
+
[fields.category, "change", () => commit({ axisTitles: { category: fields.category.value } }, "Changed the category axis title.")],
|
|
228
|
+
[fields.value, "change", () => commit({ axisTitles: { value: fields.value.value } }, "Changed the value axis title.")],
|
|
229
|
+
[fields.secondary, "change", () => commit({ axisTitles: { secondary: fields.secondary.value } }, "Changed the secondary axis title.")],
|
|
230
230
|
[fields.series, "change", () => {
|
|
231
231
|
const checked = (option) => [...fields.series.querySelectorAll(`[data-opf-chart-option='${option}']`)].filter((input) => input.checked).map((input) => input.value);
|
|
232
232
|
const line = checked("line");
|
|
233
|
-
commit({ line, secondaryAxis: checked("secondaryAxis").filter((name) => line.includes(name)) }, "Changed which series are lines.
|
|
233
|
+
commit({ line, secondaryAxis: checked("secondaryAxis").filter((name) => line.includes(name)) }, "Changed which series are lines.");
|
|
234
234
|
}],
|
|
235
|
-
[fields.legend, "change", () => commit({ legend: fields.legend.value }, "Changed the legend.
|
|
236
|
-
[fields.labels, "change", () => commit({ dataLabels: fields.labels.checked }, fields.labels.checked ? "Showing data labels.
|
|
237
|
-
[fields.position, "change", () => commit({ dataLabels: { position: fields.position.value } }, "Moved the data labels.
|
|
238
|
-
[fields.separator, "change", () => commit({ dataLabels: { separator: fields.separator.value } }, "Changed the label separator.
|
|
235
|
+
[fields.legend, "change", () => commit({ legend: fields.legend.value }, "Changed the legend.")],
|
|
236
|
+
[fields.labels, "change", () => commit({ dataLabels: fields.labels.checked }, fields.labels.checked ? "Showing data labels." : "Hid the data labels.")],
|
|
237
|
+
[fields.position, "change", () => commit({ dataLabels: { position: fields.position.value } }, "Moved the data labels.")],
|
|
238
|
+
[fields.separator, "change", () => commit({ dataLabels: { separator: fields.separator.value } }, "Changed the label separator.")],
|
|
239
239
|
[fields.colors, "click", (event) => {
|
|
240
240
|
const button = event.target.closest?.("[data-opf-chart-option='colors.choose']");
|
|
241
241
|
if (!button) return;
|
|
242
242
|
const index = Number(button.closest("[data-position]").dataset.position);
|
|
243
|
-
commit({ colors: colorsWith(index, button.dataset.color) }, button.dataset.color ? "Changed a chart color.
|
|
243
|
+
commit({ colors: colorsWith(index, button.dataset.color) }, button.dataset.color ? "Changed a chart color." : "Returned a chart color to the deck palette.");
|
|
244
244
|
}],
|
|
245
245
|
[fields.colors, "change", (event) => {
|
|
246
246
|
const input = event.target.closest?.("[data-opf-chart-option='colors.hex']");
|
|
@@ -248,18 +248,18 @@ export function createChartOptionsPanel(host, { editor, getSelectedPath, onStatu
|
|
|
248
248
|
const index = Number(input.closest("[data-position]").dataset.position);
|
|
249
249
|
const value = input.value.trim();
|
|
250
250
|
if (value && !isColorRef(value)) { onStatus("Use a hex color such as #7B241C."); refresh(); return; }
|
|
251
|
-
commit({ colors: colorsWith(index, value) }, "Changed a chart color.
|
|
251
|
+
commit({ colors: colorsWith(index, value) }, "Changed a chart color.");
|
|
252
252
|
}],
|
|
253
|
-
[fields.colorsReset, "click", () => commit({ colors: null }, "Returned the chart colors to the deck palette.
|
|
253
|
+
[fields.colorsReset, "click", () => commit({ colors: null }, "Returned the chart colors to the deck palette.")],
|
|
254
254
|
...["series", "categories"].map((part) => [fields.highlight[part], "change", () => {
|
|
255
255
|
const names = checkedNames(part);
|
|
256
|
-
commit({ highlight: { [part]: names } }, names.length ? `Highlighted ${names.join(", ")}
|
|
256
|
+
commit({ highlight: { [part]: names } }, names.length ? `Highlighted ${names.join(", ")}.` : `Cleared the ${part} highlight.`);
|
|
257
257
|
}]),
|
|
258
258
|
...fields.contents.map((input) => [input, "change", () => {
|
|
259
259
|
const content = selectedContent();
|
|
260
260
|
// A label always shows something: unticking the last part keeps it.
|
|
261
261
|
if (!content.length) { input.checked = true; return; }
|
|
262
|
-
commit({ dataLabels: { content } }, "Changed what the data labels show.
|
|
262
|
+
commit({ dataLabels: { content } }, "Changed what the data labels show.");
|
|
263
263
|
}]),
|
|
264
264
|
];
|
|
265
265
|
for (const [element, type, handler] of listeners) element.addEventListener(type, handler);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// One copy of @codemirror/view. Every CodeMirror package (autocomplete, search, lint, language) builds its view plugins with the
|
|
2
|
+
// `ViewPlugin` of the @codemirror/view copy IT resolves to. When a host app resolves a second copy (its own CodeMirror at another version,
|
|
3
|
+
// or a package manager that did not dedupe), the editor's EditorView never runs those plugins: tooltips, completions, the problems
|
|
4
|
+
// panel and find stop working, with no error. This module finds that case from the extensions the code editor mounts with and says it
|
|
5
|
+
// once per page.
|
|
6
|
+
import { ViewPlugin } from "@codemirror/view";
|
|
7
|
+
|
|
8
|
+
const REPORTED = Symbol.for("@openpresentation/opf-editor:duplicate-codemirror-view");
|
|
9
|
+
|
|
10
|
+
/** Whether `value` is a ViewPlugin, of any copy of @codemirror/view (its fields, since `instanceof` is what fails across copies). */
|
|
11
|
+
const looksLikeViewPlugin = (value) => value !== null && typeof value === "object" && typeof value.create === "function" && typeof value.id === "number" && "domEventHandlers" in value && "baseExtensions" in value;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The view plugins in the extension tree `extension` that were made by another copy of @codemirror/view than `ownViewPlugin` (the
|
|
15
|
+
* editor's own). An empty list means one copy. Walks arrays, precedence and compartment wrappers (`inner`), plugin extensions and
|
|
16
|
+
* facet providers whose value names a plugin (`plugin.of(arg)`).
|
|
17
|
+
*/
|
|
18
|
+
export function foreignViewPlugins(extension, ownViewPlugin = ViewPlugin) {
|
|
19
|
+
const found = [];
|
|
20
|
+
const seen = new Set();
|
|
21
|
+
const visit = (value) => {
|
|
22
|
+
if (value === null || typeof value !== "object" || seen.has(value)) return;
|
|
23
|
+
seen.add(value);
|
|
24
|
+
if (Array.isArray(value)) {
|
|
25
|
+
for (const item of value) visit(item);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (looksLikeViewPlugin(value)) {
|
|
29
|
+
if (!(value instanceof ownViewPlugin)) found.push(value);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (looksLikeViewPlugin(value.value?.plugin)) visit(value.value.plugin);
|
|
33
|
+
if ("inner" in value) visit(value.inner);
|
|
34
|
+
if ("extension" in value && value.extension !== value) visit(value.extension);
|
|
35
|
+
};
|
|
36
|
+
visit(extension);
|
|
37
|
+
return found;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The error a host hears when the code editor mounts on two copies of @codemirror/view. */
|
|
41
|
+
export function duplicateViewError() {
|
|
42
|
+
const error = new Error(
|
|
43
|
+
"Two copies of @codemirror/view are loaded, so the OPF source editor's tooltips, completions, problems and find do not work. " +
|
|
44
|
+
"Make the app resolve one copy of each @codemirror/* package (npm dedupe; a pnpm override or a bundler alias for @codemirror/view and @codemirror/state).",
|
|
45
|
+
);
|
|
46
|
+
error.code = "duplicate-codemirror";
|
|
47
|
+
return error;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Check the extensions a code editor mounts with and report a second copy of @codemirror/view once per page: through `onError` when
|
|
52
|
+
* the host gave one, else on the console. Returns whether a second copy was found (on every call, also after it was reported).
|
|
53
|
+
*/
|
|
54
|
+
export function checkCodeMirrorCopies(extension, onError) {
|
|
55
|
+
if (foreignViewPlugins(extension).length === 0) return false;
|
|
56
|
+
if (globalThis[REPORTED]) return true;
|
|
57
|
+
globalThis[REPORTED] = true;
|
|
58
|
+
const error = duplicateViewError();
|
|
59
|
+
if (typeof onError === "function") {
|
|
60
|
+
try {
|
|
61
|
+
onError(error);
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
/* A host callback that throws: the console still says it. */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
globalThis.console?.error?.(error.message);
|
|
68
|
+
return true;
|
|
69
|
+
}
|