@memorysmithapp/markdown-renderer 0.3.2 → 0.4.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.
- package/README.md +32 -4
- package/dist/Markdown.d.ts +6 -2
- package/dist/Markdown.js +126 -17
- package/dist/NoteActions.d.ts +9 -0
- package/dist/NoteActions.js +68 -0
- package/dist/NoteBody.d.ts +23 -2
- package/dist/NoteBody.js +73 -7
- package/dist/edit/AttachDialog.d.ts +21 -0
- package/dist/edit/AttachDialog.js +126 -0
- package/dist/edit/ContextMenu.d.ts +29 -0
- package/dist/edit/ContextMenu.js +95 -0
- package/dist/edit/LanguageControl.d.ts +19 -0
- package/dist/edit/LanguageControl.js +124 -0
- package/dist/edit/NoteEditor.d.ts +43 -0
- package/dist/edit/NoteEditor.js +461 -0
- package/dist/edit/analyze.d.ts +150 -0
- package/dist/edit/analyze.js +268 -0
- package/dist/edit/blocks.d.ts +96 -0
- package/dist/edit/blocks.js +480 -0
- package/dist/edit/commands.d.ts +78 -0
- package/dist/edit/commands.js +257 -0
- package/dist/edit/frontmatter.d.ts +45 -0
- package/dist/edit/frontmatter.js +142 -0
- package/dist/edit/index.d.ts +4 -0
- package/dist/edit/index.js +6 -0
- package/dist/edit/live.d.ts +28 -0
- package/dist/edit/live.js +675 -0
- package/dist/edit/saves.d.ts +18 -0
- package/dist/edit/saves.js +52 -0
- package/dist/edit/table.d.ts +58 -0
- package/dist/edit/table.js +185 -0
- package/dist/file-types.d.ts +30 -0
- package/dist/file-types.js +70 -0
- package/dist/highlight.d.ts +21 -7
- package/dist/highlight.js +56 -7
- package/dist/host.d.ts +60 -3
- package/dist/host.js +8 -0
- package/dist/index.d.ts +4 -4
- package/dist/mode-icons.d.ts +6 -0
- package/dist/mode-icons.js +11 -0
- package/dist/prism-grammars.d.ts +18 -0
- package/dist/prism-grammars.js +305 -0
- package/dist/remark-callouts.d.ts +6 -0
- package/dist/remark-callouts.js +2 -2
- package/dist/remark-ring.d.ts +5 -0
- package/dist/remark-ring.js +25 -15
- package/dist/slots.js +17 -1
- package/dist/strings.d.ts +127 -1
- package/dist/strings.js +126 -1
- package/dist/task-saves.d.ts +35 -0
- package/dist/task-saves.js +86 -0
- package/dist/theme.d.ts +47 -0
- package/dist/theme.js +2 -2
- package/package.json +14 -2
- package/structure.css +331 -0
- package/theme-default.css +1072 -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
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the save of the author's edits stands (#64).
|
|
3
|
+
*
|
|
4
|
+
* The host saves each change it is handed. When `onChange` answers with a promise, the editor
|
|
5
|
+
* follows it: saving while it is pending, saved once it resolves, not saved when it rejects. Only
|
|
6
|
+
* the latest change counts: a save that settles after a later change was handed says nothing, so
|
|
7
|
+
* "Saved" always means the last text the author wrote. A host that answers with nothing has no
|
|
8
|
+
* status, and the editor shows none.
|
|
9
|
+
*/
|
|
10
|
+
export type EditSave = 'idle' | 'saving' | 'saved' | 'failed';
|
|
11
|
+
export interface EditSaves {
|
|
12
|
+
readonly state: EditSave;
|
|
13
|
+
/** Hands a change to the host and follows its save. */
|
|
14
|
+
readonly hand: (markdown: string) => void;
|
|
15
|
+
/** Hands the latest change to the host again, after a save failed. */
|
|
16
|
+
readonly retry: () => void;
|
|
17
|
+
}
|
|
18
|
+
export declare function useEditSaves(onChange: ((markdown: string) => unknown) | undefined): EditSaves;
|