@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 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", { slideNumber: true, logo: true });
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`, `slideNumber` and `slideNumberFormat` (must contain `{current}`), `date` (true, or a fixed date) and `dateFormat`, `organization`, `speaker` (the first speaker's name and title), `socials`, `section`; `ZONE_FIELDS`) into one zone, checking each value; 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
+ | 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 or its social profiles when there are none 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
+ 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, logo, image, organization, socials, section, slide number and format, 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
+ 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
 
@@ -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
- function textField(name, label, { help, placeholder, list, onCommit, type = "text" } = {}) {
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 = h("input", { id, type, placeholder, list: list ? `${id}-list` : undefined, autocomplete: "off", spellcheck: "false" });
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, organization, speaker, socials, section, slide number, date." });
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 text = textField("hf-text", "Text", { help: "Literal text. Clear it to remove.", onCommit: (value) => edit({ text: value }, `${where()} text changed.`) });
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
- const section = checkField("hf-section", "Show the section label", { onChange: (checked) => edit({ section: checked }, `${where()} section label ${checked ? "shown" : "removed"}.`) });
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, number, numberFormat, dateNow, dateFixed, dateFormat, organization, speaker, socials, section];
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
- text.set(typeof fields.text === "string" ? fields.text : "");
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
- section.set(fields.section === true);
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());
@@ -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", "slideNumber", "slideNumberFormat", "date", "dateFormat", "organization", "speaker", "socials", "section"];
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), an organization or its social profiles, or a named speaker, for zones that show them. */
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[];
@@ -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", "slideNumber", "organization", "speaker", "socials", "section"];
28
- export const ZONE_FIELDS = Object.freeze(["logo", "text", "image", "slideNumber", "slideNumberFormat", "date", "dateFormat", "organization", "speaker", "socials", "section"]);
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 a logo the document does not have: a header or
105
- * footer zone with `logo: true`, or picture bullets. The renderers fall back to a glyph or report
106
- * unresolved content; the editor surfaces it before export.
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, slideNumber, slideNumberFormat, date, dateFormat, organization, speaker, socials, section); `null`,
347
- * `false` for a flag, or an empty string removes a field. A zone left empty is removed, then an empty
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]}.`, { fields });
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.
@@ -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
- return composeSlide(presentation.slides?.[slideIndex], slideContext(presentation, slideIndex, mergeCatalogs(catalogs, options.catalogs), options));
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,
@@ -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
- return h(doc, "li", { "data-builtin": entry.name, "data-kind": entry.kind, "data-available": entry.available ? "true" : "false" },
240
- h(doc, "code", { text: `{{${entry.name}}}` }), ` ${entry.label}: `, entry.available ? shown : h(doc, "em", { text: "not set" }), uses ? ` (used ${uses} time${uses === 1 ? "" : "s"})` : "");
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));
@@ -109,7 +109,9 @@ export interface BuiltinInfo {
109
109
  name: string;
110
110
  kind: "text" | "image" | "list";
111
111
  label: string;
112
- /** The source value, when the document has one. */
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)(?:.[A-Za-z0-9_-]+){1,2})$/;
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.16.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.16.0",
261
+ "@openpresentation/opf": "^0.17.0",
262
262
  "jsonc-parser": "3.3.1"
263
263
  },
264
264
  "peerDependencies": {
265
- "@openpresentation/opf-render": "^0.16.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.16.0",
318
- "@openpresentation/opf-render": "^0.16.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",