@openpresentation/opf-editor 0.0.1 → 0.1.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 +79 -0
- package/dist/block-controls.js +143 -0
- package/dist/blocks.d.ts +13 -0
- package/dist/blocks.js +125 -0
- package/dist/canvas-fields.js +63 -0
- package/dist/canvas.d.ts +67 -0
- package/dist/canvas.js +748 -0
- package/dist/data.d.ts +2 -0
- package/dist/data.js +1 -0
- package/dist/galleries.d.ts +37 -0
- package/dist/galleries.js +237 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.js +77 -6
- package/dist/layout-handles.js +116 -0
- package/dist/layout.d.ts +10 -0
- package/dist/layout.js +34 -0
- package/dist/rich-text-toolbar.js +123 -0
- package/dist/rich-text.d.ts +10 -0
- package/dist/rich-text.js +71 -0
- package/dist/schema-inspector.d.ts +3 -0
- package/dist/schema-inspector.js +100 -0
- package/dist/schema.d.ts +10 -0
- package/dist/schema.js +102 -0
- package/dist/transfer.d.ts +27 -0
- package/dist/transfer.js +256 -0
- package/package.json +38 -6
package/dist/data.d.ts
ADDED
package/dist/data.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {parseTabularData, createDataContent, OPFDataImportError} from "@openpresentation/opf/data";
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface GalleryOptions {
|
|
2
|
+
fetch?: typeof fetch;
|
|
3
|
+
signal?: AbortSignal;
|
|
4
|
+
base?: string;
|
|
5
|
+
origin?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface GalleryItem {
|
|
8
|
+
id?: string;
|
|
9
|
+
name?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
category?: string;
|
|
12
|
+
url?: string;
|
|
13
|
+
raw?: unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface OpfGallery {
|
|
16
|
+
name: string;
|
|
17
|
+
url: string;
|
|
18
|
+
items: GalleryItem[];
|
|
19
|
+
}
|
|
20
|
+
export declare function galleryUrl(input: string, base?: string): URL;
|
|
21
|
+
export declare function normalizeGalleryUrl(
|
|
22
|
+
input: string,
|
|
23
|
+
base?: string,
|
|
24
|
+
): string;
|
|
25
|
+
export declare function galleryItemUrl(input: string, base?: string): string;
|
|
26
|
+
export declare function fetchGalleryJson(
|
|
27
|
+
input: string,
|
|
28
|
+
options?: GalleryOptions,
|
|
29
|
+
): Promise<any>;
|
|
30
|
+
export declare function loadOpfGallery(
|
|
31
|
+
input: string,
|
|
32
|
+
options?: GalleryOptions,
|
|
33
|
+
): Promise<OpfGallery>;
|
|
34
|
+
export declare function loadOpfGalleryItem(
|
|
35
|
+
item: GalleryItem,
|
|
36
|
+
options?: GalleryOptions & { gallery?: string },
|
|
37
|
+
): Promise<any>;
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { assertOpf, unwrapOpf, MAX_OPF_BYTES } from "./transfer.js";
|
|
2
|
+
import { catalogs } from "@openpresentation/opf";
|
|
3
|
+
export function galleryUrl(input, base) {
|
|
4
|
+
const url = new URL(input, base);
|
|
5
|
+
if (
|
|
6
|
+
!["http:", "https:"].includes(url.protocol) ||
|
|
7
|
+
url.username ||
|
|
8
|
+
url.password
|
|
9
|
+
)
|
|
10
|
+
throw new Error("Use a public HTTP or HTTPS URL without credentials.");
|
|
11
|
+
return url;
|
|
12
|
+
}
|
|
13
|
+
export function normalizeGalleryUrl(input, base) {
|
|
14
|
+
const url = galleryUrl(input, base);
|
|
15
|
+
if (url.hostname === "pptx.gallery") url.hostname = "www.pptx.gallery";
|
|
16
|
+
if (url.pathname === "/" || url.pathname === "")
|
|
17
|
+
url.pathname = "/registry.json";
|
|
18
|
+
return url.href;
|
|
19
|
+
}
|
|
20
|
+
export function galleryItemUrl(input, base) {
|
|
21
|
+
const url = galleryUrl(input, base);
|
|
22
|
+
if (["pptx.gallery", "www.pptx.gallery"].includes(url.hostname)) {
|
|
23
|
+
url.hostname = "www.pptx.gallery";
|
|
24
|
+
const match = url.pathname.match(
|
|
25
|
+
/^\/(layouts|color-schemes|colors|font-schemes|typography|themes|charts|backgrounds|narratives)\/([^/]+)\/?$/,
|
|
26
|
+
);
|
|
27
|
+
if (match)
|
|
28
|
+
url.pathname = `/registry/${{ colors: "color-schemes", typography: "font-schemes" }[match[1]] ?? match[1]}/${match[2]}.json`;
|
|
29
|
+
const content = url.pathname.match(
|
|
30
|
+
/^\/(blocks|image-treatments)\/([^/]+)\/?$/,
|
|
31
|
+
);
|
|
32
|
+
if (content) {
|
|
33
|
+
url.pathname = `/api/${content[1]}.json`;
|
|
34
|
+
url.hash = `opf-item=${encodeURIComponent(content[2])}`;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return url.href;
|
|
38
|
+
}
|
|
39
|
+
export async function fetchGalleryJson(
|
|
40
|
+
input,
|
|
41
|
+
{ fetch: fetcher = globalThis.fetch, signal, base, origin } = {},
|
|
42
|
+
) {
|
|
43
|
+
const url = galleryUrl(input, base);
|
|
44
|
+
if (origin && url.origin !== origin)
|
|
45
|
+
throw new Error(
|
|
46
|
+
"This item points outside its configured gallery. Load its URL explicitly to use it.",
|
|
47
|
+
);
|
|
48
|
+
const response = await fetcher(url.href, {
|
|
49
|
+
signal,
|
|
50
|
+
credentials: "omit",
|
|
51
|
+
referrerPolicy: "no-referrer",
|
|
52
|
+
});
|
|
53
|
+
if (!response.ok)
|
|
54
|
+
throw new Error(`Gallery returned HTTP ${response.status}.`);
|
|
55
|
+
if (origin && response.url && new URL(response.url).origin !== origin)
|
|
56
|
+
throw new Error(
|
|
57
|
+
"The gallery redirected to another origin. Add its destination explicitly.",
|
|
58
|
+
);
|
|
59
|
+
if (Number(response.headers?.get("content-length")) > MAX_OPF_BYTES)
|
|
60
|
+
throw new Error("Gallery response exceeds 20 MB.");
|
|
61
|
+
let text = "";
|
|
62
|
+
if (response.body?.getReader) {
|
|
63
|
+
const reader = response.body.getReader();
|
|
64
|
+
const decoder = new TextDecoder();
|
|
65
|
+
let count = 0;
|
|
66
|
+
try {
|
|
67
|
+
while (true) {
|
|
68
|
+
const { done, value } = await reader.read();
|
|
69
|
+
if (done) break;
|
|
70
|
+
count += value.byteLength;
|
|
71
|
+
if (count > MAX_OPF_BYTES)
|
|
72
|
+
throw new Error("Gallery response exceeds 20 MB.");
|
|
73
|
+
text += decoder.decode(value, { stream: true });
|
|
74
|
+
}
|
|
75
|
+
text += decoder.decode();
|
|
76
|
+
} catch (error) {
|
|
77
|
+
await reader.cancel();
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
text = await response.text();
|
|
82
|
+
if (new TextEncoder().encode(text).length > MAX_OPF_BYTES)
|
|
83
|
+
throw new Error("Gallery response exceeds 20 MB.");
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(text);
|
|
87
|
+
} catch {
|
|
88
|
+
throw new Error(
|
|
89
|
+
"This URL did not return JSON. Use a gallery registry, OPF URL, or supported gallery item link.",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export async function loadOpfGallery(input, options = {}) {
|
|
94
|
+
const url = normalizeGalleryUrl(input, options.base);
|
|
95
|
+
const data = await fetchGalleryJson(url, options);
|
|
96
|
+
const entries = Array.isArray(data) ? data : (data.items ?? data.entries);
|
|
97
|
+
if (!Array.isArray(entries))
|
|
98
|
+
throw new Error(
|
|
99
|
+
"A gallery needs an items array. Use Load URL for a single OPF document.",
|
|
100
|
+
);
|
|
101
|
+
if (entries.length > 5000)
|
|
102
|
+
throw new Error("A gallery can contain at most 5,000 entries.");
|
|
103
|
+
return {
|
|
104
|
+
name: String(data.name ?? new URL(url).hostname),
|
|
105
|
+
url,
|
|
106
|
+
items: entries.map((item, index) => ({
|
|
107
|
+
id: String(item.id ?? item.slug ?? index),
|
|
108
|
+
name: String(
|
|
109
|
+
item.name ?? item.title ?? item.id ?? `Example ${index + 1}`,
|
|
110
|
+
),
|
|
111
|
+
description: String(item.description ?? ""),
|
|
112
|
+
category: String(
|
|
113
|
+
item.category ?? item.type?.replace("registry:", "") ?? "Examples",
|
|
114
|
+
),
|
|
115
|
+
url: item.registry_url ?? item.opfUrl ?? item.url,
|
|
116
|
+
raw: item,
|
|
117
|
+
})),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function attachDefinition(document, descriptor) {
|
|
121
|
+
const source = descriptor.metadata?.source,
|
|
122
|
+
category = descriptor.category ?? descriptor.metadata?.category;
|
|
123
|
+
if (!source) return;
|
|
124
|
+
const kind = {
|
|
125
|
+
layouts: "layouts",
|
|
126
|
+
"font-schemes": "fontSchemes",
|
|
127
|
+
"color-schemes": "colorSchemes",
|
|
128
|
+
themes: "themes",
|
|
129
|
+
}[category];
|
|
130
|
+
if (!kind) return;
|
|
131
|
+
const id = source.id ?? source.slug;
|
|
132
|
+
if (!id) return;
|
|
133
|
+
if (
|
|
134
|
+
catalogs[kind]?.some((record) => record.id === id) ||
|
|
135
|
+
document.catalogs?.[kind]?.records?.some((record) => record.id === id)
|
|
136
|
+
)
|
|
137
|
+
return;
|
|
138
|
+
let record;
|
|
139
|
+
if (kind === "layouts") {
|
|
140
|
+
const n = Math.min(
|
|
141
|
+
12,
|
|
142
|
+
Number(String(source.contentMultiple ?? "").replace("x", "")) || 1,
|
|
143
|
+
);
|
|
144
|
+
const type =
|
|
145
|
+
{ Image: "picture", Chart: "chart", List: "list" }[source.contentType] ??
|
|
146
|
+
"text";
|
|
147
|
+
record = {
|
|
148
|
+
$schema: "https://openpresentation.org/schema/opf-layout/v1",
|
|
149
|
+
id,
|
|
150
|
+
name: source.label ?? source.name ?? id,
|
|
151
|
+
placeholders: [
|
|
152
|
+
{ type: "title" },
|
|
153
|
+
...Array.from({ length: n }, () => ({ type })),
|
|
154
|
+
],
|
|
155
|
+
...(n > 1
|
|
156
|
+
? {
|
|
157
|
+
composition: {
|
|
158
|
+
mode: n <= 3 ? "row" : "grid",
|
|
159
|
+
...(n > 3 ? { columns: 2 } : {}),
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
: {}),
|
|
163
|
+
};
|
|
164
|
+
} else if (kind === "fontSchemes")
|
|
165
|
+
record = {
|
|
166
|
+
$schema: "https://openpresentation.org/schema/opf-font-scheme/v1",
|
|
167
|
+
id,
|
|
168
|
+
name: source.name ?? id,
|
|
169
|
+
major: source.major ?? source.heading,
|
|
170
|
+
minor: source.minor ?? source.body,
|
|
171
|
+
};
|
|
172
|
+
else {
|
|
173
|
+
record = {
|
|
174
|
+
...source,
|
|
175
|
+
$schema: `https://openpresentation.org/schema/opf-${kind === "themes" ? "theme" : "color-scheme"}/v1`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
document.catalogs ??= {};
|
|
179
|
+
document.catalogs[kind] ??= { records: [] };
|
|
180
|
+
document.catalogs[kind].records ??= [];
|
|
181
|
+
document.catalogs[kind].records.push(record);
|
|
182
|
+
}
|
|
183
|
+
export async function loadOpfGalleryItem(item, { gallery, ...options } = {}) {
|
|
184
|
+
const base = gallery ?? options.base;
|
|
185
|
+
const url = item.url ? galleryItemUrl(item.url, base) : base;
|
|
186
|
+
const origin = gallery ? new URL(gallery).origin : undefined;
|
|
187
|
+
let descriptor = item.url
|
|
188
|
+
? await fetchGalleryJson(url, { ...options, base, origin })
|
|
189
|
+
: (item.raw ?? item);
|
|
190
|
+
if (url && new URL(url).hash.startsWith("#opf-item=")) {
|
|
191
|
+
const slug = decodeURIComponent(new URL(url).hash.slice(10));
|
|
192
|
+
descriptor = descriptor.items?.find(
|
|
193
|
+
(entry) => (entry.slug ?? entry.id) === slug,
|
|
194
|
+
);
|
|
195
|
+
if (!descriptor) throw new Error("This gallery item was not found.");
|
|
196
|
+
}
|
|
197
|
+
const document = structuredClone(unwrapOpf(descriptor));
|
|
198
|
+
if (!document?.slides)
|
|
199
|
+
throw new Error("This gallery item does not include a presentation.");
|
|
200
|
+
attachDefinition(document, descriptor);
|
|
201
|
+
// PPTX.gallery's older descriptors omit inline layout records. Resolve only named
|
|
202
|
+
// layout references from its known, same-origin registry; no arbitrary dependency crawl.
|
|
203
|
+
if (
|
|
204
|
+
url &&
|
|
205
|
+
["www.pptx.gallery", "pptx.gallery"].includes(new URL(url).hostname)
|
|
206
|
+
) {
|
|
207
|
+
const missing = [
|
|
208
|
+
...new Set(
|
|
209
|
+
document.slides
|
|
210
|
+
.map((slide) => slide.layout)
|
|
211
|
+
.filter(
|
|
212
|
+
(id) =>
|
|
213
|
+
typeof id === "string" &&
|
|
214
|
+
!catalogs.layouts.some((record) => record.id === id) &&
|
|
215
|
+
!document.catalogs?.layouts?.records?.some(
|
|
216
|
+
(record) => record.id === id,
|
|
217
|
+
),
|
|
218
|
+
),
|
|
219
|
+
),
|
|
220
|
+
];
|
|
221
|
+
if (missing.length > 12)
|
|
222
|
+
throw new Error(
|
|
223
|
+
"This item needs too many layout definitions. Supply self-contained OPF.",
|
|
224
|
+
);
|
|
225
|
+
for (const id of missing) {
|
|
226
|
+
if (!/^[a-z][a-z0-9-]*$/.test(id))
|
|
227
|
+
throw new Error("Unsupported layout reference.");
|
|
228
|
+
const dependency = await fetchGalleryJson(
|
|
229
|
+
`/registry/layouts/${id}.json`,
|
|
230
|
+
{ ...options, base: url, origin: new URL(url).origin },
|
|
231
|
+
);
|
|
232
|
+
attachDefinition(document, dependency);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
assertOpf(document);
|
|
236
|
+
return document;
|
|
237
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { PaginationOptions, PaginationResult } from "@openpresentation/opf/pagination";
|
|
2
|
+
import type { Composition, ComposeSlideOptions, SlideComposition } from "@openpresentation/opf/composition";
|
|
1
3
|
export declare const packageName = "@openpresentation/opf-editor";
|
|
2
4
|
|
|
3
5
|
export declare const releaseLane: Readonly<{
|
|
@@ -18,7 +20,8 @@ export declare const runtimePolicy: Readonly<{
|
|
|
18
20
|
export type JsonPatchOperation =
|
|
19
21
|
| { op: "add"; path: string; value: unknown }
|
|
20
22
|
| { op: "replace"; path: string; value: unknown }
|
|
21
|
-
| { op: "remove"; path: string }
|
|
23
|
+
| { op: "remove"; path: string }
|
|
24
|
+
| { op: "test"; path: string; value: unknown };
|
|
22
25
|
|
|
23
26
|
export interface OPFValidationSummary {
|
|
24
27
|
valid: boolean;
|
|
@@ -54,6 +57,9 @@ export interface EditorEvent {
|
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
export interface EditorSession {
|
|
60
|
+
paginateSlide(slideIndex: number, options?: PaginationOptions, meta?: Record<string, unknown>): { change: EditorChange | null; pagination: PaginationResult };
|
|
61
|
+
composeSlide(slideIndex: number, options?: ComposeSlideOptions): SlideComposition;
|
|
62
|
+
setComposition(slideIndex: number, composition: Composition, meta?: Record<string, unknown>): EditorChange;
|
|
57
63
|
readonly document: unknown;
|
|
58
64
|
readonly validation: OPFValidationSummary;
|
|
59
65
|
readonly canUndo: boolean;
|
|
@@ -62,6 +68,7 @@ export interface EditorSession {
|
|
|
62
68
|
subscribe(listener: (event: EditorEvent) => void): () => void;
|
|
63
69
|
get(path: string | string[], fallback?: unknown): unknown;
|
|
64
70
|
set(path: string | string[], value: unknown, meta?: Record<string, unknown>): EditorChange;
|
|
71
|
+
setGroupComposition(path: string, composition: Composition, meta?: Record<string, unknown>): EditorChange;
|
|
65
72
|
setCatalog(path: string | string[], catalogKind: string, id: string, meta?: Record<string, unknown>): EditorChange;
|
|
66
73
|
applyPatch(operations: JsonPatchOperation[], meta?: Record<string, unknown>): EditorChange;
|
|
67
74
|
undo(meta?: Record<string, unknown>): EditorChange | null;
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,27 @@
|
|
|
1
|
+
import { paginateSlide } from "@openpresentation/opf/pagination";
|
|
2
|
+
import { composeSlide, resolveCanvasDimensions, resolveFontFamilies } from "@openpresentation/opf/composition";
|
|
1
3
|
import {
|
|
2
4
|
catalogKinds,
|
|
3
5
|
catalogs as bundledCatalogs,
|
|
4
6
|
validatePresentation
|
|
5
7
|
} from "@openpresentation/opf";
|
|
6
8
|
|
|
9
|
+
function resolveCompositionOptions(document, slideIndex, options = {}) {
|
|
10
|
+
const slide = document.slides?.[slideIndex];
|
|
11
|
+
if (!Number.isInteger(slideIndex) || !slide) throw new OPFEditorError("slide-index-out-of-range", "Slide index is out of range.");
|
|
12
|
+
const inline = document.catalogs?.layouts?.records ?? [];
|
|
13
|
+
const layout = options.layout ?? inline.find(record => record.id === slide.layout)
|
|
14
|
+
?? bundledCatalogs.layouts.find(record => record.id === slide.layout);
|
|
15
|
+
const themeReference = slide.design?.theme ?? document.design?.theme ?? "minimal";
|
|
16
|
+
const themeId = typeof themeReference === "string" ? themeReference : themeReference.id;
|
|
17
|
+
const theme = document.catalogs?.themes?.records?.find(record => record.id === themeId)
|
|
18
|
+
?? bundledCatalogs.themes.find(record => record.id === themeId);
|
|
19
|
+
const reference = slide.design?.fontScheme ?? document.design?.fontScheme ?? theme?.fontScheme ?? "roboto";
|
|
20
|
+
const id = typeof reference === "string" ? reference : reference.id;
|
|
21
|
+
const fontScheme = {...(document.catalogs?.fontSchemes?.records?.find(record=>record.id===id) ?? bundledCatalogs.fontSchemes.find(record=>record.id===id)),...(typeof reference === "object" ? reference : {})};
|
|
22
|
+
return { ...resolveCanvasDimensions(slide.design?.dimensions ?? document.design?.dimensions ?? theme?.dimensions), fonts:resolveFontFamilies(fontScheme), ...options, layout, slideIndex };
|
|
23
|
+
}
|
|
24
|
+
|
|
7
25
|
export const packageName = "@openpresentation/opf-editor";
|
|
8
26
|
|
|
9
27
|
export const releaseLane = Object.freeze({
|
|
@@ -135,8 +153,10 @@ export function createEditorSession(input, options = {}) {
|
|
|
135
153
|
const redoStack = [];
|
|
136
154
|
const listeners = new Set();
|
|
137
155
|
const rejectInvalid = Boolean(options.rejectInvalid);
|
|
156
|
+
let snapshotCache;
|
|
138
157
|
|
|
139
158
|
function emit(event) {
|
|
159
|
+
snapshotCache = undefined;
|
|
140
160
|
const snapshot = editor.snapshot();
|
|
141
161
|
for (const listener of listeners) listener({ ...event, snapshot });
|
|
142
162
|
}
|
|
@@ -156,6 +176,9 @@ export function createEditorSession(input, options = {}) {
|
|
|
156
176
|
});
|
|
157
177
|
}
|
|
158
178
|
|
|
179
|
+
if (patches.every(patch => patch.op === "test")) return {
|
|
180
|
+
document: clone(document), patches: clonePatchOperations(patches), inversePatches: [], validation: nextValidation
|
|
181
|
+
};
|
|
159
182
|
document = next;
|
|
160
183
|
validation = nextValidation;
|
|
161
184
|
undoStack.push({ patches: clonePatchOperations(patches), inversePatches, meta });
|
|
@@ -190,14 +213,14 @@ export function createEditorSession(input, options = {}) {
|
|
|
190
213
|
return redoStack.length > 0;
|
|
191
214
|
},
|
|
192
215
|
snapshot() {
|
|
193
|
-
return {
|
|
216
|
+
return snapshotCache ??= freezeSnapshot({
|
|
194
217
|
document: clone(document),
|
|
195
|
-
validation,
|
|
218
|
+
validation: clone(validation),
|
|
196
219
|
canUndo: undoStack.length > 0,
|
|
197
220
|
canRedo: redoStack.length > 0,
|
|
198
221
|
undoDepth: undoStack.length,
|
|
199
222
|
redoDepth: redoStack.length
|
|
200
|
-
};
|
|
223
|
+
});
|
|
201
224
|
},
|
|
202
225
|
subscribe(listener) {
|
|
203
226
|
if (typeof listener !== "function") {
|
|
@@ -215,6 +238,28 @@ export function createEditorSession(input, options = {}) {
|
|
|
215
238
|
path: normalizePatchPath(path)
|
|
216
239
|
});
|
|
217
240
|
},
|
|
241
|
+
composeSlide(slideIndex, options = {}) {
|
|
242
|
+
return composeSlide(document.slides?.[slideIndex], resolveCompositionOptions(document, slideIndex, options));
|
|
243
|
+
},
|
|
244
|
+
paginateSlide(slideIndex, options = {}, meta = {}) {
|
|
245
|
+
const resolved = resolveCompositionOptions(document, slideIndex, options);
|
|
246
|
+
const pagination = paginateSlide(document.slides[slideIndex], { ...resolved, reservedIds: document.slides.map(slide => slide.id).filter(Boolean) });
|
|
247
|
+
if (pagination.slides.length === 1) return { change: null, pagination };
|
|
248
|
+
const slides = [...document.slides];
|
|
249
|
+
slides.splice(slideIndex, 1, ...pagination.slides);
|
|
250
|
+
const change = editor.set('slides', slides, { ...meta, rejectInvalid: true, pagination: pagination.pages });
|
|
251
|
+
return { change, pagination };
|
|
252
|
+
},
|
|
253
|
+
setComposition(slideIndex, composition, meta = {}) {
|
|
254
|
+
if (!Number.isInteger(slideIndex) || !document.slides?.[slideIndex]) throw new OPFEditorError("slide-index-out-of-range", "Slide index is out of range.");
|
|
255
|
+
return editor.set(`slides.${slideIndex}.composition`, composition, { ...meta, rejectInvalid: true });
|
|
256
|
+
},
|
|
257
|
+
setGroupComposition(path, composition, meta = {}) {
|
|
258
|
+
const pointer = normalizePatchPath(path);
|
|
259
|
+
const group = editor.get(pointer);
|
|
260
|
+
if (!pointer.startsWith("/slides/") || !group || !Array.isArray(group.blocks) || /^\/slides\/[^/]+$/.test(pointer)) throw new OPFEditorError("not-a-content-group", "Select a content group containing blocks.");
|
|
261
|
+
return editor.set(`${pointer}/composition`, composition, { ...meta, rejectInvalid: true });
|
|
262
|
+
},
|
|
218
263
|
setCatalog(path, catalogKind, id, meta = {}) {
|
|
219
264
|
return setCatalogId(editor, path, catalogKind, id, meta);
|
|
220
265
|
},
|
|
@@ -301,10 +346,11 @@ export function createSvgTraceBinding(root, editor, options = {}) {
|
|
|
301
346
|
if (options.interactive !== false) {
|
|
302
347
|
ensureInteractiveTraceElement(element);
|
|
303
348
|
|
|
304
|
-
const onClick = (event) => select(element, event);
|
|
349
|
+
const onClick = (event) => { event.stopPropagation?.(); select(element, event); };
|
|
305
350
|
const onKeydown = (event) => {
|
|
306
351
|
if (event.key === "Enter" || event.key === " ") {
|
|
307
352
|
event.preventDefault?.();
|
|
353
|
+
event.stopPropagation?.();
|
|
308
354
|
select(element, event);
|
|
309
355
|
}
|
|
310
356
|
};
|
|
@@ -520,11 +566,13 @@ function normalizeOperation(operation) {
|
|
|
520
566
|
if (!operation || typeof operation !== "object") {
|
|
521
567
|
throw new OPFEditorError("invalid-patch-operation", "JSON Patch operation must be an object.", { operation });
|
|
522
568
|
}
|
|
523
|
-
if (!["add", "replace", "remove"].includes(operation.op)) {
|
|
569
|
+
if (!["add", "replace", "remove", "test"].includes(operation.op)) {
|
|
524
570
|
throw new OPFEditorError("unsupported-patch-operation", `Unsupported JSON Patch operation: ${operation.op}.`, {
|
|
525
571
|
operation
|
|
526
572
|
});
|
|
527
573
|
}
|
|
574
|
+
if (operation.op === "test" && !Object.prototype.hasOwnProperty.call(operation, "value"))
|
|
575
|
+
throw new OPFEditorError("invalid-patch-operation", "A test operation requires a value.");
|
|
528
576
|
const normalized = {
|
|
529
577
|
op: operation.op,
|
|
530
578
|
path: normalizePatchPath(operation.path)
|
|
@@ -556,8 +604,20 @@ function readAtPath(document, segments) {
|
|
|
556
604
|
return current;
|
|
557
605
|
}
|
|
558
606
|
|
|
607
|
+
function jsonEqual(a, b) {
|
|
608
|
+
if (a === b) return true;
|
|
609
|
+
if (a === null || b === null || typeof a !== "object" || typeof b !== "object" || Array.isArray(a) !== Array.isArray(b)) return false;
|
|
610
|
+
const keys = Object.keys(a);
|
|
611
|
+
return keys.length === Object.keys(b).length && keys.every(key => Object.prototype.hasOwnProperty.call(b, key) && jsonEqual(a[key], b[key]));
|
|
612
|
+
}
|
|
559
613
|
function applyJsonPatchOperation(document, operation) {
|
|
560
614
|
const segments = parseJsonPointer(operation.path);
|
|
615
|
+
if (operation.op === "test") {
|
|
616
|
+
const current = readAtPath(document, segments);
|
|
617
|
+
if (current === MISSING || !jsonEqual(current, operation.value))
|
|
618
|
+
throw new OPFEditorError("patch-test-failed", `The value changed at ${operation.path || "/"}.`, {path: operation.path});
|
|
619
|
+
return clone(document);
|
|
620
|
+
}
|
|
561
621
|
if (segments.length === 0) {
|
|
562
622
|
if (operation.op === "remove") return undefined;
|
|
563
623
|
return clone(operation.value);
|
|
@@ -596,6 +656,9 @@ function pathParentIsArray(document, path) {
|
|
|
596
656
|
}
|
|
597
657
|
|
|
598
658
|
function applyArrayOperation(parent, key, operation) {
|
|
659
|
+
if (!/^(0|[1-9][0-9]*)$/.test(key) && !(key === "-" && operation.op === "add")) {
|
|
660
|
+
throw new OPFEditorError("invalid-array-index", `Invalid array index in patch path ${operation.path}.`, { path: operation.path });
|
|
661
|
+
}
|
|
599
662
|
const index = key === "-" ? parent.length : Number(key);
|
|
600
663
|
if (!Number.isInteger(index) || index < 0 || index > parent.length) {
|
|
601
664
|
throw new OPFEditorError("invalid-array-index", `Invalid array index in patch path ${operation.path}.`, {
|
|
@@ -622,7 +685,7 @@ function applyArrayOperation(parent, key, operation) {
|
|
|
622
685
|
|
|
623
686
|
function applyObjectOperation(parent, key, operation) {
|
|
624
687
|
if (operation.op === "add") {
|
|
625
|
-
parent
|
|
688
|
+
Object.defineProperty(parent, key, { value: clone(operation.value), writable: true, enumerable: true, configurable: true });
|
|
626
689
|
return;
|
|
627
690
|
}
|
|
628
691
|
|
|
@@ -707,3 +770,11 @@ function resolveDomDocument(documentRef) {
|
|
|
707
770
|
}
|
|
708
771
|
return resolved;
|
|
709
772
|
}
|
|
773
|
+
|
|
774
|
+
function freezeSnapshot(value) {
|
|
775
|
+
if (value && typeof value === "object" && !Object.isFrozen(value)) {
|
|
776
|
+
Object.values(value).forEach(freezeSnapshot);
|
|
777
|
+
Object.freeze(value);
|
|
778
|
+
}
|
|
779
|
+
return value;
|
|
780
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {prepareTrackResize} from './layout.js';
|
|
2
|
+
import {getValueAtPath} from './index.js';
|
|
3
|
+
|
|
4
|
+
export function createLayoutHandles(root, options) {
|
|
5
|
+
const doc=root.ownerDocument,win=doc.defaultView,editor=options.editor;
|
|
6
|
+
const layer=doc.createElement('div');
|
|
7
|
+
layer.className='opf-layout-handles';
|
|
8
|
+
layer.style.cssText='position:absolute;inset:0;pointer-events:none;z-index:3';
|
|
9
|
+
root.append(layer);
|
|
10
|
+
let enabled=options.enabled??false,active=null,committing=false,frame=0,lastGeometry;
|
|
11
|
+
const signature=(path)=>JSON.stringify(editor.get(path));
|
|
12
|
+
const trackData=flow=>flow.composition.mode==='column'?{axis:'y',size:'height',tracks:flow.rows}:{axis:'x',size:'width',tracks:flow.columns};
|
|
13
|
+
function renderCanonical(){options.render();}
|
|
14
|
+
function release(edit){if(edit?.pointerId!==undefined&&layer.hasPointerCapture(edit.pointerId))layer.releasePointerCapture(edit.pointerId);}
|
|
15
|
+
function cancel(clear=true) {
|
|
16
|
+
if(!active)return;
|
|
17
|
+
const edit=active;active=null;if(frame)win.cancelAnimationFrame(frame);frame=0;release(edit);
|
|
18
|
+
renderCanonical();if(clear)options.onCancel?.({path:edit.flow.path});
|
|
19
|
+
}
|
|
20
|
+
function preview(fraction) {
|
|
21
|
+
if(!active)return false;
|
|
22
|
+
const edit=active;
|
|
23
|
+
edit.fraction=fraction;
|
|
24
|
+
try{
|
|
25
|
+
const prepared=prepareTrackResize(editor.document,active.flow,active.boundary,fraction);
|
|
26
|
+
options.render(prepared.document);
|
|
27
|
+
if(active!==edit)return false;
|
|
28
|
+
edit.prepared=prepared;edit.valid=true;
|
|
29
|
+
options.onDraft?.({document:prepared.document,path:active.flow.path+'.composition',value:prepared.composition});
|
|
30
|
+
if(active!==edit)return false;
|
|
31
|
+
options.clearError?.();return true;
|
|
32
|
+
}catch(error){if(active===edit)edit.valid=false;options.onError(error);return false;}
|
|
33
|
+
}
|
|
34
|
+
function commit() {
|
|
35
|
+
if(!active)return true;
|
|
36
|
+
if(frame){win.cancelAnimationFrame(frame);frame=0;preview(active.fraction);}
|
|
37
|
+
if(!active)return false;
|
|
38
|
+
const edit=active;
|
|
39
|
+
if(signature(edit.flow.path)!==edit.base){cancel(false);options.onError(new Error('The layout changed elsewhere. The resize was cancelled.'));return false;}
|
|
40
|
+
if(!edit.valid){cancel(false);return false;}
|
|
41
|
+
active=null;release(edit);committing=true;
|
|
42
|
+
try{
|
|
43
|
+
// Rebase unrelated document edits, retaining the guarded container.
|
|
44
|
+
const prepared=prepareTrackResize(editor.document,edit.flow,edit.boundary,edit.fraction);
|
|
45
|
+
if(edit.moved)editor.applyPatch(prepared.patches,{source:'canvas-layout',rejectInvalid:true});
|
|
46
|
+
else renderCanonical();
|
|
47
|
+
options.onCommit?.({path:edit.flow.path+'.composition',editor});return true;
|
|
48
|
+
}catch(error){renderCanonical();options.onError(error);return false;}
|
|
49
|
+
finally{committing=false;}
|
|
50
|
+
}
|
|
51
|
+
function begin(flow,boundary,event) {
|
|
52
|
+
if(!options.beforeEdit())return false;
|
|
53
|
+
if(active&&!commit())return false;
|
|
54
|
+
const {axis,tracks}=trackData(flow),a=tracks[boundary],b=tracks[boundary+1];
|
|
55
|
+
active={flow:structuredClone(flow),boundary,base:signature(flow.path),fraction:a.size/(a.size+b.size),initial:a.size/(a.size+b.size),pair:a.size+b.size,axis,valid:true,moved:false,pointerId:event?.pointerId,origin:event?(axis==='x'?event.clientX:event.clientY):0};
|
|
56
|
+
if(event)layer.setPointerCapture(event.pointerId);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
function update(document,geometry) {
|
|
60
|
+
lastGeometry=geometry;
|
|
61
|
+
layer.replaceChildren();
|
|
62
|
+
if(!enabled||options.isTextEditing())return;
|
|
63
|
+
for(const flow of geometry.flows??[]){
|
|
64
|
+
const {axis,size,tracks}=trackData(flow);
|
|
65
|
+
if(tracks.length<2||tracks.length>12||flow.itemCount!==flow.slotCount)continue;
|
|
66
|
+
for(let boundary=0;boundary<tracks.length-1;boundary++){
|
|
67
|
+
const track=tracks[boundary],next=tracks[boundary+1],vertical=axis==='x';
|
|
68
|
+
const node=doc.createElement('div');node.tabIndex=0;node.setAttribute('role','separator');
|
|
69
|
+
node.dataset.layoutPath=flow.path;node.dataset.layoutBoundary=String(boundary);
|
|
70
|
+
node.setAttribute('aria-label',`Resize ${flow.path} ${vertical?'columns':'rows'} ${boundary+1} and ${boundary+2}`);
|
|
71
|
+
node.setAttribute('aria-orientation',vertical?'vertical':'horizontal');
|
|
72
|
+
node.setAttribute('aria-valuemin','5');node.setAttribute('aria-valuemax','95');
|
|
73
|
+
node.setAttribute('aria-valuenow',String(Math.round(100*track.size/(track.size+next.size))));
|
|
74
|
+
node.setAttribute('aria-valuetext',`${Math.round(100*track.size/(track.size+next.size))}% of adjacent tracks`);
|
|
75
|
+
node.title='Drag to resize · Arrow keys adjust · Shift adjusts faster · Esc cancels';
|
|
76
|
+
const at=flow.box[axis]+track.offset+track.size+flow.gap/2;
|
|
77
|
+
node.style.cssText=`position:absolute;pointer-events:auto;touch-action:none;cursor:${vertical?'col':'row'}-resize;box-sizing:border-box;border-radius:5px;background:#8a78d733;outline-offset:2px;${vertical?`left:calc(${at/geometry.width*100}% - 5px);top:${flow.box.y/geometry.height*100}%;width:10px;height:${flow.box.height/geometry.height*100}%`:`top:calc(${at/geometry.height*100}% - 5px);left:${flow.box.x/geometry.width*100}%;height:10px;width:${flow.box.width/geometry.width*100}%`}`;
|
|
78
|
+
const grip=doc.createElement('span');grip.style.cssText=`position:absolute;border-radius:2px;background:#8170d5;${vertical?'left:4px;top:0;bottom:0;width:2px':'top:4px;left:0;right:0;height:2px'}`;node.append(grip);
|
|
79
|
+
node.onpointerdown=event=>{if(event.button!==0)return;event.preventDefault();event.stopPropagation();try{begin(flow,boundary,event);}catch(error){options.onError(error);}};
|
|
80
|
+
node.onkeydown=event=>{
|
|
81
|
+
const keys=vertical?['ArrowLeft','ArrowRight']:['ArrowUp','ArrowDown'];
|
|
82
|
+
if(event.key==='Escape'){event.preventDefault();cancel();return;}
|
|
83
|
+
if(!keys.includes(event.key)&&!['Home','End'].includes(event.key))return;
|
|
84
|
+
event.preventDefault();event.stopPropagation();
|
|
85
|
+
if(!begin(flow,boundary))return;
|
|
86
|
+
active.moved=true;
|
|
87
|
+
const fraction=event.key==='Home' ? .05 : event.key==='End' ? .95 : active.initial+(event.key===keys[1]?1:-1)*(event.shiftKey ? .05 : .01);
|
|
88
|
+
preview(Math.max(0,Math.min(1,fraction)));commit();
|
|
89
|
+
[...layer.children].find(child=>child.dataset.layoutPath===flow.path&&child.dataset.layoutBoundary===String(boundary))?.focus();
|
|
90
|
+
};
|
|
91
|
+
layer.append(node);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
layer.addEventListener('pointermove',event=>{
|
|
96
|
+
if(!active||event.pointerId!==active.pointerId)return;
|
|
97
|
+
const svg=root.querySelector('.opf-canvas-preview svg'),rect=svg.getBoundingClientRect();
|
|
98
|
+
const scale=rect.width/(lastGeometry?.width??1280);
|
|
99
|
+
const current=active.axis==='x'?event.clientX:event.clientY;
|
|
100
|
+
active.fraction=Math.max(0,Math.min(1,active.initial+(current-active.origin)/(scale*active.pair)));
|
|
101
|
+
if(Math.abs(current-active.origin)<1&&!active.moved)return;
|
|
102
|
+
active.moved=true;if(frame)win.cancelAnimationFrame(frame);
|
|
103
|
+
frame=win.requestAnimationFrame(()=>{frame=0;if(active)preview(active.fraction);});
|
|
104
|
+
});
|
|
105
|
+
layer.addEventListener('pointerup',event=>{if(active?.pointerId===event.pointerId)commit();});
|
|
106
|
+
layer.addEventListener('pointercancel',event=>{if(active?.pointerId===event.pointerId)cancel();});
|
|
107
|
+
layer.addEventListener('lostpointercapture',event=>{if(active?.pointerId===event.pointerId)cancel();});
|
|
108
|
+
const escape=event=>{if(event.key==='Escape'&&active){event.preventDefault();event.stopPropagation();cancel();}};
|
|
109
|
+
doc.addEventListener('keydown',escape,true);
|
|
110
|
+
const unsubscribe=editor.subscribe(()=>{
|
|
111
|
+
if(committing||!active)return;
|
|
112
|
+
if(signature(active.flow.path)!==active.base){cancel(false);options.onError(new Error('The layout changed elsewhere. The resize was cancelled.'));}
|
|
113
|
+
else preview(active.fraction);
|
|
114
|
+
});
|
|
115
|
+
return {update,commit,cancel,hide(){layer.replaceChildren();},get editingPath(){return active?.flow.path??null;},get enabled(){return enabled;},setEnabled(value){if(!commit())return false;enabled=!!value;renderCanonical();return true;},destroy(){if(frame)win.cancelAnimationFrame(frame);active=null;unsubscribe();doc.removeEventListener('keydown',escape,true);layer.remove();}};
|
|
116
|
+
}
|
package/dist/layout.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type {ComposedFlow, Composition} from '@openpresentation/opf/composition';
|
|
2
|
+
import type {JsonPatchOperation} from './index.js';
|
|
3
|
+
/** Resize adjacent tracks. Fractions clamp to 5–95%; automatic flows become explicit grids. */
|
|
4
|
+
export declare function prepareTrackResize(document:unknown,flow:ComposedFlow,boundary:number,fraction:number):{
|
|
5
|
+
document:unknown;patches:JsonPatchOperation[];composition:Composition;fraction:number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export {prepareBlockMove,listBlockContainers,prepareBlockInsert,prepareBlockDuplicate,prepareBlockRemove,createContentBlock} from './blocks.js';
|
|
9
|
+
|
|
10
|
+
export type {PreparedBlockChange,ContentBlockKind} from './blocks.js';
|
package/dist/layout.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {getValueAtPath, createValuePatch, applyJsonPatch, validateOpfDocument, opfPathToJsonPointer} from './index.js';
|
|
2
|
+
|
|
3
|
+
/** Prepare a validated track resize; the test operation guards its container revision. */
|
|
4
|
+
export function prepareTrackResize(document, flow, boundary, fraction) {
|
|
5
|
+
const vertical = flow?.composition?.mode === 'column';
|
|
6
|
+
const tracks = vertical ? flow?.rows : flow?.columns;
|
|
7
|
+
if (!Array.isArray(tracks) || tracks.length < 2 || tracks.length > 12)
|
|
8
|
+
throw new RangeError('Resize a flow with 2–12 tracks; group larger arrangements first.');
|
|
9
|
+
if (flow.itemCount !== flow.slotCount)
|
|
10
|
+
throw new RangeError('Choose an explicit arrangement before resizing reserved layout slots.');
|
|
11
|
+
if (!Number.isInteger(boundary) || boundary < 0 || boundary >= tracks.length - 1)
|
|
12
|
+
throw new RangeError('Track boundary is out of range.');
|
|
13
|
+
if (!Number.isFinite(fraction) || fraction < 0 || fraction > 1)
|
|
14
|
+
throw new RangeError('Track fraction must be between zero and one.');
|
|
15
|
+
const container = getValueAtPath(document,flow.path);
|
|
16
|
+
if (!container || typeof container !== 'object') throw new Error('The layout container no longer exists.');
|
|
17
|
+
const effective = flow.composition.weights ?? [];
|
|
18
|
+
const weights = Array.from({length:Math.max(tracks.length,effective.length)},(_,index)=>effective[index]??1);
|
|
19
|
+
if (weights.some(weight=>!Number.isFinite(weight)||weight<=0||weight>100)) throw new RangeError('Invalid track weights.');
|
|
20
|
+
const share = Math.min(.95,Math.max(.05,fraction));
|
|
21
|
+
const pair = weights[boundary]+weights[boundary+1];
|
|
22
|
+
weights[boundary]=pair*share;weights[boundary+1]=pair*(1-share);
|
|
23
|
+
const scale = Math.max(1,...weights.slice(0,tracks.length).map(weight=>weight/100));
|
|
24
|
+
for(let i=0;i<tracks.length;i++)weights[i]=Number((weights[i]/scale).toPrecision(12));
|
|
25
|
+
const composition={...container.composition,mode:flow.composition.mode??'auto',weights};
|
|
26
|
+
// A drag expresses an explicit arrangement. Preserve the automatic engine's chosen columns.
|
|
27
|
+
if(composition.mode==='auto'){composition.mode='grid';composition.columns=flow.columns.length;}
|
|
28
|
+
const patches=[{op:'test',path:opfPathToJsonPointer(flow.path),value:structuredClone(container)},...createValuePatch(document,[...flow.path.split('.'),'composition'],composition)];
|
|
29
|
+
const next=applyJsonPatch(document,patches),validation=validateOpfDocument(next);
|
|
30
|
+
if(!validation.valid)throw new Error(validation.errors[0]?.message??'Track resize is not valid OPF.');
|
|
31
|
+
return {document:next,patches,composition,fraction:share};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export {prepareBlockMove,listBlockContainers,prepareBlockInsert,prepareBlockDuplicate,prepareBlockRemove,createContentBlock} from './blocks.js';
|