@openpresentation/opf-editor 0.26.10 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/react.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createElement, forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, useSyncExternalStore } from "react";
1
2
  import { commitCatalogControl } from "./catalog-control.js";
2
3
  import {
3
4
  getCatalogOptions,
@@ -96,3 +97,122 @@ export function createOPFReactComponents(React) {
96
97
  OPFCatalogSelect
97
98
  };
98
99
  }
100
+
101
+ // ---- CE-06: the embeddable editor for React 18 and 19 ----------------------------------------------------------------------------------
102
+ // `<OpfEditor>` mounts `createOpfEditor` (src/workspace.js) in an effect and destroys it in the cleanup, so it is correct under StrictMode's
103
+ // mount, unmount, mount: the editor module is loaded on demand (an import that resolves after both effects), the first effect's request is
104
+ // cancelled by its cleanup, and only the second creates an editor. The server renders an empty container; nothing of the editor
105
+ // (and none of its code) runs there. React is an optional peer: only this entry point needs it.
106
+
107
+ const NO_STOP = () => {};
108
+ const isRef = (value) => value !== null && typeof value === "object" && "current" in value;
109
+
110
+ /** `slots` whose values are React refs (`useRef`) resolve to their elements when the editor mounts, after the commit that attached them. */
111
+ function resolveSlots(slots) {
112
+ if (!slots) return undefined;
113
+ const resolved = {};
114
+ for (const [name, value] of Object.entries(slots)) resolved[name] = isRef(value) ? value.current : value;
115
+ return resolved;
116
+ }
117
+
118
+ // The props that are not editor options.
119
+ const COMPONENT_PROPS = new Set(["className", "style", "id", "onEditor", "children", "slots", "onChange", "onSelection", "onError", "onReady"]);
120
+
121
+ function splitProps(props) {
122
+ const options = {};
123
+ const rest = {};
124
+ for (const [name, value] of Object.entries(props)) (COMPONENT_PROPS.has(name) ? rest : options)[name] = value;
125
+ return { options, rest };
126
+ }
127
+
128
+ /**
129
+ * Create and destroy one editor in the element `container.current` points at. The options are read once, when the editor mounts (change the
130
+ * `key` of the component to mount another); `theme`, `readOnly`, `view`, `density` and `exportMode` follow later renders. The callbacks
131
+ * (`onChange`, `onSelection`, `onError`, `onReady`) always call the latest function passed. Returns the editor once it is mounted, else `null`.
132
+ */
133
+ function useMountedEditor(container, props, onEditor) {
134
+ const [editor, setEditor] = useState(null);
135
+ const latest = useRef(props);
136
+ latest.current = props;
137
+ const report = useRef(onEditor);
138
+ report.current = onEditor;
139
+ useEffect(() => {
140
+ const element = container.current;
141
+ if (!element) return undefined;
142
+ let cancelled = false;
143
+ let created = null;
144
+ import("./workspace.js").then(({ createOpfEditor }) => {
145
+ if (cancelled) return;
146
+ const { options } = splitProps(latest.current);
147
+ const current = () => latest.current;
148
+ created = createOpfEditor(element, {
149
+ ...options,
150
+ slots: resolveSlots(latest.current.slots),
151
+ onChange: (detail) => current().onChange?.(detail),
152
+ onSelection: (detail) => current().onSelection?.(detail),
153
+ onError: (error, detail) => current().onError?.(error, detail),
154
+ onReady: (detail) => current().onReady?.(detail),
155
+ });
156
+ setEditor(created);
157
+ report.current?.(created);
158
+ }, (error) => {
159
+ if (!cancelled) latest.current.onError?.(error instanceof Error ? error : new Error(String(error)), {});
160
+ });
161
+ return () => {
162
+ cancelled = true;
163
+ if (!created) return;
164
+ created.destroy();
165
+ created = null;
166
+ setEditor(null);
167
+ report.current?.(null);
168
+ };
169
+ // The editor mounts once per element: later options are applied by the effects below, or by a new `key`.
170
+ // eslint-disable-next-line react-hooks/exhaustive-deps
171
+ }, [container]);
172
+ const { theme, readOnly, view, density, exportMode } = props;
173
+ // A theme written inline in JSX is a new object on every render; it is applied again only when its content changed.
174
+ const themeKey = theme === undefined ? undefined : JSON.stringify(theme);
175
+ // eslint-disable-next-line react-hooks/exhaustive-deps
176
+ useEffect(() => { if (editor && theme !== undefined) editor.setTheme(theme); }, [editor, themeKey]);
177
+ useEffect(() => { if (editor && readOnly !== undefined) editor.setReadOnly(readOnly); }, [editor, readOnly]);
178
+ useEffect(() => { if (editor && view !== undefined && editor.view !== view) editor.setView(view); }, [editor, view]);
179
+ useEffect(() => { if (editor && density !== undefined) editor.setDensity(density); }, [editor, density]);
180
+ useEffect(() => { if (editor && exportMode !== undefined) editor.setExportMode(exportMode); }, [editor, exportMode]);
181
+ return editor;
182
+ }
183
+
184
+ /**
185
+ * The editor in a `<div>`. Every prop that is not `className`, `style`, `id`, `slots` or a callback is an option of `createOpfEditor`;
186
+ * `ref` is the editor handle once mounted, and `onEditor(editor | null)` tells the parent when it is created and destroyed (for the hooks below).
187
+ */
188
+ export const OpfEditor = forwardRef(function OpfEditor(props, ref) {
189
+ const container = useRef(null);
190
+ const editor = useMountedEditor(container, props, props.onEditor);
191
+ useImperativeHandle(ref, () => editor, [editor]);
192
+ const { rest } = splitProps(props);
193
+ return createElement("div", { ref: container, id: rest.id, className: rest.className, style: rest.style, "data-opf-react": "" });
194
+ });
195
+
196
+ /**
197
+ * The editor as a hook, for a host that renders its own element: `const { ref, editor } = useOpfEditor(options)` and `<div ref={ref} />`.
198
+ * `editor` is `null` on the server and until the client has mounted it. Options are read once, as for `<OpfEditor>`.
199
+ */
200
+ export function useOpfEditor(options = {}) {
201
+ const ref = useRef(null);
202
+ const editor = useMountedEditor(ref, options);
203
+ return { ref, editor };
204
+ }
205
+
206
+ const subscribeTo = (editor, event) => (notify) => (editor ? editor.on(event, notify) : NO_STOP);
207
+
208
+ /** The document of an editor, replaced on every change (it is frozen, so it works as a store snapshot). `undefined` until there is an editor. */
209
+ export function useOpfDocument(editor) {
210
+ const subscribe = useCallback(subscribeTo(editor, "change"), [editor]);
211
+ return useSyncExternalStore(subscribe, () => (editor ? editor.getDocument() : undefined), () => undefined);
212
+ }
213
+
214
+ /** The content last selected on the canvas, `{ path, value }`, or `null`. */
215
+ export function useOpfSelection(editor) {
216
+ const subscribe = useCallback(subscribeTo(editor, "selection"), [editor]);
217
+ return useSyncExternalStore(subscribe, () => (editor ? editor.selection : null), () => null);
218
+ }
@@ -0,0 +1,13 @@
1
+ // CE-06 (promoted from openpresentation.org's playground runtime): which slide to show after the document changed outside the canvas
2
+ // (the source pane, an opened file, a restored copy). Stable ids survive a reorder; a deck without ids, or an id that is not unique, keeps
3
+ // the position and clamps it to the deck.
4
+
5
+ /** @param {readonly {id?: string}[]} before @param {readonly {id?: string}[]} after @param {number} index */
6
+ export function retainedSlideIndex(before, after, index) {
7
+ const id = before[index]?.id;
8
+ if (id && before.filter((slide) => slide.id === id).length === 1) {
9
+ const matches = after.flatMap((slide, position) => (slide.id === id ? [position] : []));
10
+ if (matches.length === 1) return matches[0];
11
+ }
12
+ return Math.max(0, Math.min(index, after.length - 1));
13
+ }
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 {
@@ -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% 38%);
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 {
@@ -5044,3 +5078,266 @@ input.opf-lx-input[data-field="max"] { font-family: var(--opf-ui-mono); font-siz
5044
5078
  min-height: 44px;
5045
5079
  }
5046
5080
  }
5081
+
5082
+ /* workspace.css */
5083
+ /* The embeddable editor (CE-06): the default arrangement `createOpfEditor` builds for `layout: "full" | "canvas" | "source"`.
5084
+ Uses only --opf-ui-* tokens. A host that passes its own `slots` ignores all of this except the `data-` attributes. */
5085
+ .opf-workspace {
5086
+ display: flex;
5087
+ flex-direction: column;
5088
+ box-sizing: border-box;
5089
+ width: 100%;
5090
+ height: 100%;
5091
+ min-height: 0;
5092
+ background: var(--opf-ui-bg);
5093
+ color: var(--opf-ui-fg);
5094
+ font-family: var(--opf-ui-font);
5095
+ }
5096
+ /* A host that arranges the page itself gets no box from the editor. */
5097
+ .opf-workspace[data-opf-layout="slots"] {
5098
+ display: contents;
5099
+ }
5100
+ .opf-ws-bar {
5101
+ display: flex;
5102
+ flex: none;
5103
+ align-items: center;
5104
+ flex-wrap: wrap;
5105
+ gap: 8px;
5106
+ padding: 6px 12px;
5107
+ border-bottom: 1px solid var(--opf-ui-border);
5108
+ background: var(--opf-ui-surface);
5109
+ }
5110
+ .opf-ws-group,
5111
+ .opf-ws-views {
5112
+ display: inline-flex;
5113
+ align-items: center;
5114
+ gap: 4px;
5115
+ }
5116
+ .opf-ws-spacer {
5117
+ flex: 1 1 0;
5118
+ }
5119
+ .opf-ws-button {
5120
+ box-sizing: border-box;
5121
+ min-height: var(--opf-ui-control-h);
5122
+ padding: 0 12px;
5123
+ border: 1px solid var(--opf-ui-border);
5124
+ border-radius: var(--opf-ui-radius);
5125
+ background: var(--opf-ui-surface);
5126
+ color: var(--opf-ui-fg);
5127
+ font: 500 13px/1 var(--opf-ui-font);
5128
+ cursor: pointer;
5129
+ transition: color var(--opf-ui-ease), background-color var(--opf-ui-ease), border-color var(--opf-ui-ease);
5130
+ }
5131
+ .opf-ws-button:hover:not(:disabled) {
5132
+ background: var(--opf-ui-muted);
5133
+ }
5134
+ .opf-ws-button:disabled {
5135
+ opacity: 0.45;
5136
+ cursor: default;
5137
+ }
5138
+ .opf-ws-button[aria-pressed="true"],
5139
+ .opf-ws-button[aria-expanded="true"] {
5140
+ border-color: var(--opf-ui-accent);
5141
+ background: var(--opf-ui-muted);
5142
+ color: var(--opf-ui-accent-strong);
5143
+ }
5144
+ .opf-ws-autosave {
5145
+ max-width: 240px;
5146
+ color: var(--opf-ui-muted-fg);
5147
+ font-size: 12px;
5148
+ line-height: 1.3;
5149
+ }
5150
+ .opf-ws-banner {
5151
+ flex: none;
5152
+ }
5153
+ .opf-ws-banner[hidden] {
5154
+ display: none;
5155
+ }
5156
+ .opf-ws-body {
5157
+ display: flex;
5158
+ flex: 1 1 0;
5159
+ min-height: 0;
5160
+ }
5161
+ .opf-ws-source {
5162
+ display: flex;
5163
+ flex: 0 1 42%;
5164
+ min-width: 280px;
5165
+ min-height: 0;
5166
+ border-right: 1px solid var(--opf-ui-border);
5167
+ background: var(--opf-ui-surface);
5168
+ }
5169
+ .opf-ws-source-host {
5170
+ flex: 1 1 auto;
5171
+ min-width: 0;
5172
+ min-height: 0;
5173
+ }
5174
+ .opf-ws-main {
5175
+ display: flex;
5176
+ flex: 1 1 0;
5177
+ flex-direction: column;
5178
+ min-width: 0;
5179
+ min-height: 0;
5180
+ }
5181
+ .opf-ws-find {
5182
+ flex: none;
5183
+ padding: 8px 12px 0;
5184
+ }
5185
+ .opf-ws-find[hidden] {
5186
+ display: none;
5187
+ }
5188
+ .opf-ws-stage {
5189
+ display: flex;
5190
+ flex: 1 1 0;
5191
+ flex-direction: column;
5192
+ min-height: 0;
5193
+ padding: 12px;
5194
+ overflow: auto;
5195
+ }
5196
+ .opf-ws-frame {
5197
+ display: grid;
5198
+ flex: 1 1 0;
5199
+ place-items: center;
5200
+ min-height: 0;
5201
+ }
5202
+ .opf-ws-frame[hidden],
5203
+ .opf-ws-view[hidden] {
5204
+ display: none;
5205
+ }
5206
+ .opf-ws-canvas {
5207
+ position: relative;
5208
+ overflow: hidden;
5209
+ border: 1px solid var(--opf-ui-border);
5210
+ border-radius: var(--opf-ui-radius-sm);
5211
+ background: var(--opf-ui-surface);
5212
+ }
5213
+ .opf-ws-view {
5214
+ flex: 1 1 0;
5215
+ min-height: 0;
5216
+ overflow: auto;
5217
+ }
5218
+ .opf-ws-dock {
5219
+ display: flex;
5220
+ flex: none;
5221
+ flex-direction: column;
5222
+ align-items: center;
5223
+ gap: 4px;
5224
+ padding: 0 12px 8px;
5225
+ }
5226
+ .opf-ws-status {
5227
+ flex: none;
5228
+ margin: 0;
5229
+ padding: 4px 12px 8px;
5230
+ color: var(--opf-ui-muted-fg);
5231
+ font-size: 12px;
5232
+ }
5233
+ .opf-ws-side {
5234
+ display: flex;
5235
+ flex: 0 0 300px;
5236
+ flex-direction: column;
5237
+ min-height: 0;
5238
+ border-left: 1px solid var(--opf-ui-border);
5239
+ background: var(--opf-ui-surface);
5240
+ overflow: auto;
5241
+ }
5242
+ .opf-ws-tabs {
5243
+ display: flex;
5244
+ gap: 2px;
5245
+ padding: 6px 8px 0;
5246
+ border-bottom: 1px solid var(--opf-ui-border);
5247
+ }
5248
+ .opf-ws-tab {
5249
+ min-height: var(--opf-ui-control-h);
5250
+ padding: 0 12px;
5251
+ border: 0;
5252
+ border-bottom: 2px solid transparent;
5253
+ background: transparent;
5254
+ color: var(--opf-ui-muted-fg);
5255
+ font: 500 13px/1 var(--opf-ui-font);
5256
+ cursor: pointer;
5257
+ }
5258
+ .opf-ws-tab[aria-selected="true"] {
5259
+ border-bottom-color: var(--opf-ui-accent);
5260
+ color: var(--opf-ui-fg);
5261
+ }
5262
+ .opf-ws-panel {
5263
+ padding: 12px;
5264
+ }
5265
+ .opf-ws-panel[hidden] {
5266
+ display: none;
5267
+ }
5268
+ /* Layout Studio inside the workspace. */
5269
+ .opf-ws-layouts {
5270
+ display: flex;
5271
+ flex-direction: column;
5272
+ gap: 8px;
5273
+ }
5274
+ .opf-ws-layouts-bar {
5275
+ display: flex;
5276
+ flex: none;
5277
+ flex-wrap: wrap;
5278
+ align-items: center;
5279
+ gap: 8px;
5280
+ }
5281
+ .opf-ws-layouts-bar select,
5282
+ .opf-ws-layouts-bar button {
5283
+ min-height: var(--opf-ui-control-h);
5284
+ border: 1px solid var(--opf-ui-border);
5285
+ border-radius: var(--opf-ui-radius);
5286
+ background: var(--opf-ui-surface);
5287
+ color: var(--opf-ui-fg);
5288
+ font: 500 13px/1 var(--opf-ui-font);
5289
+ }
5290
+ .opf-ws-layouts-bar button {
5291
+ padding: 0 12px;
5292
+ cursor: pointer;
5293
+ }
5294
+ .opf-ws-layouts-bar button:disabled {
5295
+ opacity: 0.45;
5296
+ cursor: default;
5297
+ }
5298
+ .opf-ws-layouts-pick {
5299
+ display: inline-flex;
5300
+ align-items: center;
5301
+ gap: 6px;
5302
+ color: var(--opf-ui-muted-fg);
5303
+ font-size: 12px;
5304
+ }
5305
+ .opf-ws-layouts-status {
5306
+ color: var(--opf-ui-muted-fg);
5307
+ font-size: 12px;
5308
+ }
5309
+ .opf-ws-layouts-host {
5310
+ flex: 1 1 0;
5311
+ min-height: 0;
5312
+ }
5313
+ /* A phone: the parts stack, the source above the slide, the side panel below. */
5314
+ @media (max-width: 760px) {
5315
+ .opf-ws-body {
5316
+ flex-direction: column;
5317
+ overflow: auto;
5318
+ }
5319
+ .opf-ws-source {
5320
+ flex: none;
5321
+ min-width: 0;
5322
+ height: 40vh;
5323
+ border-right: 0;
5324
+ border-bottom: 1px solid var(--opf-ui-border);
5325
+ }
5326
+ .opf-ws-main {
5327
+ flex: none;
5328
+ }
5329
+ .opf-ws-stage {
5330
+ flex: none;
5331
+ min-height: 40vh;
5332
+ }
5333
+ .opf-ws-side {
5334
+ flex: none;
5335
+ border-left: 0;
5336
+ border-top: 1px solid var(--opf-ui-border);
5337
+ }
5338
+ }
5339
+ @media (prefers-reduced-motion: reduce) {
5340
+ .opf-ws-button {
5341
+ transition: none;
5342
+ }
5343
+ }
@@ -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
+ }