@openpresentation/opf-editor 0.16.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 +9 -8
- package/dist/design-controls.js +62 -21
- package/dist/design-options.d.ts +22 -9
- package/dist/design-options.js +77 -18
- package/dist/find-replace.js +9 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +8 -5
- package/dist/template-panel.js +6 -4
- package/dist/templates.d.ts +3 -1
- package/dist/templates.js +1 -1
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -336,12 +336,13 @@ The playground's Content tab mounts the block-level and slide-structure actions
|
|
|
336
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.
|
|
337
337
|
|
|
338
338
|
```js
|
|
339
|
-
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";
|
|
340
340
|
|
|
341
341
|
setDesignOption(editor, "titleAlignment", "center", { slideIndex: 2 }); // /slides/2/design/titleAlignment
|
|
342
342
|
setDesignOption(editor, "watermark", { src: "asset:mark", opacity: 0.1 }); // merges into an existing watermark; false hides an inherited one
|
|
343
343
|
setLogoVariant(editor, "light", "asset:logo-white"); // default stays a bare source; more variants make a LogoSet
|
|
344
|
-
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
|
|
345
346
|
```
|
|
346
347
|
|
|
347
348
|
| Option | Writes | Values |
|
|
@@ -356,9 +357,9 @@ setHeaderFooterZone(editor, "footer", "right", { slideNumber: true, logo: true }
|
|
|
356
357
|
| `logo` | `design.logo` | a source, an Asset object or a LogoSet; `setLogoVariant` edits one of the 12 variants |
|
|
357
358
|
| `organizationLogo` | `organization.logo` (deck only; `index` picks an organization) | a source or Asset object |
|
|
358
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) |
|
|
359
|
-
| 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 |
|
|
360
361
|
|
|
361
|
-
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 }`.
|
|
362
363
|
|
|
363
364
|
### Image uploads (RR-06)
|
|
364
365
|
|
|
@@ -499,7 +500,7 @@ const selectionControls = createDesignControls(contentPanel, { editor, getSlideI
|
|
|
499
500
|
controls.refresh(); // when the slide or the selection changes
|
|
500
501
|
```
|
|
501
502
|
|
|
502
|
-
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.
|
|
503
504
|
|
|
504
505
|
In React (or Svelte, or anything else) mount it into a ref and destroy it on unmount; it needs no framework runtime:
|
|
505
506
|
|
|
@@ -561,13 +562,13 @@ const panel = createTemplatePanel(container, {
|
|
|
561
562
|
});
|
|
562
563
|
```
|
|
563
564
|
|
|
564
|
-
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.
|
|
565
566
|
|
|
566
|
-
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.
|
|
567
568
|
|
|
568
569
|
## Find and replace (RR-25)
|
|
569
570
|
|
|
570
|
-
`@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.
|
|
571
572
|
|
|
572
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.
|
|
573
574
|
|
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,
|
|
@@ -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());
|
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[];
|
package/dist/design-options.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// session form that commits it with `meta.source: "design-option"`.
|
|
6
6
|
import { getValueAtPath, opfPathToJsonPointer } from "./index.js";
|
|
7
7
|
import { checkedDocument, designPatches, fail, same } from "./edit-helpers.js";
|
|
8
|
+
import { listBuiltinVariables } from "@openpresentation/opf";
|
|
8
9
|
import { checkFormat } from "./checks.js";
|
|
9
10
|
|
|
10
11
|
/** The `design.logo` variant slots of a LogoSet, in schema order. */
|
|
@@ -24,8 +25,30 @@ export const LOGO_VARIANTS = Object.freeze([
|
|
|
24
25
|
]);
|
|
25
26
|
export const HEADER_FOOTER_ZONES = Object.freeze(["left", "center", "right"]);
|
|
26
27
|
// Flag fields of a header/footer zone: false is stored as "absent" (so is a `date` of false).
|
|
27
|
-
const ZONE_FLAGS = ["logo", "
|
|
28
|
-
export const ZONE_FIELDS = Object.freeze(["logo", "text", "image", "
|
|
28
|
+
const ZONE_FLAGS = ["logo", "socials"];
|
|
29
|
+
export const ZONE_FIELDS = Object.freeze(["logo", "text", "image", "date", "dateFormat", "socials"]);
|
|
30
|
+
// FA-31: generated values are `{{ }}` variables inside a zone's `text`. The keys 0.16 had for them are gone; say what to write instead.
|
|
31
|
+
const REMOVED_ZONE_FIELDS = Object.freeze({
|
|
32
|
+
organization: "Write {{organization.name}} in the text.",
|
|
33
|
+
speaker: "Write {{speaker.name}} in the text.",
|
|
34
|
+
section: "Write {{slide.section}} in the text.",
|
|
35
|
+
slideNumber: "Write {{slide.number}} in the text.",
|
|
36
|
+
slideNumberFormat: "Write {{slide.number}} and {{deck.slideCount}} in the text.",
|
|
37
|
+
});
|
|
38
|
+
/**
|
|
39
|
+
* The values the "Insert value" menu of a header or footer zone offers, in menu order. `token` is what lands in the zone's `text`.
|
|
40
|
+
* `{{slide.number}}`, `{{deck.slideCount}}` and `{{slide.section}}` vary per slide; the others come from the deck's own fields.
|
|
41
|
+
*/
|
|
42
|
+
export const ZONE_VALUES = Object.freeze(
|
|
43
|
+
[
|
|
44
|
+
{ name: "slide.number", label: "Slide number" },
|
|
45
|
+
{ name: "deck.slideCount", label: "Slide count" },
|
|
46
|
+
{ name: "slide.section", label: "Section" },
|
|
47
|
+
{ name: "organization.name", label: "Organization" },
|
|
48
|
+
{ name: "speaker.name", label: "Speaker" },
|
|
49
|
+
{ name: "deck.name", label: "Deck name" },
|
|
50
|
+
].map((entry) => Object.freeze({ ...entry, token: `{{${entry.name}}}` })),
|
|
51
|
+
);
|
|
29
52
|
/** Date tokens a `dateFormat` understands (English names, independent of the host locale). */
|
|
30
53
|
export const DATE_FORMAT_TOKENS = Object.freeze(["yyyy", "yy", "MMMM", "MMM", "MM", "M", "dd", "d", "EEEE", "EEE"]);
|
|
31
54
|
const ISO_DATE = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/;
|
|
@@ -36,7 +59,6 @@ function checkZoneField(key, value) {
|
|
|
36
59
|
if (ZONE_FLAGS.includes(key) && typeof value !== "boolean") throw bad(`${key} is true or false.`);
|
|
37
60
|
if (key === "text" && typeof value !== "string") throw bad("Text is a string.");
|
|
38
61
|
if (key === "image" && typeof value !== "string" && !isObject(value)) throw bad("An image is a source, an asset reference or an asset object.");
|
|
39
|
-
if (key === "slideNumberFormat" && (typeof value !== "string" || !value.includes("{current}"))) throw bad("The slide number format must contain {current}, for example Page {current} of {total}.");
|
|
40
62
|
if (key === "date" && typeof value !== "boolean" && typeof value !== "string") throw bad("A date is true (the current date) or a fixed date.");
|
|
41
63
|
if (key === "dateFormat" && (typeof value !== "string" || !value.trim())) throw bad(`A date format uses tokens such as ${DATE_FORMAT_TOKENS.join(", ")}.`);
|
|
42
64
|
}
|
|
@@ -90,20 +112,23 @@ function primaryOrganization(presentation) {
|
|
|
90
112
|
return list.find((entry) => entry?.role === "primary") ?? list[0];
|
|
91
113
|
}
|
|
92
114
|
|
|
93
|
-
function firstSpeaker(presentation) {
|
|
94
|
-
const list = Array.isArray(presentation.speaker) ? presentation.speaker : presentation.speaker ? [presentation.speaker] : [];
|
|
95
|
-
return list[0];
|
|
96
|
-
}
|
|
97
|
-
|
|
98
115
|
/** Whether a logo resolves for the slide: slide design, then deck design, then the primary organization. */
|
|
99
116
|
export function hasResolvableLogo(presentation, slideIndex) {
|
|
100
117
|
return Boolean(presentation.slides?.[slideIndex]?.design?.logo ?? presentation.design?.logo ?? primaryOrganization(presentation)?.logo);
|
|
101
118
|
}
|
|
102
119
|
|
|
120
|
+
/** The variable names a zone's `text` uses (`{{name}}` and `{{name|format}}`); a token with a backslash before it is literal text. */
|
|
121
|
+
function zoneTextVariables(text) {
|
|
122
|
+
const names = [];
|
|
123
|
+
for (const match of text.matchAll(/(\\?)\{\{\s*([A-Za-z0-9_.-]+)\s*(?:\|[^{}]*)?\}\}/g)) if (!match[1]) names.push(match[2]);
|
|
124
|
+
return names;
|
|
125
|
+
}
|
|
126
|
+
|
|
103
127
|
/**
|
|
104
|
-
* Plain-language warnings for settings that need
|
|
105
|
-
*
|
|
106
|
-
*
|
|
128
|
+
* Plain-language warnings for settings that need content the document does not have: a logo (a header or footer
|
|
129
|
+
* zone with `logo: true`, picture bullets), the organization's social profiles, or a value a zone's `text` asks for
|
|
130
|
+
* with a variable (`{{organization.name}}` without an organization, `{{slide.section}}` on a slide without a section).
|
|
131
|
+
* The renderers fall back to a glyph or report unresolved content; the editor surfaces it before export.
|
|
107
132
|
*/
|
|
108
133
|
export function designWarnings(presentation, slideIndex = 0) {
|
|
109
134
|
const slide = presentation.slides?.[slideIndex];
|
|
@@ -115,15 +140,25 @@ export function designWarnings(presentation, slideIndex = 0) {
|
|
|
115
140
|
if (!hasLogo && isObject(design[which]) && design[which][zone]?.logo === true)
|
|
116
141
|
warnings.push({ code: "unresolved-logo", path: `design.${which}.${zone}.logo`, message: `The ${which} ${zone} zone shows the logo, but no logo is set. Add a logo or an organization logo.` });
|
|
117
142
|
const organization = primaryOrganization(presentation);
|
|
143
|
+
let builtins;
|
|
118
144
|
for (const which of ["header", "footer"])
|
|
119
145
|
for (const zone of HEADER_FOOTER_ZONES) {
|
|
120
146
|
const item = isObject(design[which]) ? design[which][zone] : undefined;
|
|
121
|
-
if (item?.organization === true && !organization)
|
|
122
|
-
warnings.push({ code: "unresolved-content", path: `design.${which}.${zone}.organization`, message: `The ${which} ${zone} zone shows the organization, but the presentation has none.` });
|
|
123
|
-
if (item?.speaker === true && !firstSpeaker(presentation)?.name)
|
|
124
|
-
warnings.push({ code: "unresolved-content", path: `design.${which}.${zone}.speaker`, message: `The ${which} ${zone} zone shows the speaker, but the presentation has no named speaker.` });
|
|
125
147
|
if (item?.socials === true && !organization?.socials)
|
|
126
148
|
warnings.push({ code: "unresolved-content", path: `design.${which}.${zone}.socials`, message: `The ${which} ${zone} zone shows social profiles, but the organization has none.` });
|
|
149
|
+
// FA-31: a value the zone's text asks for and the document cannot give (the core validator reports the same as variable-builtin-missing).
|
|
150
|
+
const path = `design.${which}.${zone}.text`;
|
|
151
|
+
for (const name of new Set(typeof item?.text === "string" ? zoneTextVariables(item.text) : [])) {
|
|
152
|
+
if (name === "slide.section") {
|
|
153
|
+
if (!(typeof slide?.section === "string" && slide.section !== ""))
|
|
154
|
+
warnings.push({ code: "unresolved-content", path, message: `The ${which} ${zone} zone shows {{slide.section}}, but this slide has no section.` });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
builtins ??= listBuiltinVariables(presentation);
|
|
158
|
+
const entry = builtins.find((candidate) => candidate.name === name);
|
|
159
|
+
if (entry?.scope === "deck" && !entry.available)
|
|
160
|
+
warnings.push({ code: "unresolved-content", path, message: `The ${which} ${zone} zone shows {{${name}}}, but the presentation has no value for it.` });
|
|
161
|
+
}
|
|
127
162
|
}
|
|
128
163
|
if (!hasLogo && design.listBullet === "image")
|
|
129
164
|
warnings.push({ code: "unresolved-logo", path: "design.listBullet", message: "Picture bullets use the logo, but no logo is set, so lists draw the character bullet. Add a logo or an organization logo." });
|
|
@@ -343,8 +378,9 @@ export function readLogoVariants(presentation, options = {}) {
|
|
|
343
378
|
|
|
344
379
|
/**
|
|
345
380
|
* Compute the patch that edits one header or footer zone. `fields` merges into the zone (logo, text,
|
|
346
|
-
* image,
|
|
347
|
-
*
|
|
381
|
+
* image, date, dateFormat, socials); `null`, `false` for a flag, or an empty string removes a field.
|
|
382
|
+
* Generated values (slide number, slide count, section, organization, speaker, deck name) are `{{ }}`
|
|
383
|
+
* variables in `text` (`ZONE_VALUES`, `insertZoneValue`). A zone left empty is removed, then an empty
|
|
348
384
|
* header or footer, so a slide never carries `{}`. A slide's own header or footer replaces the deck's
|
|
349
385
|
* whole one, so the first edit on a slide that has none of its own starts from a copy of the deck's
|
|
350
386
|
* (the other zones stay); a slide emptied that way hides the furniture (`false`) instead of
|
|
@@ -356,7 +392,7 @@ export function prepareHeaderFooterZone(presentation, which, zone, fields, optio
|
|
|
356
392
|
if (!HEADER_FOOTER_ZONES.includes(zone)) throw fail("invalid-design-value", `Zone is one of ${HEADER_FOOTER_ZONES.join(", ")}.`, { zone });
|
|
357
393
|
if (!isObject(fields)) throw fail("invalid-design-value", "Pass the zone fields to change.", { fields });
|
|
358
394
|
const unknown = Object.keys(fields).filter((key) => !ZONE_FIELDS.includes(key));
|
|
359
|
-
if (unknown.length) throw fail("invalid-design-value", `Unknown header/footer field: ${unknown[0]}
|
|
395
|
+
if (unknown.length) throw fail("invalid-design-value", `Unknown header/footer field: ${unknown[0]}.${Object.hasOwn(REMOVED_ZONE_FIELDS, unknown[0]) ? ` ${REMOVED_ZONE_FIELDS[unknown[0]]}` : ""}`, { fields });
|
|
360
396
|
const { scope, base, slideIndex } = scopeOf(presentation, "logo", options);
|
|
361
397
|
const own = ownDesign(presentation, base)[which];
|
|
362
398
|
const inherited = base.length ? presentation.design?.[which] : undefined;
|
|
@@ -387,6 +423,29 @@ export function setHeaderFooterZone(editor, which, zone, fields, options = {}) {
|
|
|
387
423
|
const { meta, ...rest } = options;
|
|
388
424
|
return apply(editor, prepareHeaderFooterZone(editor.presentation, which, zone, fields, rest), meta);
|
|
389
425
|
}
|
|
426
|
+
/**
|
|
427
|
+
* Compute the patch that inserts a value's token (`ZONE_VALUES`, for example `slide.number` for `{{slide.number}}`) into a zone's
|
|
428
|
+
* `text` at UTF-16 offsets `start` and `end` (a selection is replaced; default: the end of the text). The text is the one the scope
|
|
429
|
+
* shows (the slide's own zone, else the deck's), or `options.text` when a panel holds edits it has not committed yet.
|
|
430
|
+
*/
|
|
431
|
+
export function prepareZoneValue(presentation, which, zone, name, options = {}) {
|
|
432
|
+
const value = ZONE_VALUES.find((entry) => entry.name === name);
|
|
433
|
+
if (!value) throw fail("invalid-design-value", `Insert one of ${ZONE_VALUES.map((entry) => entry.name).join(", ")}.`, { value: name });
|
|
434
|
+
const { start, end, text: typed, ...rest } = options;
|
|
435
|
+
if (typed !== undefined && typeof typed !== "string") throw fail("invalid-design-value", "Text is a string.", { text: typed });
|
|
436
|
+
const current = typed ?? readHeaderFooterZone(presentation, which, zone, rest).text;
|
|
437
|
+
const text = typeof current === "string" ? current : "";
|
|
438
|
+
const from = start === undefined ? text.length : start;
|
|
439
|
+
const to = end === undefined ? from : end;
|
|
440
|
+
if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 || to < from || to > text.length) throw fail("invalid-selection", "The selection is outside the text.", { start: from, end: to });
|
|
441
|
+
return prepareHeaderFooterZone(presentation, which, zone, { text: `${text.slice(0, from)}${value.token}${text.slice(to)}` }, rest);
|
|
442
|
+
}
|
|
443
|
+
/** Insert a value's token into one zone's `text` as a single undoable transaction. */
|
|
444
|
+
export function insertZoneValue(editor, which, zone, name, options = {}) {
|
|
445
|
+
checkEditor(editor);
|
|
446
|
+
const { meta, ...rest } = options;
|
|
447
|
+
return apply(editor, prepareZoneValue(editor.presentation, which, zone, name, rest), meta);
|
|
448
|
+
}
|
|
390
449
|
/**
|
|
391
450
|
* One header or footer zone's fields as they apply at a scope: the slide's own header (or footer) when it
|
|
392
451
|
* has one, else the deck's (`{}` when absent or suppressed). `headerFooterState` says which.
|
package/dist/find-replace.js
CHANGED
|
@@ -21,6 +21,11 @@ const REGION_KEY = /^(?:top|middle|bottom|left|center|right)(?:[+:]|$)/;
|
|
|
21
21
|
const WORD_BEFORE = "(?<![\\p{L}\\p{N}_])";
|
|
22
22
|
const WORD_AFTER = "(?![\\p{L}\\p{N}_])";
|
|
23
23
|
|
|
24
|
+
// A variable token (`{{customer}}`, `{{slide.number}}`, `{{ deck.slideCount }}`, `{{revenue|0.0}}`). It is an instruction, not text: a match inside it is
|
|
25
|
+
// never offered, because replacing part of a name would either break the variable or silently change which value it shows (FA-31). An
|
|
26
|
+
// escaped `\{{` is literal text and is searched like the rest.
|
|
27
|
+
const VARIABLE_TOKEN = /(?<!\\)\{\{\s*[A-Za-z][A-Za-z0-9_.-]*\s*(?:\|[^{}]*)?\}\}/g;
|
|
28
|
+
|
|
24
29
|
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
25
30
|
const runText = (run) => (typeof run === "string" ? run : isObject(run) && typeof run.text === "string" ? run.text : "");
|
|
26
31
|
const isRuns = (value) => Array.isArray(value) && value.length > 0 && value.every((run) => typeof run === "string" || (isObject(run) && typeof run.text === "string"));
|
|
@@ -205,7 +210,8 @@ function snippet(text, start, end) {
|
|
|
205
210
|
* Search a document. Returns `{ matches, fieldCount, slideCount, truncated, error }`. A match is `{ id, pointer, path,
|
|
206
211
|
* slideIndex, label, runs, start, end, text, groups, named, context: { before, match, after } }` where `start` and `end`
|
|
207
212
|
* are offsets into the field's text. Options: those of `compileSearch`, plus `notes` and `slideIndex` of
|
|
208
|
-
* `collectSearchFields`. Matches that are empty (a regular expression that matches nothing) are skipped
|
|
213
|
+
* `collectSearchFields`. Matches that are empty (a regular expression that matches nothing) are skipped, and so is a match that
|
|
214
|
+
* touches a variable token (`{{slide.number}}`): the token is not text, and replacing part of it would break or change the variable.
|
|
209
215
|
*/
|
|
210
216
|
export function findMatches(presentation, query, options = {}) {
|
|
211
217
|
const compiled = compileSearch(query, options);
|
|
@@ -217,11 +223,13 @@ export function findMatches(presentation, query, options = {}) {
|
|
|
217
223
|
compiled.regex.lastIndex = 0;
|
|
218
224
|
let found;
|
|
219
225
|
let hit = false;
|
|
226
|
+
const tokens = item.text.includes("{{") ? [...item.text.matchAll(VARIABLE_TOKEN)].map((token) => [token.index, token.index + token[0].length]) : [];
|
|
220
227
|
while ((found = compiled.regex.exec(item.text))) {
|
|
221
228
|
if (found[0] === "") {
|
|
222
229
|
compiled.regex.lastIndex += 1;
|
|
223
230
|
continue;
|
|
224
231
|
}
|
|
232
|
+
if (tokens.some(([from, to]) => found.index < to && found.index + found[0].length > from)) continue;
|
|
225
233
|
if (result.matches.length >= MAX_MATCHES) {
|
|
226
234
|
result.truncated = true;
|
|
227
235
|
break outer;
|
package/dist/index.d.ts
CHANGED
|
@@ -72,6 +72,7 @@ export interface EditorHistory { undo: EditorHistoryEntry[]; redo: EditorHistory
|
|
|
72
72
|
|
|
73
73
|
export interface EditorSession {
|
|
74
74
|
paginateSlide(slideIndex: number, options?: PaginationOptions & EditorDiagnosticOptions, meta?: Record<string, unknown>): { change: EditorChange | null; pagination: PaginationResult };
|
|
75
|
+
/** Composes the slide with `{{slide.number}}`, `{{slide.section}}` and `{{deck.slideCount}}` substituted for `options.slideNumber` and `options.slideCount` (default: its place in the open deck and the deck's slide count); the document keeps the tokens. */
|
|
75
76
|
composeSlide(slideIndex: number, options?: ComposeSlideOptions & EditorDiagnosticOptions): SlideComposition;
|
|
76
77
|
setComposition(slideIndex: number, composition: Composition, meta?: Record<string, unknown>): EditorChange;
|
|
77
78
|
readonly presentation: unknown;
|
package/dist/index.js
CHANGED
|
@@ -32,12 +32,14 @@ export {
|
|
|
32
32
|
// theme, engine default), with the session's registered catalogs, so the editor measures and composes what the renderer draws
|
|
33
33
|
// and the exporter writes. `options.fonts` is the renderer's fonts handle (its `textMeasurement` measures); `options.catalogs`
|
|
34
34
|
// adds catalogs for this call; `options.onDiagnostic` hears each diagnostic (`unresolved-reference`);
|
|
35
|
-
// any other option overrides the resolved one (`layout`, ...).
|
|
35
|
+
// any other option overrides the resolved one (`layout`, ...). FA-31: `slide` is the slide to compose and draw, with `{{slide.number}}`,
|
|
36
|
+
// `{{slide.section}}` and `{{deck.slideCount}}` substituted for `slideNumber` and `slideCount` (default: its place in the open deck and the
|
|
37
|
+
// deck's length), the same numbers the options carry. Pagination takes the source slide instead and substitutes while it measures.
|
|
36
38
|
function slideContext(presentation, slideIndex, catalogs, { fonts, catalogs: _extra, onDiagnostic, ...overrides } = {}) {
|
|
37
39
|
if (!Number.isInteger(slideIndex) || !presentation.slides?.[slideIndex]) throw new OPFEditorError("slide-index-out-of-range", "Slide index is out of range.");
|
|
38
|
-
const { options, diagnostics } = resolveSlideContext(presentation, slideIndex, { fonts, catalogs });
|
|
40
|
+
const { slide, options, diagnostics } = resolveSlideContext(presentation, slideIndex, { fonts, catalogs, slideNumber: overrides.slideNumber, slideCount: overrides.slideCount });
|
|
39
41
|
for (const diagnostic of diagnostics) onDiagnostic?.(diagnostic);
|
|
40
|
-
return { ...options, ...overrides };
|
|
42
|
+
return { slide, options: { ...options, ...overrides } };
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
export const packageName = "@openpresentation/opf-editor";
|
|
@@ -276,12 +278,13 @@ export function createEditorSession(input, options = {}) {
|
|
|
276
278
|
});
|
|
277
279
|
},
|
|
278
280
|
composeSlide(slideIndex, options = {}) {
|
|
279
|
-
|
|
281
|
+
const { slide, options: resolved } = slideContext(presentation, slideIndex, mergeCatalogs(catalogs, options.catalogs), options);
|
|
282
|
+
return composeSlide(slide, resolved);
|
|
280
283
|
},
|
|
281
284
|
paginateSlide(slideIndex, options = {}, meta = {}) {
|
|
282
285
|
const list = mergeCatalogs(catalogs, options.catalogs);
|
|
283
286
|
// Core pagination reads the measurement from `fonts`, so the resolved context hands it over that way.
|
|
284
|
-
const { textMeasurement, ...resolved } = slideContext(presentation, slideIndex, list, options);
|
|
287
|
+
const { textMeasurement, ...resolved } = slideContext(presentation, slideIndex, list, options).options;
|
|
285
288
|
const pagination = paginateSlide(presentation.slides[slideIndex], {
|
|
286
289
|
...resolved,
|
|
287
290
|
catalogs: list,
|
package/dist/template-panel.js
CHANGED
|
@@ -67,7 +67,7 @@ export function createTemplatePanel(container, options) {
|
|
|
67
67
|
const builtinList = h(doc, "ul", { class: "opf-template-builtin-list" });
|
|
68
68
|
const builtinSection = h(doc, "details", { class: "opf-template-builtins", "data-opf-component": "template-builtins" },
|
|
69
69
|
h(doc, "summary", { text: "Built-in variables" }),
|
|
70
|
-
h(doc, "p", { class: "opf-template-help", text: "Read from the presentation's own speaker, organization and deck fields; edit those fields to change them. Use them like any variable: {{speaker.name}} inside text, or var:speaker.photo as a whole image field." }),
|
|
70
|
+
h(doc, "p", { class: "opf-template-help", text: "Read from the presentation's own speaker, organization and deck fields; edit those fields to change them. Use them like any variable: {{speaker.name}} inside text, or var:speaker.photo as a whole image field. Slide number, section and slide count vary per slide: write them as {{slide.number}}, {{slide.section}} and {{deck.slideCount}} inside any text, not as a whole var: field." }),
|
|
71
71
|
builtinList);
|
|
72
72
|
const live = h(doc, "p", { class: "opf-template-live", role: "status", "aria-live": "polite" });
|
|
73
73
|
const applyButton = h(doc, "button", { type: "button", class: "opf-template-apply primary", text: "Fill the presentation" });
|
|
@@ -234,10 +234,12 @@ export function createTemplatePanel(container, options) {
|
|
|
234
234
|
);
|
|
235
235
|
builtinSection.hidden = !builtins.length;
|
|
236
236
|
builtinList.replaceChildren(...builtins.map((entry) => {
|
|
237
|
-
const shown = builtinValueText(entry);
|
|
238
237
|
const uses = entry.uses.length;
|
|
239
|
-
|
|
240
|
-
|
|
238
|
+
// A slide-scoped built-in has no single value: each slide gets its own when it is drawn or exported (FA-31).
|
|
239
|
+
const slideScoped = entry.scope === "slide";
|
|
240
|
+
const shown = slideScoped ? h(doc, "em", { text: "varies per slide" }) : entry.available ? builtinValueText(entry) : h(doc, "em", { text: "not set" });
|
|
241
|
+
return h(doc, "li", { "data-builtin": entry.name, "data-kind": entry.kind, "data-scope": entry.scope, "data-available": entry.available ? "true" : "false" },
|
|
242
|
+
h(doc, "code", { text: `{{${entry.name}}}` }), ` ${entry.label}: `, shown, slideScoped && !entry.available ? " (no slide has one yet)" : "", uses ? ` (used ${uses} time${uses === 1 ? "" : "s"})` : "");
|
|
241
243
|
}));
|
|
242
244
|
slideSelect.replaceChildren(...(editor.presentation.slides ?? []).map((slide, index) => h(doc, "option", { value: String(index), text: `${index + 1}. ${slideTitle(slide) || "Untitled"}` })));
|
|
243
245
|
previewSlide = Math.min(previewSlide, Math.max(0, (editor.presentation.slides?.length ?? 1) - 1));
|
package/dist/templates.d.ts
CHANGED
|
@@ -109,7 +109,9 @@ export interface BuiltinInfo {
|
|
|
109
109
|
name: string;
|
|
110
110
|
kind: "text" | "image" | "list";
|
|
111
111
|
label: string;
|
|
112
|
-
/**
|
|
112
|
+
/** `deck` for a value read once from the document; `slide` for `slide.number`, `slide.section` and `deck.slideCount`, which vary per slide. */
|
|
113
|
+
scope: "deck" | "slide";
|
|
114
|
+
/** The source value, when the document has one. Never set for a slide-scoped built-in. */
|
|
113
115
|
value?: unknown;
|
|
114
116
|
available: boolean;
|
|
115
117
|
uses: { id: string; path: string; form: "token" | "reference" }[];
|
package/dist/templates.js
CHANGED
|
@@ -8,7 +8,7 @@ import { OPFEditorError, getValueAtPath, opfPathToJsonPointer } from "./index.js
|
|
|
8
8
|
|
|
9
9
|
const VARIABLE_ID = /^[a-z][a-z0-9-]*$/;
|
|
10
10
|
// A built-in variable name (core FA-04): `speakers`, or deck/speaker/organization plus one or two dotted segments.
|
|
11
|
-
const BUILTIN_NAME = /^(?:speakers|(?:deck|speaker|organization)(
|
|
11
|
+
const BUILTIN_NAME = /^(?:speakers|(?:deck|slide|speaker|organization)(?:\.[A-Za-z0-9_-]+){1,2})$/;
|
|
12
12
|
const KINDS = ["color", "text", "number", "date", "image", "url", "list"];
|
|
13
13
|
|
|
14
14
|
/** The form control each variable kind uses. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-editor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Embeddable local OPF editor primitives built on the deterministic renderer.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -258,11 +258,11 @@
|
|
|
258
258
|
"@codemirror/state": "6.7.6",
|
|
259
259
|
"@codemirror/view": "6.43.13",
|
|
260
260
|
"@lezer/highlight": "1.2.4",
|
|
261
|
-
"@openpresentation/opf": "^0.
|
|
261
|
+
"@openpresentation/opf": "^0.17.0",
|
|
262
262
|
"jsonc-parser": "3.3.1"
|
|
263
263
|
},
|
|
264
264
|
"peerDependencies": {
|
|
265
|
-
"@openpresentation/opf-render": "^0.
|
|
265
|
+
"@openpresentation/opf-render": "^0.17.0"
|
|
266
266
|
},
|
|
267
267
|
"peerDependenciesMeta": {
|
|
268
268
|
"@openpresentation/opf-render": {
|
|
@@ -314,8 +314,8 @@
|
|
|
314
314
|
"@expo-google-fonts/roboto-mono": "0.4.2",
|
|
315
315
|
"@expo-google-fonts/stix-two-math": "0.4.0",
|
|
316
316
|
"@expo-google-fonts/tinos": "0.4.2",
|
|
317
|
-
"@openpresentation/opf-pptx": "^0.
|
|
318
|
-
"@openpresentation/opf-render": "^0.
|
|
317
|
+
"@openpresentation/opf-pptx": "^0.17.0",
|
|
318
|
+
"@openpresentation/opf-render": "^0.17.0",
|
|
319
319
|
"axe-core": "4.13.0",
|
|
320
320
|
"esbuild": "0.28.2",
|
|
321
321
|
"jszip": "3.10.2",
|