@openpresentation/opf-editor 0.28.0 → 0.29.1

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 (53) hide show
  1. package/README.md +20 -6
  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 +134 -124
  10. package/dist/exact-source.d.ts +30 -0
  11. package/dist/font-gate.js +20 -1
  12. package/dist/json-editor.js +8 -2
  13. package/dist/json-options.d.ts +2 -1
  14. package/dist/json-options.js +7 -6
  15. package/dist/layout-templates.d.ts +8 -3
  16. package/dist/layout-templates.js +30 -16
  17. package/dist/numbering-panel.js +2 -3
  18. package/dist/react.d.ts +12 -4
  19. package/dist/react.js +39 -5
  20. package/dist/slide-adapter.d.ts +49 -0
  21. package/dist/slide-adapter.js +289 -0
  22. package/dist/slide-edits.d.ts +46 -0
  23. package/dist/slide-edits.js +629 -0
  24. package/dist/slide-manager.js +2 -1
  25. package/dist/slide-scene.d.ts +96 -0
  26. package/dist/slide-scene.js +413 -0
  27. package/dist/source-checker.js +45 -6
  28. package/dist/source-completions.js +6 -4
  29. package/dist/source-pane.d.ts +18 -6
  30. package/dist/source-pane.js +45 -0
  31. package/dist/source-worker.js +8 -4
  32. package/dist/studio-canvas.d.ts +34 -0
  33. package/dist/studio-canvas.js +586 -0
  34. package/dist/studio-commands.d.ts +15 -0
  35. package/dist/studio-commands.js +155 -0
  36. package/dist/studio-design.d.ts +16 -0
  37. package/dist/studio-design.js +354 -0
  38. package/dist/studio-kit/dom.d.ts +16 -0
  39. package/dist/studio-kit/dom.js +42 -6
  40. package/dist/studio-layers.d.ts +12 -0
  41. package/dist/studio-layers.js +218 -0
  42. package/dist/studio.d.ts +31 -0
  43. package/dist/studio.js +1608 -0
  44. package/dist/styles.css +1546 -117
  45. package/dist/thumbnail-cache.d.ts +2 -0
  46. package/dist/thumbnail-cache.js +10 -1
  47. package/dist/workspace-measure.js +6 -4
  48. package/dist/workspace-parts.js +20 -0
  49. package/dist/workspace-shell.js +25 -15
  50. package/dist/workspace-theme.js +3 -0
  51. package/dist/workspace.d.ts +97 -9
  52. package/dist/workspace.js +325 -59
  53. package/package.json +29 -18
package/dist/workspace.js CHANGED
@@ -7,29 +7,13 @@
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";
@@ -39,6 +23,34 @@ export { THEME_TOKENS, createThemeController, normalizeTheme, normalizeThemeToke
39
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).
40
24
  export { layoutOptionsFor } from "./workspace-measure.js";
41
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])));
53
+
42
54
  const VIEWS = Object.freeze(["slide", "sorter", "outline", "layouts"]);
43
55
  const MUTATORS = new Set(["set", "setGroupComposition", "setCatalog", "setCatalogs", "applyPatch", "replaceDocument", "restoreState", "undo", "redo", "setComposition", "paginateSlide"]);
44
56
  const STARTER = Object.freeze({ name: "Untitled presentation", slides: [{ id: "slide-1", title: "Untitled slide" }] });
@@ -57,7 +69,11 @@ const LAYOUT_PARTS = {
57
69
  full: { source: true, navigator: true, thumbnails: true, history: true, find: true, present: true, export: true, review: true, layouts: true, sorter: true, outline: true },
58
70
  canvas: { navigator: true, thumbnails: true, history: true },
59
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 },
60
75
  };
76
+ const STUDIO_LAYOUTS = new Set(["studio", "playground"]);
61
77
 
62
78
  const isThenable = (value) => value !== null && typeof value === "object" && typeof value.then === "function";
63
79
 
@@ -110,7 +126,9 @@ function resolveParts(options, layout, slots) {
110
126
  }
111
127
 
112
128
  /**
113
- * 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.
114
132
  * See `workspace.d.ts` for the options and the handle.
115
133
  */
116
134
  export function createOpfEditor(element, options = {}) {
@@ -165,8 +183,9 @@ export function createOpfEditor(element, options = {}) {
165
183
 
166
184
  // ---- layout, parts and slots ------------------------------------------------------------------------------------------------
167
185
  const given = options.slots ?? {};
168
- const layout = options.slots ? "slots" : (options.layout ?? "full");
169
- 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);
170
189
  const parts = resolveParts(options, layout, given);
171
190
  const views = VIEWS.filter((view) => view === "slide" || parts[view]);
172
191
  if (options.view !== undefined && !views.includes(options.view)) throw new RangeError(`view "${options.view}" is not available in this layout (${views.join(", ")}).`);
@@ -183,13 +202,27 @@ export function createOpfEditor(element, options = {}) {
183
202
  const themeController = createThemeController(element, options.theme);
184
203
  let slots = { ...given };
185
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
+ }
186
217
  if (layout === "slots") root.setAttribute("data-opf-layout", "slots");
187
- else {
218
+ else if (!studioLayout) {
188
219
  const built = buildWorkspaceShell(root, { parts, views, layout, labels: options.labels });
189
220
  slots = { ...built.slots, ...given };
190
221
  controls = built.controls;
191
222
  }
192
- 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);
193
226
 
194
227
  // ---- state ------------------------------------------------------------------------------------------------------------------
195
228
  let fonts;
@@ -201,6 +234,8 @@ export function createOpfEditor(element, options = {}) {
201
234
  let sorter;
202
235
  let outline;
203
236
  let studio;
237
+ let studioCtl;
238
+ let fontsPending = false;
204
239
  let presenter;
205
240
  let presentButton;
206
241
  let exportMenu;
@@ -231,10 +266,35 @@ export function createOpfEditor(element, options = {}) {
231
266
  let statusText = "";
232
267
  let density = "comfortable";
233
268
  let exportMode = "menu";
269
+ // The parts are bound once the slide chunk has arrived and the fonts are at hand; `partsReady` is true from then on. A call on the handle made
270
+ // before that (a React host's effects run right after `createOpfEditor` returns) must neither reach a factory that is not bound yet nor be lost:
271
+ // a setting is remembered in `requested` and read when the parts mount; a call that acts on a mounted part waits in `queued` and runs, in order,
272
+ // once the editor is mounted (a later call with the same key replaces the earlier one).
273
+ let partsReady = false;
274
+ const requested = {};
275
+ const queued = [];
276
+ const waiting = () => !mounted && !destroyed;
277
+ const queue = (key, task) => {
278
+ const at = queued.findIndex((entry) => entry.key === key);
279
+ if (at >= 0) queued.splice(at, 1);
280
+ const entry = { key, task };
281
+ queued.push(entry);
282
+ return entry;
283
+ };
284
+ function flushQueued() {
285
+ for (const { task } of queued.splice(0)) {
286
+ if (destroyed) return;
287
+ try {
288
+ task();
289
+ } catch (error) {
290
+ report(error);
291
+ }
292
+ }
293
+ }
234
294
  let frameSize = { width: 0, height: 0 };
235
295
  const draftMemory = createSourceMemory();
236
296
  const variablesMode = options.variables === "resolved" ? "resolved" : "authored";
237
- const fitMode = options.fit === undefined ? (layout === "slots" ? false : "contain") : options.fit;
297
+ const fitMode = studioLayout ? false : options.fit === undefined ? (layout === "slots" ? false : "contain") : options.fit;
238
298
 
239
299
  const say = (message) => {
240
300
  statusText = message;
@@ -255,7 +315,8 @@ export function createOpfEditor(element, options = {}) {
255
315
 
256
316
  // ---- navigation and the refresh loop ----------------------------------------------------------------------------------------
257
317
  function renderViews(deck) {
258
- thumbnailCache?.prepare(deck);
318
+ // The Studio's filmstrip draws the thumbnails it shows itself, as they scroll in.
319
+ if (!studioLayout) thumbnailCache?.prepare(deck);
259
320
  slideManager?.render();
260
321
  if (activeView === "sorter") sorter?.render();
261
322
  if (activeView === "outline") outline?.render();
@@ -283,7 +344,8 @@ export function createOpfEditor(element, options = {}) {
283
344
  }
284
345
  function sync() {
285
346
  fonts?.registry?.clearSubstitutions?.();
286
- const deck = session.presentation;
347
+ // The frozen snapshot (one object per version): the session's `presentation` copies the whole deck on every read.
348
+ const deck = session.snapshot().presentation;
287
349
  slideIndex = Math.max(0, Math.min(slideIndex, deck.slides.length - 1));
288
350
  renderViews(deck);
289
351
  slideNav?.setSlide(slideIndex);
@@ -300,16 +362,18 @@ export function createOpfEditor(element, options = {}) {
300
362
  if (!mounted || destroyed) return Promise.resolve();
301
363
  const token = ++refreshToken;
302
364
  let loaded = false;
303
- lastRefresh = whenFontsReady(fonts, session.presentation, {
365
+ lastRefresh = whenFontsReady(fonts, session.snapshot().presentation, {
304
366
  renderOptions: renderOptions(),
305
367
  isCurrent: () => token === refreshToken && !destroyed,
306
368
  loading: () => {
307
369
  loaded = true;
370
+ fontsPending = true;
308
371
  emit("fonts", { state: "loading" });
309
372
  say(MESSAGES.loadingFonts);
310
373
  },
311
374
  ready: () => {
312
375
  fontsFailure = undefined;
376
+ fontsPending = false;
313
377
  if (loaded) {
314
378
  thumbnailCache?.clear();
315
379
  emit("fonts", { state: "ready" });
@@ -353,8 +417,14 @@ export function createOpfEditor(element, options = {}) {
353
417
  return false;
354
418
  }
355
419
  function goTo(index, path) {
420
+ if (waiting()) {
421
+ go(index);
422
+ queue("goTo", () => goTo(index, path));
423
+ return true;
424
+ }
356
425
  if (index !== slideIndex && !go(index)) return false;
357
- if (path) canvas?.reveal(path);
426
+ if (studioCtl) studioCtl.revealPath(path ?? `slides.${index}`);
427
+ else if (path) canvas?.reveal(path);
358
428
  return true;
359
429
  }
360
430
 
@@ -367,7 +437,8 @@ export function createOpfEditor(element, options = {}) {
367
437
  if (controls.redo) controls.redo.disabled = !inSync || readOnly || !session.canRedo;
368
438
  // 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.
369
439
  const frame = slots.canvas && (slots.slide ?? slots.canvas);
370
- if (frame && (!inSync || readOnly)) frame.setAttribute("inert", "");
440
+ // The Studio keeps selection, zoom and Present while read-only (its commands say why they are off); only invalid text locks the slide.
441
+ if (frame && (!inSync || (readOnly && !studioLayout))) frame.setAttribute("inert", "");
371
442
  else frame?.removeAttribute("inert");
372
443
  }
373
444
  function applyFit() {
@@ -393,7 +464,8 @@ export function createOpfEditor(element, options = {}) {
393
464
  setSlideIndex: (index) => {
394
465
  go(index);
395
466
  },
396
- renderThumbnail: (deck, index) => thumbnailCache.html(deck, index),
467
+ // The Studio's filmstrip draws only the thumbnails it shows (an IntersectionObserver fills the rest as they scroll in).
468
+ renderThumbnail: studioLayout ? (deck, index) => thumbnailCache.peek(deck, index) ?? "" : (deck, index) => thumbnailCache.html(deck, index),
397
469
  renderLayoutPreview: (deck, index, choice) => {
398
470
  const candidate = prepareDimensionSwitch(deck, "layouts", choice.id, { slideIndex: index, catalogs: catalogs() }).presentation;
399
471
  return toSvg(candidate, index + 1, { ...renderOptions(), fonts, text: "system", trace: false });
@@ -412,6 +484,7 @@ export function createOpfEditor(element, options = {}) {
412
484
  if (!views.includes(view)) return false;
413
485
  if (!mounted) {
414
486
  activeView = view;
487
+ requested.view = view;
415
488
  return true;
416
489
  }
417
490
  if (view !== "slide" && canvas && !canvas.commit()) return false;
@@ -454,6 +527,16 @@ export function createOpfEditor(element, options = {}) {
454
527
  return true;
455
528
  }
456
529
  function showPanel(name, focus = false) {
530
+ if (!mounted) {
531
+ // The tabs of the shell are known now, the Studio's when it mounts; the panel is shown after the default one.
532
+ if (destroyed || (!studioLayout && !controls.tabs?.[name])) return false;
533
+ queue("panel", () => showPanel(name, focus));
534
+ return true;
535
+ }
536
+ return showPanelNow(name, focus);
537
+ }
538
+ function showPanelNow(name, focus = false) {
539
+ if (studioCtl) return studioCtl.showTab(name, { focus });
457
540
  const tabs = controls.tabs;
458
541
  if (!tabs?.[name]) return false;
459
542
  for (const [tabName, tab] of Object.entries(tabs)) {
@@ -470,11 +553,16 @@ export function createOpfEditor(element, options = {}) {
470
553
 
471
554
  // ---- the parts --------------------------------------------------------------------------------------------------------------
472
555
  function mountSlideRow() {
556
+ // The row is built from the slide chunk's factories: before they are bound (and after destroy) there is nothing to rebuild; the first
557
+ // mount reads the density and the layout as they are by then.
558
+ if (!partsReady || destroyed) return;
473
559
  unbindKeys?.();
474
560
  slideNav?.destroy();
475
561
  strip?.destroy();
476
562
  slideNav = undefined;
477
563
  strip = undefined;
564
+ // The Studio shows its filmstrip; the slide row is the Playground preset's, mounted only while that preset is on.
565
+ if (studioLayout && (studioCtl?.layout ?? layout) !== "playground") return;
478
566
  if (parts.navigator && slots.navigator) {
479
567
  slideNav = createSlideNavigator(slots.navigator, {
480
568
  editor: ui,
@@ -545,7 +633,7 @@ export function createOpfEditor(element, options = {}) {
545
633
  ...(configured.cursorDelay !== undefined ? { cursorDelay: configured.cursorDelay } : {}),
546
634
  ...(configured.completions !== undefined ? { completions: configured.completions } : {}),
547
635
  ...(configured.catalogs !== undefined ? { catalogs: configured.catalogs } : {}),
548
- ...(configured.name !== undefined ? { name: configured.name } : {}),
636
+ ...((requested.name ?? configured.name) !== undefined ? { name: requested.name ?? configured.name } : {}),
549
637
  ...(text !== undefined ? { source: text } : {}),
550
638
  editor: ui,
551
639
  format: configured.format ?? "json",
@@ -625,6 +713,7 @@ export function createOpfEditor(element, options = {}) {
625
713
  );
626
714
  canvas = createCanvasEditor(slots.canvas, {
627
715
  ...configured,
716
+ ...(studioLayout ? { textEntry: layout === "playground" ? "click" : "dblclick", targets: layout === "playground" ? "interactive" : "passive", escape: "commit" } : {}),
628
717
  editor: ui,
629
718
  fonts,
630
719
  renderOptions: renderOptions(),
@@ -635,17 +724,22 @@ export function createOpfEditor(element, options = {}) {
635
724
  if (event.state === "error") {
636
725
  fontsFailure = event.error;
637
726
  say(event.error.message);
638
- } else if (event.state === "loading") say(MESSAGES.loadingFonts);
727
+ } else if (event.state === "loading") {
728
+ fontsPending = true;
729
+ say(MESSAGES.loadingFonts);
730
+ }
639
731
  else if (fontsFailure) {
640
732
  fontsFailure = undefined;
641
733
  refresh();
642
734
  }
735
+ if (event.state === "ready") fontsPending = false;
643
736
  emit("fonts", event);
644
737
  configured.onFonts?.(event);
645
738
  },
646
739
  onSelect: (value) => {
647
740
  selection = { path: value.path, value: value.value };
648
- emit("selection", { path: value.path, value: value.value, slideIndex, element: value.element });
741
+ // The Studio emits its own selection (a list of nodes); the canvas's single selection is internal there.
742
+ if (!studioLayout) emit("selection", { path: value.path, value: value.value, slideIndex, element: value.element });
649
743
  design?.refresh();
650
744
  configured.onSelect?.(value);
651
745
  },
@@ -783,7 +877,7 @@ export function createOpfEditor(element, options = {}) {
783
877
  onFocusField: ({ fix, target }) => emit("review-focus", { fix, target }),
784
878
  onStatus: say,
785
879
  onChange: (counts) => {
786
- const tab = controls.tabs?.review;
880
+ const tab = controls.tabs?.review ?? studioShell?.tabs.review?.tab;
787
881
  if (tab) {
788
882
  const name = options.labels?.review ?? "Review";
789
883
  const found = !counts.unavailable && counts.total;
@@ -795,13 +889,17 @@ export function createOpfEditor(element, options = {}) {
795
889
  });
796
890
  }
797
891
 
798
- function present() {
892
+ function present(from) {
893
+ if (waiting() && parts.present) {
894
+ queue("present", () => present(from));
895
+ return true;
896
+ }
799
897
  if (destroyed || !mounted || !presenter || presenter.isRunning() || !inSync || slides.length === 0) return false;
800
898
  // 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.
801
899
  if (canvas?.commit() === false) return false;
802
900
  pane?.getText();
803
901
  if (draftInvalid) return false;
804
- void presenter.start(canvas?.slideIndex ?? slideIndex);
902
+ void presenter.start(Number.isInteger(from) ? from : (canvas?.slideIndex ?? slideIndex));
805
903
  return true;
806
904
  }
807
905
 
@@ -809,7 +907,7 @@ export function createOpfEditor(element, options = {}) {
809
907
  slides = session.presentation.slides ?? [];
810
908
  authoredTemplate = session.presentation.template === true;
811
909
  if (parts.canvas && slots.canvas) mountCanvas();
812
- thumbnailCache = createThumbnailCache({ toSvg, getOptions: () => ({ ...renderOptions(), fonts, text: "system", trace: false }) });
910
+ if (slideParts) thumbnailCache = createThumbnailCache({ toSvg, getOptions: () => ({ ...renderOptions(), fonts, text: "system", trace: false }) });
813
911
  mountSlideRow();
814
912
  if (parts.slides && slots.slides) slideManager = createSlideManager(slots.slides, { ...slideManagerOptions(), ...(slots.slideToolbar ? { toolbar: slots.slideToolbar } : {}) });
815
913
  if (parts.source && slots.source) {
@@ -867,6 +965,7 @@ export function createOpfEditor(element, options = {}) {
867
965
  if (parts.persistence) mountPersistence();
868
966
  mountControls();
869
967
  mountEnvironment();
968
+ if (studioShell && canvas) mountStudio();
870
969
  // These come last, so a host hears a change after every part has. The refresh runs for every event, the catalogs' included.
871
970
  cleanups.push(
872
971
  session.subscribe((event) => {
@@ -890,23 +989,83 @@ export function createOpfEditor(element, options = {}) {
890
989
  );
891
990
  }
892
991
 
992
+ // CE-20: the Studio over the parts mounted above (src/studio.js).
993
+ function mountStudio() {
994
+ studioCtl = createStudio({
995
+ root,
996
+ element,
997
+ shell: studioShell,
998
+ slots,
999
+ session,
1000
+ ui,
1001
+ canvas,
1002
+ options,
1003
+ layout,
1004
+ hostCatalogs,
1005
+ get pane() {
1006
+ return pane;
1007
+ },
1008
+ get find() {
1009
+ return find;
1010
+ },
1011
+ get exportMenu() {
1012
+ return exportMenu;
1013
+ },
1014
+ get slideManager() {
1015
+ return slideManager;
1016
+ },
1017
+ get thumbnailCache() {
1018
+ return thumbnailCache;
1019
+ },
1020
+ get persistence() {
1021
+ return persistence;
1022
+ },
1023
+ get handle() {
1024
+ return handle;
1025
+ },
1026
+ slideIndex: () => slideIndex,
1027
+ go: (index) => go(index),
1028
+ present: (from) => present(from),
1029
+ say,
1030
+ emit,
1031
+ report,
1032
+ on,
1033
+ setView: (view) => setView(view),
1034
+ readOnly: () => readOnly,
1035
+ inSync: () => inSync,
1036
+ fontsLoading: () => fontsPending,
1037
+ catalogs,
1038
+ layoutFor: (deck, index) => layoutFor(deck, index),
1039
+ setDocument: (document, openOptions) => setDocument(document, openOptions),
1040
+ openFind: (openOptions) => {
1041
+ if (!find) return false;
1042
+ find.openPanel(openOptions ?? {});
1043
+ return true;
1044
+ },
1045
+ getName: () => pane?.getName(),
1046
+ setName: (name) => pane?.setName(name),
1047
+ layoutStudio: () => studio,
1048
+ mountSlideRow: () => mountSlideRow(),
1049
+ });
1050
+ }
1051
+
893
1052
  // The shell's own controls: history, views, tabs.
894
1053
  function mountControls() {
895
1054
  controls.undo?.addEventListener("click", () => undo());
896
1055
  controls.redo?.addEventListener("click", () => redo());
897
1056
  for (const [name, button] of Object.entries(controls.views ?? {})) button.addEventListener("click", () => setView(name, { focus: true }));
898
1057
  for (const [name, tab] of Object.entries(controls.tabs ?? {})) {
899
- tab.addEventListener("click", () => showPanel(name));
1058
+ tab.addEventListener("click", () => showPanelNow(name));
900
1059
  tab.addEventListener("keydown", (event) => {
901
1060
  if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
902
1061
  event.preventDefault();
903
1062
  const names = Object.keys(controls.tabs);
904
1063
  const at = names.indexOf(name);
905
1064
  const next = event.key === "Home" ? 0 : event.key === "End" ? names.length - 1 : (at + (event.key === "ArrowRight" ? 1 : -1) + names.length) % names.length;
906
- showPanel(names[next], true);
1065
+ showPanelNow(names[next], true);
907
1066
  });
908
1067
  }
909
- if (controls.tabs) showPanel(options.panel && controls.tabs[options.panel] ? options.panel : Object.keys(controls.tabs)[0]);
1068
+ if (controls.tabs) showPanelNow(options.panel && controls.tabs[options.panel] ? options.panel : Object.keys(controls.tabs)[0]);
910
1069
  if (options.shortcuts !== false) {
911
1070
  // Ctrl/Cmd+Z and +Shift+Z undo and redo when nothing that edits text has focus (the code editor and the canvas's inputs keep their own).
912
1071
  const onKey = (event) => {
@@ -943,8 +1102,8 @@ export function createOpfEditor(element, options = {}) {
943
1102
  media.addEventListener("change", change);
944
1103
  cleanups.push(() => media.removeEventListener("change", change));
945
1104
  };
946
- follow(options.density, "(pointer: coarse)", setDensity);
947
- follow(options.exportMode, "(max-width: 900px)", setExportMode);
1105
+ follow("density" in requested ? requested.density : options.density, "(pointer: coarse)", setDensity);
1106
+ follow("exportMode" in requested ? requested.exportMode : options.exportMode, "(max-width: 900px)", setExportMode);
948
1107
  }
949
1108
 
950
1109
  // ---- the handle -------------------------------------------------------------------------------------------------------------
@@ -977,8 +1136,17 @@ export function createOpfEditor(element, options = {}) {
977
1136
  studio?.setReadOnly(readOnly);
978
1137
  root.toggleAttribute("data-readonly", readOnly);
979
1138
  syncChrome();
1139
+ studioCtl?.refresh();
980
1140
  }
981
1141
  function setDensity(setting) {
1142
+ if (destroyed) return;
1143
+ // Before the parts mount the setting is kept; the mount reads it instead of the `density` option.
1144
+ if (!partsReady) {
1145
+ requested.density = setting;
1146
+ density = densityFor(setting);
1147
+ root.setAttribute("data-density", density);
1148
+ return;
1149
+ }
982
1150
  const next = densityFor(setting);
983
1151
  if (next === density) return;
984
1152
  density = next;
@@ -990,6 +1158,8 @@ export function createOpfEditor(element, options = {}) {
990
1158
  mountSlideRow();
991
1159
  }
992
1160
  function setExportMode(setting) {
1161
+ if (destroyed) return;
1162
+ if (!partsReady) requested.exportMode = setting;
993
1163
  exportMode = exportModeFor(setting);
994
1164
  exportMenu?.setMode(exportMode);
995
1165
  }
@@ -1014,17 +1184,50 @@ export function createOpfEditor(element, options = {}) {
1014
1184
  } catch (error) {
1015
1185
  if (error?.code !== "invalid-opf-edit") throw error;
1016
1186
  // A document the session refuses is no document to open: show its text with its findings, as a draft the person can fix.
1017
- if (pane && text !== undefined) {
1018
- if (openOptions.format && pane.getFormat() !== openOptions.format) pane.setFormat(openOptions.format);
1019
- const api = pane.editor.api;
1020
- api.replace(0, api.getValue().length, text);
1021
- pane.getText();
1022
- if (openOptions.name !== undefined) pane.setName(openOptions.name);
1023
- }
1187
+ if (pane && text !== undefined) pane.setSourceText(text, { ...(openOptions.format ? { format: openOptions.format } : {}), ...(openOptions.name !== undefined ? { name: openOptions.name } : {}) });
1024
1188
  return false;
1025
1189
  }
1026
1190
  }
1191
+ // 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
1192
+ // pasting it over everything. True when the session now holds its document; false when it stays a draft with its problems.
1193
+ function setSourceText(text, openOptions = {}) {
1194
+ if (destroyed || !mounted) throw new OPFEditorError("not-ready", "The editor is not mounted yet. Await `ready` first.");
1195
+ if (!pane) throw new OPFEditorError("no-source-pane", "This editor has no source pane.");
1196
+ if (readOnly) throw new OPFEditorError("read-only", "This editor is read-only.");
1197
+ if (typeof text !== "string") throw new TypeError("setSourceText needs the source text as a string.");
1198
+ // An open canvas edit is committed first; the text then replaces it.
1199
+ if (canvas?.commit() === false) return false;
1200
+ drafting = false;
1201
+ openedText = undefined;
1202
+ const applied = pane.setSourceText(text, {
1203
+ ...(openOptions.format ? { format: openOptions.format } : {}),
1204
+ ...(openOptions.name !== undefined ? { name: openOptions.name } : {}),
1205
+ });
1206
+ // Text the host loaded is not the person's work: it is not autosaved or warned about until it is edited.
1207
+ if (applied && openOptions.rebase !== false) persistence?.rebase();
1208
+ return applied;
1209
+ }
1027
1210
 
1211
+ // The Studio's command registry is not there before mount: `register` keeps the command and adds it when the Studio mounts (the function it
1212
+ // returns removes it either way); `list` is empty and `run` finds nothing yet.
1213
+ const preMountCommands = {
1214
+ list: () => [],
1215
+ run: () => false,
1216
+ register(command) {
1217
+ if (!waiting() || !studioLayout) return () => {};
1218
+ let stop;
1219
+ let withdrawn = false;
1220
+ const entry = queue(Symbol("command"), () => {
1221
+ if (!withdrawn) stop = studioCtl?.commands.register(command);
1222
+ });
1223
+ return () => {
1224
+ withdrawn = true;
1225
+ const at = queued.indexOf(entry);
1226
+ if (at >= 0) queued.splice(at, 1);
1227
+ stop?.();
1228
+ };
1229
+ },
1230
+ };
1028
1231
  let fontsRef = { value: undefined };
1029
1232
  let releaseFonts;
1030
1233
  let readyPromise;
@@ -1084,8 +1287,43 @@ export function createOpfEditor(element, options = {}) {
1084
1287
  get slideIndex() {
1085
1288
  return slideIndex;
1086
1289
  },
1290
+ /** The selection: the canvas's `{ path, value }` (or null); in the Studio, the list `[{ key, path, idPath, kind }]`. */
1087
1291
  get selection() {
1088
- return selection;
1292
+ return studioCtl ? studioCtl.selection : selection;
1293
+ },
1294
+ /** CE-20: the arrangement ("full", "canvas", "source", "slots", or the Studio's "studio" and "playground"). */
1295
+ get layout() {
1296
+ return studioCtl?.layout ?? layout;
1297
+ },
1298
+ /** CE-20: switch the Studio between "studio" and "playground" in place (same session, history, slide and selection). */
1299
+ setLayout(next) {
1300
+ if (studioCtl) return studioCtl.setLayout(next);
1301
+ // Before mount the switch is queued (true), as the Studio's own check of the name would refuse it later.
1302
+ if (!waiting() || !studioLayout) return false;
1303
+ if (next !== "studio" && next !== "playground") throw new RangeError('setLayout takes "studio" or "playground".');
1304
+ queue("layout", () => studioCtl?.setLayout(next));
1305
+ return true;
1306
+ },
1307
+ get zoom() {
1308
+ return studioCtl?.zoom ?? 1;
1309
+ },
1310
+ setZoom(value) {
1311
+ if (studioCtl) studioCtl.setZoom(value);
1312
+ else if (waiting() && studioLayout) queue("zoom", () => studioCtl?.setZoom(value));
1313
+ },
1314
+ fit(mode) {
1315
+ if (studioCtl) studioCtl.fit(mode);
1316
+ else if (waiting() && studioLayout) queue("zoom", () => studioCtl?.fit(mode));
1317
+ },
1318
+ openLayout(layoutId, openOptions = {}) {
1319
+ if (studioCtl) return studioCtl.openLayout(layoutId, openOptions);
1320
+ if (!waiting() || !studioLayout) return false;
1321
+ queue("openLayout", () => studioCtl?.openLayout(layoutId, openOptions));
1322
+ return true;
1323
+ },
1324
+ /** CE-20: the Studio's command registry (`list`, `run`, `register`); empty outside the Studio. Commands registered before mount are added when it mounts. */
1325
+ get commands() {
1326
+ return studioCtl?.commands ?? preMountCommands;
1089
1327
  },
1090
1328
  get view() {
1091
1329
  return activeView;
@@ -1116,11 +1354,17 @@ export function createOpfEditor(element, options = {}) {
1116
1354
  return session.snapshot().presentation;
1117
1355
  },
1118
1356
  setDocument,
1357
+ setSourceText,
1119
1358
  setView,
1120
1359
  setSlide: go,
1121
1360
  goTo,
1122
- select(path, { focus = false } = {}) {
1123
- return canvas?.reveal(path, { focus }) ?? null;
1361
+ select(path, selectOptions = {}) {
1362
+ if (waiting()) {
1363
+ queue("select", () => handle.select(path, selectOptions));
1364
+ return studioLayout ? true : null;
1365
+ }
1366
+ if (studioCtl) return studioCtl.selectTargets(path, selectOptions);
1367
+ return canvas?.reveal(path, { focus: Boolean(selectOptions.focus) }) ?? null;
1124
1368
  },
1125
1369
  showPanel,
1126
1370
  present,
@@ -1136,6 +1380,10 @@ export function createOpfEditor(element, options = {}) {
1136
1380
  return canvas?.commit() !== false;
1137
1381
  },
1138
1382
  focus(target) {
1383
+ if (waiting()) {
1384
+ queue("focus", () => handle.focus(target));
1385
+ return true;
1386
+ }
1139
1387
  if ((target ?? (pane ? "source" : "canvas")) === "source") {
1140
1388
  pane?.editor?.api?.focus?.();
1141
1389
  return Boolean(pane);
@@ -1145,6 +1393,10 @@ export function createOpfEditor(element, options = {}) {
1145
1393
  return Boolean(node);
1146
1394
  },
1147
1395
  openFind(openOptions) {
1396
+ if (waiting() && parts.find) {
1397
+ queue("find", () => handle.openFind(openOptions));
1398
+ return true;
1399
+ }
1148
1400
  if (!find) return false;
1149
1401
  find.openPanel(openOptions ?? {});
1150
1402
  return true;
@@ -1157,10 +1409,12 @@ export function createOpfEditor(element, options = {}) {
1157
1409
  return { text, format: pane.getFormat(), draft: draftInvalid };
1158
1410
  },
1159
1411
  setName(name) {
1412
+ if (waiting()) requested.name = name;
1160
1413
  pane?.setName(name);
1161
1414
  },
1162
1415
  getName() {
1163
- return pane?.getName();
1416
+ if (pane) return pane.getName();
1417
+ return waiting() ? (requested.name ?? options.source?.name) : undefined;
1164
1418
  },
1165
1419
  setReadOnly(value) {
1166
1420
  readOnly = Boolean(value);
@@ -1187,6 +1441,7 @@ export function createOpfEditor(element, options = {}) {
1187
1441
  /* already gone */
1188
1442
  }
1189
1443
  }
1444
+ studioCtl?.destroy();
1190
1445
  unbindKeys?.();
1191
1446
  checking?.disconnect();
1192
1447
  persistence?.destroy();
@@ -1214,17 +1469,23 @@ export function createOpfEditor(element, options = {}) {
1214
1469
 
1215
1470
  // ---- start ------------------------------------------------------------------------------------------------------------------
1216
1471
  function mount(resolved) {
1472
+ if (studioLayout && !studioShell) buildStudio();
1217
1473
  fonts = resolved;
1218
- density = densityFor(options.density);
1219
- exportMode = exportModeFor(options.exportMode);
1474
+ // A density, an export mode or a view the host chose before the parts mounted wins over the option.
1475
+ density = densityFor("density" in requested ? requested.density : options.density);
1476
+ exportMode = exportModeFor("exportMode" in requested ? requested.exportMode : options.exportMode);
1220
1477
  root.setAttribute("data-density", density);
1478
+ partsReady = true;
1221
1479
  mountParts();
1222
1480
  mounted = true;
1223
1481
  if (readOnly) applyReadOnly();
1224
1482
  say(MESSAGES.ready);
1225
- if (options.view && options.view !== "slide") setView(options.view);
1483
+ const wantedView = "view" in requested ? requested.view : options.view;
1484
+ activeView = "slide";
1485
+ if (wantedView && wantedView !== "slide") setView(wantedView);
1226
1486
  else markView("slide");
1227
1487
  syncChrome();
1488
+ flushQueued();
1228
1489
  }
1229
1490
  readyPromise = new Promise((resolve, reject) => {
1230
1491
  const start = (arrived) => {
@@ -1254,15 +1515,20 @@ export function createOpfEditor(element, options = {}) {
1254
1515
  };
1255
1516
  try {
1256
1517
  fontsRef = fontsSource(options.fonts);
1257
- if (isThenable(fontsRef.value)) {
1258
- say(MESSAGES.loadingFonts);
1259
- Promise.resolve(fontsRef.value).then((resolved) => {
1518
+ const chunk = slideParts && !partsLoaded ? loadParts() : null;
1519
+ if (isThenable(fontsRef.value) || chunk) {
1520
+ if (isThenable(fontsRef.value)) say(MESSAGES.loadingFonts);
1521
+ Promise.all([fontsRef.value, chunk]).then(([resolved]) => {
1260
1522
  try {
1261
1523
  start(resolved);
1262
1524
  } catch (error) {
1263
1525
  fail(error);
1264
1526
  }
1265
- }, fail);
1527
+ }, (error) => {
1528
+ // The slide chunk did not load: fonts that did arrive are given back.
1529
+ Promise.resolve(fontsRef.value).then((resolved) => fontsRef.use(resolved).release?.(), () => {});
1530
+ fail(error);
1531
+ });
1266
1532
  } else start(fontsRef.value);
1267
1533
  } catch (error) {
1268
1534
  fail(error);