@openpresentation/opf-editor 0.24.2 → 0.25.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 +124 -2
- package/dist/galleries.js +2 -2
- package/dist/layout-canvas.js +748 -0
- package/dist/layout-diagram.js +25 -0
- package/dist/layout-editor-ui.js +232 -0
- package/dist/layout-editor.d.ts +212 -0
- package/dist/layout-editor.js +197 -0
- package/dist/layout-inspector.js +339 -0
- package/dist/layout-layers.js +331 -0
- package/dist/layout-model.js +299 -0
- package/dist/layout-palette.js +77 -0
- package/dist/layout-samples.js +84 -0
- package/dist/layout-templates.d.ts +32 -8
- package/dist/layout-templates.js +89 -37
- package/dist/layout-tree.d.ts +87 -0
- package/dist/layout-tree.js +704 -0
- package/dist/presenter.d.ts +105 -0
- package/dist/presenter.js +431 -0
- package/dist/read-deck-file.d.ts +59 -0
- package/dist/read-deck-file.js +142 -0
- package/dist/slide-manager.js +3 -7
- package/dist/styles.css +1003 -25
- package/package.json +25 -10
- package/dist/custom-layout-editor.d.ts +0 -7
- package/dist/custom-layout-editor.js +0 -105
package/README.md
CHANGED
|
@@ -121,6 +121,67 @@ Methods: `setFindings(list | null)`, `setFormat(format)`, `focusPath(pointer)` (
|
|
|
121
121
|
|
|
122
122
|
Icons are inline paths from [Lucide](https://lucide.dev) (ISC license). The harness is `examples/source-pane.html` (built by `npm run build:playground`); `npm run test:source-pane-browser` drives it.
|
|
123
123
|
|
|
124
|
+
## Layout editor (RR-99)
|
|
125
|
+
|
|
126
|
+
`createLayoutEditor` from `@openpresentation/opf-editor/layout-editor` is a Figma-style GUI for building one layout record: a `direction` plus an ordered `regions` list of regions and splits (FA-33). It is plain DOM, styled only by `--opf-ui-*` tokens (`src/styles/layout-editor.css`, shipped in `styles.css`), and makes no network request. It is built from four parts that are exported separately, so a host can arrange them differently:
|
|
127
|
+
|
|
128
|
+
| Part | Export | Prefix |
|
|
129
|
+
| --- | --- | --- |
|
|
130
|
+
| Tool palette | `createLayoutToolPalette` | `opf-lx-palette`, `opf-lx-tool` |
|
|
131
|
+
| Canvas: a 16:9 slide frame with the region tree as labeled boxes | `createLayoutCanvas` | `opf-lx-canvas`, `opf-lx-node` |
|
|
132
|
+
| Layers: the tree of splits and regions, with the findings | `createLayoutTree` | `opf-lx-layers`, `opf-lx-tree` |
|
|
133
|
+
| Inspector: the properties of the selection | `createLayoutInspector` | `opf-lx-inspector` |
|
|
134
|
+
|
|
135
|
+
```js
|
|
136
|
+
import { createLayoutEditor } from '@openpresentation/opf-editor/layout-editor';
|
|
137
|
+
import { toSvg } from '@openpresentation/opf-render/svg';
|
|
138
|
+
|
|
139
|
+
const layoutEditor = createLayoutEditor(container, {
|
|
140
|
+
record, // { id, name, direction, regions: [...] }; default: a title and one content region
|
|
141
|
+
onChange: (record) => save(record), // after each edit, undo and redo (not after setRecord)
|
|
142
|
+
density: 'comfortable', // 'touch' makes the controls 40px
|
|
143
|
+
toSvg, fonts, // optional: Preview draws the sample slide with the host's renderer and fonts handle
|
|
144
|
+
});
|
|
145
|
+
layoutEditor.getRecord(); // a copy of the record
|
|
146
|
+
layoutEditor.setRecord(other); // load a layout; clears the selection and the history
|
|
147
|
+
layoutEditor.getFindings(); // core's findings: { ruleId, severity, message, path, node, field }
|
|
148
|
+
layoutEditor.saveToDeck(session); // writes catalogs.custom.layouts through saveCustomLayout, one undoable edit
|
|
149
|
+
layoutEditor.applyToSlide(session, 0); // saves, then makes it slide 0's layout, in one undoable edit
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
To arrange the parts yourself, create one model and pass it to each part; `saveLayoutToDeck(session, model)` and `applyLayoutToSlide(session, model, slideIndex)` are the deck functions the composed editor calls.
|
|
153
|
+
|
|
154
|
+
```js
|
|
155
|
+
import { createLayoutModel, createLayoutToolPalette, createLayoutCanvas, createLayoutTree, createLayoutInspector } from '@openpresentation/opf-editor/layout-editor';
|
|
156
|
+
const model = createLayoutModel({ record });
|
|
157
|
+
createLayoutToolPalette(paletteHost, { model, orientation: 'horizontal' });
|
|
158
|
+
createLayoutCanvas(canvasHost, { model });
|
|
159
|
+
createLayoutTree(layersHost, { model });
|
|
160
|
+
createLayoutInspector(inspectorHost, { model });
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
**Tools** (Figma-style shortcuts; the palette buttons have the same names): Select (V), Split into a row (R), Split into a column (C), Insert region (I), Heading regions (T), Hand (H, or hold Space) and Preview (P).
|
|
164
|
+
- **Select:** click a region to select it; click again inside a selected split to select what is inside. Esc selects the parent. Shift-click adds a sibling. Double-click or F2 renames in place.
|
|
165
|
+
- **Split into a row or column:** click a region. The original keeps its name and rules and the new one is called `region-N`; the tool returns to Select. In a list that already runs along that axis the new region is a sibling that shares the original's size, otherwise a split holds both. With a split tool active, Enter splits the selected region.
|
|
166
|
+
- **Insert region:** slots appear between siblings and at a split's edges; click one (or Tab to it and press Enter).
|
|
167
|
+
- **Heading regions:** a menu to add or remove the `title` and `subtitle` regions. Without a title region the engine adds an implicit title row, drawn as a dashed band.
|
|
168
|
+
- **Hand:** pan a zoomed slide (zoom buttons, Ctrl or Cmd with the wheel).
|
|
169
|
+
- **Preview:** placeholder blocks of each region's accepted kinds. With the host's `toSvg` (and its `fonts` handle) the sample slide is drawn by the renderer behind the region outlines; without them a wireframe is drawn. A layout with an error finding shows the wireframe.
|
|
170
|
+
|
|
171
|
+
**Direct manipulation on the canvas:**
|
|
172
|
+
- **Resize:** drag the divider between siblings. With other siblings the pair keeps its joint size and snaps to whole numbers and halves; alone in its list it snaps to the nearest whole-number ratio. The ratio shows while dragging ("2 : 1"); Shift skips snapping. Double-click a divider to make both sides equal. A divider next to an `auto` size is not a share and is fixed.
|
|
173
|
+
- **Reorder and move:** drag a region (or several selected siblings) before or after a sibling, or into another split, with a drop indicator; a refused drop (too deep, a split into itself) shows the reason. The same drag works in the layers panel.
|
|
174
|
+
- **Delete:** Delete or Backspace. A split left with one child collapses into it, and the child takes the split's size and inherited anchor. The last region of a layout is kept.
|
|
175
|
+
- **Context menu** (right click, the menu key or Shift+F10): wrap in row, wrap in column, unwrap, duplicate, delete, rename.
|
|
176
|
+
|
|
177
|
+
**Inspector:** with nothing selected, the layout (`id`, `name`, `summary`, `direction`, a default `anchor` that inherits down, `overflowRegion` as a picker over the body regions, and the design hints the record carries, which can be removed). A split has a `direction` toggle, `size` and `anchor`. A region has `name` (unique), `size` (a number, or `auto` only inside a column), `accepts` as chips (none selected means every kind), `role`, `flow`, `max`, `anchor` (shown as "Inherited" with its source while unset), `bleed`, `listColumns` and `empty`. A heading region takes only a size.
|
|
178
|
+
|
|
179
|
+
**Validation and history:** every edit is checked live by core (`opf/layout-template` and `opf/layout-region`, plus the schema); the finding's region is highlighted on the canvas and in the layers panel, its message is under the field, and the findings are listed under the layers (click one to select its region). Edits that would break the layout (more than three levels, more than twelve entries, a grid over 12 by 12 tracks) are refused with the reason. The editor has its own undo and redo (Cmd or Ctrl+Z, Shift+Z; the buttons in the top bar); one drag is one step and typing a value is one step. `saveToDeck` refuses a layout with an error finding and an id the deck already has, unless `replace: true`.
|
|
180
|
+
|
|
181
|
+
**Keyboard:** arrows move between siblings, Enter goes into a split and Esc goes up; `[` and `]` move a region earlier or later (at the end of a split it leaves the split); Shift with a bracket moves it into the neighboring split; Cmd or Ctrl+D duplicates; Delete deletes. A divider is `role="separator"` with `aria-valuenow` (the first side's share of the pair, in percent) and `aria-valuetext` ("2 : 1"); Arrow keys resize it by half a share (Shift: one), Enter makes the sides equal. The layers panel is `role="tree"` (Up and Down, Right to open or enter, Left to close or go to the parent, Home, End, F2). Every action is also a button in the inspector. Touch density makes controls 40px and the divider hit area wider; below 760px the palette is a row above the canvas and Layers and Inspector are tabs under it.
|
|
182
|
+
|
|
183
|
+
The headless operations the editor runs are in `@openpresentation/opf-editor/layout-tree` (`splitRegion`, `wrapNodes`, `unwrapNode`, `moveNodes`, `deleteNodes`, `duplicateNodes`, `resizeSizes`, `layoutBoxes` and more): pure functions that return a new record and the paths to select, or throw a `LayoutOperationError` with the reason, and keep a core-valid record core-valid. `npm run test:layout-editor-browser` drives `examples/layout-editor.html` (built by `npm run build:playground`); the playground's Layouts view mounts the same editor on the presentation's custom layouts. Icons are inline paths from [Lucide](https://lucide.dev) (ISC license).
|
|
184
|
+
|
|
124
185
|
## Scope
|
|
125
186
|
|
|
126
187
|
- Package: `@openpresentation/opf-editor`
|
|
@@ -508,9 +569,9 @@ const manager = createSlideManager(document.querySelector("#slide-list"), {
|
|
|
508
569
|
editor.subscribe(() => manager.render());
|
|
509
570
|
```
|
|
510
571
|
|
|
511
|
-
OPF
|
|
572
|
+
OPF layouts are one ordered list of named content regions, nested with splits for two dimensions (a record has a `direction` and `regions`). The slide manager's `openLayoutPicker("apply")` groups the 28 built-ins by intent and applies one to the current slide; `openLayoutPicker()` adds a slide. Pass `renderLayoutPreview(presentation, slideIndex, choice)` to render the current slide with each candidate through the shared renderer. The picker shows the record's `preview` when supplied, otherwise a diagram of its regions and splits. It retains content when switching.
|
|
512
573
|
|
|
513
|
-
|
|
574
|
+
Layouts are built with the layout editor (see [Layout editor](#layout-editor-rr-99)); it replaces the earlier `openCustomLayoutEditor` dialog. The headless `layoutFromRegions`, `prepareCustomLayout`, `saveCustomLayout` (with `{ replace: true }` to edit a layout the deck already has) and `pinBlockToRegion` operations are in `/layout-templates`. Canvas Arrange mode shows every template region; empty ones say “Drop content here”, and a top-level block can be dragged or chosen by keyboard for a validated region pin. A full or incompatible region refuses the edit instead of silently ignoring the pin. The host registers the 0.19 catalog with the session; the editor ships no catalog data.
|
|
514
575
|
|
|
515
576
|
`@openpresentation/opf-editor/outline` and `/outline-view` edit the deck as an outline: a row per slide title, subtitle, text paragraph and list item, and a read-only row for content that is not text (a chart, a table, an image) or text that carries formatting, so nothing is flattened away. Typing commits as one change when the row loses focus or on Enter. Enter adds a line (after a title: a slide), Alt+Up and Alt+Down move a bullet with the bullets nested under it, or a slide with its text, Alt+Shift+Right demotes and Alt+Shift+Left promotes. Demoting a bullet nests it (`level`); promoting a top-level bullet turns it into a new slide that takes the bullets after it; demoting a plain slide makes it a bullet of the slide before it (refused, with the reason, when that would drop content such as notes, blocks or a design). Backspace on an empty line removes it. Tab keeps moving focus, so the outline is no keyboard trap.
|
|
516
577
|
|
|
@@ -831,6 +892,67 @@ const dialog = createAgentHandoffDialog(document.body, { document: deck, target:
|
|
|
831
892
|
|
|
832
893
|
Icons are inlined path data from [Lucide](https://lucide.dev) (ISC license), never loaded from a CDN.
|
|
833
894
|
|
|
895
|
+
## Presenter and Present button (RR-98)
|
|
896
|
+
|
|
897
|
+
`@openpresentation/opf-editor/presenter` shows the deck full screen, one slide at a time. It is plain DOM like the other modules, with no network, no fonts or CSS fetched at runtime, and no second renderer: slides are drawn by `toSvg` with the same fonts handle, catalogs and render options the canvas and the thumbnails use. Its CSS is `src/styles/presenter.css` (classes `opf-presenter-*` and `opf-present-button`; `--opf-ui-*` tokens only, part of `styles.css`). The module needs `@openpresentation/opf-render`, an optional peer, like the thumbnail strip.
|
|
898
|
+
|
|
899
|
+
```js
|
|
900
|
+
import { createPresenter, createPresentButton, bindPresentShortcut } from "@openpresentation/opf-editor/presenter";
|
|
901
|
+
|
|
902
|
+
const presenter = createPresenter(document.body, {
|
|
903
|
+
editor, // or presentation: deck (an object, or a function returning it)
|
|
904
|
+
fonts, // the renderer's fonts handle, the same one the canvas and the exports use
|
|
905
|
+
renderOptions: { catalogs }, // merged after the session's catalogs
|
|
906
|
+
slideIndex: current, // the slide start() opens on (default 0)
|
|
907
|
+
onSlideChange: (index) => {}, // the shown slide changed (deck index)
|
|
908
|
+
onExit: (index) => { current = index; redraw(); }, // by any route: the host returns to that slide
|
|
909
|
+
density: "comfortable", // "touch": the exit button is 40px
|
|
910
|
+
});
|
|
911
|
+
createPresentButton(document.querySelector("#present"), { onPresent: () => presenter.start(current) });
|
|
912
|
+
bindPresentShortcut(document, () => presenter.start(current)); // F5
|
|
913
|
+
```
|
|
914
|
+
|
|
915
|
+
`createPresenter(container, options)` returns `{ element, start(slideIndex?), stop(), next(), previous(), goTo(index), getSlide(), isRunning(), destroy() }`. The presenter stays hidden until `start`. Call `start` from a user gesture (a click or a key press): the browser grants full screen only then. `start` resolves `"fullscreen"` or `"overlay"`.
|
|
916
|
+
|
|
917
|
+
- **Display.** The presenter's own element goes full screen through the Fullscreen API. When the browser refuses (no gesture, an iframe without `allow="fullscreen"`, iPhone Safari), the same element is a fixed overlay over the window, so everything below works the same. Slides fill the viewport and letterbox on black. The next and previous slides are drawn ahead, so advancing draws nothing. Slides draw with their `{{variables}}` resolved, as the exports do.
|
|
918
|
+
- **Navigation.** Right, Space, PageDown and Enter go forward; Left, PageUp and Backspace go back; Home and End jump; Esc exits. A click advances, and a click on the left third goes back. On touch, a swipe left goes forward and a swipe right goes back; a tap uses the same zones. Keys with Ctrl, Alt or Meta are left to the browser, and the keys the presenter handles do not reach the host page.
|
|
919
|
+
- **Hidden slides** are skipped, as the exports skip them (`slidesToConvert`; `includeHidden: true` presents them). `start(i)` and `goTo(i)` on a hidden slide show the next slide that is not hidden (`onSlideChange` says which). A deck with only hidden slides shows a message and `onExit(-1)`.
|
|
920
|
+
- **Counter.** A subtle "03 / 12" (mono, the navigator's format; hidden slides are not counted) and the exit button show on pointer move and hide after 2 seconds, with the cursor.
|
|
921
|
+
- **Exit.** Esc, the exit button, leaving full screen (the browser's Esc), `stop()` and `destroy()` all call `onExit(index)` with the deck index of the slide shown, then focus returns to the element that was focused at `start`.
|
|
922
|
+
- **Accessibility.** The element is `role="dialog"` with `aria-modal="true"` and an `aria-label` ("Presentation"; `labels.dialog`). Focus moves into it on start and stays in it (Tab cycles the dialog and the exit button). An `aria-live="polite"` region announces "Slide 3 of 12" (`labels.position`).
|
|
923
|
+
- **Fonts.** Like the canvas, the presenter waits for the faces the deck needs (`fonts.pending` and `fonts.ensure`) and shows "Loading fonts…" meanwhile.
|
|
924
|
+
|
|
925
|
+
`createPresentButton(container, { onPresent, disabled, density, variant })` mounts an icon-only button (the Lucide Presentation icon, inlined) named "Present" (`aria-label`), titled "Present (F5)". `variant` is `"quiet"` (the default: a transparent icon in `--opf-ui-muted-fg` that turns `--opf-ui-fg` on `--opf-ui-muted` on hover, like the toolbar's undo and redo, so a lone icon beside a primary Export does not compete with it) or `"primary"` (the filled accent look of the Export menu's trigger). `disabled` is `true` or a reason string (the title); the button stays focusable (`aria-disabled`) and does nothing. It returns `{ element, setDisabled(reason), setDensity(density), setVariant(variant), destroy() }`. `bindPresentShortcut(target, onPresent)` binds F5 (and prevents the browser's reload); it ignores F5 while typing in inputs, selects and editors (CodeMirror, contenteditable), in menus and dialogs, and with a modifier key, and returns a function that removes the listener. `examples/playground.js` wires all three next to the Export menu.
|
|
926
|
+
|
|
927
|
+
Tests: `npm run test:presenter-browser` and `npm run test:present-button-browser`.
|
|
928
|
+
|
|
929
|
+
## Opening a deck file (RR-98)
|
|
930
|
+
|
|
931
|
+
`@openpresentation/opf-editor/read-deck-file` reads a deck file in any OPF source format, with no DOM:
|
|
932
|
+
|
|
933
|
+
```js
|
|
934
|
+
import { readDeckFile, DECK_FILE_ACCEPT, DeckFileError } from "@openpresentation/opf-editor/read-deck-file";
|
|
935
|
+
|
|
936
|
+
input.accept = DECK_FILE_ACCEPT; // ".json,.opf,.yaml,.yml,.md"
|
|
937
|
+
input.onchange = async () => {
|
|
938
|
+
try {
|
|
939
|
+
const { document, format, name, findings } = await readDeckFile(input.files[0], { catalogs });
|
|
940
|
+
const editor = createEditorSession(document, { catalogs }); // the session validates it; findings say what to fix first
|
|
941
|
+
} catch (error) {
|
|
942
|
+
if (error instanceof DeckFileError) show(error.code, error.message, error.findings);
|
|
943
|
+
}
|
|
944
|
+
};
|
|
945
|
+
```
|
|
946
|
+
|
|
947
|
+
`readDeckFile(file | { name, text }, { catalogs? })` takes a `File` (or anything with `name` and `text()`), or `{ name, text }` with the text as a string, and returns `{ document, format: "json" | "yaml" | "md", name, findings }`.
|
|
948
|
+
|
|
949
|
+
- **Format.** The extension picks it: `.yaml` and `.yml` are YAML, `.md` is Markdown, and `.json`, `.opf` or anything else is JSON. If JSON fails to parse, the text is tried as YAML, and as Markdown when it starts with front matter (`---`); `format` is the one that read it. YAML and Markdown go through core's browser-safe `fromYaml` and `fromMarkdown` (`@openpresentation/opf/yaml` and `/markdown`, loaded on first use). JSON that wraps a deck (`opfSnippet`, `opf`, `presentation`) is unwrapped as `parseOpfTransfer` does.
|
|
950
|
+
- **Name.** The file name without a directory and without `.opf.json`, `.opf.yaml`, `.opf.md` or its plain extension (`Q3 plan.opf.yaml` gives `Q3 plan`).
|
|
951
|
+
- **Findings.** The conversion's findings for YAML and Markdown, in core's `Finding` shape: `path` (a JSON Pointer such as `/slides/1/bogus`), `message`, `severity` and `location` (line and column in the file). A deck that reads but has OPF errors is returned with them, so a host can show them; JSON gives none (the editor session validates it).
|
|
952
|
+
- **Errors.** Text that cannot be read at all throws `DeckFileError` with a `code` and a clear message: `unreadable`, `file-too-large` (over 20 MB), `empty-file`, `unsupported-file` (a .pptx or other binary file), `invalid-json`, `invalid-yaml`, `invalid-markdown` (with the syntax `findings` and the line) and `not-a-deck` (JSON without a `slides` list, such as one slide or a block, which a host can send to its paste path as the playground does).
|
|
953
|
+
|
|
954
|
+
The source pane has no file-opening path of its own, so it keeps no second copy of this. The playground's Import dialog uses `readDeckFile` and `DECK_FILE_ACCEPT` for its file tab and for dropped files (`examples/transfer-controls.js`). Icons in the presenter button are inlined [Lucide](https://lucide.dev) paths (ISC license).
|
|
955
|
+
|
|
834
956
|
## Optional React Bindings
|
|
835
957
|
|
|
836
958
|
React bindings are isolated under `@openpresentation/opf-editor/react` and require the host app to pass its React runtime. The core package does not add React to the critical path.
|
package/dist/galleries.js
CHANGED
|
@@ -184,9 +184,9 @@ function attachDefinition(presentation, descriptor, gallerySource) {
|
|
|
184
184
|
groups[group][kind] ??= {};
|
|
185
185
|
let record;
|
|
186
186
|
if (kind === "layouts") {
|
|
187
|
-
if (!Array.isArray(source.
|
|
187
|
+
if (!Array.isArray(source.regions)) return;
|
|
188
188
|
record = {name: source.label ?? source.name ?? id};
|
|
189
|
-
for (const key of ['summary', 'description', '
|
|
189
|
+
for (const key of ['summary', 'description', 'direction', 'anchor', 'regions', 'overflowRegion', 'design', 'composition', 'tags', 'preview'])
|
|
190
190
|
if (source[key] !== undefined) record[key] = structuredClone(source[key]);
|
|
191
191
|
} else if (kind === "fontSchemes") record = fontSchemeRecord(source, id);
|
|
192
192
|
else record = embeddedRecord(source);
|