@openpresentation/opf-editor 0.16.0 → 0.18.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.
@@ -1,7 +1,7 @@
1
1
  // Design controls (RR-06): the DOM panel for the dimension switches, the design options, block
2
2
  // conversion, chart type and table style/merge. Every control commits one validated, undoable
3
- // change through the session (switchDimension, setDesignOption, setLogoVariant,
4
- // setHeaderFooterZone, convertBlock, setTableStyle, mergeTableCells, ...), so a host that already
3
+ // change through the session (switchDimension, setDesignOption, setOrganizationLogo,
4
+ // setHeaderFooterZone, insertZoneLogo, convertBlock, setTableStyle, mergeTableCells, ...), so a host that already
5
5
  // subscribes to the session redraws on every change, on Undo and on Redo. The panel owns no
6
6
  // document state: it reads the session, mirrors it into native form controls, and reports
7
7
  // problems in a live region. Importing this module does not need a DOM; mounting does.
@@ -20,18 +20,26 @@ import { BLOCK_KIND_LABELS, blockConversionTargets, blockPathForSelection, readB
20
20
  import {
21
21
  DATE_FORMAT_TOKENS,
22
22
  HEADER_FOOTER_ZONES,
23
- LOGO_VARIANTS,
23
+ LOGO_BACKGROUNDS,
24
+ LOGO_SHAPES,
25
+ ZONE_VALUES,
24
26
  designWarnings,
25
27
  getDesignOption,
26
28
  headerFooterState,
27
- prepareHeaderFooterZone,
28
- prepareLogoVariant,
29
+ insertZoneLogo,
30
+ insertZoneValue,
31
+ listOrganizations,
32
+ logoReference,
33
+ parseLogoReference,
29
34
  prepareDesignOption,
35
+ prepareHeaderFooterZone,
36
+ prepareOrganizationLogo,
30
37
  readHeaderFooterZone,
31
- readLogoVariants,
38
+ readLogoChoice,
39
+ readOrganizationLogo,
32
40
  setDesignOption,
33
41
  setHeaderFooterZone,
34
- setLogoVariant,
42
+ setOrganizationLogo,
35
43
  } from "./design-options.js";
36
44
  import { createContentControls } from "./content-controls.js";
37
45
  import { IMAGE_EDGES, IMAGE_FITS, IMAGE_SHAPES, readImageTreatments, setImageTreatment } from "./image-options.js";
@@ -55,19 +63,18 @@ const SECTION_TITLES = {
55
63
  "slide-content": "Slide structure",
56
64
  };
57
65
  const OPEN_BY_DEFAULT = new Set(["look", "selection", "image", "table"]);
58
- const LOGO_VARIANT_LABELS = {
59
- default: "Default",
60
- light: "Light (for dark backgrounds)",
61
- dark: "Dark (for light backgrounds)",
62
- stacked: "Stacked",
63
- stackedLight: "Stacked, light",
64
- stackedDark: "Stacked, dark",
65
- icon: "Icon",
66
- iconLight: "Icon, light",
67
- iconDark: "Icon, dark",
68
- wordmark: "Wordmark",
69
- wordmarkLight: "Wordmark, light",
70
- wordmarkDark: "Wordmark, dark",
66
+ // RR-71: the four shapes of an organization's logo, and the two backgrounds a shape can be split by.
67
+ const LOGO_SHAPE_LABELS = {
68
+ all: "All shapes (one image)",
69
+ full: "Full (mark and name side by side)",
70
+ stacked: "Stacked (mark above the name)",
71
+ icon: "Icon (the mark alone)",
72
+ wordmark: "Wordmark (the name alone)",
73
+ };
74
+ const LOGO_BACKGROUND_LABELS = {
75
+ both: "Both backgrounds",
76
+ onLight: "Light backgrounds (usually dark artwork)",
77
+ onDark: "Dark backgrounds (usually light artwork)",
71
78
  };
72
79
  const CELL_FILLS = [
73
80
  ["", "No fill"],
@@ -228,9 +235,12 @@ export function createDesignControls(container, options = {}) {
228
235
  return api;
229
236
  }
230
237
 
231
- function textField(name, label, { help, placeholder, list, onCommit, type = "text" } = {}) {
238
+ // `multiline` makes it a two-line box: Enter commits (like a one-line field), Shift+Enter starts a new line.
239
+ function textField(name, label, { help, placeholder, list, onCommit, type = "text", multiline = false } = {}) {
232
240
  const id = nextId(name);
233
- const input = h("input", { id, type, placeholder, list: list ? `${id}-list` : undefined, autocomplete: "off", spellcheck: "false" });
241
+ const input = multiline
242
+ ? h("textarea", { id, rows: 2, placeholder, autocomplete: "off", spellcheck: "false" })
243
+ : h("input", { id, type, placeholder, list: list ? `${id}-list` : undefined, autocomplete: "off", spellcheck: "false" });
234
244
  const datalist = list ? h("datalist", { id: `${id}-list` }) : undefined;
235
245
  const { wrap, note } = wrapField(id, label, input, { help, extra: datalist });
236
246
  const api = {
@@ -246,6 +256,13 @@ export function createDesignControls(container, options = {}) {
246
256
  },
247
257
  };
248
258
  if (onCommit) input.addEventListener("change", () => onCommit(input.value));
259
+ if (onCommit && multiline) {
260
+ input.addEventListener("keydown", (event) => {
261
+ if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
262
+ event.preventDefault();
263
+ onCommit(input.value);
264
+ });
265
+ }
249
266
  return api;
250
267
  }
251
268
 
@@ -655,7 +672,7 @@ export function createDesignControls(container, options = {}) {
655
672
  { value: "footer", label: "Footer" },
656
673
  ]);
657
674
  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." });
675
+ const zoneSelect = selectField("hf-zone", "Zone", { help: "A zone lays its parts out side by side, from the edge it sits on: image (or logo), text, social profiles, date." });
659
676
  zoneSelect.setOptions(HEADER_FOOTER_ZONES.map((value) => ({ value, label: titleCase(value) })));
660
677
  zoneSelect.set(pick.zone);
661
678
  furniture.select.addEventListener("change", () => {
@@ -673,19 +690,60 @@ export function createDesignControls(container, options = {}) {
673
690
  });
674
691
  const edit = (fields, done) => run(() => setHeaderFooterZone(editor, which(), zone(), fields, scoped()), done);
675
692
  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.`) });
677
- const logo = checkField("hf-logo", "Show the logo", { onChange: (checked) => edit({ logo: checked }, `${where()} logo ${checked ? "shown" : "removed"}.`) });
693
+ const currentText = () => {
694
+ const value = readHeaderFooterZone(editor.presentation, which(), zone(), scoped()).text;
695
+ return typeof value === "string" ? value : "";
696
+ };
697
+ const text = textField("hf-text", "Text", {
698
+ multiline: true,
699
+ 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.",
700
+ onCommit: (value) => {
701
+ if (value === currentText()) return;
702
+ edit({ text: value }, `${where()} text changed.`);
703
+ },
704
+ });
705
+ // 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.
706
+ const insertValue = selectField("hf-insert-value", "Insert value", {
707
+ empty: "Choose a value…",
708
+ help: "Adds a value to the text at the cursor. It is a {{ }} variable: slide number, slide count and section change on every slide.",
709
+ });
710
+ insertValue.setOptions(ZONE_VALUES.map((value) => ({ value: value.name, label: value.label, title: value.token })));
711
+ insertValue.select.addEventListener("change", () => {
712
+ const name = insertValue.select.value;
713
+ insertValue.select.value = "";
714
+ if (!name) return;
715
+ const box = text.input;
716
+ const base = box.value;
717
+ const start = Math.min(box.selectionStart ?? base.length, base.length);
718
+ const end = Math.min(box.selectionEnd ?? start, base.length);
719
+ const { token, label } = ZONE_VALUES.find((value) => value.name === name);
720
+ run(() => insertZoneValue(editor, which(), zone(), name, { ...scoped(), text: base, start, end }), `${where()} text now includes ${label.toLowerCase()} (${token}).`);
721
+ // 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
722
+ // focused box alone, and a stale value would be committed over the insert when the box loses focus.
723
+ box.value = currentText();
724
+ const caret = start + token.length;
725
+ box.focus();
726
+ box.setSelectionRange?.(caret, caret);
727
+ });
678
728
  const image = imageCluster("hf-image", "Image", {
679
729
  apply: (ref) => setHeaderFooterZone(editor, which(), zone(), { image: ref }, scoped()),
680
730
  build: (ref, doc) => prepareHeaderFooterZone(doc, which(), zone(), { image: ref }, scoped()),
681
- 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.",
731
+ help: "A picture in this zone, such as a partner mark or badge: an asset reference, a web address or a data address, or an organization logo (Insert logo, below). Press Enter to apply; clear the field to remove it.",
682
732
  });
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.`),
733
+ // RR-71: a logo is the zone's image, written as a reference to the organization's logo (var:organization.logo.icon). Which artwork is drawn (light or dark) follows each slide's background.
734
+ const logoShape = selectField("hf-logo-shape", "Logo shape", { help: "Icon is the mark alone and fits a zone best." });
735
+ logoShape.setOptions(LOGO_SHAPES.map((value) => ({ value, label: LOGO_SHAPE_LABELS[value] })));
736
+ logoShape.set("icon");
737
+ const logoOrganization = selectField("hf-logo-organization", "Logo of", { empty: "Primary organization", help: "Choose another organization when the deck has several." });
738
+ const insertLogoButton = button("Insert logo", () => {
739
+ const shape = logoShape.select.value;
740
+ const organization = logoOrganization.select.value || undefined;
741
+ run(() => insertZoneLogo(editor, which(), zone(), { ...scoped(), shape, ...(organization ? { organization } : {}) }), `${where()} now shows the ${shape} logo (${logoReference({ ...(organization ? { organization } : {}), ...(shape !== "full" ? { shape } : {}) })}), replacing any image.`);
688
742
  });
743
+ insertLogoButton.dataset.action = "insert-logo";
744
+ const logoHelp = h("p", { class: "opf-dc-help", id: `${uid}-hf-logo-help` }, "Puts the organization's logo in this zone as its image. The logo comes from the organization's logo settings; add it under Logo, watermark and bullets.");
745
+ insertLogoButton.setAttribute("aria-describedby", logoHelp.id);
746
+ const logo = { wrap: h("fieldset", { class: "opf-dc-fieldset", "data-role": "hf-logo" }, h("legend", {}, "Logo"), logoShape.wrap, logoOrganization.wrap, h("div", { class: "opf-dc-actions" }, insertLogoButton), logoHelp) };
689
747
  const dateNow = checkField("hf-date-now", "Show the current date", {
690
748
  help: "Needs the host to supply today's date when it renders or exports; otherwise the part is reported as unresolved.",
691
749
  onChange: (checked) => edit({ date: checked }, `${where()} date ${checked ? "shown" : "removed"}.`),
@@ -700,13 +758,11 @@ export function createDesignControls(container, options = {}) {
700
758
  help: `Tokens: ${DATE_FORMAT_TOKENS.join(", ")}. Text in single quotes is literal. Clear it for the default (M/d/yyyy).`,
701
759
  onCommit: (value) => edit({ dateFormat: value.trim() }, `${where()} date format changed.`),
702
760
  });
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
761
  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"}.`) });
762
+ let shownContext;
707
763
  const summary = h("ul", { class: "opf-dc-summary", "aria-label": "Zones in use" });
708
764
  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];
765
+ const controls = [text, insertValue, logo, image, dateNow, dateFixed, dateFormat, socials];
710
766
  body.append(furniture.wrap, hide.wrap, zoneSelect.wrap, summary, ...controls.map((control) => control.wrap), warnings);
711
767
  const describe = (fields) =>
712
768
  Object.entries(fields)
@@ -718,19 +774,32 @@ export function createDesignControls(container, options = {}) {
718
774
  hide.wrap.hidden = scopeIndex() === undefined ? !state.hidden : state.own && !state.hidden;
719
775
  hide.set(state.hidden, state.inherited && !state.hidden ? "from the presentation" : "");
720
776
  const fields = readHeaderFooterZone(editor.presentation, which(), zone(), scoped());
721
- text.set(typeof fields.text === "string" ? fields.text : "");
722
- logo.set(fields.logo === true);
777
+ // A box that has the focus keeps what is being typed, except when it now belongs to another header or footer, zone or scope.
778
+ const textValue = typeof fields.text === "string" ? fields.text : "";
779
+ const textContext = `${target()}:${which()}:${zone()}`;
780
+ if (textContext !== shownContext) {
781
+ shownContext = textContext;
782
+ text.input.value = textValue;
783
+ }
784
+ text.set(textValue);
723
785
  image.set(stringOf(fields.image), "", `${target()}:${which()}:${zone()}`);
724
- number.set(fields.slideNumber === true);
725
- numberFormat.set(typeof fields.slideNumberFormat === "string" ? fields.slideNumberFormat : "");
786
+ // The logo menus follow the organizations, and show the logo the zone already has.
787
+ const organizations = listOrganizations(editor.presentation);
788
+ logoOrganization.setOptions(organizations.filter((entry) => entry.id).map((entry) => ({ value: entry.id, label: entry.name ? `${entry.name} (${entry.id})` : entry.id })));
789
+ logoOrganization.wrap.hidden = organizations.length < 2;
790
+ const shown = parseLogoReference(fields.image);
791
+ if (shown) {
792
+ logoShape.set(shown.shape ?? "full");
793
+ logoOrganization.set(shown.organization ?? "");
794
+ }
795
+
726
796
  dateNow.set(fields.date === true);
727
797
  dateFixed.set(typeof fields.date === "string" ? fields.date : "");
728
798
  dateFormat.set(typeof fields.dateFormat === "string" ? fields.dateFormat : "");
729
- organization.set(fields.organization === true);
730
- speaker.set(fields.speaker === true);
731
799
  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;
800
+ insertValue.set("");
801
+ for (const control of controls) for (const input of control.wrap.querySelectorAll("input,select,textarea,button")) input.disabled = state.hidden;
802
+ insertLogoButton.disabled = state.hidden || !listOrganizations(editor.presentation).length;
734
803
  summary.replaceChildren(
735
804
  ...HEADER_FOOTER_ZONES.map((name) => {
736
805
  const zoneFields = readHeaderFooterZone(editor.presentation, which(), name, scoped());
@@ -745,20 +814,109 @@ export function createDesignControls(container, options = {}) {
745
814
 
746
815
  if (built.brand) {
747
816
  const { body } = built.brand;
748
- const variant = selectField("logo-variant", "Logo variant", { help: "Choose the variant to edit. Engines pick one by background: light on dark, dark on light, then the default." });
749
- variant.setOptions(LOGO_VARIANTS.map((value) => ({ value, label: LOGO_VARIANT_LABELS[value] })));
750
- variant.set("default");
751
- const logoSource = imageCluster("logo", "Logo", {
752
- apply: (ref) => setLogoVariant(editor, variant.select.value, ref, scoped()),
753
- build: (ref, doc) => prepareLogoVariant(doc, variant.select.value, ref, scoped()),
754
- help: "An asset reference, a web address or a data address for the selected variant. Press Enter to apply; clear the field to remove the variant.",
817
+ // RR-71: logos live on the organization. One image serves every shape, or each of the four shapes (full, stacked, icon,
818
+ // wordmark) has its own, optionally split by the background it is drawn on (light or dark).
819
+ const logoOrganizationPick = selectField("org-logo-organization", "Organization to edit", {
820
+ help: "Whose logo to edit. The primary organization's full logo is drawn on cover and section slides.",
821
+ onChange: () => sync(),
822
+ });
823
+ const logoShapePick = selectField("org-logo-shape", "Shape to edit", {
824
+ help: "All shapes sets one image for every place the logo is drawn. Set a shape to give it its own image; a missing shape falls back to the full logo.",
825
+ onChange: () => sync(),
826
+ });
827
+ logoShapePick.setOptions(["all", ...LOGO_SHAPES].map((value) => ({ value, label: LOGO_SHAPE_LABELS[value] })));
828
+ logoShapePick.set("all");
829
+ const logoBackgroundPick = selectField("org-logo-background", "Background to edit", {
830
+ help: "Use a different image on light and on dark backgrounds. Each slide draws the one that suits its background; a missing one uses the other.",
831
+ onChange: () => sync(),
832
+ });
833
+ logoBackgroundPick.setOptions(LOGO_BACKGROUNDS.map((value) => ({ value, label: LOGO_BACKGROUND_LABELS[value] })));
834
+ logoBackgroundPick.set("both");
835
+ const logoTarget = () => ({ shape: logoShapePick.select.value, background: logoShapePick.select.value === "all" ? "both" : logoBackgroundPick.select.value, organization: Number(logoOrganizationPick.select.value || 0) });
836
+ const orgLogo = imageCluster("org-logo", "Organization logo", {
837
+ apply: (ref) => {
838
+ const { shape, ...rest } = logoTarget();
839
+ return setOrganizationLogo(editor, shape, ref, rest);
840
+ },
841
+ build: (ref, doc) => {
842
+ const { shape, ...rest } = logoTarget();
843
+ return prepareOrganizationLogo(doc, shape, ref, rest);
844
+ },
845
+ help: "An asset reference, a web address or a data address for the chosen shape and background. Press Enter to apply; clear the field to remove it.",
755
846
  });
756
- variant.select.addEventListener("change", () => sync());
757
- const orgLogo = imageCluster("org-logo", "Organization logo (whole presentation)", {
758
- apply: (ref) => setDesignOption(editor, "organizationLogo", ref),
759
- build: (ref, doc) => prepareDesignOption(doc, "organizationLogo", ref),
760
- help: "The primary organization's logo is the fallback wherever the logo is drawn.",
847
+ const logoSummary = h("ul", { class: "opf-dc-summary", "aria-label": "Logo shapes set" });
848
+ const organizationLogoGroup = h("fieldset", { class: "opf-dc-fieldset", "data-role": "organization-logo" }, h("legend", {}, "Organization logo"), logoOrganizationPick.wrap, logoShapePick.wrap, logoBackgroundPick.wrap, orgLogo.wrap, logoSummary);
849
+ const logoSummaryText = (state) => {
850
+ if (!state) return ["No organization yet: add one to give it a logo."];
851
+ if (state.all !== undefined) return ["One image for every shape."];
852
+ const rows = LOGO_SHAPES.filter((shape) => state.shapes[shape]).map((shape) => {
853
+ const entry = state.shapes[shape];
854
+ return `${titleCase(shape)}: ${entry.both !== undefined ? "both backgrounds" : [entry.onLight !== undefined ? "light backgrounds" : "", entry.onDark !== undefined ? "dark backgrounds" : ""].filter(Boolean).join(" and ")}`;
855
+ });
856
+ return rows.length ? rows : ["No logo yet."];
857
+ };
858
+ const syncOrganizationLogo = () => {
859
+ const organizations = listOrganizations(editor.presentation);
860
+ const wanted = logoOrganizationPick.select.value;
861
+ logoOrganizationPick.setOptions(organizations.map((entry) => ({ value: String(entry.index), label: `${entry.name ?? entry.id ?? `Organization ${entry.index + 1}`}${entry.primary ? " (primary)" : ""}` })));
862
+ const fallback = organizations.find((entry) => entry.primary) ?? organizations[0];
863
+ logoOrganizationPick.set(organizations.some((entry) => String(entry.index) === wanted) ? wanted : String(fallback?.index ?? ""));
864
+ logoOrganizationPick.wrap.hidden = organizations.length < 2;
865
+ const all = logoShapePick.select.value === "all";
866
+ logoBackgroundPick.wrap.hidden = all;
867
+ const state = organizations.length ? readOrganizationLogo(editor.presentation, { organization: Number(logoOrganizationPick.select.value) }) : null;
868
+ const { shape, background } = logoTarget();
869
+ const entry = shape === "all" ? undefined : state?.shapes[shape];
870
+ let value = "";
871
+ let note = "";
872
+ if (shape === "all") value = stringOf(state?.all);
873
+ else if (entry) value = stringOf(background === "both" ? entry.both : entry[background]);
874
+ else if (state?.all !== undefined) note = "uses the image for all shapes";
875
+ if (shape !== "all" && entry?.both !== undefined && background !== "both") note = "uses the image for both backgrounds";
876
+ if (shape !== "all" && background === "both" && entry && entry.both === undefined) note = "set per background";
877
+ orgLogo.set(value, note, `logo:${logoOrganizationPick.select.value}:${shape}:${background}`);
878
+ for (const input of [...orgLogo.wrap.querySelectorAll("input"), ...logoShapePick.wrap.querySelectorAll("select"), ...logoBackgroundPick.wrap.querySelectorAll("select")]) input.disabled = !organizations.length;
879
+ logoSummary.replaceChildren(...logoSummaryText(state).map((line) => h("li", {}, line)));
880
+ };
881
+ // The logo that covers, sections and picture bullets draw: the primary organization's by default, another organization's or shape, or none.
882
+ const choiceOrganizationPick = selectField("design-logo-organization", "Logo for covers, sections and bullets", {
883
+ empty: "Primary organization",
884
+ help: "Which organization's logo covers and sections draw, and picture bullets use. Choose None to draw no logo.",
885
+ onChange: () => applyLogoChoice(),
761
886
  });
887
+ const choiceShapePick = selectField("design-logo-shape", "Shape to draw", {
888
+ empty: "Default (full; icon for bullets)",
889
+ help: "Choose a shape to draw it instead of the default for that place. A missing shape falls back to the full logo.",
890
+ onChange: () => applyLogoChoice(),
891
+ });
892
+ choiceShapePick.setOptions(LOGO_SHAPES.map((value) => ({ value, label: LOGO_SHAPE_LABELS[value] })));
893
+ const logoChoiceGroup = h("fieldset", { class: "opf-dc-fieldset", "data-role": "design-logo" }, h("legend", {}, "Which logo to draw"), choiceOrganizationPick.wrap, choiceShapePick.wrap);
894
+ const applyLogoChoice = () => {
895
+ const organization = choiceOrganizationPick.select.value;
896
+ const shape = choiceShapePick.select.value || undefined;
897
+ let value;
898
+ if (organization === "none") value = false;
899
+ else if (organization === "" && shape === undefined) value = null;
900
+ else value = logoReference({ ...(organization !== "" && organization !== "primary" ? { organization } : {}), ...(shape ? { shape } : {}) });
901
+ run(() => setDesignOption(editor, "logo", value, scoped()), value === null ? "Logo set to the primary organization's." : value === false ? "No logo is drawn." : `Logo set to ${value}.`);
902
+ };
903
+ const syncLogoChoice = () => {
904
+ const choice = readLogoChoice(editor.presentation, scoped());
905
+ const entries = listOrganizations(editor.presentation)
906
+ .filter((entry) => entry.id)
907
+ .map((entry) => ({ value: entry.id, label: `${entry.name ?? entry.id}${entry.primary ? " (primary)" : ""}` }));
908
+ // On a slide, an explicit reference to the primary organization overrides a logo setting the presentation made.
909
+ if (state.scope === "slide" && editor.presentation.design?.logo !== undefined) entries.unshift({ value: "primary", label: "Primary organization (instead of the presentation's setting)" });
910
+ entries.push({ value: "none", label: "None" });
911
+ choiceOrganizationPick.setOptions(entries);
912
+ let organization = "";
913
+ if (choice.mode === "none") organization = "none";
914
+ else if (choice.mode === "custom") organization = String(choice.value);
915
+ else if (choice.mode === "reference") organization = choice.organization ?? (state.scope === "slide" && choice.shape === undefined ? "primary" : "");
916
+ choiceOrganizationPick.set(organization, sourceNote(choice.scope, choice.value !== undefined));
917
+ choiceShapePick.set(choice.mode === "reference" ? (choice.shape ?? "") : "");
918
+ for (const input of choiceShapePick.wrap.querySelectorAll("select")) input.disabled = choice.mode === "none";
919
+ };
762
920
  const bullet = selectField("list-bullet", "List bullets", {
763
921
  empty: "Default (character)",
764
922
  help: "Picture bullets draw the logo as the marker and need a logo.",
@@ -798,16 +956,12 @@ export function createDesignControls(container, options = {}) {
798
956
  const watermarkOff = checkField("watermark-off", "Hide the inherited watermark", {
799
957
  onChange: (checked) => run(() => setDesignOption(editor, "watermark", checked ? false : null, scoped()), checked ? "Watermark hidden." : "Watermark restored."),
800
958
  });
801
- body.append(variant.wrap, logoSource.wrap, orgLogo.wrap, bullet.wrap, accent.wrap, watermarkSource.wrap, watermarkText.wrap, watermarkOpacity.wrap, watermarkOff.wrap);
959
+ body.append(organizationLogoGroup, logoChoiceGroup, bullet.wrap, accent.wrap, watermarkSource.wrap, watermarkText.wrap, watermarkOpacity.wrap, watermarkOff.wrap);
802
960
  const warningList = h("ul", { class: "opf-dc-warnings", "aria-label": "Logo warnings" });
803
961
  body.append(warningList);
804
962
  syncs.push(() => {
805
- const variants = readLogoVariants(editor.presentation, scoped());
806
- logoSource.set(stringOf(variants[variant.select.value]), "", `${target()}:${variant.select.value}`);
807
- const organization = editor.presentation.organization;
808
- const owner = Array.isArray(organization) ? organization[0] : organization;
809
- orgLogo.set(stringOf(owner?.logo), "", "org");
810
- for (const input of orgLogo.wrap.querySelectorAll("input")) input.disabled = !owner;
963
+ syncOrganizationLogo();
964
+ syncLogoChoice();
811
965
  const bulletOption = getDesignOption(editor.presentation, "listBullet", scoped());
812
966
  bullet.set(bulletOption.value ?? "", sourceNote(bulletOption.scope, bulletOption.value !== undefined));
813
967
  const accentOption = getDesignOption(editor.presentation, "accentFont", scoped());
@@ -822,7 +976,7 @@ export function createDesignControls(container, options = {}) {
822
976
  const own = scopeIndex() === undefined ? editor.presentation.design?.watermark : editor.presentation.slides?.[scopeIndex()]?.design?.watermark;
823
977
  watermarkOff.set(own === false);
824
978
  watermarkOff.wrap.hidden = state.scope !== "slide";
825
- warningList.replaceChildren(...designWarnings(editor.presentation, getSlide()).filter((warning) => warning.path === "design.listBullet").map((warning) => h("li", {}, warning.message)));
979
+ warningList.replaceChildren(...designWarnings(editor.presentation, getSlide()).filter((warning) => warning.path === "design.listBullet" || /^(slides\.\d+\.)?design\.logo$/.test(warning.path)).map((warning) => h("li", {}, warning.message)));
826
980
  });
827
981
  }
828
982
 
@@ -10,34 +10,43 @@ export type DesignOptionId =
10
10
  | "imageFit"
11
11
  | "accentFont"
12
12
  | "logo"
13
- | "organizationLogo"
14
13
  | "watermark";
15
14
  export interface DesignOptionDescriptor {
16
15
  id: DesignOptionId;
17
16
  label: string;
18
- type: "enum" | "boolean" | "font" | "logo" | "organization-logo" | "watermark";
17
+ type: "enum" | "boolean" | "font" | "logo" | "watermark";
19
18
  /** Allowed values of an enum option. */
20
19
  values?: readonly string[];
21
20
  scopes: ("deck" | "slide")[];
22
21
  path: string;
23
22
  }
24
23
  export declare const DESIGN_OPTIONS: readonly DesignOptionDescriptor[];
25
- export declare const LOGO_VARIANTS: readonly ["default", "light", "dark", "stacked", "stackedLight", "stackedDark", "icon", "iconLight", "iconDark", "wordmark", "wordmarkLight", "wordmarkDark"];
26
- export type LogoVariant = (typeof LOGO_VARIANTS)[number];
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"];
24
+ /** The shapes an organization's logo can have (core's `LOGO_SHAPES`). */
25
+ export declare const LOGO_SHAPES: readonly ["full", "stacked", "icon", "wordmark"];
26
+ export type LogoShape = (typeof LOGO_SHAPES)[number];
27
+ /** The backgrounds a logo shape can be split by: one image for `both`, or one for `onLight` and one for `onDark` backgrounds. */
28
+ export declare const LOGO_BACKGROUNDS: readonly ["both", "onLight", "onDark"];
29
+ export type LogoBackground = (typeof LOGO_BACKGROUNDS)[number];
30
+ /** Every field a header or footer zone can hold. The logo is the zone's `image` (`var:organization.logo.icon`, see `insertZoneLogo`). */
31
+ export declare const ZONE_FIELDS: readonly ["text", "image", "date", "dateFormat", "socials"];
32
+ /** 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`. */
33
+ export interface ZoneValue {
34
+ readonly name: "slide.number" | "deck.slideCount" | "slide.section" | "organization.name" | "speaker.name" | "deck.name";
35
+ readonly label: string;
36
+ readonly token: string;
37
+ }
38
+ /** The values a zone's "Insert value" menu offers, in menu order: slide number, slide count, section, organization, speaker, deck name. */
39
+ export declare const ZONE_VALUES: readonly ZoneValue[];
29
40
  /** Date format tokens (English names). */
30
41
  export declare const DATE_FORMAT_TOKENS: readonly string[];
31
42
  export declare const HEADER_FOOTER_ZONES: readonly ["left", "center", "right"];
32
43
  export type HeaderFooterZone = (typeof HEADER_FOOTER_ZONES)[number];
33
44
 
34
45
  export interface DesignOptionOptions {
35
- /** One slide instead of the deck. Not allowed for organizationLogo. */
46
+ /** One slide instead of the deck. */
36
47
  slideIndex?: number;
37
48
  /** Deck scope: also remove slide-level values that would hide the change. */
38
49
  clearSlideOverrides?: boolean;
39
- /** organizationLogo with several organizations (default 0). */
40
- index?: number;
41
50
  /** Session change metadata (session forms only). */
42
51
  meta?: Record<string, unknown>;
43
52
  }
@@ -50,15 +59,14 @@ export interface PreparedDesignOption {
50
59
  option: string;
51
60
  scope: "deck" | "slide";
52
61
  slideIndex?: number;
53
- /** The logo variant or header/footer zone an edit named. */
54
- variant?: string;
62
+ /** The header/footer zone an edit named. */
55
63
  zone?: string;
56
64
  presentation: unknown;
57
65
  patches: JsonPatchOperation[];
58
66
  changed: boolean;
59
67
  /** Slides whose own design hides a deck-level change. */
60
68
  shadowed: number[];
61
- /** Settings that need a logo the document does not have. */
69
+ /** Settings that need a logo or other content the document does not have. */
62
70
  warnings: DesignWarning[];
63
71
  }
64
72
  export interface DesignOptionChange extends Omit<EditorChange, "presentation" | "patches"> {
@@ -77,34 +85,95 @@ export declare function prepareDesignOption(presentation: unknown, option: Desig
77
85
  /** Set one option as a single undoable transaction. */
78
86
  export declare function setDesignOption(editor: EditorSession, option: DesignOptionId, value: unknown, options?: DesignOptionOptions): DesignOptionChange;
79
87
  /** `{ value, scope, inherited }`; scope is "slide", "deck" or "default". */
80
- export declare function getDesignOption(presentation: unknown, option: DesignOptionId, options?: Pick<DesignOptionOptions, "slideIndex" | "index">): { value: unknown; scope: "slide" | "deck" | "default"; inherited: boolean };
81
- export declare function prepareLogoVariant(presentation: unknown, variant: LogoVariant, source: string | Record<string, unknown> | null, options?: DesignOptionOptions): PreparedDesignOption;
82
- /** Set or clear one `design.logo` variant; a lone default stays a bare source. */
83
- export declare function setLogoVariant(editor: EditorSession, variant: LogoVariant, source: string | Record<string, unknown> | null, options?: DesignOptionOptions): DesignOptionChange;
84
- /** The variants `design.logo` sets at a scope (a bare logo is reported as `default`). */
85
- export declare function readLogoVariants(presentation: unknown, options?: Pick<DesignOptionOptions, "slideIndex">): Partial<Record<LogoVariant, unknown>>;
88
+ export declare function getDesignOption(presentation: unknown, option: DesignOptionId, options?: Pick<DesignOptionOptions, "slideIndex">): { value: unknown; scope: "slide" | "deck" | "default"; inherited: boolean };
89
+ /** A parsed `var:organization(.<id>)?.logo(.<shape>)?` reference: the organization's id (absent: the primary organization) and the shape when the reference names one. */
90
+ export interface LogoReference {
91
+ organization?: string;
92
+ shape?: LogoShape;
93
+ }
94
+ /** Parse a logo reference, or null for any other string. */
95
+ export declare function parseLogoReference(text: unknown): LogoReference | null;
96
+ /** Write a logo reference: `logoReference({ organization: "beta", shape: "icon" })` is `"var:organization.beta.logo.icon"`. */
97
+ export declare function logoReference(reference?: LogoReference): string;
98
+ /** What `design.logo` takes: `null` (unset: the primary organization's logo), `false` (no logo), a reference, or the organization and shape to write as one. */
99
+ export type LogoChoice = null | false | string | LogoReference;
100
+ export interface OrganizationEntry {
101
+ index: number;
102
+ /** What a `var:` reference addresses; an organization without one is only reachable as the primary organization. */
103
+ id?: string;
104
+ name?: string;
105
+ /** The organization the unset logo and `var:organization.logo` mean (`role: "primary"`, else the first). */
106
+ primary: boolean;
107
+ hasLogo: boolean;
108
+ }
109
+ /** The deck's organizations (a single organization object is a list of one). */
110
+ export declare function listOrganizations(presentation: unknown): OrganizationEntry[];
111
+ export interface OrganizationLogoOptions extends Omit<DesignOptionOptions, "slideIndex" | "clearSlideOverrides"> {
112
+ /** The organization's index or id (default: the primary organization). */
113
+ organization?: number | string;
114
+ /** `both` (default): one image for the shape. `onLight` or `onDark`: the image for that background only. Not with shape `all`. */
115
+ background?: LogoBackground;
116
+ }
117
+ /** Compute the patch that sets or clears (`null`) the organization's logo: `all` is one image for every shape, otherwise one shape (for one background or both). */
118
+ export declare function prepareOrganizationLogo(presentation: unknown, shape: "all" | LogoShape, source: string | Record<string, unknown> | null, options?: Omit<OrganizationLogoOptions, "meta">): PreparedDesignOption & { shape: "all" | LogoShape; background: LogoBackground; organization: number };
119
+ /** Set or clear the organization's logo as a single undoable transaction. */
120
+ export declare function setOrganizationLogo(editor: EditorSession, shape: "all" | LogoShape, source: string | Record<string, unknown> | null, options?: OrganizationLogoOptions): DesignOptionChange;
121
+ export interface OrganizationLogoState {
122
+ organization: OrganizationEntry;
123
+ /** The bare image when one image serves every shape. */
124
+ all?: unknown;
125
+ /** The shapes the logo sets: `{ both }` for one image, or `{ onLight?, onDark? }`. */
126
+ shapes: Partial<Record<LogoShape, { both?: unknown; onLight?: unknown; onDark?: unknown }>>;
127
+ }
128
+ /** An organization's logo as a panel shows it; null when the deck has no organization. */
129
+ export declare function readOrganizationLogo(presentation: unknown, options?: Pick<OrganizationLogoOptions, "organization">): OrganizationLogoState | null;
130
+ export interface LogoChoiceState {
131
+ /** "primary" (unset), "none" (false), "reference" (a logo reference) or "custom" (any other value). */
132
+ mode: "primary" | "none" | "reference" | "custom";
133
+ organization?: string;
134
+ shape?: LogoShape;
135
+ value: unknown;
136
+ scope: "slide" | "deck" | "default";
137
+ inherited: boolean;
138
+ }
139
+ /** The `design.logo` choice at a scope. */
140
+ export declare function readLogoChoice(presentation: unknown, options?: Pick<DesignOptionOptions, "slideIndex">): LogoChoiceState;
86
141
  export interface HeaderFooterZoneFields {
87
- logo?: boolean | null;
88
142
  text?: string | null;
89
143
  image?: string | Record<string, unknown> | null;
90
- slideNumber?: boolean | null;
91
- slideNumberFormat?: string | null;
92
144
  date?: boolean | string | null;
93
145
  dateFormat?: string | null;
94
- organization?: boolean | null;
95
- /** The first speaker's name and title ("Ada Lovelace, CTO"). */
96
- speaker?: boolean | null;
97
146
  socials?: boolean | null;
98
- section?: boolean | null;
99
147
  }
100
148
  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. */
149
+ /** 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
150
  export declare function setHeaderFooterZone(editor: EditorSession, which: "header" | "footer", zone: HeaderFooterZone, fields: HeaderFooterZoneFields, options?: DesignOptionOptions): DesignOptionChange;
151
+ export interface ZoneValueOptions extends DesignOptionOptions {
152
+ /** UTF-16 offsets into the zone's text; a selection is replaced. Default: the end. */
153
+ start?: number;
154
+ end?: number;
155
+ /** 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. */
156
+ text?: string;
157
+ }
158
+ /** 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). */
159
+ export declare function prepareZoneValue(presentation: unknown, which: "header" | "footer", zone: HeaderFooterZone, name: ZoneValue["name"], options?: ZoneValueOptions): PreparedDesignOption;
160
+ /** Insert a value's token into one zone's `text` as a single undoable transaction. */
161
+ export declare function insertZoneValue(editor: EditorSession, which: "header" | "footer", zone: HeaderFooterZone, name: ZoneValue["name"], options?: ZoneValueOptions): DesignOptionChange;
162
+ export interface ZoneLogoOptions extends DesignOptionOptions {
163
+ /** The shape to show (default `icon`). */
164
+ shape?: LogoShape;
165
+ /** The organization's id (default: the primary organization). */
166
+ organization?: string;
167
+ }
168
+ /** The patch that makes a zone show an organization's logo: its `image` becomes `var:organization.logo.<shape>` (`var:organization.<id>.logo.<shape>` for a named organization). */
169
+ export declare function prepareZoneLogo(presentation: unknown, which: "header" | "footer", zone: HeaderFooterZone, options?: ZoneLogoOptions): PreparedDesignOption;
170
+ /** Make a zone show an organization's logo as a single undoable transaction. */
171
+ export declare function insertZoneLogo(editor: EditorSession, which: "header" | "footer", zone: HeaderFooterZone, options?: ZoneLogoOptions): DesignOptionChange;
103
172
  /** 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
173
  export declare function readHeaderFooterZone(presentation: unknown, which: "header" | "footer", zone: HeaderFooterZone, options?: Pick<DesignOptionOptions, "slideIndex">): HeaderFooterZoneFields;
105
174
  /** Whether the scope sets the header or footer itself (`own`), shows the deck's (`inherited`) or hides it with `false` (`hidden`). */
106
175
  export declare function headerFooterState(presentation: unknown, which: "header" | "footer", options?: Pick<DesignOptionOptions, "slideIndex">): { own: boolean; inherited: boolean; hidden: boolean };
107
- /** Whether a logo resolves for the slide: slide design, deck design, then the primary organization. */
108
- 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. */
176
+ /** Whether a logo resolves for the slide: its `design.logo`, then the deck's, then the primary organization's (`shape`: the shape asked for, default full). */
177
+ export declare function hasResolvableLogo(presentation: unknown, slideIndex: number, options?: { shape?: LogoShape }): boolean;
178
+ /** Settings that need content the document does not have: a logo (a zone image or `design.logo` that names a logo no organization has, 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
179
  export declare function designWarnings(presentation: unknown, slideIndex?: number): DesignWarning[];