@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
package/dist/slides.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { EditorChange, EditorSession, JsonPatchOperation } from "./index.js";
|
|
2
|
+
|
|
3
|
+
/** One section of the deck: a maximal run of consecutive slides sharing a `section` label (or none). */
|
|
4
|
+
export interface SlideSection {
|
|
5
|
+
/** Position in `listSections(document)`, the id every section operation takes. */
|
|
6
|
+
index: number;
|
|
7
|
+
/** The label; undefined for the unnamed run of slides with no label. */
|
|
8
|
+
name: string | undefined;
|
|
9
|
+
unnamed: boolean;
|
|
10
|
+
/** Index of the first slide and how many slides the run holds. */
|
|
11
|
+
start: number;
|
|
12
|
+
count: number;
|
|
13
|
+
}
|
|
14
|
+
export interface SlideSummary { index: number; id?: string; title: string; hidden: boolean; section?: string; layout?: string }
|
|
15
|
+
|
|
16
|
+
/** What every `prepare*` function returns without touching a session. Empty `patches` and `changed: false` mean nothing to do. */
|
|
17
|
+
export interface PreparedSlideChange {
|
|
18
|
+
document: unknown;
|
|
19
|
+
patches: JsonPatchOperation[];
|
|
20
|
+
changed: boolean;
|
|
21
|
+
/** The slide indices to select after the change (the new, moved or remaining slides). */
|
|
22
|
+
selection: number[];
|
|
23
|
+
action?: string;
|
|
24
|
+
/** add: the index and id of the new slide. */
|
|
25
|
+
index?: number;
|
|
26
|
+
id?: string;
|
|
27
|
+
layout?: string;
|
|
28
|
+
/** duplicate: where the copies start and how many there are, and their ids. */
|
|
29
|
+
range?: { start: number; count: number };
|
|
30
|
+
ids?: (string | undefined)[];
|
|
31
|
+
/** remove: the removed slide indices (original order). */
|
|
32
|
+
removed?: number[];
|
|
33
|
+
/** move: the new order as old slide indices. */
|
|
34
|
+
order?: number[];
|
|
35
|
+
/** hide and show. */
|
|
36
|
+
hidden?: boolean;
|
|
37
|
+
/** Section operations: the label now on the slides. */
|
|
38
|
+
section?: string;
|
|
39
|
+
}
|
|
40
|
+
/** The session change of an applied operation, with the same report fields. One undo step; an unchanged operation commits nothing. */
|
|
41
|
+
export interface SlideChange extends Omit<EditorChange, "document" | "patches">, Omit<PreparedSlideChange, "document" | "patches"> {
|
|
42
|
+
document: unknown;
|
|
43
|
+
patches: JsonPatchOperation[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** How moved slides take a section: the neighbour's (`"adopt"`, default), none of that (`"keep"`), a named one or none (`null`). */
|
|
47
|
+
export type MoveSectionMode = "adopt" | "keep" | string | null;
|
|
48
|
+
export interface AddSlideOptions {
|
|
49
|
+
/** The index the slide takes (default: the end). */
|
|
50
|
+
at?: number;
|
|
51
|
+
/** A layouts catalog id; the layout's placeholders are added as empty slots. */
|
|
52
|
+
layout?: string;
|
|
53
|
+
title?: string;
|
|
54
|
+
text?: string;
|
|
55
|
+
id?: string;
|
|
56
|
+
/** Default: the section of the slide before the new one. Pass null for none. */
|
|
57
|
+
section?: string | null;
|
|
58
|
+
/** A ready-made slide to insert instead of a blank one. */
|
|
59
|
+
slide?: Record<string, unknown>;
|
|
60
|
+
catalogs?: Record<string, unknown>;
|
|
61
|
+
record?: Record<string, unknown>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export declare function listSections(document: unknown): SlideSection[];
|
|
65
|
+
export declare function hasSections(document: unknown): boolean;
|
|
66
|
+
export declare function sectionForSlide(document: unknown, slideIndex: number): SlideSection | undefined;
|
|
67
|
+
export declare function slideTitle(slide: unknown): string;
|
|
68
|
+
export declare function slideSummaries(document: unknown): SlideSummary[];
|
|
69
|
+
|
|
70
|
+
export declare function prepareAddSlide(document: unknown, options?: AddSlideOptions): PreparedSlideChange;
|
|
71
|
+
export declare function addSlide(editor: EditorSession, options?: AddSlideOptions, meta?: Record<string, unknown>): SlideChange;
|
|
72
|
+
export declare function prepareDuplicateSlides(document: unknown, indices: number | number[], options?: { section?: "keep" }): PreparedSlideChange;
|
|
73
|
+
export declare function duplicateSlides(editor: EditorSession, indices: number | number[], options?: { section?: "keep" }, meta?: Record<string, unknown>): SlideChange;
|
|
74
|
+
/** Throws `cannot-remove-all-slides` when every slide is chosen. */
|
|
75
|
+
export declare function prepareRemoveSlides(document: unknown, indices: number | number[]): PreparedSlideChange;
|
|
76
|
+
export declare function removeSlides(editor: EditorSession, indices: number | number[], meta?: Record<string, unknown>): SlideChange;
|
|
77
|
+
/** `to` is the drop gap in the original order (0 to the slide count). */
|
|
78
|
+
export declare function prepareMoveSlides(document: unknown, indices: number | number[], to: number, options?: { section?: MoveSectionMode }): PreparedSlideChange;
|
|
79
|
+
export declare function moveSlides(editor: EditorSession, indices: number | number[], to: number, options?: { section?: MoveSectionMode }, meta?: Record<string, unknown>): SlideChange;
|
|
80
|
+
export declare function prepareMoveSlidesBy(document: unknown, indices: number | number[], delta: number, options?: { section?: MoveSectionMode }): PreparedSlideChange;
|
|
81
|
+
export declare function moveSlidesBy(editor: EditorSession, indices: number | number[], delta: number, options?: { section?: MoveSectionMode }, meta?: Record<string, unknown>): SlideChange;
|
|
82
|
+
/** `hidden` omitted toggles: all hidden become shown, otherwise all become hidden. */
|
|
83
|
+
export declare function prepareSetHidden(document: unknown, indices: number | number[], hidden?: boolean): PreparedSlideChange;
|
|
84
|
+
export declare function setHidden(editor: EditorSession, indices: number | number[], hidden?: boolean, meta?: Record<string, unknown>): SlideChange;
|
|
85
|
+
export declare function prepareSetSection(document: unknown, indices: number | number[], name: string | null): PreparedSlideChange;
|
|
86
|
+
export declare function setSection(editor: EditorSession, indices: number | number[], name: string | null, meta?: Record<string, unknown>): SlideChange;
|
|
87
|
+
export declare function prepareAddSection(document: unknown, slideIndex: number, name: string): PreparedSlideChange;
|
|
88
|
+
export declare function addSection(editor: EditorSession, slideIndex: number, name: string, meta?: Record<string, unknown>): SlideChange;
|
|
89
|
+
export declare function prepareRenameSection(document: unknown, sectionIndex: number, name: string): PreparedSlideChange;
|
|
90
|
+
export declare function renameSection(editor: EditorSession, sectionIndex: number, name: string, meta?: Record<string, unknown>): SlideChange;
|
|
91
|
+
export declare function prepareRemoveSection(document: unknown, sectionIndex: number, options?: { deleteSlides?: boolean }): PreparedSlideChange;
|
|
92
|
+
export declare function removeSection(editor: EditorSession, sectionIndex: number, options?: { deleteSlides?: boolean }, meta?: Record<string, unknown>): SlideChange;
|
|
93
|
+
/** `to` is a gap among the sections (0 to the section count). */
|
|
94
|
+
export declare function prepareMoveSection(document: unknown, sectionIndex: number, to: number): PreparedSlideChange;
|
|
95
|
+
export declare function moveSection(editor: EditorSession, sectionIndex: number, to: number, meta?: Record<string, unknown>): SlideChange;
|
|
96
|
+
export declare const SLIDE_OPERATIONS: Readonly<Record<string, (document: unknown, ...args: any[]) => PreparedSlideChange>>;
|
package/dist/slides.js
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
// Slide management (RR-21): add, duplicate, remove, move, hide and section slides as one validated, undoable
|
|
2
|
+
// change each. Every operation has a `prepare*` form that computes the JSON Patch from a document without
|
|
3
|
+
// touching a session (a dry run: `changed`, `patches` and the resulting `document`), and an apply form that
|
|
4
|
+
// commits that patch through the session as ONE transaction, so a single Undo restores the deck exactly and
|
|
5
|
+
// the preview, thumbnails and PPTX export follow from the document.
|
|
6
|
+
//
|
|
7
|
+
// Model. OPF has no section objects: a slide carries an optional `section` label and consecutive slides with
|
|
8
|
+
// the same label form a section (see `listSections`). Slides without a label form an unnamed run. `hidden`
|
|
9
|
+
// is a boolean on the slide. Slide order is the `slides` array, so a move is a remove plus an add of the same
|
|
10
|
+
// slide (removing and re-adding keeps every other slide's patch path stable).
|
|
11
|
+
import { validateOpfDocument } from "./index.js";
|
|
12
|
+
import { checkedDocument, fail, same } from "./edit-helpers.js";
|
|
13
|
+
import { collectReservedPresentationIds, remapSlideTreeIds } from "./presentation-ids.js";
|
|
14
|
+
import { prepareDimensionSwitch } from "./switches.js";
|
|
15
|
+
|
|
16
|
+
const isIndex = (value) => Number.isInteger(value) && value >= 0;
|
|
17
|
+
const clone = (value) => structuredClone(value);
|
|
18
|
+
|
|
19
|
+
function requireEditor(editor) {
|
|
20
|
+
if (!editor || typeof editor.applyPatch !== "function") throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
|
|
21
|
+
}
|
|
22
|
+
function requireSlides(document) {
|
|
23
|
+
if (!document || typeof document !== "object" || !Array.isArray(document.slides)) throw fail("invalid-input", "Slide management needs an OPF document with a slides array.");
|
|
24
|
+
return document.slides;
|
|
25
|
+
}
|
|
26
|
+
/** Validate and sort a list of slide indices; a single index is accepted too. */
|
|
27
|
+
function selectionOf(document, indices, what = "Choose at least one slide.") {
|
|
28
|
+
const slides = requireSlides(document);
|
|
29
|
+
const list = [...new Set(Array.isArray(indices) ? indices : [indices])];
|
|
30
|
+
if (!list.length) throw fail("no-slides-chosen", what);
|
|
31
|
+
for (const index of list) if (!isIndex(index) || index >= slides.length) throw fail("slide-index-out-of-range", `Slide ${index} does not exist.`, { slideIndex: index });
|
|
32
|
+
return list.sort((a, b) => a - b);
|
|
33
|
+
}
|
|
34
|
+
const sectionOf = (slide) => (typeof slide?.section === "string" && slide.section.trim() !== "" ? slide.section : undefined);
|
|
35
|
+
|
|
36
|
+
function unchanged(document, extra = {}) {
|
|
37
|
+
return { document: clone(document), patches: [], changed: false, selection: [], ...extra };
|
|
38
|
+
}
|
|
39
|
+
/** Validate the candidate the way every switch does (a valid deck must stay valid) and package the result. */
|
|
40
|
+
function finish(document, patches, extra) {
|
|
41
|
+
if (!patches.length) return unchanged(document, extra);
|
|
42
|
+
const before = validateOpfDocument(document);
|
|
43
|
+
const next = checkedDocument(document, patches, before);
|
|
44
|
+
return { document: next, patches, changed: true, ...extra };
|
|
45
|
+
}
|
|
46
|
+
function apply(editor, prepared, meta, action) {
|
|
47
|
+
requireEditor(editor);
|
|
48
|
+
const { document, patches, ...summary } = prepared;
|
|
49
|
+
void document;
|
|
50
|
+
if (!prepared.changed) return { ...summary, document: editor.document, patches: [], inversePatches: [], validation: editor.validation };
|
|
51
|
+
const change = editor.applyPatch(patches, { ...meta, source: meta?.source ?? "slides", action });
|
|
52
|
+
return { ...change, ...summary };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// --- reading ---------------------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The sections of the deck, in order: maximal runs of consecutive slides that share a `section` label. A run
|
|
59
|
+
* of slides with no label is reported with `name: undefined` (`unnamed: true`). `index` is the position in
|
|
60
|
+
* the returned list, the id every section operation takes.
|
|
61
|
+
*/
|
|
62
|
+
export function listSections(document) {
|
|
63
|
+
const slides = requireSlides(document);
|
|
64
|
+
const sections = [];
|
|
65
|
+
slides.forEach((slide, slideIndex) => {
|
|
66
|
+
const name = sectionOf(slide);
|
|
67
|
+
const last = sections.at(-1);
|
|
68
|
+
if (last && last.name === name) last.count += 1;
|
|
69
|
+
else sections.push({ index: sections.length, name, unnamed: name === undefined, start: slideIndex, count: 1 });
|
|
70
|
+
});
|
|
71
|
+
return sections;
|
|
72
|
+
}
|
|
73
|
+
/** Whether any slide carries a section label (a deck with none shows no section headers). */
|
|
74
|
+
export function hasSections(document) {
|
|
75
|
+
return requireSlides(document).some((slide) => sectionOf(slide) !== undefined);
|
|
76
|
+
}
|
|
77
|
+
/** The section (from `listSections`) that holds a slide. */
|
|
78
|
+
export function sectionForSlide(document, slideIndex) {
|
|
79
|
+
return listSections(document).find((section) => slideIndex >= section.start && slideIndex < section.start + section.count);
|
|
80
|
+
}
|
|
81
|
+
/** The title shown for a slide in navigators and outlines. */
|
|
82
|
+
export function slideTitle(slide) {
|
|
83
|
+
return typeof slide?.title === "string" && slide.title.trim() ? slide.title : "";
|
|
84
|
+
}
|
|
85
|
+
/** One row per slide for pickers and navigators: `{ index, id, title, hidden, section, layout }`. */
|
|
86
|
+
export function slideSummaries(document) {
|
|
87
|
+
return requireSlides(document).map((slide, index) => ({ index, id: slide.id, title: slideTitle(slide), hidden: slide.hidden === true, section: sectionOf(slide), layout: slide.layout }));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- patches ---------------------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
const slidePointer = (index) => `/slides/${index}`;
|
|
93
|
+
/** Set or clear one slide field with the narrowest patch (`add`, `replace` or `remove`); undefined clears. */
|
|
94
|
+
function fieldPatch(slide, index, key, value) {
|
|
95
|
+
const present = Object.hasOwn(slide, key);
|
|
96
|
+
const path = `${slidePointer(index)}/${key}`;
|
|
97
|
+
if (value === undefined) return present ? [{ op: "remove", path }] : [];
|
|
98
|
+
if (!present) return [{ op: "add", path, value }];
|
|
99
|
+
return same(slide[key], value) ? [] : [{ op: "replace", path, value }];
|
|
100
|
+
}
|
|
101
|
+
function withSection(slide, name) {
|
|
102
|
+
const next = clone(slide);
|
|
103
|
+
if (name === undefined || name === null || name === "") delete next.section;
|
|
104
|
+
else next.section = name;
|
|
105
|
+
return next;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Patches that put `moved` (old indices) at their final places. `order` lists every old index in its new
|
|
109
|
+
* order, `values` maps a moved old index to the slide value to write. Moved slides are removed (highest first,
|
|
110
|
+
* so lower paths stay valid) and re-added at their final index in ascending order.
|
|
111
|
+
*/
|
|
112
|
+
function permutationPatches(moved, order, values) {
|
|
113
|
+
const patches = [];
|
|
114
|
+
for (const index of [...moved].sort((a, b) => b - a)) patches.push({ op: "remove", path: slidePointer(index) });
|
|
115
|
+
order.forEach((old, position) => {
|
|
116
|
+
if (moved.has(old)) patches.push({ op: "add", path: slidePointer(position), value: values.get(old) });
|
|
117
|
+
});
|
|
118
|
+
return patches;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- add -------------------------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
function newSlideId(document) {
|
|
124
|
+
const used = new Set(collectReservedPresentationIds(document));
|
|
125
|
+
let number = document.slides.length + 1;
|
|
126
|
+
while (used.has(`slide-${number}`)) number += 1;
|
|
127
|
+
return `slide-${number}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Compute adding a slide. Options: `at` (the index the slide takes, default the end), `layout` (a layouts
|
|
132
|
+
* catalog id; the layout's placeholders are added as empty slots, exactly like switching a slide's layout),
|
|
133
|
+
* `title`, `text`, `id`, `section` (default: the section of the slide before it), `slide` (a ready slide to
|
|
134
|
+
* insert, for hosts that build their own), `catalogs`/`record` for catalog lookup (as `prepareDimensionSwitch`).
|
|
135
|
+
*/
|
|
136
|
+
export function prepareAddSlide(document, options = {}) {
|
|
137
|
+
const slides = requireSlides(document);
|
|
138
|
+
const at = options.at === undefined ? slides.length : options.at;
|
|
139
|
+
if (!isIndex(at) || at > slides.length) throw fail("slide-index-out-of-range", "Add the slide at an index from 0 to the slide count.", { at });
|
|
140
|
+
const id = options.id ?? options.slide?.id ?? newSlideId(document);
|
|
141
|
+
let slide = options.slide ? clone(options.slide) : { id };
|
|
142
|
+
if (!options.slide) {
|
|
143
|
+
if (options.title !== undefined) slide.title = String(options.title);
|
|
144
|
+
else if (!options.layout) slide.title = "New slide";
|
|
145
|
+
if (options.text !== undefined) slide.text = String(options.text);
|
|
146
|
+
else if (!options.layout && options.title === undefined) slide.text = "Write your next idea here.";
|
|
147
|
+
} else if (options.id) slide.id = id;
|
|
148
|
+
const inherited = options.section === undefined ? sectionOf(slides[at - 1] ?? slides[at]) : options.section;
|
|
149
|
+
slide = withSection(slide, inherited);
|
|
150
|
+
if (options.layout) {
|
|
151
|
+
// Switch the layout on a scratch copy to reuse the placeholder rules and the catalog lookup.
|
|
152
|
+
const scratch = { ...clone(document), slides: [...clone(slides)] };
|
|
153
|
+
scratch.slides.splice(at, 0, slide);
|
|
154
|
+
const switched = prepareDimensionSwitch(scratch, "layouts", options.layout, { slideIndex: at, catalogs: options.catalogs, record: options.record });
|
|
155
|
+
const catalogOps = switched.patches.filter((patch) => !patch.path.startsWith("/slides/"));
|
|
156
|
+
slide = switched.document.slides[at];
|
|
157
|
+
const patches = [...catalogOps, { op: "add", path: slidePointer(at), value: slide }];
|
|
158
|
+
return finish(document, patches, { action: "add", index: at, id: slide.id, selection: [at], layout: options.layout });
|
|
159
|
+
}
|
|
160
|
+
return finish(document, [{ op: "add", path: slidePointer(at), value: slide }], { action: "add", index: at, id: slide.id, selection: [at] });
|
|
161
|
+
}
|
|
162
|
+
/** Add a slide (see `prepareAddSlide`) as one undoable step. The change reports `index`, `id` and `selection`. */
|
|
163
|
+
export function addSlide(editor, options = {}, meta) {
|
|
164
|
+
requireEditor(editor);
|
|
165
|
+
return apply(editor, prepareAddSlide(editor.document, options), meta, "add");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// --- duplicate -------------------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Compute duplicating slides. The copies go right after the last selected slide, in order, with fresh ids for
|
|
172
|
+
* the slide and any content it identifies (`slide-2` becomes `slide-2-2`, as when pagination copies slides).
|
|
173
|
+
* Copies join the section of the slide they follow unless `options.section` is `"keep"`.
|
|
174
|
+
*/
|
|
175
|
+
export function prepareDuplicateSlides(document, indices, options = {}) {
|
|
176
|
+
const list = selectionOf(document, indices, "Choose at least one slide to duplicate.");
|
|
177
|
+
const slides = document.slides;
|
|
178
|
+
const at = list.at(-1) + 1;
|
|
179
|
+
const ids = new Set(collectReservedPresentationIds(document));
|
|
180
|
+
const section = options.section === "keep" ? undefined : sectionOf(slides[at - 1]);
|
|
181
|
+
const copies = list.map((index) => {
|
|
182
|
+
let copy = clone(slides[index]);
|
|
183
|
+
remapSlideTreeIds(copy, ids);
|
|
184
|
+
if (options.section !== "keep") copy = withSection(copy, section);
|
|
185
|
+
return copy;
|
|
186
|
+
});
|
|
187
|
+
const patches = copies.map((copy, offset) => ({ op: "add", path: slidePointer(at + offset), value: copy }));
|
|
188
|
+
return finish(document, patches, { action: "duplicate", selection: copies.map((_, offset) => at + offset), range: { start: at, count: copies.length }, ids: copies.map((copy) => copy.id) });
|
|
189
|
+
}
|
|
190
|
+
export function duplicateSlides(editor, indices, options = {}, meta) {
|
|
191
|
+
requireEditor(editor);
|
|
192
|
+
return apply(editor, prepareDuplicateSlides(editor.document, indices, options), meta, "duplicate");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- remove ----------------------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Compute deleting slides. A deck keeps at least one slide, so deleting every slide throws
|
|
199
|
+
* `cannot-remove-all-slides`. `selection` is the slide to show next (the one that moves into the first
|
|
200
|
+
* removed position, or the last slide).
|
|
201
|
+
*/
|
|
202
|
+
export function prepareRemoveSlides(document, indices) {
|
|
203
|
+
const list = selectionOf(document, indices, "Choose at least one slide to delete.");
|
|
204
|
+
const slides = document.slides;
|
|
205
|
+
if (list.length >= slides.length) throw fail("cannot-remove-all-slides", "A presentation needs at least one slide.", { count: slides.length });
|
|
206
|
+
const patches = [...list].reverse().map((index) => ({ op: "remove", path: slidePointer(index) }));
|
|
207
|
+
const remaining = slides.length - list.length;
|
|
208
|
+
return finish(document, patches, { action: "remove", removed: list, selection: [Math.min(list[0], remaining - 1)] });
|
|
209
|
+
}
|
|
210
|
+
export function removeSlides(editor, indices, meta) {
|
|
211
|
+
requireEditor(editor);
|
|
212
|
+
return apply(editor, prepareRemoveSlides(editor.document, indices), meta, "remove");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// --- move ------------------------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Compute moving slides. `to` is the drop gap in the ORIGINAL order: 0 puts the slides first, the slide count
|
|
219
|
+
* last, 3 between slides 2 and 3. The moved slides keep their order and land together. `options.section`
|
|
220
|
+
* decides their section: `"adopt"` (default) takes the section of the slide just before the gap (or the
|
|
221
|
+
* first slide's when moving to the start), `"keep"` leaves labels alone, a string sets that section and
|
|
222
|
+
* `null` clears it.
|
|
223
|
+
*/
|
|
224
|
+
export function prepareMoveSlides(document, indices, to, options = {}) {
|
|
225
|
+
const list = selectionOf(document, indices, "Choose at least one slide to move.");
|
|
226
|
+
const slides = document.slides;
|
|
227
|
+
if (!isIndex(to) || to > slides.length) throw fail("slide-index-out-of-range", "Move to a gap from 0 to the slide count.", { to });
|
|
228
|
+
const chosen = new Set(list);
|
|
229
|
+
const rest = slides.map((_, index) => index).filter((index) => !chosen.has(index));
|
|
230
|
+
const position = to - list.filter((index) => index < to).length;
|
|
231
|
+
const order = [...rest.slice(0, position), ...list, ...rest.slice(position)];
|
|
232
|
+
const mode = rest.length ? (options.section === undefined ? "adopt" : options.section) : "keep";
|
|
233
|
+
let target;
|
|
234
|
+
if (mode === "keep") target = undefined;
|
|
235
|
+
else if (mode === "adopt") target = { name: sectionOf(slides[rest[position - 1] ?? rest[position]]) };
|
|
236
|
+
else target = { name: mode === null ? undefined : String(mode) };
|
|
237
|
+
const values = new Map(list.map((index) => [index, target ? withSection(slides[index], target.name) : slides[index]]));
|
|
238
|
+
const sameOrder = order.every((old, at) => old === at);
|
|
239
|
+
const sameValues = list.every((index) => same(values.get(index), slides[index]));
|
|
240
|
+
if (sameOrder && sameValues) return unchanged(document, { action: "move", selection: list });
|
|
241
|
+
// A pure relabel keeps the order: patch the labels in place instead of remove and add.
|
|
242
|
+
const moved = new Set(list);
|
|
243
|
+
const patches = sameOrder
|
|
244
|
+
? list.flatMap((index) => fieldPatch(slides[index], index, "section", sectionOf(values.get(index))))
|
|
245
|
+
: permutationPatches(moved, order, values);
|
|
246
|
+
const selection = list.map((old) => order.indexOf(old));
|
|
247
|
+
return finish(document, patches, { action: "move", selection, order });
|
|
248
|
+
}
|
|
249
|
+
export function moveSlides(editor, indices, to, options = {}, meta) {
|
|
250
|
+
requireEditor(editor);
|
|
251
|
+
return apply(editor, prepareMoveSlides(editor.document, indices, to, options), meta, "move");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Compute moving slides by `delta` positions (negative up). A contiguous selection moves as a block and a
|
|
256
|
+
* scattered one moves each slide past its neighbour, like Alt+Arrow in a list. Moving past the first or last
|
|
257
|
+
* slide changes nothing (`changed` is false).
|
|
258
|
+
*/
|
|
259
|
+
export function prepareMoveSlidesBy(document, indices, delta, options = {}) {
|
|
260
|
+
const list = selectionOf(document, indices, "Choose at least one slide to move.");
|
|
261
|
+
const count = document.slides.length;
|
|
262
|
+
if (!Number.isInteger(delta) || delta === 0) return unchanged(document, { action: "move", selection: list });
|
|
263
|
+
const step = Math.sign(delta);
|
|
264
|
+
const order = document.slides.map((_, index) => index);
|
|
265
|
+
const selected = new Set(list);
|
|
266
|
+
for (let turn = 0; turn < Math.abs(delta); turn += 1) {
|
|
267
|
+
const sequence = step < 0 ? [...order.keys()] : [...order.keys()].reverse();
|
|
268
|
+
let moved = false;
|
|
269
|
+
for (const at of sequence) {
|
|
270
|
+
const neighbour = at + step;
|
|
271
|
+
if (neighbour < 0 || neighbour >= count || !selected.has(order[at]) || selected.has(order[neighbour])) continue;
|
|
272
|
+
[order[at], order[neighbour]] = [order[neighbour], order[at]];
|
|
273
|
+
moved = true;
|
|
274
|
+
}
|
|
275
|
+
if (!moved) break;
|
|
276
|
+
}
|
|
277
|
+
if (order.every((old, at) => old === at)) return unchanged(document, { action: "move", selection: list });
|
|
278
|
+
const slides = document.slides;
|
|
279
|
+
const mode = options.section === undefined ? "adopt" : options.section;
|
|
280
|
+
// A moved slide adopts the section of the nearest slide that did not move: the one before it in the new order,
|
|
281
|
+
// else the one after it.
|
|
282
|
+
const adopted = (at) => {
|
|
283
|
+
for (let back = at - 1; back >= 0; back -= 1) if (!selected.has(order[back])) return sectionOf(slides[order[back]]);
|
|
284
|
+
for (let ahead = at + 1; ahead < order.length; ahead += 1) if (!selected.has(order[ahead])) return sectionOf(slides[order[ahead]]);
|
|
285
|
+
return undefined;
|
|
286
|
+
};
|
|
287
|
+
const values = new Map(list.map((index) => {
|
|
288
|
+
const name = mode === "keep" ? sectionOf(slides[index]) : mode === "adopt" ? adopted(order.indexOf(index)) : mode === null ? undefined : String(mode);
|
|
289
|
+
return [index, withSection(slides[index], name)];
|
|
290
|
+
}));
|
|
291
|
+
return finish(document, permutationPatches(selected, order, values), { action: "move", selection: list.map((old) => order.indexOf(old)), order });
|
|
292
|
+
}
|
|
293
|
+
export function moveSlidesBy(editor, indices, delta, options = {}, meta) {
|
|
294
|
+
requireEditor(editor);
|
|
295
|
+
return apply(editor, prepareMoveSlidesBy(editor.document, indices, delta, options), meta, "move");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// --- hide ------------------------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Compute hiding (`hidden` true) or showing (false) slides. A hidden slide stays in the document and the
|
|
302
|
+
* navigator but is skipped when presenting; showing removes the `hidden` field instead of writing `false`.
|
|
303
|
+
* Without `hidden`, the slides toggle together: all hidden become shown, otherwise all become hidden.
|
|
304
|
+
*/
|
|
305
|
+
export function prepareSetHidden(document, indices, hidden) {
|
|
306
|
+
const list = selectionOf(document, indices, "Choose at least one slide.");
|
|
307
|
+
const slides = document.slides;
|
|
308
|
+
const value = hidden === undefined ? !list.every((index) => slides[index].hidden === true) : Boolean(hidden);
|
|
309
|
+
const patches = list.flatMap((index) => fieldPatch(slides[index], index, "hidden", value ? true : undefined));
|
|
310
|
+
// A stored `hidden: false` counts as shown; showing it removes the field, hiding replaces it.
|
|
311
|
+
return finish(document, patches, { action: value ? "hide" : "show", hidden: value, selection: list });
|
|
312
|
+
}
|
|
313
|
+
export function setHidden(editor, indices, hidden, meta) {
|
|
314
|
+
requireEditor(editor);
|
|
315
|
+
const prepared = prepareSetHidden(editor.document, indices, hidden);
|
|
316
|
+
return apply(editor, prepared, meta, prepared.action);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// --- sections --------------------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
function sectionAt(document, sectionIndex) {
|
|
322
|
+
const section = listSections(document)[sectionIndex];
|
|
323
|
+
if (!section) throw fail("section-index-out-of-range", `Section ${sectionIndex} does not exist.`, { sectionIndex });
|
|
324
|
+
return section;
|
|
325
|
+
}
|
|
326
|
+
const cleanName = (name) => (typeof name === "string" ? name.trim() : name);
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Compute giving slides a section label (`name`), or clearing it (`null`). The slides keep their order, so
|
|
330
|
+
* labelling a middle slide splits its section in two runs; use `prepareAddSection` to start a section.
|
|
331
|
+
*/
|
|
332
|
+
export function prepareSetSection(document, indices, name) {
|
|
333
|
+
const list = selectionOf(document, indices, "Choose at least one slide.");
|
|
334
|
+
const label = name === null || name === undefined ? undefined : cleanName(name);
|
|
335
|
+
if (label === "") throw fail("invalid-section-name", "A section needs a name. Use null to remove the section from a slide.");
|
|
336
|
+
const patches = list.flatMap((index) => fieldPatch(document.slides[index], index, "section", label));
|
|
337
|
+
return finish(document, patches, { action: "section", section: label, selection: list });
|
|
338
|
+
}
|
|
339
|
+
export function setSection(editor, indices, name, meta) {
|
|
340
|
+
requireEditor(editor);
|
|
341
|
+
return apply(editor, prepareSetSection(editor.document, indices, name), meta, "section");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Compute starting a section at a slide: that slide and the slides after it up to the end of its current
|
|
346
|
+
* section take `name` (PowerPoint's "Add section", whose header sits above the chosen slide).
|
|
347
|
+
*/
|
|
348
|
+
export function prepareAddSection(document, slideIndex, name) {
|
|
349
|
+
const slides = requireSlides(document);
|
|
350
|
+
if (!isIndex(slideIndex) || slideIndex >= slides.length) throw fail("slide-index-out-of-range", `Slide ${slideIndex} does not exist.`, { slideIndex });
|
|
351
|
+
const label = cleanName(name);
|
|
352
|
+
if (typeof label !== "string" || label === "") throw fail("invalid-section-name", "Give the section a name.");
|
|
353
|
+
const current = sectionOf(slides[slideIndex]);
|
|
354
|
+
const end = (() => {
|
|
355
|
+
let at = slideIndex;
|
|
356
|
+
while (at + 1 < slides.length && sectionOf(slides[at + 1]) === current) at += 1;
|
|
357
|
+
return at;
|
|
358
|
+
})();
|
|
359
|
+
const indices = Array.from({ length: end - slideIndex + 1 }, (_, offset) => slideIndex + offset);
|
|
360
|
+
const patches = indices.flatMap((index) => fieldPatch(slides[index], index, "section", label));
|
|
361
|
+
return finish(document, patches, { action: "add-section", section: label, selection: indices });
|
|
362
|
+
}
|
|
363
|
+
export function addSection(editor, slideIndex, name, meta) {
|
|
364
|
+
requireEditor(editor);
|
|
365
|
+
return apply(editor, prepareAddSection(editor.document, slideIndex, name), meta, "add-section");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Compute renaming a section (an index from `listSections`) for all of its slides. */
|
|
369
|
+
export function prepareRenameSection(document, sectionIndex, name) {
|
|
370
|
+
const section = sectionAt(document, sectionIndex);
|
|
371
|
+
const label = cleanName(name);
|
|
372
|
+
if (typeof label !== "string" || label === "") throw fail("invalid-section-name", "Give the section a name.");
|
|
373
|
+
const indices = Array.from({ length: section.count }, (_, offset) => section.start + offset);
|
|
374
|
+
const patches = indices.flatMap((index) => fieldPatch(document.slides[index], index, "section", label));
|
|
375
|
+
return finish(document, patches, { action: "rename-section", section: label, selection: indices });
|
|
376
|
+
}
|
|
377
|
+
export function renameSection(editor, sectionIndex, name, meta) {
|
|
378
|
+
requireEditor(editor);
|
|
379
|
+
return apply(editor, prepareRenameSection(editor.document, sectionIndex, name), meta, "rename-section");
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Compute removing a section. Its slides stay and join the section before it (the first section's slides
|
|
384
|
+
* lose their label). With `deleteSlides: true` the slides are deleted too (not allowed when they are every
|
|
385
|
+
* slide of the deck).
|
|
386
|
+
*/
|
|
387
|
+
export function prepareRemoveSection(document, sectionIndex, options = {}) {
|
|
388
|
+
const sections = listSections(document);
|
|
389
|
+
const section = sectionAt(document, sectionIndex);
|
|
390
|
+
const indices = Array.from({ length: section.count }, (_, offset) => section.start + offset);
|
|
391
|
+
if (options.deleteSlides) return { ...prepareRemoveSlides(document, indices), action: "remove-section" };
|
|
392
|
+
const previous = sections[sectionIndex - 1];
|
|
393
|
+
const label = previous?.name;
|
|
394
|
+
const patches = indices.flatMap((index) => fieldPatch(document.slides[index], index, "section", label));
|
|
395
|
+
return finish(document, patches, { action: "remove-section", section: label, selection: indices });
|
|
396
|
+
}
|
|
397
|
+
export function removeSection(editor, sectionIndex, options = {}, meta) {
|
|
398
|
+
requireEditor(editor);
|
|
399
|
+
return apply(editor, prepareRemoveSection(editor.document, sectionIndex, options), meta, "remove-section");
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Compute moving a whole section to the gap `to` among the sections (0 to the section count, in the
|
|
404
|
+
* original order of `listSections`). The slides keep their labels.
|
|
405
|
+
*/
|
|
406
|
+
export function prepareMoveSection(document, sectionIndex, to) {
|
|
407
|
+
const sections = listSections(document);
|
|
408
|
+
const section = sectionAt(document, sectionIndex);
|
|
409
|
+
if (!isIndex(to) || to > sections.length) throw fail("section-index-out-of-range", "Move to a gap from 0 to the section count.", { to });
|
|
410
|
+
const gap = to === sections.length ? document.slides.length : sections[to].start;
|
|
411
|
+
const indices = Array.from({ length: section.count }, (_, offset) => section.start + offset);
|
|
412
|
+
const prepared = prepareMoveSlides(document, indices, gap, { section: "keep" });
|
|
413
|
+
return { ...prepared, action: "move-section", section: section.name };
|
|
414
|
+
}
|
|
415
|
+
export function moveSection(editor, sectionIndex, to, meta) {
|
|
416
|
+
requireEditor(editor);
|
|
417
|
+
return apply(editor, prepareMoveSection(editor.document, sectionIndex, to), meta, "move-section");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Every slide operation as a name to its `prepare` function, for hosts that map a command id to a change. */
|
|
421
|
+
export const SLIDE_OPERATIONS = Object.freeze({
|
|
422
|
+
add: prepareAddSlide,
|
|
423
|
+
duplicate: prepareDuplicateSlides,
|
|
424
|
+
remove: prepareRemoveSlides,
|
|
425
|
+
move: prepareMoveSlides,
|
|
426
|
+
moveBy: prepareMoveSlidesBy,
|
|
427
|
+
setHidden: prepareSetHidden,
|
|
428
|
+
setSection: prepareSetSection,
|
|
429
|
+
addSection: prepareAddSection,
|
|
430
|
+
renameSection: prepareRenameSection,
|
|
431
|
+
removeSection: prepareRemoveSection,
|
|
432
|
+
moveSection: prepareMoveSection,
|
|
433
|
+
});
|
package/dist/switches.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ConvertOptions } from "@openpresentation/opf/convert";
|
|
1
2
|
import type { EditorChange, EditorSession, JsonPatchOperation } from "./index.js";
|
|
2
3
|
|
|
3
4
|
/** The 14 pptx.gallery dimensions. */
|
|
@@ -38,11 +39,16 @@ export interface DimensionSwitchOptions {
|
|
|
38
39
|
index?: number;
|
|
39
40
|
/** blocks: media source for the image and video kinds. */
|
|
40
41
|
source?: string;
|
|
42
|
+
/** blocks: convert the block's own content to the new kind (text, list, quote, metric, code, timeline, chart, table and a group of metrics; see block-convert) instead of replacing it. */
|
|
43
|
+
convert?: boolean;
|
|
44
|
+
/** blocks with `convert`: core's conversion options (`looseWhen`, `fences`, `headings`, `columns`, `delimiter`, `header`). */
|
|
45
|
+
conversion?: ConvertOptions;
|
|
41
46
|
/** Session change metadata (switchDimension only). */
|
|
42
47
|
meta?: Record<string, unknown>;
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
export type DimensionSwitchValue =
|
|
51
|
+
| null /* backgrounds: remove it */
|
|
46
52
|
| string
|
|
47
53
|
| string[]
|
|
48
54
|
| { platform: string; handle: string }
|
|
@@ -59,6 +65,8 @@ export interface PreparedDimensionSwitch {
|
|
|
59
65
|
changed: boolean;
|
|
60
66
|
/** Slides whose own design hides a deck-level switch. */
|
|
61
67
|
shadowed: number[];
|
|
68
|
+
/** blocks with `convert`: what the new kind cannot carry. */
|
|
69
|
+
loss?: string[];
|
|
62
70
|
}
|
|
63
71
|
|
|
64
72
|
export interface DimensionSwitchChange extends Omit<EditorChange, "document" | "patches"> {
|
|
@@ -69,9 +77,27 @@ export interface DimensionSwitchChange extends Omit<EditorChange, "document" | "
|
|
|
69
77
|
slideIndex?: number;
|
|
70
78
|
changed: boolean;
|
|
71
79
|
shadowed: number[];
|
|
80
|
+
loss?: string[];
|
|
72
81
|
}
|
|
73
82
|
|
|
74
83
|
/** Compute and validate the patch for one dimension without changing any session. */
|
|
75
84
|
export declare function prepareDimensionSwitch(document: unknown, dimension: SwitchDimension, value: DimensionSwitchValue, options?: DimensionSwitchOptions): PreparedDimensionSwitch;
|
|
76
85
|
/** Apply one dimension switch to a session as a single undoable transaction. */
|
|
77
86
|
export declare function switchDimension(editor: EditorSession, dimension: SwitchDimension, value: DimensionSwitchValue, options?: DimensionSwitchOptions): DimensionSwitchChange;
|
|
87
|
+
|
|
88
|
+
export interface SwitchOption {
|
|
89
|
+
id: string;
|
|
90
|
+
label: string;
|
|
91
|
+
record?: Record<string, unknown>;
|
|
92
|
+
}
|
|
93
|
+
export interface CompatibleChartType extends SwitchOption {
|
|
94
|
+
/** True for the chart's present type, which is always listed. */
|
|
95
|
+
current: boolean;
|
|
96
|
+
}
|
|
97
|
+
/** Values a picker can offer for a catalog-backed dimension (document inline records, caller catalogs, then the bundled catalog, without duplicates); `blocks` lists the content kinds. */
|
|
98
|
+
export declare function listSwitchOptions(document: unknown, dimension: SwitchDimension, options?: Pick<DimensionSwitchOptions, "catalogs" | "catalogSources">): SwitchOption[];
|
|
99
|
+
/** Chart types the chart's inline data can use as it is (data-shape compatibility, not an engine-support claim). `path` or `slideIndex` picks the chart. */
|
|
100
|
+
export declare function compatibleChartTypes(document: unknown, options?: Pick<DimensionSwitchOptions, "slideIndex" | "path" | "catalogs" | "catalogSources">): CompatibleChartType[];
|
|
101
|
+
/** The value a dimension currently has: `{ value, scope }`, with the catalog id for catalog dimensions. */
|
|
102
|
+
export declare function currentSwitchValue(document: unknown, dimension: SwitchDimension, options?: Pick<DimensionSwitchOptions, "slideIndex" | "path" | "owner" | "index">): { value: unknown; scope: "deck" | "slide" | "block" };
|
|
103
|
+
export { blockConversionTargets } from "./block-convert.js";
|