@memorysmithapp/markdown-renderer 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +50 -4
  2. package/dist/Markdown.d.ts +6 -2
  3. package/dist/Markdown.js +126 -17
  4. package/dist/NoteActions.d.ts +9 -0
  5. package/dist/NoteActions.js +68 -0
  6. package/dist/NoteBody.d.ts +32 -2
  7. package/dist/NoteBody.js +74 -7
  8. package/dist/edit/AttachDialog.d.ts +23 -0
  9. package/dist/edit/AttachDialog.js +126 -0
  10. package/dist/edit/ContextMenu.d.ts +29 -0
  11. package/dist/edit/ContextMenu.js +95 -0
  12. package/dist/edit/LanguageControl.d.ts +19 -0
  13. package/dist/edit/LanguageControl.js +124 -0
  14. package/dist/edit/NoteEditor.d.ts +44 -0
  15. package/dist/edit/NoteEditor.js +520 -0
  16. package/dist/edit/SaveNoticeDialog.d.ts +15 -0
  17. package/dist/edit/SaveNoticeDialog.js +41 -0
  18. package/dist/edit/analyze.d.ts +150 -0
  19. package/dist/edit/analyze.js +268 -0
  20. package/dist/edit/blocks.d.ts +97 -0
  21. package/dist/edit/blocks.js +481 -0
  22. package/dist/edit/commands.d.ts +78 -0
  23. package/dist/edit/commands.js +257 -0
  24. package/dist/edit/frontmatter.d.ts +45 -0
  25. package/dist/edit/frontmatter.js +142 -0
  26. package/dist/edit/index.d.ts +5 -0
  27. package/dist/edit/index.js +7 -0
  28. package/dist/edit/live.d.ts +28 -0
  29. package/dist/edit/live.js +675 -0
  30. package/dist/edit/saves.d.ts +48 -0
  31. package/dist/edit/saves.js +73 -0
  32. package/dist/edit/table.d.ts +58 -0
  33. package/dist/edit/table.js +185 -0
  34. package/dist/file-types.d.ts +30 -0
  35. package/dist/file-types.js +70 -0
  36. package/dist/highlight.d.ts +21 -7
  37. package/dist/highlight.js +56 -7
  38. package/dist/host.d.ts +82 -3
  39. package/dist/host.js +16 -0
  40. package/dist/index.d.ts +6 -4
  41. package/dist/index.js +1 -0
  42. package/dist/mode-icons.d.ts +6 -0
  43. package/dist/mode-icons.js +11 -0
  44. package/dist/prism-grammars.d.ts +18 -0
  45. package/dist/prism-grammars.js +305 -0
  46. package/dist/remark-callouts.d.ts +6 -0
  47. package/dist/remark-callouts.js +2 -2
  48. package/dist/remark-ring.d.ts +5 -0
  49. package/dist/remark-ring.js +25 -15
  50. package/dist/slots.js +17 -1
  51. package/dist/strings.d.ts +129 -1
  52. package/dist/strings.js +127 -1
  53. package/dist/task-saves.d.ts +35 -0
  54. package/dist/task-saves.js +86 -0
  55. package/dist/theme.d.ts +47 -0
  56. package/dist/theme.js +2 -2
  57. package/package.json +14 -2
  58. package/structure.css +355 -0
  59. package/theme-default.css +1099 -0
@@ -0,0 +1,675 @@
1
+ /**
2
+ * The page the editor draws over the note's text (ADR-006).
3
+ *
4
+ * The text is the model, and nothing here changes it: each construct the analysis finds is drawn
5
+ * by decorations — its notation hidden and its content styled while the cursor is elsewhere, its
6
+ * notation shown, dimmed, while the cursor is in it; a block drawn by the reading surface's own
7
+ * components in place of its lines until the cursor enters it, and then its lines as source.
8
+ */
9
+ import { EditorSelection, Facet, Prec, StateEffect, StateField } from '@codemirror/state';
10
+ import { Decoration, EditorView, keymap, ViewPlugin, WidgetType } from '@codemirror/view';
11
+ import katex from 'katex';
12
+ import { createElement } from 'react';
13
+ import { createRoot } from 'react-dom/client';
14
+ import { followable } from '../address.js';
15
+ import { highlightTree, languageToLoad, loadGrammar } from '../highlight.js';
16
+ import { HostProvider, resolveHost } from '../host.js';
17
+ import { highlightLanguage } from '../highlight.js';
18
+ import { analyze } from './analyze.js';
19
+ import { AddProperties, CalloutBlock, PropertiesBlock, reveal, Snippet, TableBlock } from './blocks.js';
20
+ import { LanguageControl } from './LanguageControl.js';
21
+ import { toggleTaskBox } from './commands.js';
22
+ export const editorEnvironment = Facet.define({
23
+ combine: (values) => values[0] ?? { host: resolveHost({}) },
24
+ });
25
+ /** The note's text with `\n` between lines, whose offsets are the editor's positions. */
26
+ export function textOf(state) {
27
+ return state.doc.sliceString(0, state.doc.length, '\n');
28
+ }
29
+ /** The analysis of the note, kept with the state and made again when the text changes. */
30
+ export const analysisField = StateField.define({
31
+ create: (state) => analyze(textOf(state)),
32
+ update: (analysis, transaction) => (transaction.docChanged ? analyze(textOf(transaction.state)) : analysis),
33
+ });
34
+ /** Says the editor took or lost the focus. */
35
+ export const setFocused = StateEffect.define();
36
+ /** A grammar a code block names has arrived: its tokens can be coloured now (#84). */
37
+ export const grammarArrived = StateEffect.define();
38
+ /** Whether the editor has the focus: a construct is shown as source only to an author who is in it. */
39
+ const focusField = StateField.define({
40
+ create: () => false,
41
+ update: (focused, transaction) => {
42
+ for (const effect of transaction.effects)
43
+ if (effect.is(setFocused))
44
+ return effect.value;
45
+ return focused;
46
+ },
47
+ });
48
+ // ── Widgets ──────────────────────────────────────────────────────────────────
49
+ /** The React roots the widgets mounted, unmounted when CodeMirror lets the widget go. */
50
+ const roots = new WeakMap();
51
+ function mount(view, element, node) {
52
+ const { host } = view.state.facet(editorEnvironment);
53
+ const root = createRoot(element);
54
+ // Drawn on React's own schedule: CodeMirror builds its widgets inside its update, which may run
55
+ // inside a React commit, where React cannot be asked to render synchronously.
56
+ root.render(createElement(HostProvider, { value: host }, node));
57
+ // A block whose content arrives later — an embed, a diagram — changes its height after it is
58
+ // measured; the editor is told so, or the lines below it would be placed by the old height.
59
+ const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(() => view.requestMeasure());
60
+ observer?.observe(element);
61
+ roots.set(element, { unmount: () => root.unmount(), observer });
62
+ }
63
+ function unmount(element) {
64
+ const mounted = roots.get(element);
65
+ if (!mounted)
66
+ return;
67
+ roots.delete(element);
68
+ mounted.observer?.disconnect();
69
+ // Not during CodeMirror's update, which may be inside React's own commit.
70
+ queueMicrotask(() => mounted.unmount());
71
+ }
72
+ /** A block drawn with React in place of lines of the note. */
73
+ class BlockWidget extends WidgetType {
74
+ key;
75
+ host;
76
+ draw;
77
+ revealsOnClick;
78
+ inline;
79
+ constructor(key, host, draw,
80
+ /** Whether a click on it shows its source; a block with controls of its own handles its clicks. */
81
+ revealsOnClick, inline = false) {
82
+ super();
83
+ this.key = key;
84
+ this.host = host;
85
+ this.draw = draw;
86
+ this.revealsOnClick = revealsOnClick;
87
+ this.inline = inline;
88
+ }
89
+ eq(other) {
90
+ return other.key === this.key && other.host === this.host;
91
+ }
92
+ toDOM(view) {
93
+ const element = document.createElement(this.inline ? 'span' : 'div');
94
+ element.className = this.inline ? 'cm-ms-inline-block' : 'cm-ms-block';
95
+ mount(view, element, this.draw(view));
96
+ element.addEventListener('mousedown', (event) => {
97
+ const target = event.target;
98
+ if (target.closest('[data-ms-control]'))
99
+ return;
100
+ if (event.metaKey || event.ctrlKey)
101
+ return;
102
+ if (!this.revealsOnClick)
103
+ return;
104
+ event.preventDefault();
105
+ // At the end of its first line: after a fence's language, a callout's title, a formula's `$$`.
106
+ reveal(view, view.state.doc.lineAt(blockStart(view, element, this.key)).to);
107
+ });
108
+ // A link inside a drawn block is followed with a modifier only, as in the text.
109
+ element.addEventListener('click', (event) => {
110
+ const anchor = event.target.closest('a');
111
+ if (anchor && !(event.metaKey || event.ctrlKey))
112
+ event.preventDefault();
113
+ });
114
+ return element;
115
+ }
116
+ destroy(element) {
117
+ unmount(element);
118
+ }
119
+ ignoreEvent() {
120
+ return true;
121
+ }
122
+ get estimatedHeight() {
123
+ return this.inline ? -1 : 48;
124
+ }
125
+ }
126
+ /**
127
+ * Where the lines a drawn block stands for start. `posAtDOM` answers the end of the line before a
128
+ * block widget, so the start is read from the decoration that drew it, the one nearest that answer.
129
+ */
130
+ function blockStart(view, element, key) {
131
+ const near = view.posAtDOM(element);
132
+ let found = -1;
133
+ view.state.field(decorationsField).between(Math.max(0, near - 1), Math.min(view.state.doc.length, near + 1), (from, _to, decoration) => {
134
+ const widget = decoration.spec.widget;
135
+ if (widget instanceof BlockWidget && widget.key === key) {
136
+ found = from;
137
+ return false;
138
+ }
139
+ return undefined;
140
+ });
141
+ return found === -1 ? near : found;
142
+ }
143
+ class BulletWidget extends WidgetType {
144
+ eq() {
145
+ return true;
146
+ }
147
+ toDOM() {
148
+ const element = document.createElement('span');
149
+ element.className = 'cm-ms-bullet';
150
+ element.textContent = '•';
151
+ element.setAttribute('aria-hidden', 'true');
152
+ return element;
153
+ }
154
+ }
155
+ class TaskWidget extends WidgetType {
156
+ checked;
157
+ constructor(checked) {
158
+ super();
159
+ this.checked = checked;
160
+ }
161
+ eq(other) {
162
+ return other.checked === this.checked;
163
+ }
164
+ toDOM(view) {
165
+ const box = document.createElement('input');
166
+ box.type = 'checkbox';
167
+ box.className = 'cm-ms-task';
168
+ box.checked = this.checked;
169
+ box.disabled = view.state.readOnly;
170
+ box.addEventListener('mousedown', (event) => {
171
+ event.preventDefault();
172
+ const at = view.posAtDOM(box);
173
+ const transaction = toggleTaskBox(view.state, at);
174
+ if (transaction)
175
+ view.dispatch({ ...transaction, userEvent: 'input.toggle' });
176
+ });
177
+ return box;
178
+ }
179
+ ignoreEvent() {
180
+ return false;
181
+ }
182
+ }
183
+ class RuleWidget extends WidgetType {
184
+ eq() {
185
+ return true;
186
+ }
187
+ toDOM() {
188
+ const wrap = document.createElement('div');
189
+ wrap.className = 'cm-ms-rule';
190
+ wrap.appendChild(document.createElement('hr'));
191
+ return wrap;
192
+ }
193
+ }
194
+ class MathWidget extends WidgetType {
195
+ tex;
196
+ display;
197
+ constructor(tex, display) {
198
+ super();
199
+ this.tex = tex;
200
+ this.display = display;
201
+ }
202
+ eq(other) {
203
+ return other.tex === this.tex && other.display === this.display;
204
+ }
205
+ toDOM() {
206
+ const element = document.createElement(this.display ? 'div' : 'span');
207
+ element.className = this.display ? 'cm-ms-math-block' : 'cm-ms-math';
208
+ // A formula KaTeX cannot typeset shows its source, as on the page (§7.8).
209
+ katex.render(this.tex, element, { throwOnError: false, displayMode: this.display, strict: 'ignore' });
210
+ return element;
211
+ }
212
+ ignoreEvent() {
213
+ return false;
214
+ }
215
+ }
216
+ class ImageWidget extends WidgetType {
217
+ source;
218
+ constructor(source) {
219
+ super();
220
+ this.source = source;
221
+ }
222
+ eq(other) {
223
+ return other.source === this.source;
224
+ }
225
+ toDOM() {
226
+ const match = /^!\[([^\]]*)\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)$/.exec(this.source);
227
+ const address = match ? followable(match[2] ?? '') : '';
228
+ if (!match || !address) {
229
+ const text = document.createElement('span');
230
+ text.className = 'cm-ms-raw';
231
+ text.textContent = this.source;
232
+ return text;
233
+ }
234
+ const image = document.createElement('img');
235
+ image.className = 'cm-ms-image';
236
+ image.src = address;
237
+ image.alt = (match[1] ?? '').split('|')[0] ?? '';
238
+ return image;
239
+ }
240
+ ignoreEvent() {
241
+ return false;
242
+ }
243
+ }
244
+ // ── Decorations ──────────────────────────────────────────────────────────────
245
+ const hide = Decoration.replace({});
246
+ const syntax = Decoration.mark({ class: 'cm-ms-syntax' });
247
+ const line = (className) => Decoration.line({ class: className });
248
+ const mark = (className, attributes) => Decoration.mark({ class: className, attributes });
249
+ /** The kinds drawn as blocks in place of their lines. */
250
+ const BLOCK_KINDS = new Set(['frontmatter', 'callout', 'table', 'mathBlock', 'embed', 'rule', 'code']);
251
+ function build(state) {
252
+ const { constructs, bodyFrom } = state.field(analysisField);
253
+ const { host } = state.facet(editorEnvironment);
254
+ const focused = state.field(focusField);
255
+ const doc = state.doc;
256
+ const text = textOf(state);
257
+ const ranges = state.selection.ranges;
258
+ const out = [];
259
+ const covered = [];
260
+ /** Whether the author's cursor or selection is on this range, ends included. */
261
+ const touches = (from, to) => focused && ranges.some((range) => range.from <= to && range.to >= from);
262
+ /** The whole lines a construct takes, its last line break left out. */
263
+ const linesOf = (from, to) => {
264
+ const end = to > from && text[to - 1] === '\n' ? to - 1 : to;
265
+ return { from: doc.lineAt(from).from, to: doc.lineAt(end).to };
266
+ };
267
+ const alone = (from, to) => {
268
+ const lines = linesOf(from, to);
269
+ return /^[ \t]*$/.test(text.slice(lines.from, from)) && /^[ \t]*$/.test(text.slice(to, lines.to));
270
+ };
271
+ const inCovered = (from, to) => covered.some((block) => from >= block.from && to <= block.to);
272
+ const eachLine = (from, to, className) => {
273
+ const lines = linesOf(from, to);
274
+ for (let at = doc.lineAt(lines.from).number; at <= doc.lineAt(lines.to).number; at++)
275
+ out.push(line(className).range(doc.line(at).from));
276
+ };
277
+ const block = (from, to, key, draw, revealsOnClick = true) => {
278
+ const lines = linesOf(from, to);
279
+ out.push(Decoration.replace({ widget: new BlockWidget(key, host, draw, revealsOnClick), block: true }).range(lines.from, lines.to));
280
+ covered.push(lines);
281
+ };
282
+ const after = (to, key, draw) => {
283
+ const lines = linesOf(to, to);
284
+ out.push(Decoration.widget({ widget: new BlockWidget(key, host, draw, false), block: true, side: 1 }).range(lines.to));
285
+ };
286
+ if (bodyFrom === 0) {
287
+ out.push(Decoration.widget({ widget: new BlockWidget('add-properties', host, (view) => createElement(AddProperties, { view }), false), block: true, side: -1 }).range(0));
288
+ }
289
+ // Blocks first: what a drawn block covers is not decorated again.
290
+ for (const construct of constructs) {
291
+ if (!BLOCK_KINDS.has(construct.kind) || inCovered(construct.from, construct.to))
292
+ continue;
293
+ const { from, to } = construct;
294
+ const lines = linesOf(from, to);
295
+ const open = touches(lines.from, lines.to);
296
+ const source = text.slice(lines.from, lines.to);
297
+ switch (construct.kind) {
298
+ case 'frontmatter':
299
+ if (open)
300
+ eachLine(from, to, 'cm-ms-yaml');
301
+ else
302
+ block(from, to, `front:${source}`, (view) => createElement(PropertiesBlock, { view, to }), false);
303
+ break;
304
+ case 'callout':
305
+ if (!alone(from, to))
306
+ break;
307
+ if (open)
308
+ eachLine(from, to, `cm-ms-callout-source`);
309
+ else {
310
+ const marker = { ...construct.marker, type: construct.type, fold: construct.fold };
311
+ block(from, to, `callout:${from}:${source}`, (view) => createElement(CalloutBlock, { view, text: source, marker }));
312
+ }
313
+ break;
314
+ case 'table':
315
+ if (!alone(from, to))
316
+ break;
317
+ if (open)
318
+ eachLine(from, to, 'cm-ms-table-source');
319
+ else
320
+ block(from, to, `table:${from}:${source}`, (view) => createElement(TableBlock, { view, from, to }), false);
321
+ break;
322
+ case 'mathBlock':
323
+ if (!alone(from, to))
324
+ break;
325
+ if (open) {
326
+ eachLine(from, to, 'cm-ms-code-line');
327
+ after(to, `math-preview:${construct.tex}`, () => createElement(Snippet, { text: `$$\n${construct.tex}\n$$` }));
328
+ }
329
+ else {
330
+ out.push(Decoration.replace({ widget: new MathWidget(construct.tex, true), block: true }).range(lines.from, lines.to));
331
+ covered.push(lines);
332
+ }
333
+ break;
334
+ case 'embed':
335
+ if (open)
336
+ out.push(mark('cm-ms-wikilink').range(from, to));
337
+ else
338
+ block(from, to, `embed:${source}`, () => createElement(Snippet, { text: source }));
339
+ break;
340
+ case 'rule':
341
+ if (!alone(from, to))
342
+ break;
343
+ if (open)
344
+ out.push(syntax.range(from, to));
345
+ else {
346
+ out.push(Decoration.replace({ widget: new RuleWidget(), block: true }).range(lines.from, lines.to));
347
+ covered.push(lines);
348
+ }
349
+ break;
350
+ case 'code': {
351
+ const diagram = construct.language === 'mermaid' && construct.open !== null;
352
+ if (diagram && !open && alone(from, to)) {
353
+ block(from, to, `diagram:${source}`, () => createElement(Snippet, { text: source }));
354
+ break;
355
+ }
356
+ codeLines(construct, out, doc, text);
357
+ // The language, chosen at the block's top right from every grammar Prism carries (#84).
358
+ if (construct.open) {
359
+ const fence = construct.open.from;
360
+ const language = construct.language;
361
+ out.push(Decoration.widget({
362
+ widget: new BlockWidget(`language:${fence}:${language ?? ''}`, host, (view) => createElement(LanguageControl, { view, from: fence, language }), false, true),
363
+ side: 1,
364
+ }).range(construct.open.to));
365
+ }
366
+ if (diagram)
367
+ after(to, `diagram-preview:${construct.code}`, () => createElement(Snippet, { text: '```mermaid\n' + construct.code + '\n```' }));
368
+ covered.push(lines);
369
+ break;
370
+ }
371
+ }
372
+ }
373
+ for (const construct of constructs) {
374
+ if (BLOCK_KINDS.has(construct.kind) || inCovered(construct.from, construct.to))
375
+ continue;
376
+ const { from, to } = construct;
377
+ switch (construct.kind) {
378
+ case 'heading': {
379
+ out.push(line(`cm-ms-heading cm-ms-h${construct.level}`).range(doc.lineAt(from).from));
380
+ const { marker } = construct;
381
+ if (touches(doc.lineAt(from).from, doc.lineAt(from).to))
382
+ out.push(syntax.range(marker.from, marker.to));
383
+ else
384
+ out.push(hide.range(marker.from, marker.to));
385
+ break;
386
+ }
387
+ case 'mark': {
388
+ const content = construct.open.to < construct.close.from;
389
+ if (content)
390
+ out.push(mark(`cm-ms-${construct.mark}`).range(construct.open.to, construct.close.from));
391
+ if (touches(from, to) || !content) {
392
+ out.push(syntax.range(construct.open.from, construct.open.to));
393
+ out.push(syntax.range(construct.close.from, construct.close.to));
394
+ }
395
+ else {
396
+ out.push(hide.range(construct.open.from, construct.open.to));
397
+ out.push(hide.range(construct.close.from, construct.close.to));
398
+ }
399
+ break;
400
+ }
401
+ case 'link': {
402
+ const attributes = { 'data-href': construct.url, title: construct.url };
403
+ if (!construct.label) {
404
+ out.push(mark('cm-ms-link', attributes).range(from, to));
405
+ break;
406
+ }
407
+ if (construct.label.from < construct.label.to)
408
+ out.push(mark('cm-ms-link', attributes).range(construct.label.from, construct.label.to));
409
+ if (touches(from, to)) {
410
+ out.push(syntax.range(from, construct.label.from));
411
+ out.push(syntax.range(construct.label.to, to));
412
+ }
413
+ else {
414
+ out.push(hide.range(from, construct.label.from));
415
+ out.push(hide.range(construct.label.to, to));
416
+ }
417
+ break;
418
+ }
419
+ case 'wikilink': {
420
+ const pending = host.reaches(construct.target) === 0 && !host.keeps(construct.target);
421
+ if (construct.embed && host.keeps(construct.target) && !touches(from, to)) {
422
+ const source = text.slice(from, to);
423
+ out.push(Decoration.replace({ widget: new BlockWidget(`attachment:${source}`, host, () => createElement(Snippet, { text: source }), true, true) }).range(from, to));
424
+ break;
425
+ }
426
+ out.push(mark(`cm-ms-wikilink${pending ? ' cm-ms-pending' : ''}`, { 'data-target': construct.target, title: construct.target }).range(construct.label.from, construct.label.to));
427
+ if (touches(from, to)) {
428
+ out.push(syntax.range(from, construct.label.from));
429
+ out.push(syntax.range(construct.label.to, to));
430
+ }
431
+ else {
432
+ out.push(hide.range(from, construct.label.from));
433
+ out.push(hide.range(construct.label.to, to));
434
+ }
435
+ break;
436
+ }
437
+ case 'image':
438
+ if (!touches(from, to))
439
+ out.push(Decoration.replace({ widget: new ImageWidget(text.slice(from, to)) }).range(from, to));
440
+ break;
441
+ case 'inlineMath':
442
+ if (touches(from, to))
443
+ out.push(mark('cm-ms-math-source').range(from, to));
444
+ else
445
+ out.push(Decoration.replace({ widget: new MathWidget(construct.tex, false) }).range(from, to));
446
+ break;
447
+ case 'quoteMarker': {
448
+ const depth = (text.slice(from, to).match(/>/g) ?? []).length;
449
+ out.push(line(`cm-ms-quote cm-ms-quote-${Math.min(depth, 3)}`).range(doc.lineAt(from).from));
450
+ if (touches(doc.lineAt(from).from, doc.lineAt(from).to))
451
+ out.push(syntax.range(from, to));
452
+ else
453
+ out.push(hide.range(from, to));
454
+ break;
455
+ }
456
+ case 'listMarker': {
457
+ const markerEnd = to;
458
+ const symbol = text.slice(from, markerEnd).trimEnd();
459
+ const onMarker = touches(from, construct.task ? construct.task.to : markerEnd);
460
+ // A task item shows its box and no bullet, as on the page.
461
+ if (construct.ordered || onMarker)
462
+ out.push(mark('cm-ms-list-marker').range(from, from + symbol.length));
463
+ else if (construct.task)
464
+ out.push(hide.range(from, markerEnd));
465
+ else
466
+ out.push(Decoration.replace({ widget: new BulletWidget() }).range(from, from + symbol.length));
467
+ if (construct.task) {
468
+ if (onMarker)
469
+ out.push(syntax.range(construct.task.from, construct.task.to));
470
+ else
471
+ out.push(Decoration.replace({ widget: new TaskWidget(construct.task.checked) }).range(construct.task.from, construct.task.to));
472
+ if (construct.task.checked) {
473
+ const end = doc.lineAt(construct.task.to).to;
474
+ if (end > construct.task.to + 1)
475
+ out.push(mark('cm-ms-task-done').range(construct.task.to + 1, end));
476
+ }
477
+ }
478
+ break;
479
+ }
480
+ case 'footnoteRef':
481
+ out.push(mark('cm-ms-footnote-ref', { title: construct.label }).range(from, to));
482
+ break;
483
+ case 'footnoteDef':
484
+ out.push(line('cm-ms-footnote-def').range(doc.lineAt(from).from));
485
+ out.push(syntax.range(construct.marker.from, construct.marker.to));
486
+ break;
487
+ case 'comment':
488
+ out.push(mark('cm-ms-comment').range(from, to));
489
+ break;
490
+ case 'blockId':
491
+ out.push(mark('cm-ms-block-id').range(from, to));
492
+ break;
493
+ case 'html':
494
+ case 'definition':
495
+ out.push(mark('cm-ms-raw').range(from, to));
496
+ break;
497
+ }
498
+ }
499
+ return Decoration.set(out, true);
500
+ }
501
+ /** A code block's lines, as source: its frame, its fences dimmed, its tokens coloured. */
502
+ function codeLines(construct, out, doc, text) {
503
+ const first = doc.lineAt(construct.from).number;
504
+ const last = doc.lineAt(construct.to > construct.from && text[construct.to - 1] === '\n' ? construct.to - 1 : construct.to).number;
505
+ for (let at = first; at <= last; at++) {
506
+ const classes = ['cm-ms-code-line', at === first ? 'cm-ms-code-first' : '', at === last ? 'cm-ms-code-last' : ''].filter(Boolean).join(' ');
507
+ out.push(line(classes).range(doc.line(at).from));
508
+ }
509
+ if (construct.open)
510
+ out.push(syntax.range(construct.open.from, construct.open.to));
511
+ if (construct.close)
512
+ out.push(syntax.range(construct.close.from, construct.close.to));
513
+ const language = highlightLanguage(construct.language ? `language-${construct.language}` : undefined);
514
+ if (!language || !construct.open)
515
+ return;
516
+ const start = construct.open.to + 1;
517
+ // Tokens are coloured only where the code is the bytes as written, with no container markers.
518
+ if (text.slice(start, start + construct.code.length) !== construct.code)
519
+ return;
520
+ let at = start;
521
+ const walk = (node, classes) => {
522
+ if (node.type === 'text') {
523
+ const length = node.value?.length ?? 0;
524
+ if (classes.length > 0 && length > 0)
525
+ out.push(mark(classes.join(' ')).range(at, at + length));
526
+ at += length;
527
+ return;
528
+ }
529
+ const own = node.type === 'element' && Array.isArray(node.properties?.className) ? node.properties.className : [];
530
+ for (const child of node.children ?? [])
531
+ walk(child, own.length > 0 ? [...new Set([...classes, ...own])] : classes);
532
+ };
533
+ walk(highlightTree(construct.code, language), []);
534
+ }
535
+ export const decorationsField = StateField.define({
536
+ create: build,
537
+ update: (decorations, transaction) => transaction.docChanged ||
538
+ transaction.selection ||
539
+ transaction.effects.some((effect) => effect.is(setFocused) || effect.is(grammarArrived)) ||
540
+ transaction.startState.facet(editorEnvironment) !== transaction.state.facet(editorEnvironment)
541
+ ? build(transaction.state)
542
+ : decorations,
543
+ provide: (field) => EditorView.decorations.from(field),
544
+ });
545
+ /**
546
+ * Keeps `focusField` with the focus the page really has. The focus event is the usual way it
547
+ * learns, but an editor focused as it is made — or made twice, as React's strict mode does — can
548
+ * miss it; any update that finds the two apart sets the field right, after the update.
549
+ */
550
+ const focusKeeper = ViewPlugin.fromClass(class {
551
+ pending = false;
552
+ update(update) {
553
+ this.check(update.view);
554
+ }
555
+ constructor(view) {
556
+ this.check(view);
557
+ }
558
+ check(view) {
559
+ if (this.pending || view.hasFocus === view.state.field(focusField))
560
+ return;
561
+ this.pending = true;
562
+ queueMicrotask(() => {
563
+ this.pending = false;
564
+ if (view.hasFocus !== view.state.field(focusField))
565
+ view.dispatch({ effects: setFocused.of(view.hasFocus) });
566
+ });
567
+ }
568
+ }, {
569
+ eventHandlers: {
570
+ // A click on an editor that already holds the focus changes no focus, and the field may be the one behind.
571
+ mousedown(_event, view) {
572
+ setTimeout(() => {
573
+ if (view.hasFocus && !view.state.field(focusField))
574
+ view.dispatch({ effects: setFocused.of(true) });
575
+ });
576
+ },
577
+ },
578
+ });
579
+ /**
580
+ * The arrow keys go into a drawn block rather than over it: a move up or down that would skip a
581
+ * block's lines lands at its edge instead, and the block shows its source.
582
+ */
583
+ function enterBlock(forward) {
584
+ return (view) => {
585
+ const range = view.state.selection.main;
586
+ if (!range.empty)
587
+ return false;
588
+ const moved = view.moveVertically(range, forward);
589
+ let target = null;
590
+ view.state.field(decorationsField).between(Math.min(range.head, moved.head), Math.max(range.head, moved.head), (from, to, decoration) => {
591
+ if (!decoration.spec.block || !decoration.spec.widget || from === to)
592
+ return;
593
+ if (forward && from > range.head && from <= moved.head && target === null)
594
+ target = from;
595
+ if (!forward && to < range.head && to >= moved.head)
596
+ target = to;
597
+ });
598
+ if (target === null)
599
+ return false;
600
+ view.dispatch({ selection: EditorSelection.cursor(target), scrollIntoView: true });
601
+ return true;
602
+ };
603
+ }
604
+ const blockKeys = Prec.high(keymap.of([
605
+ { key: 'ArrowDown', run: enterBlock(true) },
606
+ { key: 'ArrowUp', run: enterBlock(false) },
607
+ ]));
608
+ /** Follows a link with a modifier held: a wikilink through the host, an address the page follows in a new tab. */
609
+ const follow = EditorView.domEventHandlers({
610
+ mousedown(event, view) {
611
+ if (!(event.metaKey || event.ctrlKey))
612
+ return false;
613
+ const target = event.target.closest('[data-target], [data-href]');
614
+ if (!target)
615
+ return false;
616
+ event.preventDefault();
617
+ const { host } = view.state.facet(editorEnvironment);
618
+ const name = target.dataset['target'];
619
+ if (name !== undefined) {
620
+ const address = host.resolve(name);
621
+ if (address !== null)
622
+ host.navigate(address);
623
+ return true;
624
+ }
625
+ const address = followable(target.dataset['href'] ?? '');
626
+ if (address)
627
+ window.open(address, '_blank', 'noreferrer');
628
+ return true;
629
+ },
630
+ });
631
+ /**
632
+ * Fetches the grammar of every code block that names one not registered yet, and has the page
633
+ * drawn again once it arrives, its tokens coloured (#84).
634
+ */
635
+ const grammarLoader = ViewPlugin.fromClass(class {
636
+ view;
637
+ live = true;
638
+ constructor(view) {
639
+ this.view = view;
640
+ this.check();
641
+ }
642
+ update(update) {
643
+ if (update.docChanged)
644
+ this.check();
645
+ }
646
+ destroy() {
647
+ this.live = false;
648
+ }
649
+ check() {
650
+ for (const construct of this.view.state.field(analysisField).constructs) {
651
+ if (construct.kind !== 'code' || !construct.language)
652
+ continue;
653
+ const wanted = languageToLoad(`language-${construct.language}`);
654
+ if (!wanted)
655
+ continue;
656
+ void loadGrammar(wanted).then((loaded) => {
657
+ if (loaded && this.live)
658
+ this.view.dispatch({ effects: grammarArrived.of(wanted) });
659
+ });
660
+ }
661
+ }
662
+ });
663
+ /** The live page: the analysis, the decorations over it, and what the focus and a modifier-click do. */
664
+ export function livePage() {
665
+ return [
666
+ analysisField,
667
+ focusField,
668
+ decorationsField,
669
+ EditorView.focusChangeEffect.of((_state, focusing) => setFocused.of(focusing)),
670
+ focusKeeper,
671
+ grammarLoader,
672
+ blockKeys,
673
+ follow,
674
+ ];
675
+ }