@openpresentation/opf-editor 0.26.4 → 0.26.6
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 +8 -1
- package/dist/index.d.ts +16 -1
- package/dist/index.js +9 -2
- package/dist/json-editor.d.ts +20 -0
- package/dist/json-editor.js +189 -91
- package/dist/source-a11y.js +18 -1
- package/dist/source-check.js +90 -0
- package/dist/source-checker.js +130 -0
- package/dist/source-completions.js +232 -22
- package/dist/source-details.js +17 -0
- package/dist/source-diagnostics.js +3 -2
- package/dist/source-editing.js +53 -33
- package/dist/source-fixes-syntax.js +334 -0
- package/dist/source-fixes-text.js +75 -2
- package/dist/source-fixes.js +25 -1
- package/dist/source-folding.js +173 -0
- package/dist/source-hints.js +376 -0
- package/dist/source-keymap.js +369 -0
- package/dist/source-markdown.js +53 -17
- package/dist/source-messages.js +21 -205
- package/dist/source-navigation-model.js +426 -0
- package/dist/source-navigation-read.js +269 -0
- package/dist/source-navigation-ui.js +244 -0
- package/dist/source-navigation.js +217 -0
- package/dist/source-outline.js +112 -0
- package/dist/source-pane.d.ts +63 -0
- package/dist/source-pane.js +240 -45
- package/dist/source-problems.js +3 -0
- package/dist/source-references.js +274 -0
- package/dist/source-shortcuts.js +106 -0
- package/dist/source-snippets.js +20 -0
- package/dist/source-symbols.js +156 -0
- package/dist/source-undo-groups.js +46 -0
- package/dist/source-worker.d.ts +7 -0
- package/dist/source-worker.js +30 -0
- package/dist/source-yaml.js +94 -9
- package/dist/styles.css +288 -0
- package/package.json +12 -7
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// Cross-references inside the document as completion values (artifacts/source-editor/SPEC.md, section 3.4): `asset:<id>` from `assets`, dataset ids
|
|
2
|
+
// and column names from `datasets`, `var:<id>` and `{{name}}` from `variables`, `cite` ids from `references`, `beat` ids from the deck's narrative and
|
|
3
|
+
// `region` names from the slide's layout. Which field takes which kind of reference is ONE table (`REFERENCE_RULES`) read against the schema: a rule
|
|
4
|
+
// names the schema definitions whose fields are references (`Asset`, `DatasetId`, ...) and the property names, with a phrase the property's own
|
|
5
|
+
// description must carry (any of `phrases`), for the fields written inline in their parent. test/source-completions-units.mjs checks every entry against the schema,
|
|
6
|
+
// so a renamed definition or a reworded description fails the test instead of silently dropping a list. Plain functions, no DOM.
|
|
7
|
+
import { parse as parseYaml } from "yaml";
|
|
8
|
+
import { describeField } from "@openpresentation/opf/schema";
|
|
9
|
+
import { listBuiltinVariables } from "@openpresentation/opf";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The rules. `definitions`: a field whose own schema is that definition (`$defs` name). `keys` + `phrases`: a string field with one of those property
|
|
13
|
+
* names whose description contains one of the phrases. `item`: the field is a list (or a string or a list) and every entry is a reference.
|
|
14
|
+
*/
|
|
15
|
+
export const REFERENCE_RULES = Object.freeze([
|
|
16
|
+
{ kind: "asset", definitions: ["Asset", "LogoSource", "ImageSource"], keys: ["src", "logo", "watermark"], phrases: ["asset:<id>", "or Asset"] },
|
|
17
|
+
{ kind: "dataset", definitions: ["DatasetId"], keys: [], phrases: [] },
|
|
18
|
+
{ kind: "datasetColumn", definitions: ["DatasetFields"], keys: ["category", "x", "series", "line", "secondaryAxis"], phrases: ["column"], item: true },
|
|
19
|
+
{ kind: "cite", definitions: [], keys: ["cite"], phrases: ["references"], item: true },
|
|
20
|
+
{ kind: "beat", definitions: [], keys: ["beat"], phrases: ["narrative"], item: true },
|
|
21
|
+
{ kind: "region", definitions: [], keys: ["region"], phrases: ["template layout"] },
|
|
22
|
+
{ kind: "color", definitions: ["ColorRef"], keys: ["color"], phrases: ["var:<id>"] },
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/** The promoted regions of a slide a block can be pinned to (the lower-case names the `region` pattern accepts). */
|
|
26
|
+
export const PROMOTED_REGIONS = Object.freeze(["top", "middle", "bottom", "left", "center", "right"]);
|
|
27
|
+
|
|
28
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
29
|
+
const isIndex = (segment) => typeof segment === "number" || /^(?:0|[1-9]\d*)$/.test(String(segment));
|
|
30
|
+
|
|
31
|
+
function ruleOf(info, key) {
|
|
32
|
+
if (!info?.known || !(info.types.includes("string") || info.types.includes("array"))) return null;
|
|
33
|
+
for (const rule of REFERENCE_RULES) {
|
|
34
|
+
if (info.definition && rule.definitions.includes(info.definition)) return rule;
|
|
35
|
+
if (key !== undefined && rule.keys.includes(key) && rule.phrases.some((phrase) => (info.fullDescription ?? info.description ?? "").includes(phrase))) return rule;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What the value at `path` of `document` references: `{ kind, path }` (`path` is the field, for a list entry the list's), or null. A list entry
|
|
42
|
+
* (`cite: ["a", |]`, `fields: [|]`) is the entry of the list field the rule names; a field that can only be a list takes its references one by one.
|
|
43
|
+
*/
|
|
44
|
+
export function referenceSiteAt(path, document) {
|
|
45
|
+
if (!Array.isArray(path) || !path.length) return null;
|
|
46
|
+
const last = path.at(-1);
|
|
47
|
+
const describe = (at) => { try { return describeField(at, document); } catch { return null; } };
|
|
48
|
+
if (!isIndex(last)) {
|
|
49
|
+
const info = describe(path);
|
|
50
|
+
const rule = ruleOf(info, String(last));
|
|
51
|
+
const listOnly = info?.types.length === 1 && info.types[0] === "array";
|
|
52
|
+
return rule && !listOnly ? { kind: rule.kind, path } : null;
|
|
53
|
+
}
|
|
54
|
+
// An entry whose own schema is a reference definition (`chart.colors[i]`: a ColorRef) is a reference whatever the list is called.
|
|
55
|
+
const own = describe(path);
|
|
56
|
+
const byDefinition = own?.known && own.definition ? REFERENCE_RULES.find((rule) => rule.definitions.includes(own.definition)) : undefined;
|
|
57
|
+
if (byDefinition && !own.types.every((type) => type === "array")) return { kind: byDefinition.kind, path };
|
|
58
|
+
const field = path.slice(0, -1);
|
|
59
|
+
while (field.length && isIndex(field.at(-1))) field.pop();
|
|
60
|
+
if (!field.length) return null;
|
|
61
|
+
const rule = ruleOf(describe(field), String(field.at(-1)));
|
|
62
|
+
return rule?.item ? { kind: rule.kind, path: field } : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---- the facts of a document -------------------------------------------------------------------------------------------------------
|
|
66
|
+
const stringOf = (value) => (typeof value === "string" ? value : undefined);
|
|
67
|
+
const columnName = (column) => (typeof column === "string" ? column : isObject(column) && typeof column.name === "string" ? column.name : undefined);
|
|
68
|
+
|
|
69
|
+
// The top-level parts of a deck the built-in variables read (`{{deck.name}}`, `{{speaker.name}}`, `{{organization.name}}`).
|
|
70
|
+
const BUILTIN_SOURCES = ["name", "description", "author", "speaker", "organization"];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The parts of a deck the references read, from the plain value of the top level (`assets`, `datasets`, `variables`, `references`, `narrative`, and
|
|
74
|
+
* `name`, `description`, `author`, `speaker`, `organization` for the built-in variables):
|
|
75
|
+
* `{ assets: [{ id, detail, src, mediaType }], datasets: [{ id, title, columns }], variables: [{ id, type }], references: [{ id, text }], narrative, deck }`
|
|
76
|
+
* where `deck` holds the built-in sources the draft sets. Anything else the value holds, and anything malformed, is ignored: the text being edited is a draft.
|
|
77
|
+
*/
|
|
78
|
+
export function factsOf(deck) {
|
|
79
|
+
const out = { assets: [], datasets: [], variables: [], references: [], narrative: undefined, deck: {} };
|
|
80
|
+
if (!isObject(deck)) return out;
|
|
81
|
+
if (isObject(deck.assets)) {
|
|
82
|
+
for (const [id, asset] of Object.entries(deck.assets)) out.assets.push({ id, detail: stringOf(asset) ?? stringOf(asset?.src) ?? stringOf(asset?.title) ?? "", src: stringOf(asset) ?? stringOf(asset?.src) ?? "", mediaType: stringOf(asset?.mediaType) ?? "" });
|
|
83
|
+
}
|
|
84
|
+
if (isObject(deck.datasets)) {
|
|
85
|
+
for (const [id, dataset] of Object.entries(deck.datasets)) {
|
|
86
|
+
const columns = Array.isArray(dataset?.columns) ? dataset.columns.map(columnName).filter(Boolean) : [];
|
|
87
|
+
out.datasets.push({ id, title: stringOf(dataset?.title) ?? "", columns });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (isObject(deck.variables)) {
|
|
91
|
+
for (const [id, variable] of Object.entries(deck.variables)) {
|
|
92
|
+
// A hex string is shorthand for a color variable.
|
|
93
|
+
const type = typeof variable === "string" ? "color" : stringOf(variable?.type) ?? "color";
|
|
94
|
+
out.variables.push({ id, type });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (Array.isArray(deck.references)) {
|
|
98
|
+
for (const reference of deck.references) if (isObject(reference) && typeof reference.id === "string" && reference.id) out.references.push({ id: reference.id, text: stringOf(reference.text) ?? "" });
|
|
99
|
+
}
|
|
100
|
+
out.narrative = stringOf(deck.narrative);
|
|
101
|
+
for (const key of BUILTIN_SOURCES) if (deck[key] !== undefined) out.deck[key] = deck[key];
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const TOP_LEVEL = ["assets", "datasets", "variables", "references", "narrative", ...BUILTIN_SOURCES];
|
|
106
|
+
|
|
107
|
+
/** The top-level parts of a JSON draft the references read (`root` is a jsonc-parser tree), as a plain value for `factsOf`. */
|
|
108
|
+
export function jsonDeckParts(root, getNodeValue) {
|
|
109
|
+
const deck = {};
|
|
110
|
+
if (root?.type !== "object") return deck;
|
|
111
|
+
for (const property of root.children ?? []) {
|
|
112
|
+
const key = property.children?.[0]?.value, value = property.children?.[1];
|
|
113
|
+
if (!value || !TOP_LEVEL.includes(key)) continue;
|
|
114
|
+
if (key === "datasets" && value.type === "object") {
|
|
115
|
+
// Only the names and the columns: the rows can be the bulk of a deck.
|
|
116
|
+
deck.datasets = {};
|
|
117
|
+
for (const entry of value.children ?? []) {
|
|
118
|
+
const id = entry.children?.[0]?.value, body = entry.children?.[1];
|
|
119
|
+
if (typeof id !== "string" || body?.type !== "object") continue;
|
|
120
|
+
const columns = (body.children ?? []).find((child) => child.children?.[0]?.value === "columns")?.children?.[1];
|
|
121
|
+
const title = (body.children ?? []).find((child) => child.children?.[0]?.value === "title")?.children?.[1];
|
|
122
|
+
deck.datasets[id] = { columns: columns ? getNodeValue(columns) : [], title: title?.type === "string" ? title.value : undefined };
|
|
123
|
+
}
|
|
124
|
+
} else deck[key] = getNodeValue(value);
|
|
125
|
+
}
|
|
126
|
+
return deck;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
/** The text of the top-level blocks of a YAML text that hold the keys the references read, parsed (tolerant: a broken block gives nothing). */
|
|
131
|
+
export function yamlDeckParts(text) {
|
|
132
|
+
const lines = text.split(/\r\n|\r|\n/);
|
|
133
|
+
const deck = {};
|
|
134
|
+
for (let number = 0; number < lines.length; number++) {
|
|
135
|
+
const match = /^([A-Za-z]+)\s*:/.exec(lines[number]);
|
|
136
|
+
if (!match || !TOP_LEVEL.includes(match[1])) continue;
|
|
137
|
+
let end = number + 1;
|
|
138
|
+
while (end < lines.length && (lines[end].trim() === "" || /^[ \t]/.test(lines[end]) || lines[end].startsWith("- "))) end++;
|
|
139
|
+
try {
|
|
140
|
+
const value = parseYaml(lines.slice(number, end).join("\n"), { strict: false, uniqueKeys: false });
|
|
141
|
+
if (isObject(value) && match[1] in value) deck[match[1]] = value[match[1]];
|
|
142
|
+
} catch { /* A block that does not parse offers nothing. */ }
|
|
143
|
+
}
|
|
144
|
+
return deck;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `value` at `path` of a plain document, or undefined. */
|
|
148
|
+
export function valueAt(value, path) {
|
|
149
|
+
let current = value;
|
|
150
|
+
for (const step of path) {
|
|
151
|
+
if (current === null || typeof current !== "object") return undefined;
|
|
152
|
+
current = current[step];
|
|
153
|
+
}
|
|
154
|
+
return current;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* What a column field reads its names from: `{ dataset }` (the id of the dataset: a `dataset` beside the field, or the chart's `data.dataset`) or
|
|
159
|
+
* `{ inline }` (the `columns` of the chart's own `data`), looking outward from the field; null when the draft says neither. `read(path)` gives a
|
|
160
|
+
* value of the document.
|
|
161
|
+
*/
|
|
162
|
+
export function columnSourceFor(site, read) {
|
|
163
|
+
for (let depth = site.path.length - 1; depth >= 0; depth--) {
|
|
164
|
+
const owner = site.path.slice(0, depth);
|
|
165
|
+
const direct = read([...owner, "dataset"]);
|
|
166
|
+
if (typeof direct === "string") return { dataset: direct };
|
|
167
|
+
const via = read([...owner, "data", "dataset"]);
|
|
168
|
+
if (typeof via === "string") return { dataset: via };
|
|
169
|
+
const inline = read([...owner, "data", "columns"]);
|
|
170
|
+
if (Array.isArray(inline)) return { inline: inline.map(columnName).filter(Boolean) };
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The names of the regions of a layout record a block can be pinned to (the title and the subtitle are not regions of the body). */
|
|
176
|
+
export function regionNames(record) {
|
|
177
|
+
const names = [];
|
|
178
|
+
const walk = (regions) => {
|
|
179
|
+
for (const region of Array.isArray(regions) ? regions : []) {
|
|
180
|
+
if (typeof region?.name === "string" && region.name !== "title" && region.name !== "subtitle") names.push(region.name);
|
|
181
|
+
if (region?.regions) walk(region.regions);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
walk(record?.regions);
|
|
185
|
+
return names;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The regions of a layout record in reading order (nested ones included) as `{ name, accepts, role?, min? }`. */
|
|
189
|
+
export function flatRegions(record) {
|
|
190
|
+
const out = [];
|
|
191
|
+
const walk = (regions) => {
|
|
192
|
+
for (const region of Array.isArray(regions) ? regions : []) {
|
|
193
|
+
if (typeof region?.name === "string") out.push({ name: region.name, accepts: Array.isArray(region.accepts) ? region.accepts : [], ...(typeof region.role === "string" ? { role: region.role } : {}), ...(Number.isFinite(region.min) ? { min: region.min } : {}) });
|
|
194
|
+
if (region?.regions) walk(region.regions);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
walk(record?.regions);
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The slide key that holds content of a kind a region accepts.
|
|
202
|
+
const PAYLOAD_KEYS = { text: "text", list: "items", image: "image", video: "video", chart: "chart", table: "table", quote: "quote", code: "code", metric: "metric", timeline: "timeline" };
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The slide keys a layout's regions call for, in region order: a region named like a slide key (`title`, `items`) calls for that key; any other region
|
|
206
|
+
* that accepts one or two kinds of content (`media`: image, video) for the keys of those kinds. `keys` are the slide keys that exist.
|
|
207
|
+
*/
|
|
208
|
+
export function layoutKeys(record, keys) {
|
|
209
|
+
const wanted = [];
|
|
210
|
+
const add = (key) => { if (keys.includes(key) && !wanted.includes(key)) wanted.push(key); };
|
|
211
|
+
for (const region of flatRegions(record)) {
|
|
212
|
+
if (keys.includes(region.name)) add(region.name);
|
|
213
|
+
else if (region.accepts.length > 0 && region.accepts.length <= 2) for (const kind of region.accepts) add(PAYLOAD_KEYS[kind]);
|
|
214
|
+
}
|
|
215
|
+
return wanted;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The ids of the beats of a narrative record. */
|
|
219
|
+
export function beatsOf(record) {
|
|
220
|
+
return (Array.isArray(record?.beats) ? record.beats : []).filter((beat) => isObject(beat) && typeof beat.id === "string").map((beat) => ({ id: beat.id, name: stringOf(beat.name) ?? "", description: stringOf(beat.description) ?? "" }));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The names of the variables a `{{` in text can reference that the document declares, with their type. (The built-in ones are `builtinVariables`.)
|
|
225
|
+
*/
|
|
226
|
+
export function variableNames(facts) {
|
|
227
|
+
return facts.variables.map((variable) => ({ id: variable.id, type: variable.type }));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const asText = (value) => (typeof value === "string" ? value : Array.isArray(value) ? value.filter((entry) => typeof entry === "string").join(", ") : "");
|
|
231
|
+
const FIELD_WORDS = { legalName: "legal name", slideCount: "slide count" };
|
|
232
|
+
const fieldWord = (field) => FIELD_WORDS[field] ?? field;
|
|
233
|
+
|
|
234
|
+
/** What a built-in stands for, in a sentence (the name comes from core; the sentence is built from its parts). */
|
|
235
|
+
function builtinSentence(name) {
|
|
236
|
+
if (name === "speakers") return "The names of every speaker, joined with commas.";
|
|
237
|
+
const parts = name.split(".");
|
|
238
|
+
const field = fieldWord(parts.at(-1));
|
|
239
|
+
if (name === "slide.number") return "The displayed number of this slide, after pagination.";
|
|
240
|
+
if (name === "slide.section") return "The section of this slide.";
|
|
241
|
+
if (name === "deck.slideCount") return "The number of slides in the deck, after pagination.";
|
|
242
|
+
if (parts[0] === "deck") return `The ${field} of the deck.`;
|
|
243
|
+
if (parts[0] === "speaker") return parts.length === 3 ? `The ${field} of the speaker ${parts[1]}.` : `The ${field} of the first speaker.`;
|
|
244
|
+
if (parts[0] === "organization") return parts.length === 3 ? `The ${field} of the organization ${parts[1]}.` : `The ${field} of the primary organization.`;
|
|
245
|
+
return "A built-in variable.";
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const GROUP_RANK = { deck: 0, speaker: 1, speakers: 1, organization: 2, slide: 3 };
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The built-in variables a `{{` in text can reference (core's `listBuiltinVariables` over the deck parts the draft sets), grouped by prefix: `deck.*`,
|
|
252
|
+
* `speaker.*` and `speakers`, `organization.*`, then the per-slide ones. A logo is an image used only as a whole field, never inside `{{ }}`, so it is
|
|
253
|
+
* left out. Each is `{ name, kind, scope, available, doc, detail }`: `available` is false when the draft has no source for it (it resolves to nothing).
|
|
254
|
+
*/
|
|
255
|
+
export function builtinVariables(facts) {
|
|
256
|
+
let list = [];
|
|
257
|
+
try { list = listBuiltinVariables({ ...facts.deck, slides: [] }); } catch { list = []; }
|
|
258
|
+
return list
|
|
259
|
+
.filter((entry) => !(entry.scope === "slide" && entry.kind === "image"))
|
|
260
|
+
.map((entry, index) => ({ entry, index }))
|
|
261
|
+
.sort((a, b) => (GROUP_RANK[a.entry.name.split(".")[0]] ?? 9) - (GROUP_RANK[b.entry.name.split(".")[0]] ?? 9) || a.index - b.index)
|
|
262
|
+
.map(({ entry }) => {
|
|
263
|
+
const perSlide = entry.scope === "slide";
|
|
264
|
+
const shown = asText(entry.value);
|
|
265
|
+
const detail = perSlide ? "built-in · varies per slide" : entry.available ? `built-in${shown ? ` · ${shown.length > 24 ? `${shown.slice(0, 23)}…` : shown}` : ""}` : "built-in · not set";
|
|
266
|
+
const note = perSlide ? "" : entry.available ? "" : ` This document does not set it, so it resolves to nothing.`;
|
|
267
|
+
return { name: entry.name, kind: entry.kind, scope: entry.scope, available: perSlide || entry.available, label: entry.label, detail, doc: `${builtinSentence(entry.name)}${note}` };
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The slide a path of a deck sits in: `["slides", n]`, or null. */
|
|
272
|
+
export function slidePath(path) {
|
|
273
|
+
return path[0] === "slides" && isIndex(path[1]) ? [path[0], Number(path[1])] : null;
|
|
274
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// The keyboard shortcuts dialog (artifacts/source-editor/SPEC.md, section 8.6): every binding the editor installed, grouped and searchable, with the keys
|
|
2
|
+
// of the platform. The list is `getShortcuts()`, produced from the same table that installs the keymap (source-keymap.js), so it cannot drift from the keys.
|
|
3
|
+
// Cmd/Ctrl+K Cmd/Ctrl+S, F1 or the "?" button of the source pane open it; Escape closes it and returns focus to the editor. A host that wants its own
|
|
4
|
+
// dialog passes `onShowShortcuts(shortcuts)` and returns true. Plain DOM, loaded lazily.
|
|
5
|
+
const FOCUSABLE = 'button, input, [tabindex]:not([tabindex="-1"])';
|
|
6
|
+
let counter = 0;
|
|
7
|
+
const open = new WeakMap();
|
|
8
|
+
|
|
9
|
+
const element = (doc, tag, className, attributes = {}) => {
|
|
10
|
+
const node = doc.createElement(tag);
|
|
11
|
+
if (className) node.className = className;
|
|
12
|
+
for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value);
|
|
13
|
+
return node;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** The words a search looks in: title, group, id and the keys with `+`, `-` and spaces folded to spaces. */
|
|
17
|
+
const haystack = (row) => `${row.title} ${row.group} ${row.id} ${row.when ?? ""} ${row.keys.join(" ")}`.toLowerCase().replace(/[+-]/g, " ");
|
|
18
|
+
export const matches = (row, query) => {
|
|
19
|
+
const words = query.toLowerCase().replace(/[+-]/g, " ").split(/\s+/).filter(Boolean);
|
|
20
|
+
const text = haystack(row);
|
|
21
|
+
return words.every((word) => text.includes(word));
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Open the dialog for `view` (or close it when it is open). Returns true: the key was taken. */
|
|
25
|
+
export function showShortcuts(view, context) {
|
|
26
|
+
const shortcuts = context.getShortcuts();
|
|
27
|
+
if (context.options.onShowShortcuts?.(shortcuts) === true) return true;
|
|
28
|
+
const existing = open.get(view);
|
|
29
|
+
if (existing) { existing.close(); return true; }
|
|
30
|
+
const doc = view.dom.ownerDocument;
|
|
31
|
+
const host = view.dom.closest(".opf-ui") ?? doc.body;
|
|
32
|
+
const opener = doc.activeElement;
|
|
33
|
+
const id = `opf-shortcuts-${++counter}`;
|
|
34
|
+
const root = element(doc, "div", `opf-shortcuts${host === doc.body ? " opf-ui" : ""}`);
|
|
35
|
+
const dialog = element(doc, "div", "opf-shortcuts-dialog", { role: "dialog", "aria-modal": "true", "aria-labelledby": `${id}-title`, "aria-describedby": `${id}-help`, tabindex: "-1" });
|
|
36
|
+
const head = element(doc, "div", "opf-shortcuts-head");
|
|
37
|
+
const title = Object.assign(element(doc, "h2", "opf-shortcuts-title", { id: `${id}-title` }), { textContent: "Keyboard shortcuts" });
|
|
38
|
+
const closeButton = Object.assign(element(doc, "button", "opf-shortcuts-close", { type: "button", "aria-label": "Close the keyboard shortcuts" }), { textContent: "×" });
|
|
39
|
+
head.append(title, closeButton);
|
|
40
|
+
const help = Object.assign(element(doc, "p", "opf-shortcuts-help", { id: `${id}-help` }), { textContent: `Keys for ${context.platform === "mac" ? "macOS" : context.platform === "win" ? "Windows" : "Linux"}. Type to search by name or key; Escape closes.` });
|
|
41
|
+
const search = element(doc, "input", "opf-shortcuts-search", { type: "search", "aria-label": "Search the keyboard shortcuts", placeholder: "Search shortcuts", autocomplete: "off", spellcheck: "false" });
|
|
42
|
+
const status = element(doc, "div", "opf-shortcuts-status", { role: "status", "aria-live": "polite" });
|
|
43
|
+
const list = element(doc, "div", "opf-shortcuts-list", { tabindex: "0", role: "region", "aria-label": "Shortcuts" });
|
|
44
|
+
dialog.append(head, help, search, status, list);
|
|
45
|
+
root.append(dialog);
|
|
46
|
+
host.append(root);
|
|
47
|
+
|
|
48
|
+
const plural = (count) => `${count} ${count === 1 ? "shortcut" : "shortcuts"}`;
|
|
49
|
+
function render() {
|
|
50
|
+
const rows = shortcuts.filter((row) => matches(row, search.value));
|
|
51
|
+
list.replaceChildren();
|
|
52
|
+
let group = null, section = null;
|
|
53
|
+
for (const row of rows) {
|
|
54
|
+
if (row.group !== group) {
|
|
55
|
+
group = row.group;
|
|
56
|
+
section = element(doc, "section", "opf-shortcuts-group", { "aria-label": group });
|
|
57
|
+
section.append(Object.assign(element(doc, "h3", "opf-shortcuts-group-title"), { textContent: group }));
|
|
58
|
+
list.append(section);
|
|
59
|
+
}
|
|
60
|
+
const item = element(doc, "div", "opf-shortcuts-row", { "data-id": row.id });
|
|
61
|
+
const name = element(doc, "span", "opf-shortcuts-name");
|
|
62
|
+
name.append(document.createTextNode(row.title));
|
|
63
|
+
if (row.when) name.append(Object.assign(element(doc, "span", "opf-shortcuts-when"), { textContent: ` (${row.when})` }));
|
|
64
|
+
const keys = element(doc, "span", "opf-shortcuts-keys");
|
|
65
|
+
row.keys.forEach((keyText, index) => {
|
|
66
|
+
if (index) keys.append(Object.assign(element(doc, "span", "opf-shortcuts-or"), { textContent: "or" }));
|
|
67
|
+
for (const stroke of keyText.split(" ")) keys.append(Object.assign(element(doc, "kbd", "opf-shortcuts-key"), { textContent: stroke }));
|
|
68
|
+
});
|
|
69
|
+
item.append(name, keys);
|
|
70
|
+
section.append(item);
|
|
71
|
+
}
|
|
72
|
+
if (!rows.length) list.append(Object.assign(element(doc, "p", "opf-shortcuts-none"), { textContent: "No shortcut matches." }));
|
|
73
|
+
status.textContent = search.value ? `${plural(rows.length)} match` : plural(rows.length);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function focusables() { return [...dialog.querySelectorAll(FOCUSABLE)].filter((node) => node.getClientRects().length); }
|
|
77
|
+
function onKeydown(event) {
|
|
78
|
+
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); return; }
|
|
79
|
+
if (event.key !== "Tab") return;
|
|
80
|
+
const nodes = focusables();
|
|
81
|
+
if (!nodes.length) { event.preventDefault(); dialog.focus(); return; }
|
|
82
|
+
const first = nodes[0], last = nodes.at(-1), active = doc.activeElement;
|
|
83
|
+
if (event.shiftKey && (active === first || active === dialog)) { event.preventDefault(); last.focus(); }
|
|
84
|
+
else if (!event.shiftKey && active === last) { event.preventDefault(); first.focus(); }
|
|
85
|
+
else if (!dialog.contains(active)) { event.preventDefault(); first.focus(); }
|
|
86
|
+
}
|
|
87
|
+
function onFocusin(event) { if (!dialog.contains(event.target)) (focusables()[0] ?? dialog).focus(); }
|
|
88
|
+
function close() {
|
|
89
|
+
if (!open.has(view)) return;
|
|
90
|
+
open.delete(view);
|
|
91
|
+
doc.removeEventListener("focusin", onFocusin, true);
|
|
92
|
+
root.remove();
|
|
93
|
+
if (view.dom.isConnected) view.focus(); else if (opener?.isConnected) opener.focus();
|
|
94
|
+
context.say("Keyboard shortcuts closed");
|
|
95
|
+
}
|
|
96
|
+
open.set(view, { close });
|
|
97
|
+
root.addEventListener("keydown", onKeydown);
|
|
98
|
+
root.addEventListener("pointerdown", (event) => { if (event.target === root) close(); });
|
|
99
|
+
closeButton.addEventListener("click", close);
|
|
100
|
+
search.addEventListener("input", render);
|
|
101
|
+
doc.addEventListener("focusin", onFocusin, true);
|
|
102
|
+
render();
|
|
103
|
+
search.focus();
|
|
104
|
+
context.say(`Keyboard shortcuts, ${plural(shortcuts.length)}. Type to search, Escape to close.`);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
package/dist/source-snippets.js
CHANGED
|
@@ -76,6 +76,18 @@ export const SNIPPETS = Object.freeze([
|
|
|
76
76
|
{ prefix: "notes", where: "markdown", key: "notes", description: "Speaker notes for the slide", fragment: ph(1, "Speaker notes") },
|
|
77
77
|
]);
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Snippets a KEY of the deck is accepted as (SPEC 5: `design`, `organization`, `speaker`): not items of the list on their own (`SNIPPETS` holds
|
|
81
|
+
* those), but what accepting the property writes, as VS Code does for a property with default snippets. The fragment is the value of the key. They hold
|
|
82
|
+
* the required members of the object, each a tab stop (SPEC 5 lists only `name` for the speaker; the Speaker schema requires an id too), and `design`
|
|
83
|
+
* opens the list of the catalog at each stop.
|
|
84
|
+
*/
|
|
85
|
+
export const KEY_SNIPPETS = Object.freeze([
|
|
86
|
+
{ prefix: "design", where: "deck-key", key: "design", description: "The design of the deck: a theme, a color scheme and a font scheme", fragment: { theme: ph(1, "", { choice: true }), colorScheme: ph(2, "", { choice: true }), fontScheme: ph(3, "", { choice: true }) } },
|
|
87
|
+
{ prefix: "organization", where: "deck-key", key: "organization", description: "The organization presenting the deck: an id and a name", fragment: { id: ph(1, "acme"), name: ph(2, "Acme Inc.") } },
|
|
88
|
+
{ prefix: "speaker", where: "deck-key", key: "speaker", description: "The person presenting the deck: an id and a name", fragment: { id: ph(1, "alice"), name: ph(2, "Name") } },
|
|
89
|
+
]);
|
|
90
|
+
|
|
79
91
|
/** The snippets that fit where the caret is: `where` and the path of the node (see source-json.js). */
|
|
80
92
|
export function snippetsFor(mode, path) {
|
|
81
93
|
const last = path.at(-1);
|
|
@@ -88,6 +100,14 @@ export function snippetsFor(mode, path) {
|
|
|
88
100
|
});
|
|
89
101
|
}
|
|
90
102
|
|
|
103
|
+
/**
|
|
104
|
+
* The snippets a key of the object at `path` is accepted as (the deck's `design`, `organization`, `speaker`): not items of their own in the list,
|
|
105
|
+
* but what accepting that property writes, as VS Code does for a property with default snippets.
|
|
106
|
+
*/
|
|
107
|
+
export function keySnippetsFor(path) {
|
|
108
|
+
return path.length === 0 ? KEY_SNIPPETS : [];
|
|
109
|
+
}
|
|
110
|
+
|
|
91
111
|
/** The JSON text of a snippet for insertion: `{ text, fields }`; a dataset is a `"name": { ... }` entry. */
|
|
92
112
|
export function snippetJson(snippet, { comma = false } = {}) {
|
|
93
113
|
if (snippet.key !== undefined) {
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// The outline of an OPF document (artifacts/source-editor/SPEC.md, section 8.4): the symbols the picker (Cmd/Ctrl+Shift+O) and the outline panel list, for
|
|
2
|
+
// JSON, YAML and Markdown, read from text that may be broken. A symbol is `{ id, kind, label, detail, depth, from, to, line }` with source offsets:
|
|
3
|
+
// the slides in order ("3. Highlights"), the top-level sections (design, organization, speaker), the assets, datasets, variables and references the
|
|
4
|
+
// document declares, and the catalog records it embeds. Plain functions, no DOM.
|
|
5
|
+
import { parseTree } from "jsonc-parser";
|
|
6
|
+
import { isMap, parseDocument } from "yaml";
|
|
7
|
+
import { scanMarkdown } from "@openpresentation/opf/source";
|
|
8
|
+
|
|
9
|
+
const SECTIONS = ["design", "organization", "speaker"];
|
|
10
|
+
const DECLARED = [["assets", "asset"], ["datasets", "dataset"], ["variables", "variable"]];
|
|
11
|
+
const CATALOG_KINDS = { layouts: "layout", themes: "theme", colorSchemes: "color scheme", fontSchemes: "font scheme", narratives: "narrative", audiences: "audience", purposes: "purpose", tones: "tone" };
|
|
12
|
+
|
|
13
|
+
// ---- one tree for the three readers ------------------------------------------------------------------------------------------------------
|
|
14
|
+
// `{ type: "object" | "array" | "scalar", value?, from, to, entries?: [{ key, from, to, node }], items?: [node] }`, offsets in the text.
|
|
15
|
+
function fromJson(text) {
|
|
16
|
+
const root = parseTree(text, [], { allowTrailingComma: true, disallowComments: false });
|
|
17
|
+
const convert = (node) => {
|
|
18
|
+
if (!node) return null;
|
|
19
|
+
const base = { from: node.offset, to: node.offset + node.length };
|
|
20
|
+
if (node.type === "object") return { ...base, type: "object", entries: (node.children ?? []).filter((property) => property.children?.[0]).map((property) => ({ key: String(property.children[0].value), from: property.offset, to: property.offset + property.length, node: convert(property.children[1]) })) };
|
|
21
|
+
if (node.type === "array") return { ...base, type: "array", items: (node.children ?? []).map(convert) };
|
|
22
|
+
return { ...base, type: "scalar", value: node.value };
|
|
23
|
+
};
|
|
24
|
+
return convert(root);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fromYamlNode(node, shift = 0) {
|
|
28
|
+
if (!node) return null;
|
|
29
|
+
const range = node.range ?? [0, 0, 0];
|
|
30
|
+
const base = { from: range[0] + shift, to: range[1] + shift };
|
|
31
|
+
if (isMap(node)) {
|
|
32
|
+
return { ...base, type: "object", entries: node.items.filter((pair) => pair.key).map((pair) => ({ key: String(pair.key.value ?? pair.key), from: (pair.key.range?.[0] ?? range[0]) + shift, to: (pair.value?.range?.[1] ?? pair.key.range?.[1] ?? range[1]) + shift, node: fromYamlNode(pair.value, shift) })) };
|
|
33
|
+
}
|
|
34
|
+
if (node.items) return { ...base, type: "array", items: node.items.map((item) => fromYamlNode(item, shift)) };
|
|
35
|
+
return { ...base, type: "scalar", value: node.value };
|
|
36
|
+
}
|
|
37
|
+
function fromYaml(text, shift = 0) {
|
|
38
|
+
try { return fromYamlNode(parseDocument(text, { strict: false, uniqueKeys: false }).contents, shift); } catch { return null; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const lineStarts = (text) => { const starts = [0]; for (let i = 0; i < text.length; i++) if (text[i] === "\n") starts.push(i + 1); return starts; };
|
|
42
|
+
const lineOf = (starts, offset) => { let low = 0, high = starts.length - 1; while (low < high) { const mid = (low + high + 1) >> 1; if (starts[mid] <= offset) low = mid; else high = mid - 1; } return low + 1; };
|
|
43
|
+
|
|
44
|
+
// ---- helpers -----------------------------------------------------------------------------------------------------------------------------
|
|
45
|
+
const entry = (node, key) => (node?.type === "object" ? node.entries.find((candidate) => candidate.key === key) : undefined);
|
|
46
|
+
const scalar = (node) => (node?.type === "scalar" && typeof node.value === "string" ? node.value : undefined);
|
|
47
|
+
/** A title as text: a string, or the text of a list of runs. */
|
|
48
|
+
function titleOf(node) {
|
|
49
|
+
if (!node) return "";
|
|
50
|
+
if (node.type === "scalar") return typeof node.value === "string" ? node.value : "";
|
|
51
|
+
if (node.type === "array") return node.items.map((item) => (item?.type === "scalar" ? String(item.value ?? "") : titleOf(entry(item, "text")?.node))).join("");
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
const clip = (text, max = 80) => { const flat = text.replace(/\s+/g, " ").trim(); return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat; };
|
|
55
|
+
|
|
56
|
+
function symbolsOfTree(root, starts, { slides = true } = {}) {
|
|
57
|
+
const out = [];
|
|
58
|
+
const add = (kind, label, detail, depth, from, to) => out.push({ id: `${kind}:${out.length}`, kind, label, detail, depth, from, to, line: lineOf(starts, from) });
|
|
59
|
+
if (root?.type !== "object") return out;
|
|
60
|
+
if (slides) {
|
|
61
|
+
const list = entry(root, "slides")?.node;
|
|
62
|
+
if (list?.type === "array") {
|
|
63
|
+
list.items.forEach((item, index) => {
|
|
64
|
+
if (!item) return;
|
|
65
|
+
const title = clip(titleOf(entry(item, "title")?.node));
|
|
66
|
+
const layout = scalar(entry(item, "layout")?.node), type = scalar(entry(item, "type")?.node);
|
|
67
|
+
add("slide", `${index + 1}. ${title || "Untitled slide"}`, [layout && `layout ${layout}`, !layout && type].filter(Boolean).join(" "), 0, item.from, item.to);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for (const key of SECTIONS) {
|
|
72
|
+
const found = entry(root, key);
|
|
73
|
+
if (found?.node?.type === "object") add("section", key, "", 0, found.from, found.to);
|
|
74
|
+
}
|
|
75
|
+
for (const [key, kind] of DECLARED) {
|
|
76
|
+
const found = entry(root, key);
|
|
77
|
+
if (found?.node?.type !== "object" || !found.node.entries.length) continue;
|
|
78
|
+
add("section", key, `${found.node.entries.length}`, 0, found.from, found.to);
|
|
79
|
+
for (const child of found.node.entries) add(kind, child.key, kind === "dataset" ? scalar(entry(child.node, "title")?.node) ?? "" : "", 1, child.from, child.to);
|
|
80
|
+
}
|
|
81
|
+
const references = entry(root, "references")?.node;
|
|
82
|
+
if (references?.type === "array" && references.items.length) {
|
|
83
|
+
add("section", "references", `${references.items.length}`, 0, entry(root, "references").from, entry(root, "references").to);
|
|
84
|
+
for (const item of references.items) { const id = scalar(entry(item, "id")?.node); if (id && item) add("reference", id, clip(scalar(entry(item, "text")?.node) ?? "", 50), 1, item.from, item.to); }
|
|
85
|
+
}
|
|
86
|
+
const catalogs = entry(root, "catalogs");
|
|
87
|
+
if (catalogs?.node?.type === "object") {
|
|
88
|
+
for (const group of catalogs.node.entries) {
|
|
89
|
+
if (group.node?.type !== "object") continue;
|
|
90
|
+
for (const [kindKey, kindLabel] of Object.entries(CATALOG_KINDS)) {
|
|
91
|
+
const records = entry(group.node, kindKey)?.node;
|
|
92
|
+
if (records?.type === "object") for (const record of records.entries) add("record", `${kindLabel} ${record.key}`, group.key === "custom" ? "this document" : group.key, 0, record.from, record.to);
|
|
93
|
+
else if (records?.type === "array") for (const record of records.items) { const id = scalar(entry(record, "id")?.node); if (id && record) add("record", `${kindLabel} ${id}`, group.key === "custom" ? "this document" : group.key, 0, record.from, record.to); }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Slides first, as the document reads: sort the rest by position inside their kind order is not needed; the list keeps slides, then the sections in
|
|
98
|
+
// the order of the text so the picker's order is the document's order.
|
|
99
|
+
return out.sort((a, b) => a.from - b.from).map((symbol, index) => ({ ...symbol, id: `${symbol.kind}:${index}` }));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---- Markdown ----------------------------------------------------------------------------------------------------------------------------
|
|
103
|
+
function markdownSymbols(text, starts) {
|
|
104
|
+
const scan = scanMarkdown(text);
|
|
105
|
+
const out = [];
|
|
106
|
+
if (scan.frontMatter?.closed) {
|
|
107
|
+
const tree = fromYaml(text.slice(scan.frontMatter.yamlFrom, scan.frontMatter.yamlTo), scan.frontMatter.yamlFrom);
|
|
108
|
+
out.push(...symbolsOfTree(tree, starts, { slides: false }));
|
|
109
|
+
}
|
|
110
|
+
const skipped = [...scan.fences, ...scan.comments].map((range) => [range.from, range.to]);
|
|
111
|
+
for (const slide of scan.slides) {
|
|
112
|
+
let title = "", layout;
|
|
113
|
+
const slideComment = scan.comments.find((comment) => comment.kind === "slide" && comment.slide === slide.index);
|
|
114
|
+
if (slideComment) layout = /\blayout=("?)([A-Za-z0-9_:-]+)\1/.exec(text.slice(slideComment.innerFrom, slideComment.innerTo))?.[2];
|
|
115
|
+
const body = text.slice(slide.from, slide.to);
|
|
116
|
+
let offset = 0;
|
|
117
|
+
for (const line of body.split("\n")) {
|
|
118
|
+
const at = slide.from + offset;
|
|
119
|
+
offset += line.length + 1;
|
|
120
|
+
if (skipped.some(([from, to]) => at >= from && at < to)) continue;
|
|
121
|
+
const heading = /^#\s+(.*?)\s*#*\s*$/.exec(line);
|
|
122
|
+
if (heading) { title = heading[1].replace(/[*_`[\]]/g, ""); break; }
|
|
123
|
+
}
|
|
124
|
+
out.push({ id: "", kind: "slide", label: `${slide.index + 1}. ${clip(title) || "Untitled slide"}`, detail: layout ? `layout ${layout}` : "", depth: 0, from: slide.from, to: slide.to, line: slide.line });
|
|
125
|
+
}
|
|
126
|
+
return out.sort((a, b) => a.from - b.from).map((symbol, index) => ({ ...symbol, id: `${symbol.kind}:${index}` }));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The symbols of `text` in `format` ("json", "yaml" or "md"), in document order. Never throws. */
|
|
130
|
+
export function documentSymbols(format, text) {
|
|
131
|
+
try {
|
|
132
|
+
const starts = lineStarts(text);
|
|
133
|
+
const symbols = format === "md" ? markdownSymbols(text, starts) : symbolsOfTree(format === "yaml" ? fromYaml(text) : fromJson(text), starts);
|
|
134
|
+
// A YAML block ends with its line break: the range of a symbol stops at its last visible character.
|
|
135
|
+
return symbols.map((symbol) => { let to = Math.min(symbol.to, text.length); while (to > symbol.from + 1 && /\s/.test(text[to - 1])) to--; return { ...symbol, to }; });
|
|
136
|
+
} catch { return []; }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The innermost symbol that contains `offset` (the slide the caret is in), or null. */
|
|
140
|
+
export function symbolAt(symbols, offset) {
|
|
141
|
+
let found = null;
|
|
142
|
+
for (const symbol of symbols) if (symbol.from <= offset && offset <= symbol.to && (!found || symbol.to - symbol.from <= found.to - found.from)) found = symbol;
|
|
143
|
+
return found;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The symbols that match what was typed: every word is in the label, detail or kind; failing that, the letters in order (a fuzzy match). */
|
|
147
|
+
export function filterSymbols(symbols, query) {
|
|
148
|
+
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
149
|
+
if (!words.length) return symbols;
|
|
150
|
+
const text = (symbol) => `${symbol.label} ${symbol.detail} ${symbol.kind}`.toLowerCase();
|
|
151
|
+
const exact = symbols.filter((symbol) => words.every((word) => text(symbol).includes(word)));
|
|
152
|
+
if (exact.length) return exact;
|
|
153
|
+
const letters = words.join("");
|
|
154
|
+
return symbols.filter((symbol) => { let at = 0; for (const character of text(symbol)) if (character === letters[at]) at++; return at === letters.length; });
|
|
155
|
+
}
|
|
156
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// One undo step for a suggestion and the value list it opened (artifacts/source-editor/SPEC.md, section 4.6, item 4; TEST-PLAN J-INS-017). Accepting a key
|
|
2
|
+
// of a closed set of values writes `"key": ""` and opens the list of the values; accepting a value is a second edit. Each is an edit of its own in
|
|
3
|
+
// CodeMirror's history, as every accepted suggestion is, so the pair is recorded here and Undo and Redo step over both: one Cmd+Z puts back the text
|
|
4
|
+
// the author had typed before accepting the key (VS Code: "accept is one step with its typed prefix undoable"). Anything typed in between, or any
|
|
5
|
+
// other edit after the pair, makes the pair void, so Undo is the plain one again. Plain functions over an editor view, no DOM.
|
|
6
|
+
import { redo, undo, undoDepth, redoDepth } from "@codemirror/commands";
|
|
7
|
+
|
|
8
|
+
const pending = new WeakMap();
|
|
9
|
+
const pairs = new WeakMap();
|
|
10
|
+
|
|
11
|
+
/** A suggestion that opens a list of values was accepted: the text before it and after it. */
|
|
12
|
+
export function beginUndoGroup(view, before) {
|
|
13
|
+
pending.set(view, { before, after: view.state.doc });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A suggestion is about to be accepted: the pair it completes (the key accepted just before, with nothing typed since), or null. */
|
|
17
|
+
export function takeUndoGroup(view) {
|
|
18
|
+
const link = pending.get(view);
|
|
19
|
+
pending.delete(view);
|
|
20
|
+
pairs.delete(view);
|
|
21
|
+
return link && link.after.eq(view.state.doc) ? link : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The value was accepted (and the history closed after it): the key and the value are one undo step from now on. */
|
|
25
|
+
export function finishUndoGroup(view, link) {
|
|
26
|
+
pairs.set(view, { before: link.before, after: view.state.doc, depth: undoDepth(view.state), undone: false });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Whether Undo should step over the last pair; does it when so (returns true). Otherwise the caller does the plain undo. */
|
|
30
|
+
export function undoPair(view) {
|
|
31
|
+
const pair = pairs.get(view);
|
|
32
|
+
if (!pair || pair.undone || undoDepth(view.state) !== pair.depth || !view.state.doc.eq(pair.after)) return false;
|
|
33
|
+
for (let step = 0; step < 3 && !view.state.doc.eq(pair.before); step++) if (!undo(view)) break;
|
|
34
|
+
pair.undone = view.state.doc.eq(pair.before);
|
|
35
|
+
pair.redoDepth = redoDepth(view.state);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The same for Redo: after the pair was undone, Redo puts both back. */
|
|
40
|
+
export function redoPair(view) {
|
|
41
|
+
const pair = pairs.get(view);
|
|
42
|
+
if (!pair || !pair.undone || redoDepth(view.state) !== pair.redoDepth || !view.state.doc.eq(pair.before)) return false;
|
|
43
|
+
for (let step = 0; step < 3 && !view.state.doc.eq(pair.after); step++) if (!redo(view)) break;
|
|
44
|
+
pair.undone = !view.state.doc.eq(pair.after);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The worker script of the source pane's text checks. It exports nothing: bundle it as a module worker script and pass the worker to
|
|
3
|
+
* `createSourcePane(container, { worker })`, for example
|
|
4
|
+
* `worker: () => new Worker(new URL("@openpresentation/opf-editor/source-worker", import.meta.url), { type: "module" })`
|
|
5
|
+
* with a bundler that supports that form, or build `dist/source-worker.js` as its own entry point and serve it next to the page.
|
|
6
|
+
*/
|
|
7
|
+
export {};
|