@openpresentation/opf-editor 0.29.1 → 0.30.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 CHANGED
@@ -6,6 +6,25 @@ Unfinished prepared shaping work is preserved in the [September 15 roadmap](docs
6
6
 
7
7
  One call mounts the whole editor, over one session and one fonts handle: the source pane, the canvas, the slide row, Present, Export, the sorter, the outline, Layout Studio, Review, Find and autosave, each part optional.
8
8
 
9
+ **Quick start: a deck in a card.** The `embed` layout is the slide alone under a compact bar (the deck's name, Undo and Redo, the slide counter, Design, Expand and Present), for a page that shows a deck:
10
+
11
+ ```js
12
+ import "@openpresentation/opf-editor/styles.css";
13
+ import { createOpfEditor } from "@openpresentation/opf-editor/workspace";
14
+ import { gallery } from "@openpresentation/gallery";
15
+ import { loadPreviewFonts } from "@openpresentation/opf-render/preview-fonts";
16
+
17
+ const editor = createOpfEditor(document.querySelector("#card"), {
18
+ document: deck, catalogs: [gallery], layout: "embed",
19
+ fonts: loadPreviewFonts("/opf-fonts/"), // npx opf-preview-fonts public/opf-fonts
20
+ });
21
+ editor.on("deckName", ({ name }) => (label.textContent = name));
22
+ ```
23
+
24
+ The bar is a list of named items (`toolbar: ["title", "history", "counter", "spacer", "expand"]`), and each item can sit in your own header instead (`toolbar: false`, `slots: { title, history, counter, expand }`) or be replaced by your own controls through the handle (`getDeckName`, `setDeckName`, `undo`, `setSlide`, `expand`, `markSaved`) and its events (`deckName`, `slide`, `dirty`, `save`). The same editor is a tag (`<opf-editor layout="embed" toolbar="title,history,counter,expand">`), a React component and a Svelte store. [docs/chrome-guide.md](docs/chrome-guide.md) walks through each task with code from eight runnable pages in [`examples/chrome/`](examples/chrome) (`pnpm run build:chrome-examples`, then open `artifacts/chrome-examples/index.html` from a static server): the presets and the bar of each, choosing and ordering items, items in your own header, headless, renaming, expand, unsaved changes and Ctrl/Cmd+S, host buttons, restyling with the layered stylesheet and tokens, the web component, React and Svelte, and an iframe.
25
+
26
+ **The whole editor in one call.**
27
+
9
28
  ```js
10
29
  import "@openpresentation/opf-editor/styles.css";
11
30
  import { createOpfEditor } from "@openpresentation/opf-editor/workspace";
@@ -0,0 +1,59 @@
1
+ import type { EditorSession, JsonPatchOperation } from "./index.js";
2
+
3
+ /**
4
+ * The JSON Patch that renames a deck. `current` is the deck's `name` now (`undefined` when it has none); `next` is the text typed, which is
5
+ * trimmed. Returns `null` when nothing changes (the trimmed text equals `current`, or it is empty and there is no name to remove), a
6
+ * `remove` of `/name` for an empty text, and an `add` of `/name` (which also replaces) otherwise.
7
+ */
8
+ export declare function deckNamePatch(current: string | undefined, next: string): JsonPatchOperation[] | null;
9
+
10
+ export interface DeckTitleLabels {
11
+ /** The accessible name of the field. Default: "Presentation name". */
12
+ label?: string;
13
+ /** The placeholder shown while the deck has no name. Default: "Untitled presentation". */
14
+ placeholder?: string;
15
+ }
16
+ export interface DeckTitleRename {
17
+ /** The new trimmed name; `""` when the name was removed. */
18
+ name: string;
19
+ /** The name before the rename; `undefined` when the deck had none. */
20
+ previous: string | undefined;
21
+ }
22
+ export interface DeckTitleOptions {
23
+ /** The editor session: the field shows `snapshot().presentation.name`, follows its changes and renames through `applyPatch`. */
24
+ editor: EditorSession;
25
+ /** Default: "Untitled presentation". Overrides `labels.placeholder`. */
26
+ placeholder?: string;
27
+ /** The accessible name (`aria-label`). Default: "Presentation name". Overrides `labels.label`. */
28
+ label?: string;
29
+ /** The name is shown as text and cannot be edited. Default false. */
30
+ readOnly?: boolean;
31
+ /** `"comfortable"` (default) or `"touch"` (40px field). */
32
+ density?: "comfortable" | "touch";
33
+ labels?: DeckTitleLabels;
34
+ /**
35
+ * Called before a rename is applied. Return `false` to stop it (the workspace uses this to commit an open canvas edit first and stops
36
+ * when that fails). The typed text stays in the field.
37
+ */
38
+ beforeCommit?: () => boolean | void;
39
+ /** Called after a successful rename (not for an unchanged name). */
40
+ onRename?: (rename: DeckTitleRename) => void;
41
+ /** Called when the session refuses the patch; the field shows the current name again. */
42
+ onError?: (error: unknown) => void;
43
+ }
44
+ export interface DeckTitle {
45
+ /** The part's root element (holds the input). */
46
+ readonly element: HTMLElement;
47
+ /** The text field. */
48
+ readonly input: HTMLInputElement;
49
+ setDensity(density: "comfortable" | "touch"): void;
50
+ /** Disabled shows the name as text, like read-only; any typed text is dropped. */
51
+ setDisabled(disabled: boolean): void;
52
+ setReadOnly(readOnly: boolean): void;
53
+ /** Read the name again (not while the person is typing). The field also follows the editor on its own. */
54
+ refresh(): void;
55
+ focus(): void;
56
+ /** Remove the element and the editor subscription. Safe twice. */
57
+ destroy(): void;
58
+ }
59
+ export declare function createDeckTitle(container: HTMLElement, options: DeckTitleOptions): DeckTitle;
@@ -0,0 +1,184 @@
1
+ // Deck title (CE-28): the presentation's `name`, edited in place. It reads as plain heading text until it is hovered or focused, then shows a
2
+ // border and a focus ring. Enter or blur commits through the editor session as one undoable edit (an empty name removes the key); Escape puts the
3
+ // current name back. The field follows the document (undo, the source pane, the host) unless the person is typing. Plain DOM, no framework, no network.
4
+
5
+ const DEFAULT_LABELS = {
6
+ /** The accessible name of the field. */
7
+ label: "Presentation name",
8
+ /** The placeholder shown while the deck has no name. */
9
+ placeholder: "Untitled presentation",
10
+ };
11
+
12
+ const MIN_CHARS = 6;
13
+ const MAX_CHARS = 120;
14
+
15
+ /**
16
+ * The JSON Patch that renames a deck: `current` is the deck's `name` now (`undefined` when it has none), `next` the text the person typed.
17
+ * The text is trimmed. Returns `null` when nothing changes: the trimmed text equals `current`, or it is empty and there is no name to remove.
18
+ * A non-empty name is an `add` (which also replaces); an empty one removes the key, so the deck falls back to "no name".
19
+ */
20
+ export function deckNamePatch(current, next) {
21
+ const name = String(next ?? "").trim();
22
+ const before = typeof current === "string" ? current : undefined;
23
+ if (name === "") return before === undefined ? null : [{ op: "remove", path: "/name" }];
24
+ if (name === before) return null;
25
+ return [{ op: "add", path: "/name", value: name }];
26
+ }
27
+
28
+ /**
29
+ * Mount the editable deck name into `container`.
30
+ *
31
+ * Options: `editor` (an editor session), `placeholder`, `label` (the accessible name), `readOnly`, `density` (`"comfortable"` or `"touch"`),
32
+ * `labels` (`label`, `placeholder`), `beforeCommit()` (return `false` to stop the rename, for example when committing an open canvas edit
33
+ * failed; the typed text stays), `onRename({ name, previous })` (after a successful rename; `name` is `""` when the name was removed) and
34
+ * `onError(error)` (when the session refuses the patch; the field shows the current name again).
35
+ *
36
+ * Returns `{ element, input, setDensity(d), setDisabled(b), setReadOnly(b), refresh(), focus(), destroy() }`.
37
+ */
38
+ export function createDeckTitle(container, options = {}) {
39
+ if (!container?.ownerDocument) throw new TypeError("A DOM container is required.");
40
+ const { editor } = options;
41
+ if (!editor || typeof editor.snapshot !== "function" || typeof editor.applyPatch !== "function") throw new TypeError("An editor session is required.");
42
+ const doc = container.ownerDocument;
43
+ const labels = { ...DEFAULT_LABELS, ...(options.labels ?? {}) };
44
+ const placeholder = options.placeholder ?? labels.placeholder;
45
+ const label = options.label ?? labels.label;
46
+ let readOnly = Boolean(options.readOnly);
47
+ let disabled = false;
48
+ let editing = false;
49
+ let painted = "";
50
+ let disposed = false;
51
+
52
+ const element = doc.createElement("div");
53
+ element.className = "opf-title";
54
+ const input = doc.createElement("input");
55
+ input.type = "text";
56
+ input.className = "opf-title-input";
57
+ input.placeholder = placeholder;
58
+ input.setAttribute("aria-label", label);
59
+ input.setAttribute("autocomplete", "off");
60
+ input.setAttribute("autocapitalize", "off");
61
+ input.spellcheck = false;
62
+ element.append(input);
63
+ container.append(element);
64
+
65
+ const currentName = () => {
66
+ const name = editor.snapshot().presentation?.name;
67
+ return typeof name === "string" ? name : undefined;
68
+ };
69
+ function fit() {
70
+ // `field-sizing: content` sizes the field in current browsers; `size` is the fallback elsewhere.
71
+ const length = (input.value || placeholder).length;
72
+ input.size = Math.max(MIN_CHARS, Math.min(MAX_CHARS, length));
73
+ }
74
+ function paintState() {
75
+ input.readOnly = readOnly || disabled;
76
+ if (disabled) input.setAttribute("aria-disabled", "true");
77
+ else input.removeAttribute("aria-disabled");
78
+ element.dataset.state = disabled ? "disabled" : readOnly ? "readonly" : "editable";
79
+ input.title = input.value;
80
+ }
81
+ function paint() {
82
+ const name = currentName() ?? "";
83
+ painted = name;
84
+ if (input.value !== name) input.value = name;
85
+ paintState();
86
+ fit();
87
+ }
88
+ function refresh() {
89
+ if (disposed || editing) return;
90
+ paint();
91
+ }
92
+
93
+ // Put the document's name back in the field and stop the edit.
94
+ function revert() {
95
+ editing = false;
96
+ paint();
97
+ }
98
+ // Commit the typed text. Returns "done" (applied, unchanged or refused: the field shows the document again) or "aborted" (`beforeCommit` said no).
99
+ function commit() {
100
+ if (!editing && input.value === painted) return "done";
101
+ if (options.beforeCommit && options.beforeCommit() === false) return "aborted";
102
+ const previous = currentName();
103
+ const patch = deckNamePatch(previous, input.value);
104
+ editing = false;
105
+ if (!patch) {
106
+ paint();
107
+ return "done";
108
+ }
109
+ const name = patch[0].op === "remove" ? "" : patch[0].value;
110
+ try {
111
+ // The session's own listeners repaint this field during the call (nothing is being typed any more).
112
+ editor.applyPatch(patch, { source: "deck-name", rejectInvalid: true });
113
+ } catch (error) {
114
+ paint();
115
+ options.onError?.(error);
116
+ return "done";
117
+ }
118
+ paint();
119
+ options.onRename?.({ name, previous });
120
+ return "done";
121
+ }
122
+
123
+ input.addEventListener("input", () => {
124
+ editing = true;
125
+ input.title = input.value;
126
+ fit();
127
+ });
128
+ input.addEventListener("keydown", (event) => {
129
+ if (readOnly || disabled) return;
130
+ // Enter that confirms an IME candidate is not a commit (Safari reports it with keyCode 229 after compositionend).
131
+ if (event.isComposing || event.keyCode === 229) return;
132
+ if (event.key === "Enter") {
133
+ event.preventDefault();
134
+ // The focus stays in the field (the name selected, so typing again replaces it); Escape or Tab leaves it. Moving it would drop it on
135
+ // the page's body, where neither the editor's keys nor a screen reader's place in the bar survive.
136
+ if (commit() === "done") input.select();
137
+ } else if (event.key === "Escape") {
138
+ event.preventDefault();
139
+ event.stopPropagation();
140
+ revert();
141
+ input.blur();
142
+ }
143
+ });
144
+ input.addEventListener("blur", () => {
145
+ if (readOnly || disabled) return;
146
+ commit();
147
+ });
148
+ input.addEventListener("focus", () => {
149
+ if (!readOnly && !disabled) input.select();
150
+ });
151
+
152
+ paint();
153
+ const unsubscribe = typeof editor.subscribe === "function" ? editor.subscribe(() => refresh()) : undefined;
154
+
155
+ const handle = {
156
+ element,
157
+ input,
158
+ setDensity(density) {
159
+ element.dataset.density = density === "touch" ? "touch" : "comfortable";
160
+ },
161
+ setDisabled(value) {
162
+ disabled = Boolean(value);
163
+ if (disabled) revert();
164
+ paintState();
165
+ },
166
+ setReadOnly(value) {
167
+ readOnly = Boolean(value);
168
+ if (readOnly) revert();
169
+ paintState();
170
+ },
171
+ refresh,
172
+ focus() {
173
+ if (!disposed) input.focus();
174
+ },
175
+ destroy() {
176
+ if (disposed) return;
177
+ disposed = true;
178
+ unsubscribe?.();
179
+ element.remove();
180
+ },
181
+ };
182
+ handle.setDensity(options.density);
183
+ return handle;
184
+ }
@@ -0,0 +1,90 @@
1
+ import type { OpfIcons } from "./icons.js";
2
+ import type { Catalog } from "@openpresentation/opf";
3
+ import type { EditorSession } from "./index.js";
4
+
5
+ export interface DesignMenuLabels {
6
+ /** The visible text of the toolbar button. Default: "Design". */
7
+ trigger?: string;
8
+ /** The accessible name of the popover, and the title of the bottom sheet on a phone. Default: "Design". */
9
+ title?: string;
10
+ /** Default: "Theme". */
11
+ theme?: string;
12
+ /** The color scheme section. Default: "Colors". */
13
+ colors?: string;
14
+ /** The font scheme section. Default: "Fonts". */
15
+ fonts?: string;
16
+ /** The section for the current slide's layout. Default: "This slide: Layout". */
17
+ layout?: string;
18
+ /** The row that opens the full Design panel (only shown when `onOpenPanel` is given). Default: "More design options…". */
19
+ more?: string;
20
+ /** What the toolbar's overflow menu lists (`menuItems()`). Default: "Design…". */
21
+ menuItem?: string;
22
+ /** The accessible name of the sheet's close button. Default: "Close". */
23
+ close?: string;
24
+ /** Shown when no catalog offers any choice. */
25
+ empty?: string;
26
+ /** The status line after a choice. `kind` is "theme", "colors", "fonts" or "layout". Default: "Theme set to Classic.". */
27
+ applied?: (kind: "theme" | "colors" | "fonts" | "layout", name: string) => string;
28
+ /** The status line when the choice was already set. */
29
+ already?: (name: string) => string;
30
+ /** Appended to the status line when slides keep their own value of a deck-level choice. */
31
+ shadowed?: (count: number) => string;
32
+ }
33
+
34
+ export interface DesignMenuOptions {
35
+ /** The editor session. Every choice is one validated, undoable edit with `source: "design-menu"`. */
36
+ editor: EditorSession;
37
+ /** The catalogs that supply the choices (the workspace's merged `catalogs()`). Default: the session's registered catalogs. */
38
+ catalogs?: readonly Catalog[] | (() => readonly Catalog[]);
39
+ /** The slide whose layout the layout section shows and changes (0-based). Default: slide 0. */
40
+ getSlideIndex?: () => number;
41
+ /** `"comfortable"` (default) or `"touch"` (40px rows; a coarse pointer always gets 44px). */
42
+ density?: "comfortable" | "touch";
43
+ labels?: DesignMenuLabels;
44
+ /**
45
+ * Replace an icon: `trigger` (the palette on the button), `close` (the X in the phone sheet) and `check` (the mark on the chosen row). See `OpfIcon`.
46
+ * The icons carry `class="opf-icon"` and `data-opf-icon`.
47
+ */
48
+ icons?: OpfIcons<"trigger" | "close" | "check">;
49
+ /** True to disable the button (the document cannot be edited). Default false. */
50
+ readOnly?: boolean;
51
+ /** Adds a "More design options…" row that closes the menu and calls this, to open the host's full Design panel. Hidden when not given. */
52
+ onOpenPanel?: () => void;
53
+ /** Receives a short message after every choice (also announced in a polite live region inside the menu). */
54
+ onStatus?: (message: string) => void;
55
+ }
56
+
57
+ export interface DesignMenuItem {
58
+ id: "design";
59
+ label: string;
60
+ disabled: boolean;
61
+ run(): void;
62
+ }
63
+
64
+ export interface DesignMenu {
65
+ /** The part's root: the button and its popover. */
66
+ readonly element: HTMLElement;
67
+ /** The toolbar button. */
68
+ readonly trigger: HTMLButtonElement;
69
+ setDensity(density: "comfortable" | "touch"): void;
70
+ /** Greys the button (and closes the menu). The editor does this while the source is invalid. */
71
+ setDisabled(disabled: boolean): void;
72
+ setReadOnly(readOnly: boolean): void;
73
+ /** Read the document and catalogs again. The open menu also follows the editor session on its own. */
74
+ refresh(): void;
75
+ /** Open the popover and focus the current theme. From a button that is not on screen (the overflow menu) it opens as a sheet. */
76
+ open(): void;
77
+ /** Close the popover and return focus to the button (or to what had it when the menu opened). */
78
+ close(options?: { restoreFocus?: boolean }): void;
79
+ isOpen(): boolean;
80
+ /** What this part offers the toolbar's overflow menu: `[{ id: "design", label: "Design…", run: open }]`. */
81
+ menuItems(): DesignMenuItem[];
82
+ /** Removes the element and every listener. Safe to call twice. */
83
+ destroy(): void;
84
+ }
85
+
86
+ /**
87
+ * The Design quick menu: a toolbar button and a compact popover for Theme, Colors, Fonts and the current slide's Layout, for embeds that have no room
88
+ * for the full Design panel. Choosing applies at once (one undoable edit) and keeps the menu open. On a phone (`max-width: 600px`) the popover is a bottom sheet.
89
+ */
90
+ export declare function createDesignMenu(container: HTMLElement, options: DesignMenuOptions): DesignMenu;