@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/dist/source-pane.d.ts
CHANGED
|
@@ -74,7 +74,74 @@ export interface OutlineSymbol {
|
|
|
74
74
|
line: number;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Where the caret is in the text (CE-17), in any of the three formats. Offsets are source offsets: UTF-16 positions in the text as `getText()` returns it.
|
|
79
|
+
*/
|
|
80
|
+
export interface SourceCursor {
|
|
81
|
+
/** The JSON Pointer of the field under the caret (`/slides/0/title`; `""` outside the document's content). On a property name or value it is that property; between members it is the container. */
|
|
82
|
+
pointer: string;
|
|
83
|
+
/** The same place addressed by id (`/slides/@intro/title`), read from the session's document; the pointer itself where an element has no id, or while the text does not read as a document. */
|
|
84
|
+
idPath: string;
|
|
85
|
+
/** The start and end of the main selection (equal for a plain caret). */
|
|
86
|
+
from: number;
|
|
87
|
+
to: number;
|
|
88
|
+
/** The end of the selection the caret is at; `pointer` is read here. */
|
|
89
|
+
head: number;
|
|
90
|
+
/** What the caret is on: a property name (`key`), a value, whitespace or punctuation inside a container (`between`), or outside the document (`none`). */
|
|
91
|
+
part: "key" | "value" | "between" | "none";
|
|
92
|
+
format: SourceFormat;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One mark or line class a host draws in the text (CE-17), by path or by range. */
|
|
96
|
+
export interface SourceDecoration {
|
|
97
|
+
/** A JSON Pointer (or dotted OPF path). The decoration follows the field when slides move, and is not drawn while the text has no such field. */
|
|
98
|
+
path?: string;
|
|
99
|
+
/** Which part of the field a `path` marks: the value (default), its `key`, the whole `entry` (key through value), the `open` bracket or the `colon`. */
|
|
100
|
+
part?: "value" | "key" | "entry" | "open" | "colon";
|
|
101
|
+
/** Source offsets, instead of a path. They follow the text as it is edited, as text positions, and are not placed again when the format changes. */
|
|
102
|
+
range?: { from: number; to?: number };
|
|
103
|
+
/** The class `opf-source-decoration-<kind>`; the built-in looks are `highlight` (default), `error`, `warning`, `info`, `added` and `changed`. Any other kind is styled by the host. */
|
|
104
|
+
kind?: string;
|
|
105
|
+
/** The browser tooltip of a mark. */
|
|
106
|
+
label?: string;
|
|
107
|
+
/** Put the class on every line the range touches (a `.cm-line`) instead of marking the text. */
|
|
108
|
+
line?: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A host decoration as drawn now (`getDecorations`). */
|
|
112
|
+
export interface SourceDecorationInfo {
|
|
113
|
+
group: string;
|
|
114
|
+
kind: string;
|
|
115
|
+
label?: string;
|
|
116
|
+
path?: string;
|
|
117
|
+
/** The current source offsets; a line decoration spans from the first line's start to the last line's end. */
|
|
118
|
+
from: number;
|
|
119
|
+
to: number;
|
|
120
|
+
line: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface RevealOptions {
|
|
124
|
+
/** Also focus the code editor (default false: the page's focus stays where it is). */
|
|
125
|
+
focus?: boolean;
|
|
126
|
+
/** Also select the range (default false: the caret stays; this also emits a `cursor` event). */
|
|
127
|
+
select?: boolean;
|
|
128
|
+
/** Where the start of the range lands: `center` (default), `start` (the top) or `nearest` (the least scrolling). */
|
|
129
|
+
align?: "center" | "start" | "nearest";
|
|
130
|
+
/** Mark the first line for a moment. */
|
|
131
|
+
flash?: boolean;
|
|
132
|
+
/** Which part of the field a path means (default `entry`: the key through the end of its value). */
|
|
133
|
+
part?: SourceDecoration["part"];
|
|
134
|
+
}
|
|
135
|
+
|
|
77
136
|
export interface SourcePaneOptions {
|
|
137
|
+
/**
|
|
138
|
+
* The caret or selection moved (also while the pane has no focus), or the text under it changed: the path under the caret. Called after `cursorDelay` of
|
|
139
|
+
* quiet and not at all for a position it already reported; while a typed edit waits to be applied it waits too, so `idPath` is read from the document
|
|
140
|
+
* the text reads as. `getCursor()` reads the position at once.
|
|
141
|
+
*/
|
|
142
|
+
onCursor?(cursor: SourceCursor): void;
|
|
143
|
+
/** Milliseconds of quiet before `onCursor` is called (default 50; 0 is the next tick). */
|
|
144
|
+
cursorDelay?: number;
|
|
78
145
|
/** Add an Outline button to the bar and a panel listing the slides and other symbols of the document (`"open"` shows the panel at the start). The symbol picker (Cmd/Ctrl+Shift+O) is always there. */
|
|
79
146
|
outline?: boolean | "open";
|
|
80
147
|
/** Called with `getShortcuts()` when the shortcuts dialog is about to open; returning `true` means the host shows its own and the editor's dialog stays closed. */
|
|
@@ -160,6 +227,24 @@ export interface SourcePane {
|
|
|
160
227
|
getFormat(): SourceFormat;
|
|
161
228
|
/** Move the cursor to the line of a JSON Pointer (or dotted OPF path) in the current format (core's `locate` finds it in JSON, YAML and Markdown). Returns the line. */
|
|
162
229
|
focusPath(path: string): number;
|
|
230
|
+
/** The path under the caret now (see `SourceCursor`), read at once and also while the pane has no focus. */
|
|
231
|
+
getCursor(): SourceCursor;
|
|
232
|
+
/**
|
|
233
|
+
* Scroll a JSON Pointer (or dotted path) or a range `{ from, to }` (source offsets) into view without moving the caret or the focus, which
|
|
234
|
+
* `focusPath` does. Folded text around the start is opened. Returns the range revealed, its one-based line, and whether the path is exactly in the
|
|
235
|
+
* text (`false`: the nearest container was revealed); `null` when the path cannot be placed at all.
|
|
236
|
+
*/
|
|
237
|
+
revealPath(target: string | { from: number; to?: number }, options?: RevealOptions): { from: number; to: number; line: number; exact: boolean } | null;
|
|
238
|
+
/**
|
|
239
|
+
* Draw marks or line classes of your own, by path or range, kept apart from the editor's own decorations (squiggles, field highlights, the outline's
|
|
240
|
+
* marker). `group` (default `default`) names a set; a call replaces the whole set, so validation issues and the paths an agent touched can be
|
|
241
|
+
* two groups. `[]` or `null` clears the set. A decoration with neither a path nor a range throws a `TypeError`.
|
|
242
|
+
*/
|
|
243
|
+
setDecorations(decorations: readonly SourceDecoration[] | null, options?: { group?: string }): void;
|
|
244
|
+
/** Clear the decorations of `group`, or all of the host's without an argument. */
|
|
245
|
+
clearDecorations(group?: string): void;
|
|
246
|
+
/** The host decorations drawn now, at their current offsets, in position order; of `group`, or all. */
|
|
247
|
+
getDecorations(group?: string): SourceDecorationInfo[];
|
|
163
248
|
/** The text as shown, with any pending edit applied first. */
|
|
164
249
|
getText(): string;
|
|
165
250
|
/**
|
package/dist/source-pane.js
CHANGED
|
@@ -2,7 +2,7 @@ import { scanYamlComments, toYaml } from "@openpresentation/opf/yaml";
|
|
|
2
2
|
import { toMarkdown } from "@openpresentation/opf/markdown";
|
|
3
3
|
import { mountJsonCodeEditor } from "./json-editor.js";
|
|
4
4
|
import { MAX_EXACT_SOURCE_LENGTH, createSourceMemory, updateJsonSource } from "./exact-source.js";
|
|
5
|
-
import { lineForPath, offsetOfLine } from "./source-lines.js";
|
|
5
|
+
import { lineForPath, lineOfOffset, offsetOfLine, pathSegments } from "./source-lines.js";
|
|
6
6
|
import { errorText, normalizeFinding, rejectionFindings as findingsOfError } from "./source-findings.js";
|
|
7
7
|
import { formatSource } from "./source-format.js";
|
|
8
8
|
import { buildProblems, summarize } from "./source-problems.js";
|
|
@@ -11,7 +11,8 @@ import { createSourceChecker } from "./source-checker.js";
|
|
|
11
11
|
import { detailsFor } from "./source-details.js";
|
|
12
12
|
import { schemaAtPath, schemaVariants } from "./schema.js";
|
|
13
13
|
import { locateText } from "./source-ranges.js";
|
|
14
|
-
import { scanMarkdown, starterValue } from "@openpresentation/opf/source";
|
|
14
|
+
import { pathAt, scanMarkdown, starterValue } from "@openpresentation/opf/source";
|
|
15
|
+
import { toIdPointer } from "@openpresentation/opf/ids";
|
|
15
16
|
import { listCatalogRecords } from "./catalogs.js";
|
|
16
17
|
import { documentSymbols, symbolAt } from "./source-symbols.js";
|
|
17
18
|
|
|
@@ -135,6 +136,11 @@ export function createSourcePane(container, options = {}) {
|
|
|
135
136
|
const checker = options.worker ? createSourceChecker({ worker: options.worker, onError: (error) => options.onError?.(error) }) : null;
|
|
136
137
|
let checkToken = 0, syntaxToken = 0, checkBusy = false, syntaxBusy = false;
|
|
137
138
|
const stats = { worker: 0, inline: 0 };
|
|
139
|
+
// CE-17: the cursor event's pause and what it last said, and the host's decoration groups (below, with the cursor and the decorations).
|
|
140
|
+
const cursorDelay = Math.max(0, options.cursorDelay ?? 50);
|
|
141
|
+
let cursorTimer = null, cursorKey = "", cursorWaiting = false;
|
|
142
|
+
const hostGroups = new Map();
|
|
143
|
+
let decorationTimer = null, flashTimer = null;
|
|
138
144
|
let problemRows = [], lastDraft = null, lastSummary = "No problems", summaryTimer = null, syntaxTimer = null, recheckTimer = null;
|
|
139
145
|
// The exact text of the documents `replaceDocument` replaced and opened, per format, so Undo and Redo show it again byte for byte
|
|
140
146
|
// (a YAML comment, the author's JSON spelling). Keyed by format and document; the oldest entries go first.
|
|
@@ -260,7 +266,8 @@ export function createSourcePane(container, options = {}) {
|
|
|
260
266
|
onChange: () => schedule(),
|
|
261
267
|
onFormat: () => formatDocument(),
|
|
262
268
|
onFocusProblems: () => focusProblems(),
|
|
263
|
-
onSelectionChange: () => scheduleOutline(),
|
|
269
|
+
onSelectionChange: () => { scheduleOutline(); scheduleCursor(); },
|
|
270
|
+
onDocChange: () => scheduleDecorations(),
|
|
264
271
|
onShowShortcuts: options.onShowShortcuts,
|
|
265
272
|
// The pane supplies the problems itself (every finding, with ranges and fixes).
|
|
266
273
|
syntaxProblems: false,
|
|
@@ -667,6 +674,7 @@ export function createSourcePane(container, options = {}) {
|
|
|
667
674
|
dirty = false;
|
|
668
675
|
control.setLanguage(target.language);
|
|
669
676
|
show(written.text);
|
|
677
|
+
refreshDecorations();
|
|
670
678
|
if (format === "md") refreshNotes();
|
|
671
679
|
// A draft the document never accepted is checked and applied again, so its findings come back with it.
|
|
672
680
|
if (written.draft) { baseline = null; flush(); }
|
|
@@ -689,6 +697,7 @@ export function createSourcePane(container, options = {}) {
|
|
|
689
697
|
syntaxFindings = [];
|
|
690
698
|
noteFindings = [];
|
|
691
699
|
control.setLanguage(target.language);
|
|
700
|
+
refreshDecorations();
|
|
692
701
|
updateBar();
|
|
693
702
|
baseline = null;
|
|
694
703
|
flush();
|
|
@@ -1137,6 +1146,115 @@ export function createSourcePane(container, options = {}) {
|
|
|
1137
1146
|
return true;
|
|
1138
1147
|
}
|
|
1139
1148
|
|
|
1149
|
+
// ---- Cursor (CE-17) -----------------------------------------------------------------------------------------------
|
|
1150
|
+
// The document path under the caret in the text as shown, for any format (core's `pathAt` reads text that does not parse). `idPath` is the same place
|
|
1151
|
+
// addressed by id, read from the session's document: while the text is a draft that has not been applied, the event waits for the apply (the
|
|
1152
|
+
// pointer into a deck the session has not got yet would name another slide), and while the text does not read as a document `idPath` is the pointer.
|
|
1153
|
+
function readCursor() {
|
|
1154
|
+
const text = control.api.getValue();
|
|
1155
|
+
const [from, to] = control.api.getSelection();
|
|
1156
|
+
const head = control.api.getHead();
|
|
1157
|
+
const found = attempt(() => pathAt(text, format, head), undefined);
|
|
1158
|
+
const at = pointer(found?.path ?? []);
|
|
1159
|
+
return { pointer: at, idPath: attempt(() => toIdPointer(deck(), at), at), from, to, head, part: found?.part ?? "none", format };
|
|
1160
|
+
}
|
|
1161
|
+
const settled = () => timer === null && syntaxTimer === null && !checkBusy && !syntaxBusy;
|
|
1162
|
+
function scheduleCursor() {
|
|
1163
|
+
if (destroyed || !options.onCursor) return;
|
|
1164
|
+
clearTimeout(cursorTimer);
|
|
1165
|
+
cursorTimer = setTimeout(emitCursor, cursorDelay);
|
|
1166
|
+
}
|
|
1167
|
+
function emitCursor() {
|
|
1168
|
+
cursorTimer = null;
|
|
1169
|
+
if (destroyed) return;
|
|
1170
|
+
if (!settled()) {
|
|
1171
|
+
if (!cursorWaiting) { cursorWaiting = true; idle().then(() => { cursorWaiting = false; scheduleCursor(); }); }
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
const detail = readCursor();
|
|
1175
|
+
const key = JSON.stringify([detail.format, detail.from, detail.to, detail.head, detail.pointer, detail.idPath, detail.part]);
|
|
1176
|
+
if (key === cursorKey) return;
|
|
1177
|
+
cursorKey = key;
|
|
1178
|
+
try { options.onCursor?.(detail); } catch (error) { options.onError?.(error); }
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// ---- Host decorations and reveal (CE-17) --------------------------------------------------------------------------------
|
|
1182
|
+
// A host's marks and line classes live in the code editor's own sets (`host:range:<group>` and `host:path:<group>`), apart from the editor's
|
|
1183
|
+
// squiggles and field highlights. A range follows the text as it is edited; a path is found again after each edit (and at once when the format
|
|
1184
|
+
// changes), so it stays on its field when slides move.
|
|
1185
|
+
const kindClass = (kind) => `opf-source-decoration opf-source-decoration-${String(kind || "highlight").toLowerCase().replace(/[^a-z0-9_-]+/g, "-")}`;
|
|
1186
|
+
const hostItem = (group, spec, from, to) => ({ from, to, line: Boolean(spec.line), className: kindClass(spec.kind), title: spec.label, data: { group, kind: spec.kind || "highlight", ...(spec.path !== undefined ? { path: spec.path } : {}) } });
|
|
1187
|
+
function drawPaths(group) {
|
|
1188
|
+
const entry = hostGroups.get(group);
|
|
1189
|
+
if (!entry) return;
|
|
1190
|
+
const text = control.api.getValue();
|
|
1191
|
+
const items = [];
|
|
1192
|
+
for (const spec of entry.paths) {
|
|
1193
|
+
const found = attempt(() => locateText(format, text, pathSegments(spec.path), spec.part ?? "value"), null);
|
|
1194
|
+
if (found?.exact) items.push(hostItem(group, spec, found.from, found.to));
|
|
1195
|
+
}
|
|
1196
|
+
control.setDecorations(`host:path:${group}`, items);
|
|
1197
|
+
}
|
|
1198
|
+
function refreshDecorations() {
|
|
1199
|
+
clearTimeout(decorationTimer);
|
|
1200
|
+
decorationTimer = null;
|
|
1201
|
+
if (!destroyed) for (const [group, entry] of hostGroups) if (entry.paths.length) drawPaths(group);
|
|
1202
|
+
}
|
|
1203
|
+
function scheduleDecorations() {
|
|
1204
|
+
if (destroyed || ![...hostGroups.values()].some((entry) => entry.paths.length)) return;
|
|
1205
|
+
clearTimeout(decorationTimer);
|
|
1206
|
+
decorationTimer = setTimeout(refreshDecorations, 120);
|
|
1207
|
+
}
|
|
1208
|
+
function clearGroup(group) {
|
|
1209
|
+
hostGroups.delete(group);
|
|
1210
|
+
control.clearDecorations(`host:range:${group}`);
|
|
1211
|
+
control.clearDecorations(`host:path:${group}`);
|
|
1212
|
+
}
|
|
1213
|
+
function setHostDecorations(list, { group = "default" } = {}) {
|
|
1214
|
+
if (destroyed) return;
|
|
1215
|
+
const specs = list === null || list === undefined ? [] : list;
|
|
1216
|
+
if (!Array.isArray(specs)) throw new TypeError("setDecorations needs a list of decorations (or null to clear).");
|
|
1217
|
+
const ranges = [], paths = [];
|
|
1218
|
+
for (const spec of specs) {
|
|
1219
|
+
if (typeof spec?.path === "string") paths.push(spec);
|
|
1220
|
+
else if (Number.isFinite(spec?.range?.from) && Number.isFinite(spec?.range?.to ?? spec.range.from)) ranges.push(spec);
|
|
1221
|
+
else throw new TypeError("A decoration needs a path (a JSON Pointer) or a range ({ from, to } in source offsets).");
|
|
1222
|
+
}
|
|
1223
|
+
if (!specs.length) { clearGroup(group); return; }
|
|
1224
|
+
hostGroups.set(group, { paths });
|
|
1225
|
+
control.setDecorations(`host:range:${group}`, ranges.map((spec) => hostItem(group, spec, spec.range.from, spec.range.to ?? spec.range.from)));
|
|
1226
|
+
drawPaths(group);
|
|
1227
|
+
}
|
|
1228
|
+
function revealTarget(target, { focus = false, select = false, align = "center", flash = false, part = "entry" } = {}) {
|
|
1229
|
+
let range;
|
|
1230
|
+
if (typeof target === "string") {
|
|
1231
|
+
const found = attempt(() => locateText(format, control.api.getValue(), pathSegments(target), part), null);
|
|
1232
|
+
if (!found) return null;
|
|
1233
|
+
range = found;
|
|
1234
|
+
} else if (Number.isFinite(target?.from)) {
|
|
1235
|
+
const size = control.api.getValue().length;
|
|
1236
|
+
const from = Math.min(size, Math.max(0, target.from));
|
|
1237
|
+
range = { from, to: Math.min(size, Math.max(from, Number.isFinite(target.to) ? target.to : from)), exact: true };
|
|
1238
|
+
} else throw new TypeError("revealPath needs a JSON Pointer or a range ({ from, to } in source offsets).");
|
|
1239
|
+
control.reveal(range.from, range.to, { align, select, focus });
|
|
1240
|
+
if (flash) {
|
|
1241
|
+
control.setDecorations("flash", [{ from: range.from, to: range.from, line: true, className: "opf-source-flash" }]);
|
|
1242
|
+
clearTimeout(flashTimer);
|
|
1243
|
+
flashTimer = setTimeout(() => { flashTimer = null; if (!destroyed) control.clearDecorations("flash"); }, 1200);
|
|
1244
|
+
}
|
|
1245
|
+
return { from: range.from, to: range.to, line: lineOfOffset(control.api.getValue(), range.from), exact: range.exact };
|
|
1246
|
+
}
|
|
1247
|
+
function hostDecorationList(group) {
|
|
1248
|
+
return control.getDecorations().filter((item) => item.key.startsWith("host:") && (group === undefined || item.data.group === group))
|
|
1249
|
+
.map((item) => ({ group: item.data.group, kind: item.data.kind, ...(item.title ? { label: item.title } : {}), ...(item.data.path !== undefined ? { path: item.data.path } : {}), from: item.from, to: item.to, line: item.line }));
|
|
1250
|
+
}
|
|
1251
|
+
function idle() {
|
|
1252
|
+
return new Promise((resolve) => {
|
|
1253
|
+
const poll = () => { if (destroyed || settled()) resolve(); else setTimeout(poll, 4); };
|
|
1254
|
+
poll();
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1140
1258
|
// ---- Public API ---------------------------------------------------------------------------------------------------
|
|
1141
1259
|
function focusPath(path, finding) {
|
|
1142
1260
|
flush();
|
|
@@ -1220,15 +1338,36 @@ export function createSourcePane(container, options = {}) {
|
|
|
1220
1338
|
getOutline: () => documentSymbols(format, control.api.getValue()),
|
|
1221
1339
|
/** Show or hide the outline panel (needs the `outline` option); with no argument it toggles. Returns whether the panel is open. */
|
|
1222
1340
|
toggleOutline(open) { if (!outlinePanel) return false; setOutlineOpen(open ?? outlinePanel.hidden); return !outlinePanel.hidden; },
|
|
1341
|
+
/**
|
|
1342
|
+
* CE-17: the path under the caret: `{ pointer, idPath, from, to, head, part, format }` (offsets are source offsets), also when the pane has no focus.
|
|
1343
|
+
* Read now, without waiting for the pending edit or the pause `onCursor` uses.
|
|
1344
|
+
*/
|
|
1345
|
+
getCursor: () => readCursor(),
|
|
1346
|
+
/**
|
|
1347
|
+
* CE-17: scroll a JSON Pointer (or dotted path) or a range `{ from, to }` into view without moving the caret or the focus; folded text is opened.
|
|
1348
|
+
* `focus` and `select` do move them. `align`: "center" (default), "start" or "nearest"; `flash` marks the first line for a moment; `part`: which part of
|
|
1349
|
+
* the field a path means (default `entry`: the key through its value). Returns the range, its line and whether the path is exactly there, or `null`.
|
|
1350
|
+
*/
|
|
1351
|
+
revealPath: revealTarget,
|
|
1352
|
+
/**
|
|
1353
|
+
* CE-17: draw marks (or line classes) a host owns, by path or range, apart from the editor's own decorations. `group` (default `default`) names a set, and a
|
|
1354
|
+
* call replaces it. A range follows the text as it is edited; a path is placed again after each edit, and not drawn while the text has no such field.
|
|
1355
|
+
* `[]` or `null` clears the set.
|
|
1356
|
+
*/
|
|
1357
|
+
setDecorations: setHostDecorations,
|
|
1358
|
+
/** CE-17: clear the decorations of `group`, or every host decoration without an argument. */
|
|
1359
|
+
clearDecorations(group) {
|
|
1360
|
+
if (destroyed) return;
|
|
1361
|
+
if (group !== undefined) { clearGroup(group); return; }
|
|
1362
|
+
for (const name of [...hostGroups.keys()]) clearGroup(name);
|
|
1363
|
+
control.clearDecorations();
|
|
1364
|
+
},
|
|
1365
|
+
/** CE-17: the host decorations drawn now at their current offsets: `{ group, kind, label?, path?, from, to, line }`. */
|
|
1366
|
+
getDecorations: (group) => hostDecorationList(group),
|
|
1223
1367
|
/** The code editor, for hosts that focus or measure it. */
|
|
1224
1368
|
editor: control,
|
|
1225
1369
|
/** Resolves once no edit is waiting for its delay and no check is running (a worker's included): the text on screen has been read and applied. */
|
|
1226
|
-
whenIdle
|
|
1227
|
-
return new Promise((resolve) => {
|
|
1228
|
-
const poll = () => { if (destroyed || (timer === null && syntaxTimer === null && !checkBusy && !syntaxBusy)) resolve(); else setTimeout(poll, 4); };
|
|
1229
|
-
poll();
|
|
1230
|
-
});
|
|
1231
|
-
},
|
|
1370
|
+
whenIdle: idle,
|
|
1232
1371
|
/** How the applied edits were checked: `worker` and `inline` counts, and whether the worker is still usable (`available`). */
|
|
1233
1372
|
checkerStats: () => ({ worker: stats.worker, inline: stats.inline, available: Boolean(checker?.available) }),
|
|
1234
1373
|
destroy() {
|
|
@@ -1243,6 +1382,9 @@ export function createSourcePane(container, options = {}) {
|
|
|
1243
1382
|
clearTimeout(recheckTimer);
|
|
1244
1383
|
clearTimeout(outlineTimer);
|
|
1245
1384
|
clearTimeout(outlineMarkTimer);
|
|
1385
|
+
clearTimeout(cursorTimer);
|
|
1386
|
+
clearTimeout(decorationTimer);
|
|
1387
|
+
clearTimeout(flashTimer);
|
|
1246
1388
|
unsubscribe();
|
|
1247
1389
|
document.removeEventListener("pointerdown", outside, true);
|
|
1248
1390
|
control.destroy();
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** CE-18 studio kit (internal): the command registry and keymap. */
|
|
2
|
+
export interface KitCommand<Ctx = unknown> {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
group?: string;
|
|
6
|
+
/** The scope(s) `handle` runs it in (default "global"). */
|
|
7
|
+
scope?: string | string[];
|
|
8
|
+
/** CodeMirror-style key specs: "Mod-z", "Shift-Mod-z", "Ctrl-y", "[", "Delete", "F2". */
|
|
9
|
+
keys?: string[];
|
|
10
|
+
/** What a menu shows instead of the formatted first key. */
|
|
11
|
+
hint?: string;
|
|
12
|
+
/** false: a held key does not repeat the command. */
|
|
13
|
+
repeat?: boolean;
|
|
14
|
+
/** true runs it; false or a reason string disables it. */
|
|
15
|
+
when?(ctx: Ctx): boolean | string;
|
|
16
|
+
run(ctx: Ctx, event?: KeyboardEvent): unknown;
|
|
17
|
+
}
|
|
18
|
+
export interface KitCommandInfo { id: string; label: string; group?: string; keys: string[]; shortcut: string; enabled: boolean; reason: string }
|
|
19
|
+
export interface KitKey { key: string; shift: boolean; alt: boolean; mod: boolean; ctrl: boolean; meta: boolean }
|
|
20
|
+
export interface KitCommandRegistry<Ctx = unknown> {
|
|
21
|
+
register(command: KitCommand<Ctx>): () => void;
|
|
22
|
+
get(id: string): KitCommand<Ctx> | undefined;
|
|
23
|
+
has(id: string): boolean;
|
|
24
|
+
setKeymap(map: Record<string, string | string[] | null>): void;
|
|
25
|
+
list(ctx: Ctx, options?: { win?: Window }): KitCommandInfo[];
|
|
26
|
+
enabled(id: string, ctx: Ctx): boolean;
|
|
27
|
+
run(id: string, ctx: Ctx, event?: KeyboardEvent): boolean;
|
|
28
|
+
match(event: KeyboardEvent, ctx: Ctx, options?: { scope?: string }): KitCommand<Ctx> | null;
|
|
29
|
+
handle(event: KeyboardEvent, ctx: Ctx, options?: { scope?: string }): boolean;
|
|
30
|
+
menu(ids: string[], ctx: Ctx, options?: { win?: Window }): { id: string; label: string; hint?: string; disabled: boolean; title?: string; onSelect(): void }[];
|
|
31
|
+
}
|
|
32
|
+
export declare function createCommandRegistry<Ctx = unknown>(options?: { commands?: KitCommand<Ctx>[] }): KitCommandRegistry<Ctx>;
|
|
33
|
+
export declare function parseKey(spec: string): KitKey;
|
|
34
|
+
export declare function matchKey(event: Pick<KeyboardEvent, "key" | "code" | "shiftKey" | "altKey" | "ctrlKey" | "metaKey">, spec: string | KitKey): boolean;
|
|
35
|
+
export declare function formatKey(spec: string | KitKey, win?: Window): string;
|
|
36
|
+
export declare function isHandled(event: Event): boolean;
|
|
37
|
+
export declare function markHandled(event: Event): void;
|
|
38
|
+
export declare function keyGuard(event: KeyboardEvent): boolean;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// CE-18, the studio kit: the command registry and keymap. A command is `{ id, label, group, scope, keys, hint, repeat, when, run }`; the
|
|
2
|
+
// keyboard, the context menu, the bar's disabled states, the shortcut sheet and a host palette all read one registry, so they cannot drift.
|
|
3
|
+
// Keys are CodeMirror-style strings ("Mod-z", "Shift-Mod-z", "Ctrl-y", "[", "Shift-[", "Delete", "F2"). No DOM beyond the event it is given.
|
|
4
|
+
import { isMac, isTypingTarget } from "./dom.js";
|
|
5
|
+
|
|
6
|
+
const HANDLED = "opfStudioHandled";
|
|
7
|
+
/** Whether an earlier handler already acted on this key event. */
|
|
8
|
+
export const isHandled = (event) => Boolean(event?.[HANDLED]);
|
|
9
|
+
/** Mark a key event as acted on, so the handlers it bubbles to leave it alone. */
|
|
10
|
+
export const markHandled = (event) => { event[HANDLED] = true; };
|
|
11
|
+
/** Whether a shortcut may act on this event: not handled already, not prevented, not typed into a field, not an IME composition. */
|
|
12
|
+
export const keyGuard = (event) => !(isHandled(event) || event.defaultPrevented || isTypingTarget(event.target) || event.isComposing);
|
|
13
|
+
|
|
14
|
+
// A key can also be matched by its physical position, so a keyboard layout where "[" needs AltGr (or Shift turns it into "{") still has it.
|
|
15
|
+
const CODES = { "[": "BracketLeft", "]": "BracketRight" };
|
|
16
|
+
const MODIFIERS = new Set(["Shift", "Alt", "Mod", "Ctrl", "Meta", "Cmd"]);
|
|
17
|
+
|
|
18
|
+
/** Parse "Shift-Mod-z" into `{ key, shift, alt, mod, ctrl, meta }`. A trailing "-" is the minus key ("Mod--"). */
|
|
19
|
+
export function parseKey(spec) {
|
|
20
|
+
const text = String(spec);
|
|
21
|
+
if (text === "-") return { key: "-", shift: false, alt: false, mod: false, ctrl: false, meta: false };
|
|
22
|
+
const parts =text.endsWith("-") && text.length > 1 ? [...text.slice(0, -2).split("-").filter(Boolean), "-"] : text.split("-");
|
|
23
|
+
const key = parts.pop();
|
|
24
|
+
const out = { key, shift: false, alt: false, mod: false, ctrl: false, meta: false };
|
|
25
|
+
for (const part of parts) {
|
|
26
|
+
if (!MODIFIERS.has(part)) throw new TypeError(`Unknown modifier ${part} in ${spec}`);
|
|
27
|
+
if (part === "Cmd") out.meta = true; else out[part.toLowerCase()] = true;
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether a keyboard event matches one key spec. Shift and Alt must be exactly as listed. `Mod` is satisfied by Cmd or Ctrl on every platform
|
|
34
|
+
* (so a Windows keyboard on a Mac works), `Ctrl` by Ctrl and `Meta`/`Cmd` by Cmd; with none of the three listed, neither Cmd nor Ctrl may be
|
|
35
|
+
* down. A one-character key compares without case; "[" and "]" also match their physical key.
|
|
36
|
+
*/
|
|
37
|
+
export function matchKey(event, spec) {
|
|
38
|
+
const want = typeof spec === "string" ? parseKey(spec) : spec;
|
|
39
|
+
if (Boolean(event.shiftKey) !== want.shift || Boolean(event.altKey) !== want.alt) return false;
|
|
40
|
+
if (want.mod || want.ctrl || want.meta) {
|
|
41
|
+
if (want.mod && !(event.metaKey || event.ctrlKey)) return false;
|
|
42
|
+
if (want.ctrl && !event.ctrlKey) return false;
|
|
43
|
+
if (want.meta && !event.metaKey) return false;
|
|
44
|
+
} else if (event.metaKey || event.ctrlKey) return false;
|
|
45
|
+
const key = event.key ?? "";
|
|
46
|
+
if (want.key.length === 1) return key.toLowerCase() === want.key.toLowerCase() || (CODES[want.key] !== undefined && event.code === CODES[want.key]);
|
|
47
|
+
return key === want.key;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const MAC_NAMES = { shift: "⇧", alt: "⌥", ctrl: "⌃", mod: "⌘", meta: "⌘" };
|
|
51
|
+
const PC_NAMES = { shift: "Shift+", alt: "Alt+", ctrl: "Ctrl+", mod: "Ctrl+", meta: "Win+" };
|
|
52
|
+
const KEY_NAMES = { " ": "Space", ArrowUp: "↑", ArrowDown: "↓", ArrowLeft: "←", ArrowRight: "→", Escape: "Esc" };
|
|
53
|
+
/** A key spec as people read it: "⌘D" on a Mac, "Ctrl+D" elsewhere. */
|
|
54
|
+
export function formatKey(spec, win) {
|
|
55
|
+
const want = typeof spec === "string" ? parseKey(spec) : spec;
|
|
56
|
+
const mac = isMac(win);
|
|
57
|
+
const names = mac ? MAC_NAMES : PC_NAMES;
|
|
58
|
+
const order = mac ? ["ctrl", "alt", "shift", "mod", "meta"] : ["ctrl", "mod", "meta", "alt", "shift"];
|
|
59
|
+
const mods = order.filter((name) => want[name] && !(name === "ctrl" && want.mod)).map((name) => names[name]).join("");
|
|
60
|
+
const key = KEY_NAMES[want.key] ?? (want.key.length === 1 ? want.key.toUpperCase() : want.key);
|
|
61
|
+
return mods + key;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A command registry. `register(command)` returns the function that removes it. A command has an `id`, a `label`, optional `group`,
|
|
66
|
+
* `scope` (a name or a list of names; `handle` runs only the commands of the scope it is given; default "global"), `keys` (key specs),
|
|
67
|
+
* `hint` (what a menu shows instead of the formatted first key), `repeat` (false: a held key does not repeat it), `when(ctx)` (true, false,
|
|
68
|
+
* or a reason string that disables it) and `run(ctx, event)`.
|
|
69
|
+
*/
|
|
70
|
+
export function createCommandRegistry({ commands = [] } = {}) {
|
|
71
|
+
const byId = new Map();
|
|
72
|
+
const overrides = new Map();
|
|
73
|
+
const keysOf = (command) => (overrides.has(command.id) ? overrides.get(command.id) : command.keys) ?? [];
|
|
74
|
+
const scopesOf = (command) => [].concat(command.scope ?? "global");
|
|
75
|
+
const state = (command, ctx) => {
|
|
76
|
+
const verdict = command.when ? command.when(ctx) : true;
|
|
77
|
+
return verdict === true ? { enabled: true, reason: "" } : { enabled: false, reason: typeof verdict === "string" ? verdict : "" };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const registry = {
|
|
81
|
+
register(command) {
|
|
82
|
+
if (!command?.id || typeof command.run !== "function") throw new TypeError("A command needs an id and a run function.");
|
|
83
|
+
if (byId.has(command.id)) throw new Error(`A command ${command.id} is already registered.`);
|
|
84
|
+
const parsed = { ...command };
|
|
85
|
+
byId.set(command.id, parsed);
|
|
86
|
+
return () => { if (byId.get(command.id) === parsed) byId.delete(command.id); };
|
|
87
|
+
},
|
|
88
|
+
get: (id) => byId.get(id),
|
|
89
|
+
has: (id) => byId.has(id),
|
|
90
|
+
/** Rebind commands: `{ id: keys }` replaces a command's keys, `{ id: null }` unbinds it. */
|
|
91
|
+
setKeymap(map = {}) {
|
|
92
|
+
for (const [id, keys] of Object.entries(map)) overrides.set(id, keys === null ? [] : [].concat(keys));
|
|
93
|
+
},
|
|
94
|
+
/** Every command with its state for `ctx`: `{ id, label, group, keys, shortcut, enabled, reason }`, in registration order. */
|
|
95
|
+
list(ctx, { win } = {}) {
|
|
96
|
+
return [...byId.values()].map((command) => {
|
|
97
|
+
const keys = keysOf(command);
|
|
98
|
+
return { id: command.id, label: command.label, group: command.group, keys: [...keys], shortcut: command.hint ?? (keys[0] ? formatKey(keys[0], win) : ""), ...state(command, ctx) };
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
/** Whether `id` may run for `ctx` (`when` returned true). */
|
|
102
|
+
enabled: (id, ctx) => (byId.has(id) ? state(byId.get(id), ctx).enabled : false),
|
|
103
|
+
/** Run `id` for `ctx` when it is enabled. Returns false when it is unknown or disabled, else what `run` returned (true when it returned nothing). */
|
|
104
|
+
run(id, ctx, event) {
|
|
105
|
+
const command = byId.get(id);
|
|
106
|
+
if (!command || !state(command, ctx).enabled) return false;
|
|
107
|
+
const result = command.run(ctx, event);
|
|
108
|
+
return result === undefined ? true : Boolean(result);
|
|
109
|
+
},
|
|
110
|
+
/** The command of `scope` a key event would run: the first whose keys match and whose `when` is true. */
|
|
111
|
+
match(event, ctx, { scope = "global" } = {}) {
|
|
112
|
+
for (const command of byId.values()) {
|
|
113
|
+
if (!scopesOf(command).includes(scope)) continue;
|
|
114
|
+
if (command.repeat === false && event.repeat) continue;
|
|
115
|
+
if (!keysOf(command).some((spec) => matchKey(event, spec))) continue;
|
|
116
|
+
if (state(command, ctx).enabled) return command;
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
},
|
|
120
|
+
/**
|
|
121
|
+
* Act on a key event: the guard (`keyGuard`), then the matching command of `scope`. The event is prevented and marked handled, and true
|
|
122
|
+
* is returned, whether or not the command changed anything.
|
|
123
|
+
*/
|
|
124
|
+
handle(event, ctx, { scope = "global" } = {}) {
|
|
125
|
+
if (!keyGuard(event)) return false;
|
|
126
|
+
const command = registry.match(event, ctx, { scope });
|
|
127
|
+
if (!command) return false;
|
|
128
|
+
event.preventDefault();
|
|
129
|
+
command.run(ctx, event);
|
|
130
|
+
markHandled(event);
|
|
131
|
+
return true;
|
|
132
|
+
},
|
|
133
|
+
/**
|
|
134
|
+
* Menu items for `openMenu` from command ids, in order: `{ id, label, hint, disabled, title, onSelect }`. The state is read when the menu
|
|
135
|
+
* is built; a disabled item cannot be chosen, and choosing an enabled one runs the command for the `ctx` the menu was built with.
|
|
136
|
+
*/
|
|
137
|
+
menu(ids, ctx, { win } = {}) {
|
|
138
|
+
return ids.filter((id) => byId.has(id)).map((id) => {
|
|
139
|
+
const command = byId.get(id);
|
|
140
|
+
const keys = keysOf(command);
|
|
141
|
+
const { enabled, reason } = state(command, ctx);
|
|
142
|
+
return { id, label: command.label, hint: command.hint ?? (keys[0] ? formatKey(keys[0], win) : undefined), disabled: !enabled, title: reason || undefined, onSelect: () => command.run(ctx) };
|
|
143
|
+
});
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
for (const command of commands) registry.register(command);
|
|
147
|
+
return registry;
|
|
148
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** CE-18 studio kit (internal): DOM helpers, icons, the menu and the typing-target guard. */
|
|
2
|
+
export declare const ICONS: Readonly<Record<string, string>>;
|
|
3
|
+
/** The default class prefix of kit parts ("opf-kit"); Layout Studio passes "opf-lx". */
|
|
4
|
+
export declare const KIT_PREFIX: string;
|
|
5
|
+
export declare function nextId(prefix: string): string;
|
|
6
|
+
export interface KitButtonOptions {
|
|
7
|
+
label?: string;
|
|
8
|
+
text?: string;
|
|
9
|
+
iconName?: string;
|
|
10
|
+
size?: number;
|
|
11
|
+
pressed?: boolean;
|
|
12
|
+
title?: string;
|
|
13
|
+
attributes?: Record<string, string | number | boolean | undefined | null>;
|
|
14
|
+
}
|
|
15
|
+
export interface KitDom {
|
|
16
|
+
doc: Document;
|
|
17
|
+
prefix: string;
|
|
18
|
+
el<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, attributes?: Record<string, string | number | boolean | undefined | null>): HTMLElementTagNameMap[K];
|
|
19
|
+
icon(name: string, size?: number): SVGSVGElement;
|
|
20
|
+
button(className: string, options?: KitButtonOptions): HTMLButtonElement;
|
|
21
|
+
}
|
|
22
|
+
export declare function createDom(doc: Document, options?: { prefix?: string }): KitDom;
|
|
23
|
+
export declare function isTypingTarget(target: EventTarget | null): boolean;
|
|
24
|
+
export declare function isMac(win?: Window | null): boolean;
|
|
25
|
+
/** A unit fraction as a CSS percentage. */
|
|
26
|
+
export declare function pct(fraction: number): string;
|
|
27
|
+
export interface KitMenuItem {
|
|
28
|
+
label: string;
|
|
29
|
+
onSelect?(): void;
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
role?: "menuitem" | "menuitemcheckbox";
|
|
32
|
+
checked?: boolean;
|
|
33
|
+
hint?: string;
|
|
34
|
+
}
|
|
35
|
+
export interface KitMenu { element: HTMLElement; close(options?: { restore?: boolean }): void }
|
|
36
|
+
export declare function openMenu(dom: KitDom, host: HTMLElement, options: { x: number; y: number; items: KitMenuItem[]; label: string; returnFocus?: HTMLElement | null; onClose?(): void; className?: string }): KitMenu;
|