@openpresentation/opf-editor 0.15.0 → 0.17.0
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 +42 -14
- package/dist/canvas.js +14 -2
- package/dist/catalog-control.js +21 -0
- package/dist/design-controls.js +88 -27
- package/dist/design-options.d.ts +22 -9
- package/dist/design-options.js +77 -18
- package/dist/export.d.ts +6 -1
- package/dist/export.js +6 -1
- package/dist/find-replace.js +10 -1
- package/dist/font-gate.js +0 -11
- package/dist/index.d.ts +2 -0
- package/dist/index.js +24 -9
- package/dist/json-options.js +7 -6
- package/dist/layout-handles.js +13 -0
- package/dist/layout-placeholders.js +15 -0
- package/dist/react.d.ts +1 -0
- package/dist/react.js +5 -1
- package/dist/slide-manager.js +3 -1
- package/dist/svelte.d.ts +1 -0
- package/dist/svelte.js +3 -2
- package/dist/switches.js +3 -2
- package/dist/table-options.d.ts +6 -1
- package/dist/table-options.js +43 -0
- package/dist/template-panel.js +6 -4
- package/dist/templates.d.ts +3 -1
- package/dist/templates.js +1 -1
- package/package.json +18 -9
package/README.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
Unfinished prepared shaping work is preserved in the [September 15 roadmap](docs/roadmap-shaping-20260915.md); it is not part of the published runtime.
|
|
4
4
|
|
|
5
|
+
## Using the editor with opf-render 0.16: what to install (RR-63, unreleased)
|
|
6
|
+
|
|
7
|
+
`@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:
|
|
8
|
+
|
|
9
|
+
| You use | Also install |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| The canvas, the preview, SVG downloads in a browser (the host serves its own font files) | nothing |
|
|
12
|
+
| `loadFonts` from `/fonts-node` (a server, a test, a build script such as `npm run build:playground`) | the font packages of the pack: `@expo-google-fonts/roboto` and `roboto-mono` for `base`; also `arimo`, `caladea`, `cousine`, `gelasio`, `tinos` and `noto-sans` for `office`; a script face (`scripts: 'auto'`) needs its `@expo-google-fonts/noto-*` package. A missing one fails with `font-resource-unavailable` and names it |
|
|
13
|
+
| `svgToPng` / `svgToPdf` from the Node entries (`/png`, `/pdf`) | `@resvg/resvg-js` (and `sharp`, `pdf-lib` as the renderer's README says); a missing one fails with `converter-missing` |
|
|
14
|
+
| `exportDeck` PDF (vector), PNG, SVG | nothing: the browser entry `@openpresentation/opf-render/export-browser` imports no converter |
|
|
15
|
+
| `exportDeck` raster PDF (`pdfMode: "raster"`) | `pdf-lib`, imported by the page and passed as the `pdfLib` option (`import * as pdfLib from "pdf-lib"`); without it the export rejects with `converter-missing` |
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
const pdfLib = await import("pdf-lib");
|
|
19
|
+
await exportDeck(editor.presentation, { format: "pdf", pdfMode: "raster", pdfLib, fonts });
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The playground imports `pdf-lib` for you when its raster PDF choice is used.
|
|
23
|
+
|
|
5
24
|
## OPF 0.15: catalogs from the host, backgrounds and image blocks (FA-23)
|
|
6
25
|
|
|
7
26
|
The release after 0.14.2 moves the editor onto core 0.15 (a breaking, clean spec: no migration). See `changes/fa-23-opf-0-15.md`.
|
|
@@ -24,7 +43,7 @@ The release after 0.14.2 moves the editor onto core 0.15 (a breaking, clean spec
|
|
|
24
43
|
- **Editing a catalog's record forks it.** An edit of a layout, theme, colour scheme or font scheme embedded under `catalogs.default` or a named group (from a panel, Properties, Source or any `applyPatch`) moves it into `catalogs.custom` as `<id>-custom` (or the id in `meta.forkIds["<group>:<kind>:<id>"]`), rewrites every reference to it and drops the original, in the same undo step. The change and the session event carry `forked` and a `notice` ("… is now this presentation's own; it no longer receives catalog updates"); approved catalog updates and `moveToCustom` change records in place (`meta.catalogRecordEdit: "in-place"`). `moveToCustom(editor, { group, kind, id }, { id })` is the explicit form, offered for `opf/catalog-record-not-in-source`.
|
|
25
44
|
- **Backgrounds** take the flat image form `{ type: "image", src, alt, fit, focus, opacity, recolor, overlay }` (fits `cover`, `contain`, `stretch`, `tile`) and the image-source shorthand, on the deck and on a slide. **Image blocks** carry their own fit, focus, treatments and placement (`/image-options`, the panel's `image` section): a full-bleed photo is a background, a picture beside the content is a placed image block. `design.imageFit` is the default fit of image blocks.
|
|
26
45
|
|
|
27
|
-
|
|
46
|
+
Editor 0.15.0 uses core `^0.15.1` and the optional renderer peer `^0.15.0`. Install renderer and PPTX 0.15 together for the coordinated canvas and export workflow. The shared APIs are core's `validate`, `Finding`, `resolveSlideContext` and the catalog helpers above, and the renderer's `loadFonts()` handle passed as `fonts` to the canvas, export, composition and pagination. Historical version changes are recorded in `CHANGELOG.md`.
|
|
28
47
|
|
|
29
48
|
Version 0.8.0 required `@openpresentation/opf` ^0.11.0, renderer ^0.9.0, and PPTX ^0.9.0. Named ColorRefs (scheme slots, roles, and `var:<id>`) paint through the published renderer and hex-resolve on export. Payload ids share the slide id namespace for pagination and transfer. Native `schemeClr`, theme write, and `p:hf` remain out of scope.
|
|
30
49
|
|
|
@@ -317,12 +336,13 @@ The playground's Content tab mounts the block-level and slide-structure actions
|
|
|
317
336
|
`@openpresentation/opf-editor/design-options` edits the design-level settings that used to need All properties. Each call is one validated patch and one undo step, at the deck or on one slide with `slideIndex`; `null` removes a value so it is inherited again. `prepare...` forms return the patch without touching a session.
|
|
318
337
|
|
|
319
338
|
```js
|
|
320
|
-
import { setDesignOption, setLogoVariant, setHeaderFooterZone, getDesignOption, designWarnings } from "@openpresentation/opf-editor/design-options";
|
|
339
|
+
import { setDesignOption, setLogoVariant, setHeaderFooterZone, insertZoneValue, getDesignOption, designWarnings } from "@openpresentation/opf-editor/design-options";
|
|
321
340
|
|
|
322
341
|
setDesignOption(editor, "titleAlignment", "center", { slideIndex: 2 }); // /slides/2/design/titleAlignment
|
|
323
342
|
setDesignOption(editor, "watermark", { src: "asset:mark", opacity: 0.1 }); // merges into an existing watermark; false hides an inherited one
|
|
324
343
|
setLogoVariant(editor, "light", "asset:logo-white"); // default stays a bare source; more variants make a LogoSet
|
|
325
|
-
setHeaderFooterZone(editor, "footer", "right", {
|
|
344
|
+
setHeaderFooterZone(editor, "footer", "right", { text: "{{slide.number}} / {{deck.slideCount}}", logo: true });
|
|
345
|
+
insertZoneValue(editor, "footer", "left", "organization.name", { start: 0, end: 0 }); // puts {{organization.name}} at the start of the left text
|
|
326
346
|
```
|
|
327
347
|
|
|
328
348
|
| Option | Writes | Values |
|
|
@@ -337,9 +357,9 @@ setHeaderFooterZone(editor, "footer", "right", { slideNumber: true, logo: true }
|
|
|
337
357
|
| `logo` | `design.logo` | a source, an Asset object or a LogoSet; `setLogoVariant` edits one of the 12 variants |
|
|
338
358
|
| `organizationLogo` | `organization.logo` (deck only; `index` picks an organization) | a source or Asset object |
|
|
339
359
|
| `watermark` | `design.watermark` | `false`, a source, `{ src, opacity }` or the text stamp `{ text, opacity }` (fields merge; a lone source stays a bare source; setting `text` drops `src` and setting `src` drops `text`: a watermark is one or the other) |
|
|
340
|
-
| header and footer zones | `design.header` / `design.footer` `.left/.center/.right` | `setHeaderFooterZone` merges every part a zone can hold (`text`, `logo`, `image`, `
|
|
360
|
+
| header and footer zones | `design.header` / `design.footer` `.left/.center/.right` | `setHeaderFooterZone` merges every part a zone can hold (`text`, `logo`, `image`, `date` (true, or a fixed date) and `dateFormat`, `socials`; `ZONE_FIELDS`) into one zone, checking each value. Generated values are `{{ }}` variables written in `text`, in the order the author wants and with `\n` between lines: `{{slide.number}}`, `{{deck.slideCount}}`, `{{slide.section}}`, `{{organization.name}}`, `{{speaker.name}}`, `{{deck.name}}` (the entries of `ZONE_VALUES`, the panel's "Insert value" menu). `insertZoneValue(editor, which, zone, name, { start, end, text, slideIndex })` puts one token into a zone's text at UTF-16 offsets (a selection is replaced; the end by default; `text` when a text box holds typing that is not committed yet) as one undoable, validated edit, and `prepareZoneValue` returns the patch without a session. The 0.16 fields `organization`, `speaker`, `section`, `slideNumber` and `slideNumberFormat` are gone with no alias (`{current}` and `{total}` too), and are refused with a message that names the variable to write instead. A removed field, an emptied zone and an emptied header are all deleted rather than left as `{}`; a removed field, an emptied zone and an emptied header are all deleted rather than left as `{}`. A slide's own header or footer replaces the deck's whole one, so the first edit on a slide starts from a copy of the deck's (its other zones stay), and a slide emptied that way hides the furniture (`false`) instead of inheriting it again |
|
|
341
361
|
|
|
342
|
-
A deck-scope change reports `shadowed` slides whose own design hides it (`clearSlideOverrides: true` removes those values in the same transaction). Results carry `warnings`: a header or footer zone with `logo: true`, or picture bullets, with no logo to draw (no slide, deck or primary-organization logo) is reported as `unresolved-logo`, and a zone that shows the organization
|
|
362
|
+
A deck-scope change reports `shadowed` slides whose own design hides it (`clearSlideOverrides: true` removes those values in the same transaction). Results carry `warnings`: a header or footer zone with `logo: true`, or picture bullets, with no logo to draw (no slide, deck or primary-organization logo) is reported as `unresolved-logo`, and a zone that shows the organization's social profiles when there are none, or whose text uses a deck-wide variable the document has no value for (`{{organization.name}}` without an organization, `{{speaker.name}}` without a named speaker), or `{{slide.section}}` on a slide without a section, as `unresolved-content`, before export, as `designWarnings(presentation, slideIndex)` does for the current document. `DESIGN_OPTIONS` describes every option for a generic panel, and `getDesignOption` reads `{ value, scope, inherited }`.
|
|
343
363
|
|
|
344
364
|
### Image uploads (RR-06)
|
|
345
365
|
|
|
@@ -398,6 +418,8 @@ setTableCellStyle(editor, "slides.4.blocks.1.table", [{ section: "header", colum
|
|
|
398
418
|
|
|
399
419
|
Styles use scheme roles, so they follow the color scheme, and the renderers keep the text readable on any fill. A style sets the header fill (`theme`, `plain`, `accent`), banded rows and borders (`theme`, `none`, `horizontal`, `grid`); named presets are `theme`, `banded`, `grid`, `minimal` and `open`, and `theme` removes a previous style. A style owns only the fill and border fields of a cell: text color, alignment, padding, values and merges are kept. Merging never hides text: covered cells that hold text refuse with `merge-would-lose-content` unless you pass `join: true`, which joins the words into the anchor with a space and keeps run formatting. Other refusals are `merge-overlap` (the region crosses another merge), `invalid-table-span` (outside the table, or a header cell spanning into the body) and `table-cell-covered`. Splitting leaves the formerly covered cells as empty text. `parseTableCellPath` maps a canvas selection to a table and cell, and `describeTableCell` and `readTableStyle` read the current state.
|
|
400
420
|
|
|
421
|
+
Table alt text (FA-27): `setTableAlt(editor, tablePath, { alt: "North America leads EMEA in Q4." })` writes the table's text alternative (`Table.alt`) as one undoable patch, like a chart's. `alt` is trimmed and an empty string or `null` removes it; `{ decorative: true }` writes the empty alt (decorative, and it wins over `alt`) and `{ decorative: false }` removes an empty alt. It works on a table that shows a shared dataset too, since the alt belongs to the table and not to its data. `readTableAlt(table)` gives `{ alt, decorative }` for a form and `prepareTableAlt` is the headless patch. The table section of the design controls has an "Alt text" field and a "Decorative (no alt text)" box, and find and replace searches `table.alt`. `validate` does not ask for a table alt, so the field is optional.
|
|
422
|
+
|
|
401
423
|
## Slide management (RR-21)
|
|
402
424
|
|
|
403
425
|
`@openpresentation/opf-editor/slides` adds, duplicates, deletes, reorders, hides and sections slides. Every function has a `prepare…` form that returns the JSON Patch for a document without touching a session (`{ presentation, patches, changed, selection }`), and an apply form that commits it as ONE validated, undoable change, so a single Undo restores the deck exactly and the preview, thumbnails and PPTX export follow from the document. An operation that changes nothing commits nothing (`changed: false`).
|
|
@@ -478,7 +500,7 @@ const selectionControls = createDesignControls(contentPanel, { editor, getSlideI
|
|
|
478
500
|
controls.refresh(); // when the slide or the selection changes
|
|
479
501
|
```
|
|
480
502
|
|
|
481
|
-
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: text,
|
|
503
|
+
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), logo, image, social profiles, and the current or fixed date and format, with a summary of the zones in use), `brand` (logo variants, organization logo, 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 and alignment). 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, shows what a conversion loses before you choose it, and lists why an unavailable one is unavailable.
|
|
482
504
|
|
|
483
505
|
In React (or Svelte, or anything else) mount it into a ref and destroy it on unmount; it needs no framework runtime:
|
|
484
506
|
|
|
@@ -540,13 +562,13 @@ const panel = createTemplatePanel(container, {
|
|
|
540
562
|
});
|
|
541
563
|
```
|
|
542
564
|
|
|
543
|
-
The panel lists every variable with the input its kind needs (text, number, date, color, link, one-entry-per-line list, and an image source with an asset pick or an uploaded file, 5 MB at most), marks which are filled, defaulted, optional or still needed, says where each is used, rejects a bad value in place, and previews the result as values change. **Fill the presentation** resolves the variables and replaces the document with the concrete deck as one validated, undoable edit (one Undo restores the template); **Fill what is ready** keeps the unfilled variables declared. **Insert a variable into text** inserts `{{id}}` into the selected text, optionally declaring a new variable in the same edit. A **Built-in variables** section lists the values the document supplies itself (`deck.*`, `speaker.*`, `speakers`, `organization.*`, and `speaker.<id>.*` / `organization.<id>.*` by id), read-only, each with its current value or "not set" and where it is used; they are filled from the document's own fields, not in the panel, and the same names appear in the insert list so `{{speaker.name}}` can be inserted without declaring anything. A checkbox marks the document as a template.
|
|
565
|
+
The panel lists every variable with the input its kind needs (text, number, date, color, link, one-entry-per-line list, and an image source with an asset pick or an uploaded file, 5 MB at most), marks which are filled, defaulted, optional or still needed, says where each is used, rejects a bad value in place, and previews the result as values change. **Fill the presentation** resolves the variables and replaces the document with the concrete deck as one validated, undoable edit (one Undo restores the template); **Fill what is ready** keeps the unfilled variables declared. **Insert a variable into text** inserts `{{id}}` into the selected text, optionally declaring a new variable in the same edit. A **Built-in variables** section lists the values the document supplies itself (`deck.*`, `speaker.*`, `speakers`, `organization.*`, and `speaker.<id>.*` / `organization.<id>.*` by id), read-only, each with its current value or "not set" and where it is used; they are filled from the document's own fields, not in the panel, and the same names appear in the insert list so `{{speaker.name}}` can be inserted without declaring anything. The three slide-scoped built-ins (`slide.number`, `slide.section`, `deck.slideCount`; `scope: "slide"` in `listBuiltins`) come last and have no single value: the panel shows them as "varies per slide" (and notes when no slide has a section), because each slide gets its own when it is drawn or exported. A checkbox marks the document as a template.
|
|
544
566
|
|
|
545
|
-
The headless pieces are usable on their own: `listTemplateFields(presentation, values)`, `templateStatus`, `previewTemplate`, `createTemplateFill(editor)` (`set`, `setText`, `clear`, `reset`, `preview`, `apply({partial})`), `declareVariable`, `setTemplate`, `insertVariableToken(editor, path, id, {start, end, runIndex, format, declare})`, `variableToken` (also for built-in names), `listBuiltins(document)` (the built-ins with kind, label, value, availability and uses), `suggestVariableId`. Every write goes through the session. The canvas draws a template as authored (`renderOptions.variables` defaults to `false`), so its tokens stay visible and an inline edit never overwrites one with resolved text; the panel's preview draws the resolved deck. The playground adds a **Fill template** button.
|
|
567
|
+
The headless pieces are usable on their own: `listTemplateFields(presentation, values)`, `templateStatus`, `previewTemplate`, `createTemplateFill(editor)` (`set`, `setText`, `clear`, `reset`, `preview`, `apply({partial})`), `declareVariable`, `setTemplate`, `insertVariableToken(editor, path, id, {start, end, runIndex, format, declare})`, `variableToken` (also for built-in names), `listBuiltins(document)` (the built-ins with kind, label, scope, value, availability and uses), `suggestVariableId`. Every write goes through the session. The canvas draws a template as authored (`renderOptions.variables` defaults to `false`), so its tokens stay visible and an inline edit never overwrites one with resolved text; the panel's preview draws the resolved deck. The slide-scoped built-ins are the exception on both: the renderer substitutes them for the slide it draws (its own number, section and the deck's count), and `editor.composeSlide(index, { slideNumber, slideCount })` composes that substituted slide, while the document and an inline edit keep the token. The playground adds a **Fill template** button.
|
|
546
568
|
|
|
547
569
|
## Find and replace (RR-25)
|
|
548
570
|
|
|
549
|
-
`@openpresentation/opf-editor/find` is the headless model and `/find-panel` the DOM panel. The model lists every piece of text in a document (`collectSearchFields`: presentation name and description, header and footer text, and per slide the title, subtitle, tag, section, text and its runs, list items and bullets, code, string metrics and their label, unit and delta, quotes with attribution and source, timeline names, dates and events, chart column labels and string cells, table headers and cells including styled cells, image and video alt text, and speaker notes). Numbers, ids, asset references, URLs, colours and layout names are never searched or changed. `findMatches(presentation, query, { matchCase, wholeWord, regex, notes, slideIndex })` returns the matches with the field, offsets and a context snippet; an invalid regular expression comes back as `error` and an empty match is skipped. `replaceAll(editor, query, replacement, options)` replaces every match as **one** `applyPatch` (one undo step restores every field; a document the schema rejects changes nothing) and `replaceMatch(editor, match, query, replacement, options)` replaces one. In regular-expression mode the replacement reads `$&`, `$1` to `$99`, `$<name>`, `` $` ``, `$'` and `$$`; otherwise it is literal.
|
|
571
|
+
`@openpresentation/opf-editor/find` is the headless model and `/find-panel` the DOM panel. The model lists every piece of text in a document (`collectSearchFields`: presentation name and description, header and footer text, and per slide the title, subtitle, tag, section, text and its runs, list items and bullets, code, string metrics and their label, unit and delta, quotes with attribution and source, timeline names, dates and events, chart column labels and string cells, table headers and cells including styled cells, image and video alt text, and speaker notes). Numbers, ids, asset references, URLs, colours and layout names are never searched or changed. `findMatches(presentation, query, { matchCase, wholeWord, regex, notes, slideIndex })` returns the matches with the field, offsets and a context snippet; an invalid regular expression comes back as `error` and an empty match is skipped, and so is a match that touches a variable token such as `{{slide.number}}` (a token is not text, and replacing part of its name would break it or turn it into literal text). `replaceAll(editor, query, replacement, options)` replaces every match as **one** `applyPatch` (one undo step restores every field; a document the schema rejects changes nothing) and `replaceMatch(editor, match, query, replacement, options)` replaces one. In regular-expression mode the replacement reads `$&`, `$1` to `$99`, `$<name>`, `` $` ``, `$'` and `$$`; otherwise it is literal.
|
|
550
572
|
|
|
551
573
|
**Rich text.** A run array is searched as its runs joined, so a phrase that crosses a bold/plain boundary is found. A match inside one run changes only that run and every run keeps its formatting. A match that spans several runs is replaced with the formatting of the run that holds the first matched character (the Word and Google Docs rule): the matched characters of the later runs are removed, text before and after keeps its own formatting, and a run left empty is dropped (a field always keeps at least one run). A run written as a plain string stays a plain string.
|
|
552
574
|
|
|
@@ -595,7 +617,7 @@ import { exportDeck } from "@openpresentation/opf-editor/export";
|
|
|
595
617
|
const result = await exportDeck(editor.presentation, {
|
|
596
618
|
format: "pdf", // "pdf" | "png" | "svg"
|
|
597
619
|
slides: "all", // or "current" with slideIndex, or [slide numbers]; hidden slides only with includeHidden
|
|
598
|
-
pdfMode: "vector", // or "raster" (an image per slide); PNG and raster density: scale 1 to 4
|
|
620
|
+
pdfMode: "vector", // or "raster" (an image per slide; also pass pdfLib: import * as pdfLib from "pdf-lib"); PNG and raster density: scale 1 to 4
|
|
599
621
|
fonts, // the renderer's fonts handle (loadFonts): measures, loads the faces the deck needs, supplies the faces to embed
|
|
600
622
|
renderOptions, // catalogs, date, ...
|
|
601
623
|
signal, onProgress, onDiagnostic,
|
|
@@ -609,7 +631,11 @@ const result = await exportDeck(editor.presentation, {
|
|
|
609
631
|
- **Names.** `exportFileName(deck, ext, suffix)` uses the deck's `filename` (a trailing .pptx/.pdf/.png/.svg dropped), else the slugified `name`, else `presentation`; slides are `name-01.png`, archives `name-png.zip`.
|
|
610
632
|
- **Progress and cancel.** `onProgress({ stage, done, total, message })` reports fonts, drawing, per-page conversion and packing; an aborted `signal` rejects with `export-aborted` between pages and slides.
|
|
611
633
|
- **Diagnostics.** `describeDiagnostic` normalises renderer and converter notes into `{ code, severity, message, slide? }`: `pdf-font-substituted`, `pdf-glyph-missing`, `pdf-raster-fallback` and the renderer's own are `warning`; `pdf-font-embedded` is `info`.
|
|
612
|
-
- PDF and PNG need `@openpresentation/opf-render` with its `export-browser` entry (RR-23, opf-render#105); without it they reject with `export-unavailable` and SVG still works. Verified in Chromium; Safari and Firefox are not exercised in CI. The playground bundle grows by the PDF writer, fontkit shaping and the bidi algorithm.
|
|
634
|
+
- PDF and PNG need `@openpresentation/opf-render` with its `export-browser` entry (RR-23, opf-render#105); without it they reject with `export-unavailable` and SVG still works. The entry imports no PDF library (opf-render 0.16): the vector PDF, PNG and SVG need nothing else, and `pdfMode: "raster"` needs the `pdfLib` option. Verified in Chromium; Safari and Firefox are not exercised in CI. The playground bundle grows by the PDF writer, fontkit shaping and the bidi algorithm.
|
|
635
|
+
|
|
636
|
+
Catalog controls initialize an absent deck or slide `design` for known theme, color-scheme and font-scheme fields as one validated transaction. Undo removes a newly created parent; existing design fields are retained. DOM, React and Svelte catalog controls accept `onError(error)` and emit a bubbling `opferror` event on rejected edits, restore the committed selection and expose native validity feedback. General `editor.set` and JSON Patch require existing parents.
|
|
637
|
+
|
|
638
|
+
Optional downloads can be deferred until requested: dynamically import `@openpresentation/opf-editor/export` inside the download handler. For a PowerPoint download, dynamically import `@openpresentation/opf-pptx` inside that handler too; the `/export` helper covers PDF, PNG and SVG. This defers conversion code, while canvas rendering and font measurement still have their own startup costs.
|
|
613
639
|
|
|
614
640
|
## Optional React Bindings
|
|
615
641
|
|
|
@@ -657,7 +683,7 @@ Public npm package publication is handled by `.github/workflows/release.yml` thr
|
|
|
657
683
|
|
|
658
684
|
The current checkout uses `@openpresentation/opf/composition` for portable geometry. Slides can select `auto`, `row`, `column`, or `grid`, set weighted tracks, and request path-specific overflow diagnostics. See the sibling OPF repo's `docs/dynamic-composition.md` for the complete contract.
|
|
659
685
|
|
|
660
|
-
|
|
686
|
+
The current editor requires core `^0.15.1`; canvas rendering uses the optional renderer peer `^0.15.0`, and playground PPTX import/export uses `@openpresentation/opf-pptx@^0.15.0`. Clean registry installs support composition without sibling checkouts. For coordinated source development, build OPF and run `node scripts/link-ecosystem.mjs` there; `pnpm test:ecosystem` verifies shared geometry and import/export behavior.
|
|
661
687
|
|
|
662
688
|
## Local interactive demo
|
|
663
689
|
|
|
@@ -669,7 +695,7 @@ Nested content groups expose their bounds through `editor.composeSlide(index).gr
|
|
|
669
695
|
|
|
670
696
|
`editor.paginateSlide(index, {minFontSize:24})` splits a crowded draft into ordinary OPF slides as one undoable transaction. It returns the change and source mappings; failed pagination leaves the document unchanged. The playground’s Split overflow action demonstrates the workflow.
|
|
671
697
|
|
|
672
|
-
`composeSlide(index, { fonts })` and `paginateSlide(index, { fonts })` accept the same fonts handle as preview and export. The local playground now uses bundled, embedded fonts and reports substitutions. The font a user selects (for example Calibri or Aptos) is the source of truth and stays in the document; because license-restricted (proprietary) fonts are never bundled, the canvas draws an open look-alike (a metric-compatible one such as Carlito for Calibri where it exists,
|
|
698
|
+
`composeSlide(index, { fonts })` and `paginateSlide(index, { fonts })` accept the same fonts handle as preview and export. The local playground now uses bundled, embedded fonts and reports substitutions. The font a user selects (for example Calibri or Aptos) is the source of truth and stays in the document; because license-restricted (proprietary) fonts are never bundled, the canvas draws an open look-alike (a metric-compatible one such as Carlito for Calibri where it exists, the metric-compatible Intos family for Aptos) and the substitution report says so. Since 0.14, PPTX export names the selected font, while preview substitutions remain visible in the font report. See the [OPF font policy](https://github.com/OpenPresentation/opf/blob/main/docs/font-fidelity.md#font-policy-ff-31).
|
|
673
699
|
|
|
674
700
|
|
|
675
701
|
## Copy, paste, and gallery imports
|
|
@@ -680,7 +706,7 @@ The `/galleries` export provides `loadOpfGallery` and `loadOpfGalleryItem`, acce
|
|
|
680
706
|
|
|
681
707
|
The playground includes Copy OPF and Import dialogs with paste, file/drop, gallery search, and URL tabs. Files accept OPF JSON and PowerPoint `.pptx` up to 20 MB. PPTX conversion runs locally using `@openpresentation/opf-pptx`; review the converted preview and diagnostics before inserting slides or opening a presentation. Applying an import is one undoable edit. Custom registries persist in local storage; defaults are configured in `examples/galleries.json`. Copy permissions may require using the Select all fallback. Pasting within text fields keeps normal text editing. Cmd/Ctrl+Shift+C opens copying and Cmd/Ctrl+O opens file import.
|
|
682
708
|
|
|
683
|
-
The **PowerPoint** button commits the current canvas draft and prepares an editable `.pptx` download from that snapshot, using the preview's text measurements. Conversion diagnostics remain visible in the download dialog. Missing/remote images require an explicit host resolver or embedded data; export does not silently fetch them or omit them. Save OPF separately to retain the complete original source.
|
|
709
|
+
The **PowerPoint** button commits the current canvas draft and prepares an editable `.pptx` download from that snapshot, using the preview's text measurements. Conversion diagnostics remain visible in the download dialog. Missing/remote images require an explicit host resolver or embedded data; export does not silently fetch them or omit them. Save OPF separately to retain the complete original source. Since 0.14, PPTX export names the font the user selected, so PowerPoint uses it when installed or available as a Microsoft 365 cloud font. The download does not embed font binaries, and license-restricted (proprietary) fonts are never embedded. Arbitrary native positions and unsupported PowerPoint features can change during reimport.
|
|
684
710
|
|
|
685
711
|
Run `npm ci`, `npm run build:playground`, then serve `artifacts/playground` with a local static server. This example uses the published core/render/PPTX packages and this checkout's editor source; it does not require an account, AI provider or paid service. `npm run test:playground` builds and executes a real browser flow (local Edge on Windows, installed Chromium in CI): author JSON, inline edit, export a native merged table, validate/reimport the actual downloaded file, undo/redo, save OPF and reject malformed input without changing the document. It is browser behavior evidence, not a claim of native PowerPoint raster equivalence.
|
|
686
712
|
|
|
@@ -716,6 +742,8 @@ The canvas can show draggable, keyboard-accessible dividers for root and nested
|
|
|
716
742
|
|
|
717
743
|
### Move complete blocks
|
|
718
744
|
|
|
745
|
+
When the slide's layout record has placeholder groups (FA-26, `{ "type": "group", "composition", "placeholders" }`), Arrange also outlines every slot of the record at its cell (`geometry.slots`): groups, filled regions and labelled empty regions. The slots belong to the record, so they are shown, not edited; the slide's content in them keeps its own selection and handles. Layout pickers and the JSON field menu describe such a record as `title, column (text, text), chart` and compare layouts by their leaf regions.
|
|
746
|
+
|
|
719
747
|
Arrange mode also shows numbered block handles. Drag to reorder siblings, use arrow keys for earlier/later, or click a handle to choose an existing destination group/slide and insertion position. The move preserves the whole block, including rich text, table/chart data, and nested groups. Each move is validated, preflighted by the renderer, and undoable. Parent weights stay with layout positions; moves that leave an empty container or create a containment cycle are rejected.
|
|
720
748
|
|
|
721
749
|
Agents can import `prepareBlockMove` and `listBlockContainers` from `@openpresentation/opf-editor/layout`. The prepared result includes a full candidate document, guarded atomic patches, the new block path, and a changed flag. Destination indexes refer to the pre-removal document. Use `canvas.openBlockMenu(path)` for the corresponding browser controls.
|
package/dist/canvas.js
CHANGED
|
@@ -23,7 +23,7 @@ import { createRichTextInput } from "./rich-text-input.js";
|
|
|
23
23
|
import { textInputOffsetAtPoint } from "./text-pointer.js";
|
|
24
24
|
import { createRichTextToolbar } from "./rich-text-toolbar.js";
|
|
25
25
|
import { createImageCropper } from "./image-cropper.js";
|
|
26
|
-
import { FONTS_PENDING, fontGate, fontsPendingError,
|
|
26
|
+
import { FONTS_PENDING, fontGate, fontsPendingError, whenFontsReady } from "./font-gate.js";
|
|
27
27
|
import { checkFormat, firstErrorMessage } from "./checks.js";
|
|
28
28
|
export { getEditableFields } from "./canvas-fields.js";
|
|
29
29
|
export { whenFontsReady, FONTS_PENDING, FONTS_UNAVAILABLE } from "./font-gate.js";
|
|
@@ -75,7 +75,9 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
75
75
|
const handle = options.fonts, fonts = fontGate(handle);
|
|
76
76
|
// The renderer draws with the session's registered catalogs (plus any `renderOptions.catalogs`): the same list core resolves with.
|
|
77
77
|
const resolveOptions = () => ({ ...renderOptions, catalogs: mergeCatalogs(editor.catalogs, renderOptions.catalogs) });
|
|
78
|
-
|
|
78
|
+
// RR-61: the handle measures, and the canvas embeds no face: the browser handle already added its faces to the page, so
|
|
79
|
+
// `@font-face` data would repeat megabytes of base64 in every redraw. Exports keep the default and carry the faces they draw.
|
|
80
|
+
const drawOptions = (extra) => ({ ...resolveOptions(), fonts: handle, embedFonts: false, ...extra });
|
|
79
81
|
let slideIndex = options.slideIndex ?? 0,
|
|
80
82
|
// RR-32: the canvas edits the document as authored, so a template's {{tokens}} stay visible and an inline edit never
|
|
81
83
|
// overwrites one with its resolved text. The Fill template panel previews the resolved deck. Pass `variables` to override.
|
|
@@ -256,6 +258,9 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
256
258
|
const svg = preview.querySelector("svg");
|
|
257
259
|
svg.setAttribute("role", "group");
|
|
258
260
|
svg.setAttribute("aria-label", "Editable slide");
|
|
261
|
+
// 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
|
|
262
|
+
// the roledescription rather than announce "Editable slide, slide".
|
|
263
|
+
svg.removeAttribute("aria-roledescription");
|
|
259
264
|
svg.removeAttribute("aria-labelledby");
|
|
260
265
|
const geometry = resolvePresentation(presentation, drawOptions()).slides[
|
|
261
266
|
slideIndex
|
|
@@ -286,6 +291,13 @@ export function createCanvasEditor(container, options = {}) {
|
|
|
286
291
|
continue;
|
|
287
292
|
seen.add(path);
|
|
288
293
|
node.setAttribute("data-canvas-target", "");
|
|
294
|
+
// FA-30: the renderer hides decorative drawing and a picture or chart with an empty alt (aria-hidden), and names a chart with
|
|
295
|
+
// alt as an image group. An editing target is focusable, so neither it nor a group around it may be hidden or presentational:
|
|
296
|
+
// an ancestor image group (the chart's alt) stays named but becomes a plain group so the targets inside it are reachable.
|
|
297
|
+
for (let up = node; up && up !== svg; up = up.parentNode) {
|
|
298
|
+
up.removeAttribute("aria-hidden");
|
|
299
|
+
if (up !== node && up.getAttribute("role") === "img") up.setAttribute("role", "group");
|
|
300
|
+
}
|
|
289
301
|
node.setAttribute("role", "button");
|
|
290
302
|
node.setAttribute("tabindex", "0");
|
|
291
303
|
node.setAttribute(
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Framework-neutral event handling: rejected edits restore the committed value
|
|
2
|
+
// and reach the host callback and a bubbling opferror event.
|
|
3
|
+
export function commitCatalogControl(node, onError, sync, commit) {
|
|
4
|
+
try {
|
|
5
|
+
commit();
|
|
6
|
+
node.setCustomValidity?.("");
|
|
7
|
+
node.removeAttribute?.("aria-invalid");
|
|
8
|
+
node.removeAttribute?.("data-opf-error");
|
|
9
|
+
return true;
|
|
10
|
+
} catch (error) {
|
|
11
|
+
sync();
|
|
12
|
+
node.setCustomValidity?.(error.message);
|
|
13
|
+
node.setAttribute?.("aria-invalid", "true");
|
|
14
|
+
node.setAttribute?.("data-opf-error", error.message);
|
|
15
|
+
onError?.(error);
|
|
16
|
+
const Event = node.ownerDocument?.defaultView?.CustomEvent;
|
|
17
|
+
if (Event) node.dispatchEvent?.(new Event("opferror", { detail: error, bubbles: true }));
|
|
18
|
+
node.reportValidity?.();
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
package/dist/design-controls.js
CHANGED
|
@@ -21,9 +21,11 @@ import {
|
|
|
21
21
|
DATE_FORMAT_TOKENS,
|
|
22
22
|
HEADER_FOOTER_ZONES,
|
|
23
23
|
LOGO_VARIANTS,
|
|
24
|
+
ZONE_VALUES,
|
|
24
25
|
designWarnings,
|
|
25
26
|
getDesignOption,
|
|
26
27
|
headerFooterState,
|
|
28
|
+
insertZoneValue,
|
|
27
29
|
prepareHeaderFooterZone,
|
|
28
30
|
prepareLogoVariant,
|
|
29
31
|
prepareDesignOption,
|
|
@@ -36,7 +38,7 @@ import {
|
|
|
36
38
|
import { createContentControls } from "./content-controls.js";
|
|
37
39
|
import { IMAGE_EDGES, IMAGE_FITS, IMAGE_SHAPES, readImageTreatments, setImageTreatment } from "./image-options.js";
|
|
38
40
|
import { applyCatalogUpdate, catalogRecordAt, checkCatalogUpdates, mergeCatalogs, moveToCustom, referenceFindings } from "./catalogs.js";
|
|
39
|
-
import { describeTableCell, mergeTableCells, parseTableCellPath, readTableStyle, setTableCellStyle, setTableStyle, splitTableCell } from "./table-options.js";
|
|
41
|
+
import { describeTableCell, mergeTableCells, parseTableCellPath, readTableAlt, readTableStyle, setTableAlt, setTableCellStyle, setTableStyle, splitTableCell } from "./table-options.js";
|
|
40
42
|
|
|
41
43
|
/** The sections a panel can show. `selection` and `table` follow the current selection; the rest follow the deck or the current slide. */
|
|
42
44
|
export const DESIGN_CONTROL_SECTIONS = Object.freeze(["look", "background", "header-footer", "brand", "layout-options", "info", "selection", "image", "table", "slide-content", "catalog"]);
|
|
@@ -228,9 +230,12 @@ export function createDesignControls(container, options = {}) {
|
|
|
228
230
|
return api;
|
|
229
231
|
}
|
|
230
232
|
|
|
231
|
-
|
|
233
|
+
// `multiline` makes it a two-line box: Enter commits (like a one-line field), Shift+Enter starts a new line.
|
|
234
|
+
function textField(name, label, { help, placeholder, list, onCommit, type = "text", multiline = false } = {}) {
|
|
232
235
|
const id = nextId(name);
|
|
233
|
-
const input =
|
|
236
|
+
const input = multiline
|
|
237
|
+
? h("textarea", { id, rows: 2, placeholder, autocomplete: "off", spellcheck: "false" })
|
|
238
|
+
: h("input", { id, type, placeholder, list: list ? `${id}-list` : undefined, autocomplete: "off", spellcheck: "false" });
|
|
234
239
|
const datalist = list ? h("datalist", { id: `${id}-list` }) : undefined;
|
|
235
240
|
const { wrap, note } = wrapField(id, label, input, { help, extra: datalist });
|
|
236
241
|
const api = {
|
|
@@ -246,6 +251,13 @@ export function createDesignControls(container, options = {}) {
|
|
|
246
251
|
},
|
|
247
252
|
};
|
|
248
253
|
if (onCommit) input.addEventListener("change", () => onCommit(input.value));
|
|
254
|
+
if (onCommit && multiline) {
|
|
255
|
+
input.addEventListener("keydown", (event) => {
|
|
256
|
+
if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
|
|
257
|
+
event.preventDefault();
|
|
258
|
+
onCommit(input.value);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
249
261
|
return api;
|
|
250
262
|
}
|
|
251
263
|
|
|
@@ -655,7 +667,7 @@ export function createDesignControls(container, options = {}) {
|
|
|
655
667
|
{ value: "footer", label: "Footer" },
|
|
656
668
|
]);
|
|
657
669
|
furniture.set(pick.which);
|
|
658
|
-
const zoneSelect = selectField("hf-zone", "Zone", { help: "Each zone stacks its parts top to bottom: logo, image, text,
|
|
670
|
+
const zoneSelect = selectField("hf-zone", "Zone", { help: "Each zone stacks its parts top to bottom: logo, image, text, social profiles, date." });
|
|
659
671
|
zoneSelect.setOptions(HEADER_FOOTER_ZONES.map((value) => ({ value, label: titleCase(value) })));
|
|
660
672
|
zoneSelect.set(pick.zone);
|
|
661
673
|
furniture.select.addEventListener("change", () => {
|
|
@@ -673,19 +685,47 @@ export function createDesignControls(container, options = {}) {
|
|
|
673
685
|
});
|
|
674
686
|
const edit = (fields, done) => run(() => setHeaderFooterZone(editor, which(), zone(), fields, scoped()), done);
|
|
675
687
|
const where = () => `${titleCase(which())} ${zone()}`;
|
|
676
|
-
const
|
|
688
|
+
const currentText = () => {
|
|
689
|
+
const value = readHeaderFooterZone(editor.presentation, which(), zone(), scoped()).text;
|
|
690
|
+
return typeof value === "string" ? value : "";
|
|
691
|
+
};
|
|
692
|
+
const text = textField("hf-text", "Text", {
|
|
693
|
+
multiline: true,
|
|
694
|
+
help: "Literal text, with values added by Insert value ({{slide.number}}, {{deck.slideCount}}, {{slide.section}}, {{organization.name}}, {{speaker.name}}, {{deck.name}}). Shift+Enter starts a new line. Clear it to remove.",
|
|
695
|
+
onCommit: (value) => {
|
|
696
|
+
if (value === currentText()) return;
|
|
697
|
+
edit({ text: value }, `${where()} text changed.`);
|
|
698
|
+
},
|
|
699
|
+
});
|
|
700
|
+
// The menu inserts a variable token at the caret (or over the selection) as one edit, together with any text typed but not yet committed.
|
|
701
|
+
const insertValue = selectField("hf-insert-value", "Insert value", {
|
|
702
|
+
empty: "Choose a value…",
|
|
703
|
+
help: "Adds a value to the text at the cursor. It is a {{ }} variable: slide number, slide count and section change on every slide.",
|
|
704
|
+
});
|
|
705
|
+
insertValue.setOptions(ZONE_VALUES.map((value) => ({ value: value.name, label: value.label, title: value.token })));
|
|
706
|
+
insertValue.select.addEventListener("change", () => {
|
|
707
|
+
const name = insertValue.select.value;
|
|
708
|
+
insertValue.select.value = "";
|
|
709
|
+
if (!name) return;
|
|
710
|
+
const box = text.input;
|
|
711
|
+
const base = box.value;
|
|
712
|
+
const start = Math.min(box.selectionStart ?? base.length, base.length);
|
|
713
|
+
const end = Math.min(box.selectionEnd ?? start, base.length);
|
|
714
|
+
const { token, label } = ZONE_VALUES.find((value) => value.name === name);
|
|
715
|
+
run(() => insertZoneValue(editor, which(), zone(), name, { ...scoped(), text: base, start, end }), `${where()} text now includes ${label.toLowerCase()} (${token}).`);
|
|
716
|
+
// Back in the text box after the token, ready to carry on typing. The box shows the document's text again first: a sync leaves a
|
|
717
|
+
// focused box alone, and a stale value would be committed over the insert when the box loses focus.
|
|
718
|
+
box.value = currentText();
|
|
719
|
+
const caret = start + token.length;
|
|
720
|
+
box.focus();
|
|
721
|
+
box.setSelectionRange?.(caret, caret);
|
|
722
|
+
});
|
|
677
723
|
const logo = checkField("hf-logo", "Show the logo", { onChange: (checked) => edit({ logo: checked }, `${where()} logo ${checked ? "shown" : "removed"}.`) });
|
|
678
724
|
const image = imageCluster("hf-image", "Image", {
|
|
679
725
|
apply: (ref) => setHeaderFooterZone(editor, which(), zone(), { image: ref }, scoped()),
|
|
680
726
|
build: (ref, doc) => prepareHeaderFooterZone(doc, which(), zone(), { image: ref }, scoped()),
|
|
681
727
|
help: "A picture in this zone, such as a partner mark or badge. An asset reference, a web address or a data address. Press Enter to apply; clear the field to remove it.",
|
|
682
728
|
});
|
|
683
|
-
const number = checkField("hf-number", "Show the slide number", { onChange: (checked) => edit({ slideNumber: checked }, `${where()} slide number ${checked ? "shown" : "removed"}.`) });
|
|
684
|
-
const numberFormat = textField("hf-number-format", "Slide number format", {
|
|
685
|
-
placeholder: "Page {current} of {total}",
|
|
686
|
-
help: "Must contain {current}; {total} is the number of slides. Clear it for the plain number.",
|
|
687
|
-
onCommit: (value) => edit({ slideNumberFormat: value.trim() }, `${where()} slide number format changed.`),
|
|
688
|
-
});
|
|
689
729
|
const dateNow = checkField("hf-date-now", "Show the current date", {
|
|
690
730
|
help: "Needs the host to supply today's date when it renders or exports; otherwise the part is reported as unresolved.",
|
|
691
731
|
onChange: (checked) => edit({ date: checked }, `${where()} date ${checked ? "shown" : "removed"}.`),
|
|
@@ -700,13 +740,11 @@ export function createDesignControls(container, options = {}) {
|
|
|
700
740
|
help: `Tokens: ${DATE_FORMAT_TOKENS.join(", ")}. Text in single quotes is literal. Clear it for the default (M/d/yyyy).`,
|
|
701
741
|
onCommit: (value) => edit({ dateFormat: value.trim() }, `${where()} date format changed.`),
|
|
702
742
|
});
|
|
703
|
-
const organization = checkField("hf-organization", "Show the organization name", { onChange: (checked) => edit({ organization: checked }, `${where()} organization ${checked ? "shown" : "removed"}.`) });
|
|
704
|
-
const speaker = checkField("hf-speaker", "Show the speaker name and title", { onChange: (checked) => edit({ speaker: checked }, `${where()} speaker ${checked ? "shown" : "removed"}.`) });
|
|
705
743
|
const socials = checkField("hf-socials", "Show the organization's social profiles", { onChange: (checked) => edit({ socials: checked }, `${where()} social profiles ${checked ? "shown" : "removed"}.`) });
|
|
706
|
-
|
|
744
|
+
let shownContext;
|
|
707
745
|
const summary = h("ul", { class: "opf-dc-summary", "aria-label": "Zones in use" });
|
|
708
746
|
const warnings = h("ul", { class: "opf-dc-warnings", "aria-label": "Header and footer warnings" });
|
|
709
|
-
const controls = [text, logo, image,
|
|
747
|
+
const controls = [text, insertValue, logo, image, dateNow, dateFixed, dateFormat, socials];
|
|
710
748
|
body.append(furniture.wrap, hide.wrap, zoneSelect.wrap, summary, ...controls.map((control) => control.wrap), warnings);
|
|
711
749
|
const describe = (fields) =>
|
|
712
750
|
Object.entries(fields)
|
|
@@ -718,19 +756,22 @@ export function createDesignControls(container, options = {}) {
|
|
|
718
756
|
hide.wrap.hidden = scopeIndex() === undefined ? !state.hidden : state.own && !state.hidden;
|
|
719
757
|
hide.set(state.hidden, state.inherited && !state.hidden ? "from the presentation" : "");
|
|
720
758
|
const fields = readHeaderFooterZone(editor.presentation, which(), zone(), scoped());
|
|
721
|
-
|
|
759
|
+
// A box that has the focus keeps what is being typed, except when it now belongs to another header or footer, zone or scope.
|
|
760
|
+
const textValue = typeof fields.text === "string" ? fields.text : "";
|
|
761
|
+
const textContext = `${target()}:${which()}:${zone()}`;
|
|
762
|
+
if (textContext !== shownContext) {
|
|
763
|
+
shownContext = textContext;
|
|
764
|
+
text.input.value = textValue;
|
|
765
|
+
}
|
|
766
|
+
text.set(textValue);
|
|
722
767
|
logo.set(fields.logo === true);
|
|
723
768
|
image.set(stringOf(fields.image), "", `${target()}:${which()}:${zone()}`);
|
|
724
|
-
number.set(fields.slideNumber === true);
|
|
725
|
-
numberFormat.set(typeof fields.slideNumberFormat === "string" ? fields.slideNumberFormat : "");
|
|
726
769
|
dateNow.set(fields.date === true);
|
|
727
770
|
dateFixed.set(typeof fields.date === "string" ? fields.date : "");
|
|
728
771
|
dateFormat.set(typeof fields.dateFormat === "string" ? fields.dateFormat : "");
|
|
729
|
-
organization.set(fields.organization === true);
|
|
730
|
-
speaker.set(fields.speaker === true);
|
|
731
772
|
socials.set(fields.socials === true);
|
|
732
|
-
|
|
733
|
-
for (const control of controls) for (const input of control.wrap.querySelectorAll("input,select,button")) input.disabled = state.hidden;
|
|
773
|
+
insertValue.set("");
|
|
774
|
+
for (const control of controls) for (const input of control.wrap.querySelectorAll("input,select,textarea,button")) input.disabled = state.hidden;
|
|
734
775
|
summary.replaceChildren(
|
|
735
776
|
...HEADER_FOOTER_ZONES.map((name) => {
|
|
736
777
|
const zoneFields = readHeaderFooterZone(editor.presentation, which(), name, scoped());
|
|
@@ -1158,8 +1199,19 @@ export function createDesignControls(container, options = {}) {
|
|
|
1158
1199
|
const align = selectField("cell-align", "Cell text alignment", { empty: "Default", onChange: (value) => run(() => setTableCellStyle(editor, dynamic.tablePath, dynamic.cell, { align: value === "" ? null : value }), "Cell alignment changed.") });
|
|
1159
1200
|
align.setOptions(["left", "center", "right"].map((value) => ({ value, label: titleCase(value) })));
|
|
1160
1201
|
const cellBox = h("div", { class: "opf-dc-cell" }, cellLabel, columns.wrap, rows.wrap, join.wrap, h("div", { class: "opf-dc-actions" }, merge, split), fill.wrap, align.wrap);
|
|
1161
|
-
|
|
1162
|
-
|
|
1202
|
+
// FA-27: the table's text alternative, like a chart's. It belongs to the table, so a dataset-backed table has it too.
|
|
1203
|
+
const alt = textField("table-alt", "Alt text", {
|
|
1204
|
+
help: "What the table shows: its point and the key numbers, not \"a table\". Optional, because a screen reader reads a table cell by cell. Press Enter to apply.",
|
|
1205
|
+
placeholder: "What the table shows",
|
|
1206
|
+
onCommit: (value) => run(() => setTableAlt(editor, dynamic.tablePath, { alt: value }), "Table alt text changed."),
|
|
1207
|
+
});
|
|
1208
|
+
alt.input.setAttribute("aria-label", "Table alt text");
|
|
1209
|
+
const decorative = checkField("table-decorative", "Decorative (no alt text)", {
|
|
1210
|
+
onChange: (checked) => run(() => setTableAlt(editor, dynamic.tablePath, { decorative: checked }), checked ? "Table marked decorative." : "The table is no longer decorative."),
|
|
1211
|
+
});
|
|
1212
|
+
const styleControls = [headerStyle.wrap, banding.wrap, borders.wrap, resetStyle, cellBox];
|
|
1213
|
+
body.append(summary, alt.wrap, decorative.wrap, ...styleControls);
|
|
1214
|
+
dynamic.table = { details, summary, alt, decorative, styleControls, headerStyle, banding, borders, cellBox, cellLabel, columns, rows, merge, split, fill, align, join };
|
|
1163
1215
|
}
|
|
1164
1216
|
|
|
1165
1217
|
// --- sync -----------------------------------------------------------------------------------------
|
|
@@ -1210,12 +1262,21 @@ export function createDesignControls(container, options = {}) {
|
|
|
1210
1262
|
const tablePath = parsed?.tablePath ?? (content?.key === "table" ? `${blockPath}.table` : undefined);
|
|
1211
1263
|
dynamic.tablePath = tablePath;
|
|
1212
1264
|
dynamic.cell = parsed?.cell;
|
|
1213
|
-
// A table that shows a shared dataset (RR-54) has no cell styles or merges: the
|
|
1265
|
+
// A table that shows a shared dataset (RR-54) has no cell styles or merges: the style and merge controls are for inline tables.
|
|
1266
|
+
// Its alt text (FA-27) belongs to the table and not to the data, so the panel still opens for it.
|
|
1214
1267
|
const tableValue = tablePath ? editor.get(tablePath) : undefined;
|
|
1215
|
-
|
|
1268
|
+
const inline = Array.isArray(tableValue?.rows);
|
|
1269
|
+
tableUi.details.hidden = !tablePath || !(inline || typeof tableValue?.dataset === "string");
|
|
1216
1270
|
if (tablePath && !tableUi.details.hidden) {
|
|
1217
|
-
const table = tableValue;
|
|
1218
1271
|
tableUi.summary.textContent = `Table (${tablePath})`;
|
|
1272
|
+
const textAlternative = readTableAlt(tableValue);
|
|
1273
|
+
tableUi.alt.set(textAlternative.alt);
|
|
1274
|
+
tableUi.alt.input.disabled = textAlternative.decorative;
|
|
1275
|
+
tableUi.decorative.set(textAlternative.decorative);
|
|
1276
|
+
for (const node of tableUi.styleControls) node.hidden = !inline;
|
|
1277
|
+
}
|
|
1278
|
+
if (tablePath && !tableUi.details.hidden && inline) {
|
|
1279
|
+
const table = tableValue;
|
|
1219
1280
|
const style = readTableStyle(document_, tablePath);
|
|
1220
1281
|
dynamic.tableStyle = style.preset === "custom" ? { header: "theme", banding: false, borders: "theme" } : { header: style.header, banding: style.banding, borders: style.borders };
|
|
1221
1282
|
tableUi.headerStyle.set(style.header);
|
package/dist/design-options.d.ts
CHANGED
|
@@ -25,7 +25,15 @@ export declare const DESIGN_OPTIONS: readonly DesignOptionDescriptor[];
|
|
|
25
25
|
export declare const LOGO_VARIANTS: readonly ["default", "light", "dark", "stacked", "stackedLight", "stackedDark", "icon", "iconLight", "iconDark", "wordmark", "wordmarkLight", "wordmarkDark"];
|
|
26
26
|
export type LogoVariant = (typeof LOGO_VARIANTS)[number];
|
|
27
27
|
/** Every field a header or footer zone can hold. */
|
|
28
|
-
export declare const ZONE_FIELDS: readonly ["logo", "text", "image", "
|
|
28
|
+
export declare const ZONE_FIELDS: readonly ["logo", "text", "image", "date", "dateFormat", "socials"];
|
|
29
|
+
/** One entry of a zone's "Insert value" menu: the built-in variable `name`, its menu `label` and the `token` (`{{name}}`) that lands in the zone's `text`. */
|
|
30
|
+
export interface ZoneValue {
|
|
31
|
+
readonly name: "slide.number" | "deck.slideCount" | "slide.section" | "organization.name" | "speaker.name" | "deck.name";
|
|
32
|
+
readonly label: string;
|
|
33
|
+
readonly token: string;
|
|
34
|
+
}
|
|
35
|
+
/** The values a zone's "Insert value" menu offers, in menu order: slide number, slide count, section, organization, speaker, deck name. */
|
|
36
|
+
export declare const ZONE_VALUES: readonly ZoneValue[];
|
|
29
37
|
/** Date format tokens (English names). */
|
|
30
38
|
export declare const DATE_FORMAT_TOKENS: readonly string[];
|
|
31
39
|
export declare const HEADER_FOOTER_ZONES: readonly ["left", "center", "right"];
|
|
@@ -87,24 +95,29 @@ export interface HeaderFooterZoneFields {
|
|
|
87
95
|
logo?: boolean | null;
|
|
88
96
|
text?: string | null;
|
|
89
97
|
image?: string | Record<string, unknown> | null;
|
|
90
|
-
slideNumber?: boolean | null;
|
|
91
|
-
slideNumberFormat?: string | null;
|
|
92
98
|
date?: boolean | string | null;
|
|
93
99
|
dateFormat?: string | null;
|
|
94
|
-
organization?: boolean | null;
|
|
95
|
-
/** The first speaker's name and title ("Ada Lovelace, CTO"). */
|
|
96
|
-
speaker?: boolean | null;
|
|
97
100
|
socials?: boolean | null;
|
|
98
|
-
section?: boolean | null;
|
|
99
101
|
}
|
|
100
102
|
export declare function prepareHeaderFooterZone(presentation: unknown, which: "header" | "footer", zone: HeaderFooterZone, fields: HeaderFooterZoneFields, options?: DesignOptionOptions): PreparedDesignOption;
|
|
101
|
-
/** Merge fields into one header or footer zone; null, false (flags) or "" remove a field, an empty zone and header are removed. A slide's own header replaces the deck's whole one, so the first edit on a slide starts from a copy of the deck's and keeps its other zones. */
|
|
103
|
+
/** Merge fields into one header or footer zone; null, false (flags) or "" remove a field, an empty zone and header are removed. Generated values are `{{ }}` variables in `text` (`ZONE_VALUES`). A slide's own header replaces the deck's whole one, so the first edit on a slide starts from a copy of the deck's and keeps its other zones. */
|
|
102
104
|
export declare function setHeaderFooterZone(editor: EditorSession, which: "header" | "footer", zone: HeaderFooterZone, fields: HeaderFooterZoneFields, options?: DesignOptionOptions): DesignOptionChange;
|
|
105
|
+
export interface ZoneValueOptions extends DesignOptionOptions {
|
|
106
|
+
/** UTF-16 offsets into the zone's text; a selection is replaced. Default: the end. */
|
|
107
|
+
start?: number;
|
|
108
|
+
end?: number;
|
|
109
|
+
/** The text the offsets refer to, when it is not the document's yet (a text box with uncommitted typing). Default: the zone's text at the scope. */
|
|
110
|
+
text?: string;
|
|
111
|
+
}
|
|
112
|
+
/** The patch that inserts a value's token (`ZONE_VALUES` name, for example `slide.number`) into a zone's `text`, at `start` and `end` (default: the end). */
|
|
113
|
+
export declare function prepareZoneValue(presentation: unknown, which: "header" | "footer", zone: HeaderFooterZone, name: ZoneValue["name"], options?: ZoneValueOptions): PreparedDesignOption;
|
|
114
|
+
/** Insert a value's token into one zone's `text` as a single undoable transaction. */
|
|
115
|
+
export declare function insertZoneValue(editor: EditorSession, which: "header" | "footer", zone: HeaderFooterZone, name: ZoneValue["name"], options?: ZoneValueOptions): DesignOptionChange;
|
|
103
116
|
/** One zone's fields as they apply at a scope: the slide's own header or footer when it has one, else the deck's. */
|
|
104
117
|
export declare function readHeaderFooterZone(presentation: unknown, which: "header" | "footer", zone: HeaderFooterZone, options?: Pick<DesignOptionOptions, "slideIndex">): HeaderFooterZoneFields;
|
|
105
118
|
/** Whether the scope sets the header or footer itself (`own`), shows the deck's (`inherited`) or hides it with `false` (`hidden`). */
|
|
106
119
|
export declare function headerFooterState(presentation: unknown, which: "header" | "footer", options?: Pick<DesignOptionOptions, "slideIndex">): { own: boolean; inherited: boolean; hidden: boolean };
|
|
107
120
|
/** Whether a logo resolves for the slide: slide design, deck design, then the primary organization. */
|
|
108
121
|
export declare function hasResolvableLogo(presentation: unknown, slideIndex: number): boolean;
|
|
109
|
-
/** Settings that need content the document does not have: a logo (zones with `logo: true`, picture bullets),
|
|
122
|
+
/** Settings that need content the document does not have: a logo (zones with `logo: true`, picture bullets), the organization's social profiles, or a value a zone's `text` asks for (`{{organization.name}}` without an organization, `{{slide.section}}` on a slide without a section). */
|
|
110
123
|
export declare function designWarnings(presentation: unknown, slideIndex?: number): DesignWarning[];
|