@openpresentation/opf-editor 0.10.6 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +333 -8
- package/dist/annotations.d.ts +71 -0
- package/dist/annotations.js +281 -0
- package/dist/assets.d.ts +67 -0
- package/dist/assets.js +176 -0
- package/dist/background-options.d.ts +48 -0
- package/dist/background-options.js +134 -0
- package/dist/block-convert.d.ts +64 -0
- package/dist/block-convert.js +142 -0
- package/dist/canvas.d.ts +16 -0
- package/dist/canvas.js +82 -21
- package/dist/chart-data.d.ts +32 -0
- package/dist/chart-data.js +101 -0
- package/dist/chart-options-panel.d.ts +16 -0
- package/dist/chart-options-panel.js +127 -0
- package/dist/chart-options.d.ts +49 -0
- package/dist/chart-options.js +157 -0
- package/dist/content-actions.d.ts +91 -0
- package/dist/content-actions.js +207 -0
- package/dist/content-controls.js +326 -0
- package/dist/data-grid.d.ts +37 -0
- package/dist/data-grid.js +1035 -0
- package/dist/design-controls.d.ts +43 -0
- package/dist/design-controls.js +1077 -0
- package/dist/design-options.d.ts +108 -0
- package/dist/design-options.js +412 -0
- package/dist/edit-helpers.js +52 -0
- package/dist/export.d.ts +77 -0
- package/dist/export.js +216 -0
- package/dist/find-panel.d.ts +44 -0
- package/dist/find-panel.js +431 -0
- package/dist/find-replace.d.ts +100 -0
- package/dist/find-replace.js +374 -0
- package/dist/grid-model.d.ts +135 -0
- package/dist/grid-model.js +836 -0
- package/dist/grid-text.d.ts +33 -0
- package/dist/grid-text.js +251 -0
- package/dist/image-crop.d.ts +59 -0
- package/dist/image-crop.js +336 -0
- package/dist/image-cropper.d.ts +29 -0
- package/dist/image-cropper.js +519 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +104 -171
- package/dist/numbering-panel.d.ts +21 -0
- package/dist/numbering-panel.js +200 -0
- package/dist/numbering.d.ts +62 -0
- package/dist/numbering.js +223 -0
- package/dist/outline-view.d.ts +17 -0
- package/dist/outline-view.js +278 -0
- package/dist/outline.d.ts +56 -0
- package/dist/outline.js +271 -0
- package/dist/persistence-ui.d.ts +24 -0
- package/dist/persistence-ui.js +81 -0
- package/dist/persistence.d.ts +105 -0
- package/dist/persistence.js +429 -0
- package/dist/review-panel.d.ts +44 -0
- package/dist/review-panel.js +359 -0
- package/dist/review.d.ts +75 -0
- package/dist/review.js +170 -0
- package/dist/slide-manager.d.ts +44 -0
- package/dist/slide-manager.js +695 -0
- package/dist/slides.d.ts +96 -0
- package/dist/slides.js +433 -0
- package/dist/switches.d.ts +26 -0
- package/dist/switches.js +127 -43
- package/dist/table-options.d.ts +80 -0
- package/dist/table-options.js +419 -0
- package/dist/table-structure.d.ts +30 -0
- package/dist/table-structure.js +92 -0
- package/dist/template-panel.d.ts +31 -0
- package/dist/template-panel.js +377 -0
- package/dist/templates.d.ts +126 -0
- package/dist/templates.js +331 -0
- package/dist/zip.d.ts +4 -0
- package/dist/zip.js +71 -0
- package/package.json +150 -10
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Content actions (RR-26): list levels, grouping, regions, images between content and design, and slide
|
|
2
|
+
// split and merge, as editor transactions. The pure transforms live in core (`@openpresentation/opf/convert`);
|
|
3
|
+
// each action here finds its target in the document, calls the transform, turns the result into a guarded patch
|
|
4
|
+
// (`test` of what was read, then `replace`, or per-slide `remove` and `add`), validates the document and, in the
|
|
5
|
+
// apply form, applies it as ONE undoable step. Every prepare function returns `{ document, patches, changed,
|
|
6
|
+
// lossless, loss, path }` without touching a session; a refusal throws `content-action-refused`.
|
|
7
|
+
import {
|
|
8
|
+
OPFConversionError,
|
|
9
|
+
blocksToRegions,
|
|
10
|
+
demoteImage,
|
|
11
|
+
mergeSlides as mergeSlidesCore,
|
|
12
|
+
moveRegion,
|
|
13
|
+
promoteImage,
|
|
14
|
+
regionsToBlocks,
|
|
15
|
+
shiftListLevels,
|
|
16
|
+
splitSlide,
|
|
17
|
+
splitSlideOnOverflow as splitSlideOnOverflowCore,
|
|
18
|
+
unpaginate,
|
|
19
|
+
unwrapGroup,
|
|
20
|
+
wrapBlocks,
|
|
21
|
+
} from "@openpresentation/opf/convert";
|
|
22
|
+
import { applyJsonPatch, getValueAtPath, opfPathToJsonPointer, splitOpfPath, validateOpfDocument } from "./index.js";
|
|
23
|
+
import { readBlockContent } from "./block-convert.js";
|
|
24
|
+
import { checkedDocument, fail } from "./edit-helpers.js";
|
|
25
|
+
|
|
26
|
+
const refuse = (message, details) => fail("content-action-refused", message, details);
|
|
27
|
+
|
|
28
|
+
/** Run a core transform; a refusal becomes the editor's `content-action-refused`. */
|
|
29
|
+
function core(action) {
|
|
30
|
+
try {
|
|
31
|
+
return action();
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error instanceof OPFConversionError) throw refuse(error.message, { ...error.details, coreCode: error.code });
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function requireEditor(editor) {
|
|
39
|
+
if (!editor || typeof editor.applyPatch !== "function") throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function slideParts(document, path, what) {
|
|
43
|
+
let parts;
|
|
44
|
+
try {
|
|
45
|
+
parts = splitOpfPath(path);
|
|
46
|
+
} catch {
|
|
47
|
+
parts = undefined;
|
|
48
|
+
}
|
|
49
|
+
if (!parts || parts[0] !== "slides" || !/^(0|[1-9][0-9]*)$/.test(parts[1] ?? "") || !document?.slides?.[Number(parts[1])]) throw refuse(`Choose ${what} on a slide.`, { path });
|
|
50
|
+
return { slideIndex: Number(parts[1]), relative: parts.slice(2), parts };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const asNumbers = (path) => path.map((part) => (/^(0|[1-9][0-9]*)$/.test(String(part)) ? Number(part) : part));
|
|
54
|
+
const toDocumentPath = (slideIndex, relative) => ["slides", String(slideIndex), ...(relative ?? []).map(String)].join(".");
|
|
55
|
+
|
|
56
|
+
/** Replace one value (a slide, a block, a list) as `test` + `replace`, validated like every other edit. */
|
|
57
|
+
function replacePatches(document, parts, next) {
|
|
58
|
+
const pointer = opfPathToJsonPointer(parts);
|
|
59
|
+
return [
|
|
60
|
+
{ op: "test", path: pointer, value: structuredClone(getValueAtPath(document, parts)) },
|
|
61
|
+
{ op: "replace", path: pointer, value: next },
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
// `validate: false` skips the whole-document check, for a dry run that only needs the loss report (core has already
|
|
65
|
+
// validated the changed slide); applying always validates.
|
|
66
|
+
function finish(document, patches, extra, validate = true) {
|
|
67
|
+
if (!validate) return { document: applyJsonPatch(document, patches), patches, ...extra };
|
|
68
|
+
const before = validateOpfDocument(document);
|
|
69
|
+
return { document: checkedDocument(document, patches, before), patches, ...extra };
|
|
70
|
+
}
|
|
71
|
+
function apply(editor, prepared, meta, source) {
|
|
72
|
+
if (!prepared.changed) return { ...prepared, document: editor.document, patches: [], inversePatches: [], validation: editor.validation };
|
|
73
|
+
const change = editor.applyPatch(prepared.patches, { ...meta, source: meta.source ?? source, path: prepared.path });
|
|
74
|
+
const extra = Object.fromEntries(["levels", "range", "slideCount", "pages"].filter((key) => prepared[key] !== undefined).map((key) => [key, prepared[key]]));
|
|
75
|
+
return { ...change, changed: true, lossless: prepared.lossless, loss: prepared.loss, path: prepared.path, ...extra };
|
|
76
|
+
}
|
|
77
|
+
const unchanged = (document, path, reason) => ({ document: structuredClone(document), patches: [], path, changed: false, lossless: true, loss: [], ...(reason ? { reason } : {}) });
|
|
78
|
+
|
|
79
|
+
// --- list levels -------------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
/** The index of the list item a selection points at (`slides.0.blocks.1.items.2.text` gives 2), or undefined. */
|
|
82
|
+
export function listItemIndexForSelection(selectedPath, blockPath) {
|
|
83
|
+
if (typeof selectedPath !== "string" || typeof blockPath !== "string") return undefined;
|
|
84
|
+
const match = new RegExp(`^${blockPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\.items|\\.bullets)\\.(0|[1-9][0-9]*)(?:\\.|$)`).exec(selectedPath);
|
|
85
|
+
return match ? Number(match[1]) : undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Compute the patch that moves the list items at `indices` of the list block at `blockPath` one level in
|
|
90
|
+
* (`delta` 1, Tab) or out (`delta` -1, Shift+Tab). A level never exceeds one more than the item above, the first
|
|
91
|
+
* item cannot be nested, and items under a moved item move with it (`options.withChildren`, default true).
|
|
92
|
+
* `changed` is false, with a `reason`, when nothing can move.
|
|
93
|
+
*/
|
|
94
|
+
export function prepareListShift(document, blockPath, indices, delta, options = {}) {
|
|
95
|
+
const found = readBlockContent(document, blockPath);
|
|
96
|
+
if (!found || found.kind !== "list") throw refuse("Choose a list block.", { path: blockPath });
|
|
97
|
+
const result = core(() => shiftListLevels(found.owner, indices, delta, options));
|
|
98
|
+
const path = found.path.join(".");
|
|
99
|
+
if (!result.changed) return unchanged(document, path, result.reason);
|
|
100
|
+
return finish(document, replacePatches(document, found.path, result.payload), { path, changed: true, lossless: result.lossless, loss: result.loss, levels: result.levels }, options.validate !== false);
|
|
101
|
+
}
|
|
102
|
+
/** Nest or un-nest list items as one undoable step. Returns the session change plus `changed`, `reason`, `levels`, `lossless` and `loss`. */
|
|
103
|
+
export function shiftListItems(editor, blockPath, indices, delta, options = {}, meta = {}) {
|
|
104
|
+
requireEditor(editor);
|
|
105
|
+
return apply(editor, prepareListShift(editor.document, blockPath, indices, delta, options), meta, "list-level");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- structure inside one slide ----------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
/** One slide-level transform as a guarded replace of that slide. */
|
|
111
|
+
function slideAction(document, slideIndex, transform, targetPath, validate = true) {
|
|
112
|
+
const slide = document.slides[slideIndex];
|
|
113
|
+
const result = core(() => transform(slide));
|
|
114
|
+
const path = result.path ? toDocumentPath(slideIndex, result.path) : (targetPath ?? `slides.${slideIndex}`);
|
|
115
|
+
if (!result.changed) return unchanged(document, path);
|
|
116
|
+
const patches = replacePatches(document, ["slides", String(slideIndex)], result.slide);
|
|
117
|
+
return finish(document, patches, { path, changed: true, lossless: result.lossless, loss: result.loss }, validate);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Wrap the blocks at `indices` of the container at `containerPath` (a slide `slides.0`, a group or a region group) in a new group. */
|
|
121
|
+
export function prepareGroupBlocks(document, containerPath, indices, options = {}) {
|
|
122
|
+
const { slideIndex, relative } = slideParts(document, containerPath, "a container");
|
|
123
|
+
return slideAction(document, slideIndex, (slide) => wrapBlocks(slide, indices, { container: asNumbers(relative), ...(options.composition ? { composition: options.composition } : {}) }), undefined, options.validate !== false);
|
|
124
|
+
}
|
|
125
|
+
/** Replace the group at `groupPath` by its blocks. What the group carried (composition, id) is reported in `loss`. */
|
|
126
|
+
export function prepareUngroupBlock(document, groupPath, options = {}) {
|
|
127
|
+
const { slideIndex, relative } = slideParts(document, groupPath, "a group");
|
|
128
|
+
return slideAction(document, slideIndex, (slide) => unwrapGroup(slide, asNumbers(relative)), undefined, options.validate !== false);
|
|
129
|
+
}
|
|
130
|
+
/** Place every block of a slide in its own named region: `regions[i]` is block i's region (`left`, `right`, `top:left`, ...). */
|
|
131
|
+
export function prepareBlocksToRegions(document, slideIndex, regions, options = {}) {
|
|
132
|
+
slideParts(document, `slides.${slideIndex}`, "a slide");
|
|
133
|
+
return slideAction(document, slideIndex, (slide) => blocksToRegions(slide, regions), undefined, options.validate !== false);
|
|
134
|
+
}
|
|
135
|
+
/** Turn the named regions of a slide into ordinary blocks in reading order. The regions' placement is reported in `loss`. */
|
|
136
|
+
export function prepareRegionsToBlocks(document, slideIndex, options = {}) {
|
|
137
|
+
slideParts(document, `slides.${slideIndex}`, "a slide");
|
|
138
|
+
return slideAction(document, slideIndex, (slide) => regionsToBlocks(slide), undefined, options.validate !== false);
|
|
139
|
+
}
|
|
140
|
+
/** Move the content of region `from` to region `to`, or swap them when `to` is taken and `options.swap` is true. */
|
|
141
|
+
export function prepareMoveRegion(document, slideIndex, from, to, options = {}) {
|
|
142
|
+
slideParts(document, `slides.${slideIndex}`, "a slide");
|
|
143
|
+
return slideAction(document, slideIndex, (slide) => moveRegion(slide, from, to, options), undefined, options.validate !== false);
|
|
144
|
+
}
|
|
145
|
+
/** Move the image block at `blockPath` into the slide's design as `slideImage`, `background` or `watermark`. */
|
|
146
|
+
export function prepareImageToDesign(document, blockPath, target, options = {}) {
|
|
147
|
+
const { slideIndex, relative } = slideParts(document, blockPath, "an image block");
|
|
148
|
+
return slideAction(document, slideIndex, (slide) => promoteImage(slide, asNumbers(relative), target, options), `slides.${slideIndex}`, options.validate !== false);
|
|
149
|
+
}
|
|
150
|
+
/** Move a slide's own `slideImage`, `background` image or `watermark` back into its content as an image block. */
|
|
151
|
+
export function prepareImageToContent(document, slideIndex, source, options = {}) {
|
|
152
|
+
slideParts(document, `slides.${slideIndex}`, "a slide");
|
|
153
|
+
return slideAction(document, slideIndex, (slide) => demoteImage(slide, source, options), undefined, options.validate !== false);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export const groupBlocks = (editor, containerPath, indices, options = {}, meta = {}) => (requireEditor(editor), apply(editor, prepareGroupBlocks(editor.document, containerPath, indices, options), meta, "group-blocks"));
|
|
157
|
+
export const ungroupBlock = (editor, groupPath, meta = {}) => (requireEditor(editor), apply(editor, prepareUngroupBlock(editor.document, groupPath), meta, "ungroup-block"));
|
|
158
|
+
export const placeBlocksInRegions = (editor, slideIndex, regions, meta = {}) => (requireEditor(editor), apply(editor, prepareBlocksToRegions(editor.document, slideIndex, regions), meta, "blocks-to-regions"));
|
|
159
|
+
export const regionsAsBlocks = (editor, slideIndex, meta = {}) => (requireEditor(editor), apply(editor, prepareRegionsToBlocks(editor.document, slideIndex), meta, "regions-to-blocks"));
|
|
160
|
+
export const moveSlideRegion = (editor, slideIndex, from, to, options = {}, meta = {}) => (requireEditor(editor), apply(editor, prepareMoveRegion(editor.document, slideIndex, from, to, options), meta, "move-region"));
|
|
161
|
+
export const moveImageToDesign = (editor, blockPath, target, options = {}, meta = {}) => (requireEditor(editor), apply(editor, prepareImageToDesign(editor.document, blockPath, target, options), meta, "image-to-design"));
|
|
162
|
+
export const moveImageToContent = (editor, slideIndex, source, options = {}, meta = {}) => (requireEditor(editor), apply(editor, prepareImageToContent(editor.document, slideIndex, source, options), meta, "image-to-content"));
|
|
163
|
+
|
|
164
|
+
// --- slides ------------------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Per-slide patches for replacing `range.deleteCount` slides from `range.start` by `slides`: a `test` for every
|
|
168
|
+
* replaced slide, a `replace` for each one that changed, `remove` for the surplus old slides and `add` for the
|
|
169
|
+
* surplus new ones, so a split or merge stays one guarded, undoable transaction without rewriting the other slides.
|
|
170
|
+
*/
|
|
171
|
+
function slidePatches(document, range, slides) {
|
|
172
|
+
const old = document.slides.slice(range.start, range.start + range.deleteCount);
|
|
173
|
+
const patches = old.map((slide, offset) => ({ op: "test", path: `/slides/${range.start + offset}`, value: structuredClone(slide) }));
|
|
174
|
+
const common = Math.min(old.length, slides.length);
|
|
175
|
+
for (let offset = 0; offset < common; offset++) if (JSON.stringify(old[offset]) !== JSON.stringify(slides[offset])) patches.push({ op: "replace", path: `/slides/${range.start + offset}`, value: structuredClone(slides[offset]) });
|
|
176
|
+
for (let offset = old.length - 1; offset >= common; offset--) patches.push({ op: "remove", path: `/slides/${range.start + offset}` });
|
|
177
|
+
for (let offset = common; offset < slides.length; offset++) patches.push({ op: "add", path: `/slides/${range.start + offset}`, value: structuredClone(slides[offset]) });
|
|
178
|
+
return patches;
|
|
179
|
+
}
|
|
180
|
+
function slidesAction(document, result, extra = {}) {
|
|
181
|
+
const path = `slides.${result.range.start}`;
|
|
182
|
+
if (!result.changed) return unchanged(document, path, "Nothing to change.");
|
|
183
|
+
return finish(document, slidePatches(document, result.range, result.slides), { path, changed: true, lossless: result.lossless, loss: result.loss, range: result.range, slideCount: result.slides.length, ...extra });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Split a slide by its blocks (`options.at`: block indexes where a new slide starts, or `options.each`). See core's `splitSlide`. */
|
|
187
|
+
export function prepareSplitSlide(document, slideIndex, options = {}) {
|
|
188
|
+
return slidesAction(document, core(() => splitSlide(document, slideIndex, options)));
|
|
189
|
+
}
|
|
190
|
+
/** Split a slide that overflows into pages with the existing pagination (`options`: text measurement, fonts, as for `paginatePresentation`). `pages` maps the new slides back for `prepareUnpaginate`. */
|
|
191
|
+
export function prepareSplitSlideOnOverflow(document, slideIndex, options = {}) {
|
|
192
|
+
const result = core(() => splitSlideOnOverflowCore(document, slideIndex, options));
|
|
193
|
+
return slidesAction(document, result, { pages: result.pages });
|
|
194
|
+
}
|
|
195
|
+
/** Merge `count` consecutive slides from `start`. The first slide's id, headings and design win; what is dropped is reported in `loss`. */
|
|
196
|
+
export function prepareMergeSlides(document, start, count = 2) {
|
|
197
|
+
return slidesAction(document, core(() => mergeSlidesCore(document, start, count)));
|
|
198
|
+
}
|
|
199
|
+
/** Put the continuation slides of a pagination back into one slide each. `pages` is the mapping `splitSlideOnOverflow` or `paginatePresentation` returned. */
|
|
200
|
+
export function prepareUnpaginate(document, pages, options = {}) {
|
|
201
|
+
return slidesAction(document, core(() => unpaginate(document, pages, options)));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export const splitSlideByBlocks = (editor, slideIndex, options = {}, meta = {}) => (requireEditor(editor), apply(editor, prepareSplitSlide(editor.document, slideIndex, options), meta, "split-slide"));
|
|
205
|
+
export const splitSlideOnOverflow = (editor, slideIndex, options = {}, meta = {}) => (requireEditor(editor), apply(editor, prepareSplitSlideOnOverflow(editor.document, slideIndex, options), meta, "split-slide-overflow"));
|
|
206
|
+
export const mergeSlides = (editor, start, count = 2, meta = {}) => (requireEditor(editor), apply(editor, prepareMergeSlides(editor.document, start, count), meta, "merge-slides"));
|
|
207
|
+
export const unpaginateSlides = (editor, pages, options = {}, meta = {}) => (requireEditor(editor), apply(editor, prepareUnpaginate(editor.document, pages, options), meta, "unpaginate-slides"));
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// Content controls (RR-26): the DOM controls for the content actions, mounted by `createDesignControls` in its
|
|
2
|
+
// "Selected content" and "Slide structure" sections. Each action is a native button or select with a visible label;
|
|
3
|
+
// it shows what the change would not carry over BEFORE it is applied (a dry run through the same prepare function the
|
|
4
|
+
// action uses), is disabled with its reason when it does not apply, and commits one undoable change through the
|
|
5
|
+
// session. Nothing here owns document state: `sync` re-reads the session after every change, Undo and Redo included.
|
|
6
|
+
import { BLOCK_KIND_LABELS, blockConversionTargets, convertBlock, metricGroupForSelection } from "./block-convert.js";
|
|
7
|
+
import {
|
|
8
|
+
listItemIndexForSelection,
|
|
9
|
+
moveImageToContent,
|
|
10
|
+
moveImageToDesign,
|
|
11
|
+
placeBlocksInRegions,
|
|
12
|
+
prepareBlocksToRegions,
|
|
13
|
+
prepareGroupBlocks,
|
|
14
|
+
prepareImageToContent,
|
|
15
|
+
prepareImageToDesign,
|
|
16
|
+
prepareListShift,
|
|
17
|
+
prepareRegionsToBlocks,
|
|
18
|
+
prepareUngroupBlock,
|
|
19
|
+
regionsAsBlocks,
|
|
20
|
+
groupBlocks,
|
|
21
|
+
shiftListItems,
|
|
22
|
+
ungroupBlock,
|
|
23
|
+
} from "./content-actions.js";
|
|
24
|
+
|
|
25
|
+
/** Region layouts offered for a slide with this many blocks (each block gets its own region, none overlap). */
|
|
26
|
+
export const REGION_LAYOUTS = Object.freeze({
|
|
27
|
+
2: [
|
|
28
|
+
{ id: "left-right", label: "Side by side", regions: ["left", "right"] },
|
|
29
|
+
{ id: "top-bottom", label: "Stacked", regions: ["top", "bottom"] },
|
|
30
|
+
],
|
|
31
|
+
3: [
|
|
32
|
+
{ id: "three-columns", label: "Three columns", regions: ["left", "center", "right"] },
|
|
33
|
+
{ id: "top-two-columns", label: "One on top, two below", regions: ["top", "bottom:left", "bottom:right"] },
|
|
34
|
+
{ id: "left-two-rows", label: "One on the left, two on the right", regions: ["left", "top:right", "bottom:right"] },
|
|
35
|
+
],
|
|
36
|
+
4: [{ id: "grid", label: "Two by two", regions: ["top:left", "top:right", "bottom:left", "bottom:right"] }],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** Where an image block can go in the slide's design. The value is `<target>` or `<target>:<position>`. */
|
|
40
|
+
export const IMAGE_DESTINATIONS = Object.freeze([
|
|
41
|
+
{ value: "slideImage:right", label: "Slide image, on the right", target: "slideImage", options: { position: "right" } },
|
|
42
|
+
{ value: "slideImage:left", label: "Slide image, on the left", target: "slideImage", options: { position: "left" } },
|
|
43
|
+
{ value: "slideImage:top", label: "Slide image, along the top", target: "slideImage", options: { position: "top" } },
|
|
44
|
+
{ value: "slideImage:bottom", label: "Slide image, along the bottom", target: "slideImage", options: { position: "bottom" } },
|
|
45
|
+
{ value: "slideImage:background", label: "Slide image, full slide behind the content", target: "slideImage", options: { position: "background" } },
|
|
46
|
+
{ value: "background", label: "Slide background", target: "background", options: {} },
|
|
47
|
+
{ value: "watermark", label: "Watermark", target: "watermark", options: {} },
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const REGION_KEY = /^(?:(?:top|middle|bottom)(?:\+(?:top|middle|bottom))*(?::(?:left|center|right)(?:\+(?:left|center|right))*)?|(?:left|center|right)(?:\+(?:left|center|right))*)$/;
|
|
51
|
+
const regionKeysOfSlide = (slide) => Object.keys(slide).filter((key) => REGION_KEY.test(key));
|
|
52
|
+
const DESIGN_IMAGE_LABELS = { slideImage: "slide image", background: "background image", watermark: "watermark" };
|
|
53
|
+
const messageOf = (error) => error?.issues?.[0]?.message ?? error?.message ?? String(error);
|
|
54
|
+
const lossText = (prepared) => (prepared.loss.length ? `Not carried over: ${prepared.loss.join(", ")}.` : "Nothing is lost.");
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build the content controls. `ctx` supplies the panel's helpers: `editor`, `h` (element builder), `selectField`,
|
|
58
|
+
* `run(action, done)` (commit, report, restore on failure), `nextId`, `getSlide()` and `reselect(path)`.
|
|
59
|
+
* Returns the nodes to place and `sync(state)`, where `state` is `{ blockPath, content, selectedPath, slideIndex }`.
|
|
60
|
+
*/
|
|
61
|
+
export function createContentControls(ctx) {
|
|
62
|
+
const { editor, h, selectField, run, nextId, getSlide, reselect } = ctx;
|
|
63
|
+
const updaters = [];
|
|
64
|
+
const ctxState = { current: {} };
|
|
65
|
+
|
|
66
|
+
/** A button that shows the dry-run report under it before it is pressed and is disabled, with the reason, when it does not apply. */
|
|
67
|
+
function action(label, { prepare, apply, done, selectAfter, role }) {
|
|
68
|
+
const id = nextId("content-note");
|
|
69
|
+
const note = h("p", { class: "opf-dc-help", id, "data-role": `${role}-note` });
|
|
70
|
+
const button = h("button", { type: "button", class: "secondary", "aria-describedby": id, "data-role": role }, label);
|
|
71
|
+
button.addEventListener("click", () => {
|
|
72
|
+
run(
|
|
73
|
+
() => {
|
|
74
|
+
const change = apply();
|
|
75
|
+
if (selectAfter) reselect(selectAfter(change));
|
|
76
|
+
return change;
|
|
77
|
+
},
|
|
78
|
+
(change) => `${typeof done === "function" ? done(change) : done} ${lossText(change)}`,
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
const wrap = h("div", { class: "opf-dc-action" }, button, note);
|
|
82
|
+
let current = {};
|
|
83
|
+
const update = (state) => {
|
|
84
|
+
current = state;
|
|
85
|
+
let prepared;
|
|
86
|
+
try {
|
|
87
|
+
prepared = prepare(state);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
button.disabled = true;
|
|
90
|
+
note.textContent = messageOf(error);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
button.disabled = !prepared.changed;
|
|
94
|
+
note.textContent = prepared.changed ? lossText(prepared) : (prepared.reason ?? "");
|
|
95
|
+
};
|
|
96
|
+
return { wrap, update, state: () => current };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- list levels ---------------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
const list = h("div", { class: "opf-dc-actions-block", role: "group", "aria-label": "List levels", "data-role": "list-levels" });
|
|
102
|
+
{
|
|
103
|
+
const index = (state) => listItemIndexForSelection(state.selectedPath, state.blockPath);
|
|
104
|
+
const prepare = (delta) => (state) => {
|
|
105
|
+
const item = index(state);
|
|
106
|
+
if (item === undefined) return { changed: false, loss: [], reason: "Select a list item to change its level." };
|
|
107
|
+
return prepareListShift(editor.document, state.blockPath, [item], delta, { validate: false });
|
|
108
|
+
};
|
|
109
|
+
const apply = (delta) => () => {
|
|
110
|
+
const state = ctxState.current;
|
|
111
|
+
return shiftListItems(editor, state.blockPath, [index(state)], delta);
|
|
112
|
+
};
|
|
113
|
+
const indent = action("Indent item", { role: "indent", prepare: prepare(1), apply: apply(1), done: "Item moved in." });
|
|
114
|
+
const outdent = action("Outdent item", { role: "outdent", prepare: prepare(-1), apply: apply(-1), done: "Item moved out." });
|
|
115
|
+
list.append(h("p", { class: "opf-dc-help" }, "Select an item, then change its level. Items under it move with it."), indent.wrap, outdent.wrap);
|
|
116
|
+
updaters.push((state) => {
|
|
117
|
+
list.hidden = state.content?.kind !== "list";
|
|
118
|
+
if (!list.hidden) {
|
|
119
|
+
indent.update(state);
|
|
120
|
+
outdent.update(state);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- a group of metrics --------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
const metrics = selectField("metric-group", "Group of metrics", {
|
|
128
|
+
empty: "Convert the group to…",
|
|
129
|
+
help: "Every block in this group is a metric. Converting keeps their labels, values, units and deltas.",
|
|
130
|
+
onChange: (value) => {
|
|
131
|
+
if (!value) return;
|
|
132
|
+
const path = ctxState.current.metricGroupPath;
|
|
133
|
+
run(
|
|
134
|
+
() => convertBlock(editor, path, value),
|
|
135
|
+
(change) => `Converted the group to ${BLOCK_KIND_LABELS[value]?.toLowerCase() ?? value}. ${lossText(change)}`,
|
|
136
|
+
);
|
|
137
|
+
reselect(path);
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
updaters.push((state) => {
|
|
141
|
+
metrics.wrap.hidden = !state.metricGroupPath;
|
|
142
|
+
if (!state.metricGroupPath) return;
|
|
143
|
+
const targets = blockConversionTargets(editor.document, state.metricGroupPath);
|
|
144
|
+
metrics.setOptions(
|
|
145
|
+
targets.map((target) => ({
|
|
146
|
+
value: target.kind,
|
|
147
|
+
label: target.available ? `${target.label}${target.lossless ? "" : ` (loses ${target.loss.join(", ")})`}` : `${target.label} (unavailable)`,
|
|
148
|
+
disabled: !target.available,
|
|
149
|
+
title: target.reason,
|
|
150
|
+
})),
|
|
151
|
+
);
|
|
152
|
+
metrics.set("");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// --- arrange the selected block -------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
const arrange = h("div", { class: "opf-dc-actions-block", role: "group", "aria-label": "Arrange the selected block", "data-role": "arrange" });
|
|
158
|
+
{
|
|
159
|
+
const parts = (state) => state.blockPath?.split(".") ?? [];
|
|
160
|
+
const blockIndex = (state) => {
|
|
161
|
+
const found = parts(state);
|
|
162
|
+
return found.at(-2) === "blocks" && /^\d+$/.test(found.at(-1)) ? Number(found.at(-1)) : undefined;
|
|
163
|
+
};
|
|
164
|
+
const containerPath = (state) => parts(state).slice(0, -2).join(".");
|
|
165
|
+
const next = (state) => {
|
|
166
|
+
const index = blockIndex(state);
|
|
167
|
+
const blocks = index === undefined ? undefined : editor.get(`${containerPath(state)}.blocks`);
|
|
168
|
+
return Array.isArray(blocks) && index + 1 < blocks.length ? [index, index + 1] : undefined;
|
|
169
|
+
};
|
|
170
|
+
const group = action("Group with the next block", {
|
|
171
|
+
role: "group-next",
|
|
172
|
+
prepare: (state) => {
|
|
173
|
+
const indices = next(state);
|
|
174
|
+
if (!indices) return { changed: false, loss: [], reason: "There is no block after this one in its container." };
|
|
175
|
+
return prepareGroupBlocks(editor.document, containerPath(state), indices, { validate: false });
|
|
176
|
+
},
|
|
177
|
+
apply: () => {
|
|
178
|
+
const state = ctxState.current;
|
|
179
|
+
return groupBlocks(editor, containerPath(state), next(state));
|
|
180
|
+
},
|
|
181
|
+
selectAfter: (change) => change.path,
|
|
182
|
+
done: "Blocks grouped.",
|
|
183
|
+
});
|
|
184
|
+
const parentGroup = (state) => {
|
|
185
|
+
const found = parts(state);
|
|
186
|
+
return found.length >= 6 && found.at(-4) === "blocks" ? found.slice(0, -2).join(".") : undefined;
|
|
187
|
+
};
|
|
188
|
+
const ungroup = action("Ungroup these blocks", {
|
|
189
|
+
role: "ungroup",
|
|
190
|
+
prepare: (state) => {
|
|
191
|
+
const path = parentGroup(state);
|
|
192
|
+
if (!path) return { changed: false, loss: [], reason: "This block is not inside a group." };
|
|
193
|
+
return prepareUngroupBlock(editor.document, path, { validate: false });
|
|
194
|
+
},
|
|
195
|
+
apply: () => ungroupBlock(editor, parentGroup(ctxState.current)),
|
|
196
|
+
selectAfter: (change) => change.path,
|
|
197
|
+
done: "Group dissolved.",
|
|
198
|
+
});
|
|
199
|
+
const image = selectField("image-destination", "Use this image as", {
|
|
200
|
+
empty: "Choose where it goes…",
|
|
201
|
+
help: "Moves the image out of the content into the slide's design. Undo puts it back.",
|
|
202
|
+
onChange: (value) => {
|
|
203
|
+
const destination = IMAGE_DESTINATIONS.find((entry) => entry.value === value);
|
|
204
|
+
if (!destination) return;
|
|
205
|
+
const state = ctxState.current;
|
|
206
|
+
run(
|
|
207
|
+
() => moveImageToDesign(editor, state.blockPath, destination.target, destination.options),
|
|
208
|
+
(change) => `Image is now the ${destination.label.toLowerCase()}. ${lossText(change)}`,
|
|
209
|
+
);
|
|
210
|
+
reselect(`slides.${state.slideIndex}`);
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
arrange.append(group.wrap, ungroup.wrap, image.wrap);
|
|
214
|
+
updaters.push((state) => {
|
|
215
|
+
arrange.hidden = !state.content;
|
|
216
|
+
if (arrange.hidden) return;
|
|
217
|
+
group.update(state);
|
|
218
|
+
ungroup.update(state);
|
|
219
|
+
const isImage = state.imageBlock === true;
|
|
220
|
+
image.wrap.hidden = !isImage;
|
|
221
|
+
if (isImage) {
|
|
222
|
+
image.setOptions(
|
|
223
|
+
IMAGE_DESTINATIONS.map((entry) => {
|
|
224
|
+
try {
|
|
225
|
+
const prepared = prepareImageToDesign(editor.document, state.blockPath, entry.target, { ...entry.options, validate: false });
|
|
226
|
+
return { value: entry.value, label: `${entry.label}${prepared.loss.length ? ` (loses ${prepared.loss.join(", ")})` : ""}` };
|
|
227
|
+
} catch (error) {
|
|
228
|
+
return { value: entry.value, label: `${entry.label} (unavailable)`, disabled: true, title: messageOf(error) };
|
|
229
|
+
}
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
232
|
+
image.set("");
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// --- slide structure -----------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
const slideBox = h("div", { class: "opf-dc-actions-block", "data-role": "slide-structure" });
|
|
240
|
+
{
|
|
241
|
+
const layouts = selectField("region-layout", "Place the blocks in regions", {
|
|
242
|
+
empty: "Choose a layout…",
|
|
243
|
+
help: "Each block gets its own part of the slide. Undo returns them to one list.",
|
|
244
|
+
onChange: (value) => {
|
|
245
|
+
const slideIndex = getSlide();
|
|
246
|
+
const choice = REGION_LAYOUTS[blockCount(slideIndex)]?.find((entry) => entry.id === value);
|
|
247
|
+
if (!choice) return;
|
|
248
|
+
run(() => placeBlocksInRegions(editor, slideIndex, choice.regions), (change) => `Blocks placed: ${choice.label.toLowerCase()}. ${lossText(change)}`);
|
|
249
|
+
reselect(`slides.${slideIndex}`);
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
const toBlocks = action("Turn regions into blocks", {
|
|
253
|
+
role: "regions-to-blocks",
|
|
254
|
+
prepare: () => prepareRegionsToBlocks(editor.document, getSlide(), { validate: false }),
|
|
255
|
+
apply: () => regionsAsBlocks(editor, getSlide()),
|
|
256
|
+
done: "Regions are now blocks, in reading order.",
|
|
257
|
+
});
|
|
258
|
+
const designImages = Object.keys(DESIGN_IMAGE_LABELS).map((source) =>
|
|
259
|
+
action(`Move the ${DESIGN_IMAGE_LABELS[source]} into the content`, {
|
|
260
|
+
role: `back-${source}`,
|
|
261
|
+
prepare: () => prepareImageToContent(editor.document, getSlide(), source, { validate: false }),
|
|
262
|
+
apply: () => moveImageToContent(editor, getSlide(), source),
|
|
263
|
+
done: `The ${DESIGN_IMAGE_LABELS[source]} is now an image block.`,
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
266
|
+
slideBox.append(layouts.wrap, toBlocks.wrap, ...designImages.map((entry) => entry.wrap));
|
|
267
|
+
updaters.push((state) => {
|
|
268
|
+
const slide = editor.document.slides?.[state.slideIndex];
|
|
269
|
+
if (!slide) return;
|
|
270
|
+
const regions = regionKeysOfSlide(slide);
|
|
271
|
+
const count = blockCount(state.slideIndex);
|
|
272
|
+
const choices = regions.length ? [] : (REGION_LAYOUTS[count] ?? []);
|
|
273
|
+
layouts.wrap.hidden = choices.length === 0;
|
|
274
|
+
if (choices.length) {
|
|
275
|
+
layouts.setOptions(
|
|
276
|
+
choices.map((choice) => {
|
|
277
|
+
try {
|
|
278
|
+
const prepared = prepareBlocksToRegions(editor.document, state.slideIndex, choice.regions, { validate: false });
|
|
279
|
+
return { value: choice.id, label: `${choice.label}${prepared.loss.length ? ` (loses ${prepared.loss.join(", ")})` : ""}` };
|
|
280
|
+
} catch (error) {
|
|
281
|
+
return { value: choice.id, label: `${choice.label} (unavailable)`, disabled: true, title: messageOf(error) };
|
|
282
|
+
}
|
|
283
|
+
}),
|
|
284
|
+
);
|
|
285
|
+
layouts.set("");
|
|
286
|
+
}
|
|
287
|
+
toBlocks.wrap.hidden = regions.length === 0;
|
|
288
|
+
if (regions.length) toBlocks.update(state);
|
|
289
|
+
Object.keys(DESIGN_IMAGE_LABELS).forEach((source, index) => {
|
|
290
|
+
const present = slide.design?.[source] !== undefined && slide.design[source] !== false;
|
|
291
|
+
designImages[index].wrap.hidden = !present;
|
|
292
|
+
if (present) designImages[index].update(state);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The number of blocks a slide can place in regions: its `blocks`, or the content fields it holds inline. */
|
|
298
|
+
function blockCount(slideIndex) {
|
|
299
|
+
const slide = editor.document.slides?.[slideIndex];
|
|
300
|
+
if (!slide) return 0;
|
|
301
|
+
if (Array.isArray(slide.blocks)) return slide.blocks.length;
|
|
302
|
+
return ["text", "items", "bullets", "image", "video", "chart", "table", "code", "metric", "quote", "timeline"].filter((key) => slide[key] !== undefined).length;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
/** Nodes for the "Selected content" section: list levels, the group-of-metrics conversion and the arrange actions. */
|
|
307
|
+
nodes: { list, metrics: metrics.wrap, arrange },
|
|
308
|
+
/** The node for the "Slide structure" section. */
|
|
309
|
+
slideNode: slideBox,
|
|
310
|
+
/** Update every control from the session and the current selection. */
|
|
311
|
+
sync(state) {
|
|
312
|
+
const slide = editor.document.slides?.[state.slideIndex];
|
|
313
|
+
const imageBlock = state.blockPath ? isImageBlock(editor, state.blockPath) : false;
|
|
314
|
+
const full = { ...state, imageBlock, metricGroupPath: state.selectedPath ? metricGroupForSelection(editor.document, state.selectedPath) : undefined, slide };
|
|
315
|
+
ctxState.current = full;
|
|
316
|
+
for (const update of updaters) update(full);
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function isImageBlock(editor, blockPath) {
|
|
322
|
+
const block = editor.get(blockPath);
|
|
323
|
+
if (!block || typeof block !== "object" || Array.isArray(block)) return false;
|
|
324
|
+
const keys = Object.keys(block).filter((key) => key !== "id" && key !== "extensions" && key !== "type");
|
|
325
|
+
return keys.length === 1 && keys[0] === "image";
|
|
326
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { EditorSession } from "./index.js";
|
|
2
|
+
import type { DataGridTarget } from "./grid-model.js";
|
|
3
|
+
|
|
4
|
+
export * from "./grid-model.js";
|
|
5
|
+
|
|
6
|
+
export interface DataGridOptions {
|
|
7
|
+
editor: EditorSession;
|
|
8
|
+
/** Bind to one chart or table path. */
|
|
9
|
+
path?: string;
|
|
10
|
+
/** Or follow the host's selection into a chart or table (call `refresh()` when it changes). */
|
|
11
|
+
getSelectedPath?: () => string | undefined;
|
|
12
|
+
/** How chart numbers are read and shown: "auto" (the locale's decimal separator, the default), "." or ",". */
|
|
13
|
+
numberFormat?: "auto" | "." | ",";
|
|
14
|
+
/** A BCP 47 tag for "auto" (default: the page's `lang`, then the browser's language). */
|
|
15
|
+
locale?: string;
|
|
16
|
+
/** CSS length of the scrolling area (default "320px"). */
|
|
17
|
+
maxHeight?: string;
|
|
18
|
+
/** Called after every committed change. */
|
|
19
|
+
onChange?: (change: unknown) => void;
|
|
20
|
+
/** Called with every status or error message the grid shows. */
|
|
21
|
+
onStatus?: (message: string, info: { error: boolean }) => void;
|
|
22
|
+
/** Called when the grid starts or stops showing a chart or table. */
|
|
23
|
+
onTargetChange?: (target: DataGridTarget | undefined) => void;
|
|
24
|
+
}
|
|
25
|
+
export interface DataGrid {
|
|
26
|
+
element: HTMLElement;
|
|
27
|
+
refresh(): void;
|
|
28
|
+
focus(): void;
|
|
29
|
+
readonly target: DataGridTarget | undefined;
|
|
30
|
+
readonly numberFormat: "auto" | "." | ",";
|
|
31
|
+
setNumberFormat(format: "auto" | "." | ","): void;
|
|
32
|
+
/** Paste TSV or CSV text at the selected cell, as Ctrl+V does. Returns whether it applied. */
|
|
33
|
+
paste(text: string): boolean;
|
|
34
|
+
destroy(): void;
|
|
35
|
+
}
|
|
36
|
+
/** Mount a spreadsheet-like grid for a chart's inline data or a table. Every edit is one undoable session patch. */
|
|
37
|
+
export declare function createDataGrid(container: HTMLElement, options: DataGridOptions): DataGrid;
|