@openpresentation/opf-editor 0.26.9 → 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/README.md +58 -4
- package/dist/canvas-variables.d.ts +15 -0
- package/dist/canvas-variables.js +52 -0
- package/dist/canvas.js +33 -5
- package/dist/index.d.ts +37 -1
- package/dist/index.js +50 -3
- package/dist/presenter.js +63 -26
- package/dist/react.d.ts +34 -0
- package/dist/react.js +120 -0
- package/dist/slide-retention.js +13 -0
- package/dist/styles.css +330 -5
- package/dist/workspace-layouts.d.ts +31 -0
- package/dist/workspace-layouts.js +84 -0
- package/dist/workspace-measure.js +35 -0
- package/dist/workspace-shell.js +96 -0
- package/dist/workspace-source.js +83 -0
- package/dist/workspace-theme.js +142 -0
- package/dist/workspace.d.ts +388 -0
- package/dist/workspace.js +1261 -0
- package/package.json +26 -8
package/dist/react.d.ts
CHANGED
|
@@ -30,3 +30,37 @@ export declare function createOPFReactComponents(React: {
|
|
|
30
30
|
OPFTextInput(props: OPFTextInputProps): unknown;
|
|
31
31
|
OPFCatalogSelect(props: OPFCatalogSelectProps): unknown;
|
|
32
32
|
};
|
|
33
|
+
|
|
34
|
+
// ---- CE-06: the embeddable editor ---------------------------------------------------------------------------------------------------------
|
|
35
|
+
import type { CSSProperties, ForwardRefExoticComponent, MutableRefObject, RefAttributes } from "react";
|
|
36
|
+
import type { OpfEditor as OpfEditorHandle, OpfEditorOptions, OpfEditorSlots } from "./workspace.js";
|
|
37
|
+
|
|
38
|
+
/** A slot is an element, or a React ref to one (resolved when the editor mounts). */
|
|
39
|
+
export type OpfEditorReactSlots = { [Name in keyof OpfEditorSlots]?: OpfEditorSlots[Name] | { current: OpfEditorSlots[Name] | null } };
|
|
40
|
+
|
|
41
|
+
export interface OpfEditorProps extends Omit<OpfEditorOptions, "slots"> {
|
|
42
|
+
slots?: OpfEditorReactSlots;
|
|
43
|
+
className?: string;
|
|
44
|
+
style?: CSSProperties;
|
|
45
|
+
id?: string;
|
|
46
|
+
/** Called with the editor right after it is created, and with `null` when it is destroyed. For the hooks below. */
|
|
47
|
+
onEditor?: (editor: OpfEditorHandle | null) => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The editor in a `<div>`. Every other prop is an option of `createOpfEditor`, read once when it mounts (give the component another `key` to mount a
|
|
52
|
+
* new one); `theme`, `readOnly`, `view`, `density` and `exportMode` follow later renders, and the callbacks are always the latest. `ref` is the
|
|
53
|
+
* editor handle once mounted. Correct under StrictMode; renders an empty container on the server.
|
|
54
|
+
*/
|
|
55
|
+
export declare const OpfEditor: ForwardRefExoticComponent<OpfEditorProps & RefAttributes<OpfEditorHandle | null>>;
|
|
56
|
+
|
|
57
|
+
/** The editor as a hook: `const { ref, editor } = useOpfEditor(options)`, then `<div ref={ref} />`. `editor` is `null` on the server and until mounted. */
|
|
58
|
+
export declare function useOpfEditor(options?: OpfEditorProps): { ref: MutableRefObject<HTMLDivElement | null>; editor: OpfEditorHandle | null };
|
|
59
|
+
|
|
60
|
+
/** The document of an editor, replaced on every change; `undefined` while `editor` is `null`. Frozen: do not modify it. */
|
|
61
|
+
export declare function useOpfDocument(editor: OpfEditorHandle | null | undefined): unknown;
|
|
62
|
+
|
|
63
|
+
/** The content last selected on the canvas, or `null`. */
|
|
64
|
+
export declare function useOpfSelection(editor: OpfEditorHandle | null | undefined): { path: string; value: unknown } | null;
|
|
65
|
+
|
|
66
|
+
export type { OpfEditorHandle };
|
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 {
|
|
@@ -596,6 +630,10 @@
|
|
|
596
630
|
--opf-lx-slot: 20px;
|
|
597
631
|
--opf-lx-pad: 16px;
|
|
598
632
|
--opf-lx-chip: calc(var(--opf-ui-control-h) - 4px);
|
|
633
|
+
/* Compact controls sit a little under --opf-lx-h so they sit quietly beside it: the zoom buttons, the validity pill, the segments. */
|
|
634
|
+
--opf-lx-zoom-btn: calc(var(--opf-lx-h) - 4px);
|
|
635
|
+
--opf-lx-pill: calc(var(--opf-lx-h) - 6px);
|
|
636
|
+
--opf-lx-seg: calc(var(--opf-lx-h) - 2px);
|
|
599
637
|
font-family: var(--opf-ui-font);
|
|
600
638
|
font-size: 13px;
|
|
601
639
|
color: var(--opf-ui-fg);
|
|
@@ -667,7 +705,7 @@
|
|
|
667
705
|
display: inline-flex;
|
|
668
706
|
align-items: center;
|
|
669
707
|
gap: 6px;
|
|
670
|
-
height:
|
|
708
|
+
height: var(--opf-lx-pill);
|
|
671
709
|
padding: 0 10px;
|
|
672
710
|
background: transparent;
|
|
673
711
|
border: 1px solid var(--opf-ui-border);
|
|
@@ -1081,8 +1119,8 @@
|
|
|
1081
1119
|
display: inline-flex;
|
|
1082
1120
|
align-items: center;
|
|
1083
1121
|
justify-content: center;
|
|
1084
|
-
width:
|
|
1085
|
-
height:
|
|
1122
|
+
width: var(--opf-lx-zoom-btn);
|
|
1123
|
+
height: var(--opf-lx-zoom-btn);
|
|
1086
1124
|
padding: 0;
|
|
1087
1125
|
background: transparent;
|
|
1088
1126
|
border: 0;
|
|
@@ -1271,7 +1309,7 @@ input.opf-lx-input[data-field="max"] { font-family: var(--opf-ui-mono); font-siz
|
|
|
1271
1309
|
display: inline-flex;
|
|
1272
1310
|
align-items: center;
|
|
1273
1311
|
gap: 6px;
|
|
1274
|
-
height:
|
|
1312
|
+
height: var(--opf-lx-seg);
|
|
1275
1313
|
padding: 0 12px;
|
|
1276
1314
|
background: transparent;
|
|
1277
1315
|
border: 0;
|
|
@@ -1354,6 +1392,30 @@ input.opf-lx-input[data-field="max"] { font-family: var(--opf-ui-mono); font-siz
|
|
|
1354
1392
|
.opf-lx-canvas[data-density="touch"] .opf-lx-label { --opf-lx-chip-h: 19px; font-size: 12px; line-height: 18px; }
|
|
1355
1393
|
.opf-lx[data-density="touch"] .opf-lx-actions { grid-template-columns: minmax(0, 1fr); }
|
|
1356
1394
|
|
|
1395
|
+
/* A coarse pointer (a finger): every control sized from these tokens is a 44px target, at any width. --opf-lx-h already follows
|
|
1396
|
+
--opf-ui-control-h (44px under a coarse pointer, tokens.css); the compact controls that sit a few pixels under it, and the rows and
|
|
1397
|
+
chips, take its full height. A fine pointer keeps the compact look. Fields and the other controls that size themselves: touch.css. */
|
|
1398
|
+
@media (pointer: coarse) {
|
|
1399
|
+
.opf-lx,
|
|
1400
|
+
.opf-lx-palette,
|
|
1401
|
+
.opf-lx-canvas,
|
|
1402
|
+
.opf-lx-layers,
|
|
1403
|
+
.opf-lx-inspector {
|
|
1404
|
+
--opf-lx-row: var(--opf-lx-h);
|
|
1405
|
+
--opf-lx-chip: var(--opf-lx-h);
|
|
1406
|
+
--opf-lx-zoom-btn: var(--opf-lx-h);
|
|
1407
|
+
--opf-lx-pill: var(--opf-lx-h);
|
|
1408
|
+
--opf-lx-seg: var(--opf-lx-h);
|
|
1409
|
+
}
|
|
1410
|
+
/* A checkbox is hit through its label: the label is the 44px target and the box a little larger (as .opf-dc-check in touch.css). */
|
|
1411
|
+
.opf-lx-field-check .opf-lx-label-text {
|
|
1412
|
+
display: flex;
|
|
1413
|
+
align-items: center;
|
|
1414
|
+
min-height: var(--opf-lx-h);
|
|
1415
|
+
}
|
|
1416
|
+
.opf-lx-check { width: 22px; height: 22px; }
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1357
1419
|
@media (prefers-reduced-motion: reduce) {
|
|
1358
1420
|
.opf-lx *,
|
|
1359
1421
|
.opf-lx-palette *,
|
|
@@ -5016,3 +5078,266 @@ input.opf-lx-input[data-field="max"] { font-family: var(--opf-ui-mono); font-siz
|
|
|
5016
5078
|
min-height: 44px;
|
|
5017
5079
|
}
|
|
5018
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
|
+
}
|