@openpresentation/opf-editor 0.12.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +122 -80
- package/dist/annotations.d.ts +16 -16
- package/dist/annotations.js +71 -79
- package/dist/assets.d.ts +7 -7
- package/dist/assets.js +16 -16
- package/dist/background-options.d.ts +12 -5
- package/dist/background-options.js +44 -19
- package/dist/block-controls.js +7 -7
- package/dist/block-convert.d.ts +7 -7
- package/dist/block-convert.js +22 -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 +30 -31
- package/dist/canvas.js +64 -68
- package/dist/catalogs.d.ts +48 -0
- package/dist/catalogs.js +217 -0
- package/dist/chart-data.d.ts +10 -10
- package/dist/chart-data.js +21 -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 +23 -0
- package/dist/color-authoring.js +1 -1
- package/dist/content-actions.d.ts +17 -17
- package/dist/content-actions.js +74 -73
- package/dist/content-controls.js +21 -21
- package/dist/data-grid.js +7 -8
- package/dist/data.d.ts +4 -4
- package/dist/data.js +5 -5
- package/dist/dataset-refs.d.ts +2 -2
- package/dist/dataset-refs.js +5 -5
- package/dist/design-controls.d.ts +12 -5
- package/dist/design-controls.js +312 -111
- package/dist/design-options.d.ts +20 -18
- package/dist/design-options.js +91 -99
- package/dist/edit-helpers.js +8 -7
- package/dist/exact-source.js +5 -5
- package/dist/export.d.ts +7 -7
- package/dist/export.js +48 -17
- package/dist/find-panel.js +5 -5
- package/dist/find-replace.d.ts +3 -3
- package/dist/find-replace.js +23 -20
- package/dist/font-gate.js +49 -58
- package/dist/galleries.js +50 -101
- package/dist/grid-model.d.ts +25 -27
- package/dist/grid-model.js +92 -102
- package/dist/image-crop.d.ts +4 -4
- package/dist/image-crop.js +28 -29
- package/dist/image-cropper.js +3 -3
- package/dist/image-options.d.ts +45 -0
- package/dist/image-options.js +195 -0
- package/dist/index.d.ts +48 -31
- package/dist/index.js +154 -126
- package/dist/json-editor.js +2 -2
- package/dist/json-menu.js +2 -2
- package/dist/json-options.d.ts +3 -3
- package/dist/json-options.js +44 -54
- 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 +160 -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 +26 -6
- package/dist/slide-manager.d.ts +2 -2
- package/dist/slide-manager.js +19 -19
- package/dist/slides.d.ts +21 -21
- package/dist/slides.js +104 -101
- package/dist/svelte.js +2 -3
- package/dist/switches.d.ts +37 -22
- package/dist/switches.js +282 -171
- package/dist/table-options.d.ts +8 -8
- package/dist/table-options.js +28 -27
- 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 +15 -4
- package/dist/transfer.js +107 -161
- package/package.json +12 -6
- package/dist/font-defaults.js +0 -25
|
@@ -1,17 +1,22 @@
|
|
|
1
|
-
// Backgrounds (RR-06): every background form the schema has, with validation that explains itself.
|
|
2
|
-
// A background is a theme slot, a solid color, a linear gradient, an image or a pattern, at the deck
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Backgrounds (RR-06, FA-22/FA-23): every background form the schema has, with validation that explains itself.
|
|
2
|
+
// A background is a theme slot, a solid color, a linear gradient, an image or a pattern, at the deck or on one slide. It is
|
|
3
|
+
// the only canvas fill and never moves content. The image form is flat (OPF 0.15): `{ type: "image", src, alt, fit, focus,
|
|
4
|
+
// opacity, recolor, overlay }`; an image source string (`asset:`, `https://`, `data:`, `./`, `../`) is the cover-image shorthand.
|
|
5
|
+
// `setBackground` is one undoable patch through the `backgrounds` switch, so the preview, export and Undo behave like every
|
|
6
|
+
// other dimension.
|
|
5
7
|
import { prepareDimensionSwitch, switchDimension } from "./switches.js";
|
|
6
8
|
import { fail } from "./edit-helpers.js";
|
|
9
|
+
import { normalizeFocus, normalizeOverlay, normalizeRecolor } from "./image-options.js";
|
|
7
10
|
|
|
8
11
|
export const BACKGROUND_TYPES = Object.freeze(["theme", "solid", "gradient", "image", "pattern"]);
|
|
9
12
|
export const THEME_BACKGROUND_SLOTS = Object.freeze(["light1", "light2", "dark1", "dark2"]);
|
|
10
|
-
export const IMAGE_BACKGROUND_FITS = Object.freeze(["cover", "contain", "tile"]);
|
|
13
|
+
export const IMAGE_BACKGROUND_FITS = Object.freeze(["cover", "contain", "stretch", "tile"]);
|
|
14
|
+
/** Image sources the background string shorthand accepts (the schema's ImageSource). */
|
|
15
|
+
export const IMAGE_SOURCE = /^(?:asset:|https:\/\/|data:|\.\/|\.\.\/)/;
|
|
11
16
|
/** Scheme slots and roles a ColorRef may name (the schema's ColorRef enum), besides hex colors and `var:<id>`. */
|
|
12
17
|
export const COLOR_NAMES = Object.freeze([
|
|
13
18
|
"accent1", "accent2", "accent3", "accent4", "accent5", "accent6", "dark1", "dark2", "light1", "light2", "hyperlink", "followedHyperlink",
|
|
14
|
-
"primary", "secondary", "accent", "background", "surface", "text", "textSecondary",
|
|
19
|
+
"primary", "secondary", "accent", "background", "surface", "surfaceAlt", "text", "textSecondary",
|
|
15
20
|
]);
|
|
16
21
|
/**
|
|
17
22
|
* The 54 DrawingML preset patterns (ECMA-376 ST_PresetPatternVal) by family. PPTX export writes them as
|
|
@@ -55,7 +60,8 @@ export function normalizeBackground(spec) {
|
|
|
55
60
|
if (typeof spec === "string") {
|
|
56
61
|
if (THEME_BACKGROUND_SLOTS.includes(spec)) return spec;
|
|
57
62
|
if (HEX.test(spec)) return spec.toUpperCase();
|
|
58
|
-
|
|
63
|
+
if (IMAGE_SOURCE.test(spec.trim())) return spec.trim();
|
|
64
|
+
throw fail("invalid-background", `A background shorthand is a theme slot (${THEME_BACKGROUND_SLOTS.join(", ")}), a hex color, or an image source starting with asset:, https://, data:, ./ or ../.`, { spec });
|
|
59
65
|
}
|
|
60
66
|
if (!isObject(spec)) throw fail("invalid-background", "Describe the background as { type, ... } or a theme slot / hex shorthand.", { spec });
|
|
61
67
|
const opacity = opacityOf(spec);
|
|
@@ -83,10 +89,25 @@ export function normalizeBackground(spec) {
|
|
|
83
89
|
return withOpacity({ type: "gradient", gradient: { ...(angle === undefined ? {} : { angle }), stops } });
|
|
84
90
|
}
|
|
85
91
|
case "image": {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (
|
|
89
|
-
|
|
92
|
+
if (typeof spec.src !== "string" || !spec.src.trim()) throw fail("invalid-background", "An image background needs an image: choose a file or enter a source.", { spec });
|
|
93
|
+
const src = spec.src.trim();
|
|
94
|
+
if (!IMAGE_SOURCE.test(src)) throw fail("invalid-background", "An image source starts with asset:, https://, data:, ./ or ../.", { spec });
|
|
95
|
+
const fit = spec.fit === "" || spec.fit === null ? undefined : spec.fit;
|
|
96
|
+
if (fit !== undefined && !IMAGE_BACKGROUND_FITS.includes(fit)) throw fail("invalid-background", `Image fit is one of ${IMAGE_BACKGROUND_FITS.join(", ")}.`, { spec });
|
|
97
|
+
const alt = typeof spec.alt === "string" && spec.alt.trim() ? spec.alt.trim() : undefined;
|
|
98
|
+
const wrap = (call) => {
|
|
99
|
+
try {
|
|
100
|
+
return call();
|
|
101
|
+
} catch (error) {
|
|
102
|
+
throw fail("invalid-background", error.message, { spec });
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const focus = spec.focus === undefined || spec.focus === null ? undefined : wrap(() => normalizeFocus(spec.focus));
|
|
106
|
+
const overlay = spec.overlay === undefined || spec.overlay === null ? undefined : wrap(() => normalizeOverlay(spec.overlay));
|
|
107
|
+
const recolor = spec.recolor === undefined || spec.recolor === null || spec.recolor === "" ? undefined : wrap(() => normalizeRecolor(spec.recolor));
|
|
108
|
+
const value = { type: "image", src, ...(alt ? { alt } : {}), ...(fit ? { fit } : {}), ...(focus ? { focus } : {}), ...(opacity === undefined ? {} : { opacity }), ...(recolor ? { recolor } : {}), ...(overlay ? { overlay } : {}) };
|
|
109
|
+
// A cover image with nothing else is the shorthand string.
|
|
110
|
+
return Object.keys(value).length === 2 ? src : value;
|
|
90
111
|
}
|
|
91
112
|
case "pattern": {
|
|
92
113
|
const pattern = spec.pattern;
|
|
@@ -102,8 +123,8 @@ export function normalizeBackground(spec) {
|
|
|
102
123
|
}
|
|
103
124
|
|
|
104
125
|
/** Compute the patch that sets (or, for `null`, removes) the background, without touching a session. Options as `prepareDimensionSwitch` (`slideIndex`, `clearSlideOverrides`). */
|
|
105
|
-
export function prepareBackground(
|
|
106
|
-
return prepareDimensionSwitch(
|
|
126
|
+
export function prepareBackground(presentation, spec, options = {}) {
|
|
127
|
+
return prepareDimensionSwitch(presentation, "backgrounds", spec === null ? null : normalizeBackground(spec), options);
|
|
107
128
|
}
|
|
108
129
|
|
|
109
130
|
/** Set the background as one undoable transaction; `null` removes it so the theme's (or the deck's) shows again. */
|
|
@@ -113,21 +134,25 @@ export function setBackground(editor, spec, options = {}) {
|
|
|
113
134
|
|
|
114
135
|
/**
|
|
115
136
|
* The background that applies at a scope as a flat description for a form:
|
|
116
|
-
* `{ type, slot?, color?, opacity?, angle?, stops?, src?, fit?, preset?, foregroundColor?, backgroundColor?, scope, value }`.
|
|
137
|
+
* `{ type, slot?, color?, opacity?, angle?, stops?, src?, alt?, fit?, focus?, recolor?, overlay?, preset?, foregroundColor?, backgroundColor?, scope, value }`.
|
|
117
138
|
* `type` is undefined when nothing is set; `scope` is "slide" when the slide sets its own, else "deck".
|
|
118
139
|
*/
|
|
119
|
-
export function readBackground(
|
|
120
|
-
const own = slideIndex === undefined ? undefined :
|
|
121
|
-
const value = own !== undefined ? own :
|
|
140
|
+
export function readBackground(presentation, { slideIndex } = {}) {
|
|
141
|
+
const own = slideIndex === undefined ? undefined : presentation.slides?.[slideIndex]?.design?.background;
|
|
142
|
+
const value = own !== undefined ? own : presentation.design?.background;
|
|
122
143
|
const scope = own !== undefined ? "slide" : "deck";
|
|
123
144
|
if (value === undefined) return { scope };
|
|
124
|
-
if (typeof value === "string")
|
|
145
|
+
if (typeof value === "string") {
|
|
146
|
+
if (THEME_BACKGROUND_SLOTS.includes(value)) return { type: "theme", slot: value, scope, value };
|
|
147
|
+
if (IMAGE_SOURCE.test(value)) return { type: "image", src: value, fit: "cover", scope, value };
|
|
148
|
+
return { type: "solid", color: value, scope, value };
|
|
149
|
+
}
|
|
125
150
|
if (!isObject(value)) return { scope, value };
|
|
126
151
|
const base = { type: value.type, opacity: value.opacity, scope, value };
|
|
127
152
|
if (value.type === "theme") return { ...base, slot: value.slot };
|
|
128
153
|
if (value.type === "solid") return { ...base, color: value.color };
|
|
129
154
|
if (value.type === "gradient") return { ...base, angle: value.gradient?.angle, stops: structuredClone(value.gradient?.stops ?? []) };
|
|
130
|
-
if (value.type === "image") return { ...base, src: value.
|
|
155
|
+
if (value.type === "image") return { ...base, src: value.src, alt: value.alt, fit: value.fit, focus: value.focus && { ...value.focus }, recolor: value.recolor && structuredClone(value.recolor), overlay: value.overlay && { ...value.overlay } };
|
|
131
156
|
if (value.type === "pattern") return { ...base, preset: value.pattern?.preset, foregroundColor: value.pattern?.foregroundColor, backgroundColor: value.pattern?.backgroundColor };
|
|
132
157
|
return base;
|
|
133
158
|
}
|
package/dist/block-controls.js
CHANGED
|
@@ -13,18 +13,18 @@ export function createBlockControls(root, options) {
|
|
|
13
13
|
function apply(prepared){
|
|
14
14
|
if(!prepared?.changed){cancel();return true;}
|
|
15
15
|
// Retain changes outside the guarded containers and preflight the exact candidate.
|
|
16
|
-
const candidate=applyJsonPatch(editor.
|
|
16
|
+
const candidate=applyJsonPatch(editor.presentation,prepared.patches);
|
|
17
17
|
options.validate(candidate);
|
|
18
18
|
committing=true;
|
|
19
19
|
try{cancel();editor.applyPatch(prepared.patches,{source:'canvas-block-edit',rejectInvalid:true});options.onMove?.(prepared.path);([ ...layer.querySelectorAll("[data-block-path]")].find(node=>node.dataset.blockPath===prepared.path)??layer.querySelector('[data-block-add]'))?.focus();options.clearError?.();options.onCommit?.({path:prepared.path,editor});return true;}
|
|
20
20
|
finally{committing=false;}
|
|
21
21
|
}
|
|
22
|
-
function move(from,to,index,base=editor.
|
|
22
|
+
function move(from,to,index,base=editor.presentation){
|
|
23
23
|
try{return apply(prepareBlockMove(base,from,to,index));}catch(error){cancel();options.onError(error);return false;}
|
|
24
24
|
}
|
|
25
25
|
function open(path){
|
|
26
26
|
if(!options.beforeEdit())return;
|
|
27
|
-
cancel();returnFocus=doc.activeElement;path=opfPathToJsonPointer(path);const base=editor.
|
|
27
|
+
cancel();returnFocus=doc.activeElement;path=opfPathToJsonPointer(path);const base=editor.presentation,parts=splitOpfPath(path),container=opfPathToJsonPointer(parts.slice(0,-2)),index=Number(parts.at(-1));
|
|
28
28
|
const source=getValueAtPath(base,[...parts.slice(0,-2),'blocks']);
|
|
29
29
|
if(parts.at(-2)!=='blocks'||!Number.isInteger(index)||index<0||!Array.isArray(source)||index>=source.length){options.onError(new Error('Choose an existing content block.'));return;}
|
|
30
30
|
const count=source.length;
|
|
@@ -56,7 +56,7 @@ export function createBlockControls(root, options) {
|
|
|
56
56
|
}
|
|
57
57
|
function openInsert(containerPath,index) {
|
|
58
58
|
if(!options.beforeEdit())return;
|
|
59
|
-
cancel();returnFocus=doc.activeElement;const base=editor.
|
|
59
|
+
cancel();returnFocus=doc.activeElement;const base=editor.presentation;
|
|
60
60
|
const containers=listBlockContainers(base,{slideIndex:options.slideIndex(),includeImplicit:true});
|
|
61
61
|
if(!containers.length){options.onError(new Error('No content container is available on this slide.'));return;}
|
|
62
62
|
const requested=containerPath===undefined?containers[0].path:opfPathToJsonPointer(containerPath);
|
|
@@ -91,7 +91,7 @@ export function createBlockControls(root, options) {
|
|
|
91
91
|
panel.append(add,close);panel.onkeydown=event=>{if(event.key==='Escape'){event.preventDefault();event.stopPropagation();cancel();}};
|
|
92
92
|
layer.append(panel);kind.focus();
|
|
93
93
|
}
|
|
94
|
-
function update(
|
|
94
|
+
function update(presentation,nextGeometry){
|
|
95
95
|
geometry=nextGeometry;
|
|
96
96
|
if(drag)return;
|
|
97
97
|
cancel();layer.replaceChildren();cells=[];
|
|
@@ -99,7 +99,7 @@ export function createBlockControls(root, options) {
|
|
|
99
99
|
const add=doc.createElement('button');add.type='button';add.textContent='+ Add content';add.setAttribute('aria-label','Add content');add.dataset.blockAdd='';
|
|
100
100
|
add.style.cssText='position:absolute;right:8px;top:8px;pointer-events:auto;padding:5px 9px;background:#f6f3fc;color:#675394;border:1px solid #cfc4e7;border-radius:5px;font:12px system-ui;cursor:pointer';add.onclick=()=>openInsert();layer.append(add);
|
|
101
101
|
for(const flow of geometry.flows??[]){
|
|
102
|
-
const blocks=getValueAtPath(
|
|
102
|
+
const blocks=getValueAtPath(presentation,[...splitOpfPath(flow.path),'blocks']);
|
|
103
103
|
if(!Array.isArray(blocks)||blocks.length!==flow.itemCount)continue;
|
|
104
104
|
blocks.forEach((block,index)=>{
|
|
105
105
|
const col=flow.columns[index%flow.columns.length],row=flow.rows[Math.floor(index/flow.columns.length)];if(!col||!row)return;
|
|
@@ -116,7 +116,7 @@ export function createBlockControls(root, options) {
|
|
|
116
116
|
node.onkeydown=event=>{if(['ArrowLeft','ArrowUp','ArrowRight','ArrowDown'].includes(event.key)){event.preventDefault();event.stopPropagation();if(!options.beforeEdit())return;const delta=['ArrowLeft','ArrowUp'].includes(event.key)?-1:1;if(index+delta>=0&&index+delta<blocks.length)move(path,container,index+(delta>0?2:-1));}};
|
|
117
117
|
node.ondragstart=event=>{
|
|
118
118
|
if(!options.beforeEdit()){event.preventDefault();return;}
|
|
119
|
-
cancel();drag={from:path,container,index,base:editor.
|
|
119
|
+
cancel();drag={from:path,container,index,base:editor.presentation,signature:signature(container),cells:[...cells],prepared:null};
|
|
120
120
|
event.dataTransfer.effectAllowed='move';event.dataTransfer.setData('text/plain',path);
|
|
121
121
|
};
|
|
122
122
|
node.ondragend=()=>{cancel();options.render();};layer.append(node);
|
package/dist/block-convert.d.ts
CHANGED
|
@@ -32,7 +32,7 @@ export interface BlockConversionTarget {
|
|
|
32
32
|
reason?: string;
|
|
33
33
|
}
|
|
34
34
|
export interface PreparedBlockConversion {
|
|
35
|
-
|
|
35
|
+
presentation: unknown;
|
|
36
36
|
patches: JsonPatchOperation[];
|
|
37
37
|
path: string;
|
|
38
38
|
changed: boolean;
|
|
@@ -41,8 +41,8 @@ export interface PreparedBlockConversion {
|
|
|
41
41
|
from: BlockKind;
|
|
42
42
|
to: BlockKind;
|
|
43
43
|
}
|
|
44
|
-
export interface BlockConversionChange extends Omit<EditorChange, "
|
|
45
|
-
|
|
44
|
+
export interface BlockConversionChange extends Omit<EditorChange, "presentation" | "patches"> {
|
|
45
|
+
presentation: unknown;
|
|
46
46
|
patches: JsonPatchOperation[];
|
|
47
47
|
lossless: boolean;
|
|
48
48
|
loss: string[];
|
|
@@ -53,12 +53,12 @@ export interface BlockConversionChange extends Omit<EditorChange, "document" | "
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/** The single content payload of the block at `path`, or undefined (image-only, groups and blocks with several fields have none to convert). */
|
|
56
|
-
export declare function readBlockContent(
|
|
56
|
+
export declare function readBlockContent(presentation: unknown, path: string | string[]): BlockContent | undefined;
|
|
57
57
|
/** The block that contains a selected path (slides.0.blocks.1.text maps to slides.0.blocks.1), or undefined. */
|
|
58
|
-
export declare function blockPathForSelection(
|
|
58
|
+
export declare function blockPathForSelection(presentation: unknown, selectedPath: string): string | undefined;
|
|
59
59
|
/** The kinds the block can convert to, each with its loss report or the reason it is unavailable. */
|
|
60
|
-
export declare function blockConversionTargets(
|
|
60
|
+
export declare function blockConversionTargets(presentation: unknown, path: string | string[]): BlockConversionTarget[];
|
|
61
61
|
/** Compute the conversion patch without touching a session. Throws `block-not-convertible` for an unsupported pair or content that does not fit. */
|
|
62
|
-
export declare function prepareBlockConversion(
|
|
62
|
+
export declare function prepareBlockConversion(presentation: unknown, path: string | string[], kind: BlockKind, options?: ConvertOptions): PreparedBlockConversion;
|
|
63
63
|
/** Convert one block as a single undoable transaction. */
|
|
64
64
|
export declare function convertBlock(editor: EditorSession, path: string | string[], kind: BlockKind, meta?: Record<string, unknown>, options?: ConvertOptions): BlockConversionChange;
|
package/dist/block-convert.js
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
// transaction around them: it finds the block, turns the converted payload into one guarded patch, validates
|
|
6
6
|
// the document, and applies it as one undoable step.
|
|
7
7
|
import { CONTENT_CONVERSIONS, CONTENT_KIND_LABELS, OPFConversionError, contentConversionTargets, convertContent, readContent } from "@openpresentation/opf/convert";
|
|
8
|
-
import { applyJsonPatch, getValueAtPath, opfPathToJsonPointer, splitOpfPath
|
|
8
|
+
import { applyJsonPatch, getValueAtPath, opfPathToJsonPointer, splitOpfPath } from "./index.js";
|
|
9
9
|
import { listBlockContainers } from "./blocks.js";
|
|
10
10
|
import { fail } from "./edit-helpers.js";
|
|
11
|
+
import { firstErrorMessage, errorFindings, checkFormat } from "./checks.js";
|
|
11
12
|
|
|
12
13
|
/** Every content kind a block can hold, with its display label. */
|
|
13
14
|
export const BLOCK_KIND_LABELS = CONTENT_KIND_LABELS;
|
|
@@ -32,7 +33,7 @@ function convertOwner(owner, kind, options) {
|
|
|
32
33
|
* region that holds exactly one content field), or a group of metric blocks (a `blocks` array in a
|
|
33
34
|
* slide, region or group). Returns undefined when there is none.
|
|
34
35
|
*/
|
|
35
|
-
export function readBlockContent(
|
|
36
|
+
export function readBlockContent(presentation, path) {
|
|
36
37
|
let parts;
|
|
37
38
|
try {
|
|
38
39
|
parts = splitOpfPath(path);
|
|
@@ -41,7 +42,7 @@ export function readBlockContent(document, path) {
|
|
|
41
42
|
}
|
|
42
43
|
const pointer = opfPathToJsonPointer(parts);
|
|
43
44
|
const explicit = parts.at(-2) === "blocks" && /^(0|[1-9][0-9]*)$/.test(parts.at(-1) ?? "");
|
|
44
|
-
const owner = getValueAtPath(
|
|
45
|
+
const owner = getValueAtPath(presentation, parts);
|
|
45
46
|
if (!owner || typeof owner !== "object" || Array.isArray(owner)) return undefined;
|
|
46
47
|
if (Array.isArray(owner.blocks)) {
|
|
47
48
|
// Only a set of metrics converts as a group; any other group has nothing to offer.
|
|
@@ -49,7 +50,7 @@ export function readBlockContent(document, path) {
|
|
|
49
50
|
return { path: parts, explicit, owner, key: "blocks", kind: "group", content: owner.blocks };
|
|
50
51
|
}
|
|
51
52
|
if (!explicit) {
|
|
52
|
-
const implicit = listBlockContainers(
|
|
53
|
+
const implicit = listBlockContainers(presentation, { includeImplicit: true }).find((entry) => entry.path === pointer && entry.implicit);
|
|
53
54
|
if (!implicit || implicit.count !== 1) return undefined;
|
|
54
55
|
}
|
|
55
56
|
const info = readContent(owner);
|
|
@@ -58,7 +59,7 @@ export function readBlockContent(document, path) {
|
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
/** Whether `path` is inside a block and, if so, that block's path. A selection such as slides.0.blocks.1.text maps to slides.0.blocks.1. */
|
|
61
|
-
export function blockPathForSelection(
|
|
62
|
+
export function blockPathForSelection(presentation, selectedPath) {
|
|
62
63
|
let parts;
|
|
63
64
|
try {
|
|
64
65
|
parts = splitOpfPath(selectedPath);
|
|
@@ -68,7 +69,7 @@ export function blockPathForSelection(document, selectedPath) {
|
|
|
68
69
|
for (let length = parts.length; length >= 2; length -= 1) {
|
|
69
70
|
const candidate = parts.slice(0, length);
|
|
70
71
|
if (candidate[0] !== "slides") return undefined;
|
|
71
|
-
const found = readBlockContent(
|
|
72
|
+
const found = readBlockContent(presentation, candidate);
|
|
72
73
|
// A slide or region that holds one payload inline is a block only when the selection is that payload.
|
|
73
74
|
if (found && found.kind !== "group" && (found.explicit || parts[length] === found.key)) return candidate.join(".");
|
|
74
75
|
}
|
|
@@ -79,7 +80,7 @@ export function blockPathForSelection(document, selectedPath) {
|
|
|
79
80
|
* The nearest group of metric blocks around a selection (`slides.0.blocks.2.metric` finds `slides.0` when the
|
|
80
81
|
* slide's blocks are all metrics), or undefined. A group of metrics converts to a table as a whole.
|
|
81
82
|
*/
|
|
82
|
-
export function metricGroupForSelection(
|
|
83
|
+
export function metricGroupForSelection(presentation, selectedPath) {
|
|
83
84
|
let parts;
|
|
84
85
|
try {
|
|
85
86
|
parts = splitOpfPath(selectedPath);
|
|
@@ -89,7 +90,7 @@ export function metricGroupForSelection(document, selectedPath) {
|
|
|
89
90
|
if (parts[0] !== "slides") return undefined;
|
|
90
91
|
for (let length = parts.length; length >= 2; length -= 1) {
|
|
91
92
|
const candidate = parts.slice(0, length);
|
|
92
|
-
const found = readBlockContent(
|
|
93
|
+
const found = readBlockContent(presentation, candidate);
|
|
93
94
|
if (found?.kind === "group") return candidate.join(".");
|
|
94
95
|
}
|
|
95
96
|
return undefined;
|
|
@@ -101,10 +102,10 @@ export function metricGroupForSelection(document, selectedPath) {
|
|
|
101
102
|
* Returns [] for a block with no convertible content (image, video, a group that is not a set of metrics, several fields).
|
|
102
103
|
* `options` are core's conversion options (`looseWhen`, `fences`, `headings`, `columns`, `delimiter`, `header`).
|
|
103
104
|
*/
|
|
104
|
-
export function blockConversionTargets(
|
|
105
|
-
const found = readBlockContent(
|
|
105
|
+
export function blockConversionTargets(presentation, path, options = {}) {
|
|
106
|
+
const found = readBlockContent(presentation, path);
|
|
106
107
|
// RR-54: a table that shows a shared dataset needs the document's datasets to convert to anything but a chart.
|
|
107
|
-
return found ? contentConversionTargets(found.owner, {
|
|
108
|
+
return found ? contentConversionTargets(found.owner, { presentation: presentation, ...options }) : [];
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
/**
|
|
@@ -112,21 +113,21 @@ export function blockConversionTargets(document, path, options = {}) {
|
|
|
112
113
|
* Throws `block-not-convertible` for a pair with no safe mapping or content that does not fit.
|
|
113
114
|
* `prepared.loss` lists what the target cannot carry; `lossless` is true when nothing is lost.
|
|
114
115
|
*/
|
|
115
|
-
export function prepareBlockConversion(
|
|
116
|
-
const found = readBlockContent(
|
|
116
|
+
export function prepareBlockConversion(presentation, path, kind, options = {}) {
|
|
117
|
+
const found = readBlockContent(presentation, path);
|
|
117
118
|
if (!found) throw refuse(CHOOSE, { path });
|
|
118
119
|
const pointer = opfPathToJsonPointer(found.path);
|
|
119
120
|
const from = found.kind;
|
|
120
|
-
const result = convertOwner(found.owner, kind, {
|
|
121
|
-
if (!result.changed) return {
|
|
121
|
+
const result = convertOwner(found.owner, kind, { presentation: presentation, ...options });
|
|
122
|
+
if (!result.changed) return { presentation: structuredClone(presentation), patches: [], path: found.path.join("."), changed: false, lossless: true, loss: [], from, to: kind };
|
|
122
123
|
const patches = [
|
|
123
124
|
{ op: "test", path: pointer, value: structuredClone(found.owner) },
|
|
124
125
|
{ op: "replace", path: pointer, value: result.payload },
|
|
125
126
|
];
|
|
126
|
-
const next = applyJsonPatch(
|
|
127
|
-
const validation =
|
|
128
|
-
if (!validation.valid) throw fail("invalid-opf-edit", validation
|
|
129
|
-
return {
|
|
127
|
+
const next = applyJsonPatch(presentation, patches);
|
|
128
|
+
const validation = checkFormat(next);
|
|
129
|
+
if (!validation.valid) throw fail("invalid-opf-edit", firstErrorMessage(validation, "The converted block is not valid OPF."), { issues: errorFindings(validation), patches });
|
|
130
|
+
return { presentation: next, patches, path: found.path.join("."), changed: true, lossless: result.lossless, loss: result.loss, from, to: kind };
|
|
130
131
|
}
|
|
131
132
|
|
|
132
133
|
/**
|
|
@@ -135,9 +136,9 @@ export function prepareBlockConversion(document, path, kind, options = {}) {
|
|
|
135
136
|
*/
|
|
136
137
|
export function convertBlock(editor, path, kind, meta = {}, options = {}) {
|
|
137
138
|
if (!editor || typeof editor.applyPatch !== "function") throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
|
|
138
|
-
const prepared = prepareBlockConversion(editor.
|
|
139
|
+
const prepared = prepareBlockConversion(editor.presentation, path, kind, options);
|
|
139
140
|
const summary = { lossless: prepared.lossless, loss: prepared.loss, from: prepared.from, to: prepared.to, path: prepared.path, changed: prepared.changed };
|
|
140
|
-
if (!prepared.changed) return { ...summary,
|
|
141
|
+
if (!prepared.changed) return { ...summary, presentation: editor.presentation, patches: [], inversePatches: [], validation: editor.validation };
|
|
141
142
|
const change = editor.applyPatch(prepared.patches, { ...meta, source: meta.source ?? "block-conversion", blockPath: prepared.path, from: prepared.from, to: prepared.to });
|
|
142
143
|
return { ...change, ...summary };
|
|
143
144
|
}
|
package/dist/blocks.d.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import type {JsonPatchOperation} from './index.js';
|
|
2
2
|
/** Move a complete block; toIndex is the insertion position before removal. */
|
|
3
|
-
export declare function prepareBlockMove(
|
|
4
|
-
|
|
3
|
+
export declare function prepareBlockMove(presentation:unknown,fromPath:string,toContainerPath:string,toIndex:number):{
|
|
4
|
+
presentation:unknown;patches:JsonPatchOperation[];path:string;changed:boolean;
|
|
5
5
|
};
|
|
6
|
-
export declare function listBlockContainers(
|
|
6
|
+
export declare function listBlockContainers(presentation:unknown,options?:{slideIndex?:number;includeImplicit?:boolean}):{path:string;label:string;count:number;implicit?:boolean}[];
|
|
7
7
|
|
|
8
|
-
export interface PreparedBlockChange {
|
|
9
|
-
export declare function prepareBlockInsert(
|
|
10
|
-
export declare function prepareBlockDuplicate(
|
|
11
|
-
export declare function prepareBlockRemove(
|
|
8
|
+
export interface PreparedBlockChange { presentation:unknown;patches:JsonPatchOperation[];path:string;changed:boolean }
|
|
9
|
+
export declare function prepareBlockInsert(presentation:unknown,containerPath:string,block:unknown,index?:number):PreparedBlockChange;
|
|
10
|
+
export declare function prepareBlockDuplicate(presentation:unknown,path:string):PreparedBlockChange;
|
|
11
|
+
export declare function prepareBlockRemove(presentation:unknown,path:string):PreparedBlockChange;
|
|
12
12
|
/** Replace one complete block (or a slide/region's single content field). Replacement only: the old payload is discarded and nothing is converted between content types. Keeps `id` and `extensions` when the new block sets none. */
|
|
13
|
-
export declare function prepareBlockReplace(
|
|
13
|
+
export declare function prepareBlockReplace(presentation:unknown,path:string,block:unknown):PreparedBlockChange;
|
|
14
14
|
export type ContentBlockKind='text'|'list'|'chart'|'table'|'metric'|'quote'|'code'|'timeline'|'group'|'image'|'video';
|
|
15
15
|
export declare function createContentBlock(kind:ContentBlockKind,options?:{source?:string}):Record<string,unknown>;
|
package/dist/blocks.js
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
|
-
import {splitOpfPath,opfPathToJsonPointer,getValueAtPath,applyJsonPatch
|
|
1
|
+
import { splitOpfPath, opfPathToJsonPointer, getValueAtPath, applyJsonPatch } from './index.js';
|
|
2
|
+
import { firstErrorMessage, checkFormat } from "./checks.js";
|
|
2
3
|
const prefix=(a,b)=>a.length<=b.length&&a.every((part,i)=>part===b[i]);
|
|
3
4
|
|
|
4
5
|
/** Move one complete block. The insertion index refers to the destination before removal. */
|
|
5
|
-
export function prepareBlockMove(
|
|
6
|
+
export function prepareBlockMove(presentation,fromPath,toContainerPath,toIndex) {
|
|
6
7
|
const from=splitOpfPath(fromPath),to=splitOpfPath(toContainerPath);
|
|
7
8
|
if(from.at(-2)!=='blocks'||!/^(0|[1-9][0-9]*)$/.test(from.at(-1)??''))throw new TypeError('Move a complete block using its blocks/index path.');
|
|
8
9
|
const sourceIndex=Number(from.at(-1)),sourceContainer=from.slice(0,-2),sourceArray=from.slice(0,-1);
|
|
9
|
-
const containers=new Set(listBlockContainers(
|
|
10
|
+
const containers=new Set(listBlockContainers(presentation).map(item=>item.path));
|
|
10
11
|
if(!containers.has(opfPathToJsonPointer(sourceContainer))||!containers.has(opfPathToJsonPointer(to)))throw new TypeError('Choose slide or group block containers.');
|
|
11
|
-
const source=getValueAtPath(
|
|
12
|
+
const source=getValueAtPath(presentation,sourceArray),destination=getValueAtPath(presentation,[...to,'blocks']);
|
|
12
13
|
if(!Array.isArray(source)||sourceIndex>=source.length||!Array.isArray(destination))throw new RangeError('Source and destination must be existing block containers.');
|
|
13
14
|
if(!Number.isInteger(toIndex)||toIndex<0||toIndex>destination.length)throw new RangeError('Destination insertion index is out of range.');
|
|
14
15
|
if(prefix(from,to))throw new Error('A group cannot move into itself or one of its descendants.');
|
|
15
16
|
const same=JSON.stringify(sourceContainer)===JSON.stringify(to);
|
|
16
17
|
const index=same&&toIndex>sourceIndex?toIndex-1:toIndex;
|
|
17
|
-
if(same&&index===sourceIndex)return {
|
|
18
|
+
if(same&&index===sourceIndex)return {presentation:structuredClone(presentation),patches:[],path:opfPathToJsonPointer(from),changed:false};
|
|
18
19
|
if(!same&&source.length===1)throw new Error('This move would leave an empty block container. Move the group or add another block first.');
|
|
19
20
|
// Removing a preceding sibling shifts a destination group's address.
|
|
20
21
|
const rebased=[...to];
|
|
@@ -22,16 +23,16 @@ export function prepareBlockMove(document,fromPath,toContainerPath,toIndex) {
|
|
|
22
23
|
rebased[sourceArray.length]=String(Number(to[sourceArray.length])-1);
|
|
23
24
|
const target=[...rebased,'blocks',String(index)];
|
|
24
25
|
const guards=[sourceContainer,...(same?[]:[to])].filter((path,i,all)=>!all.some((other,j)=>j!==i&&other.length<path.length&&prefix(other,path)));
|
|
25
|
-
const patches=[...guards.map(path=>({op:'test',path:opfPathToJsonPointer(path),value:structuredClone(getValueAtPath(
|
|
26
|
+
const patches=[...guards.map(path=>({op:'test',path:opfPathToJsonPointer(path),value:structuredClone(getValueAtPath(presentation,path))})),
|
|
26
27
|
{op:'remove',path:opfPathToJsonPointer(from)},
|
|
27
28
|
{op:'add',path:opfPathToJsonPointer(target),value:structuredClone(source[sourceIndex])}];
|
|
28
|
-
const next=applyJsonPatch(
|
|
29
|
-
if(!validation.valid)throw new Error(validation
|
|
30
|
-
return {
|
|
29
|
+
const next=applyJsonPatch(presentation,patches),validation=checkFormat(next);
|
|
30
|
+
if(!validation.valid)throw new Error(firstErrorMessage(validation, 'The moved document is not valid OPF.'));
|
|
31
|
+
return {presentation:next,patches,path:opfPathToJsonPointer(target),changed:true};
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
/** Discover actual block containers without interpreting imported content as instructions. */
|
|
34
|
-
export function listBlockContainers(
|
|
35
|
+
export function listBlockContainers(presentation,{slideIndex,includeImplicit=false}={}) {
|
|
35
36
|
const result=[];
|
|
36
37
|
function visit(value,path,label,depth=0){
|
|
37
38
|
if(!value||typeof value!=='object'||depth>32)return;
|
|
@@ -40,7 +41,7 @@ export function listBlockContainers(document,{slideIndex,includeImplicit=false}=
|
|
|
40
41
|
value.blocks.forEach((block,i)=>visit(block,[...path,'blocks',String(i)],`${label} › Group ${i+1}`,depth+1));
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
|
-
(
|
|
44
|
+
(presentation?.slides??[]).forEach((slide,i)=>{
|
|
44
45
|
if(slideIndex!==undefined&&slideIndex!==i)return;
|
|
45
46
|
const path=['slides',String(i)],label=`Slide ${i+1}`;
|
|
46
47
|
if(includeImplicit&&!Array.isArray(slide.blocks)&&!Object.keys(slide).some(isRegion))result.push({path:opfPathToJsonPointer(path),label,count:contentFields.filter(key=>Object.hasOwn(slide,key)).length,implicit:true});
|
|
@@ -53,20 +54,20 @@ export function listBlockContainers(document,{slideIndex,includeImplicit=false}=
|
|
|
53
54
|
|
|
54
55
|
const contentFields=['text','items','bullets','image','video','chart','table','code','metric','quote','timeline'];
|
|
55
56
|
const isRegion=key=>/^(?:(?:top|middle|bottom)(?:\+(?:top|middle|bottom))*(?::(?:left|center|right)(?:\+(?:left|center|right))*)?|(?:left|center|right)(?:\+(?:left|center|right))*)$/.test(key);
|
|
56
|
-
function preparedChange(
|
|
57
|
+
function preparedChange(presentation,container,next,path) {
|
|
57
58
|
const pointer=opfPathToJsonPointer(container);
|
|
58
|
-
const patches=[{op:'test',path:pointer,value:structuredClone(getValueAtPath(
|
|
59
|
+
const patches=[{op:'test',path:pointer,value:structuredClone(getValueAtPath(presentation,container))},
|
|
59
60
|
{op:'replace',path:pointer,value:next}];
|
|
60
|
-
const result=applyJsonPatch(
|
|
61
|
-
if(!validation.valid)throw new Error(validation
|
|
62
|
-
return {
|
|
61
|
+
const result=applyJsonPatch(presentation,patches),validation=checkFormat(result);
|
|
62
|
+
if(!validation.valid)throw new Error(firstErrorMessage(validation, 'The changed document is not valid OPF.'));
|
|
63
|
+
return {presentation:result,patches,path:opfPathToJsonPointer(path),changed:true};
|
|
63
64
|
}
|
|
64
65
|
/** Insert into a slide, existing group or named region. Implicit payloads become blocks. */
|
|
65
|
-
export function prepareBlockInsert(
|
|
66
|
+
export function prepareBlockInsert(presentation,containerPath,block,index) {
|
|
66
67
|
const path=splitOpfPath(containerPath),pointer=opfPathToJsonPointer(path);
|
|
67
|
-
if(!listBlockContainers(
|
|
68
|
+
if(!listBlockContainers(presentation,{includeImplicit:true}).some(c=>c.path===pointer))throw new TypeError('Choose a slide, group or named region content container.');
|
|
68
69
|
if(!block||typeof block!=='object'||Array.isArray(block))throw new TypeError('Insert an OPF content block object.');
|
|
69
|
-
const next=structuredClone(getValueAtPath(
|
|
70
|
+
const next=structuredClone(getValueAtPath(presentation,path));
|
|
70
71
|
if(!Array.isArray(next.blocks)){
|
|
71
72
|
next.blocks=contentFields.filter(key=>Object.hasOwn(next,key)).map(key=>({...(next.type?{type:next.type}:{}),[key]:next[key]}));
|
|
72
73
|
for(const key of [...contentFields,'type'])delete next[key];
|
|
@@ -74,12 +75,12 @@ export function prepareBlockInsert(document,containerPath,block,index) {
|
|
|
74
75
|
const at=index??next.blocks.length;
|
|
75
76
|
if(!Number.isInteger(at)||at<0||at>next.blocks.length)throw new RangeError('Insertion index is out of range.');
|
|
76
77
|
next.blocks.splice(at,0,structuredClone(block));
|
|
77
|
-
return preparedChange(
|
|
78
|
+
return preparedChange(presentation,path,next,[...path,'blocks',String(at)]);
|
|
78
79
|
}
|
|
79
|
-
function checkedBlock(
|
|
80
|
+
function checkedBlock(presentation,path) {
|
|
80
81
|
const parts=splitOpfPath(path),container=parts.slice(0,-2),index=Number(parts.at(-1));
|
|
81
|
-
if(parts.at(-2)!=='blocks'||!/^(0|[1-9][0-9]*)$/.test(parts.at(-1)??'')||!listBlockContainers(
|
|
82
|
-
const blocks=getValueAtPath(
|
|
82
|
+
if(parts.at(-2)!=='blocks'||!/^(0|[1-9][0-9]*)$/.test(parts.at(-1)??'')||!listBlockContainers(presentation).some(c=>c.path===opfPathToJsonPointer(container)))throw new TypeError('Choose a complete block using its blocks/index path.');
|
|
83
|
+
const blocks=getValueAtPath(presentation,[...container,'blocks']);
|
|
83
84
|
if(index>=blocks.length)throw new RangeError('Block does not exist.');
|
|
84
85
|
return {parts,container,index,blocks};
|
|
85
86
|
}
|
|
@@ -90,21 +91,21 @@ function checkedBlock(document,path) {
|
|
|
90
91
|
* region payload (exactly one content field, no `blocks`) is replaced in place, keeping its
|
|
91
92
|
* other slide fields. The test guard fails if the block changed since it was read.
|
|
92
93
|
*/
|
|
93
|
-
export function prepareBlockReplace(
|
|
94
|
+
export function prepareBlockReplace(presentation,path,block) {
|
|
94
95
|
if(!block||typeof block!=='object'||Array.isArray(block))throw new TypeError('Replace with an OPF content block object.');
|
|
95
96
|
const parts=splitOpfPath(path),pointer=opfPathToJsonPointer(parts);
|
|
96
97
|
const explicit=parts.at(-2)==='blocks'&&/^(0|[1-9][0-9]*)$/.test(parts.at(-1)??'');
|
|
97
98
|
let old,next;
|
|
98
99
|
if(explicit){
|
|
99
|
-
const {index,blocks}=checkedBlock(
|
|
100
|
+
const {index,blocks}=checkedBlock(presentation,path);
|
|
100
101
|
old=blocks[index];
|
|
101
102
|
next=structuredClone(block);
|
|
102
103
|
for(const key of ['id','extensions'])if(next[key]===undefined&&old[key]!==undefined)next[key]=structuredClone(old[key]);
|
|
103
104
|
} else {
|
|
104
|
-
const implicit=listBlockContainers(
|
|
105
|
+
const implicit=listBlockContainers(presentation,{includeImplicit:true}).find(c=>c.path===pointer&&c.implicit);
|
|
105
106
|
if(!implicit)throw new TypeError('Choose a complete block using its blocks/index path, or a slide or region with one content field.');
|
|
106
107
|
if(implicit.count!==1)throw new TypeError('This slide or region holds several content fields. Choose one block, or insert and remove blocks.');
|
|
107
|
-
old=getValueAtPath(
|
|
108
|
+
old=getValueAtPath(presentation,parts);
|
|
108
109
|
next=structuredClone(old);
|
|
109
110
|
for(const key of [...contentFields,'type'])delete next[key];
|
|
110
111
|
// The block's own id and extensions would overwrite the slide's; they are not carried over.
|
|
@@ -112,28 +113,28 @@ export function prepareBlockReplace(document,path,block) {
|
|
|
112
113
|
Object.assign(next,payload);
|
|
113
114
|
}
|
|
114
115
|
const patches=[{op:'test',path:pointer,value:structuredClone(old)},{op:'replace',path:pointer,value:next}];
|
|
115
|
-
const result=applyJsonPatch(
|
|
116
|
-
if(!validation.valid)throw new Error(validation
|
|
117
|
-
return {
|
|
116
|
+
const result=applyJsonPatch(presentation,patches),validation=checkFormat(result);
|
|
117
|
+
if(!validation.valid)throw new Error(firstErrorMessage(validation, 'The replaced block is not valid OPF.'));
|
|
118
|
+
return {presentation:result,patches,path:pointer,changed:JSON.stringify(old)!==JSON.stringify(next)};
|
|
118
119
|
}
|
|
119
120
|
/** Duplicate the entire block immediately after itself, preserving formatting and asset references. */
|
|
120
|
-
export function prepareBlockDuplicate(
|
|
121
|
-
const {container,index,blocks}=checkedBlock(
|
|
122
|
-
return prepareBlockInsert(
|
|
121
|
+
export function prepareBlockDuplicate(presentation,path) {
|
|
122
|
+
const {container,index,blocks}=checkedBlock(presentation,path);
|
|
123
|
+
return prepareBlockInsert(presentation,opfPathToJsonPointer(container),blocks[index],index+1);
|
|
123
124
|
}
|
|
124
125
|
/** Delete a block and prune empty ancestor groups; a slide itself is never deleted. */
|
|
125
|
-
export function prepareBlockRemove(
|
|
126
|
-
let {parts,container,index,blocks}=checkedBlock(
|
|
126
|
+
export function prepareBlockRemove(presentation,path) {
|
|
127
|
+
let {parts,container,index,blocks}=checkedBlock(presentation,path);
|
|
127
128
|
while(blocks.length===1&&container.length>2){
|
|
128
129
|
if(container.length===3&&isRegion(container[2])){
|
|
129
|
-
const slidePath=container.slice(0,2),next=structuredClone(getValueAtPath(
|
|
130
|
-
return preparedChange(
|
|
130
|
+
const slidePath=container.slice(0,2),next=structuredClone(getValueAtPath(presentation,slidePath));delete next[container[2]];
|
|
131
|
+
return preparedChange(presentation,slidePath,next,slidePath);
|
|
131
132
|
}
|
|
132
|
-
({parts,container,index,blocks}=checkedBlock(
|
|
133
|
+
({parts,container,index,blocks}=checkedBlock(presentation,container));
|
|
133
134
|
}
|
|
134
|
-
const next=structuredClone(getValueAtPath(
|
|
135
|
+
const next=structuredClone(getValueAtPath(presentation,container));next.blocks.splice(index,1);
|
|
135
136
|
const selection=next.blocks.length?[...container,'blocks',String(Math.min(index,next.blocks.length-1))]:container;
|
|
136
|
-
return preparedChange(
|
|
137
|
+
return preparedChange(presentation,container,next,selection);
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
/** Small, schema-valid starting points. Media requires an explicit source. */
|
package/dist/canvas-fields.js
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
applyJsonPatch,
|
|
3
|
-
createValuePatch,
|
|
4
|
-
validateOpfDocument,
|
|
5
|
-
splitOpfPath,
|
|
6
|
-
opfPathToJsonPointer,
|
|
7
|
-
} from "./index.js";
|
|
1
|
+
import { applyJsonPatch, createValuePatch, splitOpfPath, opfPathToJsonPointer } from "./index.js";
|
|
8
2
|
import {preserveTextLineEndings} from './text-input.js';
|
|
3
|
+
import { firstErrorMessage, checkFormat } from "./checks.js";
|
|
9
4
|
export {preserveTextLineEndings} from './text-input.js';
|
|
10
5
|
/** Scalar fields and collection paths use JSON Pointer to preserve arbitrary object keys. */
|
|
11
6
|
export function getEditableFields(value, path) {
|
|
@@ -52,15 +47,43 @@ export function parseCanvasValue(text, type, original) {
|
|
|
52
47
|
return type === 'string' && typeof original === 'string' ? preserveTextLineEndings(original,String(text)) : text;
|
|
53
48
|
}
|
|
54
49
|
|
|
55
|
-
export function createCanvasDraft(
|
|
50
|
+
export function createCanvasDraft(presentation, path, value) {
|
|
56
51
|
const draft = applyJsonPatch(
|
|
57
|
-
|
|
58
|
-
createValuePatch(
|
|
52
|
+
presentation,
|
|
53
|
+
createValuePatch(presentation, path, value),
|
|
59
54
|
);
|
|
60
|
-
const validation =
|
|
55
|
+
const validation = checkFormat(draft);
|
|
61
56
|
if (!validation.valid)
|
|
62
57
|
throw new Error(
|
|
63
|
-
validation
|
|
58
|
+
firstErrorMessage(validation, "This change is not valid OPF."),
|
|
64
59
|
);
|
|
65
60
|
return draft;
|
|
66
61
|
}
|
|
62
|
+
|
|
63
|
+
/** The progress states of a timeline event (`TimelineEvent.status`). */
|
|
64
|
+
export const TIMELINE_STATUS_CHOICES = Object.freeze(["done", "current", "planned"]);
|
|
65
|
+
/** The fixed choices of a form field, or undefined for free text: an event's `status` inside a timeline payload. */
|
|
66
|
+
export function fieldChoices(field) {
|
|
67
|
+
const segments = splitOpfPath(field.path);
|
|
68
|
+
return segments.at(-1) === "status" && segments.at(-3) === "timeline"
|
|
69
|
+
? TIMELINE_STATUS_CHOICES
|
|
70
|
+
: segments.at(-1) === "status" && segments.at(-3) === "events" && segments.at(-4) === "timeline"
|
|
71
|
+
? TIMELINE_STATUS_CHOICES
|
|
72
|
+
: undefined;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A blank copy of `value`'s shape for the "Add" button: texts empty, numbers 0, booleans false, `type`, `mode` and
|
|
76
|
+
* `language` kept. A timeline event's `status` is left out, so a new event starts with no status instead of a copy.
|
|
77
|
+
*/
|
|
78
|
+
export function emptyLike(value, key = "") {
|
|
79
|
+
if (Array.isArray(value)) return value.map((child) => emptyLike(child));
|
|
80
|
+
if (value && typeof value === "object")
|
|
81
|
+
return Object.fromEntries(
|
|
82
|
+
Object.entries(value)
|
|
83
|
+
.filter(([name]) => name !== "status")
|
|
84
|
+
.map(([name, child]) => [name, emptyLike(child, name)]),
|
|
85
|
+
);
|
|
86
|
+
if (typeof value === "number") return 0;
|
|
87
|
+
if (typeof value === "boolean") return false;
|
|
88
|
+
return ["type", "mode", "language"].includes(key) ? value : "";
|
|
89
|
+
}
|