@openpresentation/opf-editor 0.29.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/dist/deck-title.d.ts +59 -0
- package/dist/deck-title.js +184 -0
- package/dist/design-menu.d.ts +90 -0
- package/dist/design-menu.js +462 -0
- package/dist/editor-expand.d.ts +102 -0
- package/dist/editor-expand.js +370 -0
- package/dist/element-define.d.ts +2 -0
- package/dist/element-define.js +7 -0
- package/dist/element.d.ts +91 -0
- package/dist/element.js +585 -0
- package/dist/icons.d.ts +34 -0
- package/dist/icons.js +88 -0
- package/dist/presenter.d.ts +3 -0
- package/dist/presenter.js +6 -2
- package/dist/react.d.ts +9 -0
- package/dist/react.js +18 -0
- package/dist/review-pill.d.ts +83 -0
- package/dist/review-pill.js +137 -0
- package/dist/shortcuts-sheet.d.ts +82 -0
- package/dist/shortcuts-sheet.js +230 -0
- package/dist/slide-navigator.d.ts +3 -0
- package/dist/slide-navigator.js +6 -17
- package/dist/studio.js +13 -16
- package/dist/styles.css +888 -30
- package/dist/styles.layered.css +7653 -0
- package/dist/svelte.d.ts +16 -0
- package/dist/svelte.js +32 -0
- package/dist/thumbnail-strip.d.ts +59 -0
- package/dist/thumbnail-strip.js +399 -6
- package/dist/toolbar-actions.d.ts +59 -0
- package/dist/toolbar-actions.js +136 -0
- package/dist/toolbar.d.ts +103 -0
- package/dist/toolbar.js +390 -0
- package/dist/workspace-parts.js +8 -0
- package/dist/workspace-shell.js +93 -33
- package/dist/workspace-theme.js +11 -0
- package/dist/workspace.d.ts +136 -4
- package/dist/workspace.js +710 -42
- package/package.json +57 -9
package/dist/toolbar.js
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
// CE-28: the editor's title bar. `createToolbar(container, { items })` builds one slot element per named item, in order, and keeps the row on one
|
|
2
|
+
// line: when the items do not fit the width of the bar (a ResizeObserver on the bar, so the container decides, not the window), the collapsible
|
|
3
|
+
// items are hidden lowest priority first and what they offer appears in the `more` (⋯) menu, built from each mounted part's `menuItems()`.
|
|
4
|
+
// It only arranges: the parts (the deck title, the history buttons, the counter, Export, Present, the expand button) are mounted in its slots by
|
|
5
|
+
// the workspace (src/workspace.js) or by a host, and registered with `mount(id, part)`. Plain DOM, styled by src/styles/toolbar.css (tokens only).
|
|
6
|
+
|
|
7
|
+
import { normalizeIcons, resolveIcon } from "./icons.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The items a toolbar knows, with their default priority (higher stays visible longer) and whether they can move into the `more` menu.
|
|
11
|
+
* `spacer` takes the free space; `|` is a divider, shown only between two visible items.
|
|
12
|
+
*/
|
|
13
|
+
export const TOOLBAR_ITEMS = Object.freeze({
|
|
14
|
+
title: Object.freeze({ priority: 95, collapsible: false }),
|
|
15
|
+
history: Object.freeze({ priority: 80, collapsible: true }),
|
|
16
|
+
counter: Object.freeze({ priority: 85, collapsible: true }),
|
|
17
|
+
views: Object.freeze({ priority: 40, collapsible: true }),
|
|
18
|
+
find: Object.freeze({ priority: 50, collapsible: true }),
|
|
19
|
+
zoom: Object.freeze({ priority: 45, collapsible: true }),
|
|
20
|
+
design: Object.freeze({ priority: 35, collapsible: true }),
|
|
21
|
+
review: Object.freeze({ priority: 30, collapsible: true }),
|
|
22
|
+
autosave: Object.freeze({ priority: 20, collapsible: true }),
|
|
23
|
+
export: Object.freeze({ priority: 65, collapsible: true }),
|
|
24
|
+
present: Object.freeze({ priority: 75, collapsible: true }),
|
|
25
|
+
expand: Object.freeze({ priority: 70, collapsible: true }),
|
|
26
|
+
actions: Object.freeze({ priority: 25, collapsible: true }),
|
|
27
|
+
help: Object.freeze({ priority: 10, collapsible: true }),
|
|
28
|
+
more: Object.freeze({ priority: 100, collapsible: false }),
|
|
29
|
+
spacer: Object.freeze({ priority: 0, collapsible: false }),
|
|
30
|
+
"|": Object.freeze({ priority: 0, collapsible: false }),
|
|
31
|
+
});
|
|
32
|
+
const REPEATABLE = new Set(["spacer", "|"]);
|
|
33
|
+
const LABELS = Object.freeze({ more: "More", menu: "More actions" });
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Turn the `items` option (names, or `{ id, priority, collapsible }`) into the list the toolbar builds, with each item's priority and whether
|
|
37
|
+
* it collapses. Throws on a name that is not an item and on a named item given twice (`spacer` and `|` may repeat).
|
|
38
|
+
*/
|
|
39
|
+
export function normalizeToolbarItems(items) {
|
|
40
|
+
if (!Array.isArray(items)) throw new TypeError("toolbar items must be an array of item names or { id, priority } objects.");
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
return items.map((item, index) => {
|
|
43
|
+
const spec = typeof item === "string" ? { id: item } : item;
|
|
44
|
+
if (!spec || typeof spec !== "object" || typeof spec.id !== "string") throw new TypeError(`toolbar item ${index} must be an item name or { id, priority }.`);
|
|
45
|
+
const known = TOOLBAR_ITEMS[spec.id];
|
|
46
|
+
if (!known) throw new RangeError(`"${spec.id}" is not a toolbar item (${Object.keys(TOOLBAR_ITEMS).join(", ")}).`);
|
|
47
|
+
if (!REPEATABLE.has(spec.id)) {
|
|
48
|
+
if (seen.has(spec.id)) throw new RangeError(`the toolbar item "${spec.id}" is given twice.`);
|
|
49
|
+
seen.add(spec.id);
|
|
50
|
+
}
|
|
51
|
+
if (spec.priority !== undefined && !Number.isFinite(spec.priority)) throw new TypeError(`the priority of the toolbar item "${spec.id}" must be a number.`);
|
|
52
|
+
return Object.freeze({
|
|
53
|
+
id: spec.id,
|
|
54
|
+
priority: spec.priority ?? known.priority,
|
|
55
|
+
collapsible: REPEATABLE.has(spec.id) || spec.id === "more" ? false : spec.collapsible ?? known.collapsible,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const make = (doc, tag, attributes = {}, ...children) => {
|
|
61
|
+
const node = doc.createElement(tag);
|
|
62
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
63
|
+
if (value === undefined || value === false) continue;
|
|
64
|
+
if (name === "text") node.textContent = value;
|
|
65
|
+
else node.setAttribute(name, value === true ? "" : value);
|
|
66
|
+
}
|
|
67
|
+
node.append(...children);
|
|
68
|
+
return node;
|
|
69
|
+
};
|
|
70
|
+
// The ⋯ glyph: three filled dots in a 24x24 box. A host replaces it with `icons: { more }` (src/icons.js).
|
|
71
|
+
const MORE_ICON = [4.5, 12, 19.5].map((cx) => ["circle", { cx, cy: 12, r: 2.25, fill: "currentColor", stroke: "none" }]);
|
|
72
|
+
|
|
73
|
+
let toolbarIds = 0;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build a toolbar in `container`. Returns its handle; see `toolbar.d.ts`.
|
|
77
|
+
*/
|
|
78
|
+
export function createToolbar(container, options = {}) {
|
|
79
|
+
if (!container?.ownerDocument) throw new TypeError("createToolbar needs a DOM container.");
|
|
80
|
+
const doc = container.ownerDocument;
|
|
81
|
+
const win = doc.defaultView;
|
|
82
|
+
const text = { ...LABELS, ...(options.labels ?? {}) };
|
|
83
|
+
const items = normalizeToolbarItems(options.items ?? []);
|
|
84
|
+
const icons = normalizeIcons(options.icons, ["more"], "createToolbar icons");
|
|
85
|
+
const id = `opf-tb-${++toolbarIds}`;
|
|
86
|
+
let density = options.density === "touch" ? "touch" : "comfortable";
|
|
87
|
+
let destroyed = false;
|
|
88
|
+
let menuOpen = false;
|
|
89
|
+
let collapsed = [];
|
|
90
|
+
const parts = new Map();
|
|
91
|
+
|
|
92
|
+
const bar = make(doc, "div", { class: "opf-tb opf-ui", "data-density": density });
|
|
93
|
+
const slots = {};
|
|
94
|
+
const entries = items.map((item, index) => {
|
|
95
|
+
let element;
|
|
96
|
+
if (item.id === "spacer") element = make(doc, "span", { class: "opf-tb-spacer opf-ws-spacer", "data-opf-toolbar-item": "spacer" });
|
|
97
|
+
else if (item.id === "|") element = make(doc, "span", { class: "opf-tb-divider", "data-opf-toolbar-item": "|", "aria-hidden": "true" });
|
|
98
|
+
else element = make(doc, "div", { class: "opf-tb-item", "data-opf-toolbar-item": item.id });
|
|
99
|
+
if (!REPEATABLE.has(item.id)) slots[item.id] = element;
|
|
100
|
+
bar.append(element);
|
|
101
|
+
return { ...item, index, element };
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ---- the more menu ------------------------------------------------------------------------------------------------------------------
|
|
105
|
+
const moreEntry = entries.find((entry) => entry.id === "more");
|
|
106
|
+
let trigger;
|
|
107
|
+
let menu;
|
|
108
|
+
if (moreEntry) {
|
|
109
|
+
trigger = make(doc, "button", { type: "button", class: "opf-tb-more-button", "aria-label": text.more, title: text.more, "aria-haspopup": "menu", "aria-expanded": "false", "aria-controls": `${id}-menu` }, resolveIcon(doc, icons.more, MORE_ICON, { name: "more", className: "opf-tb-more-icon", size: 16 }));
|
|
110
|
+
menu = make(doc, "div", { class: "opf-tb-menu", role: "menu", id: `${id}-menu`, "aria-label": text.menu, hidden: true });
|
|
111
|
+
moreEntry.element.classList.add("opf-tb-more");
|
|
112
|
+
moreEntry.element.append(trigger, menu);
|
|
113
|
+
moreEntry.element.hidden = true;
|
|
114
|
+
}
|
|
115
|
+
const menuButtons = () => (menu ? [...menu.querySelectorAll("[role^=menuitem]")] : []);
|
|
116
|
+
function partItems(entry) {
|
|
117
|
+
const part = parts.get(entry.id);
|
|
118
|
+
if (typeof part?.menuItems !== "function") return [];
|
|
119
|
+
try {
|
|
120
|
+
return (part.menuItems() ?? []).filter((item) => item && typeof item.label === "string" && typeof item.run === "function");
|
|
121
|
+
} catch (error) {
|
|
122
|
+
options.onError?.(error);
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** What the more menu lists now: the items of every collapsed part, in bar order, one group per part. */
|
|
127
|
+
function menuGroups() {
|
|
128
|
+
return entries.filter((entry) => collapsed.includes(entry.id)).map((entry) => ({ id: entry.id, items: partItems(entry) })).filter((group) => group.items.length);
|
|
129
|
+
}
|
|
130
|
+
function buildMenu() {
|
|
131
|
+
if (!menu) return;
|
|
132
|
+
const nodes = [];
|
|
133
|
+
for (const [at, group] of menuGroups().entries()) {
|
|
134
|
+
if (at > 0) nodes.push(make(doc, "div", { class: "opf-tb-menu-separator", role: "separator" }));
|
|
135
|
+
for (const item of group.items) {
|
|
136
|
+
const role = item.pressed === undefined ? "menuitem" : "menuitemcheckbox";
|
|
137
|
+
const button = make(doc, "button", {
|
|
138
|
+
type: "button",
|
|
139
|
+
class: "opf-tb-menu-item",
|
|
140
|
+
role,
|
|
141
|
+
tabindex: "-1",
|
|
142
|
+
"data-opf-toolbar-menu-item": item.id ?? "",
|
|
143
|
+
"data-opf-toolbar-part": group.id,
|
|
144
|
+
"aria-checked": role === "menuitemcheckbox" ? String(Boolean(item.pressed)) : undefined,
|
|
145
|
+
"aria-disabled": item.disabled ? "true" : undefined,
|
|
146
|
+
text: item.label,
|
|
147
|
+
});
|
|
148
|
+
button.addEventListener("click", () => activate(item));
|
|
149
|
+
nodes.push(button);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
menu.replaceChildren(...nodes);
|
|
153
|
+
}
|
|
154
|
+
function activate(item) {
|
|
155
|
+
if (item.disabled) return;
|
|
156
|
+
// The menu closes without taking the focus back first: what the item opens (Find's field, a popover, a dialog) keeps the focus it takes.
|
|
157
|
+
// The item runs on the next microtask, after the menu is gone; the focus returns to ⋯ only when nothing else took it.
|
|
158
|
+
closeMenu({ restoreFocus: false });
|
|
159
|
+
queueMicrotask(() => {
|
|
160
|
+
if (destroyed) return;
|
|
161
|
+
try {
|
|
162
|
+
item.run();
|
|
163
|
+
} catch (error) {
|
|
164
|
+
options.onError?.(error);
|
|
165
|
+
}
|
|
166
|
+
const active = doc.activeElement;
|
|
167
|
+
if (!active || active === doc.body || menu.contains(active)) trigger?.focus();
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
function focusAt(which) {
|
|
171
|
+
const list = menuButtons();
|
|
172
|
+
if (!list.length) return;
|
|
173
|
+
const at = list.indexOf(doc.activeElement);
|
|
174
|
+
const next = which === "first" ? 0 : which === "last" ? list.length - 1 : (at + which + list.length) % list.length;
|
|
175
|
+
list[next].focus();
|
|
176
|
+
}
|
|
177
|
+
function onOutside(event) {
|
|
178
|
+
if (!moreEntry.element.contains(event.target)) closeMenu({ restoreFocus: false });
|
|
179
|
+
}
|
|
180
|
+
function onEscape(event) {
|
|
181
|
+
if (event.key === "Escape" && menuOpen) {
|
|
182
|
+
event.preventDefault();
|
|
183
|
+
event.stopPropagation();
|
|
184
|
+
closeMenu();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function openMenu(which = "first") {
|
|
188
|
+
if (destroyed || !menu || menuOpen || moreEntry.element.hidden) return false;
|
|
189
|
+
buildMenu();
|
|
190
|
+
if (!menuButtons().length) return false;
|
|
191
|
+
menuOpen = true;
|
|
192
|
+
menu.hidden = false;
|
|
193
|
+
trigger.setAttribute("aria-expanded", "true");
|
|
194
|
+
doc.addEventListener("pointerdown", onOutside, true);
|
|
195
|
+
doc.addEventListener("keydown", onEscape, true);
|
|
196
|
+
focusAt(which);
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
function closeMenu({ restoreFocus = true } = {}) {
|
|
200
|
+
if (!menuOpen) return;
|
|
201
|
+
menuOpen = false;
|
|
202
|
+
menu.hidden = true;
|
|
203
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
204
|
+
doc.removeEventListener("pointerdown", onOutside, true);
|
|
205
|
+
doc.removeEventListener("keydown", onEscape, true);
|
|
206
|
+
if (restoreFocus) trigger.focus();
|
|
207
|
+
}
|
|
208
|
+
if (menu) {
|
|
209
|
+
trigger.addEventListener("click", () => (menuOpen ? closeMenu() : openMenu("first")));
|
|
210
|
+
trigger.addEventListener("keydown", (event) => {
|
|
211
|
+
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
|
|
212
|
+
event.preventDefault();
|
|
213
|
+
if (menuOpen) focusAt(event.key === "ArrowUp" ? "last" : "first");
|
|
214
|
+
else openMenu(event.key === "ArrowUp" ? "last" : "first");
|
|
215
|
+
});
|
|
216
|
+
menu.addEventListener("keydown", (event) => {
|
|
217
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
218
|
+
event.preventDefault();
|
|
219
|
+
focusAt(event.key === "ArrowDown" ? 1 : -1);
|
|
220
|
+
} else if (event.key === "Home" || event.key === "End") {
|
|
221
|
+
event.preventDefault();
|
|
222
|
+
focusAt(event.key === "Home" ? "first" : "last");
|
|
223
|
+
} else if (event.key === "Tab") {
|
|
224
|
+
// Tab leaves the menu from its trigger, so the next stop is the one after the trigger.
|
|
225
|
+
closeMenu();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---- fitting the row ----------------------------------------------------------------------------------------------------------------
|
|
231
|
+
// An item counts when it shows something: a slot with content or a registered part (an empty or hidden slot takes no room and no gap).
|
|
232
|
+
const present = (entry) => entry.id !== "spacer" && entry.id !== "|" && entry.id !== "more" && !entry.element.hidden && (entry.element.childNodes.length > 0 || parts.has(entry.id));
|
|
233
|
+
const shown = (entry) => present(entry) && !entry.element.hasAttribute("data-collapsed");
|
|
234
|
+
function syncDividers() {
|
|
235
|
+
for (const [at, entry] of entries.entries()) {
|
|
236
|
+
if (entry.id !== "|") continue;
|
|
237
|
+
const side = (step) => {
|
|
238
|
+
for (let i = at + step; i >= 0 && i < entries.length; i += step) {
|
|
239
|
+
const other = entries[i];
|
|
240
|
+
if (other.id === "|") return false;
|
|
241
|
+
if (other.id === "more" ? !other.element.hidden : shown(other)) return true;
|
|
242
|
+
}
|
|
243
|
+
return false;
|
|
244
|
+
};
|
|
245
|
+
entry.element.hidden = !(side(-1) && side(1));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function setCollapsed(entry, on) {
|
|
249
|
+
entry.element.toggleAttribute("data-collapsed", on);
|
|
250
|
+
}
|
|
251
|
+
function fits() {
|
|
252
|
+
const style = win.getComputedStyle(bar);
|
|
253
|
+
const available = bar.clientWidth - (Number.parseFloat(style.paddingLeft) || 0) - (Number.parseFloat(style.paddingRight) || 0);
|
|
254
|
+
const gap = Number.parseFloat(style.columnGap) || 0;
|
|
255
|
+
let needed = 0;
|
|
256
|
+
let count = 0;
|
|
257
|
+
for (const child of bar.children) {
|
|
258
|
+
const style = win.getComputedStyle(child);
|
|
259
|
+
// Hidden, and taken out of the row (a collapsed part that keeps a sheet on screen is laid out out of flow): no room and no gap.
|
|
260
|
+
if (child.hidden || style.display === "none" || style.position === "absolute" || style.position === "fixed") continue;
|
|
261
|
+
needed += child.getBoundingClientRect().width;
|
|
262
|
+
count++;
|
|
263
|
+
}
|
|
264
|
+
needed += gap * Math.max(0, count - 1);
|
|
265
|
+
return needed <= available + 0.5;
|
|
266
|
+
}
|
|
267
|
+
/** Fit the row now: show every item, then collapse the collapsible ones, lowest priority (and, at equal priority, the rightmost) first, until the row fits. */
|
|
268
|
+
function update() {
|
|
269
|
+
if (destroyed) return collapsed;
|
|
270
|
+
// A bar that is not laid out (detached, or display: none) keeps what it shows.
|
|
271
|
+
if (!bar.isConnected || bar.clientWidth === 0) {
|
|
272
|
+
syncDividers();
|
|
273
|
+
return collapsed;
|
|
274
|
+
}
|
|
275
|
+
const before = collapsed.join(",");
|
|
276
|
+
bar.setAttribute("data-measuring", "");
|
|
277
|
+
for (const entry of entries) if (entry.collapsible) setCollapsed(entry, false);
|
|
278
|
+
if (moreEntry) moreEntry.element.hidden = true;
|
|
279
|
+
syncDividers();
|
|
280
|
+
const next = [];
|
|
281
|
+
const candidates = entries.filter((entry) => entry.collapsible && present(entry)).sort((a, b) => a.priority - b.priority || b.index - a.index);
|
|
282
|
+
for (const entry of candidates) {
|
|
283
|
+
if (fits()) break;
|
|
284
|
+
setCollapsed(entry, true);
|
|
285
|
+
next.push(entry.id);
|
|
286
|
+
if (moreEntry) moreEntry.element.hidden = !next.some((name) => partItems(entries.find((other) => other.id === name)).length);
|
|
287
|
+
syncDividers();
|
|
288
|
+
}
|
|
289
|
+
bar.removeAttribute("data-measuring");
|
|
290
|
+
collapsed = entries.filter((entry) => next.includes(entry.id)).map((entry) => entry.id);
|
|
291
|
+
bar.toggleAttribute("data-overflowing", collapsed.length > 0);
|
|
292
|
+
if (collapsed.join(",") !== before) {
|
|
293
|
+
if (menuOpen) closeMenu({ restoreFocus: false });
|
|
294
|
+
options.onCollapse?.({ collapsed: [...collapsed] });
|
|
295
|
+
}
|
|
296
|
+
return collapsed;
|
|
297
|
+
}
|
|
298
|
+
let frame = 0;
|
|
299
|
+
function schedule() {
|
|
300
|
+
if (destroyed || frame) return;
|
|
301
|
+
const run = () => {
|
|
302
|
+
frame = 0;
|
|
303
|
+
update();
|
|
304
|
+
};
|
|
305
|
+
frame = typeof win?.requestAnimationFrame === "function" ? win.requestAnimationFrame(run) : (setTimeout(run, 16), -1);
|
|
306
|
+
}
|
|
307
|
+
let observer;
|
|
308
|
+
const observed = new Set();
|
|
309
|
+
const observe = (node) => {
|
|
310
|
+
if (observer && !observed.has(node)) {
|
|
311
|
+
observer.observe(node);
|
|
312
|
+
observed.add(node);
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
if (typeof win?.ResizeObserver === "function") {
|
|
316
|
+
// Laid out again on the next frame, never inside the observer's callback, so a collapse never feeds back into the same notification.
|
|
317
|
+
observer = new win.ResizeObserver(() => schedule());
|
|
318
|
+
observe(bar);
|
|
319
|
+
for (const entry of entries) if (entry.id !== "spacer" && entry.id !== "|") observe(entry.element);
|
|
320
|
+
}
|
|
321
|
+
// A part's content changing its own size (a longer status, a counter going to three digits) is caught by the observer on its slot; a slot
|
|
322
|
+
// that gets its first child (a part that mounted late) is caught here.
|
|
323
|
+
const mutations = typeof win?.MutationObserver === "function" ? new win.MutationObserver(() => schedule()) : undefined;
|
|
324
|
+
for (const entry of entries) if (entry.id !== "spacer" && entry.id !== "|" && entry.id !== "more") mutations?.observe(entry.element, { childList: true });
|
|
325
|
+
|
|
326
|
+
container.append(bar);
|
|
327
|
+
syncDividers();
|
|
328
|
+
update();
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
element: bar,
|
|
332
|
+
get items() {
|
|
333
|
+
return entries.map(({ id, priority, collapsible }) => ({ id, priority, collapsible }));
|
|
334
|
+
},
|
|
335
|
+
slots,
|
|
336
|
+
slot(name) {
|
|
337
|
+
return slots[name];
|
|
338
|
+
},
|
|
339
|
+
/** The items moved into the more menu, in bar order. */
|
|
340
|
+
get collapsed() {
|
|
341
|
+
return [...collapsed];
|
|
342
|
+
},
|
|
343
|
+
get menuOpen() {
|
|
344
|
+
return menuOpen;
|
|
345
|
+
},
|
|
346
|
+
/** Change an item's priority or whether it collapses (the workspace raises `actions` while one of them is primary). */
|
|
347
|
+
setItem(name, { priority, collapsible } = {}) {
|
|
348
|
+
const entry = entries.find((candidate) => candidate.id === name);
|
|
349
|
+
if (!entry || REPEATABLE.has(name) || name === "more") return false;
|
|
350
|
+
if (priority !== undefined) {
|
|
351
|
+
if (!Number.isFinite(priority)) throw new TypeError(`the priority of the toolbar item "${name}" must be a number.`);
|
|
352
|
+
entry.priority = priority;
|
|
353
|
+
}
|
|
354
|
+
if (collapsible !== undefined) entry.collapsible = Boolean(collapsible);
|
|
355
|
+
schedule();
|
|
356
|
+
return true;
|
|
357
|
+
},
|
|
358
|
+
mount(name, part) {
|
|
359
|
+
if (destroyed || !slots[name] || REPEATABLE.has(name) || name === "more") return () => {};
|
|
360
|
+
parts.set(name, part);
|
|
361
|
+
schedule();
|
|
362
|
+
return () => {
|
|
363
|
+
if (parts.get(name) === part) parts.delete(name);
|
|
364
|
+
schedule();
|
|
365
|
+
};
|
|
366
|
+
},
|
|
367
|
+
/** What the more menu would list now, grouped by part. */
|
|
368
|
+
menuItems() {
|
|
369
|
+
return menuGroups();
|
|
370
|
+
},
|
|
371
|
+
update,
|
|
372
|
+
openMenu,
|
|
373
|
+
closeMenu,
|
|
374
|
+
setDensity(next) {
|
|
375
|
+
density = next === "touch" ? "touch" : "comfortable";
|
|
376
|
+
bar.dataset.density = density;
|
|
377
|
+
schedule();
|
|
378
|
+
},
|
|
379
|
+
destroy() {
|
|
380
|
+
if (destroyed) return;
|
|
381
|
+
closeMenu({ restoreFocus: false });
|
|
382
|
+
destroyed = true;
|
|
383
|
+
if (frame > 0) win.cancelAnimationFrame?.(frame);
|
|
384
|
+
observer?.disconnect();
|
|
385
|
+
mutations?.disconnect();
|
|
386
|
+
parts.clear();
|
|
387
|
+
bar.remove();
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
}
|
package/dist/workspace-parts.js
CHANGED
|
@@ -18,3 +18,11 @@ export { mergeSlides, splitSlideByBlocks } from "./content-actions.js";
|
|
|
18
18
|
export { installCanvasVariableEditing } from "./canvas-variables.js";
|
|
19
19
|
export { createLayoutStudio } from "./workspace-layouts.js";
|
|
20
20
|
export { buildStudioShell, createStudio } from "./studio.js";
|
|
21
|
+
// CE-28: the toolbar's parts: the deck title, the expand button, the Design quick menu, the review pill, the help sheet and the host's actions.
|
|
22
|
+
export { createDeckTitle } from "./deck-title.js";
|
|
23
|
+
export { createExpandButton, createExpandController } from "./editor-expand.js";
|
|
24
|
+
export { createDesignMenu } from "./design-menu.js";
|
|
25
|
+
export { createReviewPill } from "./review-pill.js";
|
|
26
|
+
export { createHelpButton } from "./shortcuts-sheet.js";
|
|
27
|
+
export { createToolbarActions } from "./toolbar-actions.js";
|
|
28
|
+
export { listSwitchOptions } from "./switches.js";
|
package/dist/workspace-shell.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// CE-06: the default arrangement of the workspace (`layout: "full"`, `"canvas"` or `"source"`): a bar, the source pane, the slide, the
|
|
2
|
-
// row, and a side panel for Review and Design. It only builds DOM and hands the elements to the workspace as named slots; a host that
|
|
1
|
+
// CE-06: the default arrangement of the workspace (`layout: "full"`, `"canvas"`, `"embed"` or `"source"`): a bar, the source pane, the slide, the
|
|
2
|
+
// slide row, and a side panel for Review and Design. It only builds DOM and hands the elements to the workspace as named slots; a host that
|
|
3
3
|
// arranges the parts itself passes its own elements as `slots` and none of this runs. Styled by src/styles/workspace.css (tokens only).
|
|
4
|
+
// CE-28: the bar is a toolbar (src/toolbar.js): one slot per named item, in the order the preset or the `toolbar` option gives.
|
|
4
5
|
|
|
5
6
|
const make = (doc, tag, attributes = {}, ...children) => {
|
|
6
7
|
const node = doc.createElement(tag);
|
|
@@ -14,41 +15,101 @@ const make = (doc, tag, attributes = {}, ...children) => {
|
|
|
14
15
|
};
|
|
15
16
|
|
|
16
17
|
const VIEW_LABELS = { slide: "Slide", sorter: "Sorter", outline: "Outline", layouts: "Layouts" };
|
|
18
|
+
export const viewLabel = (view) => VIEW_LABELS[view] ?? view;
|
|
19
|
+
|
|
20
|
+
/** CE-28: the toolbar of each built layout. `"source"` has none (the pane has its own bar), nor has `slots`; the Studio builds its own topbar. */
|
|
21
|
+
export const TOOLBAR_PRESETS = Object.freeze({
|
|
22
|
+
full: Object.freeze(["title", "history", "find", "views", "spacer", "actions", "design", "review", "autosave", "export", "present", "help", "expand", "more"]),
|
|
23
|
+
canvas: Object.freeze(["title", "history", "spacer", "actions", "expand", "more"]),
|
|
24
|
+
embed: Object.freeze(["title", "history", "counter", "spacer", "actions", "design", "expand", "present", "more"]),
|
|
25
|
+
// CE-28 wave 2: a deck to look at, read-only.
|
|
26
|
+
viewer: Object.freeze(["title", "counter", "spacer", "actions", "present", "expand", "more"]),
|
|
27
|
+
source: Object.freeze([]),
|
|
28
|
+
slots: Object.freeze([]),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** The text of the shell's own controls, with the host's `labels`. */
|
|
32
|
+
export const shellText = (labels = {}) => ({ slideView: "Slide view", undo: "Undo", redo: "Redo", find: "Find", review: "Review", design: "Design", inspector: "Inspector", ...labels });
|
|
17
33
|
|
|
18
34
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* (one element per mounted part) and the bar's controls.
|
|
35
|
+
* The controls of a toolbar item the workspace builds itself (`history`, `find`, `views`), inside `container`: the bar's slot, or a host's own
|
|
36
|
+
* element (`slots.history`, `slots.views`). Returns the controls (`undo` and `redo`, `find`, `views`).
|
|
22
37
|
*/
|
|
23
|
-
export function
|
|
24
|
-
const doc =
|
|
25
|
-
const text =
|
|
26
|
-
const slots = {};
|
|
38
|
+
export function buildItemControls(name, container, { views = [], labels = {} } = {}) {
|
|
39
|
+
const doc = container.ownerDocument;
|
|
40
|
+
const text = shellText(labels);
|
|
27
41
|
const controls = {};
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
// The bar.
|
|
31
|
-
const bar = make(doc, "header", { class: "opf-ws-bar" });
|
|
32
|
-
if (parts.history) {
|
|
42
|
+
if (name === "history") {
|
|
33
43
|
controls.undo = make(doc, "button", { type: "button", class: "opf-ws-button", "data-opf-action": "undo", "aria-label": text.undo, title: `${text.undo} · Ctrl/Cmd Z`, text: text.undo });
|
|
34
44
|
controls.redo = make(doc, "button", { type: "button", class: "opf-ws-button", "data-opf-action": "redo", "aria-label": text.redo, title: `${text.redo} · Ctrl/Cmd Shift Z`, text: text.redo });
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (
|
|
45
|
+
container.classList.add("opf-ws-group");
|
|
46
|
+
container.append(controls.undo, controls.redo);
|
|
47
|
+
} else if (name === "find") {
|
|
38
48
|
controls.find = make(doc, "button", { type: "button", class: "opf-ws-button", "data-opf-action": "find", "aria-expanded": "false", "aria-keyshortcuts": "Control+F Meta+F Control+H", title: `${text.find} · Ctrl/Cmd F`, text: text.find });
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
if (views.length > 1) {
|
|
42
|
-
const group = make(doc, "div", { class: "opf-ws-views", role: "group", "aria-label": text.slideView });
|
|
49
|
+
container.append(controls.find);
|
|
50
|
+
} else if (name === "views") {
|
|
43
51
|
controls.views = {};
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
const group = make(doc, "div", { class: "opf-ws-views", role: "group", "aria-label": text.slideView });
|
|
53
|
+
for (const view of views) group.append(controls.views[view] = make(doc, "button", { type: "button", class: "opf-ws-button", "data-opf-view-button": view, "aria-pressed": "false", text: viewLabel(view) }));
|
|
54
|
+
container.append(group);
|
|
46
55
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
return controls;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The toolbar items whose slot is a part's mount element: the slot's name (`data-opf-slot`; `design` and `review` already name the side panels,
|
|
60
|
+
// so their items' slots are `designMenu` and `reviewPill`) and the class the default layout gives it.
|
|
61
|
+
export const ITEM_SLOTS = Object.freeze({
|
|
62
|
+
autosave: ["autosave", "opf-ws-autosave"],
|
|
63
|
+
export: ["export", "opf-ws-export"],
|
|
64
|
+
present: ["present", "opf-ws-present"],
|
|
65
|
+
title: ["title", "opf-ws-title"],
|
|
66
|
+
counter: ["counter", "opf-ws-counter"],
|
|
67
|
+
expand: ["expand", "opf-ws-expand"],
|
|
68
|
+
design: ["designMenu", "opf-ws-design-menu"],
|
|
69
|
+
review: ["reviewPill", "opf-ws-review-pill"],
|
|
70
|
+
help: ["help", "opf-ws-help"],
|
|
71
|
+
actions: ["actions", "opf-ws-actions"],
|
|
72
|
+
});
|
|
73
|
+
const STRUCTURE = new Set(["spacer", "|", "more"]);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build the toolbar in `host` (the shell's bar, or the host's `slots.toolbar`) from `items` (already filtered to what this editor fills).
|
|
77
|
+
* Returns the toolbar, the slots of the parts that mount in it and the controls the shell built in it; nothing when no item shows anything.
|
|
78
|
+
*/
|
|
79
|
+
export function buildToolbar(host, { items, views, labels = {}, density, createToolbar, onError, onCollapse, icons }) {
|
|
80
|
+
if (!items.some((item) => !STRUCTURE.has(item.id))) return { toolbar: undefined, slots: {}, controls: {} };
|
|
81
|
+
const toolbar = createToolbar(host, { items, density, onError, onCollapse, ...(icons ? { icons } : {}), labels: { ...(labels.more ? { more: labels.more } : {}), ...(labels.moreMenu ? { menu: labels.moreMenu } : {}) } });
|
|
82
|
+
const slots = {};
|
|
83
|
+
const controls = {};
|
|
84
|
+
for (const [name, element] of Object.entries(toolbar.slots)) {
|
|
85
|
+
if (ITEM_SLOTS[name]) {
|
|
86
|
+
const [slotName, className] = ITEM_SLOTS[name];
|
|
87
|
+
element.setAttribute("data-opf-slot", slotName);
|
|
88
|
+
element.classList.add(className);
|
|
89
|
+
slots[slotName] = element;
|
|
90
|
+
} else if (!STRUCTURE.has(name)) Object.assign(controls, buildItemControls(name, element, { views, labels }));
|
|
91
|
+
}
|
|
92
|
+
return { toolbar, slots, controls };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build the shell inside `root`. `parts` names what is mounted (`source`, `canvas`, `navigator`, `thumbnails`, `present`, `export`, `find`,
|
|
97
|
+
* `review`, `design`, `layouts`, `sorter`, `outline`, `persistence`, `history`); `views` lists the views in switch order; `toolbar` the toolbar
|
|
98
|
+
* items (`createToolbar` builds them; `toolbarHost`, the host's own element for the bar, replaces the shell's `header`). Returns the slots (one
|
|
99
|
+
* element per mounted part), the controls and the toolbar.
|
|
100
|
+
*/
|
|
101
|
+
export function buildWorkspaceShell(root, { parts, views, layout, labels = {}, toolbar: items = [], createToolbar, density, toolbarHost, onError, onCollapse, icons }) {
|
|
102
|
+
const doc = root.ownerDocument;
|
|
103
|
+
const text = shellText(labels);
|
|
104
|
+
const slots = {};
|
|
105
|
+
const controls = {};
|
|
106
|
+
const slot = (name, tag = "div", attributes = {}) => (slots[name] = make(doc, tag, { "data-opf-slot": name, ...attributes }));
|
|
107
|
+
|
|
108
|
+
// The bar: the toolbar, in the shell's header unless the host gave its own element for it.
|
|
109
|
+
const bar = toolbarHost ?? make(doc, "header", { class: "opf-ws-bar" });
|
|
110
|
+
const built = buildToolbar(bar, { items, views, labels, density, createToolbar, onError, onCollapse, icons });
|
|
111
|
+
Object.assign(slots, built.slots);
|
|
112
|
+
Object.assign(controls, built.controls);
|
|
52
113
|
|
|
53
114
|
// The main column: the slide (or another view) over the slide row, and the status line. A source-only layout has none: the pane has its own
|
|
54
115
|
// bar, problems and status.
|
|
@@ -81,7 +142,7 @@ export function buildWorkspaceShell(root, { parts, views, layout, labels = {} })
|
|
|
81
142
|
if (main) body.append(main);
|
|
82
143
|
|
|
83
144
|
// The side panel: Review and Design as tabs.
|
|
84
|
-
const tabs = ["design", "review"].filter((name) => parts[name]);
|
|
145
|
+
const tabs = ["design", "review"].filter((name) => parts[name] && !(name === "review" && parts.reviewHeadless));
|
|
85
146
|
if (tabs.length) {
|
|
86
147
|
controls.tabs = {};
|
|
87
148
|
const list = make(doc, "div", { class: "opf-ws-tabs", role: "tablist", "aria-label": text.inspector });
|
|
@@ -94,13 +155,12 @@ export function buildWorkspaceShell(root, { parts, views, layout, labels = {} })
|
|
|
94
155
|
body.append(side);
|
|
95
156
|
}
|
|
96
157
|
|
|
97
|
-
if (
|
|
98
|
-
if (hasBar) root.append(bar);
|
|
158
|
+
if (built.toolbar && !toolbarHost) root.append(bar);
|
|
99
159
|
if (parts.persistence) root.append(slot("banner", "div", { class: "opf-ws-banner", hidden: true }));
|
|
100
160
|
root.append(body);
|
|
101
161
|
root.setAttribute("data-opf-layout", layout);
|
|
102
162
|
controls.frame = frame;
|
|
103
163
|
controls.stage = stage;
|
|
104
|
-
controls.bar = bar;
|
|
105
|
-
return { slots, controls };
|
|
164
|
+
controls.bar = built.toolbar ? bar : undefined;
|
|
165
|
+
return { slots, controls, toolbar: built.toolbar };
|
|
106
166
|
}
|
package/dist/workspace-theme.js
CHANGED
|
@@ -35,6 +35,17 @@ export const THEME_TOKENS = Object.freeze({
|
|
|
35
35
|
controlHeight: "--opf-ui-control-h",
|
|
36
36
|
shadow: "--opf-ui-shadow-pop",
|
|
37
37
|
motion: "--opf-ui-ease",
|
|
38
|
+
barBackground: "--opf-ui-bar-bg",
|
|
39
|
+
barText: "--opf-ui-bar-fg",
|
|
40
|
+
barBorder: "--opf-ui-bar-border",
|
|
41
|
+
barGap: "--opf-ui-bar-gap",
|
|
42
|
+
barPadding: "--opf-ui-bar-padding",
|
|
43
|
+
barHeight: "--opf-ui-bar-height",
|
|
44
|
+
titleFont: "--opf-ui-title-font",
|
|
45
|
+
titleSize: "--opf-ui-title-size",
|
|
46
|
+
titleWeight: "--opf-ui-title-weight",
|
|
47
|
+
titleText: "--opf-ui-title-fg",
|
|
48
|
+
overlayZ: "--opf-ui-z-overlay",
|
|
38
49
|
});
|
|
39
50
|
|
|
40
51
|
const FULL_NAMES = new Set(Object.values(THEME_TOKENS));
|