@openpresentation/opf-editor 0.4.0 → 0.6.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
@@ -2,6 +2,8 @@
2
2
 
3
3
  Embeddable local editor primitives for Open Presentation Format documents. The package turns traced `@openpresentation/opf-render` SVG output into JSON-path-aware edits, validates OPF after each change, and records undo/redo as JSON Patch operations.
4
4
 
5
+ Version 0.6.0 uses core 0.9.0 and renderer 0.7.0. Code source/metadata edits preserve line endings, literal tabs and cancellation/undo through accepted trace targets. Committed preview uses shared quote/code geometry; the active source textarea uses native browser editing. `paginateSlide` records a one-page readability-policy change in undo history, and an unchanged repeat is a no-op. Coordinated examples use PPTX 0.7.0 for exact code source/metadata recovery. Native formatting, font theme and geometry are not restored; quotes still import as editable text blocks with structure and readability-policy loss.
6
+
5
7
  ## Scope
6
8
 
7
9
  - Package: `@openpresentation/opf-editor`
@@ -14,7 +16,7 @@ Embeddable local editor primitives for Open Presentation Format documents. The p
14
16
  - JSON Patch state transitions with inverse patches for undo/redo
15
17
  - Optional DOM controls plus React and Svelte bindings in separate embeddable entry points
16
18
 
17
- ## Live browser canvas (preview)
19
+ ## Live browser canvas
18
20
 
19
21
  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
22
 
@@ -32,7 +34,7 @@ await canvas.ready;
32
34
 
33
35
  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
36
 
35
- These APIs were introduced in 0.1.0. Version 0.4.0 requires core 0.7.0 and renderer 0.5.0 for the canvas, including styled/merged cells, rich table values, headers and content-aware row heights. 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.
37
+ These APIs were introduced in 0.1.0. Version 0.6.0 requires core 0.9.0 and renderer 0.7.0 for the canvas, including shared quote/code geometry, styled/merged cells, rich table values, headers and content-aware row heights. 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
38
 
37
39
  The canvas retains canonical SVG glyphs while a transparent native input supplies the caret. Advanced shaping, freeform object positioning, 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
40
 
@@ -172,7 +174,7 @@ This repo does not require an npm automation token when Trusted Publishing is co
172
174
 
173
175
  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
176
 
175
- Version 0.3.0 requires `@openpresentation/opf@^0.6.0`. The optional renderer peer requires `@openpresentation/opf-render@^0.4.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.
177
+ Version 0.6.0 requires `@openpresentation/opf@^0.9.0`. The optional renderer peer requires `@openpresentation/opf-render@^0.7.0`. Clean registry installs support the 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
178
 
177
179
  ## Local interactive demo
178
180
 
@@ -193,7 +195,11 @@ The browser-safe `/transfer` export provides `parseOpfTransfer`, `serializeOpfTr
193
195
 
194
196
  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
197
 
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.
198
+ The playground includes Copy OPF and Import dialogs with paste, file/drop, gallery search, and URL tabs. Files accept OPF JSON and PowerPoint `.pptx` up to 20 MB. PPTX conversion runs locally using `@openpresentation/opf-pptx`; review the converted preview and diagnostics before inserting slides or opening a presentation. Applying an import is one undoable edit. 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.
199
+
200
+ The **PowerPoint** button commits the current canvas draft and prepares an editable `.pptx` download from that snapshot, using the preview's text measurements. Conversion diagnostics remain visible in the download dialog. Missing/remote images require an explicit host resolver or embedded data; export does not silently fetch them or omit them. Save OPF separately to retain the complete original source. Native fonts must be installed in PowerPoint; the download does not embed font binaries. Arbitrary native positions and unsupported PowerPoint features can change during reimport.
201
+
202
+ Run `npm ci`, `npm run build:playground`, then serve `artifacts/playground` with a local static server. This example uses the published core/render/PPTX packages and this checkout's editor source; it does not require an account, AI provider or paid service. `npm run test:playground` builds and executes a real browser flow (local Edge on Windows, installed Chromium in CI): author JSON, inline edit, export a native merged table, validate/reimport the actual downloaded file, undo/redo, save OPF and reject malformed input without changing the document. It is browser behavior evidence, not a claim of native PowerPoint raster equivalence.
197
203
 
198
204
  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
205
 
@@ -36,7 +36,7 @@ export function getEditableFields(value, path) {
36
36
  walk(value, root, []);
37
37
  return { fields, arrays };
38
38
  }
39
- export function parseCanvasValue(text, type) {
39
+ export function parseCanvasValue(text, type, original) {
40
40
  if (type === "number") {
41
41
  if (!String(text).trim() || !Number.isFinite(Number(text)))
42
42
  throw new Error("Enter a finite number.");
@@ -47,7 +47,20 @@ export function parseCanvasValue(text, type) {
47
47
  if (text === "null") return null;
48
48
  throw new Error("This field must be null.");
49
49
  }
50
- return text;
50
+ return type === 'string' && typeof original === 'string' ? preserveTextLineEndings(original,String(text)) : text;
51
+ }
52
+
53
+ /** Textareas normalize line endings; retain unchanged source bytes around an edit. */
54
+ export function preserveTextLineEndings(original, input) {
55
+ const normalized=original.replace(/\r\n|\r/g,'\n'),next=input.replace(/\r\n|\r/g,'\n');
56
+ if(normalized===next)return original;
57
+ let prefix=0,suffix=0;
58
+ while(prefix<normalized.length&&prefix<next.length&&normalized[prefix]===next[prefix])prefix++;
59
+ while(suffix<normalized.length-prefix&&suffix<next.length-prefix&&normalized[normalized.length-1-suffix]===next[next.length-1-suffix])suffix++;
60
+ const offsets=[0];
61
+ for(let i=0;i<original.length;i++){if(original[i]==='\r'&&original[i+1]==='\n')i++;offsets.push(i+1);}
62
+ const ending=/\r\n|\r|\n/.exec(original)?.[0]??'\n';
63
+ return original.slice(0,offsets[prefix])+next.slice(prefix,next.length-suffix).replaceAll('\n',ending)+original.slice(offsets[normalized.length-suffix]);
51
64
  }
52
65
  export function createCanvasDraft(document, path, value) {
53
66
  const draft = applyJsonPatch(
package/dist/canvas.js CHANGED
@@ -52,7 +52,7 @@ export function createCanvasEditor(container, options = {}) {
52
52
  notice.hidden = true;
53
53
  const style = doc.createElement("style");
54
54
  style.textContent =
55
- ".opf-canvas-preview>svg{display:block;width:100%;height:auto}.opf-canvas [data-canvas-target]{cursor:text}.opf-canvas .opf-inline-input::selection{background:#8975d933;color:transparent}.opf-canvas [data-canvas-target]:focus{outline:none}.opf-canvas [data-canvas-target]:hover>.opf-selection{stroke:#9c90df}.opf-canvas [data-canvas-selected]>.opf-selection,.opf-canvas [data-canvas-target]:focus-visible>.opf-selection{stroke:#7765d0}.opf-canvas button:focus-visible,.opf-canvas textarea:focus-visible,.opf-canvas input:focus-visible{outline:2px solid #8170d5;outline-offset:2px}";
55
+ ".opf-canvas-preview>svg{display:block;width:100%;height:auto}.opf-canvas [data-canvas-target]{cursor:text}.opf-canvas .opf-inline-input::selection{background:#8975d933;color:var(--opf-inline-selection-color,transparent)}.opf-canvas [data-canvas-target]:focus{outline:none}.opf-canvas [data-canvas-target]:hover>.opf-selection{stroke:#9c90df}.opf-canvas [data-canvas-selected]>.opf-selection,.opf-canvas [data-canvas-target]:focus-visible>.opf-selection{stroke:#7765d0}.opf-canvas button:focus-visible,.opf-canvas textarea:focus-visible,.opf-canvas input:focus-visible{outline:2px solid #8170d5;outline-offset:2px}";
56
56
  root.append(style, preview, overlay, notice);
57
57
  container.replaceChildren(root);
58
58
  const report = (error) => {
@@ -135,6 +135,8 @@ export function createCanvasEditor(container, options = {}) {
135
135
  const value = getValueAtPath(document, path);
136
136
  if (
137
137
  seen.has(path) ||
138
+ node.getAttribute('data-opf-generated') === 'true' ||
139
+ (node.hasAttribute('data-opf-code-container') && typeof value === 'string') ||
138
140
  (!item && !node.matches("g") && !node.matches("image")) ||
139
141
  value === undefined ||
140
142
  path === `slides.${slideIndex}` ||
@@ -157,6 +159,7 @@ export function createCanvasEditor(container, options = {}) {
157
159
  );
158
160
  if (node.matches("g")) {
159
161
  let bounds = node.getBBox();
162
+ if (!bounds.width && !bounds.height && node.hasAttribute('data-opf-code-role')) bounds={x:Number(node.dataset.opfBoxX),y:Number(node.dataset.opfBoxY),width:Number(node.dataset.opfBoxWidth),height:Number(node.dataset.opfBoxHeight)};
160
163
  const lines = JSON.parse(node.getAttribute("data-opf-rich-lines") ?? "[]");
161
164
  if (!bounds.width && !bounds.height && lines.length) bounds = {x:lines[0].x,y:lines[0].y,width:Number(node.getAttribute("data-opf-box-width"))||8,height:lines.reduce((sum,line)=>sum+line.height,0)};
162
165
  const rect = doc.createElementNS("http://www.w3.org/2000/svg", "rect");
@@ -228,7 +231,7 @@ export function createCanvasEditor(container, options = {}) {
228
231
  return;
229
232
  }
230
233
  try {
231
- const value = active.kind === "rich-text" ? active.rich.value : parseCanvasValue(active.input.value, active.type);
234
+ const value = active.kind === "rich-text" ? active.rich.value : parseCanvasValue(active.input.value, active.type, active.originalValue);
232
235
  const draft = createCanvasDraft(editor.document, active.path, value);
233
236
  clearNotice();
234
237
  active.valid = true;
@@ -283,7 +286,7 @@ export function createCanvasEditor(container, options = {}) {
283
286
  fontWeight: font.fontWeight,
284
287
  fontStyle: font.fontStyle,
285
288
  lineHeight: `${lineHeight * scale}px`,
286
- color: active.composing ? font.fill : "transparent",
289
+ color: active.composing || active.isCode ? font.fill : "transparent",
287
290
  caretColor: font.fill,
288
291
  textAlign: anchor === "middle" ? "center" : anchor === "end" ? "right" : "left",
289
292
  width: `${width * scale}px`,
@@ -293,8 +296,10 @@ export function createCanvasEditor(container, options = {}) {
293
296
  });
294
297
  active.input.style.left = `${svgRect.left - rootRect.left + (left - viewBox.x) * scale}px`;
295
298
  active.input.style.top = `${svgRect.top - rootRect.top + (y - viewBox.y) * scale}px`;
296
- // Keep the canonical SVG visible. The transparent input supplies native selection and caret.
297
- target.style.opacity = active.composing ? "0" : "1";
299
+ // When the input supplies visible text, its selected text needs the same fill.
300
+ active.input.style.setProperty('--opf-inline-selection-color',active.composing || active.isCode ? font.fill : 'transparent');
301
+ // Other text keeps the canonical SVG visible beneath the transparent input.
302
+ target.style.opacity = active.composing || active.isCode ? "0" : "1";
298
303
  }
299
304
  function beginEdit(path) {
300
305
  if (disposed) return;
@@ -321,18 +326,22 @@ export function createCanvasEditor(container, options = {}) {
321
326
  input.className = "opf-inline-input";
322
327
  input.value = String(value);
323
328
  input.setAttribute("aria-label", `Edit ${path.split(".").at(-1)} inline`);
324
- input.spellcheck = true;
329
+ const isCode=text.hasAttribute('data-opf-code-role');
330
+ input.spellcheck = !isCode;
325
331
  input.style.cssText =
326
332
  "position:absolute;pointer-events:auto;resize:none;border:0;outline:1px solid #8975d9;outline-offset:4px;margin:0;padding:0;background:transparent;overflow:hidden;min-height:0;box-shadow:none;border-radius:0;white-space:pre-wrap;overflow-wrap:break-word;box-sizing:border-box;z-index:2";
327
333
  active = {
328
334
  kind: "text",
329
335
  path,
330
336
  base: JSON.stringify(value),
337
+ originalValue:value,
338
+ isCode,
331
339
  type: typeof value,
332
340
  input,
333
341
  valid: true,
334
342
  };
335
343
  overlay.append(input);
344
+ if(isCode){input.style.background='#111827';input.style.tabSize='4';input.style.overflow='auto';}
336
345
  // Only offer rich text where the canonical schema accepts TextRun[].
337
346
  if (typeof value === "string") {
338
347
  try {
@@ -366,7 +375,9 @@ export function createCanvasEditor(container, options = {}) {
366
375
  });
367
376
  input.addEventListener("keydown", (event) => {
368
377
  if (event.isComposing) return;
369
- if (event.key === "Escape") {
378
+ if (isCode && event.key === 'Tab' && !event.shiftKey) {
379
+ event.preventDefault();input.setRangeText('\t',input.selectionStart,input.selectionEnd,'end');input.dispatchEvent(new win.Event('input',{bubbles:true}));
380
+ } else if (event.key === "Escape") {
370
381
  event.preventDefault();
371
382
  event.stopPropagation();
372
383
  cancel();
@@ -400,6 +411,7 @@ export function createCanvasEditor(container, options = {}) {
400
411
  value: parseCanvasValue(
401
412
  field.type === "boolean" ? input.checked : input.value,
402
413
  field.type,
414
+ field.value,
403
415
  ),
404
416
  }));
405
417
  }
@@ -594,7 +606,7 @@ export function createCanvasEditor(container, options = {}) {
594
606
  let value;
595
607
  if (edit.kind === "rich-text") value = edit.rich.value;
596
608
  else if (edit.kind === "text")
597
- value = parseCanvasValue(edit.input.value, edit.type);
609
+ value = parseCanvasValue(edit.input.value, edit.type, edit.originalValue);
598
610
  else value = propertyDraft(edit);
599
611
  if (edit.kind === "text" || edit.kind === "rich-text")
600
612
  renderSvg(createCanvasDraft(editor.document, edit.path, value), {...renderOptions,slideIndex});
package/dist/index.js CHANGED
@@ -244,7 +244,9 @@ export function createEditorSession(input, options = {}) {
244
244
  paginateSlide(slideIndex, options = {}, meta = {}) {
245
245
  const resolved = resolveCompositionOptions(document, slideIndex, options);
246
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 };
247
+ // Pagination can persist a readability policy without adding a page. Commit
248
+ // that change too, so preview/export use the same minimum that was evaluated.
249
+ if (pagination.slides.length === 1 && jsonEqual(pagination.slides[0], document.slides[slideIndex])) return { change: null, pagination };
248
250
  const slides = [...document.slides];
249
251
  slides.splice(slideIndex, 1, ...pagination.slides);
250
252
  const change = editor.set('slides', slides, { ...meta, rejectInvalid: true, pagination: pagination.pages });
@@ -102,7 +102,7 @@ export function createRichTextToolbar(root, {editor, report, beforeChange, onPro
102
102
  function endpoint(node,offset) {
103
103
  if(node?.nodeType!==3) return null;
104
104
  const element=node.parentElement?.closest('text[data-opf-text-start]');
105
- if(!element||!root.contains(element))return null;
105
+ if(!element||!root.contains(element)||element.hasAttribute('data-opf-code-role'))return null;
106
106
  return {path:element.closest('[data-opf-path]')?.getAttribute('data-opf-path'),offset:Number(element.dataset.opfTextStart)+offset};
107
107
  }
108
108
  function selectionChanged() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-editor",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Embeddable local OPF editor primitives built on the deterministic renderer.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -75,16 +75,20 @@
75
75
  "sideEffects": false,
76
76
  "scripts": {
77
77
  "build": "node scripts/build.mjs",
78
- "typecheck": "node --check src/index.js && node --check src/react.js && node --check src/svelte.js && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check test/component-smoke.mjs && node --check src/canvas.js && node --check src/canvas-fields.js && node --check test/canvas-fields.mjs && node --check test/browser-canvas.mjs && node --check src/transfer.js && node --check src/galleries.js && node --check examples/transfer-controls.js && node --check src/schema.js && node --check src/schema-inspector.js && node --check test/rich-table-browser.mjs && node --check test/styled-table.mjs && node --check test/styled-table-browser.mjs",
78
+ "typecheck": "node --check src/index.js && node --check src/react.js && node --check src/svelte.js && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check test/component-smoke.mjs && node --check src/canvas.js && node --check src/canvas-fields.js && node --check test/canvas-fields.mjs && node --check test/browser-canvas.mjs && node --check src/transfer.js && node --check src/galleries.js && node --check examples/transfer-controls.js && node --check src/schema.js && node --check src/schema-inspector.js && node --check test/rich-table-browser.mjs && node --check test/styled-table.mjs && node --check test/styled-table-browser.mjs && node --check examples/pptx-controls.js && node --check scripts/build-playground.mjs && node --check test/playground-pptx.mjs",
79
79
  "test": "npm run build && node test/smoke.mjs && node test/component-smoke.mjs && node test/canvas-fields.mjs && node test/transfer.mjs && node test/schema.mjs && node test/rich-text.mjs && node test/layout.mjs && node test/blocks.mjs && node test/styled-table.mjs",
80
80
  "validate": "node scripts/validate-package.mjs",
81
- "prepack": "npm run build"
81
+ "test:packed": "node test/packed-install.mjs",
82
+ "prepack": "npm run build",
83
+ "build:playground": "node scripts/build-playground.mjs",
84
+ "test:playground": "npm run build:playground && node test/playground-pptx.mjs",
85
+ "test:code-browser": "node test/code-browser.mjs"
82
86
  },
83
87
  "dependencies": {
84
- "@openpresentation/opf": "^0.7.0"
88
+ "@openpresentation/opf": "^0.9.0"
85
89
  },
86
90
  "peerDependencies": {
87
- "@openpresentation/opf-render": "^0.5.0"
91
+ "@openpresentation/opf-render": "^0.7.0"
88
92
  },
89
93
  "peerDependenciesMeta": {
90
94
  "@openpresentation/opf-render": {
@@ -92,6 +96,9 @@
92
96
  }
93
97
  },
94
98
  "devDependencies": {
95
- "@openpresentation/opf-render": "0.5.0"
99
+ "@openpresentation/opf-pptx": "0.7.0",
100
+ "@openpresentation/opf-render": "0.7.0",
101
+ "esbuild": "0.28.2",
102
+ "playwright": "1.63.0"
96
103
  }
97
104
  }