@openpresentation/opf-editor 0.10.6 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +333 -8
- package/dist/annotations.d.ts +71 -0
- package/dist/annotations.js +281 -0
- package/dist/assets.d.ts +67 -0
- package/dist/assets.js +176 -0
- package/dist/background-options.d.ts +48 -0
- package/dist/background-options.js +134 -0
- package/dist/block-convert.d.ts +64 -0
- package/dist/block-convert.js +142 -0
- package/dist/canvas.d.ts +16 -0
- package/dist/canvas.js +82 -21
- package/dist/chart-data.d.ts +32 -0
- package/dist/chart-data.js +101 -0
- package/dist/chart-options-panel.d.ts +16 -0
- package/dist/chart-options-panel.js +127 -0
- package/dist/chart-options.d.ts +49 -0
- package/dist/chart-options.js +157 -0
- package/dist/content-actions.d.ts +91 -0
- package/dist/content-actions.js +207 -0
- package/dist/content-controls.js +326 -0
- package/dist/data-grid.d.ts +37 -0
- package/dist/data-grid.js +1035 -0
- package/dist/design-controls.d.ts +43 -0
- package/dist/design-controls.js +1077 -0
- package/dist/design-options.d.ts +108 -0
- package/dist/design-options.js +412 -0
- package/dist/edit-helpers.js +52 -0
- package/dist/export.d.ts +77 -0
- package/dist/export.js +216 -0
- package/dist/find-panel.d.ts +44 -0
- package/dist/find-panel.js +431 -0
- package/dist/find-replace.d.ts +100 -0
- package/dist/find-replace.js +374 -0
- package/dist/grid-model.d.ts +135 -0
- package/dist/grid-model.js +836 -0
- package/dist/grid-text.d.ts +33 -0
- package/dist/grid-text.js +251 -0
- package/dist/image-crop.d.ts +59 -0
- package/dist/image-crop.js +336 -0
- package/dist/image-cropper.d.ts +29 -0
- package/dist/image-cropper.js +519 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +104 -171
- package/dist/numbering-panel.d.ts +21 -0
- package/dist/numbering-panel.js +200 -0
- package/dist/numbering.d.ts +62 -0
- package/dist/numbering.js +223 -0
- package/dist/outline-view.d.ts +17 -0
- package/dist/outline-view.js +278 -0
- package/dist/outline.d.ts +56 -0
- package/dist/outline.js +271 -0
- package/dist/persistence-ui.d.ts +24 -0
- package/dist/persistence-ui.js +81 -0
- package/dist/persistence.d.ts +105 -0
- package/dist/persistence.js +429 -0
- package/dist/review-panel.d.ts +44 -0
- package/dist/review-panel.js +359 -0
- package/dist/review.d.ts +75 -0
- package/dist/review.js +170 -0
- package/dist/slide-manager.d.ts +44 -0
- package/dist/slide-manager.js +695 -0
- package/dist/slides.d.ts +96 -0
- package/dist/slides.js +433 -0
- package/dist/switches.d.ts +26 -0
- package/dist/switches.js +127 -43
- package/dist/table-options.d.ts +80 -0
- package/dist/table-options.js +419 -0
- package/dist/table-structure.d.ts +30 -0
- package/dist/table-structure.js +92 -0
- package/dist/template-panel.d.ts +31 -0
- package/dist/template-panel.js +377 -0
- package/dist/templates.d.ts +126 -0
- package/dist/templates.js +331 -0
- package/dist/zip.d.ts +4 -0
- package/dist/zip.js +71 -0
- package/package.json +150 -10
|
@@ -0,0 +1,1077 @@
|
|
|
1
|
+
// Design controls (RR-06): the DOM panel for the dimension switches, the design options, block
|
|
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
|
|
5
|
+
// subscribes to the session redraws on every change, on Undo and on Redo. The panel owns no
|
|
6
|
+
// document state: it reads the session, mirrors it into native form controls, and reports
|
|
7
|
+
// problems in a live region. Importing this module does not need a DOM; mounting does.
|
|
8
|
+
import { compatibleChartTypes, currentSwitchValue, listSwitchOptions, switchDimension } from "./switches.js";
|
|
9
|
+
import { DEFAULT_MAX_IMAGE_BYTES, IMAGE_ACCEPT, applyImageUpload, assetIdOf, setAssetAlt } from "./assets.js";
|
|
10
|
+
import {
|
|
11
|
+
COLOR_NAMES,
|
|
12
|
+
IMAGE_BACKGROUND_FITS,
|
|
13
|
+
PATTERN_GROUPS,
|
|
14
|
+
THEME_BACKGROUND_SLOTS,
|
|
15
|
+
prepareBackground,
|
|
16
|
+
readBackground,
|
|
17
|
+
setBackground,
|
|
18
|
+
} from "./background-options.js";
|
|
19
|
+
import { BLOCK_KIND_LABELS, blockConversionTargets, blockPathForSelection, readBlockContent } from "./block-convert.js";
|
|
20
|
+
import {
|
|
21
|
+
DATE_FORMAT_TOKENS,
|
|
22
|
+
HEADER_FOOTER_ZONES,
|
|
23
|
+
LOGO_VARIANTS,
|
|
24
|
+
designWarnings,
|
|
25
|
+
getDesignOption,
|
|
26
|
+
headerFooterState,
|
|
27
|
+
prepareHeaderFooterZone,
|
|
28
|
+
prepareLogoVariant,
|
|
29
|
+
prepareDesignOption,
|
|
30
|
+
readHeaderFooterZone,
|
|
31
|
+
readLogoVariants,
|
|
32
|
+
setDesignOption,
|
|
33
|
+
setHeaderFooterZone,
|
|
34
|
+
setLogoVariant,
|
|
35
|
+
} from "./design-options.js";
|
|
36
|
+
import { createContentControls } from "./content-controls.js";
|
|
37
|
+
import { describeTableCell, mergeTableCells, parseTableCellPath, readTableStyle, setTableCellStyle, setTableStyle, splitTableCell } from "./table-options.js";
|
|
38
|
+
|
|
39
|
+
/** The sections a panel can show. `selection` and `table` follow the current selection; the rest follow the deck or the current slide. */
|
|
40
|
+
export const DESIGN_CONTROL_SECTIONS = Object.freeze(["look", "background", "slide-image", "header-footer", "brand", "layout-options", "info", "selection", "table", "slide-content"]);
|
|
41
|
+
|
|
42
|
+
const SECTION_TITLES = {
|
|
43
|
+
look: "Look and language",
|
|
44
|
+
background: "Background",
|
|
45
|
+
"slide-image": "Slide image",
|
|
46
|
+
"header-footer": "Header and footer",
|
|
47
|
+
brand: "Logo, watermark and bullets",
|
|
48
|
+
"layout-options": "Alignment and arrangement",
|
|
49
|
+
info: "Audience and story",
|
|
50
|
+
selection: "Selected content",
|
|
51
|
+
table: "Table",
|
|
52
|
+
"slide-content": "Slide structure",
|
|
53
|
+
};
|
|
54
|
+
const OPEN_BY_DEFAULT = new Set(["look", "selection", "table"]);
|
|
55
|
+
const LOGO_VARIANT_LABELS = {
|
|
56
|
+
default: "Default",
|
|
57
|
+
light: "Light (for dark backgrounds)",
|
|
58
|
+
dark: "Dark (for light backgrounds)",
|
|
59
|
+
stacked: "Stacked",
|
|
60
|
+
stackedLight: "Stacked, light",
|
|
61
|
+
stackedDark: "Stacked, dark",
|
|
62
|
+
icon: "Icon",
|
|
63
|
+
iconLight: "Icon, light",
|
|
64
|
+
iconDark: "Icon, dark",
|
|
65
|
+
wordmark: "Wordmark",
|
|
66
|
+
wordmarkLight: "Wordmark, light",
|
|
67
|
+
wordmarkDark: "Wordmark, dark",
|
|
68
|
+
};
|
|
69
|
+
const CELL_FILLS = [
|
|
70
|
+
["", "No fill"],
|
|
71
|
+
["primary", "Primary"],
|
|
72
|
+
["secondary", "Secondary"],
|
|
73
|
+
["accent", "Accent"],
|
|
74
|
+
["surface", "Surface"],
|
|
75
|
+
["background", "Background"],
|
|
76
|
+
["text", "Text color"],
|
|
77
|
+
];
|
|
78
|
+
const SHAPES = ["rectangle", "rounded", "circle", "hexagon"];
|
|
79
|
+
const POSITIONS = ["background", "top", "bottom", "left", "right"];
|
|
80
|
+
const BLOCK_REPLACEMENTS = ["text", "list", "chart", "table", "metric", "quote", "code", "timeline", "group"];
|
|
81
|
+
|
|
82
|
+
let instances = 0;
|
|
83
|
+
|
|
84
|
+
const messageOf = (error) => error?.issues?.[0]?.message ?? error?.message ?? String(error);
|
|
85
|
+
const titleCase = (text) => text.charAt(0).toUpperCase() + text.slice(1);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Mount the controls into `container` and return `{ element, refresh, destroy }`.
|
|
89
|
+
*
|
|
90
|
+
* Options: `editor` (an editor session), `getSlideIndex()` and `getSelectedPath()` (the host's current
|
|
91
|
+
* slide and selection; call `refresh()` when they change), `sections` (default: all), `scope`
|
|
92
|
+
* ("deck" or "slide", the initial "Applies to" choice), `catalogs`/`catalogSources` (caller-loaded
|
|
93
|
+
* catalog records), `onChange(change)`, `onStatus(message, { error })` and `onSelectPath(path)`, called
|
|
94
|
+
* after a content conversion or replacement with the path to keep selected (the block, or the inline
|
|
95
|
+
* payload field), because the host's old selection path may no longer exist.
|
|
96
|
+
*/
|
|
97
|
+
export function createDesignControls(container, options = {}) {
|
|
98
|
+
const editor = options.editor;
|
|
99
|
+
if (!editor || typeof editor.applyPatch !== "function" || typeof editor.subscribe !== "function") throw new TypeError("createDesignControls needs an editor session.");
|
|
100
|
+
if (!container || typeof container.appendChild !== "function") throw new TypeError("createDesignControls needs a container element.");
|
|
101
|
+
const doc = container.ownerDocument;
|
|
102
|
+
const uid = `opf-dc-${++instances}`;
|
|
103
|
+
const sections = (options.sections ?? DESIGN_CONTROL_SECTIONS).filter((name) => DESIGN_CONTROL_SECTIONS.includes(name));
|
|
104
|
+
const getSlide = () => options.getSlideIndex?.() ?? 0;
|
|
105
|
+
const getSelected = () => options.getSelectedPath?.();
|
|
106
|
+
const catalogOptions = () => ({ catalogs: options.catalogs, catalogSources: options.catalogSources });
|
|
107
|
+
const state = { scope: options.scope === "slide" ? "slide" : "deck" };
|
|
108
|
+
const syncs = [];
|
|
109
|
+
let destroyed = false;
|
|
110
|
+
// After a refused change the field the person typed in is reset too, even though it still has focus.
|
|
111
|
+
let restoring = false;
|
|
112
|
+
// The background form is a draft until Apply; edits to it survive unrelated refreshes.
|
|
113
|
+
let backgroundDirty = false;
|
|
114
|
+
const maxImageBytes = options.maxImageBytes ?? DEFAULT_MAX_IMAGE_BYTES;
|
|
115
|
+
const sizeLabel = `${maxImageBytes >= 1024 * 1024 ? `${+(maxImageBytes / (1024 * 1024)).toFixed(1)} MB` : `${Math.round(maxImageBytes / 1024)} KB`}`;
|
|
116
|
+
|
|
117
|
+
const h = (tag, attrs = {}, ...children) => {
|
|
118
|
+
const node = doc.createElement(tag);
|
|
119
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
120
|
+
if (value === undefined || value === false || value === null) continue;
|
|
121
|
+
if (key === "class") node.className = value;
|
|
122
|
+
else if (key.startsWith("on")) node.addEventListener(key.slice(2), value);
|
|
123
|
+
else if (value === true) node.setAttribute(key, "");
|
|
124
|
+
else node.setAttribute(key, String(value));
|
|
125
|
+
}
|
|
126
|
+
for (const child of children.flat()) if (child !== undefined && child !== null && child !== false) node.append(child);
|
|
127
|
+
return node;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const root = h("div", { class: "opf-design-controls", "data-opf-component": "design-controls" });
|
|
131
|
+
const statusEl = h("p", { class: "opf-dc-status", role: "status", "aria-live": "polite" });
|
|
132
|
+
const errorEl = h("p", { class: "opf-dc-error", role: "alert" });
|
|
133
|
+
|
|
134
|
+
const say = (message) => {
|
|
135
|
+
errorEl.textContent = "";
|
|
136
|
+
statusEl.textContent = message;
|
|
137
|
+
options.onStatus?.(message, { error: false });
|
|
138
|
+
};
|
|
139
|
+
const complain = (message) => {
|
|
140
|
+
statusEl.textContent = "";
|
|
141
|
+
errorEl.textContent = message;
|
|
142
|
+
options.onStatus?.(message, { error: true });
|
|
143
|
+
};
|
|
144
|
+
const reselect = (blockPath) => {
|
|
145
|
+
if (!blockPath || !options.onSelectPath) return;
|
|
146
|
+
const content = readBlockContent(editor.document, blockPath);
|
|
147
|
+
options.onSelectPath(content && !content.explicit ? `${blockPath}.${content.key}` : blockPath);
|
|
148
|
+
};
|
|
149
|
+
/** Run a change; report its outcome; on failure restore every control to the document. */
|
|
150
|
+
const run = (action, done) => {
|
|
151
|
+
try {
|
|
152
|
+
const change = action();
|
|
153
|
+
const warnings = change?.warnings?.length ? ` ${change.warnings.map((warning) => warning.message).join(" ")}` : "";
|
|
154
|
+
if (change && change.changed === false) say("Already set. Nothing changed.");
|
|
155
|
+
else say(`${typeof done === "function" ? done(change) : (done ?? "Changed.")}${warnings} Undo restores the previous state.`);
|
|
156
|
+
options.onChange?.(change);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
complain(messageOf(error));
|
|
159
|
+
restoring = true;
|
|
160
|
+
try {
|
|
161
|
+
sync();
|
|
162
|
+
} finally {
|
|
163
|
+
restoring = false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// --- field builders ----------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
let fieldCount = 0;
|
|
171
|
+
const nextId = (name) => `${uid}-${name}-${++fieldCount}`;
|
|
172
|
+
|
|
173
|
+
function wrapField(id, label, control, { help, extra } = {}) {
|
|
174
|
+
const note = h("span", { class: "opf-dc-note" });
|
|
175
|
+
const labelEl = h("label", { for: id }, label, note);
|
|
176
|
+
const helpEl = help ? h("p", { class: "opf-dc-help", id: `${id}-help` }, help) : undefined;
|
|
177
|
+
if (helpEl) control.setAttribute("aria-describedby", helpEl.id);
|
|
178
|
+
const wrap = h("div", { class: "opf-dc-field" }, labelEl, control, extra, helpEl);
|
|
179
|
+
return { wrap, note, label: labelEl };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function selectField(name, label, { empty, help, multiple, size, onChange } = {}) {
|
|
183
|
+
const id = nextId(name);
|
|
184
|
+
const select = h("select", { id, multiple: multiple || undefined, size: multiple ? (size ?? 6) : undefined });
|
|
185
|
+
const { wrap, note } = wrapField(id, label, select, { help });
|
|
186
|
+
let signature = "";
|
|
187
|
+
const api = {
|
|
188
|
+
wrap,
|
|
189
|
+
select,
|
|
190
|
+
setOptions(list) {
|
|
191
|
+
const next = JSON.stringify(list.map((entry) => [entry.value, entry.label, entry.disabled ?? false, entry.title ?? "", entry.group ?? ""]));
|
|
192
|
+
if (next === signature) return;
|
|
193
|
+
signature = next;
|
|
194
|
+
const entries = empty === undefined ? list : [{ value: "", label: empty }, ...list];
|
|
195
|
+
const optionNode = (entry) => h("option", { value: entry.value, disabled: entry.disabled || undefined, title: entry.title }, entry.label);
|
|
196
|
+
const nodes = [];
|
|
197
|
+
for (const entry of entries) {
|
|
198
|
+
if (!entry.group) nodes.push(optionNode(entry));
|
|
199
|
+
else {
|
|
200
|
+
let groupNode = nodes.find((node) => node.tagName === "OPTGROUP" && node.label === entry.group);
|
|
201
|
+
if (!groupNode) nodes.push((groupNode = h("optgroup", { label: entry.group })));
|
|
202
|
+
groupNode.append(optionNode(entry));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
select.replaceChildren(...nodes);
|
|
206
|
+
},
|
|
207
|
+
set(value, noteText = "") {
|
|
208
|
+
note.textContent = noteText ? ` (${noteText})` : "";
|
|
209
|
+
if (multiple) {
|
|
210
|
+
const wanted = new Set(Array.isArray(value) ? value : value === undefined ? [] : [value]);
|
|
211
|
+
for (const option of select.options) option.selected = wanted.has(option.value);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const wanted = value === undefined || value === null ? "" : String(value);
|
|
215
|
+
select.querySelector("option[data-custom]")?.remove();
|
|
216
|
+
if (![...select.options].some((option) => option.value === wanted)) {
|
|
217
|
+
select.append(h("option", { value: wanted, "data-custom": "", disabled: true }, wanted === "" ? "Not set" : `${wanted} (custom)`));
|
|
218
|
+
}
|
|
219
|
+
select.value = wanted;
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
if (onChange) select.addEventListener("change", () => onChange(multiple ? [...select.selectedOptions].map((option) => option.value) : select.value));
|
|
223
|
+
return api;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function textField(name, label, { help, placeholder, list, onCommit, type = "text" } = {}) {
|
|
227
|
+
const id = nextId(name);
|
|
228
|
+
const input = h("input", { id, type, placeholder, list: list ? `${id}-list` : undefined, autocomplete: "off", spellcheck: "false" });
|
|
229
|
+
const datalist = list ? h("datalist", { id: `${id}-list` }) : undefined;
|
|
230
|
+
const { wrap, note } = wrapField(id, label, input, { help, extra: datalist });
|
|
231
|
+
const api = {
|
|
232
|
+
wrap,
|
|
233
|
+
input,
|
|
234
|
+
setList(values) {
|
|
235
|
+
if (datalist) datalist.replaceChildren(...values.map((value) => h("option", { value })));
|
|
236
|
+
},
|
|
237
|
+
set(value, noteText = "") {
|
|
238
|
+
note.textContent = noteText ? ` (${noteText})` : "";
|
|
239
|
+
// Never overwrite what the person is typing; they commit with Enter or by leaving the field.
|
|
240
|
+
if (restoring || doc.activeElement !== input) input.value = value ?? "";
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
if (onCommit) input.addEventListener("change", () => onCommit(input.value));
|
|
244
|
+
return api;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function numberField(name, label, { min, max, step, help, onCommit } = {}) {
|
|
248
|
+
const field = textField(name, label, { help, type: "number", onCommit: onCommit ? (value) => onCommit(value === "" ? null : Number(value)) : undefined });
|
|
249
|
+
for (const [key, value] of Object.entries({ min, max, step })) if (value !== undefined) field.input.setAttribute(key, String(value));
|
|
250
|
+
return field;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function checkField(name, label, { help, onChange } = {}) {
|
|
254
|
+
const id = nextId(name);
|
|
255
|
+
const input = h("input", { id, type: "checkbox" });
|
|
256
|
+
const helpEl = help ? h("p", { class: "opf-dc-help", id: `${id}-help` }, help) : undefined;
|
|
257
|
+
if (helpEl) input.setAttribute("aria-describedby", helpEl.id);
|
|
258
|
+
const note = h("span", { class: "opf-dc-note" });
|
|
259
|
+
const wrap = h("div", { class: "opf-dc-field opf-dc-check" }, input, h("label", { for: id }, label, note), helpEl);
|
|
260
|
+
if (onChange) input.addEventListener("change", () => onChange(input.checked));
|
|
261
|
+
return {
|
|
262
|
+
wrap,
|
|
263
|
+
input,
|
|
264
|
+
set(checked, noteText = "") {
|
|
265
|
+
note.textContent = noteText ? ` (${noteText})` : "";
|
|
266
|
+
input.checked = Boolean(checked);
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const button = (label, onClick, { quiet = false, title } = {}) => h("button", { type: "button", class: quiet ? "quiet" : "secondary", title, onclick: onClick }, label);
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Source text, file upload and alt text for one image. `apply(ref | null)` commits a typed source (null removes it);
|
|
275
|
+
* `build(ref, document)` prepares the change an upload makes, after its asset patch, in the same undo step.
|
|
276
|
+
*/
|
|
277
|
+
function imageCluster(name, label, { apply, build, help }) {
|
|
278
|
+
const source = textField(`${name}-source`, `${label} source`, {
|
|
279
|
+
list: true,
|
|
280
|
+
placeholder: "asset:photo or https://…",
|
|
281
|
+
help: help ?? "An asset reference, a web address or a data address. Press Enter to apply; clear the field to remove it.",
|
|
282
|
+
onCommit: (value) => {
|
|
283
|
+
const ref = value.trim();
|
|
284
|
+
run(() => apply(ref === "" ? null : ref), ref === "" ? `${label} removed.` : `${label} changed.`);
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
const fileId = nextId(`${name}-file`);
|
|
288
|
+
const input = h("input", { id: fileId, type: "file", accept: IMAGE_ACCEPT, "aria-describedby": `${fileId}-help` });
|
|
289
|
+
const fileWrap = h(
|
|
290
|
+
"div",
|
|
291
|
+
{ class: "opf-dc-field" },
|
|
292
|
+
h("label", { for: fileId }, `Upload ${label.toLowerCase()} file`),
|
|
293
|
+
input,
|
|
294
|
+
h("p", { class: "opf-dc-help", id: `${fileId}-help` }, `PNG, JPEG, GIF, WebP or SVG, up to ${sizeLabel}. It is added to the presentation's assets (or handed to your app) and used here in one undoable step.`),
|
|
295
|
+
);
|
|
296
|
+
let lastRef = "";
|
|
297
|
+
let lastKey = "";
|
|
298
|
+
const alt = textField(`${name}-alt`, `${label} alt text`, {
|
|
299
|
+
help: "Describes the image for screen readers. Saved with the image's asset; typed before an upload, it is used for that upload. Press Enter to apply.",
|
|
300
|
+
onCommit: (value) => {
|
|
301
|
+
const id = assetIdOf(lastRef);
|
|
302
|
+
if (!id || editor.document.assets?.[id] === undefined) return;
|
|
303
|
+
run(() => setAssetAlt(editor, id, value), "Alt text saved.");
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
input.addEventListener("change", async () => {
|
|
307
|
+
const file = input.files?.[0];
|
|
308
|
+
input.value = "";
|
|
309
|
+
if (!file) return;
|
|
310
|
+
statusEl.textContent = `Adding ${file.name}…`;
|
|
311
|
+
errorEl.textContent = "";
|
|
312
|
+
try {
|
|
313
|
+
const change = await applyImageUpload(editor, file, build, { alt: alt.input.value, maxBytes: maxImageBytes, onAddAsset: options.onAddAsset });
|
|
314
|
+
const warnings = change.prepared?.warnings?.length ? ` ${change.prepared.warnings.map((warning) => warning.message).join(" ")}` : "";
|
|
315
|
+
say(`${label} set to ${file.name}${change.assetId ? `, added to assets as ${change.assetId}` : ""}.${warnings} Undo restores the previous state.`);
|
|
316
|
+
options.onChange?.(change);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
complain(messageOf(error));
|
|
319
|
+
sync();
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
const wrap = h("div", { class: "opf-dc-cluster" }, source.wrap, fileWrap, alt.wrap);
|
|
323
|
+
return {
|
|
324
|
+
wrap,
|
|
325
|
+
source,
|
|
326
|
+
alt,
|
|
327
|
+
refreshAssets: () => source.setList(assetList()),
|
|
328
|
+
// `key` names what the image is for (variant, zone, slide, scope): alt text typed for one target is not offered to another.
|
|
329
|
+
set(ref, note = "", key = "") {
|
|
330
|
+
const targetChanged = (ref ?? "") !== lastRef || key !== lastKey;
|
|
331
|
+
lastKey = key;
|
|
332
|
+
lastRef = ref ?? "";
|
|
333
|
+
source.setList(assetList());
|
|
334
|
+
source.set(lastRef, note);
|
|
335
|
+
const id = assetIdOf(lastRef);
|
|
336
|
+
const entry = id ? editor.document.assets?.[id] : undefined;
|
|
337
|
+
// Alt text belongs to one image: another target (variant, zone, slide) never inherits what was typed for the last one.
|
|
338
|
+
alt.set(entry && typeof entry === "object" && typeof entry.alt === "string" ? entry.alt : "");
|
|
339
|
+
// The field keeps what is being typed, except when the image it describes has changed.
|
|
340
|
+
if (targetChanged) alt.input.value = entry && typeof entry === "object" && typeof entry.alt === "string" ? entry.alt : "";
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function group(section) {
|
|
346
|
+
const body = h("div", { class: "opf-dc-body" });
|
|
347
|
+
const details = h("details", { class: "opf-dc-group", "data-section": section, open: OPEN_BY_DEFAULT.has(section) || undefined }, h("summary", {}, SECTION_TITLES[section]), body);
|
|
348
|
+
return { details, body };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// --- scope --------------------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
const target = () => `${state.scope}:${state.scope === "slide" ? getSlide() : ""}`;
|
|
354
|
+
const scopeIndex = () => (state.scope === "slide" ? getSlide() : undefined);
|
|
355
|
+
const scoped = (extra = {}) => (state.scope === "slide" ? { slideIndex: getSlide(), ...extra } : extra);
|
|
356
|
+
const sourceNote = (scope, inherited) => (state.scope === "slide" ? (scope === "slide" ? "set on this slide" : inherited ? "from the presentation" : "default") : "");
|
|
357
|
+
const scopeNeeded = sections.some((name) => ["look", "background", "slide-image", "header-footer", "brand", "layout-options"].includes(name));
|
|
358
|
+
let scopeField;
|
|
359
|
+
if (scopeNeeded) {
|
|
360
|
+
scopeField = selectField("scope", "Applies to", {
|
|
361
|
+
help: "Choose the whole presentation, or only the slide you are looking at.",
|
|
362
|
+
onChange: (value) => {
|
|
363
|
+
state.scope = value === "slide" ? "slide" : "deck";
|
|
364
|
+
sync();
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
scopeField.setOptions([
|
|
368
|
+
{ value: "deck", label: "Whole presentation" },
|
|
369
|
+
{ value: "slide", label: "This slide only" },
|
|
370
|
+
]);
|
|
371
|
+
scopeField.set(state.scope);
|
|
372
|
+
root.append(scopeField.wrap);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const assetList = () => Object.keys(editor.document.assets ?? {}).map((id) => `asset:${id}`);
|
|
376
|
+
const stringOf = (value) => (typeof value === "string" ? value : value && typeof value === "object" && typeof value.src === "string" ? value.src : "");
|
|
377
|
+
const isCustomObject = (value) => Boolean(value) && typeof value === "object";
|
|
378
|
+
|
|
379
|
+
const built = {};
|
|
380
|
+
for (const section of sections) built[section] = group(section);
|
|
381
|
+
// RR-26: list levels, grouping, regions and images between content and design (content-controls.js).
|
|
382
|
+
const contentControls = built.selection || built["slide-content"] ? createContentControls({ editor, h, selectField, run, nextId, getSlide, reselect }) : undefined;
|
|
383
|
+
|
|
384
|
+
// --- look and language --------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
if (built.look) {
|
|
387
|
+
const { body } = built.look;
|
|
388
|
+
const catalogField = (name, label, dimension, { help, perSlide = true } = {}) => {
|
|
389
|
+
const field = selectField(name, label, {
|
|
390
|
+
help,
|
|
391
|
+
onChange: (value) => run(() => switchDimension(editor, dimension, value, { ...(perSlide ? scoped() : {}), ...catalogOptions() }), `${label} set to ${field.select.selectedOptions[0]?.textContent ?? value}.`),
|
|
392
|
+
});
|
|
393
|
+
body.append(field.wrap);
|
|
394
|
+
syncs.push(() => {
|
|
395
|
+
field.setOptions(listSwitchOptions(editor.document, dimension, catalogOptions()).map((entry) => ({ value: entry.id, label: entry.label })));
|
|
396
|
+
const current = currentSwitchValue(editor.document, dimension, perSlide ? scoped() : {});
|
|
397
|
+
field.set(current.value, perSlide ? sourceNote(current.scope, current.value !== undefined) : "");
|
|
398
|
+
});
|
|
399
|
+
return field;
|
|
400
|
+
};
|
|
401
|
+
catalogField("theme", "Theme", "themes", { help: "A theme also sets its color scheme, font scheme and background." });
|
|
402
|
+
catalogField("color-scheme", "Color scheme", "color-schemes");
|
|
403
|
+
catalogField("font-scheme", "Font scheme", "font-schemes", { help: "Fonts the preview and export use. The preview shows an open look-alike where a font is not bundled." });
|
|
404
|
+
catalogField("language", "Language", "languages", { perSlide: false, help: "Sets the language for the whole presentation, including its script fonts." });
|
|
405
|
+
|
|
406
|
+
const layout = selectField("layout", "Layout of this slide", {
|
|
407
|
+
help: "Adds the blank placeholders the layout declares and keeps your content.",
|
|
408
|
+
onChange: (value) => run(() => switchDimension(editor, "layouts", value, { slideIndex: getSlide(), ...catalogOptions() }), `Layout changed to ${layout.select.selectedOptions[0]?.textContent ?? value}.`),
|
|
409
|
+
});
|
|
410
|
+
body.append(layout.wrap);
|
|
411
|
+
syncs.push(() => {
|
|
412
|
+
layout.setOptions(listSwitchOptions(editor.document, "layouts", catalogOptions()).map((entry) => ({ value: entry.id, label: entry.label })));
|
|
413
|
+
layout.set(currentSwitchValue(editor.document, "layouts", { slideIndex: getSlide() }).value);
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// --- background ---------------------------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
if (built.background) {
|
|
420
|
+
const { body } = built.background;
|
|
421
|
+
const typeSelect = selectField("bg-type", "Background type", { help: "Choose a type and its settings, then Apply. Nothing changes until you apply." });
|
|
422
|
+
typeSelect.setOptions([
|
|
423
|
+
{ value: "theme", label: "Theme color" },
|
|
424
|
+
{ value: "solid", label: "Solid color" },
|
|
425
|
+
{ value: "gradient", label: "Gradient" },
|
|
426
|
+
{ value: "image", label: "Image" },
|
|
427
|
+
{ value: "pattern", label: "Pattern" },
|
|
428
|
+
]);
|
|
429
|
+
const panel = (type, title) => h("fieldset", { class: "opf-dc-fieldset", "data-bg-type": type }, h("legend", {}, title));
|
|
430
|
+
const themePanel = panel("theme", "Theme color");
|
|
431
|
+
const slot = selectField("bg-slot", "Theme slot", { help: "Follows the color scheme, so it changes with it." });
|
|
432
|
+
slot.setOptions(THEME_BACKGROUND_SLOTS.map((value) => ({ value, label: value.replace(/(\D+)(\d)/, (_, name, n) => `${titleCase(name)} ${n}`) })));
|
|
433
|
+
themePanel.append(slot.wrap);
|
|
434
|
+
|
|
435
|
+
const colorList = [...COLOR_NAMES];
|
|
436
|
+
const solidPanel = panel("solid", "Solid color");
|
|
437
|
+
const solidColor = textField("bg-color", "Color", { list: true, placeholder: "#1F2937 or accent1", help: "A hex color, a scheme color such as accent1 or surface, or var:<id>." });
|
|
438
|
+
solidColor.setList(colorList);
|
|
439
|
+
solidPanel.append(solidColor.wrap);
|
|
440
|
+
|
|
441
|
+
const gradientPanel = panel("gradient", "Linear gradient");
|
|
442
|
+
const angle = numberField("bg-angle", "Angle in degrees", { step: 1, help: "0 runs left to right, 90 top to bottom." });
|
|
443
|
+
const stopsBox = h("div", { class: "opf-dc-stops", role: "group", "aria-label": "Gradient stops" });
|
|
444
|
+
const stopRows = [];
|
|
445
|
+
const renderStops = () => {
|
|
446
|
+
stopsBox.replaceChildren(
|
|
447
|
+
...stopRows.map((row, index) => {
|
|
448
|
+
for (const [field, text] of [[row.color, `Stop ${index + 1} color`], [row.position, `Stop ${index + 1} position (0 to 1)`]]) field.wrap.querySelector("label").firstChild.textContent = text;
|
|
449
|
+
const remove = h("button", { type: "button", class: "quiet", disabled: stopRows.length <= 2 || undefined, "aria-label": `Remove stop ${index + 1}`, onclick: () => { stopRows.splice(index, 1); backgroundDirty = true; renderStops(); } }, "Remove");
|
|
450
|
+
return h("div", { class: "opf-dc-stop" }, row.color.wrap, row.position.wrap, remove);
|
|
451
|
+
}),
|
|
452
|
+
);
|
|
453
|
+
};
|
|
454
|
+
const addStop = (color = "", position = "") => {
|
|
455
|
+
const row = { color: textField(`bg-stop-color`, "Stop color", { list: true, placeholder: "#1E40AF or accent1" }), position: numberField("bg-stop-position", "Stop position", { min: 0, max: 1, step: 0.05 }) };
|
|
456
|
+
row.color.setList(colorList);
|
|
457
|
+
row.color.input.value = color;
|
|
458
|
+
row.position.input.value = String(position);
|
|
459
|
+
stopRows.push(row);
|
|
460
|
+
};
|
|
461
|
+
const addStopButton = button("Add stop", () => {
|
|
462
|
+
addStop("", stopRows.length ? "1" : "0");
|
|
463
|
+
backgroundDirty = true;
|
|
464
|
+
renderStops();
|
|
465
|
+
});
|
|
466
|
+
gradientPanel.append(angle.wrap, stopsBox, addStopButton);
|
|
467
|
+
|
|
468
|
+
const imagePanel = panel("image", "Image");
|
|
469
|
+
const fit = selectField("bg-fit", "Fit", { help: "Cover fills the slide, contain shows the whole image, tile repeats it." });
|
|
470
|
+
fit.setOptions(IMAGE_BACKGROUND_FITS.map((value) => ({ value, label: titleCase(value) })));
|
|
471
|
+
const bgImage = imageCluster("bg-image", "Background image", {
|
|
472
|
+
apply: (ref) => (ref === null ? setBackground(editor, null, scoped()) : setBackground(editor, { type: "image", image: { src: ref, fit: fit.select.value || undefined }, ...(opacityValue() === undefined ? {} : { opacity: opacityValue() }) }, scoped())),
|
|
473
|
+
build: (ref, doc) => prepareBackground(doc, { type: "image", image: { src: ref, fit: fit.select.value || undefined }, ...(opacityValue() === undefined ? {} : { opacity: opacityValue() }) }, scoped()),
|
|
474
|
+
help: "An asset reference, a web address or a data address. Press Enter or choose a file to apply it with the settings here.",
|
|
475
|
+
});
|
|
476
|
+
imagePanel.append(bgImage.wrap, fit.wrap);
|
|
477
|
+
|
|
478
|
+
const patternPanel = panel("pattern", "Pattern");
|
|
479
|
+
const preset = selectField("bg-preset", "Pattern", { help: "The 54 PowerPoint presets. PPTX export writes them as native pattern fills." });
|
|
480
|
+
preset.setOptions(Object.entries(PATTERN_GROUPS).flatMap(([name, ids]) => ids.map((id) => ({ value: id, label: id, group: name }))));
|
|
481
|
+
const patternFg = textField("bg-pattern-fg", "Pattern color", { list: true, placeholder: "#000000 or text" });
|
|
482
|
+
patternFg.setList(colorList);
|
|
483
|
+
const patternBg = textField("bg-pattern-bg", "Behind the pattern", { list: true, placeholder: "#FFFFFF or background" });
|
|
484
|
+
patternBg.setList(colorList);
|
|
485
|
+
patternPanel.append(preset.wrap, patternFg.wrap, patternBg.wrap);
|
|
486
|
+
|
|
487
|
+
const opacity = numberField("bg-opacity", "Opacity (0 to 1, optional)", { min: 0, max: 1, step: 0.05 });
|
|
488
|
+
const opacityValue = () => (opacity.input.value === "" ? undefined : Number(opacity.input.value));
|
|
489
|
+
const panels = { theme: themePanel, solid: solidPanel, gradient: gradientPanel, image: imagePanel, pattern: patternPanel };
|
|
490
|
+
|
|
491
|
+
const draftSpec = () => {
|
|
492
|
+
const type = typeSelect.select.value;
|
|
493
|
+
const withOpacity = (value) => (opacityValue() === undefined ? value : { ...value, opacity: opacityValue() });
|
|
494
|
+
if (type === "theme") return slot.select.value;
|
|
495
|
+
if (type === "solid") return withOpacity({ type: "solid", color: solidColor.input.value.trim() });
|
|
496
|
+
if (type === "gradient")
|
|
497
|
+
return withOpacity({ type: "gradient", gradient: { ...(angle.input.value === "" ? {} : { angle: Number(angle.input.value) }), stops: stopRows.map((row) => ({ color: row.color.input.value.trim(), position: row.position.input.value })) } });
|
|
498
|
+
if (type === "image") return withOpacity({ type: "image", image: { src: bgImage.source.input.value.trim(), fit: fit.select.value } });
|
|
499
|
+
return withOpacity({ type: "pattern", pattern: { preset: preset.select.value, foregroundColor: patternFg.input.value.trim() || undefined, backgroundColor: patternBg.input.value.trim() || undefined } });
|
|
500
|
+
};
|
|
501
|
+
const showType = () => {
|
|
502
|
+
for (const [name, element] of Object.entries(panels)) element.hidden = name !== typeSelect.select.value;
|
|
503
|
+
opacity.wrap.hidden = typeSelect.select.value === "theme";
|
|
504
|
+
};
|
|
505
|
+
const apply = button("Apply background", () => run(() => setBackground(editor, draftSpec(), scoped()), "Background changed."));
|
|
506
|
+
const remove = button("Remove background", () => run(() => setBackground(editor, null, scoped()), state.scope === "slide" ? "Background removed; this slide uses the presentation's." : "Background removed; the theme's shows."), { quiet: true });
|
|
507
|
+
body.addEventListener("input", () => {
|
|
508
|
+
backgroundDirty = true;
|
|
509
|
+
});
|
|
510
|
+
typeSelect.select.addEventListener("change", () => {
|
|
511
|
+
backgroundDirty = true;
|
|
512
|
+
showType();
|
|
513
|
+
});
|
|
514
|
+
body.append(typeSelect.wrap, themePanel, solidPanel, gradientPanel, imagePanel, patternPanel, opacity.wrap, h("div", { class: "opf-dc-actions" }, apply, remove));
|
|
515
|
+
|
|
516
|
+
const load = () => {
|
|
517
|
+
const current = readBackground(editor.document, scoped());
|
|
518
|
+
const note = sourceNote(current.scope, current.type !== undefined);
|
|
519
|
+
typeSelect.set(current.type ?? "theme", note);
|
|
520
|
+
slot.set(current.type === "theme" ? current.slot : "light1");
|
|
521
|
+
solidColor.set(current.type === "solid" ? current.color : "");
|
|
522
|
+
angle.set(current.type === "gradient" && current.angle !== undefined ? String(current.angle) : "");
|
|
523
|
+
stopRows.length = 0;
|
|
524
|
+
if (current.type === "gradient" && current.stops?.length) for (const stop of current.stops) addStop(stop.color, stop.position);
|
|
525
|
+
else {
|
|
526
|
+
addStop("accent1", "0");
|
|
527
|
+
addStop("accent2", "1");
|
|
528
|
+
}
|
|
529
|
+
renderStops();
|
|
530
|
+
fit.set(current.type === "image" ? (current.fit ?? "cover") : "cover");
|
|
531
|
+
bgImage.set(current.type === "image" ? (current.src ?? "") : "", "", target());
|
|
532
|
+
preset.set(current.type === "pattern" ? current.preset : "pct5");
|
|
533
|
+
patternFg.set(current.type === "pattern" ? (current.foregroundColor ?? "") : "");
|
|
534
|
+
patternBg.set(current.type === "pattern" ? (current.backgroundColor ?? "") : "");
|
|
535
|
+
opacity.set(current.opacity === undefined ? "" : String(current.opacity));
|
|
536
|
+
showType();
|
|
537
|
+
};
|
|
538
|
+
let draftTarget = "";
|
|
539
|
+
syncs.push(() => {
|
|
540
|
+
// A draft belongs to the scope and slide it was started on; moving to another discards it.
|
|
541
|
+
const target = `${state.scope}:${state.scope === "slide" ? getSlide() : ""}`;
|
|
542
|
+
if (target !== draftTarget) {
|
|
543
|
+
draftTarget = target;
|
|
544
|
+
backgroundDirty = false;
|
|
545
|
+
}
|
|
546
|
+
// A draft being edited is kept; Undo, Redo and every other change reload it from the document.
|
|
547
|
+
if (backgroundDirty) {
|
|
548
|
+
bgImage.refreshAssets();
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
load();
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// --- slide image --------------------------------------------------------------------------------
|
|
556
|
+
|
|
557
|
+
if (built["slide-image"]) {
|
|
558
|
+
const { body } = built["slide-image"];
|
|
559
|
+
const image = (fields, done) => run(() => setDesignOption(editor, "slideImage", fields, scoped()), done ?? "Slide image changed.");
|
|
560
|
+
const position = selectField("image-position", "Position", {
|
|
561
|
+
empty: "None",
|
|
562
|
+
help: "Where the slide image sits. Choose None to remove it.",
|
|
563
|
+
onChange: (value) => (value === "" ? run(() => setDesignOption(editor, "slideImage", null, scoped()), "Slide image removed.") : image({ position: value }, `Slide image placed ${value}.`)),
|
|
564
|
+
});
|
|
565
|
+
position.setOptions(POSITIONS.map((value) => ({ value, label: titleCase(value) })));
|
|
566
|
+
const source = imageCluster("image", "Slide image", {
|
|
567
|
+
apply: (ref) => setDesignOption(editor, "slideImage", { src: ref }, scoped()),
|
|
568
|
+
build: (ref, doc) => prepareDesignOption(doc, "slideImage", { src: ref }, scoped()),
|
|
569
|
+
});
|
|
570
|
+
const fill = selectField("image-fill", "Fit", { empty: "Presentation default", onChange: (value) => image({ fill: value === "" ? null : value }, "Image fit changed.") });
|
|
571
|
+
fill.setOptions([
|
|
572
|
+
{ value: "crop", label: "Crop to fill" },
|
|
573
|
+
{ value: "fit", label: "Fit whole image" },
|
|
574
|
+
]);
|
|
575
|
+
const shape = selectField("image-shape", "Shape", { empty: "Rectangle", onChange: (value) => image({ shape: value === "" ? null : value }, "Image shape changed.") });
|
|
576
|
+
shape.setOptions(SHAPES.filter((value) => value !== "rectangle").map((value) => ({ value, label: titleCase(value) })));
|
|
577
|
+
const size = numberField("image-size", "Size (share of the slide, 0.1 to 0.9)", {
|
|
578
|
+
min: 0.1,
|
|
579
|
+
max: 0.9,
|
|
580
|
+
step: 0.05,
|
|
581
|
+
onCommit: (value) => image({ size: value }, "Image size changed."),
|
|
582
|
+
});
|
|
583
|
+
const inset = checkField("image-inset", "Inset inside the slide padding", { onChange: (checked) => image({ inset: checked ? true : null }, "Image inset changed.") });
|
|
584
|
+
const placeholderFill = selectField("image-placeholder-fill", "Picture placeholders", {
|
|
585
|
+
empty: "Presentation default",
|
|
586
|
+
help: "How pictures fill their layout placeholders across the presentation.",
|
|
587
|
+
onChange: (value) => run(() => switchDimension(editor, "image-treatments", { imageFill: value === "" ? null : value }), "Picture placeholder fill changed."),
|
|
588
|
+
});
|
|
589
|
+
placeholderFill.setOptions([
|
|
590
|
+
{ value: "crop", label: "Crop to fill" },
|
|
591
|
+
{ value: "fit", label: "Fit whole image" },
|
|
592
|
+
]);
|
|
593
|
+
body.append(position.wrap, source.wrap, fill.wrap, shape.wrap, size.wrap, inset.wrap, placeholderFill.wrap);
|
|
594
|
+
syncs.push(() => {
|
|
595
|
+
const option = getDesignOption(editor.document, "slideImage", scoped());
|
|
596
|
+
const value = option.value;
|
|
597
|
+
const object = value && typeof value === "object" && !Array.isArray(value) && value.position ? value : undefined;
|
|
598
|
+
const note = sourceNote(option.scope, option.value !== undefined);
|
|
599
|
+
position.set(object?.position ?? (value === undefined ? "" : "(source only)"), note);
|
|
600
|
+
source.set(stringOf(value), "", target());
|
|
601
|
+
fill.set(object?.fill ?? "");
|
|
602
|
+
shape.set(object?.shape && object.shape !== "rectangle" ? object.shape : "");
|
|
603
|
+
size.set(object?.size === undefined ? "" : String(object.size));
|
|
604
|
+
inset.set(object?.inset === true);
|
|
605
|
+
// Fit, shape, size and inset only make sense once the image has a position.
|
|
606
|
+
for (const control of [fill, shape, size, inset]) control.wrap.hidden = !object;
|
|
607
|
+
placeholderFill.set(editor.document.design?.imageFill ?? "");
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// --- header and footer --------------------------------------------------------------------------
|
|
612
|
+
|
|
613
|
+
if (built["header-footer"]) {
|
|
614
|
+
const { body } = built["header-footer"];
|
|
615
|
+
const pick = { which: "footer", zone: "center" };
|
|
616
|
+
const which = () => pick.which;
|
|
617
|
+
const zone = () => pick.zone;
|
|
618
|
+
const furniture = selectField("hf-which", "Edit", { help: "The header runs along the top of every slide, the footer along the bottom." });
|
|
619
|
+
furniture.setOptions([
|
|
620
|
+
{ value: "header", label: "Header" },
|
|
621
|
+
{ value: "footer", label: "Footer" },
|
|
622
|
+
]);
|
|
623
|
+
furniture.set(pick.which);
|
|
624
|
+
const zoneSelect = selectField("hf-zone", "Zone", { help: "Each zone stacks its parts top to bottom: logo, image, text, organization, socials, section, slide number, date." });
|
|
625
|
+
zoneSelect.setOptions(HEADER_FOOTER_ZONES.map((value) => ({ value, label: titleCase(value) })));
|
|
626
|
+
zoneSelect.set(pick.zone);
|
|
627
|
+
furniture.select.addEventListener("change", () => {
|
|
628
|
+
pick.which = furniture.select.value;
|
|
629
|
+
sync();
|
|
630
|
+
});
|
|
631
|
+
zoneSelect.select.addEventListener("change", () => {
|
|
632
|
+
pick.zone = zoneSelect.select.value;
|
|
633
|
+
sync();
|
|
634
|
+
});
|
|
635
|
+
// Hiding is for a slide that would otherwise show the deck's furniture; at the deck, clear the zones instead.
|
|
636
|
+
const hide = checkField("hf-hide", "Hide it", {
|
|
637
|
+
help: "On a slide: shows nothing instead of the presentation's. Uncheck to inherit it again.",
|
|
638
|
+
onChange: (checked) => run(() => switchDimension(editor, "headers-footers", { [which()]: checked ? false : null }, scoped()), checked ? `${titleCase(which())} hidden on this slide.` : `${titleCase(which())} shown again.`),
|
|
639
|
+
});
|
|
640
|
+
const edit = (fields, done) => run(() => setHeaderFooterZone(editor, which(), zone(), fields, scoped()), done);
|
|
641
|
+
const where = () => `${titleCase(which())} ${zone()}`;
|
|
642
|
+
const text = textField("hf-text", "Text", { help: "Literal text. Clear it to remove.", onCommit: (value) => edit({ text: value }, `${where()} text changed.`) });
|
|
643
|
+
const logo = checkField("hf-logo", "Show the logo", { onChange: (checked) => edit({ logo: checked }, `${where()} logo ${checked ? "shown" : "removed"}.`) });
|
|
644
|
+
const image = imageCluster("hf-image", "Image", {
|
|
645
|
+
apply: (ref) => setHeaderFooterZone(editor, which(), zone(), { image: ref }, scoped()),
|
|
646
|
+
build: (ref, doc) => prepareHeaderFooterZone(doc, which(), zone(), { image: ref }, scoped()),
|
|
647
|
+
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.",
|
|
648
|
+
});
|
|
649
|
+
const number = checkField("hf-number", "Show the slide number", { onChange: (checked) => edit({ slideNumber: checked }, `${where()} slide number ${checked ? "shown" : "removed"}.`) });
|
|
650
|
+
const numberFormat = textField("hf-number-format", "Slide number format", {
|
|
651
|
+
placeholder: "Page {current} of {total}",
|
|
652
|
+
help: "Must contain {current}; {total} is the number of slides. Clear it for the plain number.",
|
|
653
|
+
onCommit: (value) => edit({ slideNumberFormat: value.trim() }, `${where()} slide number format changed.`),
|
|
654
|
+
});
|
|
655
|
+
const dateNow = checkField("hf-date-now", "Show the current date", {
|
|
656
|
+
help: "Needs the host to supply today's date when it renders or exports; otherwise the part is reported as unresolved.",
|
|
657
|
+
onChange: (checked) => edit({ date: checked }, `${where()} date ${checked ? "shown" : "removed"}.`),
|
|
658
|
+
});
|
|
659
|
+
const dateFixed = textField("hf-date-fixed", "Fixed date", {
|
|
660
|
+
placeholder: "2026-10-01",
|
|
661
|
+
help: "A fixed date written YYYY-MM-DD (formatted by the date format), or literal text when there is no date format. Replaces the current date.",
|
|
662
|
+
onCommit: (value) => edit({ date: value.trim() }, `${where()} date changed.`),
|
|
663
|
+
});
|
|
664
|
+
const dateFormat = textField("hf-date-format", "Date format", {
|
|
665
|
+
placeholder: "MMMM d, yyyy",
|
|
666
|
+
help: `Tokens: ${DATE_FORMAT_TOKENS.join(", ")}. Text in single quotes is literal. Clear it for the default (M/d/yyyy).`,
|
|
667
|
+
onCommit: (value) => edit({ dateFormat: value.trim() }, `${where()} date format changed.`),
|
|
668
|
+
});
|
|
669
|
+
const organization = checkField("hf-organization", "Show the organization name", { onChange: (checked) => edit({ organization: checked }, `${where()} organization ${checked ? "shown" : "removed"}.`) });
|
|
670
|
+
const socials = checkField("hf-socials", "Show the organization's social profiles", { onChange: (checked) => edit({ socials: checked }, `${where()} social profiles ${checked ? "shown" : "removed"}.`) });
|
|
671
|
+
const section = checkField("hf-section", "Show the section label", { onChange: (checked) => edit({ section: checked }, `${where()} section label ${checked ? "shown" : "removed"}.`) });
|
|
672
|
+
const summary = h("ul", { class: "opf-dc-summary", "aria-label": "Zones in use" });
|
|
673
|
+
const warnings = h("ul", { class: "opf-dc-warnings", "aria-label": "Header and footer warnings" });
|
|
674
|
+
const controls = [text, logo, image, number, numberFormat, dateNow, dateFixed, dateFormat, organization, socials, section];
|
|
675
|
+
body.append(furniture.wrap, hide.wrap, zoneSelect.wrap, summary, ...controls.map((control) => control.wrap), warnings);
|
|
676
|
+
const describe = (fields) =>
|
|
677
|
+
Object.entries(fields)
|
|
678
|
+
.map(([key, value]) => (typeof value === "boolean" ? key : `${key} ${typeof value === "string" ? `“${value}”` : ""}`.trim()))
|
|
679
|
+
.join(", ");
|
|
680
|
+
syncs.push(() => {
|
|
681
|
+
const state = headerFooterState(editor.document, which(), scoped());
|
|
682
|
+
// Offered on a slide that inherits the deck's furniture or already hides it, never over zones the slide set itself; at the deck only to undo a hidden state.
|
|
683
|
+
hide.wrap.hidden = scopeIndex() === undefined ? !state.hidden : state.own && !state.hidden;
|
|
684
|
+
hide.set(state.hidden, state.inherited && !state.hidden ? "from the presentation" : "");
|
|
685
|
+
const fields = readHeaderFooterZone(editor.document, which(), zone(), scoped());
|
|
686
|
+
text.set(typeof fields.text === "string" ? fields.text : "");
|
|
687
|
+
logo.set(fields.logo === true);
|
|
688
|
+
image.set(stringOf(fields.image), "", `${target()}:${which()}:${zone()}`);
|
|
689
|
+
number.set(fields.slideNumber === true);
|
|
690
|
+
numberFormat.set(typeof fields.slideNumberFormat === "string" ? fields.slideNumberFormat : "");
|
|
691
|
+
dateNow.set(fields.date === true);
|
|
692
|
+
dateFixed.set(typeof fields.date === "string" ? fields.date : "");
|
|
693
|
+
dateFormat.set(typeof fields.dateFormat === "string" ? fields.dateFormat : "");
|
|
694
|
+
organization.set(fields.organization === true);
|
|
695
|
+
socials.set(fields.socials === true);
|
|
696
|
+
section.set(fields.section === true);
|
|
697
|
+
for (const control of controls) for (const input of control.wrap.querySelectorAll("input,select,button")) input.disabled = state.hidden;
|
|
698
|
+
summary.replaceChildren(
|
|
699
|
+
...HEADER_FOOTER_ZONES.map((name) => {
|
|
700
|
+
const zoneFields = readHeaderFooterZone(editor.document, which(), name, scoped());
|
|
701
|
+
return h("li", {}, `${titleCase(name)}: ${Object.keys(zoneFields).length ? describe(zoneFields) : "empty"}`);
|
|
702
|
+
}),
|
|
703
|
+
);
|
|
704
|
+
warnings.replaceChildren(...designWarnings(editor.document, getSlide()).filter((warning) => /^design\.(header|footer)/.test(warning.path)).map((warning) => h("li", {}, warning.message)));
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// --- logo, watermark, bullets, accent font -------------------------------------------------------
|
|
709
|
+
|
|
710
|
+
if (built.brand) {
|
|
711
|
+
const { body } = built.brand;
|
|
712
|
+
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." });
|
|
713
|
+
variant.setOptions(LOGO_VARIANTS.map((value) => ({ value, label: LOGO_VARIANT_LABELS[value] })));
|
|
714
|
+
variant.set("default");
|
|
715
|
+
const logoSource = imageCluster("logo", "Logo", {
|
|
716
|
+
apply: (ref) => setLogoVariant(editor, variant.select.value, ref, scoped()),
|
|
717
|
+
build: (ref, doc) => prepareLogoVariant(doc, variant.select.value, ref, scoped()),
|
|
718
|
+
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.",
|
|
719
|
+
});
|
|
720
|
+
variant.select.addEventListener("change", () => sync());
|
|
721
|
+
const orgLogo = imageCluster("org-logo", "Organization logo (whole presentation)", {
|
|
722
|
+
apply: (ref) => setDesignOption(editor, "organizationLogo", ref),
|
|
723
|
+
build: (ref, doc) => prepareDesignOption(doc, "organizationLogo", ref),
|
|
724
|
+
help: "The primary organization's logo is the fallback wherever the logo is drawn.",
|
|
725
|
+
});
|
|
726
|
+
const bullet = selectField("list-bullet", "List bullets", {
|
|
727
|
+
empty: "Default (character)",
|
|
728
|
+
help: "Picture bullets draw the logo as the marker and need a logo.",
|
|
729
|
+
onChange: (value) => run(() => setDesignOption(editor, "listBullet", value === "" ? null : value, scoped()), value === "image" ? "Picture bullets on." : "Character bullets."),
|
|
730
|
+
});
|
|
731
|
+
bullet.setOptions([
|
|
732
|
+
{ value: "character", label: "Character" },
|
|
733
|
+
{ value: "image", label: "Picture (the logo)" },
|
|
734
|
+
]);
|
|
735
|
+
const accent = textField("accent-font", "Accent font", {
|
|
736
|
+
placeholder: "Font family name",
|
|
737
|
+
help: "The font for accent text such as tags. Press Enter to apply; clear the field to remove it.",
|
|
738
|
+
onCommit: (value) => run(() => setDesignOption(editor, "accentFont", value.trim() === "" ? null : value.trim(), scoped()), value.trim() === "" ? "Accent font removed." : "Accent font changed."),
|
|
739
|
+
});
|
|
740
|
+
const watermarkSource = imageCluster("watermark", "Watermark", {
|
|
741
|
+
apply: (ref) => setDesignOption(editor, "watermark", ref === null ? null : { src: ref }, scoped()),
|
|
742
|
+
build: (ref, doc) => prepareDesignOption(doc, "watermark", { src: ref }, scoped()),
|
|
743
|
+
});
|
|
744
|
+
const watermarkOpacity = numberField("watermark-opacity", "Watermark opacity (0 to 1)", {
|
|
745
|
+
min: 0,
|
|
746
|
+
max: 1,
|
|
747
|
+
step: 0.05,
|
|
748
|
+
onCommit: (value) => run(() => setDesignOption(editor, "watermark", { opacity: value === null ? 0.1 : value }, scoped()), "Watermark opacity changed."),
|
|
749
|
+
});
|
|
750
|
+
const watermarkOff = checkField("watermark-off", "Hide the inherited watermark", {
|
|
751
|
+
onChange: (checked) => run(() => setDesignOption(editor, "watermark", checked ? false : null, scoped()), checked ? "Watermark hidden." : "Watermark restored."),
|
|
752
|
+
});
|
|
753
|
+
body.append(variant.wrap, logoSource.wrap, orgLogo.wrap, bullet.wrap, accent.wrap, watermarkSource.wrap, watermarkOpacity.wrap, watermarkOff.wrap);
|
|
754
|
+
const warningList = h("ul", { class: "opf-dc-warnings", "aria-label": "Logo warnings" });
|
|
755
|
+
body.append(warningList);
|
|
756
|
+
syncs.push(() => {
|
|
757
|
+
const variants = readLogoVariants(editor.document, scoped());
|
|
758
|
+
logoSource.set(stringOf(variants[variant.select.value]), "", `${target()}:${variant.select.value}`);
|
|
759
|
+
const organization = editor.document.organization;
|
|
760
|
+
const owner = Array.isArray(organization) ? organization[0] : organization;
|
|
761
|
+
orgLogo.set(stringOf(owner?.logo), "", "org");
|
|
762
|
+
for (const input of orgLogo.wrap.querySelectorAll("input")) input.disabled = !owner;
|
|
763
|
+
const bulletOption = getDesignOption(editor.document, "listBullet", scoped());
|
|
764
|
+
bullet.set(bulletOption.value ?? "", sourceNote(bulletOption.scope, bulletOption.value !== undefined));
|
|
765
|
+
const accentOption = getDesignOption(editor.document, "accentFont", scoped());
|
|
766
|
+
accent.set(accentOption.value ?? "", sourceNote(accentOption.scope, accentOption.value !== undefined));
|
|
767
|
+
const watermark = getDesignOption(editor.document, "watermark", scoped());
|
|
768
|
+
const mark = watermark.value;
|
|
769
|
+
// The opacity belongs to a watermark image; without one there is nothing to fade.
|
|
770
|
+
for (const input of watermarkOpacity.wrap.querySelectorAll("input")) input.disabled = !(typeof mark === "string" || (mark && typeof mark === "object" && typeof mark.src === "string"));
|
|
771
|
+
watermarkSource.set(typeof mark === "string" ? mark : mark && typeof mark === "object" ? stringOf(mark) : "", sourceNote(watermark.scope, mark !== undefined), target());
|
|
772
|
+
watermarkOpacity.set(mark && typeof mark === "object" && typeof mark.opacity === "number" ? String(mark.opacity) : "");
|
|
773
|
+
const own = scopeIndex() === undefined ? editor.document.design?.watermark : editor.document.slides?.[scopeIndex()]?.design?.watermark;
|
|
774
|
+
watermarkOff.set(own === false);
|
|
775
|
+
watermarkOff.wrap.hidden = state.scope !== "slide";
|
|
776
|
+
warningList.replaceChildren(...designWarnings(editor.document, getSlide()).filter((warning) => warning.path === "design.listBullet").map((warning) => h("li", {}, warning.message)));
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// --- alignment and arrangement -------------------------------------------------------------------
|
|
781
|
+
|
|
782
|
+
if (built["layout-options"]) {
|
|
783
|
+
const { body } = built["layout-options"];
|
|
784
|
+
const enumField = (id, label, values, labels, help) => {
|
|
785
|
+
const field = selectField(`opt-${id}`, label, {
|
|
786
|
+
empty: "Default",
|
|
787
|
+
help,
|
|
788
|
+
onChange: (value) => run(() => setDesignOption(editor, id, value === "" ? null : value, scoped()), `${label} changed.`),
|
|
789
|
+
});
|
|
790
|
+
field.setOptions(values.map((value) => ({ value, label: labels[value] ?? titleCase(value) })));
|
|
791
|
+
body.append(field.wrap);
|
|
792
|
+
syncs.push(() => {
|
|
793
|
+
const option = getDesignOption(editor.document, id, scoped());
|
|
794
|
+
field.set(option.value ?? "", sourceNote(option.scope, option.value !== undefined));
|
|
795
|
+
});
|
|
796
|
+
};
|
|
797
|
+
enumField("titleAlignment", "Title alignment", ["left", "center", "right"], {});
|
|
798
|
+
enumField("contentAlignment", "Content alignment", ["left", "center", "right"], {});
|
|
799
|
+
enumField("contentDirection", "Content direction", ["horizontal", "vertical"], { horizontal: "Horizontal (row)", vertical: "Vertical (column)" }, "The axis parallel content is arranged along when a slide sets no arrangement of its own.");
|
|
800
|
+
enumField("chartPrimary", "Primary chart position", ["none", "top", "bottom", "left", "right"], { none: "None (equal)" }, "Where the main chart sits next to supporting content.");
|
|
801
|
+
const box = selectField("opt-contentBox", "Content box", {
|
|
802
|
+
empty: "Default",
|
|
803
|
+
onChange: (value) => run(() => setDesignOption(editor, "contentBox", value === "" ? null : value === "yes", scoped()), "Content box changed."),
|
|
804
|
+
});
|
|
805
|
+
box.setOptions([
|
|
806
|
+
{ value: "yes", label: "Show a card behind content" },
|
|
807
|
+
{ value: "no", label: "No card" },
|
|
808
|
+
]);
|
|
809
|
+
body.append(box.wrap);
|
|
810
|
+
syncs.push(() => {
|
|
811
|
+
const option = getDesignOption(editor.document, "contentBox", scoped());
|
|
812
|
+
box.set(option.value === undefined ? "" : option.value ? "yes" : "no", sourceNote(option.scope, option.value !== undefined));
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// --- audience and story --------------------------------------------------------------------------
|
|
817
|
+
|
|
818
|
+
if (built.info) {
|
|
819
|
+
const { body } = built.info;
|
|
820
|
+
const single = (name, label, dimension, help) => {
|
|
821
|
+
const field = selectField(name, label, { help, onChange: (value) => run(() => switchDimension(editor, dimension, value, catalogOptions()), `${label} set to ${field.select.selectedOptions[0]?.textContent ?? value}.`) });
|
|
822
|
+
body.append(field.wrap);
|
|
823
|
+
syncs.push(() => {
|
|
824
|
+
field.setOptions(listSwitchOptions(editor.document, dimension, catalogOptions()).map((entry) => ({ value: entry.id, label: entry.label })));
|
|
825
|
+
field.set(currentSwitchValue(editor.document, dimension).value);
|
|
826
|
+
});
|
|
827
|
+
};
|
|
828
|
+
single("narrative", "Narrative", "narratives", "The storyline the deck follows. Authoring metadata: the slides do not change.");
|
|
829
|
+
single("tone", "Tone", "tones");
|
|
830
|
+
const audience = selectField("audience", "Audience", {
|
|
831
|
+
multiple: true,
|
|
832
|
+
size: 6,
|
|
833
|
+
help: "Hold Ctrl or Shift to choose more than one.",
|
|
834
|
+
onChange: (values) => {
|
|
835
|
+
if (!values.length) return complain("Choose at least one audience.");
|
|
836
|
+
run(() => switchDimension(editor, "audiences", values, catalogOptions()), "Audience changed.");
|
|
837
|
+
},
|
|
838
|
+
});
|
|
839
|
+
body.append(audience.wrap);
|
|
840
|
+
syncs.push(() => {
|
|
841
|
+
audience.setOptions(listSwitchOptions(editor.document, "audiences", catalogOptions()).map((entry) => ({ value: entry.id, label: entry.label })));
|
|
842
|
+
const value = editor.document.audience;
|
|
843
|
+
audience.set(Array.isArray(value) ? value : value === undefined ? [] : [value]);
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
const owner = selectField("social-owner", "Socials belong to");
|
|
847
|
+
const platform = selectField("social-platform", "Platform");
|
|
848
|
+
const handle = textField("social-handle", "Handle or address", { placeholder: "@handle", help: "Press Enter to apply." });
|
|
849
|
+
const applySocial = () => {
|
|
850
|
+
const value = handle.input.value.trim();
|
|
851
|
+
if (!value) return complain("Enter a handle for the platform.");
|
|
852
|
+
run(() => switchDimension(editor, "socials", { platform: platform.select.value, handle: value }, { owner: owner.select.value, ...catalogOptions() }), "Social handle saved.");
|
|
853
|
+
};
|
|
854
|
+
handle.input.addEventListener("change", applySocial);
|
|
855
|
+
body.append(owner.wrap, platform.wrap, handle.wrap, button("Save handle", applySocial));
|
|
856
|
+
const showHandle = () => {
|
|
857
|
+
const host = editor.document[owner.select.value];
|
|
858
|
+
const target = Array.isArray(host) ? host[0] : host;
|
|
859
|
+
handle.set(typeof target?.socials?.[platform.select.value] === "string" ? target.socials[platform.select.value] : "");
|
|
860
|
+
};
|
|
861
|
+
owner.select.addEventListener("change", showHandle);
|
|
862
|
+
platform.select.addEventListener("change", showHandle);
|
|
863
|
+
syncs.push(() => {
|
|
864
|
+
const owners = ["speaker", "organization"].filter((name) => editor.document[name]);
|
|
865
|
+
owner.setOptions(owners.length ? owners.map((name) => ({ value: name, label: titleCase(name) })) : [{ value: "speaker", label: "Speaker (add one first)", disabled: true }]);
|
|
866
|
+
if (!owner.select.value || !owners.includes(owner.select.value)) owner.select.value = owners[0] ?? "speaker";
|
|
867
|
+
platform.setOptions(listSwitchOptions(editor.document, "socials", catalogOptions()).map((entry) => ({ value: entry.id, label: entry.label })));
|
|
868
|
+
showHandle();
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// --- selected content ----------------------------------------------------------------------------
|
|
873
|
+
|
|
874
|
+
const dynamic = {};
|
|
875
|
+
if (built.selection) {
|
|
876
|
+
const { body, details } = built.selection;
|
|
877
|
+
const summary = h("p", { class: "opf-dc-help", "data-role": "selection-summary" });
|
|
878
|
+
const convert = selectField("convert", "Content type", {
|
|
879
|
+
help: "Converting keeps your text and never adds content. What a type cannot carry is named below.",
|
|
880
|
+
onChange: (value) => {
|
|
881
|
+
if (!value) return;
|
|
882
|
+
const path = dynamic.blockPath;
|
|
883
|
+
run(() => switchDimension(editor, "blocks", value, { path, convert: true }), (change) => `Converted to ${BLOCK_KIND_LABELS[value]?.toLowerCase() ?? value}.${change.loss?.length ? ` Not carried over: ${change.loss.join(", ")}.` : " Nothing was lost."}`);
|
|
884
|
+
reselect(path);
|
|
885
|
+
},
|
|
886
|
+
});
|
|
887
|
+
const unavailable = h("ul", { class: "opf-dc-warnings", "aria-label": "Conversions not available" });
|
|
888
|
+
const replace = selectField("replace", "Replace with new content", {
|
|
889
|
+
empty: "Choose a type…",
|
|
890
|
+
help: "Starts from sample content. The old content is discarded; Undo brings it back.",
|
|
891
|
+
onChange: (value) => {
|
|
892
|
+
if (!value) return;
|
|
893
|
+
const path = dynamic.blockPath;
|
|
894
|
+
run(() => switchDimension(editor, "blocks", value, { path }), `Replaced with ${BLOCK_KIND_LABELS[value]?.toLowerCase() ?? value} content.`);
|
|
895
|
+
reselect(path);
|
|
896
|
+
},
|
|
897
|
+
});
|
|
898
|
+
replace.setOptions(BLOCK_REPLACEMENTS.map((value) => ({ value, label: BLOCK_KIND_LABELS[value] })));
|
|
899
|
+
const chartType = selectField("chart-type", "Chart type", {
|
|
900
|
+
help: "Only types the chart's data can use as it is.",
|
|
901
|
+
onChange: (value) => run(() => switchDimension(editor, "charts", value, { slideIndex: getSlide(), path: dynamic.blockPath, ...catalogOptions() }), `Chart type set to ${chartType.select.selectedOptions[0]?.textContent ?? value}.`),
|
|
902
|
+
});
|
|
903
|
+
body.append(summary, convert.wrap, unavailable, contentControls.nodes.metrics, contentControls.nodes.list, chartType.wrap, contentControls.nodes.arrange, replace.wrap);
|
|
904
|
+
dynamic.selection = { details, summary, convert, unavailable, replace, chartType };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
if (built["slide-content"]) built["slide-content"].body.append(contentControls.slideNode);
|
|
908
|
+
|
|
909
|
+
// --- table --------------------------------------------------------------------------------------
|
|
910
|
+
|
|
911
|
+
if (built.table) {
|
|
912
|
+
const { body, details } = built.table;
|
|
913
|
+
const summary = h("p", { class: "opf-dc-help", "data-role": "table-summary" });
|
|
914
|
+
const applyStyle = (change, done) => run(() => setTableStyle(editor, dynamic.tablePath, change), done);
|
|
915
|
+
const headerStyle = selectField("table-header", "Header row", {
|
|
916
|
+
help: "The header fill. Theme uses the primary color; Plain looks like the body.",
|
|
917
|
+
onChange: (value) => applyStyle({ ...dynamic.tableStyle, header: value }, "Table header changed."),
|
|
918
|
+
});
|
|
919
|
+
headerStyle.setOptions([
|
|
920
|
+
{ value: "theme", label: "Theme (primary color)" },
|
|
921
|
+
{ value: "plain", label: "Plain" },
|
|
922
|
+
{ value: "accent", label: "Accent color" },
|
|
923
|
+
]);
|
|
924
|
+
const banding = checkField("table-banding", "Banded rows", {
|
|
925
|
+
help: "Alternates the fill of body rows. Text color adjusts automatically.",
|
|
926
|
+
onChange: (checked) => applyStyle({ ...dynamic.tableStyle, banding: checked }, checked ? "Banded rows on." : "Banded rows off."),
|
|
927
|
+
});
|
|
928
|
+
const borders = selectField("table-borders", "Borders", {
|
|
929
|
+
onChange: (value) => applyStyle({ ...dynamic.tableStyle, borders: value }, "Table borders changed."),
|
|
930
|
+
});
|
|
931
|
+
borders.setOptions([
|
|
932
|
+
{ value: "theme", label: "Theme" },
|
|
933
|
+
{ value: "grid", label: "Grid" },
|
|
934
|
+
{ value: "horizontal", label: "Horizontal rules" },
|
|
935
|
+
{ value: "none", label: "None" },
|
|
936
|
+
]);
|
|
937
|
+
const resetStyle = button("Reset table style", () => applyStyle("theme", "Table style reset to the theme."), { quiet: true });
|
|
938
|
+
const cellLabel = h("p", { class: "opf-dc-help", "data-role": "cell-summary" });
|
|
939
|
+
const columns = numberField("span-columns", "Columns to merge", { min: 1, step: 1 });
|
|
940
|
+
const rows = numberField("span-rows", "Rows to merge", { min: 1, step: 1 });
|
|
941
|
+
const join = checkField("merge-join", "Keep text from the merged cells", { help: "Without this, merging cells that hold text is refused so nothing is hidden." });
|
|
942
|
+
const merge = button("Merge cells", () => {
|
|
943
|
+
const cell = dynamic.cell;
|
|
944
|
+
if (!cell) return;
|
|
945
|
+
const colSpan = Number(columns.input.value || 1);
|
|
946
|
+
const rowSpan = Number(rows.input.value || 1);
|
|
947
|
+
run(() => mergeTableCells(editor, dynamic.tablePath, cell, { colSpan, rowSpan }, { join: join.input.checked }), `Merged ${colSpan} by ${rowSpan} cells.`);
|
|
948
|
+
});
|
|
949
|
+
const split = button("Split cell", () => run(() => splitTableCell(editor, dynamic.tablePath, dynamic.cell), "Cell split."));
|
|
950
|
+
const fill = selectField("cell-fill", "Cell fill", { onChange: (value) => run(() => setTableCellStyle(editor, dynamic.tablePath, dynamic.cell, { fill: value === "" ? null : value }), "Cell fill changed.") });
|
|
951
|
+
fill.setOptions(CELL_FILLS.map(([value, label]) => ({ value, label })));
|
|
952
|
+
const align = selectField("cell-align", "Cell text alignment", { empty: "Default", onChange: (value) => run(() => setTableCellStyle(editor, dynamic.tablePath, dynamic.cell, { align: value === "" ? null : value }), "Cell alignment changed.") });
|
|
953
|
+
align.setOptions(["left", "center", "right"].map((value) => ({ value, label: titleCase(value) })));
|
|
954
|
+
const cellBox = h("div", { class: "opf-dc-cell" }, cellLabel, columns.wrap, rows.wrap, join.wrap, h("div", { class: "opf-dc-actions" }, merge, split), fill.wrap, align.wrap);
|
|
955
|
+
body.append(summary, headerStyle.wrap, banding.wrap, borders.wrap, resetStyle, cellBox);
|
|
956
|
+
dynamic.table = { details, summary, headerStyle, banding, borders, cellBox, cellLabel, columns, rows, merge, split, fill, align, join };
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// --- sync -----------------------------------------------------------------------------------------
|
|
960
|
+
|
|
961
|
+
function syncSelection() {
|
|
962
|
+
const selected = getSelected();
|
|
963
|
+
const document_ = editor.document;
|
|
964
|
+
const blockPath = selected ? blockPathForSelection(document_, selected) : undefined;
|
|
965
|
+
const content = blockPath ? readBlockContent(document_, blockPath) : undefined;
|
|
966
|
+
dynamic.blockPath = blockPath;
|
|
967
|
+
const ui = dynamic.selection;
|
|
968
|
+
if (ui) {
|
|
969
|
+
ui.details.hidden = !content;
|
|
970
|
+
if (content) {
|
|
971
|
+
const targets = blockConversionTargets(document_, blockPath);
|
|
972
|
+
ui.summary.textContent = `${BLOCK_KIND_LABELS[content.kind] ?? content.kind} block (${blockPath})`;
|
|
973
|
+
ui.convert.setOptions([
|
|
974
|
+
{ value: "", label: `${BLOCK_KIND_LABELS[content.kind] ?? content.kind} (current)` },
|
|
975
|
+
...targets.map((target) => ({
|
|
976
|
+
value: target.kind,
|
|
977
|
+
label: target.available ? `${target.label}${target.lossless ? "" : ` (loses ${target.loss.join(", ")})`}` : `${target.label} (unavailable)`,
|
|
978
|
+
disabled: !target.available,
|
|
979
|
+
title: target.reason,
|
|
980
|
+
})),
|
|
981
|
+
]);
|
|
982
|
+
ui.convert.set("");
|
|
983
|
+
ui.convert.wrap.hidden = targets.length === 0;
|
|
984
|
+
ui.unavailable.replaceChildren(...targets.filter((target) => !target.available).map((target) => h("li", {}, `${target.label}: ${target.reason}`)));
|
|
985
|
+
ui.replace.set("");
|
|
986
|
+
const charts = content.kind === "chart" ? compatibleChartTypes(document_, { slideIndex: getSlide(), path: blockPath, ...catalogOptions() }) : [];
|
|
987
|
+
ui.chartType.wrap.hidden = charts.length === 0;
|
|
988
|
+
if (charts.length) {
|
|
989
|
+
ui.chartType.setOptions(charts.map((entry) => ({ value: entry.id, label: entry.label })));
|
|
990
|
+
ui.chartType.set(content.content?.type);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
const tableUi = dynamic.table;
|
|
995
|
+
if (tableUi) {
|
|
996
|
+
const parsed = selected ? parseTableCellPath(selected) : undefined;
|
|
997
|
+
const tablePath = parsed?.tablePath ?? (content?.key === "table" ? `${blockPath}.table` : undefined);
|
|
998
|
+
dynamic.tablePath = tablePath;
|
|
999
|
+
dynamic.cell = parsed?.cell;
|
|
1000
|
+
tableUi.details.hidden = !tablePath;
|
|
1001
|
+
if (tablePath) {
|
|
1002
|
+
const table = editor.get(tablePath);
|
|
1003
|
+
tableUi.summary.textContent = `Table (${tablePath})`;
|
|
1004
|
+
const style = readTableStyle(document_, tablePath);
|
|
1005
|
+
dynamic.tableStyle = style.preset === "custom" ? { header: "theme", banding: false, borders: "theme" } : { header: style.header, banding: style.banding, borders: style.borders };
|
|
1006
|
+
tableUi.headerStyle.set(style.header);
|
|
1007
|
+
tableUi.banding.set(style.banding === true);
|
|
1008
|
+
tableUi.borders.set(style.borders);
|
|
1009
|
+
tableUi.cellBox.hidden = !parsed;
|
|
1010
|
+
if (parsed) {
|
|
1011
|
+
let state;
|
|
1012
|
+
try {
|
|
1013
|
+
state = describeTableCell(table, parsed.cell);
|
|
1014
|
+
} catch {
|
|
1015
|
+
state = undefined;
|
|
1016
|
+
}
|
|
1017
|
+
if (state) {
|
|
1018
|
+
tableUi.cellLabel.textContent = `${state.section === "header" ? "Header" : `Row ${state.row + 1}`}, column ${state.column + 1}${state.merged ? ` · merged ${state.colSpan} by ${state.rowSpan}` : ""}`;
|
|
1019
|
+
// A refused merge keeps the spans the person typed so they can tick "Keep text" and try again.
|
|
1020
|
+
if (!restoring) {
|
|
1021
|
+
tableUi.columns.set(String(state.colSpan));
|
|
1022
|
+
tableUi.rows.set(String(state.rowSpan));
|
|
1023
|
+
}
|
|
1024
|
+
tableUi.rows.input.disabled = state.section === "header";
|
|
1025
|
+
tableUi.split.disabled = !state.anchor;
|
|
1026
|
+
const fillValue = typeof state.style.fill === "string" ? state.style.fill : "";
|
|
1027
|
+
tableUi.fill.set(CELL_FILLS.some(([value]) => value === fillValue) ? fillValue : fillValue ? "(custom)" : "");
|
|
1028
|
+
tableUi.align.set(state.style.align ?? "");
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
if (contentControls) {
|
|
1034
|
+
contentControls.sync({ blockPath, content, selectedPath: selected, slideIndex: getSlide() });
|
|
1035
|
+
const slideContent = built["slide-content"];
|
|
1036
|
+
if (slideContent) slideContent.details.hidden = [...contentControls.slideNode.children].every((node) => node.hidden);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function sync() {
|
|
1041
|
+
if (destroyed) return;
|
|
1042
|
+
if (scopeField) {
|
|
1043
|
+
scopeField.set(state.scope);
|
|
1044
|
+
const slideOption = scopeField.select.querySelector('option[value="slide"]');
|
|
1045
|
+
if (slideOption) slideOption.textContent = `This slide only (slide ${getSlide() + 1})`;
|
|
1046
|
+
}
|
|
1047
|
+
for (const update of syncs) update();
|
|
1048
|
+
syncSelection();
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
root.append(...sections.map((name) => built[name].details), statusEl, errorEl);
|
|
1052
|
+
container.append(root);
|
|
1053
|
+
const unsubscribe = editor.subscribe(() => {
|
|
1054
|
+
backgroundDirty = false;
|
|
1055
|
+
sync();
|
|
1056
|
+
});
|
|
1057
|
+
sync();
|
|
1058
|
+
|
|
1059
|
+
return {
|
|
1060
|
+
element: root,
|
|
1061
|
+
/** Re-read the session, the current slide and the current selection. Call it when the host's slide or selection changes. */
|
|
1062
|
+
refresh: sync,
|
|
1063
|
+
/** The "Applies to" choice: "deck" or "slide". */
|
|
1064
|
+
get scope() {
|
|
1065
|
+
return state.scope;
|
|
1066
|
+
},
|
|
1067
|
+
setScope(scope) {
|
|
1068
|
+
state.scope = scope === "slide" ? "slide" : "deck";
|
|
1069
|
+
sync();
|
|
1070
|
+
},
|
|
1071
|
+
destroy() {
|
|
1072
|
+
destroyed = true;
|
|
1073
|
+
unsubscribe();
|
|
1074
|
+
root.remove();
|
|
1075
|
+
},
|
|
1076
|
+
};
|
|
1077
|
+
}
|