@openpresentation/opf-editor 0.27.0 → 0.29.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.
Files changed (80) hide show
  1. package/README.md +23 -9
  2. package/dist/canvas.d.ts +13 -1
  3. package/dist/canvas.js +39 -9
  4. package/dist/chart-options-panel.js +16 -16
  5. package/dist/codemirror-guard.js +69 -0
  6. package/dist/content-controls.js +127 -74
  7. package/dist/data-grid.js +15 -7
  8. package/dist/design-controls.d.ts +13 -0
  9. package/dist/design-controls.js +136 -125
  10. package/dist/design-options.js +1 -1
  11. package/dist/exact-source.d.ts +30 -0
  12. package/dist/font-gate.js +20 -1
  13. package/dist/json-editor.d.ts +33 -0
  14. package/dist/json-editor.js +91 -4
  15. package/dist/json-options.d.ts +2 -1
  16. package/dist/json-options.js +7 -6
  17. package/dist/layout-adapter.js +96 -0
  18. package/dist/layout-canvas.js +122 -232
  19. package/dist/layout-editor-ui.js +46 -185
  20. package/dist/layout-inspector.js +36 -99
  21. package/dist/layout-layers.js +75 -92
  22. package/dist/layout-model.js +62 -103
  23. package/dist/layout-templates.d.ts +8 -3
  24. package/dist/layout-templates.js +30 -16
  25. package/dist/numbering-panel.js +2 -3
  26. package/dist/react.d.ts +16 -4
  27. package/dist/react.js +47 -6
  28. package/dist/slide-adapter.d.ts +49 -0
  29. package/dist/slide-adapter.js +289 -0
  30. package/dist/slide-edits.d.ts +46 -0
  31. package/dist/slide-edits.js +629 -0
  32. package/dist/slide-manager.js +2 -1
  33. package/dist/slide-scene.d.ts +96 -0
  34. package/dist/slide-scene.js +413 -0
  35. package/dist/source-checker.js +45 -6
  36. package/dist/source-color-scheme.js +60 -0
  37. package/dist/source-completions.js +6 -4
  38. package/dist/source-pane.d.ts +103 -6
  39. package/dist/source-pane.js +196 -9
  40. package/dist/source-worker.js +8 -4
  41. package/dist/studio-canvas.d.ts +34 -0
  42. package/dist/studio-canvas.js +586 -0
  43. package/dist/studio-commands.d.ts +15 -0
  44. package/dist/studio-commands.js +155 -0
  45. package/dist/studio-design.d.ts +16 -0
  46. package/dist/studio-design.js +354 -0
  47. package/dist/studio-kit/commands.d.ts +38 -0
  48. package/dist/studio-kit/commands.js +148 -0
  49. package/dist/studio-kit/dom.d.ts +52 -0
  50. package/dist/studio-kit/dom.js +198 -0
  51. package/dist/studio-kit/drag.d.ts +26 -0
  52. package/dist/studio-kit/drag.js +127 -0
  53. package/dist/studio-kit/gesture.d.ts +29 -0
  54. package/dist/studio-kit/gesture.js +87 -0
  55. package/dist/studio-kit/index.d.ts +29 -0
  56. package/dist/studio-kit/index.js +12 -0
  57. package/dist/studio-kit/inspector.d.ts +22 -0
  58. package/dist/studio-kit/inspector.js +119 -0
  59. package/dist/studio-kit/overlay.d.ts +29 -0
  60. package/dist/studio-kit/overlay.js +182 -0
  61. package/dist/studio-kit/selection.d.ts +30 -0
  62. package/dist/studio-kit/selection.js +106 -0
  63. package/dist/studio-kit/tree.d.ts +9 -0
  64. package/dist/studio-kit/tree.js +82 -0
  65. package/dist/studio-kit/viewport.d.ts +29 -0
  66. package/dist/studio-kit/viewport.js +153 -0
  67. package/dist/studio-layers.d.ts +12 -0
  68. package/dist/studio-layers.js +218 -0
  69. package/dist/studio.d.ts +31 -0
  70. package/dist/studio.js +1608 -0
  71. package/dist/styles.css +1573 -123
  72. package/dist/thumbnail-cache.d.ts +2 -0
  73. package/dist/thumbnail-cache.js +10 -1
  74. package/dist/workspace-measure.js +6 -4
  75. package/dist/workspace-parts.js +20 -0
  76. package/dist/workspace-shell.js +25 -15
  77. package/dist/workspace-theme.js +3 -0
  78. package/dist/workspace.d.ts +114 -10
  79. package/dist/workspace.js +223 -49
  80. package/package.json +30 -19
package/dist/workspace.js CHANGED
@@ -7,35 +7,49 @@
7
7
  // A host picks a layout: `"full"` (a bar, the source, the slide, the slide row and a side panel are built for it), `"canvas"`, `"source"`,
8
8
  // or `slots`, an object of its own elements, one per part, for a host that arranges the page itself. Each part is optional (`panels`).
9
9
  // Nothing here fetches anything, stores anything on its own or talks to a service; the host supplies fonts, catalogs and storage.
10
- import { toSvg } from "@openpresentation/opf-render/svg";
11
10
  import { OPFEditorError, createEditorSession, mergeCatalogs } from "./index.js";
12
- import { createCanvasEditor } from "./canvas.js";
13
11
  import { whenFontsReady } from "./font-gate.js";
14
12
  import { createSourcePane } from "./source-pane.js";
15
- import { createSlideNavigator } from "./slide-navigator.js";
16
- import { createThumbnailStrip } from "./thumbnail-strip.js";
17
- import { createThumbnailCache } from "./thumbnail-cache.js";
18
- import { createSlideManager } from "./slide-manager.js";
19
- import { createOutlineView } from "./outline-view.js";
20
- import { createExportMenu } from "./export-menu.js";
21
- import { bindPresentShortcut, createPresentButton, createPresenter } from "./presenter.js";
22
- import { createReviewPanel } from "./review-panel.js";
23
- import { createFindPanel, installFindShortcuts } from "./find-panel.js";
24
- import { createDesignControls } from "./design-controls.js";
25
13
  import { createPersistence } from "./persistence.js";
26
14
  import { createPersistenceUi } from "./persistence-ui.js";
27
- import { prepareDimensionSwitch } from "./switches.js";
28
- import { mergeSlides, splitSlideByBlocks } from "./content-actions.js";
29
- import { installCanvasVariableEditing } from "./canvas-variables.js";
30
- import { createLayoutStudio } from "./workspace-layouts.js";
31
- import { fontSubstitutionNotes, layoutOptionsFor } from "./workspace-measure.js";
32
15
  import { buildWorkspaceShell } from "./workspace-shell.js";
16
+ import { fontSubstitutionNotes, layoutOptionsFor } from "./workspace-measure.js";
33
17
  import { SOURCE_CHECKS, copyText, downloadSourceFile, lf, reportSource } from "./workspace-source.js";
34
18
  import { createThemeController } from "./workspace-theme.js";
35
19
  import { retainedSlideIndex } from "./slide-retention.js";
36
20
  import { createSourceMemory, updateJsonSource } from "./exact-source.js";
37
21
 
38
22
  export { THEME_TOKENS, createThemeController, normalizeTheme, normalizeThemeTokens } from "./workspace-theme.js";
23
+ // The script-aware measurement options for composing one slide: a host that calls `composeSlide` or `paginateSlide` on the session passes these (the playground does).
24
+ export { layoutOptionsFor } from "./workspace-measure.js";
25
+
26
+ // The slide and everything around it (the renderer, the canvas, the slide row, the presenter, Export, Review, Find, Design and the views) load
27
+ // on demand, in one chunk (`workspace-parts.js`): a host that mounts only the source pane never loads the renderer. Its factories are bound
28
+ // here once the chunk has arrived, before any part is mounted.
29
+ let toSvg, createCanvasEditor, createSlideNavigator, createThumbnailStrip, createThumbnailCache, createSlideManager, createOutlineView, createExportMenu;
30
+ let bindPresentShortcut, createPresentButton, createPresenter, createReviewPanel, createFindPanel, installFindShortcuts, createDesignControls;
31
+ let prepareDimensionSwitch, mergeSlides, splitSlideByBlocks, installCanvasVariableEditing, createLayoutStudio, buildStudioShell, createStudio;
32
+ let partsModule;
33
+ let partsLoaded = false;
34
+ function loadParts() {
35
+ partsModule ??= import("./workspace-parts.js").then((parts) => {
36
+ ({ toSvg, createCanvasEditor, createSlideNavigator, createThumbnailStrip, createThumbnailCache, createSlideManager, createOutlineView, createExportMenu } = parts);
37
+ ({ bindPresentShortcut, createPresentButton, createPresenter, createReviewPanel, createFindPanel, installFindShortcuts, createDesignControls } = parts);
38
+ ({ prepareDimensionSwitch, mergeSlides, splitSlideByBlocks, installCanvasVariableEditing, createLayoutStudio, buildStudioShell, createStudio } = parts);
39
+ partsLoaded = true;
40
+ });
41
+ // A chunk that failed to load is asked for again by the next editor.
42
+ partsModule.catch(() => {
43
+ partsModule = undefined;
44
+ });
45
+ return partsModule;
46
+ }
47
+ /**
48
+ * Whether the parts to mount need the slide chunk: any part but the source pane, its autosave and the history buttons, that has an element to
49
+ * mount in (the presenter mounts in the overlay).
50
+ */
51
+ const SOURCE_ONLY_PARTS = new Set(["source", "persistence", "history"]);
52
+ const needsSlideParts = (parts, slots) => Object.entries(parts).some(([name, on]) => on && !SOURCE_ONLY_PARTS.has(name) && (name === "present" || Boolean(slots[name])));
39
53
 
40
54
  const VIEWS = Object.freeze(["slide", "sorter", "outline", "layouts"]);
41
55
  const MUTATORS = new Set(["set", "setGroupComposition", "setCatalog", "setCatalogs", "applyPatch", "replaceDocument", "restoreState", "undo", "redo", "setComposition", "paginateSlide"]);
@@ -55,7 +69,11 @@ const LAYOUT_PARTS = {
55
69
  full: { source: true, navigator: true, thumbnails: true, history: true, find: true, present: true, export: true, review: true, layouts: true, sorter: true, outline: true },
56
70
  canvas: { navigator: true, thumbnails: true, history: true },
57
71
  source: { source: true },
72
+ // CE-20: the Studio and its Playground preset arrange the same parts; the Studio mounts its own Design panel.
73
+ studio: { source: true, navigator: true, thumbnails: true, history: true, find: true, present: true, export: true, review: true, layouts: true, slides: true },
74
+ playground: { source: true, navigator: true, thumbnails: true, history: true, find: true, present: true, export: true, review: true, layouts: true, slides: true },
58
75
  };
76
+ const STUDIO_LAYOUTS = new Set(["studio", "playground"]);
59
77
 
60
78
  const isThenable = (value) => value !== null && typeof value === "object" && typeof value.then === "function";
61
79
 
@@ -108,7 +126,9 @@ function resolveParts(options, layout, slots) {
108
126
  }
109
127
 
110
128
  /**
111
- * Mount the editor in `element`. Returns the handle at once; with a `fonts` promise the parts mount when it resolves (`ready` settles then).
129
+ * Mount the editor in `element`. Returns the handle at once; the parts mount once a `fonts` promise has resolved and, for a layout that shows
130
+ * slides, once the slide chunk has loaded (`mounted` is emitted then, and `ready` settles after the first update). A source-only editor whose
131
+ * fonts are at hand mounts before this returns.
112
132
  * See `workspace.d.ts` for the options and the handle.
113
133
  */
114
134
  export function createOpfEditor(element, options = {}) {
@@ -150,6 +170,7 @@ export function createOpfEditor(element, options = {}) {
150
170
  for (const [name, listener] of Object.entries(options.on ?? {})) on(name, listener);
151
171
  if (options.onChange) on("change", options.onChange);
152
172
  if (options.onSelection) on("selection", options.onSelection);
173
+ if (options.onCursor) on("cursor", options.onCursor);
153
174
  if (options.onError) on("error", ({ error, ...detail }) => options.onError(error, detail));
154
175
  if (options.onReady) on("ready", options.onReady);
155
176
 
@@ -162,8 +183,9 @@ export function createOpfEditor(element, options = {}) {
162
183
 
163
184
  // ---- layout, parts and slots ------------------------------------------------------------------------------------------------
164
185
  const given = options.slots ?? {};
165
- const layout = options.slots ? "slots" : (options.layout ?? "full");
166
- if (!["full", "canvas", "source", "slots"].includes(layout)) throw new RangeError('layout must be "full", "canvas" or "source", or pass `slots`.');
186
+ const layout = options.slots && !STUDIO_LAYOUTS.has(options.layout) ? "slots" : (options.layout ?? "full");
187
+ if (!["full", "canvas", "source", "slots", "studio", "playground"].includes(layout)) throw new RangeError('layout must be "full", "canvas", "source", "studio" or "playground", or pass `slots`.');
188
+ const studioLayout = STUDIO_LAYOUTS.has(layout);
167
189
  const parts = resolveParts(options, layout, given);
168
190
  const views = VIEWS.filter((view) => view === "slide" || parts[view]);
169
191
  if (options.view !== undefined && !views.includes(options.view)) throw new RangeError(`view "${options.view}" is not available in this layout (${views.join(", ")}).`);
@@ -180,13 +202,27 @@ export function createOpfEditor(element, options = {}) {
180
202
  const themeController = createThemeController(element, options.theme);
181
203
  let slots = { ...given };
182
204
  let controls = {};
205
+ let studioShell = null;
206
+ let overlay = root;
207
+ // CE-20: the Studio builds its own shell; a host's slots add parts to it (`topbarEnd`, `overlay`). Its code is in the slide chunk, so the Studio
208
+ // shell is built once that has loaded, right before the parts mount.
209
+ function buildStudio() {
210
+ const built = buildStudioShell(root, { parts, layout, labels: options.labels, studio: options.studio, slots: given });
211
+ const { topbarEnd: _placed, ...rest } = given;
212
+ slots = { ...built.slots, ...rest };
213
+ controls = built.controls;
214
+ studioShell = built.shell;
215
+ overlay = slots.overlay ?? root;
216
+ }
183
217
  if (layout === "slots") root.setAttribute("data-opf-layout", "slots");
184
- else {
218
+ else if (!studioLayout) {
185
219
  const built = buildWorkspaceShell(root, { parts, views, layout, labels: options.labels });
186
220
  slots = { ...built.slots, ...given };
187
221
  controls = built.controls;
188
222
  }
189
- const overlay = slots.overlay ?? root;
223
+ overlay = slots.overlay ?? root;
224
+ // The slide chunk is loaded before the parts mount, unless only the source pane (with its autosave) is mounted. The Studio always needs it.
225
+ const slideParts = studioLayout || needsSlideParts(parts, slots);
190
226
 
191
227
  // ---- state ------------------------------------------------------------------------------------------------------------------
192
228
  let fonts;
@@ -198,6 +234,8 @@ export function createOpfEditor(element, options = {}) {
198
234
  let sorter;
199
235
  let outline;
200
236
  let studio;
237
+ let studioCtl;
238
+ let fontsPending = false;
201
239
  let presenter;
202
240
  let presentButton;
203
241
  let exportMenu;
@@ -220,6 +258,7 @@ export function createOpfEditor(element, options = {}) {
220
258
  let drafting = false;
221
259
  let openedText;
222
260
  let sourceState = null;
261
+ let sourceCursor = null;
223
262
  let fontsFailure;
224
263
  let refreshToken = 0;
225
264
  let lastRefresh = Promise.resolve();
@@ -230,7 +269,7 @@ export function createOpfEditor(element, options = {}) {
230
269
  let frameSize = { width: 0, height: 0 };
231
270
  const draftMemory = createSourceMemory();
232
271
  const variablesMode = options.variables === "resolved" ? "resolved" : "authored";
233
- const fitMode = options.fit === undefined ? (layout === "slots" ? false : "contain") : options.fit;
272
+ const fitMode = studioLayout ? false : options.fit === undefined ? (layout === "slots" ? false : "contain") : options.fit;
234
273
 
235
274
  const say = (message) => {
236
275
  statusText = message;
@@ -251,7 +290,8 @@ export function createOpfEditor(element, options = {}) {
251
290
 
252
291
  // ---- navigation and the refresh loop ----------------------------------------------------------------------------------------
253
292
  function renderViews(deck) {
254
- thumbnailCache?.prepare(deck);
293
+ // The Studio's filmstrip draws the thumbnails it shows itself, as they scroll in.
294
+ if (!studioLayout) thumbnailCache?.prepare(deck);
255
295
  slideManager?.render();
256
296
  if (activeView === "sorter") sorter?.render();
257
297
  if (activeView === "outline") outline?.render();
@@ -279,7 +319,8 @@ export function createOpfEditor(element, options = {}) {
279
319
  }
280
320
  function sync() {
281
321
  fonts?.registry?.clearSubstitutions?.();
282
- const deck = session.presentation;
322
+ // The frozen snapshot (one object per version): the session's `presentation` copies the whole deck on every read.
323
+ const deck = session.snapshot().presentation;
283
324
  slideIndex = Math.max(0, Math.min(slideIndex, deck.slides.length - 1));
284
325
  renderViews(deck);
285
326
  slideNav?.setSlide(slideIndex);
@@ -296,16 +337,18 @@ export function createOpfEditor(element, options = {}) {
296
337
  if (!mounted || destroyed) return Promise.resolve();
297
338
  const token = ++refreshToken;
298
339
  let loaded = false;
299
- lastRefresh = whenFontsReady(fonts, session.presentation, {
340
+ lastRefresh = whenFontsReady(fonts, session.snapshot().presentation, {
300
341
  renderOptions: renderOptions(),
301
342
  isCurrent: () => token === refreshToken && !destroyed,
302
343
  loading: () => {
303
344
  loaded = true;
345
+ fontsPending = true;
304
346
  emit("fonts", { state: "loading" });
305
347
  say(MESSAGES.loadingFonts);
306
348
  },
307
349
  ready: () => {
308
350
  fontsFailure = undefined;
351
+ fontsPending = false;
309
352
  if (loaded) {
310
353
  thumbnailCache?.clear();
311
354
  emit("fonts", { state: "ready" });
@@ -350,7 +393,8 @@ export function createOpfEditor(element, options = {}) {
350
393
  }
351
394
  function goTo(index, path) {
352
395
  if (index !== slideIndex && !go(index)) return false;
353
- if (path) canvas?.reveal(path);
396
+ if (studioCtl) studioCtl.revealPath(path ?? `slides.${index}`);
397
+ else if (path) canvas?.reveal(path);
354
398
  return true;
355
399
  }
356
400
 
@@ -363,7 +407,8 @@ export function createOpfEditor(element, options = {}) {
363
407
  if (controls.redo) controls.redo.disabled = !inSync || readOnly || !session.canRedo;
364
408
  // Text in the source pane that is not a valid deck: the slide shows the last valid one and is not editable until the text is fixed.
365
409
  const frame = slots.canvas && (slots.slide ?? slots.canvas);
366
- if (frame && (!inSync || readOnly)) frame.setAttribute("inert", "");
410
+ // The Studio keeps selection, zoom and Present while read-only (its commands say why they are off); only invalid text locks the slide.
411
+ if (frame && (!inSync || (readOnly && !studioLayout))) frame.setAttribute("inert", "");
367
412
  else frame?.removeAttribute("inert");
368
413
  }
369
414
  function applyFit() {
@@ -389,7 +434,8 @@ export function createOpfEditor(element, options = {}) {
389
434
  setSlideIndex: (index) => {
390
435
  go(index);
391
436
  },
392
- renderThumbnail: (deck, index) => thumbnailCache.html(deck, index),
437
+ // The Studio's filmstrip draws only the thumbnails it shows (an IntersectionObserver fills the rest as they scroll in).
438
+ renderThumbnail: studioLayout ? (deck, index) => thumbnailCache.peek(deck, index) ?? "" : (deck, index) => thumbnailCache.html(deck, index),
393
439
  renderLayoutPreview: (deck, index, choice) => {
394
440
  const candidate = prepareDimensionSwitch(deck, "layouts", choice.id, { slideIndex: index, catalogs: catalogs() }).presentation;
395
441
  return toSvg(candidate, index + 1, { ...renderOptions(), fonts, text: "system", trace: false });
@@ -450,6 +496,7 @@ export function createOpfEditor(element, options = {}) {
450
496
  return true;
451
497
  }
452
498
  function showPanel(name, focus = false) {
499
+ if (studioCtl) return studioCtl.showTab(name, { focus });
453
500
  const tabs = controls.tabs;
454
501
  if (!tabs?.[name]) return false;
455
502
  for (const [tabName, tab] of Object.entries(tabs)) {
@@ -471,6 +518,8 @@ export function createOpfEditor(element, options = {}) {
471
518
  strip?.destroy();
472
519
  slideNav = undefined;
473
520
  strip = undefined;
521
+ // The Studio shows its filmstrip; the slide row is the Playground preset's, mounted only while that preset is on.
522
+ if (studioLayout && (studioCtl?.layout ?? layout) !== "playground") return;
474
523
  if (parts.navigator && slots.navigator) {
475
524
  slideNav = createSlideNavigator(slots.navigator, {
476
525
  editor: ui,
@@ -538,6 +587,7 @@ export function createOpfEditor(element, options = {}) {
538
587
  return {
539
588
  ...(configured.outline !== undefined ? { outline: configured.outline } : {}),
540
589
  ...(configured.applyDelay !== undefined ? { applyDelay: configured.applyDelay } : {}),
590
+ ...(configured.cursorDelay !== undefined ? { cursorDelay: configured.cursorDelay } : {}),
541
591
  ...(configured.completions !== undefined ? { completions: configured.completions } : {}),
542
592
  ...(configured.catalogs !== undefined ? { catalogs: configured.catalogs } : {}),
543
593
  ...(configured.name !== undefined ? { name: configured.name } : {}),
@@ -553,6 +603,11 @@ export function createOpfEditor(element, options = {}) {
553
603
  configured.onRename?.(name);
554
604
  emit("rename", { name });
555
605
  },
606
+ // CE-17: the path under the caret of the source pane, as `cursor` events.
607
+ onCursor: (detail) => {
608
+ sourceCursor = Object.freeze(detail);
609
+ emit("cursor", sourceCursor);
610
+ },
556
611
  getFindings: ({ format, text: shown }) => {
557
612
  reportedText = shown;
558
613
  const made = reportSource(format, shown, { catalogs: catalogs(), checks: configured.checks ?? SOURCE_CHECKS });
@@ -615,6 +670,7 @@ export function createOpfEditor(element, options = {}) {
615
670
  );
616
671
  canvas = createCanvasEditor(slots.canvas, {
617
672
  ...configured,
673
+ ...(studioLayout ? { textEntry: layout === "playground" ? "click" : "dblclick", targets: layout === "playground" ? "interactive" : "passive", escape: "commit" } : {}),
618
674
  editor: ui,
619
675
  fonts,
620
676
  renderOptions: renderOptions(),
@@ -625,17 +681,22 @@ export function createOpfEditor(element, options = {}) {
625
681
  if (event.state === "error") {
626
682
  fontsFailure = event.error;
627
683
  say(event.error.message);
628
- } else if (event.state === "loading") say(MESSAGES.loadingFonts);
684
+ } else if (event.state === "loading") {
685
+ fontsPending = true;
686
+ say(MESSAGES.loadingFonts);
687
+ }
629
688
  else if (fontsFailure) {
630
689
  fontsFailure = undefined;
631
690
  refresh();
632
691
  }
692
+ if (event.state === "ready") fontsPending = false;
633
693
  emit("fonts", event);
634
694
  configured.onFonts?.(event);
635
695
  },
636
696
  onSelect: (value) => {
637
697
  selection = { path: value.path, value: value.value };
638
- emit("selection", { path: value.path, value: value.value, slideIndex, element: value.element });
698
+ // The Studio emits its own selection (a list of nodes); the canvas's single selection is internal there.
699
+ if (!studioLayout) emit("selection", { path: value.path, value: value.value, slideIndex, element: value.element });
639
700
  design?.refresh();
640
701
  configured.onSelect?.(value);
641
702
  },
@@ -773,7 +834,7 @@ export function createOpfEditor(element, options = {}) {
773
834
  onFocusField: ({ fix, target }) => emit("review-focus", { fix, target }),
774
835
  onStatus: say,
775
836
  onChange: (counts) => {
776
- const tab = controls.tabs?.review;
837
+ const tab = controls.tabs?.review ?? studioShell?.tabs.review?.tab;
777
838
  if (tab) {
778
839
  const name = options.labels?.review ?? "Review";
779
840
  const found = !counts.unavailable && counts.total;
@@ -785,13 +846,13 @@ export function createOpfEditor(element, options = {}) {
785
846
  });
786
847
  }
787
848
 
788
- function present() {
849
+ function present(from) {
789
850
  if (destroyed || !mounted || !presenter || presenter.isRunning() || !inSync || slides.length === 0) return false;
790
851
  // An open canvas edit is committed, and text typed a moment ago is applied, before the document is shown; a source that is not a valid deck waits.
791
852
  if (canvas?.commit() === false) return false;
792
853
  pane?.getText();
793
854
  if (draftInvalid) return false;
794
- void presenter.start(canvas?.slideIndex ?? slideIndex);
855
+ void presenter.start(Number.isInteger(from) ? from : (canvas?.slideIndex ?? slideIndex));
795
856
  return true;
796
857
  }
797
858
 
@@ -799,7 +860,7 @@ export function createOpfEditor(element, options = {}) {
799
860
  slides = session.presentation.slides ?? [];
800
861
  authoredTemplate = session.presentation.template === true;
801
862
  if (parts.canvas && slots.canvas) mountCanvas();
802
- thumbnailCache = createThumbnailCache({ toSvg, getOptions: () => ({ ...renderOptions(), fonts, text: "system", trace: false }) });
863
+ if (slideParts) thumbnailCache = createThumbnailCache({ toSvg, getOptions: () => ({ ...renderOptions(), fonts, text: "system", trace: false }) });
803
864
  mountSlideRow();
804
865
  if (parts.slides && slots.slides) slideManager = createSlideManager(slots.slides, { ...slideManagerOptions(), ...(slots.slideToolbar ? { toolbar: slots.slideToolbar } : {}) });
805
866
  if (parts.source && slots.source) {
@@ -857,6 +918,7 @@ export function createOpfEditor(element, options = {}) {
857
918
  if (parts.persistence) mountPersistence();
858
919
  mountControls();
859
920
  mountEnvironment();
921
+ if (studioShell && canvas) mountStudio();
860
922
  // These come last, so a host hears a change after every part has. The refresh runs for every event, the catalogs' included.
861
923
  cleanups.push(
862
924
  session.subscribe((event) => {
@@ -880,6 +942,66 @@ export function createOpfEditor(element, options = {}) {
880
942
  );
881
943
  }
882
944
 
945
+ // CE-20: the Studio over the parts mounted above (src/studio.js).
946
+ function mountStudio() {
947
+ studioCtl = createStudio({
948
+ root,
949
+ element,
950
+ shell: studioShell,
951
+ slots,
952
+ session,
953
+ ui,
954
+ canvas,
955
+ options,
956
+ layout,
957
+ hostCatalogs,
958
+ get pane() {
959
+ return pane;
960
+ },
961
+ get find() {
962
+ return find;
963
+ },
964
+ get exportMenu() {
965
+ return exportMenu;
966
+ },
967
+ get slideManager() {
968
+ return slideManager;
969
+ },
970
+ get thumbnailCache() {
971
+ return thumbnailCache;
972
+ },
973
+ get persistence() {
974
+ return persistence;
975
+ },
976
+ get handle() {
977
+ return handle;
978
+ },
979
+ slideIndex: () => slideIndex,
980
+ go: (index) => go(index),
981
+ present: (from) => present(from),
982
+ say,
983
+ emit,
984
+ report,
985
+ on,
986
+ setView: (view) => setView(view),
987
+ readOnly: () => readOnly,
988
+ inSync: () => inSync,
989
+ fontsLoading: () => fontsPending,
990
+ catalogs,
991
+ layoutFor: (deck, index) => layoutFor(deck, index),
992
+ setDocument: (document, openOptions) => setDocument(document, openOptions),
993
+ openFind: (openOptions) => {
994
+ if (!find) return false;
995
+ find.openPanel(openOptions ?? {});
996
+ return true;
997
+ },
998
+ getName: () => pane?.getName(),
999
+ setName: (name) => pane?.setName(name),
1000
+ layoutStudio: () => studio,
1001
+ mountSlideRow: () => mountSlideRow(),
1002
+ });
1003
+ }
1004
+
883
1005
  // The shell's own controls: history, views, tabs.
884
1006
  function mountControls() {
885
1007
  controls.undo?.addEventListener("click", () => undo());
@@ -967,6 +1089,7 @@ export function createOpfEditor(element, options = {}) {
967
1089
  studio?.setReadOnly(readOnly);
968
1090
  root.toggleAttribute("data-readonly", readOnly);
969
1091
  syncChrome();
1092
+ studioCtl?.refresh();
970
1093
  }
971
1094
  function setDensity(setting) {
972
1095
  const next = densityFor(setting);
@@ -1004,16 +1127,29 @@ export function createOpfEditor(element, options = {}) {
1004
1127
  } catch (error) {
1005
1128
  if (error?.code !== "invalid-opf-edit") throw error;
1006
1129
  // A document the session refuses is no document to open: show its text with its findings, as a draft the person can fix.
1007
- if (pane && text !== undefined) {
1008
- if (openOptions.format && pane.getFormat() !== openOptions.format) pane.setFormat(openOptions.format);
1009
- const api = pane.editor.api;
1010
- api.replace(0, api.getValue().length, text);
1011
- pane.getText();
1012
- if (openOptions.name !== undefined) pane.setName(openOptions.name);
1013
- }
1130
+ if (pane && text !== undefined) pane.setSourceText(text, { ...(openOptions.format ? { format: openOptions.format } : {}), ...(openOptions.name !== undefined ? { name: openOptions.name } : {}) });
1014
1131
  return false;
1015
1132
  }
1016
1133
  }
1134
+ // Raw text in the source pane (a file as it was read, a draft that does not parse), without converting it: what the person would get by
1135
+ // pasting it over everything. True when the session now holds its document; false when it stays a draft with its problems.
1136
+ function setSourceText(text, openOptions = {}) {
1137
+ if (destroyed || !mounted) throw new OPFEditorError("not-ready", "The editor is not mounted yet. Await `ready` first.");
1138
+ if (!pane) throw new OPFEditorError("no-source-pane", "This editor has no source pane.");
1139
+ if (readOnly) throw new OPFEditorError("read-only", "This editor is read-only.");
1140
+ if (typeof text !== "string") throw new TypeError("setSourceText needs the source text as a string.");
1141
+ // An open canvas edit is committed first; the text then replaces it.
1142
+ if (canvas?.commit() === false) return false;
1143
+ drafting = false;
1144
+ openedText = undefined;
1145
+ const applied = pane.setSourceText(text, {
1146
+ ...(openOptions.format ? { format: openOptions.format } : {}),
1147
+ ...(openOptions.name !== undefined ? { name: openOptions.name } : {}),
1148
+ });
1149
+ // Text the host loaded is not the person's work: it is not autosaved or warned about until it is edited.
1150
+ if (applied && openOptions.rebase !== false) persistence?.rebase();
1151
+ return applied;
1152
+ }
1017
1153
 
1018
1154
  let fontsRef = { value: undefined };
1019
1155
  let releaseFonts;
@@ -1074,8 +1210,34 @@ export function createOpfEditor(element, options = {}) {
1074
1210
  get slideIndex() {
1075
1211
  return slideIndex;
1076
1212
  },
1213
+ /** The selection: the canvas's `{ path, value }` (or null); in the Studio, the list `[{ key, path, idPath, kind }]`. */
1077
1214
  get selection() {
1078
- return selection;
1215
+ return studioCtl ? studioCtl.selection : selection;
1216
+ },
1217
+ /** CE-20: the arrangement ("full", "canvas", "source", "slots", or the Studio's "studio" and "playground"). */
1218
+ get layout() {
1219
+ return studioCtl?.layout ?? layout;
1220
+ },
1221
+ /** CE-20: switch the Studio between "studio" and "playground" in place (same session, history, slide and selection). */
1222
+ setLayout(next) {
1223
+ if (!studioCtl) return false;
1224
+ return studioCtl.setLayout(next);
1225
+ },
1226
+ get zoom() {
1227
+ return studioCtl?.zoom ?? 1;
1228
+ },
1229
+ setZoom(value) {
1230
+ studioCtl?.setZoom(value);
1231
+ },
1232
+ fit(mode) {
1233
+ studioCtl?.fit(mode);
1234
+ },
1235
+ openLayout(layoutId, openOptions = {}) {
1236
+ return studioCtl ? studioCtl.openLayout(layoutId, openOptions) : false;
1237
+ },
1238
+ /** CE-20: the Studio's command registry (`list`, `run`, `register`); empty outside the Studio. */
1239
+ get commands() {
1240
+ return studioCtl?.commands ?? { list: () => [], run: () => false, register: () => () => {} };
1079
1241
  },
1080
1242
  get view() {
1081
1243
  return activeView;
@@ -1090,6 +1252,9 @@ export function createOpfEditor(element, options = {}) {
1090
1252
  return inSync;
1091
1253
  },
1092
1254
  /** The latest verdict on the text of the source pane (what the last `source` event carried), or `null` before the first. */
1255
+ get sourceCursor() {
1256
+ return sourceCursor;
1257
+ },
1093
1258
  get sourceState() {
1094
1259
  return sourceState;
1095
1260
  },
@@ -1103,11 +1268,13 @@ export function createOpfEditor(element, options = {}) {
1103
1268
  return session.snapshot().presentation;
1104
1269
  },
1105
1270
  setDocument,
1271
+ setSourceText,
1106
1272
  setView,
1107
1273
  setSlide: go,
1108
1274
  goTo,
1109
- select(path, { focus = false } = {}) {
1110
- return canvas?.reveal(path, { focus }) ?? null;
1275
+ select(path, selectOptions = {}) {
1276
+ if (studioCtl) return studioCtl.selectTargets(path, selectOptions);
1277
+ return canvas?.reveal(path, { focus: Boolean(selectOptions.focus) }) ?? null;
1111
1278
  },
1112
1279
  showPanel,
1113
1280
  present,
@@ -1174,6 +1341,7 @@ export function createOpfEditor(element, options = {}) {
1174
1341
  /* already gone */
1175
1342
  }
1176
1343
  }
1344
+ studioCtl?.destroy();
1177
1345
  unbindKeys?.();
1178
1346
  checking?.disconnect();
1179
1347
  persistence?.destroy();
@@ -1201,6 +1369,7 @@ export function createOpfEditor(element, options = {}) {
1201
1369
 
1202
1370
  // ---- start ------------------------------------------------------------------------------------------------------------------
1203
1371
  function mount(resolved) {
1372
+ if (studioLayout && !studioShell) buildStudio();
1204
1373
  fonts = resolved;
1205
1374
  density = densityFor(options.density);
1206
1375
  exportMode = exportModeFor(options.exportMode);
@@ -1241,15 +1410,20 @@ export function createOpfEditor(element, options = {}) {
1241
1410
  };
1242
1411
  try {
1243
1412
  fontsRef = fontsSource(options.fonts);
1244
- if (isThenable(fontsRef.value)) {
1245
- say(MESSAGES.loadingFonts);
1246
- Promise.resolve(fontsRef.value).then((resolved) => {
1413
+ const chunk = slideParts && !partsLoaded ? loadParts() : null;
1414
+ if (isThenable(fontsRef.value) || chunk) {
1415
+ if (isThenable(fontsRef.value)) say(MESSAGES.loadingFonts);
1416
+ Promise.all([fontsRef.value, chunk]).then(([resolved]) => {
1247
1417
  try {
1248
1418
  start(resolved);
1249
1419
  } catch (error) {
1250
1420
  fail(error);
1251
1421
  }
1252
- }, fail);
1422
+ }, (error) => {
1423
+ // The slide chunk did not load: fonts that did arrive are given back.
1424
+ Promise.resolve(fontsRef.value).then((resolved) => fontsRef.use(resolved).release?.(), () => {});
1425
+ fail(error);
1426
+ });
1253
1427
  } else start(fontsRef.value);
1254
1428
  } catch (error) {
1255
1429
  fail(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-editor",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "description": "Embeddable local OPF editor primitives built on the deterministic renderer.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -239,6 +239,10 @@
239
239
  "types": "./dist/source-pane.d.ts",
240
240
  "default": "./dist/source-pane.js"
241
241
  },
242
+ "./exact-source": {
243
+ "types": "./dist/exact-source.d.ts",
244
+ "default": "./dist/exact-source.js"
245
+ },
242
246
  "./presenter": {
243
247
  "types": "./dist/presenter.d.ts",
244
248
  "default": "./dist/presenter.js"
@@ -281,25 +285,29 @@
281
285
  }
282
286
  },
283
287
  "types": "./dist/index.d.ts",
284
- "sideEffects": false,
288
+ "sideEffects": [
289
+ "./dist/source-worker.js",
290
+ "./dist/styles.css"
291
+ ],
285
292
  "dependencies": {
286
- "@codemirror/autocomplete": "6.20.3",
287
- "@codemirror/commands": "6.11.1",
288
- "@codemirror/lang-json": "6.0.2",
289
- "@codemirror/lang-yaml": "6.1.3",
290
- "@codemirror/language": "6.13.1",
291
- "@codemirror/lint": "6.9.7",
292
- "@codemirror/search": "6.7.2",
293
- "@codemirror/state": "6.7.6",
294
- "@codemirror/view": "6.43.14",
295
- "@lezer/highlight": "1.2.5",
293
+ "@codemirror/autocomplete": "^6.20.4",
294
+ "@codemirror/commands": "^6.11.1",
295
+ "@codemirror/lang-json": "^6.0.2",
296
+ "@codemirror/lang-yaml": "^6.1.3",
297
+ "@codemirror/language": "^6.13.1",
298
+ "@codemirror/lint": "^6.9.7",
299
+ "@codemirror/search": "^6.7.2",
300
+ "@codemirror/state": "^6.7.6",
301
+ "@codemirror/view": "^6.43.15",
302
+ "@lezer/highlight": "^1.2.5",
296
303
  "jsonc-parser": "3.3.1",
297
304
  "yaml": "^2.9.1",
298
- "@openpresentation/opf": "^0.27.0"
305
+ "@openpresentation/opf": "^0.29.0"
299
306
  },
300
307
  "peerDependencies": {
301
308
  "react": ">=18",
302
- "@openpresentation/opf-render": "^0.27.0"
309
+ "react-dom": ">=18",
310
+ "@openpresentation/opf-render": "^0.29.0"
303
311
  },
304
312
  "peerDependenciesMeta": {
305
313
  "@openpresentation/opf-render": {
@@ -307,6 +315,9 @@
307
315
  },
308
316
  "react": {
309
317
  "optional": true
318
+ },
319
+ "react-dom": {
320
+ "optional": true
310
321
  }
311
322
  },
312
323
  "devDependencies": {
@@ -360,14 +371,14 @@
360
371
  "jszip": "3.10.2",
361
372
  "pdf-lib": "1.17.1",
362
373
  "pdfjs-dist": "6.4.299",
363
- "playwright": "1.64.0",
374
+ "playwright": "1.64.1",
364
375
  "react": "19.3.1",
365
376
  "react-dom": "19.3.1",
366
377
  "react-18": "npm:react@18.3.1",
367
378
  "react-dom-18": "npm:react-dom@18.3.1",
368
- "@openpresentation/opf-pptx": "0.27.0",
369
- "@openpresentation/gallery": "0.27.0",
370
- "@openpresentation/opf-render": "0.27.0"
379
+ "@openpresentation/gallery": "0.29.0",
380
+ "@openpresentation/opf-pptx": "0.29.0",
381
+ "@openpresentation/opf-render": "0.29.0"
371
382
  },
372
383
  "scripts": {
373
384
  "build": "node scripts/build.mjs",
@@ -420,5 +431,5 @@
420
431
  "test:workspace-browser": "node test/workspace-browser.mjs",
421
432
  "test:workspace-react-browser": "node test/workspace-react-browser.mjs"
422
433
  },
423
- "gitHead": "f90536d0e9a7a16b25aa718e03ced4e00a7316e1"
434
+ "gitHead": "464e10d3c15647b0e43326ac7e6761e34789991a"
424
435
  }