@memorysmithapp/markdown-renderer 0.6.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 +1 -1
- package/dist/NoteActions.d.ts +21 -2
- package/dist/NoteActions.js +14 -7
- package/dist/NoteBody.d.ts +15 -3
- package/dist/NoteBody.js +9 -8
- package/dist/edit/NoteEditor.d.ts +7 -1
- package/dist/edit/NoteEditor.js +3 -3
- package/dist/edit/blocks.d.ts +12 -0
- package/dist/edit/blocks.js +106 -0
- package/dist/edit/heights.d.ts +35 -1
- package/dist/edit/heights.js +64 -3
- package/dist/edit/image-size.d.ts +36 -0
- package/dist/edit/image-size.js +61 -0
- package/dist/edit/live.d.ts +2 -0
- package/dist/edit/live.js +298 -70
- package/dist/host.d.ts +3 -1
- 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 +6 -0
- package/dist/strings.js +3 -0
- package/dist/theme.d.ts +7 -2
- package/dist/theme.js +1 -0
- package/package.json +2 -2
- package/structure.css +56 -2
- package/theme-default.css +51 -2
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The size an image is drawn at, as its notation writes it (#98): the dimensions after the pipe
|
|
3
|
+
* of a Markdown image's description, `` (`SPEC.md` §3.14), or of the
|
|
4
|
+
* embed of a file the notebook keeps, `![[photo.jpg|300]]` (§5.7). The editor reads them and
|
|
5
|
+
* rewrites them, and no other byte.
|
|
6
|
+
*/
|
|
7
|
+
import { readDimensions, readImageAlt } from '../image-dimensions.js';
|
|
8
|
+
/** The largest width the notation writes: five digits (`SPEC.md` §3.14). */
|
|
9
|
+
export const MAX_IMAGE_WIDTH = 99999;
|
|
10
|
+
const MARKDOWN_IMAGE = /^!\[([^\]]*)\]\(/;
|
|
11
|
+
const EMBED = /^!\[\[([^\]]*)\]\]$/;
|
|
12
|
+
/**
|
|
13
|
+
* The size an image's source declares, and where it is written. `null` for a source that is not an
|
|
14
|
+
* image's notation, and for an embed whose pipe holds something other than dimensions, which the
|
|
15
|
+
* editor does not overwrite.
|
|
16
|
+
*/
|
|
17
|
+
export function readImageSize(source) {
|
|
18
|
+
// A block's line may be indented, or end in spaces, around its one embed.
|
|
19
|
+
const lead = source.length - source.trimStart().length;
|
|
20
|
+
const size = readTrimmed(source.trim());
|
|
21
|
+
return size && lead ? { ...size, at: { from: size.at.from + lead, to: size.at.to + lead } } : size;
|
|
22
|
+
}
|
|
23
|
+
function readTrimmed(source) {
|
|
24
|
+
const image = MARKDOWN_IMAGE.exec(source);
|
|
25
|
+
if (image) {
|
|
26
|
+
const alt = image[1] ?? '';
|
|
27
|
+
const start = 2;
|
|
28
|
+
const read = readImageAlt(alt);
|
|
29
|
+
if (read.width === null)
|
|
30
|
+
return { width: null, height: null, at: { from: start + alt.length, to: start + alt.length }, pipe: '|' };
|
|
31
|
+
return { width: read.width, height: read.height, at: { from: start + alt.lastIndexOf('|'), to: start + alt.length }, pipe: '|' };
|
|
32
|
+
}
|
|
33
|
+
const embed = EMBED.exec(source);
|
|
34
|
+
if (embed) {
|
|
35
|
+
const inner = embed[1] ?? '';
|
|
36
|
+
const start = 3;
|
|
37
|
+
const pipe = /\\?\|/.exec(inner);
|
|
38
|
+
if (!pipe)
|
|
39
|
+
return { width: null, height: null, at: { from: start + inner.length, to: start + inner.length }, pipe: '|' };
|
|
40
|
+
const measured = readDimensions(inner.slice(pipe.index + pipe[0].length));
|
|
41
|
+
if (!measured)
|
|
42
|
+
return null;
|
|
43
|
+
return { width: measured.width, height: measured.height, at: { from: start + pipe.index, to: start + inner.length }, pipe: pipe[0] };
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The change that draws the image whose source starts at `from` at `width`, its height following,
|
|
49
|
+
* or at its own size for `null`: the dimensions written, replaced or taken away, and nothing else.
|
|
50
|
+
* Empty when the source is not an image's notation or already says so.
|
|
51
|
+
*/
|
|
52
|
+
export function resizeImage(source, from, width) {
|
|
53
|
+
const size = readImageSize(source);
|
|
54
|
+
if (!size)
|
|
55
|
+
return [];
|
|
56
|
+
const wanted = width === null ? null : Math.min(MAX_IMAGE_WIDTH, Math.max(1, Math.round(width)));
|
|
57
|
+
if (wanted === size.width && (wanted === null || size.height === null))
|
|
58
|
+
return [];
|
|
59
|
+
const insert = wanted === null ? '' : `${size.pipe}${wanted}`;
|
|
60
|
+
return [{ from: from + size.at.from, to: from + size.at.to, insert }];
|
|
61
|
+
}
|
package/dist/edit/live.d.ts
CHANGED
|
@@ -24,6 +24,8 @@ export declare const analysisField: StateField<Analysis>;
|
|
|
24
24
|
export declare const setFocused: import("@codemirror/state").StateEffectType<boolean>;
|
|
25
25
|
/** A grammar a code block names has arrived: its tokens can be coloured now (#84). */
|
|
26
26
|
export declare const grammarArrived: import("@codemirror/state").StateEffectType<string>;
|
|
27
|
+
/** The metrics of the editor's text column, which a drawn block estimates its height by (#91). */
|
|
28
|
+
export declare const metricsField: StateField<BlockMetrics>;
|
|
27
29
|
export declare const decorationsField: StateField<DecorationSet>;
|
|
28
30
|
/**
|
|
29
31
|
* The live page: the analysis, the decorations over it, and what the focus and a modifier-click do.
|
package/dist/edit/live.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
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 { Component, 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';
|
|
@@ -17,8 +17,8 @@ import { HostProvider, resolveHost } from '../host.js';
|
|
|
17
17
|
import { readImageAlt } from '../image-dimensions.js';
|
|
18
18
|
import { highlightLanguage } from '../highlight.js';
|
|
19
19
|
import { analyze } from './analyze.js';
|
|
20
|
-
import { DEFAULT_METRICS, estimateHeight, measuredHeight, rememberHeight, withinReason } from './heights.js';
|
|
21
|
-
import { AddProperties, CalloutBlock, PropertiesBlock, reveal, Snippet, TableBlock } from './blocks.js';
|
|
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';
|
|
22
22
|
import { LanguageControl } from './LanguageControl.js';
|
|
23
23
|
import { toggleTaskBox } from './commands.js';
|
|
24
24
|
export const editorEnvironment = Facet.define({
|
|
@@ -39,8 +39,10 @@ export const setFocused = StateEffect.define();
|
|
|
39
39
|
export const grammarArrived = StateEffect.define();
|
|
40
40
|
/** The editor's metrics changed: the blocks made from now on estimate their height by them (#91). */
|
|
41
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();
|
|
42
44
|
/** The metrics of the editor's text column, which a drawn block estimates its height by (#91). */
|
|
43
|
-
const metricsField = StateField.define({
|
|
45
|
+
export const metricsField = StateField.define({
|
|
44
46
|
create: () => DEFAULT_METRICS,
|
|
45
47
|
update: (metrics, transaction) => {
|
|
46
48
|
for (const effect of transaction.effects)
|
|
@@ -53,10 +55,19 @@ const metricsField = StateField.define({
|
|
|
53
55
|
const metricsWatcher = ViewPlugin.define((view) => {
|
|
54
56
|
let destroyed = false;
|
|
55
57
|
const check = () => view.requestMeasure({
|
|
56
|
-
read: () => withinReason({
|
|
58
|
+
read: () => withinReason({
|
|
59
|
+
width: view.contentDOM.clientWidth,
|
|
60
|
+
lineHeight: view.defaultLineHeight,
|
|
61
|
+
charWidth: view.defaultCharacterWidth,
|
|
62
|
+
...measureLines(view.dom),
|
|
63
|
+
}),
|
|
57
64
|
write: (next) => {
|
|
58
65
|
const now = view.state.field(metricsField);
|
|
59
|
-
const
|
|
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);
|
|
60
71
|
// Not inside the measure, where CodeMirror takes no transaction.
|
|
61
72
|
if (moved)
|
|
62
73
|
queueMicrotask(() => destroyed || view.dispatch({ effects: setBlockMetrics.of(next) }));
|
|
@@ -109,22 +120,70 @@ class BlockBoundary extends Component {
|
|
|
109
120
|
}
|
|
110
121
|
/** The React roots the widgets mounted, unmounted when CodeMirror lets the widget go. */
|
|
111
122
|
const roots = new WeakMap();
|
|
112
|
-
|
|
113
|
-
|
|
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) {
|
|
114
139
|
const root = createRoot(element);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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);
|
|
118
168
|
// A block whose content arrives later — an embed, a diagram — changes its height after it is
|
|
119
169
|
// measured; the editor is told so, or the lines below it would be placed by the old height.
|
|
120
170
|
const observer = typeof ResizeObserver === 'undefined'
|
|
121
171
|
? null
|
|
122
172
|
: new ResizeObserver(() => {
|
|
123
|
-
|
|
173
|
+
check();
|
|
174
|
+
if (!holding)
|
|
175
|
+
measured(element.getBoundingClientRect().height);
|
|
124
176
|
view.requestMeasure();
|
|
125
177
|
});
|
|
126
178
|
observer?.observe(element);
|
|
127
|
-
roots.set(element, {
|
|
179
|
+
roots.set(element, {
|
|
180
|
+
unmount: () => {
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
root.unmount();
|
|
183
|
+
},
|
|
184
|
+
observer,
|
|
185
|
+
render,
|
|
186
|
+
});
|
|
128
187
|
}
|
|
129
188
|
function unmount(element) {
|
|
130
189
|
const mounted = roots.get(element);
|
|
@@ -155,29 +214,57 @@ class BlockWidget extends WidgetType {
|
|
|
155
214
|
this.options = options;
|
|
156
215
|
this.inline = options.inline ?? false;
|
|
157
216
|
this.source = options.source ?? '';
|
|
217
|
+
this.estimate = this.estimateNow();
|
|
158
218
|
}
|
|
159
219
|
/** What it is remembered by once measured: what it draws, wherever in the note it stands. */
|
|
160
220
|
get identity() {
|
|
161
221
|
return `${this.options.kind ?? 'block'}\u0000${this.source}`;
|
|
162
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;
|
|
163
229
|
eq(other) {
|
|
164
|
-
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;
|
|
165
246
|
}
|
|
166
247
|
toDOM(view) {
|
|
167
248
|
const element = document.createElement(this.inline ? 'span' : 'div');
|
|
168
249
|
element.className = this.inline ? 'cm-ms-inline-block' : 'cm-ms-block';
|
|
169
|
-
|
|
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);
|
|
170
256
|
element.addEventListener('mousedown', (event) => {
|
|
171
257
|
const target = event.target;
|
|
172
258
|
if (target.closest('[data-ms-control]'))
|
|
173
259
|
return;
|
|
174
260
|
if (event.metaKey || event.ctrlKey)
|
|
175
261
|
return;
|
|
176
|
-
|
|
262
|
+
const widget = drawing.get(element) ?? this;
|
|
263
|
+
if (!widget.revealsOnClick)
|
|
177
264
|
return;
|
|
178
265
|
event.preventDefault();
|
|
179
266
|
// At the end of its first line: after a fence's language, a callout's title, a formula's `$$`.
|
|
180
|
-
reveal(view, view.state.doc.lineAt(blockStart(view, element,
|
|
267
|
+
reveal(view, view.state.doc.lineAt(blockStart(view, element, widget.key)).to);
|
|
181
268
|
});
|
|
182
269
|
// A link inside a drawn block is followed with a modifier only, as in the text.
|
|
183
270
|
element.addEventListener('click', (event) => {
|
|
@@ -198,10 +285,14 @@ class BlockWidget extends WidgetType {
|
|
|
198
285
|
* block is made: the height it was drawn at before at this width, or its estimate (#91).
|
|
199
286
|
*/
|
|
200
287
|
get estimatedHeight() {
|
|
201
|
-
|
|
202
|
-
|
|
288
|
+
return this.estimate;
|
|
289
|
+
}
|
|
290
|
+
estimateNow() {
|
|
203
291
|
const metrics = this.options.metrics ?? DEFAULT_METRICS;
|
|
204
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;
|
|
205
296
|
return measuredHeight(this.identity, metrics) ?? (kind ? estimateHeight(kind, this.source, metrics) : 2 * metrics.lineHeight);
|
|
206
297
|
}
|
|
207
298
|
}
|
|
@@ -222,6 +313,29 @@ function blockStart(view, element, key) {
|
|
|
222
313
|
});
|
|
223
314
|
return found === -1 ? near : found;
|
|
224
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
|
+
}
|
|
225
339
|
class BulletWidget extends WidgetType {
|
|
226
340
|
eq() {
|
|
227
341
|
return true;
|
|
@@ -306,52 +420,15 @@ class MathWidget extends WidgetType {
|
|
|
306
420
|
return false;
|
|
307
421
|
}
|
|
308
422
|
}
|
|
309
|
-
/**
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
match;
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
this.match = /^!\[([^\]]*)\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)$/.exec(source);
|
|
319
|
-
this.alt = readImageAlt(this.match?.[1] ?? '');
|
|
320
|
-
}
|
|
321
|
-
eq(other) {
|
|
322
|
-
return other.source === this.source;
|
|
323
|
-
}
|
|
324
|
-
toDOM() {
|
|
325
|
-
const match = this.match;
|
|
326
|
-
const address = match ? followable(match[2] ?? '') : '';
|
|
327
|
-
if (!match || !address) {
|
|
328
|
-
const text = document.createElement('span');
|
|
329
|
-
text.className = 'cm-ms-raw';
|
|
330
|
-
text.textContent = this.source;
|
|
331
|
-
return text;
|
|
332
|
-
}
|
|
333
|
-
const image = document.createElement('img');
|
|
334
|
-
image.className = 'cm-ms-image';
|
|
335
|
-
image.src = address;
|
|
336
|
-
// Its description and the dimensions it declares, as the page draws it (`SPEC.md` §3.14).
|
|
337
|
-
image.alt = this.alt.description;
|
|
338
|
-
if (this.alt.width !== null)
|
|
339
|
-
image.width = this.alt.width;
|
|
340
|
-
if (this.alt.height !== null)
|
|
341
|
-
image.height = this.alt.height;
|
|
342
|
-
return image;
|
|
343
|
-
}
|
|
344
|
-
/** The line it stands in is as tall as the image: the height it declares, or a guess (#91). */
|
|
345
|
-
get estimatedHeight() {
|
|
346
|
-
if (!this.match)
|
|
347
|
-
return -1;
|
|
348
|
-
if (this.alt.height !== null)
|
|
349
|
-
return this.alt.height;
|
|
350
|
-
return this.alt.width !== null ? Math.round(this.alt.width * 0.75) : UNDECLARED_IMAGE_HEIGHT;
|
|
351
|
-
}
|
|
352
|
-
ignoreEvent() {
|
|
353
|
-
return false;
|
|
354
|
-
}
|
|
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 });
|
|
355
432
|
}
|
|
356
433
|
// ── Decorations ──────────────────────────────────────────────────────────────
|
|
357
434
|
const hide = Decoration.replace({});
|
|
@@ -370,6 +447,8 @@ function build(state) {
|
|
|
370
447
|
const ranges = state.selection.ranges;
|
|
371
448
|
const out = [];
|
|
372
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();
|
|
373
452
|
/** Whether the author's cursor or selection is on this range, ends included. */
|
|
374
453
|
const touches = (from, to) => focused && ranges.some((range) => range.from <= to && range.to >= from);
|
|
375
454
|
/** The whole lines a construct takes, its last line break left out. */
|
|
@@ -449,6 +528,9 @@ function build(state) {
|
|
|
449
528
|
case 'embed':
|
|
450
529
|
if (open)
|
|
451
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 })));
|
|
452
534
|
else
|
|
453
535
|
block(from, to, 'embed', `embed:${source}`, () => createElement(Snippet, { text: source }));
|
|
454
536
|
break;
|
|
@@ -495,6 +577,7 @@ function build(state) {
|
|
|
495
577
|
case 'heading': {
|
|
496
578
|
out.push(line(`cm-ms-heading cm-ms-h${construct.level}`).range(doc.lineAt(from).from));
|
|
497
579
|
const { marker } = construct;
|
|
580
|
+
headingLevels.set(doc.lineAt(from).number, construct.level);
|
|
498
581
|
if (touches(doc.lineAt(from).from, doc.lineAt(from).to))
|
|
499
582
|
out.push(syntax.range(marker.from, marker.to));
|
|
500
583
|
else
|
|
@@ -537,7 +620,10 @@ function build(state) {
|
|
|
537
620
|
const pending = host.reaches(construct.target) === 0 && !host.keeps(construct.target);
|
|
538
621
|
if (construct.embed && host.keeps(construct.target) && !touches(from, to)) {
|
|
539
622
|
const source = text.slice(from, to);
|
|
540
|
-
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));
|
|
541
627
|
break;
|
|
542
628
|
}
|
|
543
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));
|
|
@@ -551,10 +637,21 @@ function build(state) {
|
|
|
551
637
|
}
|
|
552
638
|
break;
|
|
553
639
|
}
|
|
554
|
-
case 'image':
|
|
555
|
-
if (
|
|
556
|
-
|
|
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
|
+
}
|
|
557
653
|
break;
|
|
654
|
+
}
|
|
558
655
|
case 'inlineMath':
|
|
559
656
|
if (touches(from, to))
|
|
560
657
|
out.push(mark('cm-ms-math-source').range(from, to));
|
|
@@ -613,8 +710,47 @@ function build(state) {
|
|
|
613
710
|
break;
|
|
614
711
|
}
|
|
615
712
|
}
|
|
713
|
+
out.push(...lineEstimates(state, out, covered, headingLevels, metrics));
|
|
616
714
|
return Decoration.set(out, true);
|
|
617
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
|
+
}
|
|
618
754
|
/** A code block's lines, as source: its frame, its fences dimmed, its tokens coloured. */
|
|
619
755
|
function codeLines(construct, out, doc, text) {
|
|
620
756
|
const first = doc.lineAt(construct.from).number;
|
|
@@ -653,7 +789,7 @@ export const decorationsField = StateField.define({
|
|
|
653
789
|
create: build,
|
|
654
790
|
update: (decorations, transaction) => transaction.docChanged ||
|
|
655
791
|
transaction.selection ||
|
|
656
|
-
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)) ||
|
|
657
793
|
transaction.startState.facet(editorEnvironment) !== transaction.state.facet(editorEnvironment)
|
|
658
794
|
? build(transaction.state)
|
|
659
795
|
: decorations,
|
|
@@ -745,6 +881,97 @@ const follow = EditorView.domEventHandlers({
|
|
|
745
881
|
return true;
|
|
746
882
|
},
|
|
747
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
|
+
});
|
|
748
975
|
/**
|
|
749
976
|
* Fetches the grammar of every code block that names one not registered yet, and has the page
|
|
750
977
|
* drawn again once it arrives, its tokens coloured (#84).
|
|
@@ -792,6 +1019,7 @@ export function livePage(metrics) {
|
|
|
792
1019
|
EditorView.focusChangeEffect.of((_state, focusing) => setFocused.of(focusing)),
|
|
793
1020
|
focusKeeper,
|
|
794
1021
|
grammarLoader,
|
|
1022
|
+
embedPremeasure,
|
|
795
1023
|
blockKeys,
|
|
796
1024
|
follow,
|
|
797
1025
|
];
|
package/dist/host.d.ts
CHANGED
|
@@ -38,7 +38,9 @@ export interface NoteHost {
|
|
|
38
38
|
/**
|
|
39
39
|
* The note a target names, for an embed to draw in place: its body as the host keeps it, its name
|
|
40
40
|
* and its address. Asked only for a target exactly one note reaches; `null` draws the embed as
|
|
41
|
-
* pending. Without it, every embed of a note is pending. The package never fetches.
|
|
41
|
+
* pending. Without it, every embed of a note is pending. The package never fetches. In edit mode
|
|
42
|
+
* each embedded note is also asked for as the editor opens, to lay the page out at its height
|
|
43
|
+
* before it is reached (#94): a host that loads from the network may cache what it answered.
|
|
42
44
|
*/
|
|
43
45
|
readonly loadNote?: (name: string) => Promise<EmbeddedNote | null>;
|
|
44
46
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { NoteBody, type NoteBodyProps, type NoteMode } from './NoteBody.js';
|
|
2
|
+
export type { NoteButtons } from './NoteActions.js';
|
|
2
3
|
export type { SaveAction, SaveNotice, SaveOutcome, SaveRefusal } from './edit/saves.js';
|
|
3
4
|
export { isAttachRefusal } from './host.js';
|
|
4
5
|
export type { AttachAttributes, AttachmentRequest, AttachProgress, AttachRefusal, EmbeddedNote, KeptFile, KeptFileEntry, LinkChoiceNote, LinkChoices, LinkRequest, NoteHost } from './host.js';
|
package/dist/mode-icons.d.ts
CHANGED
|
@@ -4,3 +4,4 @@ export declare const ICON_MARKDOWN: import("react").JSX.Element;
|
|
|
4
4
|
export declare const ICON_COPY: import("react").JSX.Element;
|
|
5
5
|
export declare const ICON_CHECK: import("react").JSX.Element;
|
|
6
6
|
export declare const ICON_CLOSE: import("react").JSX.Element;
|
|
7
|
+
export declare const ICON_OWN_SIZE: import("react").JSX.Element;
|
package/dist/mode-icons.js
CHANGED
|
@@ -9,3 +9,5 @@ export const ICON_MARKDOWN = svg('M10 12.5 8 15l2 2.5', 'm14 12.5 2 2.5-2 2.5',
|
|
|
9
9
|
export const ICON_COPY = svg('M10 8h10a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2z', 'M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2');
|
|
10
10
|
export const ICON_CHECK = svg('M20 6 9 17l-5-5');
|
|
11
11
|
export const ICON_CLOSE = svg('M18 6 6 18', 'm6 6 12 12');
|
|
12
|
+
// The button that draws an image at its own size (#98): Lucide's scaling (ISC).
|
|
13
|
+
export const ICON_OWN_SIZE = svg('M21 3 9 15', 'M12 3H3v18h18v-9', 'M16 3h5v5', 'M14 15H9v-5');
|
package/dist/strings.d.ts
CHANGED
|
@@ -158,6 +158,12 @@ export interface EditorWords {
|
|
|
158
158
|
readonly addItem: string;
|
|
159
159
|
readonly removeItem: string;
|
|
160
160
|
readonly calloutType: string;
|
|
161
|
+
/** The field of an image's width in edit mode, in pixels (#98). */
|
|
162
|
+
readonly imageWidth: string;
|
|
163
|
+
/** The button that draws an image at its own size again (#98). */
|
|
164
|
+
readonly imageOwnSize: string;
|
|
165
|
+
/** The handle at an image's corner that drags its size (#98). */
|
|
166
|
+
readonly imageResize: string;
|
|
161
167
|
readonly fold: string;
|
|
162
168
|
readonly foldNone: string;
|
|
163
169
|
readonly foldOpen: string;
|
package/dist/strings.js
CHANGED
|
@@ -156,6 +156,9 @@ export const ENGLISH = {
|
|
|
156
156
|
addItem: 'Add',
|
|
157
157
|
removeItem: 'Remove',
|
|
158
158
|
calloutType: 'Type',
|
|
159
|
+
imageWidth: 'Width, in pixels',
|
|
160
|
+
imageOwnSize: 'Its own size',
|
|
161
|
+
imageResize: 'Drag to resize',
|
|
159
162
|
fold: 'Folding',
|
|
160
163
|
foldNone: 'Not foldable',
|
|
161
164
|
foldOpen: 'Folds, open',
|