@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
package/dist/source-a11y.js
CHANGED
|
@@ -77,7 +77,8 @@ export function a11yExtension(announcer, describe = () => "") {
|
|
|
77
77
|
const open = completionStatus(state) === "active" && currentCompletions(state).length > 0;
|
|
78
78
|
const selected = open ? selectedCompletion(state) : null;
|
|
79
79
|
if (open && !this.open) {
|
|
80
|
-
|
|
80
|
+
const footer = currentCompletions(state).find((completion) => completion.opfFooter)?.opfFooter;
|
|
81
|
+
announcer.say(`${plural(currentCompletions(state).length, "suggestion")}${footer ? `. ${footer}` : ""}`);
|
|
81
82
|
} else if (open && selected && selected !== this.selected) {
|
|
82
83
|
clearTimeout(this.timer);
|
|
83
84
|
this.timer = setTimeout(() => {
|
|
@@ -98,12 +99,28 @@ export function a11yExtension(announcer, describe = () => "") {
|
|
|
98
99
|
const { view } = this, { state } = view;
|
|
99
100
|
const completions = currentCompletions(state);
|
|
100
101
|
if (!completions.length) return;
|
|
102
|
+
this.footer(completions);
|
|
101
103
|
for (const node of view.dom.querySelectorAll(".cm-tooltip-autocomplete [role=option]")) {
|
|
102
104
|
const index = Number(node.id.slice(node.id.lastIndexOf("-") + 1));
|
|
103
105
|
const completion = completions[index];
|
|
104
106
|
if (completion) node.setAttribute("aria-label", optionName(completion, index, completions.length));
|
|
105
107
|
}
|
|
106
108
|
}
|
|
109
|
+
// The line under a list of catalog records whose catalog is not loaded (SPEC 4.5): part of the list's own box, after the options.
|
|
110
|
+
footer(completions) {
|
|
111
|
+
const tooltip = this.view.dom.querySelector(".cm-tooltip-autocomplete");
|
|
112
|
+
if (!tooltip) return;
|
|
113
|
+
const text = completions.find((completion) => completion.opfFooter)?.opfFooter ?? "";
|
|
114
|
+
let node = tooltip.querySelector(":scope > .opf-completion-footer");
|
|
115
|
+
if (!text) { node?.remove(); return; }
|
|
116
|
+
if (!node) {
|
|
117
|
+
node = tooltip.ownerDocument.createElement("div");
|
|
118
|
+
node.className = "opf-completion-footer";
|
|
119
|
+
node.setAttribute("role", "note");
|
|
120
|
+
}
|
|
121
|
+
if (node.textContent !== text) node.textContent = text;
|
|
122
|
+
if (tooltip.lastElementChild !== node) tooltip.append(node);
|
|
123
|
+
}
|
|
107
124
|
destroy() { clearTimeout(this.timer); clearTimeout(this.labelTimer); }
|
|
108
125
|
});
|
|
109
126
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// The source pane's text checks as plain functions of their input (no DOM, no editor session), so the main thread and the Web Worker
|
|
2
|
+
// (`./source-worker.js`) run exactly the same code and give the same answer. `readSource` is what the pane has always done with a text
|
|
3
|
+
// (parse it, collect the syntax findings); `checkSource` adds the validation the editor session would run on the deck it reads, so a worker
|
|
4
|
+
// can hand both back and the session need not repeat it on the main thread. Everything returned is structured-cloneable data.
|
|
5
|
+
import { fromYaml } from "@openpresentation/opf/yaml";
|
|
6
|
+
import { fromMarkdown, toMarkdown } from "@openpresentation/opf/markdown";
|
|
7
|
+
import { checkFormat } from "./checks.js";
|
|
8
|
+
import { normalizeFinding } from "./source-findings.js";
|
|
9
|
+
import { analyzeSyntax, findDuplicateKeys } from "./source-syntax.js";
|
|
10
|
+
import { withSyntaxFixes } from "./source-fixes-syntax.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Read the text of one format: `{ deck, findings, notes? }` on success, `{ syntax: true, findings }` when it cannot be read. `findings` carry the
|
|
14
|
+
* syntax errors (`kind: "syntax"`) or the OPF findings of the parsed deck; `notes` are the reader's warnings that come with a deck.
|
|
15
|
+
*/
|
|
16
|
+
export function readSource(format, text, catalogs) {
|
|
17
|
+
if (format === "json") {
|
|
18
|
+
const problems = analyzeSyntax(text);
|
|
19
|
+
if (problems.length) return { syntax: true, findings: problems.map((problem) => normalizeFinding(problem, "syntax")) };
|
|
20
|
+
const deck = JSON.parse(text);
|
|
21
|
+
const duplicates = findDuplicateKeys(text);
|
|
22
|
+
if (duplicates.length) return { syntax: true, findings: duplicates };
|
|
23
|
+
return { deck, findings: [] };
|
|
24
|
+
}
|
|
25
|
+
const result = format === "yaml" ? fromYaml(text, { catalogs }) : fromMarkdown(text, { catalogs });
|
|
26
|
+
// The reader's own findings: errors stop the document being read; its warnings (a numbered list that became bullets) come with the deck.
|
|
27
|
+
const readerFindings = result.findings.filter((finding) => !finding.ruleId?.startsWith("opf/"));
|
|
28
|
+
const errors = readerFindings.filter((finding) => finding.severity === "error");
|
|
29
|
+
const syntax = (list) => withSyntaxFixes(format, text, list).map((finding) => normalizeFinding(finding, "syntax"));
|
|
30
|
+
if (errors.length) return { syntax: true, findings: syntax(errors) };
|
|
31
|
+
return { deck: result.presentation, findings: result.findings.map((finding) => normalizeFinding(finding, "schema")), notes: syntax(readerFindings) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `readSource` plus, when a deck was read, `validation`: the report the editor session computes for that deck (`checkFormat`, with the same
|
|
36
|
+
* catalogs). `validate: false` skips it (the main thread's own path, where the session validates the patch itself).
|
|
37
|
+
*/
|
|
38
|
+
export function checkSource({ format, text, catalogs, validate = true }) {
|
|
39
|
+
const read = readSource(format, text, catalogs);
|
|
40
|
+
if (validate && read.deck) {
|
|
41
|
+
read.validation = checkFormat(read.deck, { catalogs });
|
|
42
|
+
// What the Markdown writer cannot hold for this deck: the pane lists it after every applied edit, and writing a big deck back is not cheap.
|
|
43
|
+
if (format === "md") read.markdownNotes = markdownNotesOf(read.deck);
|
|
44
|
+
}
|
|
45
|
+
return read;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The notes about what Markdown cannot hold, as findings, from the report of `toMarkdown`. */
|
|
49
|
+
export function markdownNotes(report) {
|
|
50
|
+
const notes = report.embedded.map((part) => normalizeFinding({ path: part.path, message: `Markdown has no syntax for this, so it is kept as YAML in a fenced block (${part.reason}).`, severity: "warning" }, "format"));
|
|
51
|
+
for (const entry of report.loss) {
|
|
52
|
+
const at = entry.indexOf(": ");
|
|
53
|
+
notes.push(normalizeFinding({ path: at > 0 ? entry.slice(0, at) : "", message: `Left out of the Markdown: ${at > 0 ? entry.slice(at + 2) : entry}.`, severity: "warning" }, "format"));
|
|
54
|
+
}
|
|
55
|
+
return notes;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The Markdown notes of `deck`; none when it cannot be written as Markdown. */
|
|
59
|
+
export function markdownNotesOf(deck) {
|
|
60
|
+
try { return markdownNotes(toMarkdown(deck).report); } catch { return []; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The syntax problems of a JSON text as findings (the pane's quick pass, 150 ms after the last key). */
|
|
64
|
+
export function checkSyntaxOnly(text) {
|
|
65
|
+
return analyzeSyntax(text).map((problem) => normalizeFinding(problem, "syntax"));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The worker's side of the protocol, free of any worker API so it can be tested in Node. `handle(message)` returns the reply for one message
|
|
70
|
+
* (or null for a message with no reply). Messages: `{ id, type: "catalogs", catalogs }`, `{ id, type: "check", format, text }` and
|
|
71
|
+
* `{ id, type: "syntax", text }`; replies: `{ id, ok: true, result }` or `{ id, ok: false, error }`.
|
|
72
|
+
*/
|
|
73
|
+
export function createCheckService() {
|
|
74
|
+
let catalogs;
|
|
75
|
+
return {
|
|
76
|
+
handle(message) {
|
|
77
|
+
const id = message?.id;
|
|
78
|
+
try {
|
|
79
|
+
switch (message?.type) {
|
|
80
|
+
case "catalogs": catalogs = message.catalogs; return { id, ok: true, result: null };
|
|
81
|
+
case "check": return { id, ok: true, result: checkSource({ format: message.format, text: message.text, catalogs }) };
|
|
82
|
+
case "syntax": return { id, ok: true, result: checkSyntaxOnly(message.text) };
|
|
83
|
+
default: return { id, ok: false, error: `Unknown message type ${String(message?.type)}.` };
|
|
84
|
+
}
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return { id, ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// The client side of the source worker (`./source-worker.js`): it owns the worker, numbers the requests, keeps the worker's catalogs in step with the
|
|
2
|
+
// pane's, and falls back to the pane's own inline path for good, once, when anything about the worker goes wrong (it cannot start, a script URL is
|
|
3
|
+
// blocked or missing, a message cannot be cloned, the worker throws or stops answering). It never loses an edit: a failed request rejects with
|
|
4
|
+
// `error.workerFailed === true`, and the pane then does the same work inline.
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TIMEOUT = 15000;
|
|
7
|
+
|
|
8
|
+
/** A failure of the worker as an error the caller can tell from an exception of the checked text. */
|
|
9
|
+
function failure(cause) {
|
|
10
|
+
const error = new Error(cause instanceof Error ? cause.message : String(cause ?? "The source worker failed."));
|
|
11
|
+
error.workerFailed = true;
|
|
12
|
+
error.cause = cause;
|
|
13
|
+
return error;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The worker the `worker` option stands for: a function is called, an object with `postMessage` is used as given, `true` starts the package's own. */
|
|
17
|
+
function makeWorker(option) {
|
|
18
|
+
if (typeof option === "function") return option();
|
|
19
|
+
if (option && typeof option === "object" && typeof option.postMessage === "function") return option;
|
|
20
|
+
if (option === true) {
|
|
21
|
+
if (typeof Worker !== "function") throw new Error("Web Workers are not available here.");
|
|
22
|
+
return new Worker(new URL("./source-worker.js", import.meta.url), { type: "module" });
|
|
23
|
+
}
|
|
24
|
+
throw new TypeError("The worker option is true, a Worker, or a function that returns one.");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {{ worker: boolean | object | (() => object), onError?: (error: unknown) => void, timeout?: number }} options
|
|
29
|
+
*/
|
|
30
|
+
export function createSourceChecker(options = {}) {
|
|
31
|
+
const timeout = options.timeout ?? DEFAULT_TIMEOUT;
|
|
32
|
+
let worker = null;
|
|
33
|
+
let failed = false;
|
|
34
|
+
let disposed = false;
|
|
35
|
+
let nextId = 0;
|
|
36
|
+
let sentCatalogs;
|
|
37
|
+
let catalogsSent = false;
|
|
38
|
+
const pending = new Map();
|
|
39
|
+
const latest = { check: 0, syntax: 0 };
|
|
40
|
+
const counts = { worker: 0 };
|
|
41
|
+
|
|
42
|
+
function fail(cause) {
|
|
43
|
+
if (failed || disposed) return;
|
|
44
|
+
failed = true;
|
|
45
|
+
const error = failure(cause);
|
|
46
|
+
for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(error); }
|
|
47
|
+
pending.clear();
|
|
48
|
+
try { worker?.terminate?.(); } catch { /* Already gone. */ }
|
|
49
|
+
worker = null;
|
|
50
|
+
try { options.onError?.(cause instanceof Error ? cause : error); } catch { /* A host callback must not break the fallback. */ }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function reply(event) {
|
|
54
|
+
const data = event?.data;
|
|
55
|
+
const entry = pending.get(data?.id);
|
|
56
|
+
if (!entry) return;
|
|
57
|
+
pending.delete(data.id);
|
|
58
|
+
clearTimeout(entry.timer);
|
|
59
|
+
if (data.ok === false) { entry.reject(failure(data.error)); fail(new Error(String(data.error))); return; }
|
|
60
|
+
if (data.skipped || (entry.kind && latest[entry.kind] !== data.id)) { entry.resolve(null); return; }
|
|
61
|
+
if (entry.kind) counts.worker++;
|
|
62
|
+
entry.resolve(data.result);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function start() {
|
|
66
|
+
if (worker) return worker;
|
|
67
|
+
worker = makeWorker(options.worker);
|
|
68
|
+
if (!worker || typeof worker.postMessage !== "function") throw new TypeError("The worker option did not give a Worker.");
|
|
69
|
+
worker.onmessage = reply;
|
|
70
|
+
worker.onerror = (event) => { event?.preventDefault?.(); fail(new Error(event?.message || "The source worker failed to start.")); };
|
|
71
|
+
worker.onmessageerror = () => fail(new Error("The source worker sent a message that could not be read."));
|
|
72
|
+
return worker;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function post(message, kind) {
|
|
76
|
+
if (failed || disposed) return Promise.reject(failure("The source worker is not available."));
|
|
77
|
+
let target;
|
|
78
|
+
try { target = start(); } catch (error) { fail(error); return Promise.reject(failure(error)); }
|
|
79
|
+
const id = ++nextId;
|
|
80
|
+
if (kind) latest[kind] = id;
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const timer = setTimeout(() => fail(new Error("The source worker did not answer in time.")), timeout);
|
|
83
|
+
pending.set(id, { resolve, reject, timer, kind });
|
|
84
|
+
try { target.postMessage({ id, ...message }); } catch (error) { fail(error); }
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Send the catalogs when they are not the ones the worker already has (by identity), ahead of the request that needs them. */
|
|
89
|
+
function syncCatalogs(catalogs) {
|
|
90
|
+
if (catalogsSent && sentCatalogs === catalogs) return;
|
|
91
|
+
sentCatalogs = catalogs;
|
|
92
|
+
catalogsSent = true;
|
|
93
|
+
post({ type: "catalogs", catalogs }).catch(() => { /* `fail` already ran; the request after it rejects too. */ });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
/** Whether the worker can still be used (it may not have been started yet). */
|
|
98
|
+
get available() { return !failed && !disposed; },
|
|
99
|
+
/** Whether the worker is running now. */
|
|
100
|
+
get running() { return worker !== null && !failed && !disposed; },
|
|
101
|
+
/** The catalogs object the worker's next check will use (the last one sent). */
|
|
102
|
+
get catalogs() { return catalogsSent ? sentCatalogs : undefined; },
|
|
103
|
+
/** How many checks the worker answered. */
|
|
104
|
+
get answered() { return counts.worker; },
|
|
105
|
+
/**
|
|
106
|
+
* Check `text` as `format` in the worker, with `catalogs`. Resolves with the `checkSource` result, or with null when a newer check replaced this
|
|
107
|
+
* one. Rejects with `error.workerFailed` when the worker is not usable (and never again starts it).
|
|
108
|
+
*/
|
|
109
|
+
check(format, text, catalogs) {
|
|
110
|
+
if (failed || disposed) return Promise.reject(failure("The source worker is not available."));
|
|
111
|
+
try { start(); syncCatalogs(catalogs); } catch (error) { fail(error); return Promise.reject(failure(error)); }
|
|
112
|
+
return post({ type: "check", format, text }, "check");
|
|
113
|
+
},
|
|
114
|
+
/** The syntax findings of a JSON text (null when a newer request replaced this one). */
|
|
115
|
+
syntax(text) {
|
|
116
|
+
if (failed || disposed) return Promise.reject(failure("The source worker is not available."));
|
|
117
|
+
try { start(); } catch (error) { fail(error); return Promise.reject(failure(error)); }
|
|
118
|
+
return post({ type: "syntax", text }, "syntax");
|
|
119
|
+
},
|
|
120
|
+
/** Stop the worker and answer nothing more. */
|
|
121
|
+
dispose() {
|
|
122
|
+
if (disposed) return;
|
|
123
|
+
disposed = true;
|
|
124
|
+
for (const entry of pending.values()) { clearTimeout(entry.timer); entry.resolve(null); }
|
|
125
|
+
pending.clear();
|
|
126
|
+
try { worker?.terminate?.(); } catch { /* Already gone. */ }
|
|
127
|
+
worker = null;
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -5,15 +5,18 @@ import { hasNextSnippetField, hasPrevSnippetField, pickedCompletion, snippet, st
|
|
|
5
5
|
import { isolateHistory } from "@codemirror/commands";
|
|
6
6
|
import { EditorSelection } from "@codemirror/state";
|
|
7
7
|
import { describeField } from "@openpresentation/opf/schema";
|
|
8
|
-
import { listCatalogRecords } from "./catalogs.js";
|
|
8
|
+
import { listCatalogRecords, mergeCatalogs } from "./catalogs.js";
|
|
9
9
|
import { LAYOUT_INTENT_GROUPS, layoutRegionSummary } from "./layout-templates.js";
|
|
10
|
-
import { Document, parse as parseYamlDocument } from "yaml";
|
|
11
|
-
import { pathAt } from "@openpresentation/opf/source";
|
|
10
|
+
import { Document, parse as parseYamlDocument, parseDocument } from "yaml";
|
|
11
|
+
import { pathAt, scanMarkdown } from "@openpresentation/opf/source";
|
|
12
|
+
import { findNodeAtLocation, getNodeValue } from "jsonc-parser";
|
|
12
13
|
import { contextAt, nextSignificant, nodeAt, skeleton, tokenAt } from "./source-json.js";
|
|
14
|
+
import { PROMOTED_REGIONS, beatsOf, builtinVariables, columnSourceFor, factsOf, jsonDeckParts, layoutKeys, referenceSiteAt, regionNames, slidePath, valueAt, variableNames, yamlDeckParts } from "./source-references.js";
|
|
15
|
+
import { beginUndoGroup, finishUndoGroup, takeUndoGroup } from "./source-undo-groups.js";
|
|
13
16
|
import { scalarValue, yamlContextAt } from "./source-yaml.js";
|
|
14
17
|
import { markdownContextAt, markdownOptions, markdownPlan, markdownHoverAt } from "./source-markdown.js";
|
|
15
18
|
import { childDoc, docNode, docText, fieldDoc } from "./source-docs.js";
|
|
16
|
-
import { snippetJson, snippetYaml, snippetsFor } from "./source-snippets.js";
|
|
19
|
+
import { keySnippetsFor, snippetJson, snippetYaml, snippetsFor } from "./source-snippets.js";
|
|
17
20
|
|
|
18
21
|
export { contextAt, tokenAt };
|
|
19
22
|
|
|
@@ -114,16 +117,38 @@ function advancedShown(children, typed, explicit) {
|
|
|
114
117
|
return children.some((child) => child.advanced && child.key.toLowerCase().startsWith(typed));
|
|
115
118
|
}
|
|
116
119
|
|
|
117
|
-
|
|
120
|
+
/**
|
|
121
|
+
* The keys of an object in the order the list shows them (SPEC 4.4): the required ones first, then, for a slide with a layout, the keys the layout's
|
|
122
|
+
* regions call for that are not set yet, then the common order the schema gives (`children` arrives in that order and keeps it).
|
|
123
|
+
*/
|
|
124
|
+
function rankKeys(context, shown, catalogs) {
|
|
125
|
+
const slide = context.path.length === 2 && context.path[0] === "slides" ? valueAt(context.document, context.path) : undefined;
|
|
126
|
+
const layout = typeof slide?.layout === "string" ? slide.layout : undefined;
|
|
127
|
+
let wanted = [];
|
|
128
|
+
if (layout) {
|
|
129
|
+
try {
|
|
130
|
+
const record = listCatalogRecords(context.document, "layouts", { catalogs }).find((entry) => entry.id === layout)?.record;
|
|
131
|
+
wanted = layoutKeys(record, shown.map((child) => child.key));
|
|
132
|
+
} catch { wanted = []; }
|
|
133
|
+
}
|
|
134
|
+
const rank = (child) => (child.required ? 0 : wanted.includes(child.key) ? 1 : 2);
|
|
135
|
+
return shown.map((child, index) => ({ child, index })).sort((a, b) => rank(a.child) - rank(b.child) || (rank(a.child) === 1 ? wanted.indexOf(a.child.key) - wanted.indexOf(b.child.key) : a.index - b.index)).map((entry) => entry.child);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function keyOptions(context, text, caret, explicit, catalogs) {
|
|
118
139
|
const info = describeField(context.path, context.document);
|
|
119
140
|
const typed = text.slice(context.token.from, caret).toLowerCase();
|
|
120
141
|
const options = [];
|
|
121
142
|
if (info.known) {
|
|
122
143
|
const free = info.children.filter((child) => !context.existing.has(child.key));
|
|
123
144
|
const withAdvanced = advancedShown(free, typed, explicit);
|
|
124
|
-
const shown = free.filter((child) => !child.advanced || (withAdvanced && child.key.toLowerCase().startsWith(typed)));
|
|
145
|
+
const shown = rankKeys(context, free.filter((child) => !child.advanced || (withAdvanced && child.key.toLowerCase().startsWith(typed))), catalogs);
|
|
125
146
|
// A blank document starts with a deck: its snippet comes first.
|
|
126
147
|
if (context.blank) for (const entry of snippetsFor("value", [])) options.push(snippetOption(entry, 99));
|
|
148
|
+
// A key a snippet is written for (the deck's `design`, `organization`, `speaker`) is accepted as that snippet, as VS Code does for a property
|
|
149
|
+
// with default snippets: its members come with it, each a tab stop.
|
|
150
|
+
const keySnippets = snippetsFor("key", context.path);
|
|
151
|
+
const forKey = (key) => (context.flow ? undefined : keySnippetsFor(context.path).find((entry) => entry.key === key));
|
|
127
152
|
shown.forEach((child, index) => options.push({
|
|
128
153
|
label: child.key,
|
|
129
154
|
type: "property",
|
|
@@ -131,10 +156,10 @@ function keyOptions(context, text, caret, explicit) {
|
|
|
131
156
|
boost: boostAt(index + (context.blank ? 1 : 0)),
|
|
132
157
|
opfRequired: child.required,
|
|
133
158
|
opfDoc: child.description ?? "",
|
|
134
|
-
opf: { kind: "key", key: child.key },
|
|
159
|
+
opf: { kind: "key", key: child.key, ...(forKey(child.key) ? { snippet: child.key } : {}) },
|
|
135
160
|
info: () => docNode(describeChild(context, child)),
|
|
136
161
|
}));
|
|
137
|
-
for (const entry of
|
|
162
|
+
for (const entry of keySnippets) options.push(snippetOption(entry, boostAt(options.length)));
|
|
138
163
|
// A key right after a list dash is also the start of the item: the item snippets (a slide, a reference) fit there.
|
|
139
164
|
if (context.itemStart) for (const entry of snippetsFor("item", context.path)) options.push(snippetOption(entry, boostAt(options.length)));
|
|
140
165
|
return { options, advanced: withAdvanced };
|
|
@@ -183,12 +208,141 @@ function catalogOptions(kind, context, catalogs) {
|
|
|
183
208
|
});
|
|
184
209
|
}
|
|
185
210
|
|
|
186
|
-
|
|
211
|
+
// ---- cross references (SPEC 3.4) --------------------------------------------------------------------------------------------------------
|
|
212
|
+
/**
|
|
213
|
+
* Where the references of the document are read from, for the context: `facts()` the assets, datasets, variables, references and the narrative
|
|
214
|
+
* (once), `read(path)` a value of the text being edited (a draft: undefined when the text does not say). `format` is the editor's: "json", "yaml"
|
|
215
|
+
* or "md" (the deck of a Markdown text is its front matter).
|
|
216
|
+
*/
|
|
217
|
+
export function referenceScope(context, text, format) {
|
|
218
|
+
let facts, parsed;
|
|
219
|
+
const deckParts = () => {
|
|
220
|
+
if (format === "json") return context.root ? jsonDeckParts(context.root, getNodeValue) : {};
|
|
221
|
+
if (format === "md") {
|
|
222
|
+
const front = scanMarkdown(text).frontMatter;
|
|
223
|
+
return front ? yamlDeckParts(text.slice(front.yamlFrom, front.yamlTo)) : {};
|
|
224
|
+
}
|
|
225
|
+
return yamlDeckParts(text);
|
|
226
|
+
};
|
|
227
|
+
return {
|
|
228
|
+
facts: () => (facts ??= factsOf(deckParts())),
|
|
229
|
+
read(path) {
|
|
230
|
+
if (format === "json") {
|
|
231
|
+
const node = context.root ? findNodeAtLocation(context.root, path) : undefined;
|
|
232
|
+
return node ? getNodeValue(node) : undefined;
|
|
233
|
+
}
|
|
234
|
+
if (parsed === undefined) {
|
|
235
|
+
try { parsed = parseDocument(context.sourceText ?? text, { strict: false, uniqueKeys: false }).toJS({ maxAliasCount: -1 }); } catch { parsed = null; }
|
|
236
|
+
}
|
|
237
|
+
return valueAt(parsed, path.slice(context.prefix?.length ?? 0));
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const REFERENCE_LABELS = { asset: "asset", dataset: "dataset", datasetColumn: "dataset column", cite: "reference", beat: "narrative beat", region: "layout region", color: "color variable" };
|
|
243
|
+
const referenceOption = (id, value, extra) => literalOption(id, JSON.stringify(value), { type: "reference", value, ...extra });
|
|
244
|
+
|
|
245
|
+
function referenceOptions(site, context, catalogs, scope) {
|
|
246
|
+
const facts = scope.facts();
|
|
247
|
+
const options = [];
|
|
248
|
+
const add = (id, value, extra = {}) => options.push(referenceOption(id, value, { boost: boostAt(options.length), info: () => docNode({ title: id, type: REFERENCE_LABELS[site.kind], description: extra.doc || undefined, notes: [extra.detail].filter(Boolean) }), ...extra }));
|
|
249
|
+
switch (site.kind) {
|
|
250
|
+
case "asset":
|
|
251
|
+
for (const asset of facts.assets) add(`asset:${asset.id}`, `asset:${asset.id}`, { detail: asset.detail || "asset", doc: asset.detail });
|
|
252
|
+
for (const variable of facts.variables) if (variable.type === "image") add(`var:${variable.id}`, `var:${variable.id}`, { detail: "image variable" });
|
|
253
|
+
break;
|
|
254
|
+
case "dataset":
|
|
255
|
+
for (const dataset of facts.datasets) add(dataset.id, dataset.id, { detail: dataset.title || `${dataset.columns.length} columns`, doc: dataset.columns.join(", ") });
|
|
256
|
+
break;
|
|
257
|
+
case "datasetColumn": {
|
|
258
|
+
const source = columnSourceFor(site, (path) => scope.read(path));
|
|
259
|
+
const columns = source?.dataset !== undefined ? facts.datasets.find((entry) => entry.id === source.dataset)?.columns : source?.inline;
|
|
260
|
+
for (const column of columns ?? []) add(column, column, { detail: source.dataset !== undefined ? `column of ${source.dataset}` : "column" });
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
case "cite":
|
|
264
|
+
for (const reference of facts.references) add(reference.id, reference.id, { detail: brief(reference.text) ?? "reference", doc: reference.text });
|
|
265
|
+
break;
|
|
266
|
+
case "beat": {
|
|
267
|
+
let record;
|
|
268
|
+
if (facts.narrative) {
|
|
269
|
+
try { record = listCatalogRecords(context.document, "narratives", { catalogs }).find((entry) => entry.id === facts.narrative)?.record; } catch { record = undefined; }
|
|
270
|
+
}
|
|
271
|
+
for (const beat of beatsOf(record)) add(beat.id, beat.id, { detail: beat.name || "beat", doc: brief(beat.description) });
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
case "region": {
|
|
275
|
+
const slide = slidePath(context.path);
|
|
276
|
+
const layout = slide ? valueAt(context.document, slide)?.layout : undefined;
|
|
277
|
+
let names = [];
|
|
278
|
+
if (typeof layout === "string") {
|
|
279
|
+
try { names = regionNames(listCatalogRecords(context.document, "layouts", { catalogs }).find((entry) => entry.id === layout)?.record); } catch { names = []; }
|
|
280
|
+
}
|
|
281
|
+
for (const name of names) add(name, name, { detail: `region of ${layout}` });
|
|
282
|
+
for (const name of PROMOTED_REGIONS) if (!names.includes(name)) add(name, name, { detail: "promoted region" });
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
case "color":
|
|
286
|
+
for (const variable of facts.variables) if (variable.type === "color") add(`var:${variable.id}`, `var:${variable.id}`, { detail: "color variable" });
|
|
287
|
+
break;
|
|
288
|
+
default:
|
|
289
|
+
}
|
|
290
|
+
return options;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// `{{name}}` in a text, offered once `{{` is typed: the variables the document declares first, then the built-in ones the engine resolves
|
|
294
|
+
// (`deck.*`, `speaker.*`, `organization.*`, `slide.*`), grouped by prefix, each saying whether the document sets it. `before` is the text of the value
|
|
295
|
+
// before the caret.
|
|
296
|
+
const VARIABLE_OPEN = /\{\{([A-Za-z0-9._-]*)$/;
|
|
297
|
+
function variableOptions(scope) {
|
|
298
|
+
const facts = scope.facts();
|
|
299
|
+
const options = [];
|
|
300
|
+
variableNames(facts).forEach((variable) => options.push({ label: variable.id, type: "reference", detail: `${variable.type} variable`, boost: boostAt(options.length), opfDoc: `The variable ${variable.id}`, opf: { kind: "variable", name: variable.id } }));
|
|
301
|
+
for (const builtin of builtinVariables(facts)) {
|
|
302
|
+
options.push({
|
|
303
|
+
label: builtin.name, type: "reference", detail: builtin.detail, boost: boostAt(options.length), opfDoc: builtin.doc, opf: { kind: "variable", name: builtin.name, builtin: true },
|
|
304
|
+
info: () => docNode({ title: builtin.name, type: "built-in variable", description: builtin.doc, notes: [builtin.available ? "" : "Not set in this document"].filter(Boolean) }),
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
return options;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The result for a caret right after `{{` in a text value, or null: the variable names, replacing what is typed after the braces. */
|
|
311
|
+
function variableResult(context, text, position, scope) {
|
|
312
|
+
const token = context.token;
|
|
313
|
+
if (!token || context.mode === "key") return null;
|
|
314
|
+
const open = VARIABLE_OPEN.exec(text.slice(token.from, Math.min(position, token.to)));
|
|
315
|
+
return open ? variableList(scope, position - open[1].length, position) : null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function variableList(scope, from, position) {
|
|
319
|
+
const options = variableOptions(scope);
|
|
320
|
+
return options.length ? { from, to: position, filter: true, options, validFor: (typed) => /^[A-Za-z0-9._-]*$/.test(typed) } : null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The line under a list of catalog records when a group of the document names a source no registered catalog matches (SPEC 4.5): its records
|
|
325
|
+
* beyond the embedded ones are not available here. Empty when every source is registered.
|
|
326
|
+
*/
|
|
327
|
+
export function unloadedFooter(document, catalogs) {
|
|
328
|
+
const groups = document && typeof document === "object" ? document.catalogs : undefined;
|
|
329
|
+
if (!groups || typeof groups !== "object") return "";
|
|
330
|
+
let registered = [];
|
|
331
|
+
try { registered = mergeCatalogs(catalogs).map((entry) => entry.source); } catch { registered = []; }
|
|
332
|
+
const names = Object.entries(groups).filter(([name, group]) => name !== "custom" && group && typeof group === "object" && typeof group.source === "string" && !registered.includes(group.source)).map(([name]) => name);
|
|
333
|
+
return names.length ? `Records from catalog ${names.join(", ")} are not loaded` : "";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function valueOptions(context, catalogs, scope) {
|
|
187
337
|
const info = describeField(context.path, context.document);
|
|
188
338
|
const options = [];
|
|
189
339
|
if (!info.known) return { options };
|
|
190
340
|
const variant = primaryVariant(info);
|
|
191
|
-
|
|
341
|
+
const site = scope ? referenceSiteAt(context.path, context.document) : null;
|
|
342
|
+
if (info.catalogKind) {
|
|
343
|
+
const footer = unloadedFooter(context.document, catalogs);
|
|
344
|
+
for (const option of catalogOptions(info.catalogKind, context, catalogs)) options.push(footer ? { ...option, opfFooter: footer } : option);
|
|
345
|
+
}
|
|
192
346
|
else if (info.enum?.length) {
|
|
193
347
|
// In the order VS Code lists them: by label.
|
|
194
348
|
info.enum.forEach((entry) => options.push(literalOption(String(entry.value), JSON.stringify(entry.value), {
|
|
@@ -201,6 +355,7 @@ function valueOptions(context, catalogs) {
|
|
|
201
355
|
const label = variant.type === "object" ? "{}" : "[]";
|
|
202
356
|
options.push({ label, type: "keyword", detail: variant.type === "object" ? "object" : "list", boost: 99, opfDoc: info.description ?? "", opf: { kind: "template" }, info: () => docNode(fieldDoc(info, context.key ?? label)) });
|
|
203
357
|
}
|
|
358
|
+
if (site) options.push(...referenceOptions(site, context, catalogs, scope));
|
|
204
359
|
for (const entry of snippetsFor(context.mode, context.path)) options.push(snippetOption(entry, boostAt(options.length)));
|
|
205
360
|
return { options };
|
|
206
361
|
}
|
|
@@ -234,12 +389,15 @@ export function completionResult(state, position, { explicit, catalogs, format =
|
|
|
234
389
|
const text = state.doc.toString();
|
|
235
390
|
let context = contextFor(format, text, position, explicit);
|
|
236
391
|
if (!context) return null;
|
|
237
|
-
|
|
392
|
+
const scope = referenceScope(context, text, format);
|
|
393
|
+
if (context.format === "md") return context.region === "variable" ? variableList(scope, context.token.from, position) : markdownResult(context, text, position, { explicit, catalogs, scope });
|
|
238
394
|
if (context.format === "yaml") {
|
|
239
395
|
context = settleYamlContext(context, text);
|
|
240
396
|
if (!context) return null;
|
|
241
397
|
}
|
|
242
|
-
const
|
|
398
|
+
const variables = context.mode !== "key" ? variableResult(context, text, position, scope) : null;
|
|
399
|
+
if (variables || context.multiline) return variables;
|
|
400
|
+
const { options, advanced } = context.mode === "key" ? keyOptions(context, text, position, explicit, catalogs) : valueOptions(context, catalogs, scope);
|
|
243
401
|
if (!options.length) return null;
|
|
244
402
|
return listResult(context, options, advanced, { position, explicit });
|
|
245
403
|
}
|
|
@@ -261,8 +419,8 @@ function listResult(context, options, advanced, { position, explicit }) {
|
|
|
261
419
|
};
|
|
262
420
|
}
|
|
263
421
|
|
|
264
|
-
function markdownResult(context, text, position, { explicit, catalogs }) {
|
|
265
|
-
const built = markdownOptions(context, { catalogs, explicit, text, position, makers: { snippetOption, literalOption, boostAt, docNode, valueOptions, listCatalogRecords } });
|
|
422
|
+
function markdownResult(context, text, position, { explicit, catalogs, scope }) {
|
|
423
|
+
const built = markdownOptions(context, { catalogs, explicit, text, position, scope, makers: { snippetOption, literalOption, boostAt, docNode, valueOptions, listCatalogRecords } });
|
|
266
424
|
if (!built?.options.length) return null;
|
|
267
425
|
const { token } = context;
|
|
268
426
|
return {
|
|
@@ -289,9 +447,14 @@ const yamlKey = (key) => (/^[A-Za-z_$][A-Za-z0-9_.$+-]*(?::[A-Za-z0-9_.$+-]+)*$/
|
|
|
289
447
|
const escapeTemplate = (text) => text.replace(/[\\{}]/g, (character) => `\\${character}`);
|
|
290
448
|
|
|
291
449
|
/** What accepting a key writes in YAML: `key: ` for a scalar, `key:` and a new line for a mapping or a list. */
|
|
292
|
-
function yamlKeyPlan(context, key, text, end) {
|
|
450
|
+
function yamlKeyPlan(context, key, text, end, snippetKey) {
|
|
293
451
|
const written = yamlKey(key);
|
|
294
452
|
if (/^[ \t]*:/.test(text.slice(end))) return { template: escapeTemplate(written), fields: 0, chain: false };
|
|
453
|
+
const entry = snippetKey ? keySnippetsFor(context.path).find((candidate) => candidate.key === snippetKey) : undefined;
|
|
454
|
+
if (entry && !context.flow) {
|
|
455
|
+
const rendered = snippetYaml(entry, { dash: 0 });
|
|
456
|
+
return { template: rendered.text, fields: rendered.fields.length, chain: false, choices: rendered.fields };
|
|
457
|
+
}
|
|
295
458
|
let info;
|
|
296
459
|
try { info = describeField([...context.path, key], context.document); } catch { info = null; }
|
|
297
460
|
const kind = info?.known ? variantKind(info) : "string";
|
|
@@ -313,7 +476,7 @@ function yamlKeyPlan(context, key, text, end) {
|
|
|
313
476
|
/** What accepting `completion` writes in YAML: `{ start, end, template, fields, chain, choices }`. */
|
|
314
477
|
function planYaml(context, completion, text, end) {
|
|
315
478
|
const kind = completion.opf?.kind;
|
|
316
|
-
if (kind === "key") return yamlKeyPlan(context, completion.opf.key, text, end);
|
|
479
|
+
if (kind === "key") return yamlKeyPlan(context, completion.opf.key, text, end, completion.opf.snippet);
|
|
317
480
|
if (kind === "literal") {
|
|
318
481
|
const value = completion.opf.value;
|
|
319
482
|
const prefix = context.needsSpace ? " " : "";
|
|
@@ -345,6 +508,11 @@ export function planCompletion(context, completion, text, end) {
|
|
|
345
508
|
const key = completion.opf.key;
|
|
346
509
|
const quoted = JSON.stringify(key);
|
|
347
510
|
if (/^\s*:/.test(text.slice(end))) return { template: quoted, fields: 0, chain: false };
|
|
511
|
+
const entry = completion.opf.snippet ? keySnippetsFor(context.path).find((candidate) => candidate.key === completion.opf.snippet) : undefined;
|
|
512
|
+
if (entry) {
|
|
513
|
+
const rendered = snippetJson(entry, { comma: needsComma });
|
|
514
|
+
return { template: rendered.text, fields: rendered.fields.length, chain: false, choices: rendered.fields };
|
|
515
|
+
}
|
|
348
516
|
let info;
|
|
349
517
|
try { info = describeField([...context.path, key], context.document); } catch { info = null; }
|
|
350
518
|
const value = info?.known ? valueTemplate(info) : { template: '"${1}"', fields: 1, chain: false };
|
|
@@ -363,7 +531,7 @@ export function planCompletion(context, completion, text, end) {
|
|
|
363
531
|
if (kind === "snippet") {
|
|
364
532
|
const entry = snippetsFor(context.mode, context.path).find((candidate) => candidate.prefix === completion.opf.prefix);
|
|
365
533
|
if (!entry) return null;
|
|
366
|
-
const rendered = snippetJson(entry, { comma: needsComma &&
|
|
534
|
+
const rendered = snippetJson(entry, { comma: needsComma && !/^\s*:/.test(text.slice(end)) });
|
|
367
535
|
return { template: rendered.text, fields: rendered.fields.length, chain: false, choices: rendered.fields };
|
|
368
536
|
}
|
|
369
537
|
return null;
|
|
@@ -374,10 +542,20 @@ export function applyCompletion(view, completion, fallbackFrom, fallbackTo, say,
|
|
|
374
542
|
const { state } = view;
|
|
375
543
|
const text = state.doc.toString();
|
|
376
544
|
const main = state.selection.main;
|
|
545
|
+
if (completion.opf?.kind === "variable") { applyVariable(view, completion, fallbackFrom, fallbackTo); return; }
|
|
546
|
+
// A value accepted right after the key that opened its list is one undo step with that key (R-CMP-14).
|
|
547
|
+
const group = takeUndoGroup(view);
|
|
377
548
|
const context = contextFor(format, text, main.head, true);
|
|
378
549
|
const token = context?.token;
|
|
379
550
|
const start = token ? token.start : fallbackFrom, end = token ? token.end : fallbackTo;
|
|
380
551
|
const plan = context ? planCompletion(context, completion, text, end) : null;
|
|
552
|
+
// Accepting is one undo step: what is typed next starts another.
|
|
553
|
+
const finish = (chain) => {
|
|
554
|
+
view.dispatch({ annotations: isolateHistory.of("full") });
|
|
555
|
+
if (group) finishUndoGroup(view, group);
|
|
556
|
+
// A key whose value is one of a closed set opens that list at once.
|
|
557
|
+
if (chain) { beginUndoGroup(view, state.doc); startCompletion(view); }
|
|
558
|
+
};
|
|
381
559
|
if (!plan) {
|
|
382
560
|
view.dispatch({ changes: { from: fallbackFrom, to: fallbackTo, insert: completion.label }, selection: { anchor: fallbackFrom + completion.label.length }, annotations: [pickedCompletion.of(completion)], userEvent: "input.complete" });
|
|
383
561
|
return;
|
|
@@ -393,6 +571,7 @@ export function applyCompletion(view, completion, fallbackFrom, fallbackTo, say,
|
|
|
393
571
|
if (next !== "" && !",}]".includes(next)) changes.push({ from: token.end, insert: "," });
|
|
394
572
|
const caret = inSnippet ? token.from + inner.length : token.end + inner.length - (token.to - token.from);
|
|
395
573
|
view.dispatch({ changes, selection: { anchor: caret }, annotations: [pickedCompletion.of(completion)], userEvent: "input.complete", scrollIntoView: true });
|
|
574
|
+
finish(false);
|
|
396
575
|
return;
|
|
397
576
|
}
|
|
398
577
|
// YAML and Markdown values inside quotes: only the inside changes, and the caret ends after the closing quote.
|
|
@@ -400,6 +579,7 @@ export function applyCompletion(view, completion, fallbackFrom, fallbackTo, say,
|
|
|
400
579
|
const insert = plan.template.replace(/\\([{}\\])/g, "$1");
|
|
401
580
|
const caret = token.closed ? token.end + insert.length - (token.to - token.from) : token.from + insert.length;
|
|
402
581
|
view.dispatch({ changes: { from: token.from, to: token.to, insert }, selection: { anchor: caret }, annotations: [pickedCompletion.of(completion)], userEvent: "input.complete", scrollIntoView: true });
|
|
582
|
+
finish(false);
|
|
403
583
|
return;
|
|
404
584
|
}
|
|
405
585
|
const from = plan.replaceFrom ?? start, to = plan.replaceTo ?? end;
|
|
@@ -407,16 +587,46 @@ export function applyCompletion(view, completion, fallbackFrom, fallbackTo, say,
|
|
|
407
587
|
snippet(plan.template)(view, completion, from, to);
|
|
408
588
|
if (plan.choices) snippetRuns.set(view, { fields: plan.choices, index: -1 });
|
|
409
589
|
else snippetRuns.delete(view);
|
|
410
|
-
if (plan.choices) snippetStep(view, true, { start: true, say });
|
|
590
|
+
if (plan.choices) snippetStep(view, true, { start: true, say: say && ((message) => say(`Inserted ${completion.label}. ${message}`)) });
|
|
411
591
|
} else {
|
|
412
592
|
const insert = plan.template.replace(/\\([{}\\])/g, "$1");
|
|
413
593
|
view.dispatch({ changes: { from, to, insert }, selection: { anchor: from + insert.length }, annotations: [pickedCompletion.of(completion)], userEvent: "input.complete", scrollIntoView: true });
|
|
414
594
|
snippetRuns.delete(view);
|
|
415
595
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
596
|
+
finish(plan.chain);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* `{{na|` becomes `{{name}}`: the name replaces what was typed after the braces and the closing braces are written unless they are there. With
|
|
601
|
+
* several cursors it does so at each of them, in one undo step. A cursor with no `{{` before it replaces as many characters as the main cursor's typed
|
|
602
|
+
* name when they are the same text (VS Code applies the item at every cursor the same way), and adds the braces all the same.
|
|
603
|
+
*/
|
|
604
|
+
function applyVariable(view, completion, fallbackFrom, fallbackTo) {
|
|
605
|
+
const { state } = view;
|
|
606
|
+
const text = state.doc.toString();
|
|
607
|
+
const insert = `${completion.opf.name}}}`;
|
|
608
|
+
const annotations = [pickedCompletion.of(completion), isolateHistory.of("full")];
|
|
609
|
+
if (state.selection.ranges.length <= 1) {
|
|
610
|
+
const to = text.startsWith("}}", fallbackTo) ? fallbackTo + 2 : fallbackTo;
|
|
611
|
+
view.dispatch({ changes: { from: fallbackFrom, to, insert }, selection: { anchor: fallbackFrom + insert.length }, annotations, userEvent: "input.complete", scrollIntoView: true });
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const typed = text.slice(fallbackFrom, fallbackTo);
|
|
615
|
+
const sorted = [...state.selection.ranges].sort((a, b) => a.from - b.from);
|
|
616
|
+
const changes = [], carets = [];
|
|
617
|
+
let shift = 0;
|
|
618
|
+
for (const range of sorted) {
|
|
619
|
+
const head = range.head;
|
|
620
|
+
const open = VARIABLE_OPEN.exec(text.slice(Math.max(0, head - 200), head));
|
|
621
|
+
const length = open ? open[1].length : text.slice(head - typed.length, head) === typed ? typed.length : 0;
|
|
622
|
+
const from = head - length;
|
|
623
|
+
const to = text.startsWith("}}", head) ? head + 2 : head;
|
|
624
|
+
changes.push({ from, to, insert });
|
|
625
|
+
carets.push(from + shift + insert.length);
|
|
626
|
+
shift += insert.length - (to - from);
|
|
627
|
+
}
|
|
628
|
+
const main = sorted.indexOf(state.selection.main);
|
|
629
|
+
view.dispatch({ changes, selection: EditorSelection.create(carets.map((position) => EditorSelection.cursor(position)), Math.max(0, main)), annotations, userEvent: "input.complete", scrollIntoView: true });
|
|
420
630
|
}
|
|
421
631
|
|
|
422
632
|
// With several cursors the item goes in at each of them as plain text (no tab stops), each over its own token.
|