@openpresentation/opf-editor 0.26.10 → 0.28.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 (52) hide show
  1. package/README.md +60 -6
  2. package/dist/canvas-variables.d.ts +15 -0
  3. package/dist/canvas-variables.js +52 -0
  4. package/dist/canvas.js +33 -5
  5. package/dist/design-controls.js +3 -2
  6. package/dist/design-options.js +1 -1
  7. package/dist/index.d.ts +37 -1
  8. package/dist/index.js +50 -3
  9. package/dist/json-editor.d.ts +33 -0
  10. package/dist/json-editor.js +83 -2
  11. package/dist/layout-adapter.js +96 -0
  12. package/dist/layout-canvas.js +122 -232
  13. package/dist/layout-editor-ui.js +46 -185
  14. package/dist/layout-inspector.js +36 -99
  15. package/dist/layout-layers.js +75 -92
  16. package/dist/layout-model.js +62 -103
  17. package/dist/react.d.ts +38 -0
  18. package/dist/react.js +127 -0
  19. package/dist/slide-retention.js +13 -0
  20. package/dist/source-color-scheme.js +60 -0
  21. package/dist/source-pane.d.ts +85 -0
  22. package/dist/source-pane.js +151 -9
  23. package/dist/studio-kit/commands.d.ts +38 -0
  24. package/dist/studio-kit/commands.js +148 -0
  25. package/dist/studio-kit/dom.d.ts +36 -0
  26. package/dist/studio-kit/dom.js +162 -0
  27. package/dist/studio-kit/drag.d.ts +26 -0
  28. package/dist/studio-kit/drag.js +127 -0
  29. package/dist/studio-kit/gesture.d.ts +29 -0
  30. package/dist/studio-kit/gesture.js +87 -0
  31. package/dist/studio-kit/index.d.ts +29 -0
  32. package/dist/studio-kit/index.js +12 -0
  33. package/dist/studio-kit/inspector.d.ts +22 -0
  34. package/dist/studio-kit/inspector.js +119 -0
  35. package/dist/studio-kit/overlay.d.ts +29 -0
  36. package/dist/studio-kit/overlay.js +182 -0
  37. package/dist/studio-kit/selection.d.ts +30 -0
  38. package/dist/studio-kit/selection.js +106 -0
  39. package/dist/studio-kit/tree.d.ts +9 -0
  40. package/dist/studio-kit/tree.js +82 -0
  41. package/dist/studio-kit/viewport.d.ts +29 -0
  42. package/dist/studio-kit/viewport.js +153 -0
  43. package/dist/styles.css +325 -7
  44. package/dist/workspace-layouts.d.ts +31 -0
  45. package/dist/workspace-layouts.js +84 -0
  46. package/dist/workspace-measure.js +35 -0
  47. package/dist/workspace-shell.js +96 -0
  48. package/dist/workspace-source.js +83 -0
  49. package/dist/workspace-theme.js +142 -0
  50. package/dist/workspace.d.ts +413 -0
  51. package/dist/workspace.js +1274 -0
  52. package/package.json +29 -11
@@ -0,0 +1,153 @@
1
+ // CE-18, the studio kit: the viewport around a studio's frame. Zoom in steps (buttons, keys, Ctrl or Cmd with the wheel), smooth zoom (a
2
+ // trackpad pinch, two-finger touch), fit, and pan (the hand tool, a held Space, two fingers), plus the mapping from screen points to the
3
+ // frame's unit square. The zoom is a factor of the fitted size, written to a CSS variable on the root; the stylesheet sizes the stage from it.
4
+
5
+ /** The zoom steps of the Studio (STUDIO-SPEC 4.2), as factors of the fitted size. */
6
+ export const ZOOM_STEPS = Object.freeze([0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4]);
7
+
8
+ /** `value` kept between the first and the last step. */
9
+ export const clampZoom = (value, steps = ZOOM_STEPS) => Math.max(steps[0], Math.min(steps[steps.length - 1], value));
10
+
11
+ /** The next step up (`direction` 1) or down (-1) from `zoom`; a zoom between two steps goes to the nearer one in that direction. */
12
+ export function stepZoom(zoom, direction, steps = ZOOM_STEPS) {
13
+ const at = steps.findIndex((value) => value >= zoom - 1e-9);
14
+ const index = direction > 0 ? (steps[at] > zoom + 1e-9 ? at : at + 1) : at - 1;
15
+ return steps[Math.max(0, Math.min(steps.length - 1, index))];
16
+ }
17
+
18
+ /** A client point as a fraction of `rect` (a DOMRect): `{ x, y }`, 0 to 1 inside it. */
19
+ export const toUnit = (point, rect) => ({ x: (point.clientX - rect.left) / (rect.width || 1), y: (point.clientY - rect.top) / (rect.height || 1) });
20
+
21
+ /**
22
+ * The scroll position that keeps the content point under `anchor` (client coordinates, relative to the viewport's box) in place when the
23
+ * zoom changes from `from` to `to`: `{ left, top }`.
24
+ */
25
+ export function scrollForZoom({ left, top }, anchor, from, to) {
26
+ const ratio = to / (from || 1);
27
+ return { left: (left + anchor.x) * ratio - anchor.x, top: (top + anchor.y) * ratio - anchor.y };
28
+ }
29
+
30
+ /**
31
+ * Attach zoom and pan to `viewport` (the scrolling element). Options:
32
+ * - `root`: where the zoom variable and the `data-zoomed`, `data-panning` and `data-space` states go (default the viewport);
33
+ * - `zoomVar`: the CSS variable (default `--opf-kit-zoom`); `steps` (default `ZOOM_STEPS`); `zoom` (the first zoom, default 1);
34
+ * - `panEnabled()`: whether a drag pans now (the hand tool); a held Space pans as well (`holdSpace`);
35
+ * - `wheel`: "step" (Ctrl or Cmd with the wheel steps the zoom, the default), "smooth" (it zooms continuously around the pointer) or false;
36
+ * - `pinch`: two touch pointers zoom around their midpoint and pan (default false);
37
+ * - `fitZoom(mode)`: the zoom of `fit(mode)` ("slide" or "width"), default 1 (the zoom is a factor of the fitted size);
38
+ * - `onZoom(zoom)`: after each change.
39
+ * Returns `{ zoom, panMode, setZoom, step, fit, holdSpace, toUnit, destroy }`.
40
+ */
41
+ export function createViewport(viewport, options = {}) {
42
+ const root = options.root ?? viewport;
43
+ const steps = options.steps ?? ZOOM_STEPS;
44
+ const zoomVar = options.zoomVar ?? "--opf-kit-zoom";
45
+ const wheelMode = options.wheel === undefined ? "step" : options.wheel;
46
+ let zoom = 1;
47
+ let spaceHeld = false;
48
+ let pan = null;
49
+ const touches = new Map();
50
+ let pinch = null;
51
+ const panMode = () => Boolean(options.panEnabled?.()) || spaceHeld;
52
+
53
+ function setZoom(value, { anchor } = {}) {
54
+ const next = clampZoom(value, steps);
55
+ const before = zoom;
56
+ zoom = next;
57
+ root.style.setProperty(zoomVar, String(zoom));
58
+ root.dataset.zoomed = String(zoom !== 1);
59
+ if (anchor && before !== next) {
60
+ const box = viewport.getBoundingClientRect();
61
+ const scroll = scrollForZoom({ left: viewport.scrollLeft, top: viewport.scrollTop }, { x: anchor.clientX - box.left, y: anchor.clientY - box.top }, before, next);
62
+ viewport.scrollLeft = scroll.left;
63
+ viewport.scrollTop = scroll.top;
64
+ }
65
+ options.onZoom?.(zoom);
66
+ return zoom;
67
+ }
68
+ const step = (direction, opts) => setZoom(stepZoom(zoom, direction, steps), opts);
69
+ const fit = (mode = "slide") => setZoom(options.fitZoom ? options.fitZoom(mode) : 1);
70
+
71
+ // Pan: a drag with the hand tool or Space; the capture phase keeps the drag from reaching the frame below.
72
+ const onDown = (event) => {
73
+ if (options.pinch && event.pointerType === "touch") {
74
+ touches.set(event.pointerId, { x: event.clientX, y: event.clientY });
75
+ if (touches.size === 2) { startPinch(); event.preventDefault(); event.stopPropagation(); return; }
76
+ }
77
+ if (!panMode() || (event.pointerType === "mouse" && event.button !== 0)) return;
78
+ event.preventDefault();
79
+ event.stopPropagation();
80
+ pan = { x: event.clientX, y: event.clientY, left: viewport.scrollLeft, top: viewport.scrollTop };
81
+ root.dataset.panning = "true";
82
+ viewport.setPointerCapture?.(event.pointerId);
83
+ };
84
+ const onMove = (event) => {
85
+ if (touches.has(event.pointerId)) {
86
+ touches.set(event.pointerId, { x: event.clientX, y: event.clientY });
87
+ if (pinch) { movePinch(); return; }
88
+ }
89
+ if (!pan) return;
90
+ viewport.scrollLeft = pan.left - (event.clientX - pan.x);
91
+ viewport.scrollTop = pan.top - (event.clientY - pan.y);
92
+ };
93
+ const endPan = (event) => {
94
+ if (event && touches.delete(event.pointerId) && pinch && touches.size < 2) { pinch = null; delete root.dataset.panning; }
95
+ pan = null;
96
+ delete root.dataset.panning;
97
+ };
98
+
99
+ // Two fingers: the distance between them scales the zoom from where it was, and the content point first under their midpoint stays
100
+ // under it (so moving both fingers pans).
101
+ const pair = () => { const [a, b] = [...touches.values()]; return { distance: Math.hypot(b.x - a.x, b.y - a.y) || 1, mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 } }; };
102
+ function startPinch() {
103
+ pan = null;
104
+ const { distance, mid } = pair();
105
+ const box = viewport.getBoundingClientRect();
106
+ pinch = { distance, zoom, point: { x: (viewport.scrollLeft + mid.x - box.left) / zoom, y: (viewport.scrollTop + mid.y - box.top) / zoom } };
107
+ root.dataset.panning = "true";
108
+ }
109
+ function movePinch() {
110
+ const { distance, mid } = pair();
111
+ const next = setZoom(pinch.zoom * (distance / pinch.distance));
112
+ const box = viewport.getBoundingClientRect();
113
+ viewport.scrollLeft = pinch.point.x * next - (mid.x - box.left);
114
+ viewport.scrollTop = pinch.point.y * next - (mid.y - box.top);
115
+ }
116
+
117
+ const onWheel = (event) => {
118
+ if (!wheelMode || !(event.ctrlKey || event.metaKey)) return;
119
+ event.preventDefault();
120
+ if (wheelMode === "smooth") setZoom(zoom * Math.exp(-event.deltaY * 0.01), { anchor: event });
121
+ else step(event.deltaY < 0 ? 1 : -1);
122
+ };
123
+
124
+ viewport.addEventListener("pointerdown", onDown, true);
125
+ viewport.addEventListener("pointermove", onMove);
126
+ viewport.addEventListener("pointerup", endPan);
127
+ viewport.addEventListener("pointercancel", endPan);
128
+ viewport.addEventListener("wheel", onWheel, { passive: false });
129
+ setZoom(options.zoom ?? 1);
130
+
131
+ return {
132
+ get zoom() { return zoom; },
133
+ get panMode() { return panMode(); },
134
+ get panning() { return pan !== null || pinch !== null; },
135
+ setZoom,
136
+ step,
137
+ fit,
138
+ /** Space held (true) or released (false): a drag pans while it is held. */
139
+ holdSpace(on) {
140
+ spaceHeld = Boolean(on);
141
+ if (spaceHeld) root.dataset.space = "true"; else delete root.dataset.space;
142
+ },
143
+ /** A pointer event's position as a fraction of `element`'s box. */
144
+ toUnit: (event, element) => toUnit(event, element.getBoundingClientRect()),
145
+ destroy() {
146
+ viewport.removeEventListener("pointerdown", onDown, true);
147
+ viewport.removeEventListener("pointermove", onMove);
148
+ viewport.removeEventListener("pointerup", endPan);
149
+ viewport.removeEventListener("pointercancel", endPan);
150
+ viewport.removeEventListener("wheel", onWheel);
151
+ },
152
+ };
153
+ }
package/dist/styles.css CHANGED
@@ -1,7 +1,8 @@
1
1
  /*
2
2
  * OPF editor UI tokens (RR-98). Every other file in this stylesheet uses only these `--opf-ui-*` variables.
3
3
  * Values copy the openpresentation.org design system. The host loads Inter and Geist Mono; nothing here fetches a font.
4
- * Light is the default. Dark applies under `.dark .opf-ui`, `.opf-ui.dark`, `[data-theme=dark]` (on the root or on any ancestor) and `:root.dark`.
4
+ * Light is the default. Dark applies under `.dark .opf-ui`, `.opf-ui.dark`, `[data-theme=dark]` (on the root or on any ancestor) and `:root.dark`;
5
+ * `[data-theme=light]` on an ancestor or on the surface forces light inside a dark page. docs/theming.md is the contract for hosts.
5
6
  */
6
7
  :root,
7
8
  .opf-ui {
@@ -18,7 +19,7 @@
18
19
  --opf-ui-border-strong: hsl(214 22% 72%);
19
20
  --opf-ui-ring: hsl(214 57% 28%);
20
21
  --opf-ui-danger: hsl(0 72% 45%);
21
- --opf-ui-warn: hsl(28 75% 38%);
22
+ --opf-ui-warn: hsl(28 75% 30%);
22
23
  --opf-ui-success: hsl(152 76% 26%);
23
24
  --opf-ui-code-bg: hsl(220 30% 96%);
24
25
  --opf-ui-code-border: hsl(214 18% 84%);
@@ -66,6 +67,39 @@
66
67
  color-scheme: dark;
67
68
  }
68
69
 
70
+ /*
71
+ * A surface that forces light (`data-theme="light"` on the editor root, the `colorScheme: "light"` theme option) wins over a dark page.
72
+ * The values repeat the light block above; test/styles.mjs keeps the two equal.
73
+ */
74
+ [data-theme="light"].opf-ui,
75
+ [data-theme="light"] .opf-ui,
76
+ .dark [data-theme="light"].opf-ui,
77
+ .dark [data-theme="light"] .opf-ui,
78
+ [data-theme="dark"] [data-theme="light"].opf-ui,
79
+ [data-theme="dark"] [data-theme="light"] .opf-ui {
80
+ --opf-ui-bg: hsl(210 30% 98%);
81
+ --opf-ui-surface: hsl(0 0% 100%);
82
+ --opf-ui-muted: hsl(214 28% 93%);
83
+ --opf-ui-muted-2: hsl(214 28% 96%);
84
+ --opf-ui-fg: hsl(220 28% 11%);
85
+ --opf-ui-muted-fg: hsl(218 14% 38%);
86
+ --opf-ui-accent: hsl(214 57% 28%);
87
+ --opf-ui-accent-fg: hsl(0 0% 100%);
88
+ --opf-ui-accent-strong: hsl(214 57% 28%);
89
+ --opf-ui-border: hsl(214 22% 83%);
90
+ --opf-ui-border-strong: hsl(214 22% 72%);
91
+ --opf-ui-ring: hsl(214 57% 28%);
92
+ --opf-ui-danger: hsl(0 72% 45%);
93
+ --opf-ui-warn: hsl(28 75% 30%);
94
+ --opf-ui-success: hsl(152 76% 26%);
95
+ --opf-ui-code-bg: hsl(220 30% 96%);
96
+ --opf-ui-code-border: hsl(214 18% 84%);
97
+
98
+ --opf-ui-shadow-pop: 0 8px 24px rgba(0, 0, 0, 0.1);
99
+
100
+ color-scheme: light;
101
+ }
102
+
69
103
  /* Touch density: controls grow to 40px, as in the mobile design. */
70
104
  .opf-ui[data-density="touch"],
71
105
  [data-density="touch"] .opf-ui {
@@ -3639,9 +3673,21 @@ input.opf-lx-input[data-field="max"] { font-family: var(--opf-ui-mono); font-siz
3639
3673
  background: color-mix(in srgb, var(--opf-ui-accent) 8%, transparent);
3640
3674
  }
3641
3675
 
3642
- :is(.opf-ui, .opf-json, .opf-code) .cm-selectionBackground,
3643
- :is(.opf-ui, .opf-json, .opf-code) .cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground {
3644
- background: color-mix(in srgb, var(--opf-ui-accent) 20%, transparent);
3676
+ /* The selection is a layer behind the text, so the text keeps its own colors: the tint is light enough that every token color (test/selection-contrast-browser.mjs
3677
+ measures each) keeps 4.5:1 against it, focused or not, in the light and the dark scheme. The focused rule of CodeMirror's base theme
3678
+ (`&light.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground`, `#d7d4f0` for light and `#233` for dark) has five classes, so
3679
+ the second selector has six and wins whichever stylesheet loads last; src/source-color-scheme.js flags the view dark so the rest of the base theme follows. */
3680
+ :is(.opf-ui, .opf-json, .opf-code) .cm-editor .cm-selectionLayer .cm-selectionBackground,
3681
+ :is(.opf-ui, .opf-json, .opf-code) .cm-editor.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground {
3682
+ background: color-mix(in srgb, var(--opf-ui-accent) 16%, transparent);
3683
+ }
3684
+
3685
+ /* A selection that is not drawn by CodeMirror (the handoff prompt, the data grid's text areas, the numbering preview) uses the browser's own, and a browser's
3686
+ default highlight is not made for our surfaces: the same tint, with the text color the surface already has, is 4.5:1 or better in both schemes. */
3687
+ :is(.opf-json, .opf-code, .opf-source-code, .opf-handoff-box, .opf-numbering-preview) ::selection,
3688
+ :is(.opf-handoff-prompt, .opf-numbering-preview, .opf-grid details textarea)::selection {
3689
+ background: color-mix(in srgb, var(--opf-ui-accent) 16%, transparent);
3690
+ color: var(--opf-ui-fg);
3645
3691
  }
3646
3692
 
3647
3693
  :is(.opf-ui, .opf-json, .opf-code) .cm-selectionMatch {
@@ -4484,8 +4530,7 @@ input.opf-lx-input[data-field="max"] { font-family: var(--opf-ui-mono); font-siz
4484
4530
  .opf-source-code .cm-gutters { border: 0; background: transparent; color: color-mix(in srgb, var(--opf-ui-muted-fg) 60%, transparent); }
4485
4531
  .opf-source-code .cm-lineNumbers .cm-gutterElement { min-width: 30px; padding: 0 12px 0 0; text-align: right; font-size: 11px; }
4486
4532
  .opf-source-code .cm-activeLine { background: transparent; }
4487
- .opf-source-code .cm-selectionBackground,
4488
- .opf-source-code .cm-focused .cm-selectionBackground { background: color-mix(in srgb, var(--opf-ui-accent) 16%, transparent); }
4533
+ /* The selection color is the shared one in panels.css (JSON editor section). */
4489
4534
  .opf-source-code .cm-cursor { border-left-color: var(--opf-ui-fg); }
4490
4535
  .opf-source-code .tok-key { color: var(--opf-ui-accent-strong); }
4491
4536
  .opf-source-code .tok-str { color: var(--opf-ui-fg); }
@@ -4498,6 +4543,16 @@ input.opf-lx-input[data-field="max"] { font-family: var(--opf-ui-mono); font-siz
4498
4543
  .opf-source-code .tok-comment { font-style: italic; }
4499
4544
  .opf-source-code .code-key.is-active { background: color-mix(in srgb, var(--opf-ui-accent) 10%, transparent); border-radius: var(--opf-ui-radius-sm); }
4500
4545
  .opf-source-code .cm-line.is-hover { background: color-mix(in srgb, var(--opf-ui-accent) 6%, transparent); }
4546
+ /* CE-17: decorations a host draws (setDecorations) and the flash of revealPath; the host styles any other kind as .opf-source-decoration-<kind>. */
4547
+ .opf-source-code .opf-source-decoration { background: color-mix(in srgb, var(--opf-ui-accent) 16%, transparent); border-radius: var(--opf-ui-radius-sm); }
4548
+ .opf-source-code .opf-source-decoration-error { background: color-mix(in srgb, var(--opf-ui-danger) 18%, transparent); }
4549
+ .opf-source-code .opf-source-decoration-warning { background: color-mix(in srgb, var(--opf-ui-warn) 20%, transparent); }
4550
+ .opf-source-code .opf-source-decoration-info { background: color-mix(in srgb, var(--opf-ui-accent) 10%, transparent); }
4551
+ .opf-source-code .opf-source-decoration-added { background: color-mix(in srgb, var(--opf-ui-success) 20%, transparent); }
4552
+ .opf-source-code .opf-source-decoration-changed { background: color-mix(in srgb, var(--opf-ui-warn) 14%, transparent); }
4553
+ .opf-source-code .cm-line.opf-source-flash { animation: opf-source-flash 1.2s ease-out; }
4554
+ @keyframes opf-source-flash { from { background: color-mix(in srgb, var(--opf-ui-accent) 32%, transparent); } to { background: transparent; } }
4555
+ @media (prefers-reduced-motion: reduce) { .opf-source-code .cm-line.opf-source-flash { animation: none; background: color-mix(in srgb, var(--opf-ui-accent) 20%, transparent); } }
4501
4556
  .opf-source-code .cm-panels { background: var(--opf-ui-surface); color: var(--opf-ui-fg); border-color: var(--opf-ui-border); }
4502
4557
  .opf-source-code .cm-tooltip { background: var(--opf-ui-surface); border: 1px solid var(--opf-ui-border); border-radius: var(--opf-ui-radius); color: var(--opf-ui-fg); }
4503
4558
  .opf-source-code .cm-lintRange-error { background-image: none; text-decoration: underline wavy var(--opf-ui-danger); }
@@ -5044,3 +5099,266 @@ input.opf-lx-input[data-field="max"] { font-family: var(--opf-ui-mono); font-siz
5044
5099
  min-height: 44px;
5045
5100
  }
5046
5101
  }
5102
+
5103
+ /* workspace.css */
5104
+ /* The embeddable editor (CE-06): the default arrangement `createOpfEditor` builds for `layout: "full" | "canvas" | "source"`.
5105
+ Uses only --opf-ui-* tokens. A host that passes its own `slots` ignores all of this except the `data-` attributes. */
5106
+ .opf-workspace {
5107
+ display: flex;
5108
+ flex-direction: column;
5109
+ box-sizing: border-box;
5110
+ width: 100%;
5111
+ height: 100%;
5112
+ min-height: 0;
5113
+ background: var(--opf-ui-bg);
5114
+ color: var(--opf-ui-fg);
5115
+ font-family: var(--opf-ui-font);
5116
+ }
5117
+ /* A host that arranges the page itself gets no box from the editor. */
5118
+ .opf-workspace[data-opf-layout="slots"] {
5119
+ display: contents;
5120
+ }
5121
+ .opf-ws-bar {
5122
+ display: flex;
5123
+ flex: none;
5124
+ align-items: center;
5125
+ flex-wrap: wrap;
5126
+ gap: 8px;
5127
+ padding: 6px 12px;
5128
+ border-bottom: 1px solid var(--opf-ui-border);
5129
+ background: var(--opf-ui-surface);
5130
+ }
5131
+ .opf-ws-group,
5132
+ .opf-ws-views {
5133
+ display: inline-flex;
5134
+ align-items: center;
5135
+ gap: 4px;
5136
+ }
5137
+ .opf-ws-spacer {
5138
+ flex: 1 1 0;
5139
+ }
5140
+ .opf-ws-button {
5141
+ box-sizing: border-box;
5142
+ min-height: var(--opf-ui-control-h);
5143
+ padding: 0 12px;
5144
+ border: 1px solid var(--opf-ui-border);
5145
+ border-radius: var(--opf-ui-radius);
5146
+ background: var(--opf-ui-surface);
5147
+ color: var(--opf-ui-fg);
5148
+ font: 500 13px/1 var(--opf-ui-font);
5149
+ cursor: pointer;
5150
+ transition: color var(--opf-ui-ease), background-color var(--opf-ui-ease), border-color var(--opf-ui-ease);
5151
+ }
5152
+ .opf-ws-button:hover:not(:disabled) {
5153
+ background: var(--opf-ui-muted);
5154
+ }
5155
+ .opf-ws-button:disabled {
5156
+ opacity: 0.45;
5157
+ cursor: default;
5158
+ }
5159
+ .opf-ws-button[aria-pressed="true"],
5160
+ .opf-ws-button[aria-expanded="true"] {
5161
+ border-color: var(--opf-ui-accent);
5162
+ background: var(--opf-ui-muted);
5163
+ color: var(--opf-ui-accent-strong);
5164
+ }
5165
+ .opf-ws-autosave {
5166
+ max-width: 240px;
5167
+ color: var(--opf-ui-muted-fg);
5168
+ font-size: 12px;
5169
+ line-height: 1.3;
5170
+ }
5171
+ .opf-ws-banner {
5172
+ flex: none;
5173
+ }
5174
+ .opf-ws-banner[hidden] {
5175
+ display: none;
5176
+ }
5177
+ .opf-ws-body {
5178
+ display: flex;
5179
+ flex: 1 1 0;
5180
+ min-height: 0;
5181
+ }
5182
+ .opf-ws-source {
5183
+ display: flex;
5184
+ flex: 0 1 42%;
5185
+ min-width: 280px;
5186
+ min-height: 0;
5187
+ border-right: 1px solid var(--opf-ui-border);
5188
+ background: var(--opf-ui-surface);
5189
+ }
5190
+ .opf-ws-source-host {
5191
+ flex: 1 1 auto;
5192
+ min-width: 0;
5193
+ min-height: 0;
5194
+ }
5195
+ .opf-ws-main {
5196
+ display: flex;
5197
+ flex: 1 1 0;
5198
+ flex-direction: column;
5199
+ min-width: 0;
5200
+ min-height: 0;
5201
+ }
5202
+ .opf-ws-find {
5203
+ flex: none;
5204
+ padding: 8px 12px 0;
5205
+ }
5206
+ .opf-ws-find[hidden] {
5207
+ display: none;
5208
+ }
5209
+ .opf-ws-stage {
5210
+ display: flex;
5211
+ flex: 1 1 0;
5212
+ flex-direction: column;
5213
+ min-height: 0;
5214
+ padding: 12px;
5215
+ overflow: auto;
5216
+ }
5217
+ .opf-ws-frame {
5218
+ display: grid;
5219
+ flex: 1 1 0;
5220
+ place-items: center;
5221
+ min-height: 0;
5222
+ }
5223
+ .opf-ws-frame[hidden],
5224
+ .opf-ws-view[hidden] {
5225
+ display: none;
5226
+ }
5227
+ .opf-ws-canvas {
5228
+ position: relative;
5229
+ overflow: hidden;
5230
+ border: 1px solid var(--opf-ui-border);
5231
+ border-radius: var(--opf-ui-radius-sm);
5232
+ background: var(--opf-ui-surface);
5233
+ }
5234
+ .opf-ws-view {
5235
+ flex: 1 1 0;
5236
+ min-height: 0;
5237
+ overflow: auto;
5238
+ }
5239
+ .opf-ws-dock {
5240
+ display: flex;
5241
+ flex: none;
5242
+ flex-direction: column;
5243
+ align-items: center;
5244
+ gap: 4px;
5245
+ padding: 0 12px 8px;
5246
+ }
5247
+ .opf-ws-status {
5248
+ flex: none;
5249
+ margin: 0;
5250
+ padding: 4px 12px 8px;
5251
+ color: var(--opf-ui-muted-fg);
5252
+ font-size: 12px;
5253
+ }
5254
+ .opf-ws-side {
5255
+ display: flex;
5256
+ flex: 0 0 300px;
5257
+ flex-direction: column;
5258
+ min-height: 0;
5259
+ border-left: 1px solid var(--opf-ui-border);
5260
+ background: var(--opf-ui-surface);
5261
+ overflow: auto;
5262
+ }
5263
+ .opf-ws-tabs {
5264
+ display: flex;
5265
+ gap: 2px;
5266
+ padding: 6px 8px 0;
5267
+ border-bottom: 1px solid var(--opf-ui-border);
5268
+ }
5269
+ .opf-ws-tab {
5270
+ min-height: var(--opf-ui-control-h);
5271
+ padding: 0 12px;
5272
+ border: 0;
5273
+ border-bottom: 2px solid transparent;
5274
+ background: transparent;
5275
+ color: var(--opf-ui-muted-fg);
5276
+ font: 500 13px/1 var(--opf-ui-font);
5277
+ cursor: pointer;
5278
+ }
5279
+ .opf-ws-tab[aria-selected="true"] {
5280
+ border-bottom-color: var(--opf-ui-accent);
5281
+ color: var(--opf-ui-fg);
5282
+ }
5283
+ .opf-ws-panel {
5284
+ padding: 12px;
5285
+ }
5286
+ .opf-ws-panel[hidden] {
5287
+ display: none;
5288
+ }
5289
+ /* Layout Studio inside the workspace. */
5290
+ .opf-ws-layouts {
5291
+ display: flex;
5292
+ flex-direction: column;
5293
+ gap: 8px;
5294
+ }
5295
+ .opf-ws-layouts-bar {
5296
+ display: flex;
5297
+ flex: none;
5298
+ flex-wrap: wrap;
5299
+ align-items: center;
5300
+ gap: 8px;
5301
+ }
5302
+ .opf-ws-layouts-bar select,
5303
+ .opf-ws-layouts-bar button {
5304
+ min-height: var(--opf-ui-control-h);
5305
+ border: 1px solid var(--opf-ui-border);
5306
+ border-radius: var(--opf-ui-radius);
5307
+ background: var(--opf-ui-surface);
5308
+ color: var(--opf-ui-fg);
5309
+ font: 500 13px/1 var(--opf-ui-font);
5310
+ }
5311
+ .opf-ws-layouts-bar button {
5312
+ padding: 0 12px;
5313
+ cursor: pointer;
5314
+ }
5315
+ .opf-ws-layouts-bar button:disabled {
5316
+ opacity: 0.45;
5317
+ cursor: default;
5318
+ }
5319
+ .opf-ws-layouts-pick {
5320
+ display: inline-flex;
5321
+ align-items: center;
5322
+ gap: 6px;
5323
+ color: var(--opf-ui-muted-fg);
5324
+ font-size: 12px;
5325
+ }
5326
+ .opf-ws-layouts-status {
5327
+ color: var(--opf-ui-muted-fg);
5328
+ font-size: 12px;
5329
+ }
5330
+ .opf-ws-layouts-host {
5331
+ flex: 1 1 0;
5332
+ min-height: 0;
5333
+ }
5334
+ /* A phone: the parts stack, the source above the slide, the side panel below. */
5335
+ @media (max-width: 760px) {
5336
+ .opf-ws-body {
5337
+ flex-direction: column;
5338
+ overflow: auto;
5339
+ }
5340
+ .opf-ws-source {
5341
+ flex: none;
5342
+ min-width: 0;
5343
+ height: 40vh;
5344
+ border-right: 0;
5345
+ border-bottom: 1px solid var(--opf-ui-border);
5346
+ }
5347
+ .opf-ws-main {
5348
+ flex: none;
5349
+ }
5350
+ .opf-ws-stage {
5351
+ flex: none;
5352
+ min-height: 40vh;
5353
+ }
5354
+ .opf-ws-side {
5355
+ flex: none;
5356
+ border-left: 0;
5357
+ border-top: 1px solid var(--opf-ui-border);
5358
+ }
5359
+ }
5360
+ @media (prefers-reduced-motion: reduce) {
5361
+ .opf-ws-button {
5362
+ transition: none;
5363
+ }
5364
+ }
@@ -0,0 +1,31 @@
1
+ import type { EditorSession } from "./index.js";
2
+ import type { LayoutEditor, LayoutEditorDensity } from "./layout-editor.js";
3
+
4
+ export interface LayoutStudioOptions {
5
+ session: EditorSession;
6
+ toSvg?: (...args: any[]) => string;
7
+ fonts?: unknown;
8
+ renderOptions?: Record<string, unknown> | (() => Record<string, unknown>);
9
+ getSlideIndex: () => number;
10
+ onStatus?: (message: string) => void;
11
+ density?: LayoutEditorDensity;
12
+ readOnly?: boolean;
13
+ labels?: Partial<Record<"layout" | "pick" | "fresh" | "save" | "apply", string>>;
14
+ }
15
+ /** Layout Studio inside the workspace: the layout editor over the deck's own custom layouts, with a bar to pick, save and apply. */
16
+ export interface LayoutStudio {
17
+ element: HTMLElement;
18
+ editor: LayoutEditor;
19
+ pick: HTMLSelectElement;
20
+ save: HTMLButtonElement;
21
+ apply: HTMLButtonElement;
22
+ status: HTMLElement;
23
+ /** Re-read the deck's custom layouts into the picker. */
24
+ refresh(): void;
25
+ /** Edit the deck's layout `id`, or a new layout for `""`. */
26
+ load(id: string): void;
27
+ setReadOnly(readOnly: boolean): void;
28
+ setDensity(density: LayoutEditorDensity): void;
29
+ destroy(): void;
30
+ }
31
+ export declare function createLayoutStudio(container: HTMLElement, options: LayoutStudioOptions): LayoutStudio;
@@ -0,0 +1,84 @@
1
+ // CE-06 (promoted from examples/playground.js): Layout Studio inside the workspace. The layout editor (palette, canvas, layers, inspector)
2
+ // works on one layout record at a time; "Save to deck" writes it to `catalogs.custom.layouts` (one undoable edit) and "Use on this slide"
3
+ // also makes it the layout of the current slide. A bar above the editor picks which of the deck's own layouts to edit.
4
+ import { blankLayoutRecord, createLayoutEditor } from "./layout-editor.js";
5
+
6
+ const make = (doc, tag, attributes = {}, ...children) => {
7
+ const node = doc.createElement(tag);
8
+ for (const [name, value] of Object.entries(attributes)) {
9
+ if (name === "text") node.textContent = value;
10
+ else node.setAttribute(name, value);
11
+ }
12
+ node.append(...children);
13
+ return node;
14
+ };
15
+
16
+ /**
17
+ * Mount Layout Studio in `container`. Returns `{ element, refresh, destroy, load }`. `refresh()` re-reads the deck's layouts into the picker (call it
18
+ * when the view opens); `load(id)` edits the deck's layout `id`, or a new layout for `""`.
19
+ */
20
+ export function createLayoutStudio(container, { session, toSvg, fonts, renderOptions, getSlideIndex, onStatus, density = "comfortable", readOnly = false, labels = {} }) {
21
+ const doc = container.ownerDocument;
22
+ const text = { layout: "Layout", pick: "Layout to edit", fresh: "New layout", save: "Save to deck", apply: "Use on this slide", ...labels };
23
+ const pick = make(doc, "select", { "data-opf-layouts": "pick", "aria-label": text.pick }, make(doc, "option", { value: "", text: text.fresh }));
24
+ const save = make(doc, "button", { type: "button", "data-opf-layouts": "save", text: text.save });
25
+ const apply = make(doc, "button", { type: "button", "data-opf-layouts": "apply", text: text.apply });
26
+ const status = make(doc, "span", { "data-opf-layouts": "status", role: "status", class: "opf-ws-layouts-status" });
27
+ const bar = make(doc, "div", { class: "opf-ws-layouts-bar" }, make(doc, "label", { class: "opf-ws-layouts-pick", text: `${text.layout} ` }, pick), save, apply, status);
28
+ const host = make(doc, "div", { class: "opf-ws-layouts-host" });
29
+ container.replaceChildren(bar, host);
30
+ save.disabled = apply.disabled = readOnly;
31
+
32
+ const customLayouts = () => session.presentation.catalogs?.custom?.layouts ?? {};
33
+ function freeLayoutId() {
34
+ const taken = customLayouts();
35
+ if (!taken["my-layout"]) return "my-layout";
36
+ for (let n = 2; ; n++) if (!taken[`my-layout-${n}`]) return `my-layout-${n}`;
37
+ }
38
+ const say = (message) => { status.textContent = message; };
39
+ function refresh() {
40
+ const current = pick.value;
41
+ const layouts = customLayouts();
42
+ pick.replaceChildren(new Option(text.fresh, ""), ...Object.entries(layouts).map(([id, record]) => new Option(`${record.name || id} (${id})`, id)));
43
+ pick.value = current in layouts ? current : "";
44
+ }
45
+ const editor = createLayoutEditor(host, { record: { ...blankLayoutRecord(), id: freeLayoutId() }, toSvg, fonts, renderOptions, density, readOnly });
46
+ function load(id) {
47
+ const layouts = customLayouts();
48
+ editor.setRecord(id ? { id, ...structuredClone(layouts[id]) } : { ...blankLayoutRecord(), id: freeLayoutId() });
49
+ say(id ? `Editing ${id}. Save to deck replaces it.` : "A new layout. Save to deck adds it to this presentation.");
50
+ }
51
+ const problem = (error) => error.issues?.[0]?.message ?? error.message;
52
+ pick.onchange = () => load(pick.value);
53
+ save.onclick = () => {
54
+ const replace = pick.value === editor.getRecord().id;
55
+ try {
56
+ const change = editor.saveToDeck(session, { replace });
57
+ refresh();
58
+ pick.value = change.layoutId;
59
+ say(`Saved ${change.layoutId} in custom layouts`);
60
+ onStatus?.(`Saved ${change.layoutId} in custom layouts`);
61
+ } catch (error) { say(problem(error)); }
62
+ };
63
+ apply.onclick = () => {
64
+ try {
65
+ const slideIndex = getSlideIndex();
66
+ const change = editor.applyToSlide(session, slideIndex);
67
+ refresh();
68
+ pick.value = change.layoutId;
69
+ say(`Slide ${slideIndex + 1} now uses ${change.layoutId}`);
70
+ } catch (error) { say(problem(error)); }
71
+ };
72
+ refresh();
73
+ load("");
74
+ return {
75
+ element: bar.parentElement,
76
+ editor,
77
+ pick, save, apply, status,
78
+ refresh,
79
+ load,
80
+ setReadOnly(on) { editor.setReadOnly(on); save.disabled = apply.disabled = on; },
81
+ setDensity(next) { editor.setDensity(next); },
82
+ destroy() { editor.destroy(); container.replaceChildren(); },
83
+ };
84
+ }
@@ -0,0 +1,35 @@
1
+ // CE-06 (promoted from examples/playground.js): text measurement and font notes shared by the workspace parts.
2
+ import { createScriptTextMeasurement } from "@openpresentation/opf-render/fonts";
3
+ import { resolveScriptFonts } from "@openpresentation/opf/composition";
4
+
5
+ /**
6
+ * The layout options for composing slide `index` of `deck`: the fonts handle's measurement, made script-aware for this document (Japanese
7
+ * under Aptos measures with Noto Sans JP, where the plain strict measurement would throw "cannot display U+65E5" with every face loaded).
8
+ * A fonts handle without a measurement is passed through.
9
+ */
10
+ export function layoutOptionsFor(fonts, deck, index, catalogs) {
11
+ if (!fonts?.textMeasurement) return fonts ? { fonts } : {};
12
+ try {
13
+ return { fonts: { textMeasurement: createScriptTextMeasurement(fonts.textMeasurement, resolveScriptFonts(deck, { slideIndex: index, catalogs })) } };
14
+ } catch {
15
+ return { fonts };
16
+ }
17
+ }
18
+
19
+ /** Review text for one font resolution the registry recorded. A change inside one family is a style fallback, not a replacement font. */
20
+ export function describeFontChange(change) {
21
+ const sameFamily = (change.sourceFamily ?? change.requestedFamily).toLowerCase() === change.resolvedFamily.toLowerCase();
22
+ if (!sameFamily) return `${change.requestedFamily} → ${change.resolvedFamily} · ${change.compatibility === "metric" ? "Metric substitute" : "Approximate substitute; wrapping may change"} (${change.resolvedWeight}).`;
23
+ const drawn = `${change.resolvedFamily} ${change.resolvedWeight}${change.italic ? " italic" : ""}`;
24
+ return change.requestedWeight === change.resolvedWeight
25
+ ? `${change.requestedFamily}: the ${change.italic ? "italic" : "upright"} face asked for is not available, so ${drawn} is drawn; wrapping may change.`
26
+ : `${change.requestedFamily} ${change.requestedWeight}: that weight is not available, so ${drawn} is drawn; wrapping may change.`;
27
+ }
28
+
29
+ /** Notes (`{ path, message }`) for the font substitutions a fonts handle recorded, one per requested face. */
30
+ export function fontSubstitutionNotes(fonts) {
31
+ const changes = fonts?.substitutions ?? [];
32
+ return changes
33
+ .filter((change, index, all) => all.findIndex((other) => other.requestedFamily === change.requestedFamily && other.requestedWeight === change.requestedWeight && other.italic === change.italic) === index)
34
+ .map((change) => ({ path: change.path ?? "design.fontScheme", message: describeFontChange(change) }));
35
+ }