@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 CHANGED
@@ -14,6 +14,28 @@ Embeddable local editor primitives for Open Presentation Format documents. The p
14
14
  - JSON Patch state transitions with inverse patches for undo/redo
15
15
  - Optional DOM controls plus React and Svelte bindings in separate embeddable entry points
16
16
 
17
+ ## Live browser canvas (preview)
18
+
19
+ The new `@openpresentation/opf-editor/canvas` entry provides a framework-independent SVG canvas with inline text/table-cell editing, live validated drafts, undo/redo, cancellation, and structured property forms for charts, lists, metrics, quotes, code, timelines and images. Mount it in a DOM container:
20
+
21
+ ```js
22
+ import { createCanvasEditor } from '@openpresentation/opf-editor/canvas';
23
+
24
+ const canvas = createCanvasEditor(container, {
25
+ document: presentation,
26
+ renderOptions: { textMeasurement: fontRegistry.textMeasurement },
27
+ onCommit: ({ editor }) => saveDocument(editor.document),
28
+ });
29
+ await canvas.ready;
30
+ // canvas.destroy() when unmounting.
31
+ ```
32
+
33
+ Load the same font bytes into the browser using `loadBrowserFontRegistry` from `@openpresentation/opf-render/fonts-browser` before mounting. The host owns font URLs, storage and collaboration. `onDraft` provides live document drafts; the session only changes on commit. Escape cancels. Concurrent edits to the selected payload cancel a stale draft.
34
+
35
+ These APIs are included in version 0.1.0 and require core 0.4.0 and renderer 0.1.0 for the canvas. See the OPF repository’s `docs/live-editor.md` for setup, the support matrix and roadmap. `pnpm pack:ecosystem` in that repository also prepares local preview tarballs for coordinated development.
36
+
37
+ The canvas retains canonical SVG glyphs while a transparent native input supplies the caret. Complete rich-text formatting, freeform drag/resize, all chart/media treatments, and cross-engine pixel identity remain work in progress. The Source dialog in the playground now provides a live JSON preview; changes are validated before committing.
38
+
17
39
  ## Runtime Policy
18
40
 
19
41
  The package runtime must stay local and deterministic:
@@ -145,3 +167,60 @@ Required first-publish setup:
145
167
  3. Publish by creating a GitHub Release or manually running the Release workflow after CI passes.
146
168
 
147
169
  This repo does not require an npm automation token when Trusted Publishing is configured.
170
+
171
+ ## Shared dynamic composition (local development)
172
+
173
+ The current checkout uses `@openpresentation/opf/composition` for portable geometry. Slides can select `auto`, `row`, `column`, or `grid`, set weighted tracks, and request path-specific overflow diagnostics. See the sibling OPF repo's `docs/dynamic-composition.md` for the complete contract.
174
+
175
+ Version 0.1.0 requires published `@openpresentation/opf@^0.4.0`. The optional renderer peer requires `@openpresentation/opf-render@^0.1.0`. Clean registry installs support the new composition APIs without sibling checkouts. For coordinated source development, build OPF and run `node scripts/link-ecosystem.mjs` there; `pnpm test:ecosystem` verifies shared geometry and import/export behavior.
176
+
177
+ ## Local interactive demo
178
+
179
+ With the sibling workspace linked, run `pnpm demo:editor` from the OPF repo, then serve its `artifacts/editor` directory with a static HTTP server. The demo lets you select SVG text, apply edits, change composition, edit the JSON document, and undo/redo. It uses the real renderer and editor session with no service dependency.
180
+
181
+ Editor snapshots are immutable and keep the same object identity until a change. This supports React `useSyncExternalStore` without render loops. `editor.document` remains a separate mutable copy for callers that need one.
182
+
183
+ Nested content groups expose their bounds through `editor.composeSlide(index).groups`. Use `editor.setGroupComposition("slides.0.blocks.0", {mode:"column"})` to reflow a group with validation and undo/redo. The playground includes a nested example and group arrangement controls.
184
+
185
+ `editor.paginateSlide(index, {minFontSize:24})` splits a crowded draft into ordinary OPF slides as one undoable transaction. It returns the change and source mappings; failed pagination leaves the document unchanged. The playground’s Split overflow action demonstrates the workflow.
186
+
187
+ `composeSlide(index, {textMeasurement})` and `paginateSlide(index, {textMeasurement})` accept the same font provider as preview and export. The local playground now uses bundled, embedded fonts and reports substitutions.
188
+
189
+
190
+ ## Copy, paste, and gallery imports
191
+
192
+ The browser-safe `/transfer` export provides `parseOpfTransfer`, `serializeOpfTransfer`, and `prepareOpfImport`. Copy whole documents, self-contained slides, or selected JSON values as readable JSON, compact JSON, or fenced Markdown. Parse pasted documents, slides, arrays, fragments, and one fenced code block. Prepare an immutable validated import before applying one root JSON Patch through the editor session. Insertion namespaces inline catalogs and conflicting asset/slide IDs and preserves primary source design defaults.
193
+
194
+ The `/galleries` export provides `loadOpfGallery` and `loadOpfGalleryItem`, accepting an AbortSignal and injectable fetch. Registries use `{name, items:[{id, name, category, opf}]}` for inline documents or `opfUrl` for a relative JSON URL. Public PPTX.gallery registry descriptors are supported. Cross-origin endpoints need CORS; requests omit credentials/referrers, cap responses at 20 MB, and keep automatic item requests on the configured gallery origin. Documents should include inline catalog records; arbitrary external catalog sources are not automatically fetched.
195
+
196
+ The playground includes Copy OPF and Add OPF dialogs with paste, file/drop, gallery search, and URL tabs. Preview before inserting or opening. Custom registries persist in local storage; defaults are configured in `examples/galleries.json`. Copy permissions may require using the Select all fallback. Pasting within text fields keeps normal text editing. Cmd/Ctrl+Shift+C opens copying and Cmd/Ctrl+O opens file import.
197
+
198
+ Slide insertion does not merge root speakers, organizations, or narrative metadata. Use Open as a presentation for the full source document. Some gallery presets contain minimal examples. The root OPF workspace's `docs/live-editor.md` documents the complete demo and npm API workflow.
199
+
200
+
201
+ ## Schema-driven property editing
202
+
203
+ `@openpresentation/opf-editor/schema-inspector` exports `createSchemaInspector(container, {editor, path, onDraft, onCommit})`. It uses the canonical schemas to expose optional properties, union forms, nested arrays and maps, catalog records, and scalar fields. Valid drafts invoke `onDraft` for a host preview. `commit()` validates and applies one undoable root patch; `reset()` discards the draft; `navigate(path)` selects another field; `destroy()` unsubscribes and removes owned DOM. Concurrent document changes reject a stale draft.
204
+
205
+ `@openpresentation/opf-editor/schema` exports schema resolution, field traversal, initial values, and the full property inventory. These APIs power the All properties workspace and the gallery reference. Arbitrary extensions can use string, number, boolean, object, array, and null forms. Full-schema editing does not imply that the SVG renderer implements every visual feature.
206
+
207
+
208
+ ### Rich text on the canvas
209
+
210
+ Select rendered rich text to format it, or double-click a rich text block to select all of it. The toolbar supports character styles, point size, font family, hex color, links, scripts, and selected-text replacement. Plain text payloads offer **Format text** during inline editing. **Edit runs** opens the structured fields; `canvas.editProperties(path)` does the same programmatically. Each action is validated and undoable. Continuous rich-text typing with a mixed-style caret remains under development.
211
+
212
+ Headless applications and agents can import `formatRichTextRange`, `replaceRichTextRange`, and `richTextContent` from `@openpresentation/opf-editor/rich-text`. The helpers preserve surrounding run metadata and accept UTF-16 offsets on whole grapheme boundaries. A null format value removes an override. Apply the returned value through the editor session or validate the resulting document before saving.
213
+
214
+
215
+ ### Resize dynamic layouts
216
+
217
+ The canvas can show draggable, keyboard-accessible dividers for root and nested composition tracks. Pass `layoutEditing: true`, or call `canvas.setLayoutEditing(true)`. A pointer drag produces live preview drafts and commits one undo step. Escape cancels; strict overflow prevents invalid fit. Automatic layouts become explicit grids when resized. Promoted regions stay fixed, while their nested groups can be resized.
218
+
219
+ `prepareTrackResize(document, flow, boundary, fraction)` from `@openpresentation/opf-editor/layout` returns a candidate document and guarded patches for headless agents. Obtain `flow` from the shared renderer's `geometry.flows`; preview the candidate before applying. The editor supports JSON Patch `test` guards alongside add/replace/remove; failed guards leave state and history unchanged. This export is included in version 0.1.0.
220
+
221
+
222
+ ### Move complete blocks
223
+
224
+ Arrange mode also shows numbered block handles. Drag to reorder siblings, use arrow keys for earlier/later, or click a handle to choose an existing destination group/slide and insertion position. The move preserves the whole block, including rich text, table/chart data, and nested groups. Each move is validated, preflighted by the renderer, and undoable. Parent weights stay with layout positions; moves that leave an empty container or create a containment cycle are rejected.
225
+
226
+ Agents can import `prepareBlockMove` and `listBlockContainers` from `@openpresentation/opf-editor/layout`. The prepared result includes a full candidate document, guarded atomic patches, the new block path, and a changed flag. Destination indexes refer to the pre-removal document. Use `canvas.openBlockMenu(path)` for the corresponding browser controls.
@@ -0,0 +1,143 @@
1
+ import {prepareBlockMove,listBlockContainers,prepareBlockInsert,prepareBlockDuplicate,prepareBlockRemove,createContentBlock} from './blocks.js';
2
+ import {getValueAtPath,splitOpfPath,opfPathToJsonPointer,applyJsonPatch} from './index.js';
3
+
4
+ /** Arrange-mode block handles. Native drag reorders siblings; the menu also reparents blocks. */
5
+ export function createBlockControls(root, options) {
6
+ const doc=root.ownerDocument,editor=options.editor;
7
+ const layer=doc.createElement('div');layer.className='opf-block-controls';
8
+ layer.style.cssText='position:absolute;inset:0;pointer-events:none;z-index:4';root.append(layer);
9
+ let cells=[],geometry,drag=null,panel=null,committing=false,returnFocus=null;
10
+ const signature=path=>JSON.stringify(editor.get(path));
11
+ const clearMarker=()=>layer.querySelector('[data-block-drop]')?.remove();
12
+ function cancel(){drag=null;clearMarker();const restore=panel?.contains(doc.activeElement);panel?.remove();panel=null;if(restore&&returnFocus?.isConnected)returnFocus.focus();returnFocus=null;}
13
+ function apply(prepared){
14
+ if(!prepared?.changed){cancel();return true;}
15
+ // Retain changes outside the guarded containers and preflight the exact candidate.
16
+ const candidate=applyJsonPatch(editor.document,prepared.patches);
17
+ options.validate(candidate);
18
+ committing=true;
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
+ finally{committing=false;}
21
+ }
22
+ function move(from,to,index,base=editor.document){
23
+ try{return apply(prepareBlockMove(base,from,to,index));}catch(error){cancel();options.onError(error);return false;}
24
+ }
25
+ function open(path){
26
+ if(!options.beforeEdit())return;
27
+ cancel();returnFocus=doc.activeElement;path=opfPathToJsonPointer(path);const base=editor.document,parts=splitOpfPath(path),container=opfPathToJsonPointer(parts.slice(0,-2)),index=Number(parts.at(-1));
28
+ const source=getValueAtPath(base,[...parts.slice(0,-2),'blocks']);
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
+ const count=source.length;
31
+ panel=doc.createElement('div');panel.setAttribute('role','dialog');panel.setAttribute('aria-label','Move content block');
32
+ panel.style.cssText='position:absolute;right:8px;bottom:8px;width:280px;max-width:calc(100% - 16px);box-sizing:border-box;padding:14px;background:white;color:#332e40;border:1px solid #d5cde5;border-radius:8px;box-shadow:0 5px 24px #28203922;pointer-events:auto;font:12px/1.5 system-ui;z-index:3';
33
+ const heading=doc.createElement('strong');heading.textContent='Move content';panel.append(heading);
34
+ const button=(label,handler,disabled=false)=>{const b=doc.createElement('button');b.type='button';b.textContent=label;b.disabled=disabled;b.style.cssText='margin:6px 5px 0 0;padding:5px 8px;border:1px solid #ded8e8;border-radius:4px;background:#faf9fc;color:inherit;cursor:pointer';b.onclick=handler;panel.append(b);return b;};
35
+ button('Earlier',()=>move(path,container,index-1,base),index===0);
36
+ button('Later',()=>move(path,container,index+2,base),index===count-1);
37
+ const label=doc.createElement('label');label.textContent='Destination';label.style.cssText='display:block;margin-top:10px';
38
+ const destinations=doc.createElement('select');destinations.setAttribute('aria-label','Move destination');destinations.style.cssText='display:block;width:100%;padding:6px;margin-top:4px';
39
+ const containers=listBlockContainers(base).filter(item=>item.path!==path&&!item.path.startsWith(path+'/'));
40
+ for(const item of containers){const option=doc.createElement('option');option.value=item.path;option.textContent=item.label;destinations.append(option);}destinations.value=container;label.append(destinations);panel.append(label);
41
+ const positionLabel=doc.createElement('label');positionLabel.textContent='Position';positionLabel.style.cssText='display:block;margin-top:8px';
42
+ const positions=doc.createElement('select');positions.setAttribute('aria-label','Move position');positions.style.cssText='display:block;width:100%;padding:6px;margin-top:4px';positionLabel.append(positions);panel.append(positionLabel);
43
+ function fillPositions(){positions.replaceChildren();const selected=containers.find(c=>c.path===destinations.value);for(let i=0;i<=selected.count;i++){const option=doc.createElement('option');option.value=String(i);option.textContent=i===selected.count?'At the end':`Before block ${i+1}`;positions.append(option);}positions.value=destinations.value===container?String(index):String(selected.count);}
44
+ destinations.onchange=fillPositions;fillPositions();
45
+ button('Move',()=>move(path,destinations.value,Number(positions.value),base));
46
+ button('Duplicate',()=>change(()=>prepareBlockDuplicate(base,path)));
47
+ button('Delete',()=>change(()=>prepareBlockRemove(base,path)));
48
+ button('Add after',()=>openInsert(container,index+1));
49
+ if(Array.isArray(source[index].blocks))button('Add inside',()=>openInsert(path));
50
+ button('Cancel',cancel);
51
+ panel.onkeydown=event=>{if(event.key==='Escape'){event.preventDefault();event.stopPropagation();cancel();}};
52
+ layer.append(panel);destinations.focus();
53
+ }
54
+ function change(prepare) {
55
+ try{return apply(prepare());}catch(error){options.onError(error);return false;}
56
+ }
57
+ function openInsert(containerPath,index) {
58
+ if(!options.beforeEdit())return;
59
+ cancel();returnFocus=doc.activeElement;const base=editor.document;
60
+ const containers=listBlockContainers(base,{slideIndex:options.slideIndex(),includeImplicit:true});
61
+ if(!containers.length){options.onError(new Error('No content container is available on this slide.'));return;}
62
+ const requested=containerPath===undefined?containers[0].path:opfPathToJsonPointer(containerPath);
63
+ if(!containers.some(c=>c.path===requested)){options.onError(new Error('Choose a content container on this slide.'));return;}
64
+ if(index!==undefined&&(!Number.isInteger(index)||index<0||index>containers.find(c=>c.path===requested).count)){options.onError(new Error('Insertion index is out of range.'));return;}
65
+ panel=doc.createElement('div');panel.setAttribute('role','dialog');panel.setAttribute('aria-label','Add content');
66
+ panel.style.cssText='position:absolute;right:8px;top:40px;width:300px;max-width:calc(100% - 16px);max-height:calc(100% - 48px);overflow:auto;box-sizing:border-box;padding:14px;background:white;color:#332e40;border:1px solid #d5cde5;border-radius:8px;box-shadow:0 5px 24px #28203922;pointer-events:auto;font:12px/1.5 system-ui;z-index:3';
67
+ const heading=doc.createElement('strong');heading.textContent='Add content';panel.append(heading);
68
+ function field(label,node){const wrapper=doc.createElement('label');wrapper.textContent=label;wrapper.style.cssText='display:block;margin-top:10px';node.setAttribute('aria-label',label);node.style.cssText='display:block;box-sizing:border-box;width:100%;padding:6px;margin-top:4px';wrapper.append(node);panel.append(wrapper);return wrapper;}
69
+ const kind=doc.createElement('select');for(const [value,label] of [['text','Text'],['list','List'],['chart','Chart'],['table','Table'],['metric','Metric'],['quote','Quote'],['code','Code'],['timeline','Timeline'],['group','Group'],['image','Image'],['video','Video']]){const option=doc.createElement('option');option.value=value;option.textContent=label;kind.append(option);}field('Content type',kind);
70
+ const destinations=doc.createElement('select');for(const item of containers){const option=doc.createElement('option');option.value=item.path;option.textContent=item.label;destinations.append(option);}destinations.value=requested;field('Add destination',destinations);
71
+ const positions=doc.createElement('select');field('Add position',positions);
72
+ function positionsFor(){positions.replaceChildren();const count=containers.find(c=>c.path===destinations.value).count;for(let i=0;i<=count;i++){const option=doc.createElement('option');option.value=String(i);option.textContent=i===count?'At the end':`Before block ${i+1}`;positions.append(option);}positions.value=String(destinations.value===requested&&index!==undefined?Math.min(index,count):count);}
73
+ destinations.onchange=positionsFor;positionsFor();
74
+ const source=doc.createElement('input');source.type='text';const media=field('Media URL or asset reference',source);media.style.display='none';
75
+ const file=doc.createElement('input');file.type='file';file.accept='image/png,image/jpeg,image/webp';const upload=field('Or choose an image',file);upload.style.display='none';
76
+ let loading=false,fileRevision=0;const thisPanel=panel;
77
+ const add=doc.createElement('button');add.type='button';add.textContent='Add';add.style.cssText='margin:12px 6px 0 0;padding:6px 12px;border:1px solid #bfb1de;border-radius:4px;background:#ede7f9;color:inherit;cursor:pointer';
78
+ const close=doc.createElement('button');close.type='button';close.textContent='Cancel';close.onclick=cancel;close.style.cssText=add.style.cssText;
79
+ const cancelRead=()=>{fileRevision++;loading=false;add.disabled=false;};
80
+ source.oninput=()=>{cancelRead();file.value='';};
81
+ kind.onchange=()=>{cancelRead();media.style.display=['image','video'].includes(kind.value)?'block':'none';upload.style.display=kind.value==='image'?'block':'none';};
82
+ file.onchange=()=>{
83
+ cancelRead();const revision=fileRevision,chosen=file.files?.[0];if(!chosen)return;
84
+ if(!['image/png','image/jpeg','image/webp'].includes(chosen.type)){options.onError(new Error('Choose a PNG, JPEG or WebP image.'));return;}
85
+ loading=true;add.disabled=true;const reader=new doc.defaultView.FileReader();
86
+ reader.onload=()=>{if(panel!==thisPanel||revision!==fileRevision)return;source.value=String(reader.result);loading=false;add.disabled=false;};
87
+ reader.onerror=()=>{if(panel!==thisPanel||revision!==fileRevision)return;loading=false;add.disabled=false;options.onError(new Error('The image file could not be read.'));};
88
+ reader.readAsDataURL(chosen);
89
+ };
90
+ add.onclick=()=>{if(!loading)change(()=>prepareBlockInsert(base,destinations.value,createContentBlock(kind.value,{source:source.value}),Number(positions.value)));};
91
+ panel.append(add,close);panel.onkeydown=event=>{if(event.key==='Escape'){event.preventDefault();event.stopPropagation();cancel();}};
92
+ layer.append(panel);kind.focus();
93
+ }
94
+ function update(document,nextGeometry){
95
+ geometry=nextGeometry;
96
+ if(drag)return;
97
+ cancel();layer.replaceChildren();cells=[];
98
+ if(!options.enabled()||options.isEditing())return;
99
+ const add=doc.createElement('button');add.type='button';add.textContent='+ Add content';add.setAttribute('aria-label','Add content');add.dataset.blockAdd='';
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
+ for(const flow of geometry.flows??[]){
102
+ const blocks=getValueAtPath(document,[...splitOpfPath(flow.path),'blocks']);
103
+ if(!Array.isArray(blocks)||blocks.length!==flow.itemCount)continue;
104
+ blocks.forEach((block,index)=>{
105
+ const col=flow.columns[index%flow.columns.length],row=flow.rows[Math.floor(index/flow.columns.length)];if(!col||!row)return;
106
+ const path=opfPathToJsonPointer([...splitOpfPath(flow.path),'blocks',String(index)]),container=opfPathToJsonPointer(flow.path);
107
+ const box={x:flow.box.x+col.offset,y:flow.box.y+row.offset,width:col.size,height:row.size};
108
+ const depth=splitOpfPath(path).filter(p=>p==='blocks').length;
109
+ const node=doc.createElement('button');node.type='button';node.draggable=true;node.dataset.blockPath=path;
110
+ node.textContent=Array.isArray(block.blocks)?`Group ${index+1} ⋮⋮`:`${index+1} ⋮⋮`;
111
+ node.setAttribute('aria-label',`Move ${Array.isArray(block.blocks)?'group':'block'} ${index+1} in ${flow.path===`slides.${options.slideIndex()}`?'slide':'group'}`);
112
+ node.title='Drag to reorder · Click to move between groups · Arrow keys reorder';
113
+ node.style.cssText=`position:absolute;pointer-events:auto;touch-action:none;cursor:grab;left:calc(${(box.x+box.width)/geometry.width*100}% - ${Array.isArray(block.blocks)?70:38}px);top:calc(${box.y/geometry.height*100}% + ${(depth-1)*23}px);height:22px;min-width:34px;padding:2px 5px;background:#f6f3fc;color:#675394;border:1px solid #cfc4e7;border-radius:4px;font:11px system-ui;z-index:${depth}`;
114
+ const cell={path,container,index,box,axis:flow.composition.mode==='column'?'y':'x',node};cells.push(cell);
115
+ node.onclick=()=>open(path);
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
+ node.ondragstart=event=>{
118
+ if(!options.beforeEdit()){event.preventDefault();return;}
119
+ cancel();drag={from:path,container,index,base:editor.document,signature:signature(container),cells:[...cells],prepared:null};
120
+ event.dataTransfer.effectAllowed='move';event.dataTransfer.setData('text/plain',path);
121
+ };
122
+ node.ondragend=()=>{cancel();options.render();};layer.append(node);
123
+ });
124
+ }
125
+ }
126
+ function over(event){
127
+ if(!drag)return;
128
+ const rect=root.querySelector('.opf-canvas-preview svg').getBoundingClientRect();
129
+ const x=(event.clientX-rect.left)*geometry.width/rect.width,y=(event.clientY-rect.top)*geometry.height/rect.height;
130
+ const target=drag.cells.find(cell=>cell.container===drag.container&&x>=cell.box.x&&x<=cell.box.x+cell.box.width&&y>=cell.box.y&&y<=cell.box.y+cell.box.height);
131
+ clearMarker();drag.prepared=null;if(!target)return;
132
+ event.preventDefault();event.dataTransfer.dropEffect='move';
133
+ const after=target.axis==='x'?x>target.box.x+target.box.width/2:y>target.box.y+target.box.height/2;
134
+ try{drag.prepared=prepareBlockMove(drag.base,drag.from,target.container,target.index+(after?1:0));}catch(error){options.onError(error);return;}
135
+ const marker=doc.createElement('div');marker.dataset.blockDrop='';const vertical=target.axis==='x',box=target.box;
136
+ marker.style.cssText=`position:absolute;pointer-events:none;background:#7765d0;z-index:10;${vertical?`left:${(box.x+(after?box.width:0))/geometry.width*100}%;top:${box.y/geometry.height*100}%;width:3px;height:${box.height/geometry.height*100}%`:`top:${(box.y+(after?box.height:0))/geometry.height*100}%;left:${box.x/geometry.width*100}%;height:3px;width:${box.width/geometry.width*100}%`}`;layer.append(marker);
137
+ }
138
+ function drop(event){if(!drag)return;event.preventDefault();const prepared=drag.prepared;try{apply(prepared);}catch(error){cancel();options.onError(error);}finally{cancel();options.render();}}
139
+ const escape=event=>{if(event.key==='Escape'&&(drag||panel)){event.preventDefault();event.stopPropagation();cancel();options.render();}};
140
+ root.addEventListener('dragover',over);root.addEventListener('drop',drop);doc.addEventListener('keydown',escape,true);
141
+ const unsubscribe=editor.subscribe(()=>{if(committing)return;if(drag&&signature(drag.container)!==drag.signature){cancel();options.onError(new Error('The block container changed elsewhere. Start the move again.'));}if(panel)cancel();});
142
+ return {update,cancel,open,openInsert,hide(){cancel();layer.replaceChildren();},get editingPath(){return drag?.from??null;},destroy(){cancel();unsubscribe();root.removeEventListener('dragover',over);root.removeEventListener('drop',drop);doc.removeEventListener('keydown',escape,true);layer.remove();}};
143
+ }
@@ -0,0 +1,13 @@
1
+ import type {JsonPatchOperation} from './index.js';
2
+ /** Move a complete block; toIndex is the insertion position before removal. */
3
+ export declare function prepareBlockMove(document:unknown,fromPath:string,toContainerPath:string,toIndex:number):{
4
+ document:unknown;patches:JsonPatchOperation[];path:string;changed:boolean;
5
+ };
6
+ export declare function listBlockContainers(document:unknown,options?:{slideIndex?:number;includeImplicit?:boolean}):{path:string;label:string;count:number;implicit?:boolean}[];
7
+
8
+ export interface PreparedBlockChange { document:unknown;patches:JsonPatchOperation[];path:string;changed:boolean }
9
+ export declare function prepareBlockInsert(document:unknown,containerPath:string,block:unknown,index?:number):PreparedBlockChange;
10
+ export declare function prepareBlockDuplicate(document:unknown,path:string):PreparedBlockChange;
11
+ export declare function prepareBlockRemove(document:unknown,path:string):PreparedBlockChange;
12
+ export type ContentBlockKind='text'|'list'|'chart'|'table'|'metric'|'quote'|'code'|'timeline'|'group'|'image'|'video';
13
+ export declare function createContentBlock(kind:ContentBlockKind,options?:{source?:string}):Record<string,unknown>;
package/dist/blocks.js ADDED
@@ -0,0 +1,125 @@
1
+ import {splitOpfPath,opfPathToJsonPointer,getValueAtPath,applyJsonPatch,validateOpfDocument} from './index.js';
2
+ const prefix=(a,b)=>a.length<=b.length&&a.every((part,i)=>part===b[i]);
3
+
4
+ /** Move one complete block. The insertion index refers to the destination before removal. */
5
+ export function prepareBlockMove(document,fromPath,toContainerPath,toIndex) {
6
+ const from=splitOpfPath(fromPath),to=splitOpfPath(toContainerPath);
7
+ 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
+ const sourceIndex=Number(from.at(-1)),sourceContainer=from.slice(0,-2),sourceArray=from.slice(0,-1);
9
+ const containers=new Set(listBlockContainers(document).map(item=>item.path));
10
+ if(!containers.has(opfPathToJsonPointer(sourceContainer))||!containers.has(opfPathToJsonPointer(to)))throw new TypeError('Choose slide or group block containers.');
11
+ const source=getValueAtPath(document,sourceArray),destination=getValueAtPath(document,[...to,'blocks']);
12
+ if(!Array.isArray(source)||sourceIndex>=source.length||!Array.isArray(destination))throw new RangeError('Source and destination must be existing block containers.');
13
+ if(!Number.isInteger(toIndex)||toIndex<0||toIndex>destination.length)throw new RangeError('Destination insertion index is out of range.');
14
+ if(prefix(from,to))throw new Error('A group cannot move into itself or one of its descendants.');
15
+ const same=JSON.stringify(sourceContainer)===JSON.stringify(to);
16
+ const index=same&&toIndex>sourceIndex?toIndex-1:toIndex;
17
+ if(same&&index===sourceIndex)return {document:structuredClone(document),patches:[],path:opfPathToJsonPointer(from),changed:false};
18
+ 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
+ // Removing a preceding sibling shifts a destination group's address.
20
+ const rebased=[...to];
21
+ if(!same&&prefix(sourceArray,to)&&/^(0|[1-9][0-9]*)$/.test(to[sourceArray.length]??'')&&Number(to[sourceArray.length])>sourceIndex)
22
+ rebased[sourceArray.length]=String(Number(to[sourceArray.length])-1);
23
+ const target=[...rebased,'blocks',String(index)];
24
+ 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(document,path))})),
26
+ {op:'remove',path:opfPathToJsonPointer(from)},
27
+ {op:'add',path:opfPathToJsonPointer(target),value:structuredClone(source[sourceIndex])}];
28
+ const next=applyJsonPatch(document,patches),validation=validateOpfDocument(next);
29
+ if(!validation.valid)throw new Error(validation.errors[0]?.message??'The moved document is not valid OPF.');
30
+ return {document:next,patches,path:opfPathToJsonPointer(target),changed:true};
31
+ }
32
+
33
+ /** Discover actual block containers without interpreting imported content as instructions. */
34
+ export function listBlockContainers(document,{slideIndex,includeImplicit=false}={}) {
35
+ const result=[];
36
+ function visit(value,path,label,depth=0){
37
+ if(!value||typeof value!=='object'||depth>32)return;
38
+ if(Array.isArray(value.blocks)){
39
+ result.push({path:opfPathToJsonPointer(path),label,count:value.blocks.length});
40
+ value.blocks.forEach((block,i)=>visit(block,[...path,'blocks',String(i)],`${label} › Group ${i+1}`,depth+1));
41
+ }
42
+ }
43
+ (document?.slides??[]).forEach((slide,i)=>{
44
+ if(slideIndex!==undefined&&slideIndex!==i)return;
45
+ const path=['slides',String(i)],label=`Slide ${i+1}`;
46
+ 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});
47
+ visit(slide,path,label);
48
+ if(includeImplicit)for(const [key,value] of Object.entries(slide))if(isRegion(key)&&value&&typeof value==='object'&&!Array.isArray(value.blocks))result.push({path:opfPathToJsonPointer([...path,key]),label:`${label} › ${key}`,count:1,implicit:true});
49
+ for(const [key,value] of Object.entries(slide))if(key.split(/[+:]/).every(part=>['top','middle','bottom','left','center','right'].includes(part))&&value&&typeof value==='object'&&!Array.isArray(value)&&Array.isArray(value.blocks))visit(value,[...path,key],`${label} › ${key}`);
50
+ });
51
+ return result;
52
+ }
53
+
54
+ const contentFields=['text','items','bullets','image','video','chart','table','code','metric','quote','timeline'];
55
+ 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(document,container,next,path) {
57
+ const pointer=opfPathToJsonPointer(container);
58
+ const patches=[{op:'test',path:pointer,value:structuredClone(getValueAtPath(document,container))},
59
+ {op:'replace',path:pointer,value:next}];
60
+ const result=applyJsonPatch(document,patches),validation=validateOpfDocument(result);
61
+ if(!validation.valid)throw new Error(validation.errors[0]?.message??'The changed document is not valid OPF.');
62
+ return {document:result,patches,path:opfPathToJsonPointer(path),changed:true};
63
+ }
64
+ /** Insert into a slide, existing group or named region. Implicit payloads become blocks. */
65
+ export function prepareBlockInsert(document,containerPath,block,index) {
66
+ const path=splitOpfPath(containerPath),pointer=opfPathToJsonPointer(path);
67
+ if(!listBlockContainers(document,{includeImplicit:true}).some(c=>c.path===pointer))throw new TypeError('Choose a slide, group or named region content container.');
68
+ if(!block||typeof block!=='object'||Array.isArray(block))throw new TypeError('Insert an OPF content block object.');
69
+ const next=structuredClone(getValueAtPath(document,path));
70
+ if(!Array.isArray(next.blocks)){
71
+ next.blocks=contentFields.filter(key=>Object.hasOwn(next,key)).map(key=>({...(next.type?{type:next.type}:{}),[key]:next[key]}));
72
+ for(const key of [...contentFields,'type'])delete next[key];
73
+ }
74
+ const at=index??next.blocks.length;
75
+ if(!Number.isInteger(at)||at<0||at>next.blocks.length)throw new RangeError('Insertion index is out of range.');
76
+ next.blocks.splice(at,0,structuredClone(block));
77
+ return preparedChange(document,path,next,[...path,'blocks',String(at)]);
78
+ }
79
+ function checkedBlock(document,path) {
80
+ 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(document).some(c=>c.path===opfPathToJsonPointer(container)))throw new TypeError('Choose a complete block using its blocks/index path.');
82
+ const blocks=getValueAtPath(document,[...container,'blocks']);
83
+ if(index>=blocks.length)throw new RangeError('Block does not exist.');
84
+ return {parts,container,index,blocks};
85
+ }
86
+ /** Duplicate the entire block immediately after itself, preserving formatting and asset references. */
87
+ export function prepareBlockDuplicate(document,path) {
88
+ const {container,index,blocks}=checkedBlock(document,path);
89
+ return prepareBlockInsert(document,opfPathToJsonPointer(container),blocks[index],index+1);
90
+ }
91
+ /** Delete a block and prune empty ancestor groups; a slide itself is never deleted. */
92
+ export function prepareBlockRemove(document,path) {
93
+ let {parts,container,index,blocks}=checkedBlock(document,path);
94
+ while(blocks.length===1&&container.length>2){
95
+ if(container.length===3&&isRegion(container[2])){
96
+ const slidePath=container.slice(0,2),next=structuredClone(getValueAtPath(document,slidePath));delete next[container[2]];
97
+ return preparedChange(document,slidePath,next,slidePath);
98
+ }
99
+ ({parts,container,index,blocks}=checkedBlock(document,container));
100
+ }
101
+ const next=structuredClone(getValueAtPath(document,container));next.blocks.splice(index,1);
102
+ const selection=next.blocks.length?[...container,'blocks',String(Math.min(index,next.blocks.length-1))]:container;
103
+ return preparedChange(document,container,next,selection);
104
+ }
105
+
106
+ /** Small, schema-valid starting points. Media requires an explicit source. */
107
+ export function createContentBlock(kind,{source}={}) {
108
+ const presets={
109
+ text:{text:'Add your text'},
110
+ list:{items:['First point','Second point']},
111
+ chart:{chart:{type:'column',data:{columns:['Category','Value'],rows:[['A',12],['B',18],['C',9]]}}},
112
+ table:{table:{columns:['Item','Value'],rows:[['First','12'],['Second','18']]}},
113
+ metric:{metric:{value:42,label:'Metric'}},
114
+ quote:{quote:{text:'Add a quotation',attribution:'Source'}},
115
+ code:{code:{source:'const message = "Hello";',language:'javascript'}},
116
+ timeline:{timeline:[{when:'Now',what:'First milestone'},{when:'Next',what:'Second milestone'}]},
117
+ group:{composition:{mode:'column'},blocks:[{text:'First point'},{text:'Second point'}]}
118
+ };
119
+ if(kind==='image'||kind==='video'){
120
+ if(typeof source!=='string'||!source.trim())throw new TypeError('Choose a media file or enter its source.');
121
+ return {[kind]:source};
122
+ }
123
+ if(!Object.hasOwn(presets,kind))throw new TypeError('Unknown content block kind.');
124
+ return structuredClone(presets[kind]);
125
+ }
@@ -0,0 +1,63 @@
1
+ import {
2
+ applyJsonPatch,
3
+ createValuePatch,
4
+ validateOpfDocument,
5
+ splitOpfPath,
6
+ opfPathToJsonPointer,
7
+ } from "./index.js";
8
+ /** Scalar fields and collection paths use JSON Pointer to preserve arbitrary object keys. */
9
+ export function getEditableFields(value, path) {
10
+ const fields = [],
11
+ arrays = [],
12
+ root = splitOpfPath(path);
13
+ const walk = (value, segments, labels) => {
14
+ const path = opfPathToJsonPointer(segments),
15
+ label = labels.join(" · ") || root.at(-1) || "Value";
16
+ if (Array.isArray(value)) {
17
+ arrays.push({ path, label, length: value.length });
18
+ value.forEach((child, index) =>
19
+ walk(
20
+ child,
21
+ [...segments, String(index)],
22
+ [...labels, String(index + 1)],
23
+ ),
24
+ );
25
+ } else if (value && typeof value === "object")
26
+ for (const [key, child] of Object.entries(value))
27
+ walk(child, [...segments, key], [...labels, key]);
28
+ else
29
+ fields.push({
30
+ path,
31
+ label,
32
+ type: value === null ? "null" : typeof value,
33
+ value,
34
+ });
35
+ };
36
+ walk(value, root, []);
37
+ return { fields, arrays };
38
+ }
39
+ export function parseCanvasValue(text, type) {
40
+ if (type === "number") {
41
+ if (!String(text).trim() || !Number.isFinite(Number(text)))
42
+ throw new Error("Enter a finite number.");
43
+ return Number(text);
44
+ }
45
+ if (type === "boolean") return text === true || text === "true";
46
+ if (type === "null") {
47
+ if (text === "null") return null;
48
+ throw new Error("This field must be null.");
49
+ }
50
+ return text;
51
+ }
52
+ export function createCanvasDraft(document, path, value) {
53
+ const draft = applyJsonPatch(
54
+ document,
55
+ createValuePatch(document, path, value),
56
+ );
57
+ const validation = validateOpfDocument(draft);
58
+ if (!validation.valid)
59
+ throw new Error(
60
+ validation.errors[0]?.message ?? "This change is not valid OPF.",
61
+ );
62
+ return draft;
63
+ }
@@ -0,0 +1,67 @@
1
+ import type { EditorSession } from "./index.js";
2
+ import type { RenderSvgOptions } from "@openpresentation/opf-render";
3
+ import type { SlideComposition } from "@openpresentation/opf/composition";
4
+ export interface CanvasEditorOptions {
5
+ editor?: EditorSession;
6
+ document?: unknown;
7
+ slideIndex?: number;
8
+ /** Show keyboard-accessible dividers for resizing composition tracks. */
9
+ layoutEditing?: boolean;
10
+ renderOptions?: RenderSvgOptions;
11
+ /** Optional empty host for property forms; defaults to a floating canvas panel. */
12
+ propertiesContainer?: HTMLElement;
13
+ onSelect?: (selection: {
14
+ path: string;
15
+ value: unknown;
16
+ element: Element | undefined;
17
+ editor: EditorSession;
18
+ }) => void;
19
+ onDraft?: (draft: {
20
+ document: unknown;
21
+ path: string;
22
+ value: unknown;
23
+ }) => void;
24
+ onCommit?: (event: { path: string; editor: EditorSession }) => void;
25
+ onCancel?: (event: { path?: string }) => void;
26
+ onError?: (error: Error) => void;
27
+ onRender?: (event: {
28
+ document: unknown;
29
+ slideIndex: number;
30
+ svg: SVGSVGElement;
31
+ geometry: SlideComposition;
32
+ draft: boolean;
33
+ }) => void;
34
+ }
35
+ export interface CanvasEditor {
36
+ editor: EditorSession;
37
+ ready: Promise<void>;
38
+ readonly slideIndex: number;
39
+ readonly editingPath: string | null;
40
+ readonly layoutEditing: boolean;
41
+ setLayoutEditing(enabled: boolean): boolean;
42
+ /** Open reorder and move-to-group controls for a complete block path. */
43
+ openBlockMenu(path: string): void;
44
+ /** Add starter content to a slide/group/region, converting implicit content when needed. */
45
+ openInsertMenu(containerPath?: string,index?: number): void;
46
+ select(path: string): void;
47
+ beginEdit(path: string): void;
48
+ /** Open structured controls, including rich run fields. */
49
+ editProperties(path: string): boolean;
50
+ commit(): boolean;
51
+ cancel(): void;
52
+ render(document?: unknown): void;
53
+ setSlide(index: number): boolean;
54
+ setRenderOptions(options: RenderSvgOptions): boolean;
55
+ destroy(): void;
56
+ }
57
+ export declare function createCanvasEditor(
58
+ container: HTMLElement,
59
+ options: CanvasEditorOptions,
60
+ ): CanvasEditor;
61
+ export declare function getEditableFields(
62
+ value: unknown,
63
+ path: string,
64
+ ): {
65
+ fields: { path: string; label: string; type: string; value: unknown }[];
66
+ arrays: { path: string; label: string; length: number }[];
67
+ };