@openpresentation/opf-editor 0.27.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/design-controls.js +3 -2
- package/dist/design-options.js +1 -1
- package/dist/json-editor.d.ts +33 -0
- package/dist/json-editor.js +83 -2
- package/dist/layout-adapter.js +96 -0
- package/dist/layout-canvas.js +122 -232
- package/dist/layout-editor-ui.js +46 -185
- package/dist/layout-inspector.js +36 -99
- package/dist/layout-layers.js +75 -92
- package/dist/layout-model.js +62 -103
- package/dist/react.d.ts +4 -0
- package/dist/react.js +9 -2
- package/dist/source-color-scheme.js +60 -0
- package/dist/source-pane.d.ts +85 -0
- package/dist/source-pane.js +151 -9
- package/dist/studio-kit/commands.d.ts +38 -0
- package/dist/studio-kit/commands.js +148 -0
- package/dist/studio-kit/dom.d.ts +36 -0
- package/dist/studio-kit/dom.js +162 -0
- package/dist/studio-kit/drag.d.ts +26 -0
- package/dist/studio-kit/drag.js +127 -0
- package/dist/studio-kit/gesture.d.ts +29 -0
- package/dist/studio-kit/gesture.js +87 -0
- package/dist/studio-kit/index.d.ts +29 -0
- package/dist/studio-kit/index.js +12 -0
- package/dist/studio-kit/inspector.d.ts +22 -0
- package/dist/studio-kit/inspector.js +119 -0
- package/dist/studio-kit/overlay.d.ts +29 -0
- package/dist/studio-kit/overlay.js +182 -0
- package/dist/studio-kit/selection.d.ts +30 -0
- package/dist/studio-kit/selection.js +106 -0
- package/dist/studio-kit/tree.d.ts +9 -0
- package/dist/studio-kit/tree.js +82 -0
- package/dist/studio-kit/viewport.d.ts +29 -0
- package/dist/studio-kit/viewport.js +153 -0
- package/dist/styles.css +28 -7
- package/dist/workspace.d.ts +26 -1
- package/dist/workspace.js +13 -0
- package/package.json +10 -10
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ From React 18 or 19 (StrictMode-safe, renders an empty container on the server):
|
|
|
29
29
|
import { OpfEditor, useOpfDocument } from "@openpresentation/opf-editor/react";
|
|
30
30
|
|
|
31
31
|
<OpfEditor document={deck} fonts={loadFonts} catalogs={[gallery]} theme={theme} onEditor={setEditor} />;
|
|
32
|
-
const document = useOpfDocument(editor); // also useOpfSelection(editor), useOpfEditor(options)
|
|
32
|
+
const document = useOpfDocument(editor); // also useOpfSelection(editor), useOpfCursor(editor), useOpfEditor(options)
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
[docs/embedding.md](docs/embedding.md) lists the layouts, `panels`, `slots` (your own arrangement), every option, the handle, the events and the React bindings; [docs/theming.md](docs/theming.md) is the token contract (surface, text, accent, border, radius and font, light and dark) and the `theme` option. `examples/playground.js` is a thin user of it.
|
|
@@ -142,7 +142,7 @@ const pane = createSourcePane(container, {
|
|
|
142
142
|
pane.setFormat('yaml'); pane.focusPath('/slides/1/title'); pane.getText(); pane.destroy();
|
|
143
143
|
```
|
|
144
144
|
|
|
145
|
-
Methods: `replaceDocument(document, { source?, format?, name?, meta? })` (below), `setFindings(list | null)`, `setFormat(format)`, `focusPath(pointer)` (moves the cursor to the line of a JSON Pointer in any format, found with core's `locate`), `getText()`, `refresh()`, `setName`, `setDensity`, `setReadOnly` and `destroy()`. A finding is `{ path, message, severity, line? }` (core `Finding` objects fit). Rows show the path in mono and the line at the right; a click moves the cursor and calls `onFindingSelect`.
|
|
145
|
+
Methods: `replaceDocument(document, { source?, format?, name?, meta? })` (below), `setFindings(list | null)`, `setFormat(format)`, `focusPath(pointer)` (moves the cursor to the line of a JSON Pointer in any format, found with core's `locate`), `getText()`, `getCursor()` and the `onCursor` option (the path under the caret), `revealPath(pointer | range, { focus?, select?, align?, flash?, part? })` (scrolls without moving the caret or the focus), `setDecorations(list, { group? })`, `clearDecorations(group?)` and `getDecorations(group?)` (a host's own marks, by path or range; see "The source pane's hooks" in [docs/embedding.md](docs/embedding.md)), `refresh()`, `setName`, `setDensity`, `setReadOnly` and `destroy()`. A finding is `{ path, message, severity, line? }` (core `Finding` objects fit). Rows show the path in mono and the line at the right; a click moves the cursor and calls `onFindingSelect`.
|
|
146
146
|
|
|
147
147
|
- **Opening a document: `pane.replaceDocument(document, { source, format, name })`.** Loading an example or a file into the pane (never by writing its text into the code editor, which the pane applies as a typed edit and forks the catalog records the two documents share) calls `editor.replaceDocument`: one undoable change, no catalog record forked. `document` is a parsed document or JSON text; `name` is the file name. `source` is the text the document was read from, in any of the three formats: the pane shows it as written (the JSON's formatting, the YAML's comments, the Markdown's layout) when the pane is in that format, and core's rewrite of the document otherwise. `format` (`"json"`, `"yaml"` or `"md"`) says which format `source` is; left out, it is inferred as the first of JSON, the pane's format, YAML and Markdown that reads as exactly this document (a `source` that reads as no format, or as another document, is ignored; an unknown `format` throws a `TypeError` before anything changes). The original text of a format other than the one shown is kept too: switching the pane to it shows those bytes, not a rewrite. Undo and Redo bring back each document's text exactly in the format shown (the opened text as written, the replaced document's text as it was, including YAML comments), so one Undo returns to the document and the text the visitor had. The pane keeps the last 8 such texts, which are also what a later load of the same document shows. A refused document throws (`invalid-opf-edit`, with `issues`) and changes nothing, and works in a read-only pane. A diff typed or pasted into the pane is still an edit, and editing a catalog record in it forks the record.
|
|
148
148
|
- **Keyboard Undo and Redo in the pane (one model).** Cmd/Ctrl+Z and Cmd/Ctrl+Shift+Z (Ctrl+Y) work in two layers, in this order. First CodeMirror's history, which holds only the text typed in the pane since its last whole-document change (a `replaceDocument`, or a session Undo, Redo or restore, each of which clears it because the typed edits no longer fit the text). When that has nothing left, the key steps the session over a whole-document replace, exactly as its Undo and Redo do, so the text comes back byte for byte: Undo steps back over the nearest `replaceDocument` together with the pane's own edits typed on it (the typing was already undone, step by step, by CodeMirror's history); Redo steps forward over the replace. The key never reaches over an edit made elsewhere (the canvas, a panel): Undo stops at it, and the host's Undo button (or `editor.undo()`) undoes that edit, after which the key reaches the load again. A read-only pane takes neither key. `mountJsonCodeEditor` exposes the same seam as `historyFallback: { undo(), redo() }` (answer `true` when you did something) plus `resetHistory()` and `undoDepth()` on the control.
|
|
@@ -537,7 +537,7 @@ insertZoneLogo(editor, "header", "left"); // th
|
|
|
537
537
|
| `titleAlignment`, `contentAlignment` | `design.titleAlignment`, `design.contentAlignment` | `left`, `center`, `right` |
|
|
538
538
|
| `contentDirection` | `design.contentDirection` | `horizontal`, `vertical` |
|
|
539
539
|
| `chartPrimary` | `design.chartPrimary` | `none`, `top`, `bottom`, `left`, `right` |
|
|
540
|
-
| `listBullet` | `design.listBullet` | `character`, `image` (picture bullets draw the logo) |
|
|
540
|
+
| `listBullet` | `design.listBullet` | `character`, `image` (picture bullets draw the logo), `none` (no marker and no indent) |
|
|
541
541
|
| `contentBox` | `design.contentBox` | `true`, `false` |
|
|
542
542
|
| `imageFit` | `design.imageFit` | `cover`, `contain`, `stretch`: the fit of image blocks that choose none |
|
|
543
543
|
| `accentFont` | `design.fontScheme.accent` | a family name; the font scheme becomes its object form and collapses back to the bare id when the accent is cleared |
|
|
@@ -1027,7 +1027,7 @@ Put the opened document in the session with `editor.replaceDocument(document)` (
|
|
|
1027
1027
|
|
|
1028
1028
|
React bindings are isolated under `@openpresentation/opf-editor/react`; React (18 or later) is an optional peer, needed only by that entry point. The core package does not add React to the critical path.
|
|
1029
1029
|
|
|
1030
|
-
`<OpfEditor>`, `useOpfEditor`, `useOpfDocument` and `
|
|
1030
|
+
`<OpfEditor>`, `useOpfEditor`, `useOpfDocument`, `useOpfSelection` and `useOpfCursor` mount and read the embeddable editor ([Embed the editor](#embed-the-editor-ce-06)). The older factory below builds two controls over a session you already have, with the React you pass:
|
|
1031
1031
|
|
|
1032
1032
|
```jsx
|
|
1033
1033
|
import { createOPFReactComponents } from "@openpresentation/opf-editor/react";
|
package/dist/design-controls.js
CHANGED
|
@@ -1018,12 +1018,13 @@ export function createDesignControls(container, options = {}) {
|
|
|
1018
1018
|
};
|
|
1019
1019
|
const bullet = selectField("list-bullet", "List bullets", {
|
|
1020
1020
|
empty: "Default (character)",
|
|
1021
|
-
help: "Picture bullets draw the logo as the marker and need a logo.",
|
|
1022
|
-
onChange: (value) => run(() => setDesignOption(editor, "listBullet", value === "" ? null : value, scoped()), value === "image" ? "Picture bullets on." : "Character bullets."),
|
|
1021
|
+
help: "Picture bullets draw the logo as the marker and need a logo. No bullets draws no marker and no indent, for label-and-description lists.",
|
|
1022
|
+
onChange: (value) => run(() => setDesignOption(editor, "listBullet", value === "" ? null : value, scoped()), value === "image" ? "Picture bullets on." : value === "none" ? "No bullets." : "Character bullets."),
|
|
1023
1023
|
});
|
|
1024
1024
|
bullet.setOptions([
|
|
1025
1025
|
{ value: "character", label: "Character" },
|
|
1026
1026
|
{ value: "image", label: "Picture (the logo)" },
|
|
1027
|
+
{ value: "none", label: "None (no bullets)" },
|
|
1027
1028
|
]);
|
|
1028
1029
|
const accent = textField("accent-font", "Accent font", {
|
|
1029
1030
|
placeholder: "Font family name",
|
package/dist/design-options.js
CHANGED
|
@@ -62,7 +62,7 @@ const ENUMS = Object.freeze({
|
|
|
62
62
|
contentAlignment: ["left", "center", "right"],
|
|
63
63
|
contentDirection: ["horizontal", "vertical"],
|
|
64
64
|
chartPrimary: ["none", "top", "bottom", "left", "right"],
|
|
65
|
-
listBullet: ["character", "image"],
|
|
65
|
+
listBullet: ["character", "image", "none"],
|
|
66
66
|
imageFit: ["cover", "contain", "stretch"],
|
|
67
67
|
});
|
|
68
68
|
|
package/dist/json-editor.d.ts
CHANGED
|
@@ -29,6 +29,18 @@ export interface EditorProblem {
|
|
|
29
29
|
fixes?: EditorFix[];
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/** One host decoration of the code editor (CE-17): a mark over `from..to`, or the class on every line it touches. Offsets are source offsets. */
|
|
33
|
+
export interface EditorDecoration {
|
|
34
|
+
from: number;
|
|
35
|
+
to?: number;
|
|
36
|
+
className: string;
|
|
37
|
+
/** The browser tooltip of a mark. */
|
|
38
|
+
title?: string;
|
|
39
|
+
line?: boolean;
|
|
40
|
+
/** Kept as given, for `getDecorations`. */
|
|
41
|
+
data?: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
32
44
|
/** All offsets address the original source in UTF-16, including CRLF pairs. */
|
|
33
45
|
export interface JsonEditorApi {
|
|
34
46
|
/** Say `message` in the editor's live region (screen readers). */
|
|
@@ -37,6 +49,8 @@ export interface JsonEditorApi {
|
|
|
37
49
|
applyEdits(edits: SourceEdit[], options?: { caret?: number; select?: [number, number]; userEvent?: string }): void;
|
|
38
50
|
getValue(): string;
|
|
39
51
|
getSelection(): [number, number];
|
|
52
|
+
/** The end of the main selection the caret is at (equal to both ends of `getSelection()` for a plain caret), in source offsets. */
|
|
53
|
+
getHead(): number;
|
|
40
54
|
hasFocus(): boolean;
|
|
41
55
|
focus(): void;
|
|
42
56
|
setSelection(from: number, to?: number): void;
|
|
@@ -115,6 +129,10 @@ export interface JsonCodeEditorOptions {
|
|
|
115
129
|
/** Cmd/Ctrl+Shift+M: move focus to the host's problems list. Answer true when it did. */
|
|
116
130
|
onFocusProblems?(): boolean;
|
|
117
131
|
onFocus?(): void;
|
|
132
|
+
/** The selection or the text changed (every change, also one the host made). */
|
|
133
|
+
onSelectionChange?(): void;
|
|
134
|
+
/** The text changed (also by `update`). */
|
|
135
|
+
onDocChange?(): void;
|
|
118
136
|
onHoverField?(path: string | null): void;
|
|
119
137
|
onError?(error: unknown): void;
|
|
120
138
|
}
|
|
@@ -153,6 +171,21 @@ export interface JsonCodeEditor {
|
|
|
153
171
|
resetHistory(): void;
|
|
154
172
|
/** How many typed edit groups CodeMirror can still undo. */
|
|
155
173
|
undoDepth(): number;
|
|
174
|
+
/**
|
|
175
|
+
* CE-17: scroll the start of `from..to` (source offsets) into view without moving the selection or the focus. `align` is "center" (default),
|
|
176
|
+
* "start" or "nearest" (the least scrolling). `select` also selects the range and `focus` also focuses the editor. Folded text around the start is opened.
|
|
177
|
+
*/
|
|
178
|
+
reveal(from: number, to?: number, options?: { align?: "center" | "start" | "nearest"; select?: boolean; focus?: boolean }): void;
|
|
179
|
+
/**
|
|
180
|
+
* CE-17: draw decorations a host owns, kept apart from the editor's own. `key` names a set and a call replaces that set; an empty list
|
|
181
|
+
* removes it. Each item is a mark over `from..to` (source offsets), or with `line` the class on every line it touches. The decorations
|
|
182
|
+
* move with the text as it is edited.
|
|
183
|
+
*/
|
|
184
|
+
setDecorations(key: string, list: readonly EditorDecoration[]): void;
|
|
185
|
+
/** CE-17: remove the decoration set `key`, or every set when `key` is omitted. */
|
|
186
|
+
clearDecorations(key?: string): void;
|
|
187
|
+
/** CE-17: the decorations drawn now, at their current source offsets, in position order. A `line` decoration is one entry over all its lines. */
|
|
188
|
+
getDecorations(key?: string): (EditorDecoration & { key: string; to: number })[];
|
|
156
189
|
/** The CodeMirror `EditorView`. */
|
|
157
190
|
readonly view: import("@codemirror/view").EditorView;
|
|
158
191
|
openOptions(left?: number, top?: number): Promise<void>;
|
package/dist/json-editor.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { Annotation, ChangeSet, Compartment, EditorSelection, EditorState, Prec, StateEffect, StateField, Transaction, countColumn } from "@codemirror/state";
|
|
1
|
+
import { Annotation, ChangeSet, Compartment, EditorSelection, EditorState, Prec, RangeSet, StateEffect, StateField, Transaction, countColumn } from "@codemirror/state";
|
|
2
2
|
import { Decoration, EditorView, activateHover, closeHoverTooltips, crosshairCursor, drawSelection, hasHoverTooltips, hoverTooltip, keymap, lineNumbers, rectangularSelection } from "@codemirror/view";
|
|
3
3
|
import { defaultKeymap, deleteCharBackward, history, historyKeymap, indentLess, indentMore, insertNewlineAndIndent, isolateHistory, redo, selectAll, selectLine, selectParentSyntax, toggleTabFocusMode, undo, undoDepth } from "@codemirror/commands";
|
|
4
|
-
import { HighlightStyle, StreamLanguage, bracketMatching, codeFolding, indentOnInput, indentUnit, syntaxHighlighting } from "@codemirror/language";
|
|
4
|
+
import { HighlightStyle, StreamLanguage, bracketMatching, codeFolding, foldedRanges, indentOnInput, indentUnit, syntaxHighlighting, unfoldEffect } from "@codemirror/language";
|
|
5
5
|
import { json } from "@codemirror/lang-json";
|
|
6
6
|
import { yaml } from "@codemirror/lang-yaml";
|
|
7
7
|
import { acceptCompletion, autocompletion, closeBrackets, closeBracketsKeymap, closeCompletion, completionStatus, currentCompletions, deleteBracketPair, hasNextSnippetField, hasPrevSnippetField, moveCompletionSelection, nextSnippetField, prevSnippetField, startCompletion } from "@codemirror/autocomplete";
|
|
@@ -18,6 +18,7 @@ import {createDiagnostics,editsTransaction} from './source-diagnostics.js';
|
|
|
18
18
|
import { createKeymap, domKeymap, strokeText } from './source-keymap.js';
|
|
19
19
|
import { fold, foldEverything, foldGutterExtension, foldLevel, foldRecursively, toggleFoldHere, unfold, unfoldEverything, unfoldRecursively } from './source-folding.js';
|
|
20
20
|
import { hangingIndent } from './source-wrap.js';
|
|
21
|
+
import { colorSchemeExtension } from './source-color-scheme.js';
|
|
21
22
|
import {languageDataFor,markdownBackspace,markdownEnter,markdownFolding,markdownTab,markdownToggleComment,markdownWrapSelection,yamlEnter} from './source-editing.js';
|
|
22
23
|
const external = Annotation.define();
|
|
23
24
|
const hoverField = StateEffect.define();
|
|
@@ -228,6 +229,23 @@ function sourceDecorations(state) {
|
|
|
228
229
|
for (const from of lines) ranges.push(Decoration.line({ class: "is-hover" }).range(from));
|
|
229
230
|
return Decoration.set(ranges, true);
|
|
230
231
|
}
|
|
232
|
+
// CE-17: decorations a host owns, kept apart from the editor's own (field highlights, problems): one set per key, mapped through every edit, so a
|
|
233
|
+
// mark stays on its text while the author types. `setHostDecorations.of({ key, set })` replaces the set of `key` (`set` null removes it).
|
|
234
|
+
const setHostDecorations = StateEffect.define();
|
|
235
|
+
const hostDecorations = StateField.define({
|
|
236
|
+
create: () => new Map(),
|
|
237
|
+
update(value, tr) {
|
|
238
|
+
let next = value;
|
|
239
|
+
if (tr.docChanged) next = new Map([...value].map(([key, set]) => [key, set.map(tr.changes)]));
|
|
240
|
+
for (const effect of tr.effects) {
|
|
241
|
+
if (!effect.is(setHostDecorations)) continue;
|
|
242
|
+
if (next === value) next = new Map(value);
|
|
243
|
+
if (effect.value.set) next.set(effect.value.key, effect.value.set); else next.delete(effect.value.key);
|
|
244
|
+
}
|
|
245
|
+
return next;
|
|
246
|
+
},
|
|
247
|
+
provide: (field) => EditorView.decorations.from(field, (value) => RangeSet.join([...value.values()])),
|
|
248
|
+
});
|
|
231
249
|
const fieldDecorations = StateField.define({
|
|
232
250
|
create: sourceDecorations,
|
|
233
251
|
update: (value, tr) => tr.docChanged || tr.effects.some((effect) => effect.is(hoverField)) ? sourceDecorations(tr.state) : value,
|
|
@@ -535,6 +553,8 @@ function mountJsonCodeEditor(parent, options) {
|
|
|
535
553
|
indentOnInput(),
|
|
536
554
|
historyField.of(history()),
|
|
537
555
|
drawSelection(),
|
|
556
|
+
// The dark flag follows the surface's color scheme: CodeMirror paints the selection (and the tooltips and panels) by it.
|
|
557
|
+
colorSchemeExtension(parent),
|
|
538
558
|
// Multiple cursors, as in VS Code: Cmd/Ctrl+D, Cmd/Ctrl+Shift+L, Cmd/Ctrl+Alt+Up and Down, Alt+click, and Shift+Alt+drag for a column.
|
|
539
559
|
EditorState.allowMultipleSelections.of(true),
|
|
540
560
|
// Alt+click adds a cursor (VS Code's default); Cmd/Ctrl+click goes to the definition.
|
|
@@ -549,6 +569,7 @@ function mountJsonCodeEditor(parent, options) {
|
|
|
549
569
|
highlightSelectionMatches(),
|
|
550
570
|
syntaxHighlighting(highlightStyle),
|
|
551
571
|
activeField,
|
|
572
|
+
hostDecorations,
|
|
552
573
|
attributes.of(contentAttributes()),
|
|
553
574
|
options.lineNumbers ? lineNumbers() : [],
|
|
554
575
|
EditorView.lineWrapping,
|
|
@@ -594,12 +615,15 @@ function mountJsonCodeEditor(parent, options) {
|
|
|
594
615
|
if (update.docChanged && !update.transactions.every((tr) => tr.annotation(external))) options.onChange(update.state.field(jsonSource));
|
|
595
616
|
if (update.docChanged) scheduleSyntax();
|
|
596
617
|
if (update.selectionSet || update.docChanged) options.onSelectionChange?.();
|
|
618
|
+
if (update.docChanged) options.onDocChange?.();
|
|
597
619
|
if (update.docChanged && update.transactions.some((tr) => tr.isUserEvent("input.type"))) autoHint(update.view);
|
|
598
620
|
})
|
|
599
621
|
] }) });
|
|
600
622
|
const api = {
|
|
601
623
|
getValue: () => view.state.field(jsonSource),
|
|
602
624
|
getSelection: () => {const map=textInputMap(api.getValue());return [map.toSource(view.state.selection.main.from),map.toSource(view.state.selection.main.to)];},
|
|
625
|
+
// The end of the selection the caret is at (the head), in source offsets: where a path under the cursor is read.
|
|
626
|
+
getHead: () => textInputMap(api.getValue()).toSource(view.state.selection.main.head),
|
|
603
627
|
hasFocus: () => view.hasFocus,
|
|
604
628
|
focus: () => view.focus(),
|
|
605
629
|
setSelection: (from, to=from) => {const map=textInputMap(api.getValue());view.dispatch({ selection: EditorSelection.single(map.toInput(from),map.toInput(to)), scrollIntoView: true });},
|
|
@@ -707,6 +731,63 @@ function mountJsonCodeEditor(parent, options) {
|
|
|
707
731
|
/** How many typed edit groups CodeMirror can still undo. */
|
|
708
732
|
undoDepth: () => undoDepth(view.state),
|
|
709
733
|
setReadOnly(value){view.dispatch({ effects: editable.reconfigure(editableExtension(value)) });},
|
|
734
|
+
/**
|
|
735
|
+
* CE-17: scroll the start of `from..to` (source offsets) into view without moving the selection or the focus. `align`: "center" (default),
|
|
736
|
+
* "start" or "nearest" (the least scrolling). `select` also selects the range, `focus` also focuses the editor; folded text around the start is opened.
|
|
737
|
+
*/
|
|
738
|
+
reveal(from, to = from, { align = "center", select = false, focus = false } = {}) {
|
|
739
|
+
if (disposed) return;
|
|
740
|
+
const source = api.getValue(), map = textInputMap(source);
|
|
741
|
+
const a = map.toInput(Math.min(source.length, Math.max(0, from))), b = map.toInput(Math.min(source.length, Math.max(0, to)));
|
|
742
|
+
const effects = [];
|
|
743
|
+
foldedRanges(view.state).between(a, a, (start, end) => { effects.push(unfoldEffect.of({ from: start, to: end })); });
|
|
744
|
+
effects.push(EditorView.scrollIntoView(a, { y: align === "nearest" ? "nearest" : align === "start" ? "start" : "center" }));
|
|
745
|
+
view.dispatch({ effects, ...(select ? { selection: EditorSelection.single(a, b) } : {}) });
|
|
746
|
+
if (focus) view.focus();
|
|
747
|
+
},
|
|
748
|
+
/**
|
|
749
|
+
* CE-17: draw decorations a host owns, kept apart from the editor's own. `key` names a set (a call replaces that set); `list` is
|
|
750
|
+
* `{ from, to, className, title?, line?, data? }` in source offsets: a mark over the range, or with `line` the class on every line it touches.
|
|
751
|
+
* `data` is kept for `getDecorations`. An empty list removes the set.
|
|
752
|
+
*/
|
|
753
|
+
setDecorations(key, list) {
|
|
754
|
+
if (disposed) return;
|
|
755
|
+
const source = api.getValue(), map = textInputMap(source), ranges = [];
|
|
756
|
+
const at = (offset) => map.toInput(Math.min(source.length, Math.max(0, offset)));
|
|
757
|
+
(list ?? []).forEach((item, id) => {
|
|
758
|
+
const a = at(item.from), b = Math.max(a, at(item.to ?? item.from));
|
|
759
|
+
const spec = { opf: { id, className: item.className, title: item.title, line: Boolean(item.line), data: item.data } };
|
|
760
|
+
if (item.line) {
|
|
761
|
+
const last = view.state.doc.lineAt(b).number;
|
|
762
|
+
for (let number = view.state.doc.lineAt(a).number; number <= last; number++) ranges.push(Decoration.line({ class: item.className, ...spec }).range(view.state.doc.line(number).from));
|
|
763
|
+
} else if (b > a) {
|
|
764
|
+
ranges.push(Decoration.mark({ class: item.className, attributes: item.title ? { title: item.title } : undefined, ...spec }).range(a, b));
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
view.dispatch({ effects: setHostDecorations.of({ key, set: ranges.length ? Decoration.set(ranges, true) : null }) });
|
|
768
|
+
},
|
|
769
|
+
/** CE-17: remove the decoration set `key`, or every host set when `key` is omitted. */
|
|
770
|
+
clearDecorations(key) {
|
|
771
|
+
if (disposed) return;
|
|
772
|
+
const keys = key === undefined ? [...view.state.field(hostDecorations).keys()] : [key];
|
|
773
|
+
view.dispatch({ effects: keys.map((name) => setHostDecorations.of({ key: name, set: null })) });
|
|
774
|
+
},
|
|
775
|
+
/** CE-17: the decorations drawn now at their current source offsets, `{ key, from, to, className, title, line, data }`, in position order (a `line` one is one entry over all its lines). */
|
|
776
|
+
getDecorations(key) {
|
|
777
|
+
const map = textInputMap(api.getValue()), out = [], lines = new Map();
|
|
778
|
+
for (const [name, set] of view.state.field(hostDecorations)) {
|
|
779
|
+
if (key !== undefined && name !== key) continue;
|
|
780
|
+
set.between(0, view.state.doc.length, (from, to, value) => {
|
|
781
|
+
const info = value.spec.opf;
|
|
782
|
+
if (!info) return;
|
|
783
|
+
const entry = { key: name, from: map.toSource(from), to: map.toSource(info.line ? view.state.doc.lineAt(from).to : to), className: info.className, title: info.title, line: info.line, data: info.data };
|
|
784
|
+
if (!info.line) { out.push(entry); return; }
|
|
785
|
+
const id = `${name}\0${info.id}`, seen = lines.get(id);
|
|
786
|
+
if (seen) seen.to = Math.max(seen.to, entry.to); else { lines.set(id, entry); out.push(entry); }
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
return out.sort((a, b) => a.from - b.from || a.to - b.to);
|
|
790
|
+
},
|
|
710
791
|
/** The CodeMirror view, for hosts that scroll or measure it. */
|
|
711
792
|
view,
|
|
712
793
|
update(code, invalid=false, field=null, describedBy) {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// CE-18: Layout Studio's document adapter for the studio kit. The kit works on keys, boxes, containers, trial moves and gestures; this file
|
|
2
|
+
// says what those are for a layout record: a key is a node's index path (`[]` is the layout itself, `[1, 0]` is `regions[1].regions[0]`),
|
|
3
|
+
// boxes and dividers come from `layoutBoxes`, a trial move is `moveNodes` run without applying it, and a gesture is the model's. The slide
|
|
4
|
+
// adapter (CE-19) answers the same questions for a slide of a deck. No DOM.
|
|
5
|
+
import { LayoutOperationError, isHeading, isPrefix, isSplit, layoutBoxes, listAt, moveNodes, nodeAt, pathKey, samePath } from "./layout-tree.js";
|
|
6
|
+
import { boxesAt } from "./studio-kit/overlay.js";
|
|
7
|
+
import { clickTarget } from "./studio-kit/selection.js";
|
|
8
|
+
|
|
9
|
+
/** The kit selection's adapter over the record `getRecord()` returns: keys are index paths, the layout itself is never selected. */
|
|
10
|
+
export function layoutSelectionAdapter(getRecord) {
|
|
11
|
+
return {
|
|
12
|
+
equals: samePath,
|
|
13
|
+
copy: (path) => [...path],
|
|
14
|
+
exists: (path) => path.length > 0 && Boolean(nodeAt(getRecord(), path)),
|
|
15
|
+
parent: (path) => (path.length > 1 ? path.slice(0, -1) : null),
|
|
16
|
+
firstChild(path) {
|
|
17
|
+
const node = nodeAt(getRecord(), path);
|
|
18
|
+
return node && isSplit(node) && node.regions?.length ? [...path, 0] : null;
|
|
19
|
+
},
|
|
20
|
+
sibling(path, delta) {
|
|
21
|
+
const list = listAt(getRecord(), path.slice(0, -1));
|
|
22
|
+
const at = path[path.length - 1] + delta;
|
|
23
|
+
return !list || at < 0 || at >= list.length ? null : [...path.slice(0, -1), at];
|
|
24
|
+
},
|
|
25
|
+
first: () => (getRecord().regions.length ? [0] : null),
|
|
26
|
+
siblings(path) {
|
|
27
|
+
const list = listAt(getRecord(), path.slice(0, -1)) ?? [];
|
|
28
|
+
return list.map((_, index) => [...path.slice(0, -1), index]);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Where moving `paths` to `index` of the list at `listPath` would go: `{ ok, reason, noop }` (a refusal's reason is the operation's message). */
|
|
34
|
+
export function trialMove(record, paths, listPath, index) {
|
|
35
|
+
try {
|
|
36
|
+
const moved = moveNodes(record, paths, listPath, index);
|
|
37
|
+
return { ok: true, reason: "", noop: JSON.stringify(moved.record) === JSON.stringify(record) };
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (!(error instanceof LayoutOperationError)) throw error;
|
|
40
|
+
return { ok: false, reason: error.message, noop: false };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The studio document adapter of a layout model (`createLayoutModel`). `geometry()` is `layoutBoxes` of the current record (computed once per
|
|
46
|
+
* record); `nodes()` lists `{ key, path, kind, parent, depth, label, findings }`; `boxOf`, `containerOf`, `hitChain`, `target` and
|
|
47
|
+
* `trialMove` answer the canvas and the layers; `begin`, `preview`, `commit` and `cancel` are the model's gesture; `run(name, ...args)` runs
|
|
48
|
+
* one of the model's commands; `undo` and `redo` its history.
|
|
49
|
+
*/
|
|
50
|
+
export function createLayoutAdapter(model) {
|
|
51
|
+
let cached = null, cachedFor = null;
|
|
52
|
+
const geometry = () => {
|
|
53
|
+
if (cachedFor !== model.record) { cached = layoutBoxes(model.record); cachedFor = model.record; }
|
|
54
|
+
return cached;
|
|
55
|
+
};
|
|
56
|
+
const boxOf = (path) => geometry().nodes.find((box) => samePath(box.path, path));
|
|
57
|
+
return {
|
|
58
|
+
geometry,
|
|
59
|
+
nodes() {
|
|
60
|
+
return geometry().nodes.map((box) => ({
|
|
61
|
+
key: pathKey(box.path),
|
|
62
|
+
path: box.path,
|
|
63
|
+
kind: isSplit(box.entry) ? "split" : isHeading(box.entry) ? "heading" : "region",
|
|
64
|
+
parent: box.path.length > 1 ? pathKey(box.path.slice(0, -1)) : null,
|
|
65
|
+
depth: box.depth,
|
|
66
|
+
label: model.describe(box.path),
|
|
67
|
+
findings: model.findingsFor(box.path),
|
|
68
|
+
}));
|
|
69
|
+
},
|
|
70
|
+
boxOf,
|
|
71
|
+
/** The direction and the tracks of the layout (`[]`) or of a split, for its dividers and drop lines. */
|
|
72
|
+
containerOf(path) {
|
|
73
|
+
const entry = path.length ? nodeAt(model.record, path) : model.record;
|
|
74
|
+
if (!entry || (path.length && !isSplit(entry))) return undefined;
|
|
75
|
+
const direction = entry.direction === "row" ? "row" : "column";
|
|
76
|
+
const tracks = geometry().nodes.filter((box) => box.path.length === path.length + 1 && isPrefix(path, box.path)).map((box) => (direction === "row" ? { start: box.x, length: box.w } : { start: box.y, length: box.h }));
|
|
77
|
+
return { direction, tracks };
|
|
78
|
+
},
|
|
79
|
+
/** The boxes under a unit point, from the layout's outermost split to the deepest region (`skip(box)` leaves boxes out). */
|
|
80
|
+
hitChain: (point, skip) => boxesAt(geometry().nodes, point, { skip }),
|
|
81
|
+
/** The box a click on `chain` selects: the deepest, or one level into a selected split. */
|
|
82
|
+
target: (chain, { shift = false } = {}) => clickTarget(chain, model.primary, { shift, equals: samePath, keyOf: (box) => box.path, isContainer: (box) => isSplit(box.entry) }),
|
|
83
|
+
trialMove: (paths, listPath, index) => trialMove(model.record, paths, listPath, index),
|
|
84
|
+
begin: (label) => model.beginGesture(label),
|
|
85
|
+
preview: (record, message) => model.updateGesture(record, message),
|
|
86
|
+
commit: (message) => model.endGesture(message),
|
|
87
|
+
cancel: () => model.cancelGesture(),
|
|
88
|
+
run(name, ...args) {
|
|
89
|
+
if (!COMMANDS.has(name)) throw new TypeError(`Unknown layout command ${name}.`);
|
|
90
|
+
return model[name](...args);
|
|
91
|
+
},
|
|
92
|
+
undo: () => model.undo(),
|
|
93
|
+
redo: () => model.redo(),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const COMMANDS = new Set(["splitRegion", "insertRegion", "toggleHeading", "wrap", "unwrap", "duplicate", "remove", "move", "reorder", "moveInto", "setProperty", "setLayoutProperty", "removeDesignHint", "equalize", "nudge"]);
|