@openpresentation/opf-editor 0.10.5 → 0.11.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 +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,223 @@
|
|
|
1
|
+
// List numbering (RR-33): the headless model behind the numbering control. A list payload (`items` or `bullets`) can
|
|
2
|
+
// carry `numbering`: a style name, a { style, start, suffix } object, or an array with one entry per list level. Every
|
|
3
|
+
// write goes through the session as a validated JSON Patch edit, so each change is one undoable edit. Importing this
|
|
4
|
+
// module needs no DOM. Core is read from the namespace so an older core still loads this module; the writes then
|
|
5
|
+
// throw "numbering-unavailable".
|
|
6
|
+
import * as core from "@openpresentation/opf";
|
|
7
|
+
import { OPFEditorError, getValueAtPath, opfPathToJsonPointer, splitOpfPath } from "./index.js";
|
|
8
|
+
|
|
9
|
+
/** The number styles and how each draws, in the order a picker lists them. */
|
|
10
|
+
export const NUMBERING_STYLE_OPTIONS = Object.freeze([
|
|
11
|
+
{ value: "arabic", label: "1, 2, 3", sample: "1" },
|
|
12
|
+
{ value: "roman-upper", label: "I, II, III", sample: "I" },
|
|
13
|
+
{ value: "roman-lower", label: "i, ii, iii", sample: "i" },
|
|
14
|
+
{ value: "alpha-upper", label: "A, B, C", sample: "A" },
|
|
15
|
+
{ value: "alpha-lower", label: "a, b, c", sample: "a" },
|
|
16
|
+
]);
|
|
17
|
+
/** The suffixes: the text drawn after the number. */
|
|
18
|
+
export const NUMBERING_SUFFIX_OPTIONS = Object.freeze([
|
|
19
|
+
{ value: "period", label: "1.", sample: "." },
|
|
20
|
+
{ value: "paren", label: "1)", sample: ")" },
|
|
21
|
+
{ value: "paren-both", label: "(1)", sample: "()" },
|
|
22
|
+
]);
|
|
23
|
+
/** Largest list depth a native paragraph can carry, and so the most level entries `numbering` holds. */
|
|
24
|
+
export const MAX_NUMBERING_LEVELS = 9;
|
|
25
|
+
/** Largest start (and counted number) a native auto-number can hold. */
|
|
26
|
+
export const MAX_NUMBERING_START = 32767;
|
|
27
|
+
|
|
28
|
+
const DEFAULT_LEVEL = Object.freeze({ style: "arabic", start: 1, suffix: "period" });
|
|
29
|
+
const STYLES = NUMBERING_STYLE_OPTIONS.map((option) => option.value);
|
|
30
|
+
const SUFFIXES = NUMBERING_SUFFIX_OPTIONS.map((option) => option.value);
|
|
31
|
+
const FIELDS = ["items", "bullets"];
|
|
32
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
33
|
+
|
|
34
|
+
function fail(code, message, details) {
|
|
35
|
+
return new OPFEditorError(code, message, details);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function checkEditor(editor) {
|
|
39
|
+
if (!editor || typeof editor.applyPatch !== "function" || typeof editor.subscribe !== "function") {
|
|
40
|
+
throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** True when the core this editor runs on composes numbered lists (the core that ships `numbering`). */
|
|
45
|
+
export function numberingAvailable() {
|
|
46
|
+
return typeof core.listNumbers === "function" && typeof core.formatListNumber === "function";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function requireCore() {
|
|
50
|
+
if (!numberingAvailable()) throw fail("numbering-unavailable", "List numbering needs a core release that ships numbering (@openpresentation/opf after 0.11.4).");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Where a list lives for a path that points at it or into it: `slides.0.items`, `slides.0.items.2` or
|
|
55
|
+
* `slides.0.left.items.1.text` all resolve to the payload that owns the list. Returns undefined when the path is not
|
|
56
|
+
* inside a list.
|
|
57
|
+
*/
|
|
58
|
+
export function listPayloadAt(document, path) {
|
|
59
|
+
let segments;
|
|
60
|
+
try {
|
|
61
|
+
segments = splitOpfPath(path);
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
for (let index = 0; index < segments.length; index++) {
|
|
66
|
+
if (!FIELDS.includes(segments[index])) continue;
|
|
67
|
+
const next = segments[index + 1];
|
|
68
|
+
if (next !== undefined && !/^\d+$/.test(next)) continue;
|
|
69
|
+
const payloadPath = segments.slice(0, index);
|
|
70
|
+
const payload = payloadPath.length ? getValueAtPath(document, payloadPath) : document;
|
|
71
|
+
if (!isObject(payload) || !Array.isArray(payload[segments[index]])) continue;
|
|
72
|
+
const entry = next === undefined ? undefined : Number(next);
|
|
73
|
+
return { payloadPath: payloadPath.join("."), field: segments[index], path: [...payloadPath, segments[index]].join("."), payload, entry };
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const isRegionKey = (key) => /^(?:(?:top|middle|bottom)(?:\+(?:top|middle|bottom))*(?::(?:left|center|right)(?:\+(?:left|center|right))*)?|(?:left|center|right)(?:\+(?:left|center|right))*)$/.test(key);
|
|
79
|
+
|
|
80
|
+
/** Every list (`items` or `bullets`) of a slide: its path, label, entry count and whether it is numbered. */
|
|
81
|
+
export function findNumberableLists(document, slideIndex) {
|
|
82
|
+
const slide = document?.slides?.[slideIndex];
|
|
83
|
+
if (!isObject(slide)) return [];
|
|
84
|
+
const found = [];
|
|
85
|
+
const visit = (payload, base, label, depth = 0) => {
|
|
86
|
+
if (!isObject(payload) || depth > 32) return;
|
|
87
|
+
for (const field of FIELDS) {
|
|
88
|
+
if (Array.isArray(payload[field])) found.push({ path: `${base}.${field}`, payloadPath: base, field, label: found.some((entry) => entry.label === label) ? `${label} (${field})` : label, count: payload[field].length, numbered: payload.numbering !== undefined });
|
|
89
|
+
}
|
|
90
|
+
if (Array.isArray(payload.blocks)) payload.blocks.forEach((block, index) => visit(block, `${base}.blocks.${index}`, `${label} › Block ${index + 1}`, depth + 1));
|
|
91
|
+
};
|
|
92
|
+
const base = `slides.${slideIndex}`;
|
|
93
|
+
visit(slide, base, `Slide ${slideIndex + 1}`);
|
|
94
|
+
for (const key of Object.keys(slide)) if (isRegionKey(key)) visit(slide[key], `${base}.${key}`, `Slide ${slideIndex + 1} › ${key}`);
|
|
95
|
+
return found;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resolveLevels(value) {
|
|
99
|
+
const list = Array.isArray(value) ? value : [value];
|
|
100
|
+
return list.map((entry) => (typeof entry === "string"
|
|
101
|
+
? { ...DEFAULT_LEVEL, style: entry }
|
|
102
|
+
: { style: entry?.style ?? DEFAULT_LEVEL.style, start: entry?.start ?? DEFAULT_LEVEL.start, suffix: entry?.suffix ?? DEFAULT_LEVEL.suffix }));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const sameLevel = (a, b) => a.style === b.style && a.start === b.start && a.suffix === b.suffix;
|
|
106
|
+
|
|
107
|
+
function levelEntry({ style, start, suffix }) {
|
|
108
|
+
const fields = { ...(style !== "arabic" ? { style } : {}), ...(start !== 1 ? { start } : {}), ...(suffix !== "period" ? { suffix } : {}) };
|
|
109
|
+
const names = Object.keys(fields);
|
|
110
|
+
if (!names.length) return "arabic";
|
|
111
|
+
return names.length === 1 && names[0] === "style" ? style : fields;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The shortest `numbering` value for a list of per-level settings: one entry when every level matches (a style name when
|
|
116
|
+
* only the style is not the default, else an object holding just the non-default fields), otherwise an array with the
|
|
117
|
+
* redundant trailing entries dropped (the last entry repeats for deeper levels).
|
|
118
|
+
*/
|
|
119
|
+
export function numberingValue(levels) {
|
|
120
|
+
const list = levels.map((level) => ({ ...DEFAULT_LEVEL, ...level }));
|
|
121
|
+
if (!list.length) throw fail("invalid-numbering", "Numbering needs at least one level.");
|
|
122
|
+
if (list.length > MAX_NUMBERING_LEVELS) throw fail("invalid-numbering", `Numbering holds at most ${MAX_NUMBERING_LEVELS} levels.`);
|
|
123
|
+
for (const [index, level] of list.entries()) {
|
|
124
|
+
if (!STYLES.includes(level.style)) throw fail("invalid-numbering", `Level ${index + 1}: unknown style '${level.style}'.`, { level: index });
|
|
125
|
+
if (!SUFFIXES.includes(level.suffix)) throw fail("invalid-numbering", `Level ${index + 1}: unknown suffix '${level.suffix}'.`, { level: index });
|
|
126
|
+
if (!Number.isInteger(level.start) || level.start < 1 || level.start > MAX_NUMBERING_START) throw fail("invalid-numbering", `Level ${index + 1}: start must be a whole number from 1 to ${MAX_NUMBERING_START}.`, { level: index });
|
|
127
|
+
}
|
|
128
|
+
while (list.length > 1 && sameLevel(list.at(-1), list.at(-2))) list.pop();
|
|
129
|
+
const entries = list.map(levelEntry);
|
|
130
|
+
return entries.length === 1 ? entries[0] : entries;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const maxLevelOf = (items) => items.reduce((max, item) => Math.max(max, isObject(item) && Number.isInteger(item.level) ? item.level : 0), 0);
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The numbering state of the list a path points at: whether it is numbered, its settings per level (one entry when the
|
|
137
|
+
* numbering applies to every level), the number of levels the list uses, the markers it draws and which entry (if any)
|
|
138
|
+
* the path selects with that entry's own `start`.
|
|
139
|
+
*/
|
|
140
|
+
export function numberingState(document, path) {
|
|
141
|
+
const target = listPayloadAt(document, path);
|
|
142
|
+
if (!target) return undefined;
|
|
143
|
+
const { payload, field, entry } = target;
|
|
144
|
+
const items = payload[field];
|
|
145
|
+
const numbering = payload.numbering;
|
|
146
|
+
const numbered = numbering !== undefined;
|
|
147
|
+
const levels = numbered ? resolveLevels(numbering) : [{ ...DEFAULT_LEVEL }];
|
|
148
|
+
const depth = Math.min(MAX_NUMBERING_LEVELS, maxLevelOf(items) + 1);
|
|
149
|
+
let markers = [];
|
|
150
|
+
if (numbered && numberingAvailable()) {
|
|
151
|
+
try {
|
|
152
|
+
markers = core.listNumbers(items, numbering).map((number) => ({ index: number.index, level: number.level, text: number.text, value: number.value, adapted: number.adapted }));
|
|
153
|
+
} catch {
|
|
154
|
+
markers = [];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const selected = entry === undefined ? undefined : items[entry];
|
|
158
|
+
return {
|
|
159
|
+
path: target.path,
|
|
160
|
+
payloadPath: target.payloadPath,
|
|
161
|
+
field,
|
|
162
|
+
count: items.length,
|
|
163
|
+
numbered,
|
|
164
|
+
perLevel: levels.length > 1,
|
|
165
|
+
levels,
|
|
166
|
+
depth,
|
|
167
|
+
value: numbering,
|
|
168
|
+
markers,
|
|
169
|
+
entry: entry === undefined || selected === undefined ? undefined : { index: entry, start: isObject(selected) ? selected.start : undefined, marker: markers[entry]?.text },
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function numberingPointer(target) {
|
|
174
|
+
return opfPathToJsonPointer(target.payloadPath ? `${target.payloadPath}.numbering` : "numbering");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Number a list, change how it is numbered, or turn numbering off, as one undoable validated edit. `value` is a
|
|
179
|
+
* `numbering` value (see {@link numberingValue}), or `undefined` / `null` to remove it; turning numbering off also removes
|
|
180
|
+
* the entry `start` values, which mean nothing without it. `path` addresses the list or anything inside it.
|
|
181
|
+
*/
|
|
182
|
+
export function setNumbering(editor, path, value, meta = {}) {
|
|
183
|
+
requireCore();
|
|
184
|
+
checkEditor(editor);
|
|
185
|
+
const target = listPayloadAt(editor.document, path);
|
|
186
|
+
if (!target) throw fail("not-a-list", "Select a list (items or bullets) to number.", { path });
|
|
187
|
+
const patches = [];
|
|
188
|
+
const present = Object.hasOwn(target.payload, "numbering");
|
|
189
|
+
if (value === undefined || value === null) {
|
|
190
|
+
if (!present) return { document: editor.document, patches: [], inversePatches: [], validation: editor.validation };
|
|
191
|
+
patches.push({ op: "remove", path: numberingPointer(target) });
|
|
192
|
+
target.payload[target.field].forEach((item, index) => {
|
|
193
|
+
if (isObject(item) && item.start !== undefined) patches.push({ op: "remove", path: opfPathToJsonPointer(`${target.path}.${index}.start`) });
|
|
194
|
+
});
|
|
195
|
+
} else {
|
|
196
|
+
patches.push({ op: present ? "replace" : "add", path: numberingPointer(target), value: structuredClone(value) });
|
|
197
|
+
}
|
|
198
|
+
return editor.applyPatch(patches, { source: "list-numbering", rejectInvalid: true, ...meta });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Restart the numbering at an entry: the entry shows `start`, and the entries after it continue from it. `undefined` / `null`
|
|
203
|
+
* removes the restart. A plain entry (a string or runs) becomes the object form to carry it. One undoable validated edit.
|
|
204
|
+
*/
|
|
205
|
+
export function setEntryStart(editor, itemPath, start, meta = {}) {
|
|
206
|
+
requireCore();
|
|
207
|
+
checkEditor(editor);
|
|
208
|
+
const target = listPayloadAt(editor.document, itemPath);
|
|
209
|
+
if (!target || target.entry === undefined) throw fail("not-an-entry", "Select a list entry to restart its numbering.", { path: itemPath });
|
|
210
|
+
if (target.payload.numbering === undefined) throw fail("not-numbered", "Number the list first; an entry start has no effect on a bulleted list.", { path: itemPath });
|
|
211
|
+
const item = target.payload[target.field][target.entry];
|
|
212
|
+
const pointer = opfPathToJsonPointer(`${target.path}.${target.entry}`);
|
|
213
|
+
const patches = [];
|
|
214
|
+
if (start === undefined || start === null) {
|
|
215
|
+
if (isObject(item) && item.start !== undefined) patches.push({ op: "remove", path: `${pointer}/start` });
|
|
216
|
+
} else {
|
|
217
|
+
if (!Number.isInteger(start) || start < 1 || start > MAX_NUMBERING_START) throw fail("invalid-numbering", `An entry start is a whole number from 1 to ${MAX_NUMBERING_START}.`, { start });
|
|
218
|
+
if (isObject(item)) patches.push({ op: item.start === undefined ? "add" : "replace", path: `${pointer}/start`, value: start });
|
|
219
|
+
else patches.push({ op: "replace", path: pointer, value: { text: structuredClone(item), start } });
|
|
220
|
+
}
|
|
221
|
+
if (!patches.length) return { document: editor.document, patches: [], inversePatches: [], validation: editor.validation };
|
|
222
|
+
return editor.applyPatch(patches, { source: "list-numbering-start", rejectInvalid: true, ...meta });
|
|
223
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { EditorSession } from "./index.js";
|
|
2
|
+
|
|
3
|
+
export interface OutlineViewOptions {
|
|
4
|
+
editor: EditorSession;
|
|
5
|
+
/** Called when a row takes focus, so the host can show that slide in its inspector. */
|
|
6
|
+
onSelectSlide?: (slideIndex: number) => void;
|
|
7
|
+
getSlideIndex?: () => number;
|
|
8
|
+
/** Every message the outline announces in its live region. */
|
|
9
|
+
onStatus?: (message: string) => void;
|
|
10
|
+
}
|
|
11
|
+
export interface OutlineView {
|
|
12
|
+
/** Redraw from the session; the host calls this after document changes. */
|
|
13
|
+
render(): void;
|
|
14
|
+
focusSlide(slideIndex: number): void;
|
|
15
|
+
destroy(): void;
|
|
16
|
+
}
|
|
17
|
+
export declare function createOutlineView(container: HTMLElement, options: OutlineViewOptions): OutlineView;
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// Outline view (RR-21): the deck as an editable outline. Slide titles are the top level; each slide's text, bullets and subtitle sit beneath
|
|
2
|
+
// it. Rows are native text inputs, so they work with screen readers, IME and the clipboard. Typing commits on Enter or when the row loses focus
|
|
3
|
+
// as ONE undoable change; structure edits (Enter for a new line or slide, Alt+Shift+Left/Right to promote and demote, Alt+Up/Down to move)
|
|
4
|
+
// are one undoable change each, built in `outline.js`. Content that is not text is shown as a read-only row, and formatted text stays
|
|
5
|
+
// read-only here, so nothing is flattened away. Importing needs no DOM; mounting does.
|
|
6
|
+
import {
|
|
7
|
+
applyOutlineChange, prepareAddOutlineBullet, prepareInsertOutlineItem, prepareInsertOutlineSlide, prepareMoveOutlineItem, prepareMoveOutlineSlide, prepareOutlineDemoteSlide,
|
|
8
|
+
prepareOutlinePromote, prepareRemoveOutlineItem, prepareSetOutlineText, prepareShiftOutlineItem, readOutline,
|
|
9
|
+
} from "./outline.js";
|
|
10
|
+
import { hasSections, prepareRemoveSlides } from "./slides.js";
|
|
11
|
+
|
|
12
|
+
let instances = 0;
|
|
13
|
+
function el(doc, tag, props = {}, ...children) {
|
|
14
|
+
const node = doc.createElement(tag);
|
|
15
|
+
for (const [key, value] of Object.entries(props)) {
|
|
16
|
+
if (value === undefined || value === null || value === false) continue;
|
|
17
|
+
if (key === "class") node.className = value;
|
|
18
|
+
else if (key === "text") node.textContent = value;
|
|
19
|
+
else node.setAttribute(key, value === true ? "" : String(value));
|
|
20
|
+
}
|
|
21
|
+
node.append(...children.filter((child) => child !== null && child !== undefined && child !== false));
|
|
22
|
+
return node;
|
|
23
|
+
}
|
|
24
|
+
const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Mount the outline into `container`. Options: `editor`, `onSelectSlide(index)` (called when a row takes focus, so the host can follow),
|
|
28
|
+
* `getSlideIndex()` and `onStatus(message)`. Returns `{ render, focusSlide, destroy }`; the host calls `render()` after document changes.
|
|
29
|
+
*/
|
|
30
|
+
export function createOutlineView(container, options) {
|
|
31
|
+
const { editor } = options;
|
|
32
|
+
const doc = container.ownerDocument;
|
|
33
|
+
const id = `opf-outline-${++instances}`;
|
|
34
|
+
let rows = [];
|
|
35
|
+
let renderedKeys = "";
|
|
36
|
+
let lastSnapshot;
|
|
37
|
+
let activeKey;
|
|
38
|
+
let pendingFocus;
|
|
39
|
+
let keys = [];
|
|
40
|
+
|
|
41
|
+
container.classList.add("opf-outline");
|
|
42
|
+
const live = el(doc, "div", { class: "sr-only", role: "status", "aria-live": "polite", "aria-atomic": "true" });
|
|
43
|
+
const help = el(doc, "p", { id: `${id}-help`, class: "outline-help", text: "Type to edit. Enter adds a line (or a slide after a title). Alt+Shift+Right demotes, Alt+Shift+Left promotes, Alt+Up and Alt+Down move the line or slide. Backspace on an empty line removes it." });
|
|
44
|
+
const bar = el(doc, "div", { class: "outline-toolbar", role: "toolbar", "aria-label": "Outline actions" });
|
|
45
|
+
const list = el(doc, "ul", { class: "outline-list", "aria-label": "Presentation outline" });
|
|
46
|
+
const tools = {};
|
|
47
|
+
for (const [name, label] of [["promote", "Promote"], ["demote", "Demote"], ["up", "Move up"], ["down", "Move down"], ["add", "Add line below"]]) {
|
|
48
|
+
const button = el(doc, "button", { type: "button", class: "secondary outline-tool", "data-action": name, text: label });
|
|
49
|
+
// Keep the row's text selected: a click on a tool must not drop the caret before the action reads the row.
|
|
50
|
+
button.addEventListener("mousedown", (event) => event.preventDefault());
|
|
51
|
+
button.addEventListener("click", () => perform(name));
|
|
52
|
+
tools[name] = button;
|
|
53
|
+
bar.append(button);
|
|
54
|
+
}
|
|
55
|
+
container.replaceChildren(bar, help, list, live);
|
|
56
|
+
|
|
57
|
+
const announce = (message) => {
|
|
58
|
+
live.textContent = "";
|
|
59
|
+
globalThis.setTimeout(() => { live.textContent = message; }, 20);
|
|
60
|
+
options.onStatus?.(message);
|
|
61
|
+
};
|
|
62
|
+
const report = (error) => announce(error?.issues?.[0]?.message ?? error?.message ?? String(error));
|
|
63
|
+
const rowByKey = (key) => rows.find((row) => row.key === key);
|
|
64
|
+
const inputFor = (key) => list.querySelector(`[data-key="${CSS.escape(key)}"] .outline-input`);
|
|
65
|
+
|
|
66
|
+
function label(row) {
|
|
67
|
+
const slide = `Slide ${row.slideIndex + 1}`;
|
|
68
|
+
if (row.kind === "slide") return `${slide} title`;
|
|
69
|
+
if (row.kind === "subtitle") return `${slide} subtitle`;
|
|
70
|
+
if (row.kind === "text") return `${slide} text${row.editable ? "" : " (formatted, edit it on the slide)"}`;
|
|
71
|
+
if (row.kind === "other") return `${slide} ${row.label}, not editable in the outline`;
|
|
72
|
+
return `${slide}, ${row.level > 1 ? `level ${row.level} ` : ""}bullet`;
|
|
73
|
+
}
|
|
74
|
+
function build(row) {
|
|
75
|
+
const input = el(doc, "input", { type: "text", class: "outline-input", "aria-label": label(row), spellcheck: row.editable ? "true" : "false", autocomplete: "off", value: row.kind === "other" ? row.label : row.text, "aria-describedby": `${id}-help` });
|
|
76
|
+
if (!row.editable) input.readOnly = true;
|
|
77
|
+
if (row.kind === "slide") input.placeholder = "Untitled slide";
|
|
78
|
+
const item = el(doc, "li", { class: "outline-row", "data-key": row.key, "data-kind": row.kind, "data-level": row.level });
|
|
79
|
+
item.style.setProperty("--level", String(row.level));
|
|
80
|
+
if (row.kind === "slide") item.append(el(doc, "span", { class: "outline-number", "aria-hidden": "true", text: String(row.slideIndex + 1) }));
|
|
81
|
+
else item.append(el(doc, "span", { class: "outline-bullet", "aria-hidden": "true", text: row.kind === "item" ? "•" : "" }));
|
|
82
|
+
item.append(input);
|
|
83
|
+
if (row.kind === "slide" && row.hidden) item.append(el(doc, "span", { class: "slide-badge", text: "Hidden" }));
|
|
84
|
+
input.addEventListener("focus", () => { activeKey = row.key; options.onSelectSlide?.(rowByKey(row.key)?.slideIndex ?? row.slideIndex); updateTools(); });
|
|
85
|
+
input.addEventListener("change", () => commit(row.key, input));
|
|
86
|
+
input.addEventListener("keydown", (event) => keydown(event, row.key, input));
|
|
87
|
+
return item;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function render() {
|
|
91
|
+
const snapshot = editor.snapshot();
|
|
92
|
+
const firstDraw = !lastSnapshot;
|
|
93
|
+
if (snapshot === lastSnapshot && !pendingFocus) return;
|
|
94
|
+
lastSnapshot = snapshot;
|
|
95
|
+
const document = editor.document;
|
|
96
|
+
rows = readOutline(document).rows;
|
|
97
|
+
const sections = hasSections(document);
|
|
98
|
+
const entries = [];
|
|
99
|
+
let lastSection;
|
|
100
|
+
for (const row of rows) {
|
|
101
|
+
if (sections && row.kind === "slide" && (row.section !== lastSection || entries.every((entry) => entry.kind !== "section"))) {
|
|
102
|
+
entries.push({ kind: "section", key: `section:${row.slideIndex}`, text: row.section ?? "No section" });
|
|
103
|
+
lastSection = row.section;
|
|
104
|
+
}
|
|
105
|
+
entries.push(row);
|
|
106
|
+
}
|
|
107
|
+
const nextKeys = entries.map((entry) => entry.key);
|
|
108
|
+
const joined = nextKeys.join("|");
|
|
109
|
+
const hadFocus = list.contains(doc.activeElement);
|
|
110
|
+
if (joined === renderedKeys && !firstDraw) {
|
|
111
|
+
// Same structure: update values in place so a click on another row is never lost to a rebuild.
|
|
112
|
+
for (const row of rows) {
|
|
113
|
+
const input = inputFor(row.key);
|
|
114
|
+
if (!input) continue;
|
|
115
|
+
const text = row.kind === "other" ? row.label : row.text;
|
|
116
|
+
if (input.value !== text && doc.activeElement !== input) input.value = text;
|
|
117
|
+
input.setAttribute("aria-label", label(row));
|
|
118
|
+
input.readOnly = !row.editable;
|
|
119
|
+
}
|
|
120
|
+
for (const entry of entries) if (entry.kind === "section") list.querySelector(`[data-key="${CSS.escape(entry.key)}"] .outline-section-name`).textContent = entry.text;
|
|
121
|
+
for (const row of rows) if (row.kind === "slide") {
|
|
122
|
+
const item = list.querySelector(`[data-key="${CSS.escape(row.key)}"]`);
|
|
123
|
+
item.querySelector(".slide-badge")?.remove();
|
|
124
|
+
if (row.hidden) item.append(el(doc, "span", { class: "slide-badge", text: "Hidden" }));
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
renderedKeys = joined;
|
|
128
|
+
keys = nextKeys;
|
|
129
|
+
list.replaceChildren(...entries.map((entry) => {
|
|
130
|
+
if (entry.kind === "section") return el(doc, "li", { class: "outline-section", "data-key": entry.key }, el(doc, "span", { class: "outline-section-name", text: entry.text }));
|
|
131
|
+
return build(entry);
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
const target = pendingFocus ?? (hadFocus ? activeKey : undefined);
|
|
135
|
+
pendingFocus = undefined;
|
|
136
|
+
if (target) {
|
|
137
|
+
const input = inputFor(target);
|
|
138
|
+
if (input && doc.activeElement !== input) input.focus();
|
|
139
|
+
}
|
|
140
|
+
updateTools();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function activeRow() { return activeKey ? rowByKey(activeKey) : undefined; }
|
|
144
|
+
function updateTools() {
|
|
145
|
+
const row = activeRow();
|
|
146
|
+
const document = editor.document;
|
|
147
|
+
const can = { promote: false, demote: false, up: false, down: false, add: Boolean(row) };
|
|
148
|
+
if (row) {
|
|
149
|
+
try {
|
|
150
|
+
if (row.kind === "item") {
|
|
151
|
+
can.demote = prepareShiftOutlineItem(document, row, 1).changed;
|
|
152
|
+
can.promote = row.level > 1 ? prepareShiftOutlineItem(document, row, -1).changed : row.editable;
|
|
153
|
+
can.up = prepareMoveOutlineItem(document, row, -1).changed;
|
|
154
|
+
can.down = prepareMoveOutlineItem(document, row, 1).changed;
|
|
155
|
+
} else if (row.kind === "slide") {
|
|
156
|
+
can.up = row.slideIndex > 0;
|
|
157
|
+
can.down = row.slideIndex < document.slides.length - 1;
|
|
158
|
+
can.demote = row.slideIndex > 0;
|
|
159
|
+
}
|
|
160
|
+
} catch { /* a row that vanished mid-edit leaves the tools off */ }
|
|
161
|
+
}
|
|
162
|
+
for (const [name, button] of Object.entries(tools)) button.disabled = !can[name];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function commit(key, input) {
|
|
166
|
+
const row = rowByKey(key);
|
|
167
|
+
if (!row?.editable) return false;
|
|
168
|
+
if (input.value === row.text) return false;
|
|
169
|
+
try {
|
|
170
|
+
const prepared = prepareSetOutlineText(editor.document, row, input.value);
|
|
171
|
+
if (!prepared.changed) return false;
|
|
172
|
+
applyOutlineChange(editor, prepared);
|
|
173
|
+
render();
|
|
174
|
+
return true;
|
|
175
|
+
} catch (error) {
|
|
176
|
+
input.value = row.text;
|
|
177
|
+
report(error);
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function apply(prepare, message) {
|
|
183
|
+
try {
|
|
184
|
+
const prepared = prepare();
|
|
185
|
+
if (!prepared.changed) { announce(prepared.reason ?? "Nothing to change"); return false; }
|
|
186
|
+
pendingFocus = prepared.focus ?? activeKey;
|
|
187
|
+
applyOutlineChange(editor, prepared);
|
|
188
|
+
lastSnapshot = undefined;
|
|
189
|
+
render();
|
|
190
|
+
announce(typeof message === "function" ? message(prepared) : message);
|
|
191
|
+
return true;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
pendingFocus = undefined;
|
|
194
|
+
report(error);
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Commit what is typed in the row before an action reads it, so the typed text and the structure change are two honest steps.
|
|
200
|
+
function settle(input, key) { if (input) commit(key, input); return rowByKey(key); }
|
|
201
|
+
|
|
202
|
+
function perform(action, input) {
|
|
203
|
+
const key = activeKey;
|
|
204
|
+
const row = settle(input ?? inputFor(key), key);
|
|
205
|
+
if (!row) return;
|
|
206
|
+
const document = editor.document;
|
|
207
|
+
if (action === "add") {
|
|
208
|
+
if (row.kind === "item") apply(() => prepareInsertOutlineItem(document, row), "Line added");
|
|
209
|
+
else if (row.kind === "slide") apply(() => prepareAddOutlineBullet(document, row.slideIndex), "Bullet added under the slide title");
|
|
210
|
+
else announce("Add a line from a title or a bullet");
|
|
211
|
+
} else if (action === "demote") {
|
|
212
|
+
if (row.kind === "item") apply(() => prepareShiftOutlineItem(document, row, 1), () => "Demoted");
|
|
213
|
+
else if (row.kind === "slide") apply(() => prepareOutlineDemoteSlide(document, row.slideIndex), "Slide joined the slide before it as a bullet");
|
|
214
|
+
else announce("Only titles and bullets change level");
|
|
215
|
+
} else if (action === "promote") {
|
|
216
|
+
if (row.kind === "item") {
|
|
217
|
+
if (row.level > 1) apply(() => prepareShiftOutlineItem(document, row, -1), "Promoted");
|
|
218
|
+
else apply(() => prepareOutlinePromote(document, row), (prepared) => `Promoted to slide ${prepared.newSlideIndex + 1}`);
|
|
219
|
+
} else if (row.kind === "slide") announce("Slide titles are already the top level");
|
|
220
|
+
else announce("Only titles and bullets change level");
|
|
221
|
+
} else if (action === "up" || action === "down") {
|
|
222
|
+
const delta = action === "up" ? -1 : 1;
|
|
223
|
+
if (row.kind === "item") apply(() => prepareMoveOutlineItem(document, row, delta), `Moved ${action}`);
|
|
224
|
+
else if (row.kind === "slide") apply(() => prepareMoveOutlineSlide(document, row.slideIndex, delta), (prepared) => `Slide moved to position ${prepared.selection[0] + 1} of ${document.slides.length}`);
|
|
225
|
+
else announce("Move bullets or slides");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function keydown(event, key, input) {
|
|
230
|
+
const row = rowByKey(key);
|
|
231
|
+
if (!row) return;
|
|
232
|
+
const index = keys.indexOf(key);
|
|
233
|
+
const focusAt = (position) => {
|
|
234
|
+
for (let at = position; at >= 0 && at < keys.length; at += position > index ? 1 : -1) {
|
|
235
|
+
const target = list.querySelector(`[data-key="${CSS.escape(keys[at])}"] .outline-input`);
|
|
236
|
+
if (target) { target.focus(); return; }
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
if (event.altKey && event.shiftKey && (event.key === "ArrowRight" || event.key === "ArrowLeft")) { event.preventDefault(); perform(event.key === "ArrowRight" ? "demote" : "promote", input); return; }
|
|
240
|
+
if (event.altKey && !event.shiftKey && (event.key === "ArrowUp" || event.key === "ArrowDown")) { event.preventDefault(); perform(event.key === "ArrowUp" ? "up" : "down", input); return; }
|
|
241
|
+
if (event.key === "ArrowDown" && !event.altKey && !event.ctrlKey && !event.metaKey) { event.preventDefault(); focusAt(index + 1); return; }
|
|
242
|
+
if (event.key === "ArrowUp" && !event.altKey && !event.ctrlKey && !event.metaKey) { event.preventDefault(); focusAt(index - 1); return; }
|
|
243
|
+
if (event.key === "Enter" && !event.isComposing) {
|
|
244
|
+
event.preventDefault();
|
|
245
|
+
const settled = settle(input, key);
|
|
246
|
+
if (!settled) return;
|
|
247
|
+
const document = editor.document;
|
|
248
|
+
if (settled.kind === "item") apply(() => prepareInsertOutlineItem(document, settled), "Line added");
|
|
249
|
+
else if (settled.kind === "slide") apply(() => prepareInsertOutlineSlide(document, settled.slideIndex), (prepared) => `Slide ${prepared.index + 1} added`);
|
|
250
|
+
else focusAt(index + 1);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (event.key === "Backspace" && input.value === "" && input.selectionStart === 0 && !input.readOnly) {
|
|
254
|
+
if (row.kind === "item") { event.preventDefault(); apply(() => prepareRemoveOutlineItem(editor.document, row), "Line removed"); }
|
|
255
|
+
else if (row.kind === "slide" && !rows.some((other) => other.slideIndex === row.slideIndex && other.kind !== "slide")) {
|
|
256
|
+
event.preventDefault();
|
|
257
|
+
apply(() => {
|
|
258
|
+
const prepared = prepareRemoveSlides(editor.document, [row.slideIndex]);
|
|
259
|
+
return { ...prepared, focus: `slide:slides.${Math.max(0, row.slideIndex - 1)}` };
|
|
260
|
+
}, `Slide ${row.slideIndex + 1} removed`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
render();
|
|
266
|
+
return {
|
|
267
|
+
render,
|
|
268
|
+
/** Focus the title row of a slide. */
|
|
269
|
+
focusSlide(slideIndex) {
|
|
270
|
+
pendingFocus = `slide:slides.${slideIndex}`;
|
|
271
|
+
render();
|
|
272
|
+
},
|
|
273
|
+
destroy() {
|
|
274
|
+
container.replaceChildren();
|
|
275
|
+
container.classList.remove("opf-outline");
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { EditorChange, EditorSession, JsonPatchOperation } from "./index.js";
|
|
2
|
+
import type { PreparedSlideChange } from "./slides.js";
|
|
3
|
+
|
|
4
|
+
export type OutlineRowKind = "slide" | "subtitle" | "text" | "item" | "other";
|
|
5
|
+
export interface OutlineRow {
|
|
6
|
+
/** Stable for a row across edits that do not move it (`kind:path`), for focus and keys. */
|
|
7
|
+
key: string;
|
|
8
|
+
kind: OutlineRowKind;
|
|
9
|
+
slideIndex: number;
|
|
10
|
+
/** 0 for a slide title; 1 for its text; 1 plus the nesting level for a list item. */
|
|
11
|
+
level: number;
|
|
12
|
+
/** The plain text (formatted text is flattened for display). */
|
|
13
|
+
text: string;
|
|
14
|
+
/** The document path of the text value (an `other` row: of the content). */
|
|
15
|
+
path: string;
|
|
16
|
+
/** False for formatted text and for content that is not text. */
|
|
17
|
+
editable: boolean;
|
|
18
|
+
/** item: the list (`slides.0.items`) and the item's index in it. */
|
|
19
|
+
listPath?: string;
|
|
20
|
+
itemIndex?: number;
|
|
21
|
+
/** other: the content kind ("Chart", "Table", ...). */
|
|
22
|
+
label?: string;
|
|
23
|
+
/** slide: whether the slide is hidden, its section and id. */
|
|
24
|
+
hidden?: boolean;
|
|
25
|
+
section?: string;
|
|
26
|
+
id?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface Outline { rows: OutlineRow[] }
|
|
29
|
+
|
|
30
|
+
/** An outline change: the slide patch report plus the key of the row to focus afterwards. */
|
|
31
|
+
export interface PreparedOutlineChange extends Omit<PreparedSlideChange, "selection"> {
|
|
32
|
+
selection: number[];
|
|
33
|
+
/** The `key` of the row to focus after the change. */
|
|
34
|
+
focus?: string;
|
|
35
|
+
reason?: string;
|
|
36
|
+
newSlideIndex?: number;
|
|
37
|
+
}
|
|
38
|
+
export type OutlineChange = Omit<EditorChange, "document" | "patches"> & Omit<PreparedOutlineChange, "document" | "patches"> & { document: unknown; patches: JsonPatchOperation[] };
|
|
39
|
+
|
|
40
|
+
export declare function readOutline(document: unknown): Outline;
|
|
41
|
+
export declare function prepareSetOutlineText(document: unknown, row: OutlineRow, text: string): PreparedOutlineChange;
|
|
42
|
+
export declare function setOutlineText(editor: EditorSession, row: OutlineRow, text: string, meta?: Record<string, unknown>): OutlineChange;
|
|
43
|
+
/** Nest (`delta` 1) or un-nest (-1) a list item with the items under it. A top-level item reports `changed: false` and a `reason`. */
|
|
44
|
+
export declare function prepareShiftOutlineItem(document: unknown, row: OutlineRow, delta: 1 | -1): PreparedOutlineChange;
|
|
45
|
+
export declare function prepareMoveOutlineItem(document: unknown, row: OutlineRow, delta: 1 | -1): PreparedOutlineChange;
|
|
46
|
+
export declare function prepareInsertOutlineItem(document: unknown, row: OutlineRow, text?: string): PreparedOutlineChange;
|
|
47
|
+
export declare function prepareAddOutlineBullet(document: unknown, slideIndex: number, text?: string): PreparedOutlineChange;
|
|
48
|
+
export declare function prepareRemoveOutlineItem(document: unknown, row: OutlineRow): PreparedOutlineChange;
|
|
49
|
+
/** A top-level list item becomes the title of a new slide that takes the items after it. */
|
|
50
|
+
export declare function prepareOutlinePromote(document: unknown, row: OutlineRow): PreparedOutlineChange;
|
|
51
|
+
/** A plain slide becomes a bullet (with its text nested) of the slide before it. Throws `outline-row-not-supported` rather than dropping content. */
|
|
52
|
+
export declare function prepareOutlineDemoteSlide(document: unknown, slideIndex: number): PreparedOutlineChange;
|
|
53
|
+
export declare function prepareMoveOutlineSlide(document: unknown, slideIndex: number, delta: number): PreparedOutlineChange;
|
|
54
|
+
export declare function prepareInsertOutlineSlide(document: unknown, slideIndex: number): PreparedOutlineChange;
|
|
55
|
+
/** Apply any prepared outline change as one undoable step. */
|
|
56
|
+
export declare function applyOutlineChange(editor: EditorSession, prepared: PreparedOutlineChange | PreparedSlideChange, meta?: Record<string, unknown>): OutlineChange;
|