@memorysmithapp/markdown-renderer 0.6.0 → 0.7.1

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/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,9 @@ 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
+ import { caretAt, sourceOffsetOf } from './click-source.js';
22
23
  import { LanguageControl } from './LanguageControl.js';
23
24
  import { toggleTaskBox } from './commands.js';
24
25
  export const editorEnvironment = Facet.define({
@@ -39,8 +40,10 @@ export const setFocused = StateEffect.define();
39
40
  export const grammarArrived = StateEffect.define();
40
41
  /** The editor's metrics changed: the blocks made from now on estimate their height by them (#91). */
41
42
  const setBlockMetrics = StateEffect.define();
43
+ /** A block's height was learned before it was drawn: the blocks are made again with it (#94). */
44
+ const heightsLearned = StateEffect.define();
42
45
  /** The metrics of the editor's text column, which a drawn block estimates its height by (#91). */
43
- const metricsField = StateField.define({
46
+ export const metricsField = StateField.define({
44
47
  create: () => DEFAULT_METRICS,
45
48
  update: (metrics, transaction) => {
46
49
  for (const effect of transaction.effects)
@@ -53,10 +56,19 @@ const metricsField = StateField.define({
53
56
  const metricsWatcher = ViewPlugin.define((view) => {
54
57
  let destroyed = false;
55
58
  const check = () => view.requestMeasure({
56
- read: () => withinReason({ width: view.contentDOM.clientWidth, lineHeight: view.defaultLineHeight, charWidth: view.defaultCharacterWidth }),
59
+ read: () => withinReason({
60
+ width: view.contentDOM.clientWidth,
61
+ lineHeight: view.defaultLineHeight,
62
+ charWidth: view.defaultCharacterWidth,
63
+ ...measureLines(view.dom),
64
+ }),
57
65
  write: (next) => {
58
66
  const now = view.state.field(metricsField);
59
- const moved = Math.abs(next.width - now.width) >= 1 || Math.abs(next.lineHeight - now.lineHeight) >= 0.5 || Math.abs(next.charWidth - now.charWidth) >= 0.05;
67
+ 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();
68
+ const moved = Math.abs(next.width - now.width) >= 1 ||
69
+ Math.abs(next.lineHeight - now.lineHeight) >= 0.5 ||
70
+ Math.abs(next.charWidth - now.charWidth) >= 0.05 ||
71
+ lines(next) !== lines(now);
60
72
  // Not inside the measure, where CodeMirror takes no transaction.
61
73
  if (moved)
62
74
  queueMicrotask(() => destroyed || view.dispatch({ effects: setBlockMetrics.of(next) }));
@@ -109,22 +121,70 @@ class BlockBoundary extends Component {
109
121
  }
110
122
  /** The React roots the widgets mounted, unmounted when CodeMirror lets the widget go. */
111
123
  const roots = new WeakMap();
112
- function mount(view, element, node, source, measured) {
113
- const { host } = view.state.facet(editorEnvironment);
124
+ /** The widget an element draws now: one made again for the same block takes its element over (#99). */
125
+ const drawing = new WeakMap();
126
+ /** How long a block made again holds the height it was drawn at, at most, while it draws (#99). */
127
+ const HOLD_MS = 2000;
128
+ /** Tells its block it was drawn, once React has put it in the page. */
129
+ function Drawn({ onDrawn, children }) {
130
+ useLayoutEffect(onDrawn);
131
+ return createElement(Fragment, null, children);
132
+ }
133
+ /**
134
+ * Draws `node` in `element` with React, under the editor's host. `held` is the height the block
135
+ * was drawn at before, which the element keeps until what it draws is as tall — the root draws on
136
+ * React's schedule, and an element left empty meanwhile would let the page collapse under the
137
+ * author (#99).
138
+ */
139
+ function mount(view, element, node, source, identity, measured, held) {
114
140
  const root = createRoot(element);
115
- // Drawn on React's own schedule: CodeMirror builds its widgets inside its update, which may run
116
- // inside a React commit, where React cannot be asked to render synchronously.
117
- root.render(createElement(HostProvider, { value: host }, createElement(BlockBoundary, { host, source }, node)));
141
+ let holding = held !== undefined && held > 0;
142
+ if (holding)
143
+ element.style.minHeight = `${held}px`;
144
+ const release = () => {
145
+ holding = false;
146
+ element.style.minHeight = '';
147
+ };
148
+ const timer = holding ? setTimeout(release, HOLD_MS) : undefined;
149
+ // Released once what it draws fills the height it holds: the content's own box, margins aside.
150
+ const check = () => {
151
+ if (!holding || typeof document.createRange !== 'function')
152
+ return;
153
+ const range = document.createRange();
154
+ range.selectNodeContents(element);
155
+ if (range.getBoundingClientRect().height >= held * 0.9) {
156
+ clearTimeout(timer);
157
+ release();
158
+ }
159
+ };
160
+ const render = (next, text, drawn) => {
161
+ const { host } = view.state.facet(editorEnvironment);
162
+ // Drawn on React's own schedule: CodeMirror builds its widgets inside its update, which may run
163
+ // inside a React commit, where React cannot be asked to render synchronously. Keyed by what it
164
+ // draws: the same block is drawn again in place, another one replaces it at once on commit,
165
+ // what was drawn before staying until then (#99).
166
+ root.render(createElement(HostProvider, { value: host }, createElement(BlockBoundary, { key: drawn, host, source: text }, createElement(Drawn, { onDrawn: check }, next))));
167
+ };
168
+ render(node, source, identity);
118
169
  // A block whose content arrives later — an embed, a diagram — changes its height after it is
119
170
  // measured; the editor is told so, or the lines below it would be placed by the old height.
120
171
  const observer = typeof ResizeObserver === 'undefined'
121
172
  ? null
122
173
  : new ResizeObserver(() => {
123
- measured(element.getBoundingClientRect().height);
174
+ check();
175
+ if (!holding)
176
+ measured(element.getBoundingClientRect().height);
124
177
  view.requestMeasure();
125
178
  });
126
179
  observer?.observe(element);
127
- roots.set(element, { unmount: () => root.unmount(), observer });
180
+ roots.set(element, {
181
+ unmount: () => {
182
+ clearTimeout(timer);
183
+ root.unmount();
184
+ },
185
+ observer,
186
+ render,
187
+ });
128
188
  }
129
189
  function unmount(element) {
130
190
  const mounted = roots.get(element);
@@ -155,29 +215,61 @@ class BlockWidget extends WidgetType {
155
215
  this.options = options;
156
216
  this.inline = options.inline ?? false;
157
217
  this.source = options.source ?? '';
218
+ this.estimate = this.estimateNow();
158
219
  }
159
220
  /** What it is remembered by once measured: what it draws, wherever in the note it stands. */
160
221
  get identity() {
161
222
  return `${this.options.kind ?? 'block'}\u0000${this.source}`;
162
223
  }
224
+ /**
225
+ * The height it is laid out at until drawn, read when it is made: a block whose height was learned
226
+ * since — measured, or measured ahead of being drawn (#94) — is made again with it, so CodeMirror
227
+ * reads the new one, and keeps its drawing through `updateDOM`.
228
+ */
229
+ estimate;
163
230
  eq(other) {
164
- return other.key === this.key && other.host === this.host;
231
+ return other.key === this.key && other.host === this.host && other.estimate === this.estimate;
232
+ }
233
+ /**
234
+ * A block of the same kind made again — because it moved, the host was given again, its
235
+ * estimate was learned or its source changed — keeps its element: it is drawn again in it, with
236
+ * this widget's positions and the host as it is now (#94, #99). The same source keeps its React
237
+ * tree; another source replaces it on commit, so the element is never left empty.
238
+ */
239
+ updateDOM(dom, view, from) {
240
+ const mounted = roots.get(dom);
241
+ if (!mounted || from.inline !== this.inline || from.options.kind !== this.options.kind || from.revealsOnClick !== this.revealsOnClick)
242
+ return false;
243
+ drawing.set(dom, this);
244
+ if (from.key !== this.key || from.host !== this.host)
245
+ mounted.render(this.draw(view), this.source, this.identity);
246
+ return true;
165
247
  }
166
248
  toDOM(view) {
167
249
  const element = document.createElement(this.inline ? 'span' : 'div');
168
250
  element.className = this.inline ? 'cm-ms-inline-block' : 'cm-ms-block';
169
- mount(view, element, this.draw(view), this.source, (height) => rememberHeight(this.identity, view.state.field(metricsField), height));
251
+ drawing.set(element, this);
252
+ const metrics = view.state.field(metricsField);
253
+ const held = this.inline ? undefined : measuredHeight(this.identity, metrics);
254
+ // Remembered for what the element draws now, which a widget made again may have changed.
255
+ const measured = (height) => rememberHeight((drawing.get(element) ?? this).identity, view.state.field(metricsField), height);
256
+ mount(view, element, this.draw(view), this.source, this.identity, measured, held);
170
257
  element.addEventListener('mousedown', (event) => {
171
258
  const target = event.target;
172
259
  if (target.closest('[data-ms-control]'))
173
260
  return;
174
261
  if (event.metaKey || event.ctrlKey)
175
262
  return;
176
- if (!this.revealsOnClick)
263
+ const widget = drawing.get(element) ?? this;
264
+ if (!widget.revealsOnClick)
177
265
  return;
178
266
  event.preventDefault();
179
- // 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, this.key)).to);
267
+ // Where the text clicked is written in the source (#102); else at the end of its first line,
268
+ // after a callout's title or a diagram's fence.
269
+ const start = blockStart(view, element, widget.key);
270
+ const caret = caretAt(event.clientX, event.clientY);
271
+ const offset = caret ? sourceOffsetOf(element, widget.source, caret.node, caret.offset) : null;
272
+ reveal(view, offset === null ? view.state.doc.lineAt(start).to : Math.min(start + offset, view.state.doc.length));
181
273
  });
182
274
  // A link inside a drawn block is followed with a modifier only, as in the text.
183
275
  element.addEventListener('click', (event) => {
@@ -198,10 +290,14 @@ class BlockWidget extends WidgetType {
198
290
  * block is made: the height it was drawn at before at this width, or its estimate (#91).
199
291
  */
200
292
  get estimatedHeight() {
201
- if (this.inline)
202
- return -1;
293
+ return this.estimate;
294
+ }
295
+ estimateNow() {
203
296
  const metrics = this.options.metrics ?? DEFAULT_METRICS;
204
297
  const kind = this.options.kind;
298
+ // Within a line only an image estimates, as tall as the line it stands in (#91).
299
+ if (this.inline && !kind)
300
+ return -1;
205
301
  return measuredHeight(this.identity, metrics) ?? (kind ? estimateHeight(kind, this.source, metrics) : 2 * metrics.lineHeight);
206
302
  }
207
303
  }
@@ -222,6 +318,29 @@ function blockStart(view, element, key) {
222
318
  });
223
319
  return found === -1 ? near : found;
224
320
  }
321
+ /**
322
+ * Nothing drawn, at the start of a line CodeMirror would estimate too short — a heading, larger than
323
+ * the text and spaced from it — telling it how tall the line is until it is drawn (#94). CodeMirror
324
+ * lays a line out at the tallest of its widgets' estimates and its own, so this only ever raises it.
325
+ */
326
+ class LineEstimate extends WidgetType {
327
+ height;
328
+ constructor(height) {
329
+ super();
330
+ this.height = height;
331
+ }
332
+ eq(other) {
333
+ return other.height === this.height;
334
+ }
335
+ toDOM() {
336
+ const element = document.createElement('span');
337
+ element.className = 'cm-ms-line-estimate';
338
+ return element;
339
+ }
340
+ get estimatedHeight() {
341
+ return this.height;
342
+ }
343
+ }
225
344
  class BulletWidget extends WidgetType {
226
345
  eq() {
227
346
  return true;
@@ -306,52 +425,15 @@ class MathWidget extends WidgetType {
306
425
  return false;
307
426
  }
308
427
  }
309
- /** What an image declares no height for is drawn at before it loads, near enough (#91). */
310
- const UNDECLARED_IMAGE_HEIGHT = 200;
311
- class ImageWidget extends WidgetType {
312
- source;
313
- match;
314
- alt;
315
- constructor(source) {
316
- super();
317
- this.source = source;
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
- }
428
+ /** A Markdown image's description and address, when its address is one the page follows. */
429
+ function readImage(source) {
430
+ const match = /^!\[([^\]]*)\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)$/.exec(source);
431
+ const address = match ? followable(match[2] ?? '') : '';
432
+ return match && address ? { alt: readImageAlt(match[1] ?? ''), address } : null;
433
+ }
434
+ /** An image drawn with its description and the dimensions it declares, as the page draws it (`SPEC.md` §3.14). */
435
+ function drawImage({ alt, address }) {
436
+ return createElement('img', { className: 'cm-ms-image', src: address, alt: alt.description, width: alt.width ?? undefined, height: alt.height ?? undefined });
355
437
  }
356
438
  // ── Decorations ──────────────────────────────────────────────────────────────
357
439
  const hide = Decoration.replace({});
@@ -370,6 +452,8 @@ function build(state) {
370
452
  const ranges = state.selection.ranges;
371
453
  const out = [];
372
454
  const covered = [];
455
+ /** The level of each heading, by its line's number, which its estimate is made for (#94). */
456
+ const headingLevels = new Map();
373
457
  /** Whether the author's cursor or selection is on this range, ends included. */
374
458
  const touches = (from, to) => focused && ranges.some((range) => range.from <= to && range.to >= from);
375
459
  /** The whole lines a construct takes, its last line break left out. */
@@ -449,6 +533,9 @@ function build(state) {
449
533
  case 'embed':
450
534
  if (open)
451
535
  out.push(mark('cm-ms-wikilink').range(from, to));
536
+ // A kept image is sized from the image itself (#98); a note's embed draws no image of its own.
537
+ else if (host.keeps(construct.target))
538
+ block(from, to, 'embed', `embed:${source}`, (view) => createElement(ImageBlock, { view, source }, createElement(Snippet, { text: source })));
452
539
  else
453
540
  block(from, to, 'embed', `embed:${source}`, () => createElement(Snippet, { text: source }));
454
541
  break;
@@ -495,6 +582,7 @@ function build(state) {
495
582
  case 'heading': {
496
583
  out.push(line(`cm-ms-heading cm-ms-h${construct.level}`).range(doc.lineAt(from).from));
497
584
  const { marker } = construct;
585
+ headingLevels.set(doc.lineAt(from).number, construct.level);
498
586
  if (touches(doc.lineAt(from).from, doc.lineAt(from).to))
499
587
  out.push(syntax.range(marker.from, marker.to));
500
588
  else
@@ -537,7 +625,10 @@ function build(state) {
537
625
  const pending = host.reaches(construct.target) === 0 && !host.keeps(construct.target);
538
626
  if (construct.embed && host.keeps(construct.target) && !touches(from, to)) {
539
627
  const source = text.slice(from, to);
540
- out.push(Decoration.replace({ widget: new BlockWidget(`attachment:${source}`, host, () => createElement(Snippet, { text: source }), true, { inline: true, source }) }).range(from, to));
628
+ out.push(Decoration.replace({
629
+ // A kept image is sized from the image itself, as a Markdown one is (#98).
630
+ widget: new BlockWidget(`attachment:${source}`, host, (view) => createElement(ImageBlock, { view, source }, createElement(Snippet, { text: source })), true, { inline: true, source }),
631
+ }).range(from, to));
541
632
  break;
542
633
  }
543
634
  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 +642,21 @@ function build(state) {
551
642
  }
552
643
  break;
553
644
  }
554
- case 'image':
555
- if (!touches(from, to))
556
- out.push(Decoration.replace({ widget: new ImageWidget(text.slice(from, to)) }).range(from, to));
645
+ case 'image': {
646
+ if (touches(from, to))
647
+ break;
648
+ const source = text.slice(from, to);
649
+ const image = readImage(source);
650
+ // An image the page cannot follow shows its notation, as written.
651
+ if (!image)
652
+ out.push(mark('cm-ms-raw').range(from, to));
653
+ else {
654
+ // Its size is set from the image itself (#98).
655
+ const draw = (view) => createElement(ImageBlock, { view, source }, drawImage(image));
656
+ out.push(Decoration.replace({ widget: new BlockWidget(`image:${source}`, host, draw, true, { inline: true, source, kind: 'image', metrics }) }).range(from, to));
657
+ }
557
658
  break;
659
+ }
558
660
  case 'inlineMath':
559
661
  if (touches(from, to))
560
662
  out.push(mark('cm-ms-math-source').range(from, to));
@@ -613,8 +715,47 @@ function build(state) {
613
715
  break;
614
716
  }
615
717
  }
718
+ out.push(...lineEstimates(state, out, covered, headingLevels, metrics));
616
719
  return Decoration.set(out, true);
617
720
  }
721
+ /**
722
+ * A `LineEstimate` for each line CodeMirror would lay out shorter than the page draws it (#94): a
723
+ * heading, a line of code, a line of text that wraps. Its length is what the reader sees of it, the
724
+ * notation the decorations hide taken away; a line a drawn block covers estimates nothing.
725
+ */
726
+ function lineEstimates(state, decorations, covered, headingLevels, metrics) {
727
+ const doc = state.doc;
728
+ const hidden = new Map();
729
+ for (const range of decorations) {
730
+ if (range.value !== hide || range.to <= range.from)
731
+ continue;
732
+ const number = doc.lineAt(range.from).number;
733
+ hidden.set(number, (hidden.get(number) ?? 0) + range.to - range.from);
734
+ }
735
+ const code = new Set();
736
+ for (const construct of state.field(analysisField).constructs) {
737
+ if (construct.kind !== 'code')
738
+ continue;
739
+ for (let at = doc.lineAt(construct.from).number; at <= doc.lineAt(Math.max(construct.from, construct.to - 1)).number; at++)
740
+ code.add(at);
741
+ }
742
+ const out = [];
743
+ for (let number = 1; number <= doc.lines; number++) {
744
+ const line = doc.line(number);
745
+ if (line.length === 0)
746
+ continue;
747
+ if (covered.some((each) => line.from >= each.from && line.to <= each.to))
748
+ continue;
749
+ const level = headingLevels.get(number);
750
+ const length = line.length - (hidden.get(number) ?? 0);
751
+ const height = lineHeightOf(level !== undefined ? { heading: level } : code.has(number) ? 'code' : 'text', length, metrics);
752
+ // Said of every line, even one text line tall: a line with no decoration is laid out in a gap,
753
+ // which CodeMirror estimates by the length of all its lines together, too tall on a wide column.
754
+ if (height !== null)
755
+ out.push(Decoration.widget({ widget: new LineEstimate(height), side: -1 }).range(line.from));
756
+ }
757
+ return out;
758
+ }
618
759
  /** A code block's lines, as source: its frame, its fences dimmed, its tokens coloured. */
619
760
  function codeLines(construct, out, doc, text) {
620
761
  const first = doc.lineAt(construct.from).number;
@@ -653,7 +794,7 @@ export const decorationsField = StateField.define({
653
794
  create: build,
654
795
  update: (decorations, transaction) => transaction.docChanged ||
655
796
  transaction.selection ||
656
- transaction.effects.some((effect) => effect.is(setFocused) || effect.is(grammarArrived)) ||
797
+ transaction.effects.some((effect) => effect.is(setFocused) || effect.is(grammarArrived) || effect.is(setBlockMetrics) || effect.is(heightsLearned)) ||
657
798
  transaction.startState.facet(editorEnvironment) !== transaction.state.facet(editorEnvironment)
658
799
  ? build(transaction.state)
659
800
  : decorations,
@@ -745,6 +886,97 @@ const follow = EditorView.domEventHandlers({
745
886
  return true;
746
887
  },
747
888
  });
889
+ /** How long an embed measured ahead of being drawn may take to load before it is let go (#94). */
890
+ const PREMEASURE_TIMEOUT = 8000;
891
+ /**
892
+ * Draws, out of sight and at the editor's width, every embed of a note whose height is not known
893
+ * yet, and remembers the height it is drawn at, so the page is laid out with it before the author
894
+ * scrolls to it (#94). What an embed draws is a note the host loads, which no estimate can know; it
895
+ * is asked for as the editor opens, and once measured the blocks are made again with the height.
896
+ */
897
+ const embedPremeasure = ViewPlugin.define((view) => {
898
+ const asked = new Set();
899
+ const pending = new Set();
900
+ const premeasure = (source, identity, metrics) => {
901
+ const { host } = view.state.facet(editorEnvironment);
902
+ const container = document.createElement('div');
903
+ container.className = 'cm-ms-block cm-ms-premeasure';
904
+ container.setAttribute('aria-hidden', 'true');
905
+ container.style.cssText = `position: absolute; top: 0; left: 0; width: ${metrics.width}px; visibility: hidden; pointer-events: none; contain: layout style`;
906
+ // Outside the text, it is given the text's own face and line, which a drawn block inherits.
907
+ const text = getComputedStyle(view.contentDOM);
908
+ container.style.fontFamily = text.fontFamily;
909
+ container.style.fontSize = text.fontSize;
910
+ container.style.lineHeight = text.lineHeight;
911
+ (view.dom.parentElement ?? view.dom).appendChild(container);
912
+ const root = createRoot(container);
913
+ root.render(createElement(HostProvider, { value: host }, createElement(BlockBoundary, { host, source }, createElement(Snippet, { text: source }))));
914
+ let finished = false;
915
+ const finish = (height) => {
916
+ if (finished)
917
+ return;
918
+ finished = true;
919
+ pending.delete(stop);
920
+ clearTimeout(timer);
921
+ observer?.disconnect();
922
+ queueMicrotask(() => {
923
+ root.unmount();
924
+ container.remove();
925
+ });
926
+ // Remembered at the width it was drawn at; at another one it says nothing.
927
+ const now = view.state.field(metricsField);
928
+ if (height === null || Math.round(now.width) !== Math.round(metrics.width))
929
+ return;
930
+ rememberHeight(identity, now, height);
931
+ view.dispatch({ effects: heightsLearned.of(null) });
932
+ };
933
+ const stop = () => finish(null);
934
+ pending.add(stop);
935
+ // Drawn once the note arrived: no skeleton left, and a frame later, laid out.
936
+ const check = () => {
937
+ if (finished || !container.firstElementChild || container.querySelector('.embed-skeleton'))
938
+ return;
939
+ requestAnimationFrame(() => {
940
+ if (!finished && !container.querySelector('.embed-skeleton'))
941
+ finish(container.getBoundingClientRect().height);
942
+ });
943
+ };
944
+ const observer = typeof MutationObserver === 'undefined' ? null : new MutationObserver(check);
945
+ observer?.observe(container, { childList: true, subtree: true });
946
+ const timer = setTimeout(stop, PREMEASURE_TIMEOUT);
947
+ };
948
+ const run = () => {
949
+ const { host } = view.state.facet(editorEnvironment);
950
+ if (!host.loadNote || typeof requestAnimationFrame === 'undefined')
951
+ return;
952
+ const metrics = view.state.field(metricsField);
953
+ const doc = view.state.doc;
954
+ for (const construct of view.state.field(analysisField).constructs) {
955
+ if (construct.kind !== 'embed' || host.keeps(construct.target) || host.reaches(construct.target) !== 1)
956
+ continue;
957
+ const end = construct.to > construct.from && doc.sliceString(construct.to - 1, construct.to) === '\n' ? construct.to - 1 : construct.to;
958
+ const source = doc.sliceString(doc.lineAt(construct.from).from, doc.lineAt(end).to);
959
+ const identity = `embed\u0000${source}`;
960
+ const key = `${Math.round(metrics.width)}\u0000${identity}`;
961
+ if (asked.has(key) || measuredHeight(identity, metrics) !== undefined)
962
+ continue;
963
+ asked.add(key);
964
+ premeasure(source, identity, metrics);
965
+ }
966
+ };
967
+ // Not inside the update that made the plugin, where CodeMirror takes no transaction.
968
+ queueMicrotask(run);
969
+ return {
970
+ update: (update) => {
971
+ if (update.docChanged || update.startState.field(metricsField) !== update.state.field(metricsField))
972
+ queueMicrotask(run);
973
+ },
974
+ destroy: () => {
975
+ for (const stop of [...pending])
976
+ stop();
977
+ },
978
+ };
979
+ });
748
980
  /**
749
981
  * Fetches the grammar of every code block that names one not registered yet, and has the page
750
982
  * drawn again once it arrives, its tokens coloured (#84).
@@ -792,6 +1024,7 @@ export function livePage(metrics) {
792
1024
  EditorView.focusChangeEffect.of((_state, focusing) => setFocused.of(focusing)),
793
1025
  focusKeeper,
794
1026
  grammarLoader,
1027
+ embedPremeasure,
795
1028
  blockKeys,
796
1029
  follow,
797
1030
  ];
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';
@@ -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;
@@ -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;
@@ -181,6 +187,8 @@ export interface EditorWords {
181
187
  readonly retry: string;
182
188
  /** Said when the author asked to go back to reading and the last change is not saved. */
183
189
  readonly stayInEdit: string;
190
+ /** Said while a refused save waits for the author's choice before the note is written in again (#101). */
191
+ readonly chooseToEdit: string;
184
192
  }
185
193
  /** The words MemorySmith shows in English, used for every word a host does not give. */
186
194
  export declare const ENGLISH: NoteStrings;
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',
@@ -176,6 +179,7 @@ export const ENGLISH = {
176
179
  saving: 'Saving…',
177
180
  retry: 'Try again',
178
181
  stayInEdit: 'Not saved: the note stays in edit',
182
+ chooseToEdit: 'Choose what to do with it to keep writing',
179
183
  },
180
184
  loading: 'Loading…',
181
185
  failed: 'Something went wrong.',
package/dist/theme.d.ts CHANGED
@@ -23,6 +23,7 @@
23
23
  import type { ReactNode } from 'react';
24
24
  import type { FileShape } from './file-types.js';
25
25
  import type { KeptFile } from './host.js';
26
+ import type { NoteButtons } from './NoteActions.js';
26
27
  import type { PropertyEntry } from './properties.js';
27
28
  export type Scheme = 'light' | 'dark';
28
29
  /**
@@ -119,6 +120,7 @@ export interface ThemeProperties {
119
120
  readonly chipBg?: string;
120
121
  readonly chipColor?: string;
121
122
  readonly chipFont?: string;
123
+ readonly buttonWordsFrom?: string;
122
124
  }
123
125
  /** The names of `ThemeProperties`, in the order the documentation lists them. */
124
126
  export declare const THEME_PROPERTIES: (keyof ThemeProperties)[];
@@ -251,8 +253,11 @@ export interface ModeSwitchSlotProps {
251
253
  readonly label: string;
252
254
  /** The default theme's icon of that action: a pencil to edit, an eye to read. */
253
255
  readonly icon: ReactNode;
254
- /** Whether the host asked for the icon alone or the icon and its word. */
255
- readonly display: 'icon' | 'label';
256
+ /**
257
+ * How the host asked the buttons around the note to show their word (#96): the icon alone, the
258
+ * icon and its word, or the word only on a wide note (`responsive`).
259
+ */
260
+ readonly display: NoteButtons;
256
261
  /** Switches to the other mode. */
257
262
  readonly toggle: () => void;
258
263
  }
package/dist/theme.js CHANGED
@@ -42,6 +42,7 @@ export const THEME_PROPERTIES = Object.keys({
42
42
  propertiesRule: 1, propertiesLabelFont: 1, propertiesLabelColor: 1, propertiesKeyColor: 1,
43
43
  iconPropertyText: 1, iconPropertyList: 1, iconPropertyDate: 1, iconPropertyBoolean: 1,
44
44
  chipBg: 1, chipColor: 1, chipFont: 1,
45
+ buttonWordsFrom: 1,
45
46
  });
46
47
  /** `textSoft` → `--markdown-text-soft`, `h1Size` → `--markdown-h1-size`. */
47
48
  export function customPropertyOf(name) {