@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/dist/data.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export {parseTabularData, createDataContent, OPFDataImportError} from "@openpresentation/opf/data";
2
+ export type {TabularData, DataImportOptions, DataContentOptions} from "@openpresentation/opf/data";
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[key] = clone(operation.value);
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
+ }
@@ -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';