@openpresentation/opf-editor 0.11.3 → 0.14.2
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 +82 -58
- package/dist/annotations.d.ts +16 -16
- package/dist/annotations.js +71 -79
- package/dist/assets.d.ts +7 -7
- package/dist/assets.js +15 -15
- package/dist/background-options.d.ts +2 -2
- package/dist/background-options.js +6 -6
- package/dist/block-controls.js +7 -7
- package/dist/block-convert.d.ts +7 -7
- package/dist/block-convert.js +23 -21
- package/dist/blocks.d.ts +8 -8
- package/dist/blocks.js +41 -40
- package/dist/canvas-fields.js +35 -12
- package/dist/canvas.d.ts +22 -30
- package/dist/canvas.js +57 -64
- package/dist/chart-data.d.ts +12 -10
- package/dist/chart-data.js +22 -21
- package/dist/chart-options-panel.d.ts +2 -2
- package/dist/chart-options-panel.js +82 -8
- package/dist/chart-options.d.ts +35 -9
- package/dist/chart-options.js +133 -40
- package/dist/checks.js +21 -0
- package/dist/color-authoring.js +1 -1
- package/dist/content-actions.d.ts +15 -15
- package/dist/content-actions.js +71 -70
- package/dist/content-controls.js +12 -12
- package/dist/data-grid.js +179 -10
- package/dist/data.d.ts +23 -2
- package/dist/data.js +40 -1
- package/dist/dataset-refs.d.ts +17 -0
- package/dist/dataset-refs.js +45 -0
- package/dist/design-controls.js +66 -47
- package/dist/design-options.d.ts +16 -14
- package/dist/design-options.js +86 -74
- package/dist/edit-helpers.js +8 -7
- package/dist/exact-source.js +5 -5
- package/dist/export.d.ts +5 -7
- package/dist/export.js +40 -14
- package/dist/find-panel.js +5 -5
- package/dist/find-replace.d.ts +3 -3
- package/dist/find-replace.js +35 -20
- package/dist/font-gate.js +49 -58
- package/dist/galleries.js +36 -52
- package/dist/grid-model.d.ts +99 -24
- package/dist/grid-model.js +577 -101
- package/dist/image-crop.d.ts +4 -4
- package/dist/image-crop.js +20 -20
- package/dist/image-cropper.js +3 -3
- package/dist/index.d.ts +28 -28
- package/dist/index.js +74 -90
- package/dist/json-options.js +17 -16
- package/dist/layout-handles.js +5 -5
- package/dist/layout-placeholders.js +4 -4
- package/dist/layout.d.ts +2 -2
- package/dist/layout.js +8 -7
- package/dist/numbering-panel.js +3 -5
- package/dist/numbering.d.ts +3 -5
- package/dist/numbering.js +14 -26
- package/dist/outline-view.js +24 -24
- package/dist/outline.d.ts +12 -12
- package/dist/outline.js +52 -50
- package/dist/persistence.d.ts +1 -1
- package/dist/persistence.js +19 -18
- package/dist/presentation-ids.js +2 -2
- package/dist/react.js +3 -3
- package/dist/review-panel.d.ts +30 -13
- package/dist/review-panel.js +154 -53
- package/dist/review.d.ts +36 -43
- package/dist/review.js +92 -53
- package/dist/rich-text-input.js +4 -3
- package/dist/rich-text-toolbar.js +5 -3
- package/dist/rich-text.js +5 -2
- package/dist/schema-inspector.d.ts +2 -2
- package/dist/schema-inspector.js +16 -11
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +25 -5
- package/dist/slide-manager.d.ts +2 -2
- package/dist/slide-manager.js +18 -18
- package/dist/slides.d.ts +20 -20
- package/dist/slides.js +99 -97
- package/dist/svelte.js +1 -1
- package/dist/switches.d.ts +10 -10
- package/dist/switches.js +136 -109
- package/dist/table-options.d.ts +8 -8
- package/dist/table-options.js +41 -30
- package/dist/table-structure.d.ts +10 -10
- package/dist/table-structure.js +21 -21
- package/dist/template-panel.d.ts +3 -3
- package/dist/template-panel.js +37 -13
- package/dist/templates.d.ts +22 -11
- package/dist/templates.js +48 -55
- package/dist/transfer.d.ts +4 -4
- package/dist/transfer.js +74 -54
- package/package.json +10 -6
- package/dist/font-defaults.js +0 -25
package/dist/review.js
CHANGED
|
@@ -1,17 +1,14 @@
|
|
|
1
|
-
// Review (RR-29): the headless model behind the Review panel.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
1
|
+
// Review (RR-29, RR-55): the headless model behind the Review panel. The findings are core's: `validate(presentation)` runs the
|
|
2
|
+
// full check (format, references, policy, accessibility, layout and content) and returns `Finding`s with a rule id, severity,
|
|
3
|
+
// category, JSON Pointer path and optional `fixes`. A hosted reviewer (pptx.dev's AI review, a house style checker) returns the
|
|
4
|
+
// same shape through the panel's `review` hook, and `mergeFindingReports` puts both in one list keyed by `Finding.source`. This
|
|
5
|
+
// module turns a finding into something an editor can act on (the slide and the nearest existing path to select) and applies its
|
|
6
|
+
// suggested fixes through the session, so every fix is a validated, undoable JSON Patch edit like any other. Importing this
|
|
7
|
+
// module needs no DOM.
|
|
8
|
+
import { validationCategories } from "@openpresentation/opf";
|
|
7
9
|
import { OPFEditorError, getValueAtPath, hasValueAtPath, jsonPointerToOpfPath, opfPathToJsonPointer, splitOpfPath } from "./index.js";
|
|
8
10
|
import { assetIdOf, prepareAssetAlt } from "./assets.js";
|
|
9
11
|
|
|
10
|
-
/** True when the installed core ships the audit (`@openpresentation/opf` after 0.11.4). */
|
|
11
|
-
export function auditAvailable() {
|
|
12
|
-
return typeof core.auditPresentation === "function";
|
|
13
|
-
}
|
|
14
|
-
|
|
15
12
|
function fail(code, message, details) {
|
|
16
13
|
return new OPFEditorError(code, message, details);
|
|
17
14
|
}
|
|
@@ -19,34 +16,59 @@ function fail(code, message, details) {
|
|
|
19
16
|
const SEVERITIES = ["error", "warning", "info"];
|
|
20
17
|
const RANK = { error: 3, warning: 2, info: 1 };
|
|
21
18
|
export const reviewSeverities = Object.freeze([...SEVERITIES]);
|
|
19
|
+
/** Core's finding categories in the order a report lists them: format, references, policy, accessibility, layout, content. */
|
|
20
|
+
export const reviewCategories = validationCategories;
|
|
21
|
+
/** The `source` of a finding that names none: core's own checks (`Finding.source` defaults to `opf`). */
|
|
22
|
+
export const CORE_SOURCE = "opf";
|
|
23
|
+
|
|
24
|
+
const sourceOf = (finding) => finding?.source ?? CORE_SOURCE;
|
|
25
|
+
|
|
26
|
+
const countBySeverity = (findings) => {
|
|
27
|
+
const counts = { error: 0, warning: 0, info: 0 };
|
|
28
|
+
for (const finding of findings) if (finding.severity in counts) counts[finding.severity] += 1;
|
|
29
|
+
return counts;
|
|
30
|
+
};
|
|
22
31
|
|
|
23
32
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
33
|
+
* One report from core's and a hosted reviewer's. Findings are kept per `Finding.source`: a report replaces what an earlier one
|
|
34
|
+
* said for the sources it carries, so the hook's findings (`pptx.dev/review`) sit beside core's (`opf`) and never overwrite them.
|
|
35
|
+
* A hook finding that names no source gets `hookSource` (default `review`), so it cannot be mistaken for one of core's.
|
|
36
|
+
* Returns `{ valid, findings, counts, sources }` (core's `FindingReport` plus the sources present).
|
|
26
37
|
*/
|
|
27
|
-
export function
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
export function mergeFindingReports(core, hook, { hookSource = "review" } = {}) {
|
|
39
|
+
const bySource = new Map();
|
|
40
|
+
const add = (report, defaultSource) => {
|
|
41
|
+
const fresh = new Map();
|
|
42
|
+
for (const finding of report?.findings ?? []) {
|
|
43
|
+
const source = finding.source ?? defaultSource;
|
|
44
|
+
const entry = source === finding.source ? finding : { ...finding, source };
|
|
45
|
+
if (!fresh.has(source)) fresh.set(source, []);
|
|
46
|
+
fresh.get(source).push(entry);
|
|
47
|
+
}
|
|
48
|
+
for (const [source, list] of fresh) bySource.set(source, list);
|
|
49
|
+
};
|
|
50
|
+
add(core, CORE_SOURCE);
|
|
51
|
+
add(hook, hookSource);
|
|
52
|
+
const findings = [...bySource.values()].flat();
|
|
53
|
+
const counts = countBySeverity(findings);
|
|
54
|
+
return { valid: counts.error === 0, findings, counts, sources: [...bySource.keys()] };
|
|
33
55
|
}
|
|
34
56
|
|
|
35
57
|
/**
|
|
36
|
-
* The review's findings:
|
|
37
|
-
* the deck) and `dottedPath`, the OPF path to select.
|
|
58
|
+
* The review's findings: a report's `findings` with an `id` unique within the report, the slide index (or null for
|
|
59
|
+
* the deck) and `dottedPath`, the OPF path to select.
|
|
38
60
|
*/
|
|
39
61
|
export function reviewFindings(report) {
|
|
40
62
|
const seen = new Map();
|
|
41
|
-
return (report?.
|
|
42
|
-
const base = `${
|
|
63
|
+
return (report?.findings ?? []).map((finding) => {
|
|
64
|
+
const base = `${sourceOf(finding)}|${finding.ruleId}|${finding.path}`;
|
|
43
65
|
const count = seen.get(base) ?? 0;
|
|
44
66
|
seen.set(base, count + 1);
|
|
45
67
|
return {
|
|
46
|
-
...
|
|
68
|
+
...finding,
|
|
47
69
|
id: count ? `${base}#${count}` : base,
|
|
48
|
-
slide: Number.isInteger(
|
|
49
|
-
dottedPath: safeDotted(
|
|
70
|
+
slide: Number.isInteger(finding.slide) ? finding.slide : slideOfPointer(finding.path),
|
|
71
|
+
dottedPath: safeDotted(finding.path),
|
|
50
72
|
};
|
|
51
73
|
});
|
|
52
74
|
}
|
|
@@ -65,9 +87,7 @@ function safeDotted(pointer) {
|
|
|
65
87
|
|
|
66
88
|
/** Counts by severity and the number of findings. */
|
|
67
89
|
export function countFindings(findings) {
|
|
68
|
-
|
|
69
|
-
for (const finding of findings) counts[finding.severity] += 1;
|
|
70
|
-
return { ...counts, total: findings.length };
|
|
90
|
+
return { ...countBySeverity(findings), total: findings.length };
|
|
71
91
|
}
|
|
72
92
|
|
|
73
93
|
/** Findings at or above `minimum` severity (`info` keeps all), optionally only those of one slide (or the deck, `null`). */
|
|
@@ -75,61 +95,80 @@ export function filterFindings(findings, { minimum = "info", slide } = {}) {
|
|
|
75
95
|
return findings.filter((finding) => RANK[finding.severity] >= RANK[minimum] && (slide === undefined || finding.slide === slide));
|
|
76
96
|
}
|
|
77
97
|
|
|
98
|
+
/** Findings in reading order: the deck first, then each slide; errors before warnings before notes; then by rule and path. */
|
|
99
|
+
export function sortFindings(findings) {
|
|
100
|
+
return [...findings].sort((a, b) =>
|
|
101
|
+
(a.slide ?? -1) - (b.slide ?? -1) || RANK[b.severity] - RANK[a.severity] || a.ruleId.localeCompare(b.ruleId) || a.path.localeCompare(b.path));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Findings grouped by category, core's categories first in core's order, then any other category (a hosted reviewer's `narrative`,
|
|
106
|
+
* ...) in the order it first appears. Each group is sorted as `sortFindings` does. Empty categories are left out.
|
|
107
|
+
*/
|
|
108
|
+
export function groupFindings(findings) {
|
|
109
|
+
const groups = new Map(reviewCategories.map((category) => [category, []]));
|
|
110
|
+
for (const finding of findings) {
|
|
111
|
+
if (!groups.has(finding.category)) groups.set(finding.category, []);
|
|
112
|
+
groups.get(finding.category).push(finding);
|
|
113
|
+
}
|
|
114
|
+
return [...groups].filter(([, list]) => list.length).map(([category, list]) => ({ category, findings: sortFindings(list) }));
|
|
115
|
+
}
|
|
116
|
+
|
|
78
117
|
/**
|
|
79
118
|
* Where "go to" lands for a finding: the slide and the deepest existing path on the finding's way (a finding about
|
|
80
119
|
* a missing field points at the field's parent). `path` is the OPF dotted path a host selects; `pointer` the JSON Pointer.
|
|
81
120
|
*/
|
|
82
|
-
export function findingTarget(
|
|
121
|
+
export function findingTarget(presentation, finding) {
|
|
83
122
|
const segments = splitOpfPath(finding.path ?? "");
|
|
84
123
|
let length = segments.length;
|
|
85
|
-
while (length > 0 && !hasValueAtPath(
|
|
124
|
+
while (length > 0 && !hasValueAtPath(presentation, segments.slice(0, length))) length -= 1;
|
|
86
125
|
const kept = segments.slice(0, length);
|
|
87
126
|
const slide = kept[0] === "slides" && /^\d+$/.test(kept[1] ?? "") ? Number(kept[1]) : null;
|
|
88
127
|
return { slide, path: kept.join("."), pointer: opfPathToJsonPointer(kept), exact: length === segments.length };
|
|
89
128
|
}
|
|
90
129
|
|
|
91
|
-
const PATCH_OPS = new Set(["add", "replace", "remove"]);
|
|
92
|
-
const MAX_FIX_OPERATIONS =
|
|
130
|
+
const PATCH_OPS = new Set(["add", "replace", "remove", "move", "copy", "test"]);
|
|
131
|
+
const MAX_FIX_OPERATIONS = 20;
|
|
132
|
+
const STALE = "The slide has changed since this finding was made. The review has been refreshed; apply the fix again if it still shows.";
|
|
93
133
|
|
|
94
|
-
function checkPatch(
|
|
134
|
+
function checkPatch(presentation, operations) {
|
|
95
135
|
if (!Array.isArray(operations) || !operations.length || operations.length > MAX_FIX_OPERATIONS) {
|
|
96
136
|
throw fail("invalid-fix", `A review fix changes one to ${MAX_FIX_OPERATIONS} fields.`);
|
|
97
137
|
}
|
|
98
138
|
for (const operation of operations) {
|
|
99
139
|
if (!operation || !PATCH_OPS.has(operation.op) || typeof operation.path !== "string" || !operation.path.startsWith("/") || operation.path === "/") {
|
|
100
|
-
throw fail("invalid-fix", "A review fix may
|
|
140
|
+
throw fail("invalid-fix", "A review fix may change a field below the document root.", { operation });
|
|
101
141
|
}
|
|
102
142
|
// The finding is stale when the field it was about has since changed: refuse instead of guessing.
|
|
103
|
-
if ((operation.op === "replace" || operation.op === "remove") && !hasValueAtPath(
|
|
104
|
-
|
|
105
|
-
}
|
|
143
|
+
if ((operation.op === "replace" || operation.op === "remove") && !hasValueAtPath(presentation, operation.path)) throw fail("stale-finding", STALE, { operation });
|
|
144
|
+
if ((operation.op === "move" || operation.op === "copy") && (typeof operation.from !== "string" || !hasValueAtPath(presentation, operation.from))) throw fail("stale-finding", STALE, { operation });
|
|
106
145
|
}
|
|
107
146
|
}
|
|
108
147
|
|
|
109
148
|
/**
|
|
110
|
-
* Apply one `patch` fix of a finding
|
|
111
|
-
* document invalid is rejected
|
|
149
|
+
* Apply one `patch` fix of a finding (core's `FindingFix`: `title`, `patch`; a fix with no `kind` is a patch fix) as a single
|
|
150
|
+
* undoable session change, validated: a fix that would make the document invalid is rejected. Returns the session's change.
|
|
112
151
|
*/
|
|
113
152
|
export function applyReviewFix(editor, finding, fix, meta = {}) {
|
|
114
153
|
if (!editor || typeof editor.applyPatch !== "function") throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
|
|
115
|
-
if (!fix || fix.kind !== "patch") throw fail("invalid-fix", "Only patch fixes can be applied; a focus fix asks the author to type something.");
|
|
116
|
-
checkPatch(editor.
|
|
154
|
+
if (!fix || (fix.kind ?? "patch") !== "patch") throw fail("invalid-fix", "Only patch fixes can be applied; a focus fix asks the author to type something.");
|
|
155
|
+
checkPatch(editor.presentation, fix.patch);
|
|
117
156
|
return editor.applyPatch(fix.patch, { ...meta, source: meta.source ?? "review", ruleId: finding?.ruleId, fix: fix.id, rejectInvalid: true });
|
|
118
157
|
}
|
|
119
158
|
|
|
120
159
|
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
121
160
|
|
|
122
161
|
/** The patch operations that give the asset value at `pointer` alt text (`""` marks it decorative), or `null` when it already has exactly this alt. */
|
|
123
|
-
export function altTextPatch(
|
|
124
|
-
const value = getValueAtPath(
|
|
162
|
+
export function altTextPatch(presentation, pointer, text) {
|
|
163
|
+
const value = getValueAtPath(presentation, pointer);
|
|
125
164
|
if (value === undefined) throw fail("stale-finding", "The picture this finding is about no longer exists.", { pointer });
|
|
126
165
|
const alt = String(text ?? "");
|
|
127
166
|
const id = assetIdOf(value);
|
|
128
|
-
const entry = id === undefined ? undefined : getValueAtPath(
|
|
167
|
+
const entry = id === undefined ? undefined : getValueAtPath(presentation, ["assets", id]);
|
|
129
168
|
// A picture that points into the assets registry gets its alt text there, so every use of the asset has it.
|
|
130
169
|
if (id !== undefined && entry !== undefined) {
|
|
131
170
|
// prepareAssetAlt treats "" as "remove the alt"; an empty alt here is the decorative choice and must stay in the document.
|
|
132
|
-
if (alt !== "") return prepareAssetAlt(
|
|
171
|
+
if (alt !== "") return prepareAssetAlt(presentation, id, alt).patches;
|
|
133
172
|
if (isObject(entry)) {
|
|
134
173
|
if (entry.alt === "") return null;
|
|
135
174
|
return [{ op: Object.hasOwn(entry, "alt") ? "replace" : "add", path: opfPathToJsonPointer(["assets", id, "alt"]), value: "" }];
|
|
@@ -148,23 +187,23 @@ export function altTextPatch(document, pointer, text) {
|
|
|
148
187
|
export function setReviewAltText(editor, pointer, text, meta = {}) {
|
|
149
188
|
const alt = String(text ?? "").trim();
|
|
150
189
|
if (!alt) throw fail("empty-alt-text", "Write what the picture shows, or mark it decorative.", { pointer });
|
|
151
|
-
const patch = altTextPatch(editor.
|
|
152
|
-
if (!patch) return { changed: false,
|
|
190
|
+
const patch = altTextPatch(editor.presentation, pointer, alt);
|
|
191
|
+
if (!patch) return { changed: false, presentation: editor.presentation };
|
|
153
192
|
return { changed: true, ...editor.applyPatch(patch, { ...meta, source: meta.source ?? "review-alt", pointer, rejectInvalid: true }) };
|
|
154
193
|
}
|
|
155
194
|
|
|
156
195
|
/** Mark a picture decorative (empty alt text), an explicit choice, as one undoable change. */
|
|
157
196
|
export function markDecorative(editor, pointer, meta = {}) {
|
|
158
|
-
const patch = altTextPatch(editor.
|
|
159
|
-
if (!patch) return { changed: false,
|
|
197
|
+
const patch = altTextPatch(editor.presentation, pointer, "");
|
|
198
|
+
if (!patch) return { changed: false, presentation: editor.presentation };
|
|
160
199
|
return { changed: true, ...editor.applyPatch(patch, { ...meta, source: meta.source ?? "review-decorative", pointer, rejectInvalid: true }) };
|
|
161
200
|
}
|
|
162
201
|
|
|
163
202
|
/** The alt text a picture has now (own or through the assets registry), for pre-filling the field. */
|
|
164
|
-
export function currentAltText(
|
|
165
|
-
const value = getValueAtPath(
|
|
203
|
+
export function currentAltText(presentation, pointer) {
|
|
204
|
+
const value = getValueAtPath(presentation, pointer);
|
|
166
205
|
if (isObject(value) && typeof value.alt === "string") return value.alt;
|
|
167
206
|
const id = assetIdOf(value);
|
|
168
|
-
const entry = id === undefined ? undefined : getValueAtPath(
|
|
207
|
+
const entry = id === undefined ? undefined : getValueAtPath(presentation, ["assets", id]);
|
|
169
208
|
return isObject(entry) && typeof entry.alt === "string" ? entry.alt : "";
|
|
170
209
|
}
|
package/dist/rich-text-input.js
CHANGED
|
@@ -27,14 +27,15 @@ export function createRichTextInput(root, overlay, {path, value, getTarget, onIn
|
|
|
27
27
|
const range=doc.createRange();range.setStart(node.firstChild,start);range.setEnd(node.firstChild,end);return range.getBoundingClientRect();
|
|
28
28
|
}
|
|
29
29
|
function boundaries() {
|
|
30
|
-
|
|
30
|
+
// A rich quote draws its quotation marks inside its first and last run (FA-10): they sit just outside the text, so their offsets clamp to its ends.
|
|
31
|
+
const result=[],total=richTextContent(current).length,clampOffset=offset=>Math.max(0,Math.min(total,offset));
|
|
31
32
|
for(const node of fragments()) {
|
|
32
33
|
if(!node.firstChild)continue;
|
|
33
34
|
const start=Number(node.dataset.opfTextStart),text=node.textContent;
|
|
34
35
|
for(const part of new Intl.Segmenter(undefined,{granularity:'grapheme'}).segment(text)) {
|
|
35
36
|
const box=rect(node,part.index,part.index+part.segment.length),rtl=win.getComputedStyle(node).direction==='rtl';
|
|
36
|
-
result.push({offset:start+part.index,x:rtl?box.right:box.left,y:box.top,height:box.height,node});
|
|
37
|
-
result.push({offset:start+part.index+part.segment.length,x:rtl?box.left:box.right,y:box.top,height:box.height,node});
|
|
37
|
+
result.push({offset:clampOffset(start+part.index),x:rtl?box.right:box.left,y:box.top,height:box.height,node});
|
|
38
|
+
result.push({offset:clampOffset(start+part.index+part.segment.length),x:rtl?box.left:box.right,y:box.top,height:box.height,node});
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
const target=getTarget(path),matrix=target?.getScreenCTM();
|
|
@@ -72,7 +72,7 @@ export function createRichTextToolbar(root, {editor, report, beforeChange, onPro
|
|
|
72
72
|
onChange?.(snapshot.path);
|
|
73
73
|
} finally {restoring=false;}
|
|
74
74
|
}
|
|
75
|
-
for(const [key,label] of [['bold','Bold'],['italic','Italic'],['underline','Underline'],['strikethrough','Strike'],['superscript','Superscript'],['subscript','Subscript']])
|
|
75
|
+
for(const [key,label] of [['bold','Bold'],['italic','Italic'],['underline','Underline'],['strikethrough','Strike'],['superscript','Superscript'],['subscript','Subscript'],['code','Code']])
|
|
76
76
|
buttons.set(key,button(label,()=>apply({[key]:!currentRuns().every(run=>!!run[key])})));
|
|
77
77
|
function field(label,type='text') {
|
|
78
78
|
const input=doc.createElement('input');input.type=type;input.setAttribute('aria-label',label);input.placeholder=label;
|
|
@@ -85,11 +85,13 @@ export function createRichTextToolbar(root, {editor, report, beforeChange, onPro
|
|
|
85
85
|
const size=field('Font size (pt)','number');size.min='0.1';size.step='0.5';size.style.width='80px';
|
|
86
86
|
size.onchange=()=>{try{apply({fontSize:size.value?Number(size.value):null});}catch(error){report(error);}};
|
|
87
87
|
const family=field('Font family');family.onchange=()=>{try{apply({fontFamily:family.value||null});}catch(error){report(error);}};
|
|
88
|
-
const link=field('Link URL');link.onchange=()=>{try{if(link.value&&!/^(https
|
|
88
|
+
const link=field('Link URL');link.onchange=()=>{try{if(link.value&&!/^(https?:\/\/|mailto:|tel:)\S+$/.test(link.value))throw new Error('Use an HTTP, HTTPS, mailto or tel link without spaces.');apply({link:link.value||null});}catch(error){report(error);}};
|
|
89
|
+
const language=field('Language tag');language.style.width='80px';
|
|
90
|
+
language.onchange=()=>{try{if(language.value&&!/^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$/.test(language.value))throw new Error('Use a BCP-47 language tag such as fr-FR or ja-JP.');apply({lang:language.value||null});}catch(error){report(error);}};
|
|
89
91
|
const replacement=field('Selected text');replacement.style.width='160px';
|
|
90
92
|
replacement.onkeydown=event=>{if(event.key==='Enter'){event.preventDefault();try{apply(null,replacement.value);}catch(error){report(error);}}};
|
|
91
93
|
button('Replace text',()=>apply(null,replacement.value));
|
|
92
|
-
button('Reset style',()=>apply(Object.fromEntries(['bold','italic','underline','strikethrough','superscript','subscript','color','fontSize','fontFamily','link'].map(key=>[key,null]))));
|
|
94
|
+
button('Reset style',()=>apply(Object.fromEntries(['bold','italic','underline','strikethrough','superscript','subscript','code','color','fontSize','fontFamily','link','lang'].map(key=>[key,null]))));
|
|
93
95
|
button('Edit text',()=>{const path=selected?.path;hide();if(path)onTyping?.(path);});
|
|
94
96
|
button('Edit runs',()=>{const path=selected?.path;hide();if(path)onProperties?.(path);});
|
|
95
97
|
button('Done',()=>{hide();win.getSelection()?.removeAllRanges();});
|
package/dist/rich-text.js
CHANGED
|
@@ -19,8 +19,10 @@ function range(value, start, end, allowEmpty) {
|
|
|
19
19
|
if (!boundaries.has(start) || !boundaries.has(end)) throw new RangeError('Text ranges must end at whole grapheme boundaries.');
|
|
20
20
|
return runs(value);
|
|
21
21
|
}
|
|
22
|
-
const booleans = ['bold','italic','underline','strikethrough','superscript','subscript'];
|
|
23
|
-
const strings = ['color','fontFamily','link'];
|
|
22
|
+
const booleans = ['bold','italic','underline','strikethrough','superscript','subscript','code'];
|
|
23
|
+
const strings = ['color','fontFamily','link','lang'];
|
|
24
|
+
// TextRun.lang is a BCP-47 tag (core schema pattern).
|
|
25
|
+
const languageTag = /^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$/;
|
|
24
26
|
function checkedFormat(format) {
|
|
25
27
|
if (!format || typeof format !== 'object' || Array.isArray(format)) throw new TypeError('Expected a run formatting object.');
|
|
26
28
|
for (const [key,value] of Object.entries(format)) {
|
|
@@ -29,6 +31,7 @@ function checkedFormat(format) {
|
|
|
29
31
|
if (booleans.includes(key) && typeof value !== 'boolean' || strings.includes(key) && typeof value !== 'string' || key === 'fontSize' && (!Number.isFinite(value) || value <= 0))
|
|
30
32
|
throw new TypeError(`Invalid run style: ${key}`);
|
|
31
33
|
}
|
|
34
|
+
if (typeof format.lang === 'string' && !languageTag.test(format.lang)) throw new TypeError('Use a BCP-47 language tag such as fr-FR or ja-JP.');
|
|
32
35
|
if (format.superscript && format.subscript) throw new TypeError('Choose superscript or subscript, not both.');
|
|
33
36
|
return {...format, ...(format.superscript ? {subscript:false} : {}), ...(format.subscript ? {superscript:false} : {})};
|
|
34
37
|
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { EditorSession } from './index.js';
|
|
2
|
-
export interface SchemaInspector { navigate(path:string|string[]):void; commit():boolean; reset():void; readonly
|
|
3
|
-
export declare function createSchemaInspector(container:HTMLElement, options:{editor:EditorSession;path?:string;onDraft?:(event:{
|
|
2
|
+
export interface SchemaInspector { navigate(path:string|string[]):void; commit():boolean; reset():void; readonly presentation:any; readonly dirty:boolean; destroy():void; }
|
|
3
|
+
export declare function createSchemaInspector(container:HTMLElement, options:{editor:EditorSession;path?:string;onDraft?:(event:{presentation:any;path:string;dirty:boolean})=>void;onCommit?:(event:{editor:EditorSession;path:string})=>void;onCancel?:()=>void;onError?:(error:Error)=>void}):SchemaInspector;
|
package/dist/schema-inspector.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { applyJsonPatch, createValuePatch, getValueAtPath, opfPathToJsonPointer, splitOpfPath
|
|
1
|
+
import { applyJsonPatch, createValuePatch, getValueAtPath, opfPathToJsonPointer, splitOpfPath } from './index.js';
|
|
2
2
|
import { schemaAtPath, schemaVariants, activeSchema, schemaType, schemaLabel, createSchemaValue } from './schema.js';
|
|
3
|
+
import { validate } from "@openpresentation/opf";
|
|
4
|
+
import { checkFormat } from "./checks.js";
|
|
3
5
|
/** A staged form editor; valid drafts preview immediately and Apply creates one undo step. */
|
|
4
6
|
export function createSchemaInspector(container, {editor,path='',onDraft,onCommit,onCancel,onError}={}) {
|
|
5
7
|
if(!editor)throw new Error('An editor session is required.');
|
|
6
|
-
let base=JSON.stringify(editor.
|
|
8
|
+
let base=JSON.stringify(editor.presentation), draft=structuredClone(editor.presentation), selected=opfPathToJsonPointer(path), dirty=false, destroyed=false, suppress=false;
|
|
7
9
|
const doc=container.ownerDocument;
|
|
8
10
|
const el=(tag,text,cls)=>{const node=doc.createElement(tag);if(text!==undefined)node.textContent=text;if(cls)node.className=cls;return node;};
|
|
9
11
|
const button=(text,action)=>{const node=el('button',text);node.type='button';node.onclick=()=>{try{action();}catch(error){showError(error.message);onError?.(error);}};return node;};
|
|
@@ -15,24 +17,27 @@ export function createSchemaInspector(container, {editor,path='',onDraft,onCommi
|
|
|
15
17
|
container.replaceChildren(nav,body,errorBox,actions);container.classList.add('opf-schema-inspector');
|
|
16
18
|
function showError(message){errorBox.textContent=message;}
|
|
17
19
|
function check(){
|
|
18
|
-
|
|
20
|
+
// The draft must be valid OPF (the format category, as every edit); unresolved references (an unknown variable, a missing asset) are listed as notes.
|
|
21
|
+
const report=validate(draft,{only:['format','references']});
|
|
22
|
+
const errors=report.findings.filter(finding=>finding.severity==='error'&&finding.category==='format'),warnings=report.findings.filter(finding=>finding.category!=='format'&&finding.severity!=='info');
|
|
23
|
+
const validation={valid:!errors.length};
|
|
19
24
|
apply.disabled=!dirty||!validation.valid;
|
|
20
|
-
const warningNote=
|
|
25
|
+
const warningNote=warnings.length?` · ${warnings.length} warning${warnings.length===1?'':'s'}`:'';
|
|
21
26
|
status.textContent=dirty?`Draft changes · apply to save${warningNote}`:`No pending changes${warningNote}`;
|
|
22
|
-
const issues=[...(validation.valid?[]:
|
|
27
|
+
const issues=[...(validation.valid?[]:errors.slice(0,3).map(error=>`${error.path||'/'}: ${error.message}`)),...warnings.slice(0,2).map(warning=>`${warning.path||'/'}: ${warning.message}`)];
|
|
23
28
|
showError(issues.join('\n'));
|
|
24
|
-
if(validation.valid)try{onDraft?.({
|
|
29
|
+
if(validation.valid)try{onDraft?.({presentation:structuredClone(draft),path:selected,dirty});}catch(error){showError(`Preview: ${error.message}`);onError?.(error);}
|
|
25
30
|
}
|
|
26
31
|
function mutate(operations,redraw=true){draft=applyJsonPatch(draft,operations);dirty=JSON.stringify(draft)!==base;if(redraw)render();check();}
|
|
27
32
|
function set(path,value,redraw=true){mutate(createValuePatch(draft,path,value),redraw);}
|
|
28
33
|
function navigate(path){selected=opfPathToJsonPointer(path);render();check();}
|
|
29
|
-
function reset(){base=JSON.stringify(editor.
|
|
34
|
+
function reset(){base=JSON.stringify(editor.presentation);draft=structuredClone(editor.presentation);dirty=false;render();check();onCancel?.();}
|
|
30
35
|
function commit(){
|
|
31
36
|
if(!dirty)return true;
|
|
32
|
-
if(JSON.stringify(editor.
|
|
33
|
-
const result=
|
|
37
|
+
if(JSON.stringify(editor.presentation)!==base){showError('The document changed elsewhere. Discard this draft and reopen the field to avoid overwriting newer changes.');return false;}
|
|
38
|
+
const result=checkFormat(draft);if(!result.valid){check();return false;}
|
|
34
39
|
suppress=true;
|
|
35
|
-
try{editor.applyPatch([{op:'replace',path:'',value:draft}],{source:'schema-inspector',rejectInvalid:true});base=JSON.stringify(editor.
|
|
40
|
+
try{editor.applyPatch([{op:'replace',path:'',value:draft}],{source:'schema-inspector',rejectInvalid:true});base=JSON.stringify(editor.presentation);draft=structuredClone(editor.presentation);dirty=false;render();check();onCommit?.({editor,path:selected});return true;}finally{suppress=false;}
|
|
36
41
|
}
|
|
37
42
|
function summary(value){return Array.isArray(value)?`${value.length} items`:value&&typeof value==='object'?`${Object.keys(value).length} fields`:String(value??'').slice(0,80);}
|
|
38
43
|
function render(){
|
|
@@ -98,5 +103,5 @@ export function createSchemaInspector(container, {editor,path='',onDraft,onCommi
|
|
|
98
103
|
}
|
|
99
104
|
const unsubscribe=editor.subscribe(()=>{if(suppress)return;if(dirty){showError('The document changed elsewhere. Discard the draft to load the latest version.');apply.disabled=true;}else reset();});
|
|
100
105
|
render();check();
|
|
101
|
-
return {navigate,commit,reset,get
|
|
106
|
+
return {navigate,commit,reset,get presentation(){return structuredClone(draft);},get dirty(){return dirty;},destroy(){destroyed=true;unsubscribe();container.replaceChildren();}};
|
|
102
107
|
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export declare function resolveSchema(schema: Schema, root?: Schema): Schema;
|
|
|
4
4
|
export declare function schemaVariants(schema: Schema, root?: Schema): Schema[];
|
|
5
5
|
export declare function schemaType(schema: Schema, value?: unknown): string;
|
|
6
6
|
export declare function activeSchema(schema: Schema, value: unknown, root?: Schema): Schema;
|
|
7
|
-
export declare function schemaAtPath(
|
|
7
|
+
export declare function schemaAtPath(presentation: unknown, path: string | string[], root?: Schema): Schema;
|
|
8
8
|
export declare function schemaLabel(schema: Schema, index?: number): string;
|
|
9
9
|
export declare function createSchemaValue(schema: Schema, root?: Schema): any;
|
|
10
10
|
export declare function listSchemaFields(): Array<{schema:string;path:string;name:string;description:string;reference:string|null;type:string|null;values:unknown[]|null}>;
|
package/dist/schema.js
CHANGED
|
@@ -3,9 +3,22 @@ import { getValueAtPath, splitOpfPath, opfPathToJsonPointer } from './index.js';
|
|
|
3
3
|
export const opfSchemas = schemas;
|
|
4
4
|
const documents = new Map(Object.values(schemas).map(schema => [schema.$id, schema]));
|
|
5
5
|
const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
|
|
6
|
+
// An allOf of shaping members (the SlideDesign definition is Design plus a `not` constraint) reads as one schema: members are
|
|
7
|
+
// merged, properties included; constraint-only members (`not`, `required`) shape nothing a picker can show and are dropped.
|
|
8
|
+
function mergeAllOf(schema, root, seen) {
|
|
9
|
+
if (!Array.isArray(schema.allOf)) return schema;
|
|
10
|
+
const {allOf, ...base} = schema;
|
|
11
|
+
let merged = base;
|
|
12
|
+
for (const member of allOf) {
|
|
13
|
+
const resolved = resolveSchema(member, root, new Set(seen));
|
|
14
|
+
if (!resolved.properties && !resolved.type && !resolved.oneOf && !resolved.anyOf) continue;
|
|
15
|
+
merged = {...resolved, ...merged, ...(resolved.properties || merged.properties ? {properties: {...resolved.properties, ...merged.properties}} : {})};
|
|
16
|
+
}
|
|
17
|
+
return merged;
|
|
18
|
+
}
|
|
6
19
|
export function resolveSchema(schema, root = schemas.presentation, seen = new Set()) {
|
|
7
20
|
if (!schema || typeof schema !== 'object') return {};
|
|
8
|
-
if (!schema.$ref) return schema;
|
|
21
|
+
if (!schema.$ref) return mergeAllOf(schema, root, seen);
|
|
9
22
|
const ref = schema.$ref;
|
|
10
23
|
if (seen.has(ref)) return {};
|
|
11
24
|
seen.add(ref);
|
|
@@ -15,7 +28,7 @@ export function resolveSchema(schema, root = schemas.presentation, seen = new Se
|
|
|
15
28
|
const resolved = fragment ? getValueAtPath(target, fragment) : target;
|
|
16
29
|
if (!resolved) throw new Error(`Unresolved OPF schema: ${ref}`);
|
|
17
30
|
const { $ref, ...rest } = schema;
|
|
18
|
-
return {...resolveSchema(resolved, target, seen), ...rest};
|
|
31
|
+
return mergeAllOf({...resolveSchema(resolved, target, seen), ...rest}, target, seen);
|
|
19
32
|
}
|
|
20
33
|
export function schemaVariants(schema, root = schemas.presentation) {
|
|
21
34
|
const resolved = resolveSchema(schema, root);
|
|
@@ -23,7 +36,14 @@ export function schemaVariants(schema, root = schemas.presentation) {
|
|
|
23
36
|
const variants = resolved.oneOf ?? resolved.anyOf;
|
|
24
37
|
if (!variants) return [resolved];
|
|
25
38
|
const {$ref, oneOf, anyOf, ...base} = resolved;
|
|
26
|
-
return variants.flatMap(child => schemaVariants(child, root).map(variant =>
|
|
39
|
+
return variants.flatMap(child => schemaVariants(child, root).map(variant => {
|
|
40
|
+
// A branch that only names required fields (Watermark: exactly one of src and text) adds to the base's required list and is
|
|
41
|
+
// labelled by them, so the generic form offers "src" and "text" as two forms of one object.
|
|
42
|
+
const requiredOnly = !variant.type && !variant.properties && !variant.$ref && Array.isArray(variant.required);
|
|
43
|
+
return {...base, ...variant, properties: base.properties || variant.properties ? {...base.properties,...variant.properties} : undefined,
|
|
44
|
+
...(base.required || variant.required ? {required: [...new Set([...(base.required ?? []), ...(variant.required ?? [])])]} : {}),
|
|
45
|
+
...(requiredOnly && !variant.title ? {title: variant.required.join(' + ')} : {})};
|
|
46
|
+
}));
|
|
27
47
|
}
|
|
28
48
|
export function schemaType(schema, value) {
|
|
29
49
|
return schema.type ?? (schema.properties || schema.additionalProperties ? 'object' : schema.items ? 'array' : schema.const !== undefined ? typeof schema.const : value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value === 'undefined' ? 'string' : typeof value);
|
|
@@ -45,8 +65,8 @@ function score(schema, value) {
|
|
|
45
65
|
export function activeSchema(schema, value, root=schemas.presentation) {
|
|
46
66
|
return schemaVariants(schema,root).sort((a,b)=>score(b,value)-score(a,value))[0] ?? {};
|
|
47
67
|
}
|
|
48
|
-
export function schemaAtPath(
|
|
49
|
-
const parts=splitOpfPath(path);let current=root,value=
|
|
68
|
+
export function schemaAtPath(presentation, path, root = schemas.presentation) {
|
|
69
|
+
const parts=splitOpfPath(path);let current=root,value=presentation;
|
|
50
70
|
for(let index=0; index<parts.length;index++) {
|
|
51
71
|
const key=parts[index], resolved=activeSchema(current,value,root);
|
|
52
72
|
if(parts[0]==='catalogs' && parts[2]==='records' && index===3) current=schemas[catalogSchemaNames[parts[1]]]??{};
|
package/dist/slide-manager.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export interface SlideManagerOptions {
|
|
|
12
12
|
/** Called when the user chooses or moves to a slide; the host shows it and calls `render()`. */
|
|
13
13
|
setSlideIndex: (index: number) => void;
|
|
14
14
|
/** The thumbnail of one slide as an HTML string (usually the renderer's SVG). Cache it: it is called for every card on every render. */
|
|
15
|
-
renderThumbnail?: (
|
|
15
|
+
renderThumbnail?: (presentation: unknown, index: number) => string;
|
|
16
16
|
/** `"navigator"` (a vertical list, the default) or `"sorter"` (a grid with arrow keys in both directions). */
|
|
17
17
|
variant?: "navigator" | "sorter";
|
|
18
18
|
/** An element to fill with the Duplicate, Delete, Hide and More buttons. */
|
|
@@ -20,7 +20,7 @@ export interface SlideManagerOptions {
|
|
|
20
20
|
/** Show Split and Merge in the slide menu. */
|
|
21
21
|
contentActions?: SlideContentActions;
|
|
22
22
|
/** Layout choices for "Add slide with layout" (default: the layouts catalog, via `listSwitchOptions`). */
|
|
23
|
-
layoutOptions?: (
|
|
23
|
+
layoutOptions?: (presentation: unknown) => { id: string; label: string; record?: Record<string, any> }[];
|
|
24
24
|
/** Every message the manager announces in its live region, for a visual status line. */
|
|
25
25
|
onStatus?: (message: string) => void;
|
|
26
26
|
onError?: (error: unknown) => void;
|
package/dist/slide-manager.js
CHANGED
|
@@ -52,9 +52,9 @@ const isMac = () => /Mac|iPhone|iPad/.test(globalThis.navigator?.platform ?? "")
|
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
54
|
* Mount the slide list into `container` (a `nav` or `div`; one `.slide-card` button per slide, with section headers between when the deck has
|
|
55
|
-
* sections). Options: `editor`, `getSlideIndex()`, `setSlideIndex(index)`, `renderThumbnail(
|
|
55
|
+
* sections). Options: `editor`, `getSlideIndex()`, `setSlideIndex(index)`, `renderThumbnail(presentation, index)` (an HTML string), `variant`
|
|
56
56
|
* (`"navigator"` or `"sorter"`), `toolbar` (an element to fill with the action buttons), `contentActions` (the RR-26 split and merge
|
|
57
|
-
* functions: `{ splitSlideByBlocks, mergeSlides }`), `layoutOptions(
|
|
57
|
+
* functions: `{ splitSlideByBlocks, mergeSlides }`), `layoutOptions(presentation)` (layout choices for "Add slide with layout"), `onStatus(message)`
|
|
58
58
|
* `autoRender` (false to skip the first draw) and `onError(error)`. Returns `{ render, getSelection, setSelection, openLayoutPicker, destroy }`.
|
|
59
59
|
*/
|
|
60
60
|
export function createSlideManager(container, options) {
|
|
@@ -79,7 +79,7 @@ export function createSlideManager(container, options) {
|
|
|
79
79
|
: "Up and Down arrows move between slides. Shift plus arrow keys extend the selection. Alt plus Up or Down moves the selected slides. Delete removes them. Press the context menu key for more actions." });
|
|
80
80
|
container.after(hint);
|
|
81
81
|
|
|
82
|
-
const deck = () => editor.
|
|
82
|
+
const deck = () => editor.presentation;
|
|
83
83
|
const count = () => deck().slides.length;
|
|
84
84
|
const current = () => Math.max(0, Math.min(count() - 1, options.getSlideIndex()));
|
|
85
85
|
const announce = (message) => {
|
|
@@ -107,9 +107,9 @@ export function createSlideManager(container, options) {
|
|
|
107
107
|
return `${occurrence}:${section.name ?? ""}`;
|
|
108
108
|
}
|
|
109
109
|
function render() {
|
|
110
|
-
const
|
|
111
|
-
const sections = hasSections(
|
|
112
|
-
const slideCount =
|
|
110
|
+
const presentation = deck();
|
|
111
|
+
const sections = hasSections(presentation) ? listSections(presentation) : [];
|
|
112
|
+
const slideCount = presentation.slides.length;
|
|
113
113
|
selection = new Set([...selection].filter((index) => index < slideCount));
|
|
114
114
|
selection.add(current());
|
|
115
115
|
if (selection.size === 1) anchor = current();
|
|
@@ -122,19 +122,19 @@ export function createSlideManager(container, options) {
|
|
|
122
122
|
const nodes = [];
|
|
123
123
|
const sectionAtStart = new Map(sections.map((section) => [section.start, section]));
|
|
124
124
|
const sectionOfIndex = (index) => sections.find((section) => index >= section.start && index < section.start + section.count);
|
|
125
|
-
|
|
125
|
+
presentation.slides.forEach((slide, index) => {
|
|
126
126
|
const section = sectionAtStart.get(index);
|
|
127
127
|
if (section) nodes.push(sectionHeader(sections, section));
|
|
128
128
|
const owner = sectionOfIndex(index);
|
|
129
129
|
if (owner && collapsed.has(sectionKey(sections, owner))) return;
|
|
130
|
-
nodes.push(card(
|
|
130
|
+
nodes.push(card(presentation, slide, index, slideCount));
|
|
131
131
|
});
|
|
132
132
|
container.replaceChildren(...nodes);
|
|
133
133
|
container.setAttribute("aria-label", variant === "sorter" ? "Slide sorter" : "Slides");
|
|
134
134
|
if (hadFocus) container.querySelector(`.slide-card[data-index="${focusIndex}"]`)?.focus({ preventScroll: false });
|
|
135
135
|
renderToolbar();
|
|
136
136
|
}
|
|
137
|
-
function card(
|
|
137
|
+
function card(presentation, slide, index, slideCount) {
|
|
138
138
|
const selected = selection.has(index);
|
|
139
139
|
const title = slideTitle(slide) || "Untitled slide";
|
|
140
140
|
const hidden = slide.hidden === true;
|
|
@@ -150,7 +150,7 @@ export function createSlideManager(container, options) {
|
|
|
150
150
|
const number = el("span", { class: "thumbnail-number", draggable: "true", text: String(index + 1).padStart(2, "0") });
|
|
151
151
|
const thumbnail = el("span", { class: "thumbnail", "aria-hidden": "true" });
|
|
152
152
|
let html = "";
|
|
153
|
-
try { html = options.renderThumbnail?.(
|
|
153
|
+
try { html = options.renderThumbnail?.(presentation, index) ?? ""; } catch { html = "Preview unavailable"; }
|
|
154
154
|
thumbnail.innerHTML = html;
|
|
155
155
|
thumbnail.querySelectorAll("[tabindex]").forEach((node) => node.removeAttribute("tabindex"));
|
|
156
156
|
const label = el("span", { class: "thumbnail-title", text: slideTitle(slide) || "Untitled slide" });
|
|
@@ -281,9 +281,9 @@ export function createSlideManager(container, options) {
|
|
|
281
281
|
} catch (error) { report(error); }
|
|
282
282
|
}
|
|
283
283
|
function sectionNote(index) {
|
|
284
|
-
const
|
|
285
|
-
if (!hasSections(
|
|
286
|
-
const name =
|
|
284
|
+
const presentation = deck();
|
|
285
|
+
if (!hasSections(presentation)) return "";
|
|
286
|
+
const name = presentation.slides[index].section;
|
|
287
287
|
return name ? `, in section ${name}` : ", with no section";
|
|
288
288
|
}
|
|
289
289
|
function moveTo(indices, edge) {
|
|
@@ -339,8 +339,8 @@ export function createSlideManager(container, options) {
|
|
|
339
339
|
} catch (error) { report(error); }
|
|
340
340
|
}
|
|
341
341
|
async function sectionPrompt(index) {
|
|
342
|
-
const
|
|
343
|
-
const hasAny = hasSections(
|
|
342
|
+
const presentation = deck();
|
|
343
|
+
const hasAny = hasSections(presentation);
|
|
344
344
|
const name = await ask("Add section", "Section name", hasAny ? "" : "Section 1", `The section starts at slide ${index + 1}.`);
|
|
345
345
|
if (name === null) { focusCard(index); return; }
|
|
346
346
|
try {
|
|
@@ -380,9 +380,9 @@ export function createSlideManager(container, options) {
|
|
|
380
380
|
|
|
381
381
|
function slideMenu() {
|
|
382
382
|
const list = selectedList();
|
|
383
|
-
const
|
|
384
|
-
const sections = hasSections(
|
|
385
|
-
const everyHidden = list.every((index) =>
|
|
383
|
+
const presentation = deck();
|
|
384
|
+
const sections = hasSections(presentation) ? listSections(presentation) : [];
|
|
385
|
+
const everyHidden = list.every((index) => presentation.slides[index].hidden === true);
|
|
386
386
|
const first = list[0];
|
|
387
387
|
const items = [
|
|
388
388
|
{ label: list.length > 1 ? `Duplicate ${list.length} slides` : "Duplicate slide", shortcut: `${isMac() ? "⌘" : "Ctrl"}+D`, run: () => act("duplicate") },
|