@memorysmithapp/markdown-renderer 0.5.0 → 0.7.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 +3 -3
- package/dist/NoteActions.d.ts +21 -2
- package/dist/NoteActions.js +14 -7
- package/dist/NoteBody.d.ts +17 -2
- package/dist/NoteBody.js +9 -8
- package/dist/edit/NoteEditor.d.ts +7 -1
- package/dist/edit/NoteEditor.js +5 -4
- package/dist/edit/blocks.d.ts +12 -0
- package/dist/edit/blocks.js +106 -0
- package/dist/edit/heights.d.ts +68 -0
- package/dist/edit/heights.js +162 -0
- package/dist/edit/image-size.d.ts +36 -0
- package/dist/edit/image-size.js +61 -0
- package/dist/edit/live.d.ts +9 -2
- package/dist/edit/live.js +415 -64
- package/dist/host.d.ts +16 -2
- package/dist/index.d.ts +1 -0
- package/dist/mode-icons.d.ts +1 -0
- package/dist/mode-icons.js +2 -0
- package/dist/strings.d.ts +8 -0
- package/dist/strings.js +4 -0
- package/dist/theme.d.ts +11 -3
- package/dist/theme.js +1 -0
- package/package.json +2 -2
- package/structure.css +69 -2
- package/theme-default.css +65 -2
package/dist/edit/live.js
CHANGED
|
@@ -9,14 +9,16 @@
|
|
|
9
9
|
import { EditorSelection, Facet, Prec, StateEffect, StateField } from '@codemirror/state';
|
|
10
10
|
import { Decoration, EditorView, keymap, ViewPlugin, WidgetType } from '@codemirror/view';
|
|
11
11
|
import katex from 'katex';
|
|
12
|
-
import { createElement } from 'react';
|
|
12
|
+
import { Component, createElement, Fragment, useLayoutEffect } from 'react';
|
|
13
13
|
import { createRoot } from 'react-dom/client';
|
|
14
14
|
import { followable } from '../address.js';
|
|
15
15
|
import { highlightTree, languageToLoad, loadGrammar } from '../highlight.js';
|
|
16
16
|
import { HostProvider, resolveHost } from '../host.js';
|
|
17
|
+
import { readImageAlt } from '../image-dimensions.js';
|
|
17
18
|
import { highlightLanguage } from '../highlight.js';
|
|
18
19
|
import { analyze } from './analyze.js';
|
|
19
|
-
import {
|
|
20
|
+
import { DEFAULT_METRICS, estimateHeight, lineHeightOf, measureLines, measuredHeight, rememberHeight, withinReason } from './heights.js';
|
|
21
|
+
import { AddProperties, CalloutBlock, ImageBlock, PropertiesBlock, reveal, Snippet, TableBlock } from './blocks.js';
|
|
20
22
|
import { LanguageControl } from './LanguageControl.js';
|
|
21
23
|
import { toggleTaskBox } from './commands.js';
|
|
22
24
|
export const editorEnvironment = Facet.define({
|
|
@@ -35,6 +37,53 @@ export const analysisField = StateField.define({
|
|
|
35
37
|
export const setFocused = StateEffect.define();
|
|
36
38
|
/** A grammar a code block names has arrived: its tokens can be coloured now (#84). */
|
|
37
39
|
export const grammarArrived = StateEffect.define();
|
|
40
|
+
/** The editor's metrics changed: the blocks made from now on estimate their height by them (#91). */
|
|
41
|
+
const setBlockMetrics = StateEffect.define();
|
|
42
|
+
/** A block's height was learned before it was drawn: the blocks are made again with it (#94). */
|
|
43
|
+
const heightsLearned = StateEffect.define();
|
|
44
|
+
/** The metrics of the editor's text column, which a drawn block estimates its height by (#91). */
|
|
45
|
+
export const metricsField = StateField.define({
|
|
46
|
+
create: () => DEFAULT_METRICS,
|
|
47
|
+
update: (metrics, transaction) => {
|
|
48
|
+
for (const effect of transaction.effects)
|
|
49
|
+
if (effect.is(setBlockMetrics))
|
|
50
|
+
return effect.value;
|
|
51
|
+
return metrics;
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
/** Reads the editor's metrics again whenever its geometry changes, and keeps them when they moved. */
|
|
55
|
+
const metricsWatcher = ViewPlugin.define((view) => {
|
|
56
|
+
let destroyed = false;
|
|
57
|
+
const check = () => view.requestMeasure({
|
|
58
|
+
read: () => withinReason({
|
|
59
|
+
width: view.contentDOM.clientWidth,
|
|
60
|
+
lineHeight: view.defaultLineHeight,
|
|
61
|
+
charWidth: view.defaultCharacterWidth,
|
|
62
|
+
...measureLines(view.dom),
|
|
63
|
+
}),
|
|
64
|
+
write: (next) => {
|
|
65
|
+
const now = view.state.field(metricsField);
|
|
66
|
+
const lines = (metrics) => [...(metrics.headings ?? []), ...(metrics.code ? [metrics.code] : [])].map((each) => `${each.lineHeight.toFixed(1)}/${each.charWidth.toFixed(2)}/${each.padding.toFixed(1)}/${each.inset.toFixed(1)}`).join();
|
|
67
|
+
const moved = Math.abs(next.width - now.width) >= 1 ||
|
|
68
|
+
Math.abs(next.lineHeight - now.lineHeight) >= 0.5 ||
|
|
69
|
+
Math.abs(next.charWidth - now.charWidth) >= 0.05 ||
|
|
70
|
+
lines(next) !== lines(now);
|
|
71
|
+
// Not inside the measure, where CodeMirror takes no transaction.
|
|
72
|
+
if (moved)
|
|
73
|
+
queueMicrotask(() => destroyed || view.dispatch({ effects: setBlockMetrics.of(next) }));
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
check();
|
|
77
|
+
return {
|
|
78
|
+
update: (update) => {
|
|
79
|
+
if (update.geometryChanged)
|
|
80
|
+
check();
|
|
81
|
+
},
|
|
82
|
+
destroy: () => {
|
|
83
|
+
destroyed = true;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
});
|
|
38
87
|
/** Whether the editor has the focus: a construct is shown as source only to an author who is in it. */
|
|
39
88
|
const focusField = StateField.define({
|
|
40
89
|
create: () => false,
|
|
@@ -46,19 +95,95 @@ const focusField = StateField.define({
|
|
|
46
95
|
},
|
|
47
96
|
});
|
|
48
97
|
// ── Widgets ──────────────────────────────────────────────────────────────────
|
|
98
|
+
/**
|
|
99
|
+
* What a drawn block shows when drawing it threw — a host's `renderLink` that needs the host's own
|
|
100
|
+
* context, which a block's root, outside the host's tree, does not carry (#90): its lines as written,
|
|
101
|
+
* under a notice, so a failure never hides a part of the note. A click on it shows its source.
|
|
102
|
+
*/
|
|
103
|
+
class BlockBoundary extends Component {
|
|
104
|
+
state = { failed: false };
|
|
105
|
+
static getDerivedStateFromError() {
|
|
106
|
+
return { failed: true };
|
|
107
|
+
}
|
|
108
|
+
componentDidCatch(error, info) {
|
|
109
|
+
// React reports a caught error only in development; the host is told in every build.
|
|
110
|
+
console.error('A block of the note could not be drawn:', error, info.componentStack);
|
|
111
|
+
}
|
|
112
|
+
render() {
|
|
113
|
+
if (!this.state.failed)
|
|
114
|
+
return this.props.children;
|
|
115
|
+
const notice = this.props.host.strings.editor.blockFailed;
|
|
116
|
+
if (!this.props.source)
|
|
117
|
+
return createElement('span', { className: 'cm-ms-block-failed', title: notice });
|
|
118
|
+
return createElement('div', { className: 'cm-ms-block-failed', role: 'note' }, createElement('p', { className: 'cm-ms-block-failed-notice' }, notice), createElement('pre', null, this.props.source));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
49
121
|
/** The React roots the widgets mounted, unmounted when CodeMirror lets the widget go. */
|
|
50
122
|
const roots = new WeakMap();
|
|
51
|
-
|
|
52
|
-
|
|
123
|
+
/** The widget an element draws now: one made again for the same block takes its element over (#99). */
|
|
124
|
+
const drawing = new WeakMap();
|
|
125
|
+
/** How long a block made again holds the height it was drawn at, at most, while it draws (#99). */
|
|
126
|
+
const HOLD_MS = 2000;
|
|
127
|
+
/** Tells its block it was drawn, once React has put it in the page. */
|
|
128
|
+
function Drawn({ onDrawn, children }) {
|
|
129
|
+
useLayoutEffect(onDrawn);
|
|
130
|
+
return createElement(Fragment, null, children);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Draws `node` in `element` with React, under the editor's host. `held` is the height the block
|
|
134
|
+
* was drawn at before, which the element keeps until what it draws is as tall — the root draws on
|
|
135
|
+
* React's schedule, and an element left empty meanwhile would let the page collapse under the
|
|
136
|
+
* author (#99).
|
|
137
|
+
*/
|
|
138
|
+
function mount(view, element, node, source, identity, measured, held) {
|
|
53
139
|
const root = createRoot(element);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
140
|
+
let holding = held !== undefined && held > 0;
|
|
141
|
+
if (holding)
|
|
142
|
+
element.style.minHeight = `${held}px`;
|
|
143
|
+
const release = () => {
|
|
144
|
+
holding = false;
|
|
145
|
+
element.style.minHeight = '';
|
|
146
|
+
};
|
|
147
|
+
const timer = holding ? setTimeout(release, HOLD_MS) : undefined;
|
|
148
|
+
// Released once what it draws fills the height it holds: the content's own box, margins aside.
|
|
149
|
+
const check = () => {
|
|
150
|
+
if (!holding || typeof document.createRange !== 'function')
|
|
151
|
+
return;
|
|
152
|
+
const range = document.createRange();
|
|
153
|
+
range.selectNodeContents(element);
|
|
154
|
+
if (range.getBoundingClientRect().height >= held * 0.9) {
|
|
155
|
+
clearTimeout(timer);
|
|
156
|
+
release();
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
const render = (next, text, drawn) => {
|
|
160
|
+
const { host } = view.state.facet(editorEnvironment);
|
|
161
|
+
// Drawn on React's own schedule: CodeMirror builds its widgets inside its update, which may run
|
|
162
|
+
// inside a React commit, where React cannot be asked to render synchronously. Keyed by what it
|
|
163
|
+
// draws: the same block is drawn again in place, another one replaces it at once on commit,
|
|
164
|
+
// what was drawn before staying until then (#99).
|
|
165
|
+
root.render(createElement(HostProvider, { value: host }, createElement(BlockBoundary, { key: drawn, host, source: text }, createElement(Drawn, { onDrawn: check }, next))));
|
|
166
|
+
};
|
|
167
|
+
render(node, source, identity);
|
|
57
168
|
// A block whose content arrives later — an embed, a diagram — changes its height after it is
|
|
58
169
|
// measured; the editor is told so, or the lines below it would be placed by the old height.
|
|
59
|
-
const observer = typeof ResizeObserver === 'undefined'
|
|
170
|
+
const observer = typeof ResizeObserver === 'undefined'
|
|
171
|
+
? null
|
|
172
|
+
: new ResizeObserver(() => {
|
|
173
|
+
check();
|
|
174
|
+
if (!holding)
|
|
175
|
+
measured(element.getBoundingClientRect().height);
|
|
176
|
+
view.requestMeasure();
|
|
177
|
+
});
|
|
60
178
|
observer?.observe(element);
|
|
61
|
-
roots.set(element, {
|
|
179
|
+
roots.set(element, {
|
|
180
|
+
unmount: () => {
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
root.unmount();
|
|
183
|
+
},
|
|
184
|
+
observer,
|
|
185
|
+
render,
|
|
186
|
+
});
|
|
62
187
|
}
|
|
63
188
|
function unmount(element) {
|
|
64
189
|
const mounted = roots.get(element);
|
|
@@ -75,35 +200,71 @@ class BlockWidget extends WidgetType {
|
|
|
75
200
|
host;
|
|
76
201
|
draw;
|
|
77
202
|
revealsOnClick;
|
|
203
|
+
options;
|
|
78
204
|
inline;
|
|
205
|
+
source;
|
|
79
206
|
constructor(key, host, draw,
|
|
80
207
|
/** Whether a click on it shows its source; a block with controls of its own handles its clicks. */
|
|
81
|
-
revealsOnClick,
|
|
208
|
+
revealsOnClick, options = {}) {
|
|
82
209
|
super();
|
|
83
210
|
this.key = key;
|
|
84
211
|
this.host = host;
|
|
85
212
|
this.draw = draw;
|
|
86
213
|
this.revealsOnClick = revealsOnClick;
|
|
87
|
-
this.
|
|
214
|
+
this.options = options;
|
|
215
|
+
this.inline = options.inline ?? false;
|
|
216
|
+
this.source = options.source ?? '';
|
|
217
|
+
this.estimate = this.estimateNow();
|
|
218
|
+
}
|
|
219
|
+
/** What it is remembered by once measured: what it draws, wherever in the note it stands. */
|
|
220
|
+
get identity() {
|
|
221
|
+
return `${this.options.kind ?? 'block'}\u0000${this.source}`;
|
|
88
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* The height it is laid out at until drawn, read when it is made: a block whose height was learned
|
|
225
|
+
* since — measured, or measured ahead of being drawn (#94) — is made again with it, so CodeMirror
|
|
226
|
+
* reads the new one, and keeps its drawing through `updateDOM`.
|
|
227
|
+
*/
|
|
228
|
+
estimate;
|
|
89
229
|
eq(other) {
|
|
90
|
-
return other.key === this.key && other.host === this.host;
|
|
230
|
+
return other.key === this.key && other.host === this.host && other.estimate === this.estimate;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* A block of the same kind made again — because it moved, the host was given again, its
|
|
234
|
+
* estimate was learned or its source changed — keeps its element: it is drawn again in it, with
|
|
235
|
+
* this widget's positions and the host as it is now (#94, #99). The same source keeps its React
|
|
236
|
+
* tree; another source replaces it on commit, so the element is never left empty.
|
|
237
|
+
*/
|
|
238
|
+
updateDOM(dom, view, from) {
|
|
239
|
+
const mounted = roots.get(dom);
|
|
240
|
+
if (!mounted || from.inline !== this.inline || from.options.kind !== this.options.kind || from.revealsOnClick !== this.revealsOnClick)
|
|
241
|
+
return false;
|
|
242
|
+
drawing.set(dom, this);
|
|
243
|
+
if (from.key !== this.key || from.host !== this.host)
|
|
244
|
+
mounted.render(this.draw(view), this.source, this.identity);
|
|
245
|
+
return true;
|
|
91
246
|
}
|
|
92
247
|
toDOM(view) {
|
|
93
248
|
const element = document.createElement(this.inline ? 'span' : 'div');
|
|
94
249
|
element.className = this.inline ? 'cm-ms-inline-block' : 'cm-ms-block';
|
|
95
|
-
|
|
250
|
+
drawing.set(element, this);
|
|
251
|
+
const metrics = view.state.field(metricsField);
|
|
252
|
+
const held = this.inline ? undefined : measuredHeight(this.identity, metrics);
|
|
253
|
+
// Remembered for what the element draws now, which a widget made again may have changed.
|
|
254
|
+
const measured = (height) => rememberHeight((drawing.get(element) ?? this).identity, view.state.field(metricsField), height);
|
|
255
|
+
mount(view, element, this.draw(view), this.source, this.identity, measured, held);
|
|
96
256
|
element.addEventListener('mousedown', (event) => {
|
|
97
257
|
const target = event.target;
|
|
98
258
|
if (target.closest('[data-ms-control]'))
|
|
99
259
|
return;
|
|
100
260
|
if (event.metaKey || event.ctrlKey)
|
|
101
261
|
return;
|
|
102
|
-
|
|
262
|
+
const widget = drawing.get(element) ?? this;
|
|
263
|
+
if (!widget.revealsOnClick)
|
|
103
264
|
return;
|
|
104
265
|
event.preventDefault();
|
|
105
266
|
// 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,
|
|
267
|
+
reveal(view, view.state.doc.lineAt(blockStart(view, element, widget.key)).to);
|
|
107
268
|
});
|
|
108
269
|
// A link inside a drawn block is followed with a modifier only, as in the text.
|
|
109
270
|
element.addEventListener('click', (event) => {
|
|
@@ -119,8 +280,20 @@ class BlockWidget extends WidgetType {
|
|
|
119
280
|
ignoreEvent() {
|
|
120
281
|
return true;
|
|
121
282
|
}
|
|
283
|
+
/**
|
|
284
|
+
* The height CodeMirror lays the block out at until it is drawn, which it reads once, when the
|
|
285
|
+
* block is made: the height it was drawn at before at this width, or its estimate (#91).
|
|
286
|
+
*/
|
|
122
287
|
get estimatedHeight() {
|
|
123
|
-
return this.
|
|
288
|
+
return this.estimate;
|
|
289
|
+
}
|
|
290
|
+
estimateNow() {
|
|
291
|
+
const metrics = this.options.metrics ?? DEFAULT_METRICS;
|
|
292
|
+
const kind = this.options.kind;
|
|
293
|
+
// Within a line only an image estimates, as tall as the line it stands in (#91).
|
|
294
|
+
if (this.inline && !kind)
|
|
295
|
+
return -1;
|
|
296
|
+
return measuredHeight(this.identity, metrics) ?? (kind ? estimateHeight(kind, this.source, metrics) : 2 * metrics.lineHeight);
|
|
124
297
|
}
|
|
125
298
|
}
|
|
126
299
|
/**
|
|
@@ -140,6 +313,29 @@ function blockStart(view, element, key) {
|
|
|
140
313
|
});
|
|
141
314
|
return found === -1 ? near : found;
|
|
142
315
|
}
|
|
316
|
+
/**
|
|
317
|
+
* Nothing drawn, at the start of a line CodeMirror would estimate too short — a heading, larger than
|
|
318
|
+
* the text and spaced from it — telling it how tall the line is until it is drawn (#94). CodeMirror
|
|
319
|
+
* lays a line out at the tallest of its widgets' estimates and its own, so this only ever raises it.
|
|
320
|
+
*/
|
|
321
|
+
class LineEstimate extends WidgetType {
|
|
322
|
+
height;
|
|
323
|
+
constructor(height) {
|
|
324
|
+
super();
|
|
325
|
+
this.height = height;
|
|
326
|
+
}
|
|
327
|
+
eq(other) {
|
|
328
|
+
return other.height === this.height;
|
|
329
|
+
}
|
|
330
|
+
toDOM() {
|
|
331
|
+
const element = document.createElement('span');
|
|
332
|
+
element.className = 'cm-ms-line-estimate';
|
|
333
|
+
return element;
|
|
334
|
+
}
|
|
335
|
+
get estimatedHeight() {
|
|
336
|
+
return this.height;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
143
339
|
class BulletWidget extends WidgetType {
|
|
144
340
|
eq() {
|
|
145
341
|
return true;
|
|
@@ -180,6 +376,10 @@ class TaskWidget extends WidgetType {
|
|
|
180
376
|
return false;
|
|
181
377
|
}
|
|
182
378
|
}
|
|
379
|
+
/** What a rule and a formula's row are drawn at by the default theme, before they are measured (#91). */
|
|
380
|
+
const RULE_HEIGHT = 33;
|
|
381
|
+
const MATH_ROW = 32;
|
|
382
|
+
const MATH_MARGINS = 32;
|
|
183
383
|
class RuleWidget extends WidgetType {
|
|
184
384
|
eq() {
|
|
185
385
|
return true;
|
|
@@ -190,6 +390,9 @@ class RuleWidget extends WidgetType {
|
|
|
190
390
|
wrap.appendChild(document.createElement('hr'));
|
|
191
391
|
return wrap;
|
|
192
392
|
}
|
|
393
|
+
get estimatedHeight() {
|
|
394
|
+
return RULE_HEIGHT;
|
|
395
|
+
}
|
|
193
396
|
}
|
|
194
397
|
class MathWidget extends WidgetType {
|
|
195
398
|
tex;
|
|
@@ -209,38 +412,24 @@ class MathWidget extends WidgetType {
|
|
|
209
412
|
katex.render(this.tex, element, { throwOnError: false, displayMode: this.display, strict: 'ignore' });
|
|
210
413
|
return element;
|
|
211
414
|
}
|
|
212
|
-
|
|
213
|
-
|
|
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;
|
|
415
|
+
/** A formula's rows, each a line of KaTeX's display, within its margins (#91). */
|
|
416
|
+
get estimatedHeight() {
|
|
417
|
+
return this.display ? MATH_MARGINS + MATH_ROW * this.tex.split('\\\\').length : -1;
|
|
239
418
|
}
|
|
240
419
|
ignoreEvent() {
|
|
241
420
|
return false;
|
|
242
421
|
}
|
|
243
422
|
}
|
|
423
|
+
/** A Markdown image's description and address, when its address is one the page follows. */
|
|
424
|
+
function readImage(source) {
|
|
425
|
+
const match = /^!\[([^\]]*)\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)$/.exec(source);
|
|
426
|
+
const address = match ? followable(match[2] ?? '') : '';
|
|
427
|
+
return match && address ? { alt: readImageAlt(match[1] ?? ''), address } : null;
|
|
428
|
+
}
|
|
429
|
+
/** An image drawn with its description and the dimensions it declares, as the page draws it (`SPEC.md` §3.14). */
|
|
430
|
+
function drawImage({ alt, address }) {
|
|
431
|
+
return createElement('img', { className: 'cm-ms-image', src: address, alt: alt.description, width: alt.width ?? undefined, height: alt.height ?? undefined });
|
|
432
|
+
}
|
|
244
433
|
// ── Decorations ──────────────────────────────────────────────────────────────
|
|
245
434
|
const hide = Decoration.replace({});
|
|
246
435
|
const syntax = Decoration.mark({ class: 'cm-ms-syntax' });
|
|
@@ -251,12 +440,15 @@ const BLOCK_KINDS = new Set(['frontmatter', 'callout', 'table', 'mathBlock', 'em
|
|
|
251
440
|
function build(state) {
|
|
252
441
|
const { constructs, bodyFrom } = state.field(analysisField);
|
|
253
442
|
const { host } = state.facet(editorEnvironment);
|
|
443
|
+
const metrics = state.field(metricsField);
|
|
254
444
|
const focused = state.field(focusField);
|
|
255
445
|
const doc = state.doc;
|
|
256
446
|
const text = textOf(state);
|
|
257
447
|
const ranges = state.selection.ranges;
|
|
258
448
|
const out = [];
|
|
259
449
|
const covered = [];
|
|
450
|
+
/** The level of each heading, by its line's number, which its estimate is made for (#94). */
|
|
451
|
+
const headingLevels = new Map();
|
|
260
452
|
/** Whether the author's cursor or selection is on this range, ends included. */
|
|
261
453
|
const touches = (from, to) => focused && ranges.some((range) => range.from <= to && range.to >= from);
|
|
262
454
|
/** The whole lines a construct takes, its last line break left out. */
|
|
@@ -274,17 +466,18 @@ function build(state) {
|
|
|
274
466
|
for (let at = doc.lineAt(lines.from).number; at <= doc.lineAt(lines.to).number; at++)
|
|
275
467
|
out.push(line(className).range(doc.line(at).from));
|
|
276
468
|
};
|
|
277
|
-
const block = (from, to, key, draw, revealsOnClick = true) => {
|
|
469
|
+
const block = (from, to, kind, key, draw, revealsOnClick = true) => {
|
|
278
470
|
const lines = linesOf(from, to);
|
|
279
|
-
|
|
471
|
+
const source = text.slice(lines.from, lines.to);
|
|
472
|
+
out.push(Decoration.replace({ widget: new BlockWidget(key, host, draw, revealsOnClick, { source, kind, metrics }), block: true }).range(lines.from, lines.to));
|
|
280
473
|
covered.push(lines);
|
|
281
474
|
};
|
|
282
|
-
const after = (to, key, draw) => {
|
|
475
|
+
const after = (to, key, draw, source) => {
|
|
283
476
|
const lines = linesOf(to, to);
|
|
284
|
-
out.push(Decoration.widget({ widget: new BlockWidget(key, host, draw, false), block: true, side: 1 }).range(lines.to));
|
|
477
|
+
out.push(Decoration.widget({ widget: new BlockWidget(key, host, draw, false, { source, kind: 'preview', metrics }), block: true, side: 1 }).range(lines.to));
|
|
285
478
|
};
|
|
286
479
|
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));
|
|
480
|
+
out.push(Decoration.widget({ widget: new BlockWidget('add-properties', host, (view) => createElement(AddProperties, { view }), false, { kind: 'add-properties', metrics }), block: true, side: -1 }).range(0));
|
|
288
481
|
}
|
|
289
482
|
// Blocks first: what a drawn block covers is not decorated again.
|
|
290
483
|
for (const construct of constructs) {
|
|
@@ -299,7 +492,7 @@ function build(state) {
|
|
|
299
492
|
if (open)
|
|
300
493
|
eachLine(from, to, 'cm-ms-yaml');
|
|
301
494
|
else
|
|
302
|
-
block(from, to, `front:${source}`, (view) => createElement(PropertiesBlock, { view, to }), false);
|
|
495
|
+
block(from, to, 'frontmatter', `front:${source}`, (view) => createElement(PropertiesBlock, { view, to }), false);
|
|
303
496
|
break;
|
|
304
497
|
case 'callout':
|
|
305
498
|
if (!alone(from, to))
|
|
@@ -308,7 +501,7 @@ function build(state) {
|
|
|
308
501
|
eachLine(from, to, `cm-ms-callout-source`);
|
|
309
502
|
else {
|
|
310
503
|
const marker = { ...construct.marker, type: construct.type, fold: construct.fold };
|
|
311
|
-
block(from, to, `callout:${from}:${source}`, (view) => createElement(CalloutBlock, { view, text: source, marker }));
|
|
504
|
+
block(from, to, 'callout', `callout:${from}:${source}`, (view) => createElement(CalloutBlock, { view, text: source, marker }));
|
|
312
505
|
}
|
|
313
506
|
break;
|
|
314
507
|
case 'table':
|
|
@@ -317,14 +510,15 @@ function build(state) {
|
|
|
317
510
|
if (open)
|
|
318
511
|
eachLine(from, to, 'cm-ms-table-source');
|
|
319
512
|
else
|
|
320
|
-
block(from, to, `table:${from}:${source}`, (view) => createElement(TableBlock, { view, from, to }), false);
|
|
513
|
+
block(from, to, 'table', `table:${from}:${source}`, (view) => createElement(TableBlock, { view, from, to }), false);
|
|
321
514
|
break;
|
|
322
515
|
case 'mathBlock':
|
|
323
516
|
if (!alone(from, to))
|
|
324
517
|
break;
|
|
325
518
|
if (open) {
|
|
326
519
|
eachLine(from, to, 'cm-ms-code-line');
|
|
327
|
-
|
|
520
|
+
const preview = `$$\n${construct.tex}\n$$`;
|
|
521
|
+
after(to, `math-preview:${construct.tex}`, () => createElement(Snippet, { text: preview }), preview);
|
|
328
522
|
}
|
|
329
523
|
else {
|
|
330
524
|
out.push(Decoration.replace({ widget: new MathWidget(construct.tex, true), block: true }).range(lines.from, lines.to));
|
|
@@ -334,8 +528,11 @@ function build(state) {
|
|
|
334
528
|
case 'embed':
|
|
335
529
|
if (open)
|
|
336
530
|
out.push(mark('cm-ms-wikilink').range(from, to));
|
|
531
|
+
// A kept image is sized from the image itself (#98); a note's embed draws no image of its own.
|
|
532
|
+
else if (host.keeps(construct.target))
|
|
533
|
+
block(from, to, 'embed', `embed:${source}`, (view) => createElement(ImageBlock, { view, source }, createElement(Snippet, { text: source })));
|
|
337
534
|
else
|
|
338
|
-
block(from, to, `embed:${source}`, () => createElement(Snippet, { text: source }));
|
|
535
|
+
block(from, to, 'embed', `embed:${source}`, () => createElement(Snippet, { text: source }));
|
|
339
536
|
break;
|
|
340
537
|
case 'rule':
|
|
341
538
|
if (!alone(from, to))
|
|
@@ -350,7 +547,7 @@ function build(state) {
|
|
|
350
547
|
case 'code': {
|
|
351
548
|
const diagram = construct.language === 'mermaid' && construct.open !== null;
|
|
352
549
|
if (diagram && !open && alone(from, to)) {
|
|
353
|
-
block(from, to, `diagram:${source}`, () => createElement(Snippet, { text: source }));
|
|
550
|
+
block(from, to, 'diagram', `diagram:${source}`, () => createElement(Snippet, { text: source }));
|
|
354
551
|
break;
|
|
355
552
|
}
|
|
356
553
|
codeLines(construct, out, doc, text);
|
|
@@ -359,12 +556,14 @@ function build(state) {
|
|
|
359
556
|
const fence = construct.open.from;
|
|
360
557
|
const language = construct.language;
|
|
361
558
|
out.push(Decoration.widget({
|
|
362
|
-
widget: new BlockWidget(`language:${fence}:${language ?? ''}`, host, (view) => createElement(LanguageControl, { view, from: fence, language }), false, true),
|
|
559
|
+
widget: new BlockWidget(`language:${fence}:${language ?? ''}`, host, (view) => createElement(LanguageControl, { view, from: fence, language }), false, { inline: true }),
|
|
363
560
|
side: 1,
|
|
364
561
|
}).range(construct.open.to));
|
|
365
562
|
}
|
|
366
|
-
if (diagram)
|
|
367
|
-
|
|
563
|
+
if (diagram) {
|
|
564
|
+
const preview = '```mermaid\n' + construct.code + '\n```';
|
|
565
|
+
after(to, `diagram-preview:${construct.code}`, () => createElement(Snippet, { text: preview }), preview);
|
|
566
|
+
}
|
|
368
567
|
covered.push(lines);
|
|
369
568
|
break;
|
|
370
569
|
}
|
|
@@ -378,6 +577,7 @@ function build(state) {
|
|
|
378
577
|
case 'heading': {
|
|
379
578
|
out.push(line(`cm-ms-heading cm-ms-h${construct.level}`).range(doc.lineAt(from).from));
|
|
380
579
|
const { marker } = construct;
|
|
580
|
+
headingLevels.set(doc.lineAt(from).number, construct.level);
|
|
381
581
|
if (touches(doc.lineAt(from).from, doc.lineAt(from).to))
|
|
382
582
|
out.push(syntax.range(marker.from, marker.to));
|
|
383
583
|
else
|
|
@@ -420,7 +620,10 @@ function build(state) {
|
|
|
420
620
|
const pending = host.reaches(construct.target) === 0 && !host.keeps(construct.target);
|
|
421
621
|
if (construct.embed && host.keeps(construct.target) && !touches(from, to)) {
|
|
422
622
|
const source = text.slice(from, to);
|
|
423
|
-
out.push(Decoration.replace({
|
|
623
|
+
out.push(Decoration.replace({
|
|
624
|
+
// A kept image is sized from the image itself, as a Markdown one is (#98).
|
|
625
|
+
widget: new BlockWidget(`attachment:${source}`, host, (view) => createElement(ImageBlock, { view, source }, createElement(Snippet, { text: source })), true, { inline: true, source }),
|
|
626
|
+
}).range(from, to));
|
|
424
627
|
break;
|
|
425
628
|
}
|
|
426
629
|
out.push(mark(`cm-ms-wikilink${pending ? ' cm-ms-pending' : ''}`, { 'data-target': construct.target, title: construct.target }).range(construct.label.from, construct.label.to));
|
|
@@ -434,10 +637,21 @@ function build(state) {
|
|
|
434
637
|
}
|
|
435
638
|
break;
|
|
436
639
|
}
|
|
437
|
-
case 'image':
|
|
438
|
-
if (
|
|
439
|
-
|
|
640
|
+
case 'image': {
|
|
641
|
+
if (touches(from, to))
|
|
642
|
+
break;
|
|
643
|
+
const source = text.slice(from, to);
|
|
644
|
+
const image = readImage(source);
|
|
645
|
+
// An image the page cannot follow shows its notation, as written.
|
|
646
|
+
if (!image)
|
|
647
|
+
out.push(mark('cm-ms-raw').range(from, to));
|
|
648
|
+
else {
|
|
649
|
+
// Its size is set from the image itself (#98).
|
|
650
|
+
const draw = (view) => createElement(ImageBlock, { view, source }, drawImage(image));
|
|
651
|
+
out.push(Decoration.replace({ widget: new BlockWidget(`image:${source}`, host, draw, true, { inline: true, source, kind: 'image', metrics }) }).range(from, to));
|
|
652
|
+
}
|
|
440
653
|
break;
|
|
654
|
+
}
|
|
441
655
|
case 'inlineMath':
|
|
442
656
|
if (touches(from, to))
|
|
443
657
|
out.push(mark('cm-ms-math-source').range(from, to));
|
|
@@ -496,8 +710,47 @@ function build(state) {
|
|
|
496
710
|
break;
|
|
497
711
|
}
|
|
498
712
|
}
|
|
713
|
+
out.push(...lineEstimates(state, out, covered, headingLevels, metrics));
|
|
499
714
|
return Decoration.set(out, true);
|
|
500
715
|
}
|
|
716
|
+
/**
|
|
717
|
+
* A `LineEstimate` for each line CodeMirror would lay out shorter than the page draws it (#94): a
|
|
718
|
+
* heading, a line of code, a line of text that wraps. Its length is what the reader sees of it, the
|
|
719
|
+
* notation the decorations hide taken away; a line a drawn block covers estimates nothing.
|
|
720
|
+
*/
|
|
721
|
+
function lineEstimates(state, decorations, covered, headingLevels, metrics) {
|
|
722
|
+
const doc = state.doc;
|
|
723
|
+
const hidden = new Map();
|
|
724
|
+
for (const range of decorations) {
|
|
725
|
+
if (range.value !== hide || range.to <= range.from)
|
|
726
|
+
continue;
|
|
727
|
+
const number = doc.lineAt(range.from).number;
|
|
728
|
+
hidden.set(number, (hidden.get(number) ?? 0) + range.to - range.from);
|
|
729
|
+
}
|
|
730
|
+
const code = new Set();
|
|
731
|
+
for (const construct of state.field(analysisField).constructs) {
|
|
732
|
+
if (construct.kind !== 'code')
|
|
733
|
+
continue;
|
|
734
|
+
for (let at = doc.lineAt(construct.from).number; at <= doc.lineAt(Math.max(construct.from, construct.to - 1)).number; at++)
|
|
735
|
+
code.add(at);
|
|
736
|
+
}
|
|
737
|
+
const out = [];
|
|
738
|
+
for (let number = 1; number <= doc.lines; number++) {
|
|
739
|
+
const line = doc.line(number);
|
|
740
|
+
if (line.length === 0)
|
|
741
|
+
continue;
|
|
742
|
+
if (covered.some((each) => line.from >= each.from && line.to <= each.to))
|
|
743
|
+
continue;
|
|
744
|
+
const level = headingLevels.get(number);
|
|
745
|
+
const length = line.length - (hidden.get(number) ?? 0);
|
|
746
|
+
const height = lineHeightOf(level !== undefined ? { heading: level } : code.has(number) ? 'code' : 'text', length, metrics);
|
|
747
|
+
// Said of every line, even one text line tall: a line with no decoration is laid out in a gap,
|
|
748
|
+
// which CodeMirror estimates by the length of all its lines together, too tall on a wide column.
|
|
749
|
+
if (height !== null)
|
|
750
|
+
out.push(Decoration.widget({ widget: new LineEstimate(height), side: -1 }).range(line.from));
|
|
751
|
+
}
|
|
752
|
+
return out;
|
|
753
|
+
}
|
|
501
754
|
/** A code block's lines, as source: its frame, its fences dimmed, its tokens coloured. */
|
|
502
755
|
function codeLines(construct, out, doc, text) {
|
|
503
756
|
const first = doc.lineAt(construct.from).number;
|
|
@@ -536,7 +789,7 @@ export const decorationsField = StateField.define({
|
|
|
536
789
|
create: build,
|
|
537
790
|
update: (decorations, transaction) => transaction.docChanged ||
|
|
538
791
|
transaction.selection ||
|
|
539
|
-
transaction.effects.some((effect) => effect.is(setFocused) || effect.is(grammarArrived)) ||
|
|
792
|
+
transaction.effects.some((effect) => effect.is(setFocused) || effect.is(grammarArrived) || effect.is(setBlockMetrics) || effect.is(heightsLearned)) ||
|
|
540
793
|
transaction.startState.facet(editorEnvironment) !== transaction.state.facet(editorEnvironment)
|
|
541
794
|
? build(transaction.state)
|
|
542
795
|
: decorations,
|
|
@@ -628,6 +881,97 @@ const follow = EditorView.domEventHandlers({
|
|
|
628
881
|
return true;
|
|
629
882
|
},
|
|
630
883
|
});
|
|
884
|
+
/** How long an embed measured ahead of being drawn may take to load before it is let go (#94). */
|
|
885
|
+
const PREMEASURE_TIMEOUT = 8000;
|
|
886
|
+
/**
|
|
887
|
+
* Draws, out of sight and at the editor's width, every embed of a note whose height is not known
|
|
888
|
+
* yet, and remembers the height it is drawn at, so the page is laid out with it before the author
|
|
889
|
+
* scrolls to it (#94). What an embed draws is a note the host loads, which no estimate can know; it
|
|
890
|
+
* is asked for as the editor opens, and once measured the blocks are made again with the height.
|
|
891
|
+
*/
|
|
892
|
+
const embedPremeasure = ViewPlugin.define((view) => {
|
|
893
|
+
const asked = new Set();
|
|
894
|
+
const pending = new Set();
|
|
895
|
+
const premeasure = (source, identity, metrics) => {
|
|
896
|
+
const { host } = view.state.facet(editorEnvironment);
|
|
897
|
+
const container = document.createElement('div');
|
|
898
|
+
container.className = 'cm-ms-block cm-ms-premeasure';
|
|
899
|
+
container.setAttribute('aria-hidden', 'true');
|
|
900
|
+
container.style.cssText = `position: absolute; top: 0; left: 0; width: ${metrics.width}px; visibility: hidden; pointer-events: none; contain: layout style`;
|
|
901
|
+
// Outside the text, it is given the text's own face and line, which a drawn block inherits.
|
|
902
|
+
const text = getComputedStyle(view.contentDOM);
|
|
903
|
+
container.style.fontFamily = text.fontFamily;
|
|
904
|
+
container.style.fontSize = text.fontSize;
|
|
905
|
+
container.style.lineHeight = text.lineHeight;
|
|
906
|
+
(view.dom.parentElement ?? view.dom).appendChild(container);
|
|
907
|
+
const root = createRoot(container);
|
|
908
|
+
root.render(createElement(HostProvider, { value: host }, createElement(BlockBoundary, { host, source }, createElement(Snippet, { text: source }))));
|
|
909
|
+
let finished = false;
|
|
910
|
+
const finish = (height) => {
|
|
911
|
+
if (finished)
|
|
912
|
+
return;
|
|
913
|
+
finished = true;
|
|
914
|
+
pending.delete(stop);
|
|
915
|
+
clearTimeout(timer);
|
|
916
|
+
observer?.disconnect();
|
|
917
|
+
queueMicrotask(() => {
|
|
918
|
+
root.unmount();
|
|
919
|
+
container.remove();
|
|
920
|
+
});
|
|
921
|
+
// Remembered at the width it was drawn at; at another one it says nothing.
|
|
922
|
+
const now = view.state.field(metricsField);
|
|
923
|
+
if (height === null || Math.round(now.width) !== Math.round(metrics.width))
|
|
924
|
+
return;
|
|
925
|
+
rememberHeight(identity, now, height);
|
|
926
|
+
view.dispatch({ effects: heightsLearned.of(null) });
|
|
927
|
+
};
|
|
928
|
+
const stop = () => finish(null);
|
|
929
|
+
pending.add(stop);
|
|
930
|
+
// Drawn once the note arrived: no skeleton left, and a frame later, laid out.
|
|
931
|
+
const check = () => {
|
|
932
|
+
if (finished || !container.firstElementChild || container.querySelector('.embed-skeleton'))
|
|
933
|
+
return;
|
|
934
|
+
requestAnimationFrame(() => {
|
|
935
|
+
if (!finished && !container.querySelector('.embed-skeleton'))
|
|
936
|
+
finish(container.getBoundingClientRect().height);
|
|
937
|
+
});
|
|
938
|
+
};
|
|
939
|
+
const observer = typeof MutationObserver === 'undefined' ? null : new MutationObserver(check);
|
|
940
|
+
observer?.observe(container, { childList: true, subtree: true });
|
|
941
|
+
const timer = setTimeout(stop, PREMEASURE_TIMEOUT);
|
|
942
|
+
};
|
|
943
|
+
const run = () => {
|
|
944
|
+
const { host } = view.state.facet(editorEnvironment);
|
|
945
|
+
if (!host.loadNote || typeof requestAnimationFrame === 'undefined')
|
|
946
|
+
return;
|
|
947
|
+
const metrics = view.state.field(metricsField);
|
|
948
|
+
const doc = view.state.doc;
|
|
949
|
+
for (const construct of view.state.field(analysisField).constructs) {
|
|
950
|
+
if (construct.kind !== 'embed' || host.keeps(construct.target) || host.reaches(construct.target) !== 1)
|
|
951
|
+
continue;
|
|
952
|
+
const end = construct.to > construct.from && doc.sliceString(construct.to - 1, construct.to) === '\n' ? construct.to - 1 : construct.to;
|
|
953
|
+
const source = doc.sliceString(doc.lineAt(construct.from).from, doc.lineAt(end).to);
|
|
954
|
+
const identity = `embed\u0000${source}`;
|
|
955
|
+
const key = `${Math.round(metrics.width)}\u0000${identity}`;
|
|
956
|
+
if (asked.has(key) || measuredHeight(identity, metrics) !== undefined)
|
|
957
|
+
continue;
|
|
958
|
+
asked.add(key);
|
|
959
|
+
premeasure(source, identity, metrics);
|
|
960
|
+
}
|
|
961
|
+
};
|
|
962
|
+
// Not inside the update that made the plugin, where CodeMirror takes no transaction.
|
|
963
|
+
queueMicrotask(run);
|
|
964
|
+
return {
|
|
965
|
+
update: (update) => {
|
|
966
|
+
if (update.docChanged || update.startState.field(metricsField) !== update.state.field(metricsField))
|
|
967
|
+
queueMicrotask(run);
|
|
968
|
+
},
|
|
969
|
+
destroy: () => {
|
|
970
|
+
for (const stop of [...pending])
|
|
971
|
+
stop();
|
|
972
|
+
},
|
|
973
|
+
};
|
|
974
|
+
});
|
|
631
975
|
/**
|
|
632
976
|
* Fetches the grammar of every code block that names one not registered yet, and has the page
|
|
633
977
|
* drawn again once it arrives, its tokens coloured (#84).
|
|
@@ -660,15 +1004,22 @@ const grammarLoader = ViewPlugin.fromClass(class {
|
|
|
660
1004
|
}
|
|
661
1005
|
}
|
|
662
1006
|
});
|
|
663
|
-
/**
|
|
664
|
-
|
|
1007
|
+
/**
|
|
1008
|
+
* The live page: the analysis, the decorations over it, and what the focus and a modifier-click do.
|
|
1009
|
+
* `metrics` are the editor's text column measured before the editor is made, which the first drawn
|
|
1010
|
+
* blocks estimate their height by; without them they assume `DEFAULT_METRICS` until it is measured.
|
|
1011
|
+
*/
|
|
1012
|
+
export function livePage(metrics) {
|
|
665
1013
|
return [
|
|
666
1014
|
analysisField,
|
|
1015
|
+
metricsField.init(() => metrics ?? DEFAULT_METRICS),
|
|
1016
|
+
metricsWatcher,
|
|
667
1017
|
focusField,
|
|
668
1018
|
decorationsField,
|
|
669
1019
|
EditorView.focusChangeEffect.of((_state, focusing) => setFocused.of(focusing)),
|
|
670
1020
|
focusKeeper,
|
|
671
1021
|
grammarLoader,
|
|
1022
|
+
embedPremeasure,
|
|
672
1023
|
blockKeys,
|
|
673
1024
|
follow,
|
|
674
1025
|
];
|