@openpresentation/opf-editor 0.26.5 → 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 +6 -0
- 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 +186 -89
- package/dist/source-check.js +90 -0
- package/dist/source-checker.js +130 -0
- package/dist/source-completions.js +46 -10
- package/dist/source-diagnostics.js +3 -2
- package/dist/source-editing.js +53 -33
- package/dist/source-fixes-syntax.js +138 -8
- 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 +42 -1
- 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 +200 -44
- package/dist/source-references.js +58 -11
- package/dist/source-shortcuts.js +106 -0
- package/dist/source-symbols.js +156 -0
- package/dist/source-worker.d.ts +7 -0
- package/dist/source-worker.js +30 -0
- package/dist/styles.css +262 -0
- package/package.json +11 -7
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// A document of any of the three source formats as one tree (artifacts/source-editor/SPEC.md, section 8.3). Go to definition, peek, references and rename
|
|
2
|
+
// work on ids written in text that may be broken or half typed, so every reader is tolerant and every node knows where it is written (UTF-16 offsets of
|
|
3
|
+
// the text as the code editor holds it). The shapes:
|
|
4
|
+
// object { t: "object", from, to, props: [{ key, from, to, cFrom, cTo, value }] } (`from`..`to` is the key token, `cFrom`..`cTo` its text without quotes)
|
|
5
|
+
// array { t: "array", from, to, items: [node] }
|
|
6
|
+
// string { t: "string", from, to, cFrom, cTo, value, quoted, block } (a quoted or plain scalar that reads as text)
|
|
7
|
+
// scalar { t: "scalar", from, to, value } (a number, a boolean or null)
|
|
8
|
+
// JSON reads with jsonc-parser, YAML with the `yaml` library's tolerant parse, Markdown as a deck whose root is the front matter's mapping with a `slides`
|
|
9
|
+
// array built from the `<!-- slide: ... -->` and `<!-- block: ... -->` comments and the `opf-slide` and `opf-block` fences, so the code that looks for ids
|
|
10
|
+
// is written once. Plain functions, no DOM.
|
|
11
|
+
import { parseTree } from "jsonc-parser";
|
|
12
|
+
import { isAlias, isMap, isScalar, isSeq, parseDocument } from "yaml";
|
|
13
|
+
import { scanMarkdown } from "@openpresentation/opf/source";
|
|
14
|
+
|
|
15
|
+
const OPTION = /([A-Za-z][A-Za-z0-9_-]*)(?:=("(?:[^"\\\n]|\\.)*"|'[^'\n]*'|[^\s"'][^\s]*))?/g;
|
|
16
|
+
|
|
17
|
+
// ---- JSON ---------------------------------------------------------------------------------------------------------------------------
|
|
18
|
+
function jsonNode(node) {
|
|
19
|
+
const end = node.offset + node.length;
|
|
20
|
+
if (node.type === "object") {
|
|
21
|
+
const props = [];
|
|
22
|
+
for (const property of node.children ?? []) {
|
|
23
|
+
const [key, value] = property.children ?? [];
|
|
24
|
+
if (!key || key.type !== "string") continue;
|
|
25
|
+
const closed = key.length >= 2;
|
|
26
|
+
props.push({ key: String(key.value), from: key.offset, to: key.offset + key.length, cFrom: key.offset + 1, cTo: key.offset + key.length - (closed ? 1 : 0), value: value ? jsonNode(value) : null });
|
|
27
|
+
}
|
|
28
|
+
return { t: "object", from: node.offset, to: end, props };
|
|
29
|
+
}
|
|
30
|
+
if (node.type === "array") return { t: "array", from: node.offset, to: end, items: (node.children ?? []).map(jsonNode) };
|
|
31
|
+
if (node.type === "string") return { t: "string", from: node.offset, to: end, cFrom: node.offset + 1, cTo: node.length >= 2 ? end - 1 : end, value: node.value, quoted: true };
|
|
32
|
+
return { t: "scalar", from: node.offset, to: end, value: node.value ?? null };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The tree of a JSON text, or null when it has no value. */
|
|
36
|
+
export function readJson(text) {
|
|
37
|
+
const root = parseTree(text.startsWith("") ? ` ${text.slice(1)}` : text, [], { allowTrailingComma: true, allowEmptyContent: true, disallowComments: false });
|
|
38
|
+
return root ? jsonNode(root) : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---- YAML ---------------------------------------------------------------------------------------------------------------------------
|
|
42
|
+
function trimEnd(text, from, to) {
|
|
43
|
+
let end = to;
|
|
44
|
+
while (end > from && /\s/.test(text[end - 1])) end--;
|
|
45
|
+
return end;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function yamlKey(text, node, shift) {
|
|
49
|
+
if (!isScalar(node) || node.value === null || node.value === undefined) return null;
|
|
50
|
+
const start = node.range[0], end = trimEnd(text, start, node.range[1]);
|
|
51
|
+
const quoted = node.type === "QUOTE_DOUBLE" || node.type === "QUOTE_SINGLE" || text[start] === '"' || text[start] === "'";
|
|
52
|
+
return { key: String(node.value), from: start + shift, to: end + shift, cFrom: start + shift + (quoted ? 1 : 0), cTo: end + shift - (quoted ? 1 : 0) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function yamlNode(text, node, shift) {
|
|
56
|
+
if (node === null || node === undefined || isAlias(node)) return null;
|
|
57
|
+
const start = node.range?.[0] ?? 0, end = trimEnd(text, start, node.range?.[1] ?? start);
|
|
58
|
+
if (isMap(node)) {
|
|
59
|
+
const props = [];
|
|
60
|
+
for (const pair of node.items) {
|
|
61
|
+
const key = yamlKey(text, pair.key, shift);
|
|
62
|
+
if (key) props.push({ ...key, value: yamlNode(text, pair.value, shift) });
|
|
63
|
+
}
|
|
64
|
+
return { t: "object", from: start + shift, to: end + shift, props };
|
|
65
|
+
}
|
|
66
|
+
if (isSeq(node)) return { t: "array", from: start + shift, to: end + shift, items: node.items.map((item) => yamlNode(text, item, shift) ?? { t: "scalar", from: start + shift, to: start + shift, value: null }) };
|
|
67
|
+
if (isScalar(node)) {
|
|
68
|
+
if (typeof node.value !== "string") return { t: "scalar", from: start + shift, to: end + shift, value: node.value };
|
|
69
|
+
const quoted = node.type === "QUOTE_DOUBLE" || node.type === "QUOTE_SINGLE";
|
|
70
|
+
const block = node.type === "BLOCK_LITERAL" || node.type === "BLOCK_FOLDED";
|
|
71
|
+
return { t: "string", from: start + shift, to: end + shift, cFrom: start + shift + (quoted ? 1 : 0), cTo: end + shift - (quoted ? 1 : 0), value: node.value, quoted, block };
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The tree of a YAML text; `shift` is where the text starts in the document it was cut from. Null when it holds no value. */
|
|
77
|
+
export function readYaml(text, shift = 0) {
|
|
78
|
+
const bom = text.startsWith("") ? 1 : 0;
|
|
79
|
+
const body = bom ? text.slice(1) : text;
|
|
80
|
+
let document;
|
|
81
|
+
try { document = parseDocument(body, { prettyErrors: false, uniqueKeys: false, merge: false, strict: false }); } catch { return null; }
|
|
82
|
+
const root = yamlNode(body, document.contents, shift + bom);
|
|
83
|
+
return root;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Give the main thread back to the browser (a message to ourselves: a task of its own, with no timer clamp). */
|
|
87
|
+
export const yieldToBrowser = () => new Promise((resolve) => { const channel = new MessageChannel(); channel.port1.onmessage = () => { channel.port1.close(); resolve(); }; channel.port2.postMessage(0); });
|
|
88
|
+
|
|
89
|
+
const SEGMENT = 24_000, SLICE = 12_000;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A big YAML text read in pieces, with the main thread given back between them, so that a thousand slides never make one task of fifty milliseconds:
|
|
93
|
+
* the text is cut where a top-level key starts, and the `slides` list where one of its items starts; each piece is read on its own and the nodes
|
|
94
|
+
* are put together. Null when the text is not shaped like that (a list at the top, several documents, flow style): the caller reads it whole.
|
|
95
|
+
*/
|
|
96
|
+
export async function readYamlInPieces(text) {
|
|
97
|
+
if (/^(?:---|\.\.\.)/m.test(text) || text.includes("\t") && /^\t/m.test(text)) return null;
|
|
98
|
+
const starts = [];
|
|
99
|
+
const key = /^[^\s#\-\[{%&*!?|>'"][^\r\n]*|^["'][^\r\n]*/gm;
|
|
100
|
+
for (let match = key.exec(text); match; match = key.exec(text)) starts.push(match.index);
|
|
101
|
+
if (!starts.length || /\S/.test(text.slice(0, starts[0]).replace(/^\s*#[^\n]*$/gm, ""))) return null;
|
|
102
|
+
const props = [];
|
|
103
|
+
for (let at = 0; at < starts.length; at++) {
|
|
104
|
+
const from = starts[at], to = at + 1 < starts.length ? starts[at + 1] : text.length;
|
|
105
|
+
const piece = text.slice(from, to);
|
|
106
|
+
let tree = null;
|
|
107
|
+
if (piece.length > SEGMENT && /^slides\s*:[ \t]*(?:#[^\n]*)?\r?\n/.test(piece)) tree = await readSlidesInPieces(text, from, to);
|
|
108
|
+
else tree = readYaml(piece, from);
|
|
109
|
+
if (tree?.t !== "object" || tree.props.length !== 1) return null;
|
|
110
|
+
props.push(tree.props[0]);
|
|
111
|
+
if (piece.length > SEGMENT / 4) await yieldToBrowser();
|
|
112
|
+
}
|
|
113
|
+
return { t: "object", from: props[0].from, to: props.at(-1).value?.to ?? props.at(-1).to, props };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// `slides:` and then the items of the list, cut where an item starts (a line with the list marker at the indentation of the first item).
|
|
117
|
+
async function readSlidesInPieces(text, from, to) {
|
|
118
|
+
const head = /^[^\n]*\n/.exec(text.slice(from, to))[0];
|
|
119
|
+
const headTree = readYaml(head.replace(/\s+$/, ""), from);
|
|
120
|
+
const first = /^([ \t]*)- /m.exec(text.slice(from + head.length, to));
|
|
121
|
+
if (headTree?.t !== "object" || headTree.props.length !== 1 || (headTree.props[0].value && !(headTree.props[0].value.t === "scalar" && headTree.props[0].value.value === null)) || !first) return null;
|
|
122
|
+
const indent = first[1];
|
|
123
|
+
const marker = new RegExp(`^${indent}- `, "gm");
|
|
124
|
+
const itemStarts = [];
|
|
125
|
+
const body = text.slice(from + head.length, to);
|
|
126
|
+
for (let match = marker.exec(body); match; match = marker.exec(body)) itemStarts.push(from + head.length + match.index);
|
|
127
|
+
// Anything between the header and the first item must be blank or a comment.
|
|
128
|
+
if (/\S/.test(body.slice(0, itemStarts[0] - from - head.length).replace(/^\s*#[^\n]*$/gm, ""))) return null;
|
|
129
|
+
const items = [];
|
|
130
|
+
let begin = 0;
|
|
131
|
+
while (begin < itemStarts.length) {
|
|
132
|
+
let end = begin + 1;
|
|
133
|
+
while (end < itemStarts.length && itemStarts[end] - itemStarts[begin] < SLICE) end++;
|
|
134
|
+
const sliceFrom = itemStarts[begin], sliceTo = end < itemStarts.length ? itemStarts[end] : to;
|
|
135
|
+
const tree = readYaml(text.slice(sliceFrom, sliceTo), sliceFrom);
|
|
136
|
+
if (tree?.t !== "array" || tree.items.length !== end - begin) return null;
|
|
137
|
+
items.push(...tree.items);
|
|
138
|
+
begin = end;
|
|
139
|
+
await yieldToBrowser();
|
|
140
|
+
}
|
|
141
|
+
const prop = headTree.props[0];
|
|
142
|
+
return { t: "object", from: prop.from, to: items.at(-1).to, props: [{ ...prop, value: { t: "array", from: items[0].from, to: items.at(-1).to, items } }] };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---- Markdown -----------------------------------------------------------------------------------------------------------------------
|
|
146
|
+
/** The `key=value` options of the text between `<!--` and `-->` after the `slide:` or `block:` word: `[{ key, from, to, value, cFrom, cTo, quoted }]`. */
|
|
147
|
+
export function readOptions(text, innerFrom, innerTo) {
|
|
148
|
+
const inner = text.slice(innerFrom, innerTo);
|
|
149
|
+
const head = /^\s*(?:slide|block)\s*:/.exec(inner);
|
|
150
|
+
if (!head) return [];
|
|
151
|
+
const options = [];
|
|
152
|
+
const body = inner.slice(head[0].length);
|
|
153
|
+
const base = innerFrom + head[0].length;
|
|
154
|
+
OPTION.lastIndex = 0;
|
|
155
|
+
for (let match = OPTION.exec(body); match; match = OPTION.exec(body)) {
|
|
156
|
+
const raw = match[2];
|
|
157
|
+
const from = base + match.index, keyTo = from + match[1].length;
|
|
158
|
+
if (raw === undefined) { options.push({ key: match[1], from, to: keyTo, value: null }); continue; }
|
|
159
|
+
const valueFrom = base + match.index + match[0].length - raw.length, valueTo = valueFrom + raw.length;
|
|
160
|
+
const quoted = raw[0] === '"' || raw[0] === "'";
|
|
161
|
+
let value = raw;
|
|
162
|
+
if (quoted) { try { value = raw[0] === '"' ? JSON.parse(raw) : raw.slice(1, -1); } catch { value = raw.slice(1, -1); } }
|
|
163
|
+
options.push({ key: match[1], from, to: keyTo, value, cFrom: valueFrom + (quoted ? 1 : 0), cTo: valueTo - (quoted ? 1 : 0), quoted });
|
|
164
|
+
}
|
|
165
|
+
return options;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const optionProps = (options) => {
|
|
169
|
+
const props = [];
|
|
170
|
+
const byKey = new Map();
|
|
171
|
+
for (const option of options) {
|
|
172
|
+
if (option.value === null) continue;
|
|
173
|
+
const node = { t: "string", from: option.cFrom - (option.quoted ? 1 : 0), to: option.cTo + (option.quoted ? 1 : 0), cFrom: option.cFrom, cTo: option.cTo, value: String(option.value), quoted: option.quoted };
|
|
174
|
+
const known = byKey.get(option.key);
|
|
175
|
+
if (known) { known.values.push(node); continue; }
|
|
176
|
+
const prop = { key: option.key, from: option.from, to: option.to, cFrom: option.from, cTo: option.to, values: [node], value: node };
|
|
177
|
+
byKey.set(option.key, prop);
|
|
178
|
+
props.push(prop);
|
|
179
|
+
}
|
|
180
|
+
// A repeated option (`beat=a beat=b`) is a list.
|
|
181
|
+
for (const prop of props) {
|
|
182
|
+
if (prop.values.length > 1) prop.value = { t: "array", from: prop.values[0].from, to: prop.values.at(-1).to, items: prop.values };
|
|
183
|
+
delete prop.values;
|
|
184
|
+
}
|
|
185
|
+
return props;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/** The deck of a Markdown text: the front matter's mapping with a `slides` array (see the header). Also returns the scan for the regions of the text. */
|
|
189
|
+
export function readMarkdown(text) {
|
|
190
|
+
const scan = scanMarkdown(text);
|
|
191
|
+
const root = { t: "object", from: 0, to: text.length, props: [] };
|
|
192
|
+
if (scan.frontMatter) {
|
|
193
|
+
const tree = readYaml(text.slice(scan.frontMatter.yamlFrom, scan.frontMatter.yamlTo), scan.frontMatter.yamlFrom);
|
|
194
|
+
if (tree?.t === "object") root.props.push(...tree.props);
|
|
195
|
+
}
|
|
196
|
+
const slides = scan.slides.map((slide) => ({ t: "object", from: slide.from, to: slide.to, props: [], blocks: [] }));
|
|
197
|
+
const slideOf = (index) => slides[scan.slides.findIndex((slide) => slide.index === index)] ?? null;
|
|
198
|
+
for (const comment of scan.comments) {
|
|
199
|
+
if (comment.kind !== "slide" && comment.kind !== "block") continue;
|
|
200
|
+
const slide = slideOf(comment.slide);
|
|
201
|
+
if (!slide) continue;
|
|
202
|
+
const props = optionProps(readOptions(text, comment.innerFrom, comment.innerTo));
|
|
203
|
+
if (comment.kind === "slide") slide.props.push(...props);
|
|
204
|
+
else slide.blocks.push({ t: "object", from: comment.from, to: comment.to, props });
|
|
205
|
+
}
|
|
206
|
+
for (const fence of scan.fences) {
|
|
207
|
+
if (fence.kind !== "opf-slide" && fence.kind !== "opf-block") continue;
|
|
208
|
+
const slide = slideOf(fence.slide);
|
|
209
|
+
if (!slide) continue;
|
|
210
|
+
const tree = readYaml(text.slice(fence.bodyFrom, fence.bodyTo), fence.bodyFrom);
|
|
211
|
+
if (tree?.t !== "object") continue;
|
|
212
|
+
if (fence.kind === "opf-slide") slide.props.push(...tree.props);
|
|
213
|
+
else slide.blocks.push(tree);
|
|
214
|
+
}
|
|
215
|
+
for (const slide of slides) {
|
|
216
|
+
if (slide.blocks.length) slide.props.push({ key: "blocks", from: slide.from, to: slide.from, cFrom: slide.from, cTo: slide.from, value: { t: "array", from: slide.from, to: slide.to, items: slide.blocks } });
|
|
217
|
+
delete slide.blocks;
|
|
218
|
+
}
|
|
219
|
+
root.props.push({ key: "slides", from: 0, to: 0, cFrom: 0, cTo: 0, value: { t: "array", from: 0, to: text.length, items: slides } });
|
|
220
|
+
return { root, scan };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** `readDocument`, reading a big YAML text in pieces (see `readYamlInPieces`) with the main thread given back between them. */
|
|
224
|
+
export async function readDocumentInPieces(text, format) {
|
|
225
|
+
if (format === "yaml" && text.length > SEGMENT) {
|
|
226
|
+
const root = await readYamlInPieces(text);
|
|
227
|
+
if (root) return { root };
|
|
228
|
+
}
|
|
229
|
+
return readDocument(text, format);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The tree of `text` in `format` ("json", "yaml" or "md"): `{ root, scan? }` with `root` an object node or null. */
|
|
233
|
+
export function readDocument(text, format) {
|
|
234
|
+
if (format === "md") return readMarkdown(text);
|
|
235
|
+
const root = format === "yaml" ? readYaml(text) : readJson(text);
|
|
236
|
+
return { root: root?.t === "object" ? root : null };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---- helpers ------------------------------------------------------------------------------------------------------------------------
|
|
240
|
+
/** The property `key` of an object node, or undefined. */
|
|
241
|
+
export const propOf = (node, key) => (node?.t === "object" ? node.props.find((prop) => prop.key === key) : undefined);
|
|
242
|
+
/** The value node of the property `key`. */
|
|
243
|
+
export const childOf = (node, key) => propOf(node, key)?.value ?? null;
|
|
244
|
+
/** The node at `path` (keys and indexes) below `node`, or null. */
|
|
245
|
+
export function nodeAtPath(node, path) {
|
|
246
|
+
let current = node;
|
|
247
|
+
for (const step of path) {
|
|
248
|
+
if (!current) return null;
|
|
249
|
+
if (current.t === "object") current = propOf(current, String(step))?.value ?? null;
|
|
250
|
+
else if (current.t === "array") current = current.items[Number(step)] ?? null;
|
|
251
|
+
else return null;
|
|
252
|
+
}
|
|
253
|
+
return current;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The plain value of a node (what `JSON.parse` would give), skipping what `skip(key, depth)` says. */
|
|
257
|
+
export function plainOf(node, skip, depth = 0) {
|
|
258
|
+
if (!node) return undefined;
|
|
259
|
+
if (node.t === "object") {
|
|
260
|
+
const out = {};
|
|
261
|
+
for (const prop of node.props) {
|
|
262
|
+
if (prop.key === "__proto__" || skip?.(prop.key, depth)) continue;
|
|
263
|
+
out[prop.key] = prop.value ? plainOf(prop.value, skip, depth + 1) : null;
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
if (node.t === "array") return node.items.map((item) => plainOf(item, skip, depth + 1));
|
|
268
|
+
return node.value;
|
|
269
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// The widgets of go to definition, peek, find references and rename (artifacts/source-editor/SPEC.md, sections 8.3 and 13): one block widget under the
|
|
2
|
+
// line of the symbol, holding a peek of the definition, the card of a record only the app provides, the list of references or the rename input. The widget
|
|
3
|
+
// is a CodeMirror decoration, so it moves with the edits above it; keys inside it are its own (Escape closes it and returns to the editor, Enter acts on the
|
|
4
|
+
// choice), and it is plain DOM named for assistive technology. Styles: styles/source-navigation.css, only `--opf-ui-*` tokens.
|
|
5
|
+
import { Decoration, EditorView, WidgetType, keymap } from "@codemirror/view";
|
|
6
|
+
import { Prec, StateEffect, StateField } from "@codemirror/state";
|
|
7
|
+
import { textInputMap } from "./text-input.js";
|
|
8
|
+
import { docNode } from "./source-docs.js";
|
|
9
|
+
|
|
10
|
+
const el = (tag, className, text) => {
|
|
11
|
+
const node = document.createElement(tag);
|
|
12
|
+
if (className) node.className = className;
|
|
13
|
+
if (text !== undefined) node.textContent = text;
|
|
14
|
+
return node;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// ---- the widget and its place in the editor ----------------------------------------------------------------------------------------
|
|
18
|
+
const openEffect = StateEffect.define();
|
|
19
|
+
const closeEffect = StateEffect.define();
|
|
20
|
+
|
|
21
|
+
class PanelWidget extends WidgetType {
|
|
22
|
+
constructor(panel) { super(); this.panel = panel; }
|
|
23
|
+
eq(other) { return other.panel === this.panel; }
|
|
24
|
+
toDOM() { return this.panel.dom; }
|
|
25
|
+
ignoreEvent() { return true; }
|
|
26
|
+
get estimatedHeight() { return this.panel.dom.offsetHeight || 120; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const panelField = StateField.define({
|
|
30
|
+
create: () => ({ decorations: Decoration.none, panel: null }),
|
|
31
|
+
update(value, tr) {
|
|
32
|
+
let { decorations, panel } = value;
|
|
33
|
+
if (tr.docChanged) decorations = decorations.map(tr.changes);
|
|
34
|
+
for (const effect of tr.effects) {
|
|
35
|
+
if (effect.is(closeEffect)) { decorations = Decoration.none; panel = null; }
|
|
36
|
+
else if (effect.is(openEffect)) {
|
|
37
|
+
panel = effect.value.panel;
|
|
38
|
+
decorations = Decoration.set([Decoration.widget({ widget: new PanelWidget(panel), block: true, side: 1 }).range(effect.value.pos)]);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return decorations === value.decorations && panel === value.panel ? value : { decorations, panel };
|
|
42
|
+
},
|
|
43
|
+
provide: (field) => EditorView.decorations.from(field, (value) => value.decorations),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Escape in the editor closes an open widget before anything else (a widget the author clicked away from keeps listening to it).
|
|
47
|
+
const escapeKey = Prec.highest(keymap.of([{ key: "Escape", run: (view) => closePanel(view) }]));
|
|
48
|
+
|
|
49
|
+
function install(view) {
|
|
50
|
+
if (view.state.field(panelField, false)) return;
|
|
51
|
+
view.dispatch({ effects: StateEffect.appendConfig.of([panelField, escapeKey]) });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The panel now open under the editor, or null. */
|
|
55
|
+
export const openPanelOf = (view) => view.state.field(panelField, false)?.panel ?? null;
|
|
56
|
+
|
|
57
|
+
/** Close the open panel and give the focus back to the editor. Returns whether a panel was open. */
|
|
58
|
+
export function closePanel(view, { focus = true } = {}) {
|
|
59
|
+
if (!openPanelOf(view)) return false;
|
|
60
|
+
view.dispatch({ effects: closeEffect.of(null) });
|
|
61
|
+
if (focus) view.focus();
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Show `panel` (`{ dom, focus() }`) under the line that ends at source offset `at`, and move the focus into it. */
|
|
66
|
+
export function showPanel(view, panel, at, source) {
|
|
67
|
+
install(view);
|
|
68
|
+
if (openPanelOf(view)) view.dispatch({ effects: closeEffect.of(null) });
|
|
69
|
+
const map = textInputMap(source);
|
|
70
|
+
const pos = Math.min(map.toInput(Math.min(at, source.length)), view.state.doc.length);
|
|
71
|
+
view.dispatch({ effects: openEffect.of({ panel, pos }) });
|
|
72
|
+
panel.focus();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---- shared pieces -----------------------------------------------------------------------------------------------------------------
|
|
76
|
+
function frame(kind, label, title, onClose, role = "region") {
|
|
77
|
+
const dom = el("div", `opf-sym opf-sym-${kind}`);
|
|
78
|
+
dom.setAttribute("role", role);
|
|
79
|
+
dom.setAttribute("aria-label", label);
|
|
80
|
+
const head = el("div", "opf-sym-head");
|
|
81
|
+
head.append(el("span", "opf-sym-title", title));
|
|
82
|
+
const close = el("button", "opf-sym-close", "Close");
|
|
83
|
+
close.type = "button";
|
|
84
|
+
close.setAttribute("aria-label", `Close ${kind === "refs" ? "references" : kind}`);
|
|
85
|
+
close.addEventListener("click", (event) => { event.preventDefault(); onClose(); });
|
|
86
|
+
head.append(close);
|
|
87
|
+
dom.append(head);
|
|
88
|
+
return { dom, head };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The keys inside a panel are the panel's: nothing reaches the editor behind it.
|
|
92
|
+
const own = (dom, handler) => dom.addEventListener("keydown", (event) => {
|
|
93
|
+
if (event.isComposing) return;
|
|
94
|
+
if (handler(event)) { event.preventDefault(); event.stopPropagation(); } else if (event.key !== "Tab") event.stopPropagation();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const cut = (text, max = 220) => {
|
|
98
|
+
const flat = text.trim().replace(/\s+/g, " ");
|
|
99
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// ---- the peek of a definition --------------------------------------------------------------------------------------------------------
|
|
103
|
+
/**
|
|
104
|
+
* `{ label, title, where, lines: [{ number, text, target }], onGo, onClose }`: the lines of the definition with some context, the declaration marked
|
|
105
|
+
* (by an arrow and a bold line, not by color alone). Enter goes to the definition.
|
|
106
|
+
*/
|
|
107
|
+
export function peekPanel(spec) {
|
|
108
|
+
const { dom } = frame("peek", `Peek definition of ${spec.label}`, spec.title, () => spec.onClose());
|
|
109
|
+
dom.tabIndex = 0;
|
|
110
|
+
dom.querySelector(".opf-sym-head").insertBefore(el("span", "opf-sym-where", spec.where), dom.querySelector(".opf-sym-close"));
|
|
111
|
+
const list = el("ol", "opf-sym-lines");
|
|
112
|
+
list.setAttribute("role", "list");
|
|
113
|
+
for (const line of spec.lines) {
|
|
114
|
+
const row = el("li", `opf-sym-line${line.target ? " is-target" : ""}`);
|
|
115
|
+
if (line.target) row.setAttribute("aria-current", "location");
|
|
116
|
+
row.append(el("span", "opf-sym-no", String(line.number)), el("span", "opf-sym-mark", line.target ? "▸" : ""), el("code", "opf-sym-text", line.text || " "));
|
|
117
|
+
row.setAttribute("aria-label", `Line ${line.number}${line.target ? ", definition" : ""}: ${cut(line.text) || "blank"}`);
|
|
118
|
+
list.append(row);
|
|
119
|
+
}
|
|
120
|
+
dom.append(list, el("p", "opf-sym-hint", "Enter goes to the definition. Escape closes."));
|
|
121
|
+
own(dom, (event) => {
|
|
122
|
+
if (event.key === "Escape") { spec.onClose(); return true; }
|
|
123
|
+
if (event.key === "Enter" && event.target === dom) { spec.onGo(); return true; }
|
|
124
|
+
return false;
|
|
125
|
+
});
|
|
126
|
+
return { dom, focus: () => dom.focus() };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The card of a record only the app provides: `{ label, title, model, preview?, onClose }` (`model` is a source-docs.js card). */
|
|
130
|
+
export function cardPanel(spec) {
|
|
131
|
+
const { dom } = frame("card", `Peek of ${spec.label}, provided by the app`, spec.title, () => spec.onClose());
|
|
132
|
+
dom.tabIndex = 0;
|
|
133
|
+
dom.append(docNode(spec.model));
|
|
134
|
+
const slot = el("div", "opf-sym-preview");
|
|
135
|
+
slot.hidden = true;
|
|
136
|
+
dom.append(slot);
|
|
137
|
+
dom.append(el("p", "opf-sym-hint", "This record is provided by the app, so it has no definition in the document. Escape closes."));
|
|
138
|
+
own(dom, (event) => {
|
|
139
|
+
if (event.key === "Escape" || (event.key === "Enter" && event.target === dom)) { spec.onClose(); return true; }
|
|
140
|
+
return false;
|
|
141
|
+
});
|
|
142
|
+
// An optional preview from the host: an element, or an image address (https or data), possibly after a wait.
|
|
143
|
+
Promise.resolve(spec.preview).then((value) => {
|
|
144
|
+
if (value instanceof Node) slot.append(value);
|
|
145
|
+
else if (typeof value === "string" && /^(?:https:\/\/|data:image\/)/.test(value)) { const image = el("img", "opf-sym-image"); image.alt = ""; image.src = value; slot.append(image); }
|
|
146
|
+
slot.hidden = slot.childNodes.length === 0;
|
|
147
|
+
}, () => {});
|
|
148
|
+
return { dom, focus: () => dom.focus() };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---- the list of references --------------------------------------------------------------------------------------------------------------
|
|
152
|
+
let counter = 0;
|
|
153
|
+
/**
|
|
154
|
+
* `{ label, title, rows: [{ number, slide, text, declaration }], onPick(index), onClose }`: Up, Down, Home and End move, Enter jumps to the usage, Escape
|
|
155
|
+
* closes. The declaration is marked with the word, not only a style.
|
|
156
|
+
*/
|
|
157
|
+
export function referencesPanel(spec) {
|
|
158
|
+
const { dom } = frame("refs", `References to ${spec.label}`, spec.title, () => spec.onClose());
|
|
159
|
+
const id = `opf-sym-refs-${++counter}`;
|
|
160
|
+
const list = el("ul", "opf-sym-list");
|
|
161
|
+
list.setAttribute("role", "listbox");
|
|
162
|
+
list.setAttribute("aria-label", `References to ${spec.label}`);
|
|
163
|
+
list.tabIndex = 0;
|
|
164
|
+
const rows = spec.rows.map((row, index) => {
|
|
165
|
+
const item = el("li", `opf-sym-ref${row.declaration ? " is-declaration" : ""}`);
|
|
166
|
+
item.id = `${id}-${index}`;
|
|
167
|
+
item.setAttribute("role", "option");
|
|
168
|
+
item.setAttribute("aria-selected", "false");
|
|
169
|
+
item.setAttribute("aria-label", `${row.declaration ? "Declaration, " : ""}line ${row.number}${row.slide ? `, slide ${row.slide}` : ""}: ${cut(row.text) || "blank"}`);
|
|
170
|
+
item.append(el("span", "opf-sym-no", String(row.number)));
|
|
171
|
+
item.append(el("span", "opf-sym-badge", row.declaration ? "Declaration" : row.slide ? `Slide ${row.slide}` : ""));
|
|
172
|
+
item.append(el("code", "opf-sym-text", cut(row.text)));
|
|
173
|
+
item.addEventListener("click", (event) => { event.preventDefault(); spec.onPick(index); });
|
|
174
|
+
list.append(item);
|
|
175
|
+
return item;
|
|
176
|
+
});
|
|
177
|
+
let active = 0;
|
|
178
|
+
const select = (index) => {
|
|
179
|
+
active = Math.max(0, Math.min(rows.length - 1, index));
|
|
180
|
+
rows.forEach((item, at) => item.setAttribute("aria-selected", String(at === active)));
|
|
181
|
+
list.setAttribute("aria-activedescendant", rows[active]?.id ?? "");
|
|
182
|
+
rows[active]?.scrollIntoView?.({ block: "nearest" });
|
|
183
|
+
};
|
|
184
|
+
dom.append(list, el("p", "opf-sym-hint", "Up and Down choose, Enter goes to it. Escape closes."));
|
|
185
|
+
own(dom, (event) => {
|
|
186
|
+
if (event.key === "Escape") { spec.onClose(); return true; }
|
|
187
|
+
if (event.key === "ArrowDown") { select(active + 1); return true; }
|
|
188
|
+
if (event.key === "ArrowUp") { select(active - 1); return true; }
|
|
189
|
+
if (event.key === "Home") { select(0); return true; }
|
|
190
|
+
if (event.key === "End") { select(rows.length - 1); return true; }
|
|
191
|
+
if (event.key === "Enter" && event.target === list) { spec.onPick(active); return true; }
|
|
192
|
+
return false;
|
|
193
|
+
});
|
|
194
|
+
select(0);
|
|
195
|
+
return { dom, focus: () => list.focus() };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---- rename -------------------------------------------------------------------------------------------------------------------------
|
|
199
|
+
/**
|
|
200
|
+
* `{ label, current, validate(name) => message, onSubmit(name), onClose, say }`: an input holding the current id, all selected. The name is checked as
|
|
201
|
+
* it is typed; the message shows in the widget and is announced. Enter applies a valid name, Escape cancels.
|
|
202
|
+
*/
|
|
203
|
+
export function renamePanel(spec) {
|
|
204
|
+
const { dom } = frame("rename", `Rename ${spec.label}`, `Rename ${spec.label}`, () => spec.onClose(), "group");
|
|
205
|
+
const id = `opf-sym-rename-${++counter}`;
|
|
206
|
+
const row = el("div", "opf-sym-field");
|
|
207
|
+
const label = el("label", "opf-sym-label", "New name");
|
|
208
|
+
label.htmlFor = `${id}-input`;
|
|
209
|
+
const input = el("input", "opf-sym-input");
|
|
210
|
+
input.id = `${id}-input`;
|
|
211
|
+
input.type = "text";
|
|
212
|
+
input.value = spec.current;
|
|
213
|
+
input.spellcheck = false;
|
|
214
|
+
input.autocomplete = "off";
|
|
215
|
+
input.setAttribute("autocapitalize", "off");
|
|
216
|
+
input.setAttribute("aria-describedby", `${id}-message`);
|
|
217
|
+
input.setAttribute("aria-invalid", "false");
|
|
218
|
+
const message = el("div", "opf-sym-message");
|
|
219
|
+
message.id = `${id}-message`;
|
|
220
|
+
row.append(label, input);
|
|
221
|
+
dom.append(row, message, el("p", "opf-sym-hint", "Enter renames everywhere it is used. Escape cancels."));
|
|
222
|
+
let last = "";
|
|
223
|
+
const check = () => {
|
|
224
|
+
const problem = spec.validate(input.value);
|
|
225
|
+
input.setAttribute("aria-invalid", String(Boolean(problem)));
|
|
226
|
+
message.textContent = problem;
|
|
227
|
+
message.classList.toggle("is-error", Boolean(problem));
|
|
228
|
+
if (problem && problem !== last) spec.say(problem);
|
|
229
|
+
last = problem;
|
|
230
|
+
return problem;
|
|
231
|
+
};
|
|
232
|
+
input.addEventListener("input", check);
|
|
233
|
+
own(dom, (event) => {
|
|
234
|
+
if (event.key === "Escape") { spec.onClose(); return true; }
|
|
235
|
+
if (event.key === "Enter" && event.target === input) {
|
|
236
|
+
const problem = check();
|
|
237
|
+
if (problem) { spec.say(problem); return true; }
|
|
238
|
+
spec.onSubmit(input.value);
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
return false;
|
|
242
|
+
});
|
|
243
|
+
return { dom, focus: () => { input.focus(); input.select(); } };
|
|
244
|
+
}
|