@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.
@@ -0,0 +1,173 @@
1
+ // Folding in the source editor (artifacts/source-editor/SPEC.md, sections 6.8 and 8.5): a fold gutter, and VS Code's commands over it. Fold and Unfold work
2
+ // on the innermost range around the caret; Fold All, Unfold All, Fold Level 1 to 7, Fold and Unfold Recursively and Toggle Fold are the chords of
3
+ // Cmd/Ctrl+K. The ranges are the language's own (JSON objects and lists, YAML by indent) and, for Markdown, the front matter, each fence and each slide
4
+ // (source-editing.js `markdownFoldIndex`, one pass over the text). Nothing here reads the whole text unless a command that needs every range runs.
5
+ import { EditorState } from "@codemirror/state";
6
+ import { foldEffect, foldGutter, foldable, foldedRanges, unfoldEffect } from "@codemirror/language";
7
+
8
+ const svg = (open) => {
9
+ const node = document.createElementNS("http://www.w3.org/2000/svg", "svg");
10
+ node.setAttribute("viewBox", "0 0 16 16");
11
+ node.setAttribute("width", "12");
12
+ node.setAttribute("height", "12");
13
+ node.setAttribute("aria-hidden", "true");
14
+ node.setAttribute("focusable", "false");
15
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
16
+ path.setAttribute("d", open ? "M4 6l4 4 4-4" : "M6 4l4 4-4 4");
17
+ path.setAttribute("fill", "none");
18
+ path.setAttribute("stroke", "currentColor");
19
+ path.setAttribute("stroke-width", "1.6");
20
+ path.setAttribute("stroke-linecap", "round");
21
+ path.setAttribute("stroke-linejoin", "round");
22
+ node.append(path);
23
+ return node;
24
+ };
25
+
26
+ /** The fold gutter: a chevron beside every line that starts a foldable range; clicking it folds or unfolds. Keys are in source-keymap.js. */
27
+ export const foldGutterExtension = [
28
+ foldGutter({
29
+ markerDOM(open) {
30
+ const marker = document.createElement("span");
31
+ marker.className = `opf-fold-marker ${open ? "is-open" : "is-folded"}`;
32
+ marker.append(svg(open));
33
+ return marker;
34
+ },
35
+ }),
36
+ EditorState.phrases.of({ "Folded lines": "Folded lines", "Unfolded lines": "Unfolded lines", to: "to", "folded code": "folded code", unfold: "unfold", "Fold line": "Fold", "Unfold line": "Unfold" }),
37
+ ];
38
+
39
+ // ---- ranges ----------------------------------------------------------------------------------------------------------------------------
40
+ /** The fold that starts on the line of `from` and covers [from, to), or null. */
41
+ const foldedAt = (state, from, to) => {
42
+ let found = null;
43
+ foldedRanges(state).between(from, to, (start, end) => { if (start >= from && start <= to) { found = { from: start, to: end }; return false; } return undefined; });
44
+ return found;
45
+ };
46
+ const isFolded = (state, range) => {
47
+ let found = false;
48
+ foldedRanges(state).between(range.from, range.to, (start, end) => { if (start === range.from && end === range.to) found = true; });
49
+ return found;
50
+ };
51
+
52
+ /** Every foldable range of the text, outermost first, with its nesting `level` (1 = not inside another): `{ from, to, line, level }`. */
53
+ export function allFoldRanges(state) {
54
+ const ranges = [];
55
+ for (let number = 1; number <= state.doc.lines; number++) {
56
+ const line = state.doc.line(number);
57
+ const range = foldable(state, line.from, line.to);
58
+ if (range && range.to > range.from) ranges.push({ from: range.from, to: range.to, line: number, level: 1 });
59
+ }
60
+ // A range inside another one is one level deeper. The ranges come in order of their first line, so a stack of the open ones finds the parent.
61
+ const open = [];
62
+ for (const range of ranges) {
63
+ while (open.length && open.at(-1).to < range.from) open.pop();
64
+ range.level = open.length + 1;
65
+ open.push(range);
66
+ }
67
+ return ranges;
68
+ }
69
+
70
+ /** The innermost foldable range around the caret line that is (`folded`) or is not folded yet (VS Code's Fold), or null. */
71
+ function rangeAround(state, position, { folded = false } = {}) {
72
+ const here = state.doc.lineAt(position).number;
73
+ for (let number = here; number >= 1; number--) {
74
+ const line = state.doc.line(number);
75
+ const range = foldable(state, line.from, line.to);
76
+ if (!range || range.to <= range.from) continue;
77
+ if (number !== here && state.doc.lineAt(range.to).number < here) continue;
78
+ if (isFolded(state, range) === folded) return { from: range.from, to: range.to, line: number };
79
+ }
80
+ return null;
81
+ }
82
+
83
+ const dispatchFolds = (view, effects) => { if (!effects.length) return false; view.dispatch({ effects, scrollIntoView: true }); return true; };
84
+
85
+ // ---- commands --------------------------------------------------------------------------------------------------------------------------
86
+ /** Fold: the innermost range around the caret. */
87
+ export function fold(view, say) {
88
+ const range = rangeAround(view.state, view.state.selection.main.head);
89
+ if (!range) { say?.("Nothing to fold here"); return true; }
90
+ view.dispatch({ effects: foldEffect.of({ from: range.from, to: range.to }), selection: { anchor: Math.min(view.state.selection.main.head, range.from) }, scrollIntoView: true });
91
+ say?.("Folded");
92
+ return true;
93
+ }
94
+
95
+ /** Unfold: the fold on the caret line, else the fold that hides the caret. */
96
+ export function unfold(view, say) {
97
+ const { state } = view;
98
+ const line = state.doc.lineAt(state.selection.main.head);
99
+ const found = foldedAt(state, line.from, line.to);
100
+ if (!found) { say?.("Nothing to unfold here"); return true; }
101
+ view.dispatch({ effects: unfoldEffect.of(found), scrollIntoView: true });
102
+ say?.("Unfolded");
103
+ return true;
104
+ }
105
+
106
+ /** Toggle Fold: unfold the fold on the caret line, else fold the range around the caret. */
107
+ export function toggleFoldHere(view, say) {
108
+ const { state } = view;
109
+ const line = state.doc.lineAt(state.selection.main.head);
110
+ return foldedAt(state, line.from, line.to) ? unfold(view, say) : fold(view, say);
111
+ }
112
+
113
+ /** Fold All: every range, nested ones too (unfolding an outer range leaves the inner ones folded, as in VS Code). */
114
+ export function foldEverything(view, say) {
115
+ const ranges = allFoldRanges(view.state).filter((range) => !isFolded(view.state, range));
116
+ const effects = ranges.map((range) => foldEffect.of({ from: range.from, to: range.to }));
117
+ if (!effects.length) { say?.("Nothing to fold"); return true; }
118
+ // The caret goes to the start of the line that now hides it, not into hidden text.
119
+ const caret = view.state.selection.main.head;
120
+ const hiding = ranges.filter((range) => caret > range.from && caret <= range.to).sort((a, b) => a.from - b.from)[0];
121
+ view.dispatch({ effects, ...(hiding ? { selection: { anchor: hiding.from } } : {}), scrollIntoView: true });
122
+ say?.(`Folded ${ranges.length} ${ranges.length === 1 ? "range" : "ranges"}`);
123
+ return true;
124
+ }
125
+
126
+ /** Unfold All. */
127
+ export function unfoldEverything(view, say) {
128
+ const effects = [];
129
+ foldedRanges(view.state).between(0, view.state.doc.length, (from, to) => { effects.push(unfoldEffect.of({ from, to })); });
130
+ if (!effects.length) { say?.("Nothing is folded"); return true; }
131
+ view.dispatch({ effects, scrollIntoView: true });
132
+ say?.("Unfolded all");
133
+ return true;
134
+ }
135
+
136
+ /** Fold Level N: every range at nesting level N, except the one around the caret (VS Code). */
137
+ export function foldLevel(level) {
138
+ return (view, say) => {
139
+ const caret = view.state.selection.main.head;
140
+ const ranges = allFoldRanges(view.state).filter((range) => range.level === level && !isFolded(view.state, range) && !(caret > range.from && caret <= range.to));
141
+ if (!ranges.length) { say?.(`Nothing to fold at level ${level}`); return true; }
142
+ dispatchFolds(view, ranges.map((range) => foldEffect.of({ from: range.from, to: range.to })));
143
+ say?.(`Folded level ${level}`);
144
+ return true;
145
+ };
146
+ }
147
+
148
+ /** Fold Recursively: the range around the caret and every range inside it. */
149
+ export function foldRecursively(view, say) {
150
+ const { state } = view;
151
+ const around = rangeAround(state, state.selection.main.head) ?? rangeAround(state, state.selection.main.head, { folded: true });
152
+ if (!around) { say?.("Nothing to fold here"); return true; }
153
+ const inside = allFoldRanges(state).filter((range) => range.from >= around.from && range.to <= around.to && !isFolded(state, range));
154
+ if (!inside.some((range) => range.from === around.from)) inside.push(around);
155
+ view.dispatch({ effects: inside.map((range) => foldEffect.of({ from: range.from, to: range.to })), selection: { anchor: Math.min(state.selection.main.head, around.from) }, scrollIntoView: true });
156
+ say?.("Folded recursively");
157
+ return true;
158
+ }
159
+
160
+ /** Unfold Recursively: the fold on the caret line and every fold inside it. */
161
+ export function unfoldRecursively(view, say) {
162
+ const { state } = view;
163
+ const line = state.doc.lineAt(state.selection.main.head);
164
+ const start = foldedAt(state, line.from, line.to);
165
+ const around = start ?? rangeAround(state, state.selection.main.head);
166
+ if (!around) { say?.("Nothing to unfold here"); return true; }
167
+ const effects = [];
168
+ foldedRanges(state).between(around.from, around.to, (from, to) => { effects.push(unfoldEffect.of({ from, to })); });
169
+ if (!effects.length) { say?.("Nothing to unfold here"); return true; }
170
+ view.dispatch({ effects, scrollIntoView: true });
171
+ say?.("Unfolded recursively");
172
+ return true;
173
+ }
@@ -0,0 +1,376 @@
1
+ // The shape hint (artifacts/source-editor/SPEC.md, section 8.2): a signature-like tooltip of the object around the caret, as parameter hints are for a
2
+ // call. Cmd/Ctrl+Shift+Space shows it (`showShapeHint`); typing `{` or `,` between the members of a slide that has a `layout` shows it by itself
3
+ // (`autoShapeHint`). Its rows are the fields to write, each `set` (the object has it), `next` (the first required one it has not) or `optional`:
4
+ // for a slide with a layout, the layout's regions (title, then the region's key, ...); for a block, the fields of its `type`; otherwise the
5
+ // required fields of the schema first, then the common ones. It follows the caret from member to member and closes when the caret leaves the object
6
+ // or on Escape; the arrow keys are not taken. It is a real CodeMirror tooltip (`showTooltip`), mirrored into the live region as one sentence.
7
+ // The reading of the text (`shapeAt`) is plain functions over the text, so it is tested without a browser. Loaded lazily by the code editor.
8
+ import { Prec, StateEffect, StateField } from "@codemirror/state";
9
+ import { EditorView, keymap, showTooltip } from "@codemirror/view";
10
+ import { findNodeAtLocation, getNodeValue, parseTree } from "jsonc-parser";
11
+ import { parseDocument } from "yaml";
12
+ import { describeField } from "@openpresentation/opf/schema";
13
+ import { fromMarkdown } from "@openpresentation/opf/markdown";
14
+ import { pathAt, scanMarkdown } from "@openpresentation/opf/source";
15
+ import { listCatalogRecords } from "./catalogs.js";
16
+ import { flatRegions, valueAt } from "./source-references.js";
17
+ import { SYNTAX_FIELDS, markdownRegionAt } from "./source-markdown.js";
18
+ import { yamlContextAt } from "./source-yaml.js";
19
+
20
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
21
+ const isIndex = (segment) => typeof segment === "number" || /^(?:0|[1-9]\d*)$/.test(String(segment));
22
+ const present = (value) => value !== undefined && value !== null && value !== "" && !(Array.isArray(value) && value.length === 0);
23
+ const MAX_ROWS = 7;
24
+
25
+ // ---- the words ---------------------------------------------------------------------------------------------------------------------
26
+ const KEY_WORDS = {
27
+ title: "Slide title", subtitle: "Subtitle", items: "List of points", text: "Body text", image: "Picture", video: "Video", chart: "Chart", table: "Table",
28
+ quote: "Quote", code: "Code", metric: "Key number", timeline: "Timeline", notes: "Speaker notes", layout: "Layout of the slide", id: "A name for this object", type: "Content kind",
29
+ region: "Region of the layout to place it in",
30
+ };
31
+ // The slide key that holds content of a kind a region accepts, and the other way round.
32
+ const KEY_OF_KIND = { text: "text", list: "items", image: "image", video: "video", chart: "chart", table: "table", quote: "quote", code: "code", metric: "metric", timeline: "timeline" };
33
+ const KIND_OF_KEY = Object.fromEntries(Object.entries(KEY_OF_KIND).map(([kind, key]) => [key, kind]));
34
+ // Which key a region that takes many kinds is filled by first (a slide's own list or text).
35
+ const KEY_PREFERENCE = ["items", "text", "image", "chart", "table", "quote", "code", "metric", "timeline", "video"];
36
+ const joinWords = (words) => (words.length > 1 ? `${words.slice(0, -1).join(", ")} or ${words.at(-1)}` : (words[0] ?? ""));
37
+
38
+ function clip(text, length = 58) {
39
+ const flat = String(text ?? "").replace(/\s+/g, " ").trim();
40
+ const first = /^.*?[.!?](?=\s|$)/.exec(flat)?.[0] ?? flat;
41
+ return first.length <= length ? first : `${first.slice(0, first.lastIndexOf(" ", length - 1) > 20 ? first.lastIndexOf(" ", length - 1) : length - 1)}…`;
42
+ }
43
+
44
+ // ---- reading the text ----------------------------------------------------------------------------------------------------------------
45
+ const parseYamlValue = (text) => {
46
+ try { return parseDocument(text, { strict: false, uniqueKeys: false }).toJS({ maxAliasCount: -1 }) ?? {}; } catch { return {}; }
47
+ };
48
+
49
+ /** Which slide of a Markdown text the position is in: the last one that starts before it, or the next when a `---` separator lies between. */
50
+ function markdownSlideAt(text, scan, position) {
51
+ let index = -1;
52
+ scan.slides.forEach((slide, number) => { if (slide.from <= position) index = number; });
53
+ const from = index >= 0 ? scan.slides[index].to : (scan.frontMatter?.to ?? 0);
54
+ if (/^-{3,}[ \t]*$/m.test(text.slice(from, Math.max(from, position)))) return index + 1;
55
+ return Math.max(0, index);
56
+ }
57
+
58
+ /**
59
+ * The draft around `position`: `{ path, firstIsObject, deck, read, where }` or null. `path` is where the caret is (the object when it sits between
60
+ * members), `deck` the plain value of the whole text as far as it reads, `where` "text" when the caret is in the structured text and "body" in the
61
+ * prose of a Markdown slide.
62
+ */
63
+ function readDraft(format, text, position) {
64
+ if (format === "json") {
65
+ const root = parseTree(text, [], { allowTrailingComma: true });
66
+ if (!root) return null;
67
+ const found = pathAt(text, "json", position);
68
+ if (!found || found.part === "none") return null;
69
+ const deck = getNodeValue(root);
70
+ return { path: found.path, firstIsObject: found.part === "between", deck, read: (path) => (path.length ? valueAt(deck, path) : deck), where: "text", node: (path) => findNodeAtLocation(root, path) };
71
+ }
72
+ if (format === "yaml") {
73
+ const context = yamlContextAt(text, position, { explicit: true });
74
+ const found = context ? null : pathAt(text, "yaml", position);
75
+ if (!context && (!found || found.part === "none")) return null;
76
+ const deck = parseYamlValue(text);
77
+ return { path: context ? context.path : found.path, firstIsObject: context ? context.mode === "key" : found.part === "between", deck, read: (path) => (path.length ? valueAt(deck, path) : deck), where: "text" };
78
+ }
79
+ if (format === "md") {
80
+ let deck;
81
+ try { deck = fromMarkdown(text, { validate: false }).presentation ?? {}; } catch { deck = {}; }
82
+ const where = markdownRegionAt(text, position);
83
+ if (where.region === "front-matter") {
84
+ const front = where.front;
85
+ const context = yamlContextAt(text.slice(front.yamlFrom, front.closed ? front.yamlTo : text.length), position - front.yamlFrom, { explicit: true, exclude: ["slides"] });
86
+ if (!context) return null;
87
+ return { path: context.path, firstIsObject: context.mode === "key", deck, read: (path) => (path.length ? valueAt(deck, path) : deck), where: "text" };
88
+ }
89
+ if (where.region === "fence" && where.part === "body" && (where.fence.kind === "opf-slide" || where.fence.kind === "opf-block")) {
90
+ const { fence } = where;
91
+ const prefix = fence.kind === "opf-slide" ? ["slides", fence.slide] : ["slides", fence.slide, "blocks", 0];
92
+ const context = yamlContextAt(text.slice(fence.bodyFrom, fence.bodyTo), position - fence.bodyFrom, { explicit: true, prefix });
93
+ if (!context) return null;
94
+ // An opf-block fence is read from its own YAML (its index among the blocks is not the 0 the context pictures).
95
+ const own = fence.kind === "opf-block" ? parseYamlValue(text.slice(fence.bodyFrom, fence.bodyTo)) : null;
96
+ const read = (path) => (own && path.length >= prefix.length && prefix.every((step, index) => String(path[index]) === String(step)) ? valueAt(own, path.slice(prefix.length)) : path.length ? valueAt(deck, path) : deck);
97
+ return { path: context.path, firstIsObject: context.mode === "key", deck, read, where: "text" };
98
+ }
99
+ if (where.region === "front-matter-edge" || where.region === "fence" && where.part === "close") return null;
100
+ const scan = where.scan ?? scanMarkdown(text);
101
+ const slide = markdownSlideAt(text, scan, position);
102
+ const path = ["slides", slide];
103
+ return { path, firstIsObject: true, deck, read: (at) => (at.length ? valueAt(deck, at) : deck), where: "body" };
104
+ }
105
+ return null;
106
+ }
107
+
108
+ /** The innermost object at or above `path` that has a shape the schema knows: `{ path, value, info }`, null when the first object is not one. */
109
+ function targetFor(draft, path, firstIsObject) {
110
+ for (let length = path.length; length >= 0; length--) {
111
+ const candidate = path.slice(0, length);
112
+ const value = draft.read(candidate);
113
+ const object = isObject(value) || (length === path.length && firstIsObject && (value === undefined || value === null));
114
+ if (!object) continue;
115
+ let info;
116
+ try { info = describeField(candidate, draft.deck); } catch { return null; }
117
+ // The first object we meet decides: an object the schema has no shape for (`extensions`) has no hint, it does not borrow its parent's.
118
+ if (!info.known || !info.children?.length) return null;
119
+ return { path: candidate, value: isObject(value) ? value : {}, info };
120
+ }
121
+ return null;
122
+ }
123
+
124
+ // ---- the rows ----------------------------------------------------------------------------------------------------------------------------
125
+ const row = (key, description, set, required, extra = {}) => ({ key, description, state: set ? "set" : "optional", required, ...extra });
126
+
127
+ function layoutRows(record, slide) {
128
+ const rows = [];
129
+ const used = new Set();
130
+ const blocks = Array.isArray(slide.blocks) ? slide.blocks.filter(isObject) : [];
131
+ for (const region of flatRegions(record)) {
132
+ const accepts = region.accepts;
133
+ let keys;
134
+ let description;
135
+ if (region.name in KEY_WORDS && !["id", "type", "region", "layout", "notes"].includes(region.name)) {
136
+ keys = [region.name];
137
+ description = KEY_WORDS[region.name];
138
+ } else if (!accepts.length) continue;
139
+ else {
140
+ const words = `The ${region.name} (${joinWords(accepts)})`;
141
+ if (accepts.length <= 2) keys = [...new Set(accepts.map((kind) => KEY_OF_KIND[kind]).filter(Boolean))];
142
+ else keys = [KEY_PREFERENCE.find((key) => accepts.includes(KIND_OF_KEY[key]) && !used.has(key))].filter(Boolean);
143
+ description = words;
144
+ if (!keys.length) keys = [];
145
+ }
146
+ keys.forEach((key) => used.add(key));
147
+ const set = keys.some((key) => present(slide[key])) || blocks.some((block) => block.region === region.name);
148
+ const required = region.name === "title" || region.role === "primary" || (Number.isFinite(region.min) && region.min >= 1);
149
+ rows.push(row(keys.length ? keys.join(" / ") : region.name, description, set, required, { keys, region: region.name }));
150
+ }
151
+ rows.push(row("notes", KEY_WORDS.notes, present(slide.notes), false, { keys: ["notes"] }));
152
+ return rows;
153
+ }
154
+
155
+ function genericRows(target) {
156
+ const { info, value } = target;
157
+ const required = info.requiredChildren?.length ? info.requiredChildren : info.children.filter((child) => child.required).map((child) => child.key);
158
+ const byKey = new Map(info.children.map((child) => [child.key, child]));
159
+ const keys = [...required.filter((key) => byKey.has(key)), ...info.children.filter((child) => !required.includes(child.key) && !child.advanced && !["extensions", "$schema"].includes(child.key)).map((child) => child.key)].slice(0, MAX_ROWS);
160
+ return keys.map((key) => row(key, KEY_WORDS[key] && target.path[0] === "slides" && target.path.length === 2 ? KEY_WORDS[key] : clip(byKey.get(key)?.description) || KEY_WORDS[key] || "", present(value[key]), required.includes(key), { keys: [key] }));
161
+ }
162
+
163
+ function blockRows(target) {
164
+ const { value } = target;
165
+ const keys = Object.keys(KIND_OF_KEY).filter((key) => present(value[key]));
166
+ const kind = typeof value.type === "string" ? value.type : keys.length ? KIND_OF_KEY[keys[0]] : undefined;
167
+ const payload = kind ? (kind === "group" ? "blocks" : KEY_OF_KIND[kind]) : undefined;
168
+ const rows = [row("type", `Content kind: ${joinWords(Object.keys(KEY_OF_KIND))}`, present(value.type) || (!kind ? false : keys.length > 0), !payload || !present(value[payload]) ? !kind : false, { keys: ["type"] })];
169
+ if (payload) rows.push(row(payload, payload === "blocks" ? "The blocks of the group" : (KEY_WORDS[payload] ?? payload), present(value[payload]), true, { keys: [payload] }));
170
+ rows.push(row("id", KEY_WORDS.id, present(value.id), false, { keys: ["id"] }), row("region", KEY_WORDS.region, present(value.region), false, { keys: ["region"] }));
171
+ return { rows, kind };
172
+ }
173
+
174
+ const SINGULAR = { references: "reference", speakers: "speaker", organizations: "organization", slides: "slide", blocks: "block", datasets: "dataset", assets: "asset" };
175
+ const nounOf = (path) => {
176
+ const names = path.filter((segment) => !isIndex(segment));
177
+ const last = String(names.at(-1) ?? "deck");
178
+ return path.length && isIndex(path.at(-1)) ? (SINGULAR[last] ?? last.replace(/s$/, "")) : last;
179
+ };
180
+
181
+ /**
182
+ * The shape of `target`: `{ kind, path, title, aside, rows, sentence, layout }` with `rows` `{ key, description, state }`. `catalogs` are the host's.
183
+ */
184
+ function buildModel(draft, target, catalogs, format) {
185
+ const { path, value } = target;
186
+ const slideIndex = path.length === 2 && path[0] === "slides" && isIndex(path[1]) ? Number(path[1]) : -1;
187
+ const blockIndex = path.length >= 2 && path.at(-2) === "blocks" && isIndex(path.at(-1)) ? Number(path.at(-1)) : -1;
188
+ let kind = "object", title, aside = "", rows, layout, unknownLayout = false, blockKind;
189
+ if (slideIndex >= 0) {
190
+ kind = "slide";
191
+ layout = typeof value.layout === "string" && value.layout ? value.layout : undefined;
192
+ let record;
193
+ if (layout) {
194
+ try { record = listCatalogRecords(draft.deck, "layouts", { catalogs }).find((entry) => entry.id === layout)?.record; } catch { record = undefined; }
195
+ unknownLayout = !record;
196
+ }
197
+ rows = record ? layoutRows(record, value) : genericRows(target);
198
+ title = layout ? `slide · layout "${layout}"${unknownLayout ? " (not found)" : ""}` : "slide";
199
+ const count = Array.isArray(draft.deck?.slides) ? draft.deck.slides.length : 0;
200
+ aside = count ? `slide ${slideIndex + 1} of ${Math.max(count, slideIndex + 1)}` : "";
201
+ } else if (blockIndex >= 0) {
202
+ kind = "block";
203
+ const made = blockRows(target);
204
+ ({ rows } = made);
205
+ blockKind = made.kind;
206
+ title = blockKind ? `block · type "${blockKind}"` : "block";
207
+ const siblings = draft.read(path.slice(0, -1));
208
+ aside = Array.isArray(siblings) && siblings.length ? `block ${blockIndex + 1} of ${siblings.length}` : "";
209
+ } else {
210
+ rows = genericRows(target);
211
+ title = nounOf(path);
212
+ const required = rows.filter((entry) => entry.required).length;
213
+ aside = required ? `${required} required` : "";
214
+ }
215
+ // `next` is the first required row that is not set yet.
216
+ const next = rows.findIndex((entry) => entry.required && entry.state !== "set");
217
+ if (next >= 0) rows[next].state = "next";
218
+ const written = format === "md" && kind === "slide" ? SYNTAX_FIELDS : null;
219
+ const shown = rows.slice(0, MAX_ROWS + 1).map((entry) => {
220
+ const hint = written && entry.keys?.length === 1 && entry.description === KEY_WORDS[entry.keys[0]] ? written[entry.keys[0]]?.written : undefined;
221
+ return { key: entry.key, description: hint ? `${entry.description} · ${hint}` : entry.description, state: entry.state };
222
+ });
223
+ const noun = title.split(" ")[0];
224
+ const head = `${noun[0].toUpperCase()}${noun.slice(1)}${layout ? ` with layout ${layout}` : blockKind ? ` of type ${blockKind}` : ""}`;
225
+ const sentence = `${head}: ${shown.map((entry) => `${entry.key} ${entry.state}`).join(", ")}`;
226
+ return { kind, path, title, aside, rows: shown, sentence, layout, next: next >= 0 ? rows[next].key : undefined };
227
+ }
228
+
229
+ /**
230
+ * The shape hint at `position` of a text of `format` ("json", "yaml" or "md"), or null where the schema has no shape (`extensions`, free text, no
231
+ * object around the caret). `atMember` is true when the caret sits between the members of the object (where a key goes), not inside a value or list.
232
+ */
233
+ export function shapeAt(format, text, position, { catalogs } = {}) {
234
+ const draft = readDraft(format, text, position);
235
+ if (!draft) return null;
236
+ const target = targetFor(draft, draft.path, draft.firstIsObject);
237
+ if (!target) return null;
238
+ const model = buildModel(draft, target, catalogs, format);
239
+ const atMember = draft.where === "text" && draft.path.length === target.path.length;
240
+ return { ...model, rawPath: draft.path, atMember, where: draft.where };
241
+ }
242
+
243
+ /** The shape of the object at `anchor`, while the caret (at `position`) is still inside it; null once it left. `undefined` when the text does not say. */
244
+ function shapeWithin(format, text, position, anchor, { catalogs }) {
245
+ const draft = readDraft(format, text, position);
246
+ if (!draft) return undefined;
247
+ const inside = draft.path.length >= anchor.length && anchor.every((step, index) => String(draft.path[index]) === String(step));
248
+ if (!inside) return null;
249
+ const value = draft.read(anchor);
250
+ if (!isObject(value) && value !== undefined && value !== null) return null;
251
+ let info;
252
+ try { info = describeField(anchor, draft.deck); } catch { return null; }
253
+ if (!info.known || !info.children?.length) return null;
254
+ return { ...buildModel(draft, { path: anchor, value: isObject(value) ? value : {}, info }, catalogs, format), rawPath: draft.path };
255
+ }
256
+
257
+ // ---- the tooltip ---------------------------------------------------------------------------------------------------------------------------
258
+ const setHint = StateEffect.define();
259
+
260
+ const same = (a, b) => a && b && a.title === b.title && a.aside === b.aside && a.sentence === b.sentence && a.rows.length === b.rows.length
261
+ && a.rows.every((entry, index) => entry.key === b.rows[index].key && entry.state === b.rows[index].state && entry.description === b.rows[index].description);
262
+
263
+ function el(tag, className, text) {
264
+ const node = document.createElement(tag);
265
+ node.className = className;
266
+ if (text !== undefined) node.textContent = text;
267
+ return node;
268
+ }
269
+
270
+ /** The DOM of a model. */
271
+ function render(dom, model) {
272
+ dom.replaceChildren();
273
+ dom.setAttribute("aria-label", `Shape hint: ${model.title}`);
274
+ const head = el("div", "opf-hint-head");
275
+ head.append(el("span", "opf-hint-title", model.title));
276
+ if (model.aside) head.append(el("span", "opf-hint-aside", model.aside));
277
+ const list = el("ul", "opf-hint-rows");
278
+ list.setAttribute("role", "list");
279
+ for (const entry of model.rows) {
280
+ const item = el("li", "opf-hint-row");
281
+ item.dataset.state = entry.state;
282
+ item.dataset.key = entry.key;
283
+ item.append(el("span", "opf-hint-key", entry.key), el("span", "opf-hint-description", entry.description), el("span", "opf-hint-state", entry.state));
284
+ list.append(item);
285
+ }
286
+ dom.append(head, list);
287
+ }
288
+
289
+ // CodeMirror keeps a tooltip's DOM while its `create` function is the same one and calls `update` on it, so the hint moves with the caret and is
290
+ // redrawn only when what it says changes.
291
+ function createHintView(view) {
292
+ const dom = el("div", "opf-hint");
293
+ dom.setAttribute("role", "tooltip");
294
+ let drawn = null;
295
+ const draw = (state) => {
296
+ const value = state.field(hintField, false);
297
+ if (value && value.model !== drawn) { drawn = value.model; render(dom, value.model); }
298
+ };
299
+ draw(view.state);
300
+ return { dom, update: (update) => draw(update.state) };
301
+ }
302
+
303
+ /** The field: `{ model, anchor, config }` while a hint is open (`anchor` the path of its object, `config` `{ format, catalogs }`), null otherwise. */
304
+ const hintField = StateField.define({
305
+ create: () => null,
306
+ update(value, tr) {
307
+ for (const effect of tr.effects) if (effect.is(setHint)) return effect.value;
308
+ if (!value || (!tr.docChanged && !tr.selection)) return value;
309
+ const { state } = tr;
310
+ const found = shapeWithin(value.config.format, state.doc.toString(), state.selection.main.head, value.anchor, value.config);
311
+ // The text does not say where the caret is (a half-typed line): the hint stays as it was.
312
+ if (found === undefined) return value;
313
+ if (found === null) return null;
314
+ return same(found, value.model) ? value : { ...value, model: found };
315
+ },
316
+ // Anchored to the start of the caret's line: it follows the caret from line to line and does not jitter along a line.
317
+ provide: (field) => showTooltip.compute([field, "selection"], (state) => (state.field(field) ? { pos: state.doc.lineAt(state.selection.main.head).from, above: true, strictSide: false, arrow: false, create: createHintView } : null)),
318
+ });
319
+
320
+ const keys = Prec.highest(keymap.of([{
321
+ key: "Escape",
322
+ run: (view) => {
323
+ if (!view.state.field(hintField, false)) return false;
324
+ view.dispatch({ effects: setHint.of(null) });
325
+ return true;
326
+ },
327
+ }]));
328
+
329
+ // Leaving the editor closes the hint, as it does in VS Code.
330
+ const closeOnBlur = EditorView.domEventHandlers({
331
+ blur(_event, view) {
332
+ if (view.state.field(hintField, false)) view.dispatch({ effects: setHint.of(null) });
333
+ },
334
+ });
335
+
336
+ /** Adds the hint's field, its Escape key and the close on blur to the editor the first time. */
337
+ function install(view) {
338
+ if (view.state.field(hintField, false) === undefined) view.dispatch({ effects: StateEffect.appendConfig.of([hintField, keys, closeOnBlur]) });
339
+ }
340
+
341
+ /** Open (or move) the hint on `model`; says it in the live region when it is new or changed. */
342
+ function open(view, context, model, anchor) {
343
+ const before = view.state.field(hintField, false);
344
+ view.dispatch({ effects: setHint.of({ model, anchor, config: { format: context.format, catalogs: context.catalogs } }) });
345
+ if (!before || before.model.sentence !== model.sentence) context.say(model.sentence);
346
+ }
347
+
348
+ /** Cmd/Ctrl+Shift+Space: show the shape of the object around the caret, or say there is none. */
349
+ export function showShapeHint(view, context) {
350
+ install(view);
351
+ const found = shapeAt(context.format, view.state.doc.toString(), view.state.selection.main.head, { catalogs: context.catalogs });
352
+ if (!found) {
353
+ if (view.state.field(hintField, false)) view.dispatch({ effects: setHint.of(null) });
354
+ context.say("No shape hint here");
355
+ return true;
356
+ }
357
+ open(view, context, found, found.path);
358
+ return true;
359
+ }
360
+
361
+ /**
362
+ * After a `{` or a `,` was typed: shows the hint when the caret sits between the members of a slide that has a layout (an open hint keeps itself up
363
+ * to date). Returns whether it opened one.
364
+ */
365
+ export function autoShapeHint(view, context, typed) {
366
+ if (typed !== "{" && typed !== ",") return false;
367
+ const { state } = view;
368
+ const head = state.selection.main.head;
369
+ if (state.selection.ranges.length !== 1 || !state.selection.main.empty || state.sliceDoc(head - 1, head) !== typed) return false;
370
+ if (state.field(hintField, false)) return false;
371
+ const found = shapeAt(context.format, state.doc.toString(), head, { catalogs: context.catalogs });
372
+ if (!found || found.kind !== "slide" || !found.layout || !found.atMember) return false;
373
+ install(view);
374
+ open(view, context, found, found.path);
375
+ return true;
376
+ }