@memorysmithapp/markdown-renderer 0.5.0 → 0.6.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 CHANGED
@@ -20,7 +20,7 @@ import 'katex/dist/katex.min.css';
20
20
  resolve={(name) => addressOfTheOneNoteNamed(name)} // or null: the link is pending
21
21
  keeps={(name) => notebookKeepsAFile(name)} // only an embed reaches a file
22
22
  fileOf={(name) => describe(name)} // { mimeType, bytes, url, description }: drawn by its type, as MemorySmith does
23
- renderLink={({ href, className, children }) => <RouterLink to={href} className={className}>{children}</RouterLink>}
23
+ renderLink={({ href, className, children }) => <a href={href} className={className} onClick={follow(href)}>{children}</a>} // follow: your router's navigate, captured where it is
24
24
  loadNote={(name) => fetchTheBodyOf(name)} // for an embedded note; the package never fetches
25
25
  writable
26
26
  onToggleTask={(ordinal) => save(toggleTaskAt(latest(), ordinal))} // a promise locks the item under "Updating…" until it settles
@@ -29,7 +29,7 @@ import 'katex/dist/katex.min.css';
29
29
  />;
30
30
  ```
31
31
 
32
- Every prop is optional, and every default is the safe one: with no `resolve` every link is pending, with no `keeps` no embed reaches a file. A file `fileOf` describes is drawn by its media type, never its name: an image, an audio or a video player, and anything else a card with its type, size, description, *Open* where a browser shows it and *Download*; give `renderAttachment` instead to draw files yourself. The stylesheet styles nothing outside `.markdown`. The faces of the default theme are Inter and Space Grotesk, which the page loads.
32
+ Every prop is optional, and every default is the safe one: with no `resolve` every link is pending, with no `keeps` no embed reaches a file. A file `fileOf` describes is drawn by its media type, never its name: an image, an audio or a video player, and anything else a card with its type, size, description, *Open* where a browser shows it and *Download*; give `renderAttachment` instead to draw files yourself. In edit mode, `renderLink`, `renderAttachment` and a theme's slots may be drawn in a root of their own, outside your React tree, so what they return must not read your context: a router's `<Link>` throws there, and the block shows its Markdown under a notice instead. The stylesheet styles nothing outside `.markdown`. The faces of the default theme are Inter and Space Grotesk, which the page loads.
33
33
 
34
34
  ## Themes
35
35
 
@@ -86,7 +86,7 @@ A note the host makes `editable` carries a button at its top right that switches
86
86
  suggest={(typed) => namesOfNotesLike(typed)} // what [[ offers
87
87
  attach={(file, { name, description }, progress) => keepInNotebook(file, name, description, progress)} // the name it is kept under
88
88
  files={() => filesOfTheNotebook()} // what the attach dialog lists: { name, mimeType, bytes, url, description }
89
- modeButton="icon" // or "label", the icon and its word
89
+ modeButton="label" // the icon and its word; "icon", the default, the icon alone
90
90
  />
91
91
  ```
92
92
 
@@ -25,7 +25,10 @@ export interface NoteBodyProps extends NoteHost {
25
25
  * a dialog with the host's words and its actions (#86).
26
26
  */
27
27
  readonly onChange?: (markdown: string) => unknown;
28
- /** Whether the switch shows its icon alone or its icon and its word. Defaults to `label`. */
28
+ /**
29
+ * Whether the switch shows its icon alone or its icon and its word. Defaults to `icon`, like Show
30
+ * Markdown and Copy Markdown beside it, its word then its name and its title (#92).
31
+ */
29
32
  readonly modeButton?: 'icon' | 'label';
30
33
  /**
31
34
  * Whether the note offers Show Markdown and Copy Markdown at its top right, editable or not:
package/dist/NoteBody.js CHANGED
@@ -34,7 +34,7 @@ function ModeSwitch({ mode, display, toggle }) {
34
34
  * It renders inside `.markdown`, so the stylesheet of the package applies, and asks the host for
35
35
  * every piece of notebook data through the props of `NoteHost`.
36
36
  */
37
- export function NoteBody({ editable = false, mode, defaultMode = 'view', onModeChange, onChange, modeButton = 'label', actions = true, ...rest }) {
37
+ export function NoteBody({ editable = false, mode, defaultMode = 'view', onModeChange, onChange, modeButton = 'icon', actions = true, ...rest }) {
38
38
  const [own, setOwn] = useState(defaultMode);
39
39
  // The author asked to go back to reading, and the editor is finishing: a save, the toolbar folding.
40
40
  const [leaving, setLeaving] = useState(false);
@@ -19,6 +19,7 @@ import { rootStyle } from '../theme.js';
19
19
  import { ICONS } from './blocks.js';
20
20
  import { BLOCKS, command, headingLevel, insertBlock, insertFootnote, insertLink, insertWikilink, setHeading, toggleComment, toggleList, toggleMark, toggleQuote, } from './commands.js';
21
21
  import { analysisField, editorEnvironment, livePage } from './live.js';
22
+ import { measureMetrics } from './heights.js';
22
23
  import { NoteActions } from '../NoteActions.js';
23
24
  import { AttachDialog, webImage } from './AttachDialog.js';
24
25
  import { ContextMenu } from './ContextMenu.js';
@@ -358,7 +359,7 @@ export function NoteEditor({ markdown: given, onChange, editable = true, corner,
358
359
  markdown(),
359
360
  EditorView.lineWrapping,
360
361
  EditorView.contentAttributes.of({ 'aria-multiline': 'true', spellcheck: 'true' }),
361
- livePage(),
362
+ livePage(measureMetrics(parent.current)),
362
363
  autocompletion({ override: [wikilinkCompletion(() => latest.current.host)], icons: false }),
363
364
  shortcuts(() => created),
364
365
  keymap.of([...markdownKeymap, ...historyKeymap, indentWithTab, ...defaultKeymap]),
@@ -0,0 +1,34 @@
1
+ /**
2
+ * How tall a drawn block will be before it is drawn (#91).
3
+ *
4
+ * CodeMirror lays out the blocks it has not drawn yet by the height their widget estimates, and
5
+ * reads that estimate once, when the block's decoration is made; a block drawn taller than its
6
+ * estimate grows the document under the scrollbar as the author scrolls to it. Each block
7
+ * estimates its height from what it draws — its lines wrapped at the editor's width, its rows, its
8
+ * properties — and a block drawn once at a width is remembered at its measured height, so the same
9
+ * block made again, moved by an edit above it or in the next edit of the note, starts right.
10
+ */
11
+ /** What an estimate reads of the editor: its text column, its line height and its characters. */
12
+ export interface BlockMetrics {
13
+ readonly width: number;
14
+ readonly lineHeight: number;
15
+ readonly charWidth: number;
16
+ }
17
+ /** What an estimate assumes before the editor has been measured, as in a test with no layout. */
18
+ export declare const DEFAULT_METRICS: BlockMetrics;
19
+ /**
20
+ * The metrics of a text column laid out in `parent`, read before the editor exists there, so the
21
+ * first estimates are made at the width the note is drawn at. Falls back to `DEFAULT_METRICS` where
22
+ * nothing is laid out.
23
+ */
24
+ export declare function measureMetrics(parent: HTMLElement): BlockMetrics;
25
+ /** Metrics as measured, any that could not be read replaced by the default. */
26
+ export declare function withinReason(metrics: BlockMetrics): BlockMetrics;
27
+ /** What a block is, for its estimate. */
28
+ export type BlockKind = 'frontmatter' | 'callout' | 'table' | 'embed' | 'diagram' | 'preview' | 'add-properties';
29
+ /** The height a block of this kind and these lines is drawn at, near enough to lay out the page. */
30
+ export declare function estimateHeight(kind: BlockKind, source: string, metrics: BlockMetrics): number;
31
+ /** The height a block of this content was drawn at at this width, if it was. */
32
+ export declare function measuredHeight(identity: string, metrics: BlockMetrics): number | undefined;
33
+ /** Remembers the height a block was drawn at. */
34
+ export declare function rememberHeight(identity: string, metrics: BlockMetrics, height: number): void;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * How tall a drawn block will be before it is drawn (#91).
3
+ *
4
+ * CodeMirror lays out the blocks it has not drawn yet by the height their widget estimates, and
5
+ * reads that estimate once, when the block's decoration is made; a block drawn taller than its
6
+ * estimate grows the document under the scrollbar as the author scrolls to it. Each block
7
+ * estimates its height from what it draws — its lines wrapped at the editor's width, its rows, its
8
+ * properties — and a block drawn once at a width is remembered at its measured height, so the same
9
+ * block made again, moved by an edit above it or in the next edit of the note, starts right.
10
+ */
11
+ /** What an estimate assumes before the editor has been measured, as in a test with no layout. */
12
+ export const DEFAULT_METRICS = { width: 700, lineHeight: 24, charWidth: 8 };
13
+ /**
14
+ * The metrics of a text column laid out in `parent`, read before the editor exists there, so the
15
+ * first estimates are made at the width the note is drawn at. Falls back to `DEFAULT_METRICS` where
16
+ * nothing is laid out.
17
+ */
18
+ export function measureMetrics(parent) {
19
+ const probe = document.createElement('div');
20
+ probe.style.cssText = 'position: absolute; visibility: hidden; white-space: pre; pointer-events: none';
21
+ probe.textContent = 'abcdefghijklmnopqrstuvwxyz';
22
+ parent.appendChild(probe);
23
+ const box = probe.getBoundingClientRect();
24
+ const width = parent.clientWidth;
25
+ probe.remove();
26
+ return withinReason({ width, lineHeight: box.height, charWidth: box.width / 26 });
27
+ }
28
+ /** Metrics as measured, any that could not be read replaced by the default. */
29
+ export function withinReason(metrics) {
30
+ const usable = (value, fallback) => (Number.isFinite(value) && value > 1 ? value : fallback);
31
+ return {
32
+ width: usable(metrics.width, DEFAULT_METRICS.width),
33
+ lineHeight: usable(metrics.lineHeight, DEFAULT_METRICS.lineHeight),
34
+ charWidth: usable(metrics.charWidth, DEFAULT_METRICS.charWidth),
35
+ };
36
+ }
37
+ /** The lines a text takes in a column `width` wide, each of its lines at least one. */
38
+ function wrapped(lines, width, metrics) {
39
+ const perLine = Math.max(8, Math.floor(width / metrics.charWidth));
40
+ return lines.reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / perLine)), 0);
41
+ }
42
+ /**
43
+ * Below this width the default theme lays a property's value under its key, not beside it; it is
44
+ * the text column of its 560px breakpoint.
45
+ */
46
+ const STACKED_PROPERTIES = 528;
47
+ /** The height a block of this kind and these lines is drawn at, near enough to lay out the page. */
48
+ export function estimateHeight(kind, source, metrics) {
49
+ const { lineHeight } = metrics;
50
+ const lines = source.split('\n');
51
+ switch (kind) {
52
+ case 'frontmatter': {
53
+ // The name's field, then a row per key of the top level, which a list's items share.
54
+ const keys = lines.filter((line) => /^[^\s#-][^:]*:/.test(line)).length;
55
+ const row = (metrics.width < STACKED_PROPERTIES ? 2.8 : 1.4) * lineHeight;
56
+ return Math.round(2.5 * lineHeight + keys * row);
57
+ }
58
+ case 'callout': {
59
+ // Its title and its body, as text inside its padding and its border.
60
+ const text = lines.map((line) => line.replace(/^(?:\s*>)+\s?/, ''));
61
+ return Math.round(lineHeight + wrapped(text, metrics.width - 32, metrics) * lineHeight);
62
+ }
63
+ case 'table': {
64
+ // A row per line but the delimiter row. The columns share the width by what they hold, so a
65
+ // row wraps as the text of all its cells would in the width their padding leaves.
66
+ const rows = lines.filter((line, index) => index !== 1 || !/^[\s|:-]+$/.test(line));
67
+ const cells = (row) => row.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map((cell) => cell.trim());
68
+ const columns = Math.max(1, ...rows.map((row) => cells(row).length));
69
+ const width = metrics.width - 16 * columns;
70
+ const height = rows.reduce((sum, row) => sum + 9 + lineHeight * wrapped([cells(row).join('')], width, metrics), 0);
71
+ return Math.round(0.75 * lineHeight + height);
72
+ }
73
+ case 'embed':
74
+ // What a note or a file embeds is not known before it is drawn: a card's height.
75
+ return Math.round(8 * lineHeight);
76
+ case 'diagram':
77
+ return Math.round(12 * lineHeight);
78
+ case 'preview':
79
+ return Math.round(lineHeight + wrapped(lines, metrics.width, metrics) * lineHeight);
80
+ case 'add-properties':
81
+ return Math.round(1.5 * lineHeight);
82
+ }
83
+ }
84
+ /** The heights blocks were drawn at, by width and content; the oldest are let go past the limit. */
85
+ const measured = new Map();
86
+ const MEASURED_LIMIT = 2000;
87
+ const measuredKey = (identity, metrics) => `${Math.round(metrics.width)}\u0000${identity}`;
88
+ /** The height a block of this content was drawn at at this width, if it was. */
89
+ export function measuredHeight(identity, metrics) {
90
+ return measured.get(measuredKey(identity, metrics));
91
+ }
92
+ /** Remembers the height a block was drawn at. */
93
+ export function rememberHeight(identity, metrics, height) {
94
+ if (!(height > 0))
95
+ return;
96
+ const key = measuredKey(identity, metrics);
97
+ measured.delete(key);
98
+ measured.set(key, height);
99
+ if (measured.size > MEASURED_LIMIT)
100
+ measured.delete(measured.keys().next().value);
101
+ }
@@ -10,6 +10,7 @@ import { Facet, StateField, type EditorState, type Extension } from '@codemirror
10
10
  import { type DecorationSet } from '@codemirror/view';
11
11
  import { type ResolvedHost } from '../host.js';
12
12
  import { type Analysis } from './analyze.js';
13
+ import { type BlockMetrics } from './heights.js';
13
14
  /** What the drawn blocks read: the host the note is edited under. */
14
15
  export interface EditorEnvironment {
15
16
  readonly host: ResolvedHost;
@@ -24,5 +25,9 @@ export declare const setFocused: import("@codemirror/state").StateEffectType<boo
24
25
  /** A grammar a code block names has arrived: its tokens can be coloured now (#84). */
25
26
  export declare const grammarArrived: import("@codemirror/state").StateEffectType<string>;
26
27
  export declare const decorationsField: StateField<DecorationSet>;
27
- /** The live page: the analysis, the decorations over it, and what the focus and a modifier-click do. */
28
- export declare function livePage(): Extension;
28
+ /**
29
+ * The live page: the analysis, the decorations over it, and what the focus and a modifier-click do.
30
+ * `metrics` are the editor's text column measured before the editor is made, which the first drawn
31
+ * blocks estimate their height by; without them they assume `DEFAULT_METRICS` until it is measured.
32
+ */
33
+ export declare function livePage(metrics?: BlockMetrics): Extension;
package/dist/edit/live.js CHANGED
@@ -9,13 +9,15 @@
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 } 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';
20
+ import { DEFAULT_METRICS, estimateHeight, measuredHeight, rememberHeight, withinReason } from './heights.js';
19
21
  import { AddProperties, CalloutBlock, PropertiesBlock, reveal, Snippet, TableBlock } from './blocks.js';
20
22
  import { LanguageControl } from './LanguageControl.js';
21
23
  import { toggleTaskBox } from './commands.js';
@@ -35,6 +37,42 @@ 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
+ /** The metrics of the editor's text column, which a drawn block estimates its height by (#91). */
43
+ const metricsField = StateField.define({
44
+ create: () => DEFAULT_METRICS,
45
+ update: (metrics, transaction) => {
46
+ for (const effect of transaction.effects)
47
+ if (effect.is(setBlockMetrics))
48
+ return effect.value;
49
+ return metrics;
50
+ },
51
+ });
52
+ /** Reads the editor's metrics again whenever its geometry changes, and keeps them when they moved. */
53
+ const metricsWatcher = ViewPlugin.define((view) => {
54
+ let destroyed = false;
55
+ const check = () => view.requestMeasure({
56
+ read: () => withinReason({ width: view.contentDOM.clientWidth, lineHeight: view.defaultLineHeight, charWidth: view.defaultCharacterWidth }),
57
+ write: (next) => {
58
+ 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;
60
+ // Not inside the measure, where CodeMirror takes no transaction.
61
+ if (moved)
62
+ queueMicrotask(() => destroyed || view.dispatch({ effects: setBlockMetrics.of(next) }));
63
+ },
64
+ });
65
+ check();
66
+ return {
67
+ update: (update) => {
68
+ if (update.geometryChanged)
69
+ check();
70
+ },
71
+ destroy: () => {
72
+ destroyed = true;
73
+ },
74
+ };
75
+ });
38
76
  /** Whether the editor has the focus: a construct is shown as source only to an author who is in it. */
39
77
  const focusField = StateField.define({
40
78
  create: () => false,
@@ -46,17 +84,45 @@ const focusField = StateField.define({
46
84
  },
47
85
  });
48
86
  // ── Widgets ──────────────────────────────────────────────────────────────────
87
+ /**
88
+ * What a drawn block shows when drawing it threw — a host's `renderLink` that needs the host's own
89
+ * context, which a block's root, outside the host's tree, does not carry (#90): its lines as written,
90
+ * under a notice, so a failure never hides a part of the note. A click on it shows its source.
91
+ */
92
+ class BlockBoundary extends Component {
93
+ state = { failed: false };
94
+ static getDerivedStateFromError() {
95
+ return { failed: true };
96
+ }
97
+ componentDidCatch(error, info) {
98
+ // React reports a caught error only in development; the host is told in every build.
99
+ console.error('A block of the note could not be drawn:', error, info.componentStack);
100
+ }
101
+ render() {
102
+ if (!this.state.failed)
103
+ return this.props.children;
104
+ const notice = this.props.host.strings.editor.blockFailed;
105
+ if (!this.props.source)
106
+ return createElement('span', { className: 'cm-ms-block-failed', title: notice });
107
+ 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));
108
+ }
109
+ }
49
110
  /** The React roots the widgets mounted, unmounted when CodeMirror lets the widget go. */
50
111
  const roots = new WeakMap();
51
- function mount(view, element, node) {
112
+ function mount(view, element, node, source, measured) {
52
113
  const { host } = view.state.facet(editorEnvironment);
53
114
  const root = createRoot(element);
54
115
  // Drawn on React's own schedule: CodeMirror builds its widgets inside its update, which may run
55
116
  // inside a React commit, where React cannot be asked to render synchronously.
56
- root.render(createElement(HostProvider, { value: host }, node));
117
+ root.render(createElement(HostProvider, { value: host }, createElement(BlockBoundary, { host, source }, node)));
57
118
  // A block whose content arrives later — an embed, a diagram — changes its height after it is
58
119
  // measured; the editor is told so, or the lines below it would be placed by the old height.
59
- const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(() => view.requestMeasure());
120
+ const observer = typeof ResizeObserver === 'undefined'
121
+ ? null
122
+ : new ResizeObserver(() => {
123
+ measured(element.getBoundingClientRect().height);
124
+ view.requestMeasure();
125
+ });
60
126
  observer?.observe(element);
61
127
  roots.set(element, { unmount: () => root.unmount(), observer });
62
128
  }
@@ -75,16 +141,24 @@ class BlockWidget extends WidgetType {
75
141
  host;
76
142
  draw;
77
143
  revealsOnClick;
144
+ options;
78
145
  inline;
146
+ source;
79
147
  constructor(key, host, draw,
80
148
  /** Whether a click on it shows its source; a block with controls of its own handles its clicks. */
81
- revealsOnClick, inline = false) {
149
+ revealsOnClick, options = {}) {
82
150
  super();
83
151
  this.key = key;
84
152
  this.host = host;
85
153
  this.draw = draw;
86
154
  this.revealsOnClick = revealsOnClick;
87
- this.inline = inline;
155
+ this.options = options;
156
+ this.inline = options.inline ?? false;
157
+ this.source = options.source ?? '';
158
+ }
159
+ /** What it is remembered by once measured: what it draws, wherever in the note it stands. */
160
+ get identity() {
161
+ return `${this.options.kind ?? 'block'}\u0000${this.source}`;
88
162
  }
89
163
  eq(other) {
90
164
  return other.key === this.key && other.host === this.host;
@@ -92,7 +166,7 @@ class BlockWidget extends WidgetType {
92
166
  toDOM(view) {
93
167
  const element = document.createElement(this.inline ? 'span' : 'div');
94
168
  element.className = this.inline ? 'cm-ms-inline-block' : 'cm-ms-block';
95
- mount(view, element, this.draw(view));
169
+ mount(view, element, this.draw(view), this.source, (height) => rememberHeight(this.identity, view.state.field(metricsField), height));
96
170
  element.addEventListener('mousedown', (event) => {
97
171
  const target = event.target;
98
172
  if (target.closest('[data-ms-control]'))
@@ -119,8 +193,16 @@ class BlockWidget extends WidgetType {
119
193
  ignoreEvent() {
120
194
  return true;
121
195
  }
196
+ /**
197
+ * The height CodeMirror lays the block out at until it is drawn, which it reads once, when the
198
+ * block is made: the height it was drawn at before at this width, or its estimate (#91).
199
+ */
122
200
  get estimatedHeight() {
123
- return this.inline ? -1 : 48;
201
+ if (this.inline)
202
+ return -1;
203
+ const metrics = this.options.metrics ?? DEFAULT_METRICS;
204
+ const kind = this.options.kind;
205
+ return measuredHeight(this.identity, metrics) ?? (kind ? estimateHeight(kind, this.source, metrics) : 2 * metrics.lineHeight);
124
206
  }
125
207
  }
126
208
  /**
@@ -180,6 +262,10 @@ class TaskWidget extends WidgetType {
180
262
  return false;
181
263
  }
182
264
  }
265
+ /** What a rule and a formula's row are drawn at by the default theme, before they are measured (#91). */
266
+ const RULE_HEIGHT = 33;
267
+ const MATH_ROW = 32;
268
+ const MATH_MARGINS = 32;
183
269
  class RuleWidget extends WidgetType {
184
270
  eq() {
185
271
  return true;
@@ -190,6 +276,9 @@ class RuleWidget extends WidgetType {
190
276
  wrap.appendChild(document.createElement('hr'));
191
277
  return wrap;
192
278
  }
279
+ get estimatedHeight() {
280
+ return RULE_HEIGHT;
281
+ }
193
282
  }
194
283
  class MathWidget extends WidgetType {
195
284
  tex;
@@ -209,21 +298,31 @@ class MathWidget extends WidgetType {
209
298
  katex.render(this.tex, element, { throwOnError: false, displayMode: this.display, strict: 'ignore' });
210
299
  return element;
211
300
  }
301
+ /** A formula's rows, each a line of KaTeX's display, within its margins (#91). */
302
+ get estimatedHeight() {
303
+ return this.display ? MATH_MARGINS + MATH_ROW * this.tex.split('\\\\').length : -1;
304
+ }
212
305
  ignoreEvent() {
213
306
  return false;
214
307
  }
215
308
  }
309
+ /** What an image declares no height for is drawn at before it loads, near enough (#91). */
310
+ const UNDECLARED_IMAGE_HEIGHT = 200;
216
311
  class ImageWidget extends WidgetType {
217
312
  source;
313
+ match;
314
+ alt;
218
315
  constructor(source) {
219
316
  super();
220
317
  this.source = source;
318
+ this.match = /^!\[([^\]]*)\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)$/.exec(source);
319
+ this.alt = readImageAlt(this.match?.[1] ?? '');
221
320
  }
222
321
  eq(other) {
223
322
  return other.source === this.source;
224
323
  }
225
324
  toDOM() {
226
- const match = /^!\[([^\]]*)\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)$/.exec(this.source);
325
+ const match = this.match;
227
326
  const address = match ? followable(match[2] ?? '') : '';
228
327
  if (!match || !address) {
229
328
  const text = document.createElement('span');
@@ -234,9 +333,22 @@ class ImageWidget extends WidgetType {
234
333
  const image = document.createElement('img');
235
334
  image.className = 'cm-ms-image';
236
335
  image.src = address;
237
- image.alt = (match[1] ?? '').split('|')[0] ?? '';
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;
238
342
  return image;
239
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
+ }
240
352
  ignoreEvent() {
241
353
  return false;
242
354
  }
@@ -251,6 +363,7 @@ const BLOCK_KINDS = new Set(['frontmatter', 'callout', 'table', 'mathBlock', 'em
251
363
  function build(state) {
252
364
  const { constructs, bodyFrom } = state.field(analysisField);
253
365
  const { host } = state.facet(editorEnvironment);
366
+ const metrics = state.field(metricsField);
254
367
  const focused = state.field(focusField);
255
368
  const doc = state.doc;
256
369
  const text = textOf(state);
@@ -274,17 +387,18 @@ function build(state) {
274
387
  for (let at = doc.lineAt(lines.from).number; at <= doc.lineAt(lines.to).number; at++)
275
388
  out.push(line(className).range(doc.line(at).from));
276
389
  };
277
- const block = (from, to, key, draw, revealsOnClick = true) => {
390
+ const block = (from, to, kind, key, draw, revealsOnClick = true) => {
278
391
  const lines = linesOf(from, to);
279
- out.push(Decoration.replace({ widget: new BlockWidget(key, host, draw, revealsOnClick), block: true }).range(lines.from, lines.to));
392
+ const source = text.slice(lines.from, lines.to);
393
+ out.push(Decoration.replace({ widget: new BlockWidget(key, host, draw, revealsOnClick, { source, kind, metrics }), block: true }).range(lines.from, lines.to));
280
394
  covered.push(lines);
281
395
  };
282
- const after = (to, key, draw) => {
396
+ const after = (to, key, draw, source) => {
283
397
  const lines = linesOf(to, to);
284
- out.push(Decoration.widget({ widget: new BlockWidget(key, host, draw, false), block: true, side: 1 }).range(lines.to));
398
+ out.push(Decoration.widget({ widget: new BlockWidget(key, host, draw, false, { source, kind: 'preview', metrics }), block: true, side: 1 }).range(lines.to));
285
399
  };
286
400
  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));
401
+ 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
402
  }
289
403
  // Blocks first: what a drawn block covers is not decorated again.
290
404
  for (const construct of constructs) {
@@ -299,7 +413,7 @@ function build(state) {
299
413
  if (open)
300
414
  eachLine(from, to, 'cm-ms-yaml');
301
415
  else
302
- block(from, to, `front:${source}`, (view) => createElement(PropertiesBlock, { view, to }), false);
416
+ block(from, to, 'frontmatter', `front:${source}`, (view) => createElement(PropertiesBlock, { view, to }), false);
303
417
  break;
304
418
  case 'callout':
305
419
  if (!alone(from, to))
@@ -308,7 +422,7 @@ function build(state) {
308
422
  eachLine(from, to, `cm-ms-callout-source`);
309
423
  else {
310
424
  const marker = { ...construct.marker, type: construct.type, fold: construct.fold };
311
- block(from, to, `callout:${from}:${source}`, (view) => createElement(CalloutBlock, { view, text: source, marker }));
425
+ block(from, to, 'callout', `callout:${from}:${source}`, (view) => createElement(CalloutBlock, { view, text: source, marker }));
312
426
  }
313
427
  break;
314
428
  case 'table':
@@ -317,14 +431,15 @@ function build(state) {
317
431
  if (open)
318
432
  eachLine(from, to, 'cm-ms-table-source');
319
433
  else
320
- block(from, to, `table:${from}:${source}`, (view) => createElement(TableBlock, { view, from, to }), false);
434
+ block(from, to, 'table', `table:${from}:${source}`, (view) => createElement(TableBlock, { view, from, to }), false);
321
435
  break;
322
436
  case 'mathBlock':
323
437
  if (!alone(from, to))
324
438
  break;
325
439
  if (open) {
326
440
  eachLine(from, to, 'cm-ms-code-line');
327
- after(to, `math-preview:${construct.tex}`, () => createElement(Snippet, { text: `$$\n${construct.tex}\n$$` }));
441
+ const preview = `$$\n${construct.tex}\n$$`;
442
+ after(to, `math-preview:${construct.tex}`, () => createElement(Snippet, { text: preview }), preview);
328
443
  }
329
444
  else {
330
445
  out.push(Decoration.replace({ widget: new MathWidget(construct.tex, true), block: true }).range(lines.from, lines.to));
@@ -335,7 +450,7 @@ function build(state) {
335
450
  if (open)
336
451
  out.push(mark('cm-ms-wikilink').range(from, to));
337
452
  else
338
- block(from, to, `embed:${source}`, () => createElement(Snippet, { text: source }));
453
+ block(from, to, 'embed', `embed:${source}`, () => createElement(Snippet, { text: source }));
339
454
  break;
340
455
  case 'rule':
341
456
  if (!alone(from, to))
@@ -350,7 +465,7 @@ function build(state) {
350
465
  case 'code': {
351
466
  const diagram = construct.language === 'mermaid' && construct.open !== null;
352
467
  if (diagram && !open && alone(from, to)) {
353
- block(from, to, `diagram:${source}`, () => createElement(Snippet, { text: source }));
468
+ block(from, to, 'diagram', `diagram:${source}`, () => createElement(Snippet, { text: source }));
354
469
  break;
355
470
  }
356
471
  codeLines(construct, out, doc, text);
@@ -359,12 +474,14 @@ function build(state) {
359
474
  const fence = construct.open.from;
360
475
  const language = construct.language;
361
476
  out.push(Decoration.widget({
362
- widget: new BlockWidget(`language:${fence}:${language ?? ''}`, host, (view) => createElement(LanguageControl, { view, from: fence, language }), false, true),
477
+ widget: new BlockWidget(`language:${fence}:${language ?? ''}`, host, (view) => createElement(LanguageControl, { view, from: fence, language }), false, { inline: true }),
363
478
  side: 1,
364
479
  }).range(construct.open.to));
365
480
  }
366
- if (diagram)
367
- after(to, `diagram-preview:${construct.code}`, () => createElement(Snippet, { text: '```mermaid\n' + construct.code + '\n```' }));
481
+ if (diagram) {
482
+ const preview = '```mermaid\n' + construct.code + '\n```';
483
+ after(to, `diagram-preview:${construct.code}`, () => createElement(Snippet, { text: preview }), preview);
484
+ }
368
485
  covered.push(lines);
369
486
  break;
370
487
  }
@@ -420,7 +537,7 @@ function build(state) {
420
537
  const pending = host.reaches(construct.target) === 0 && !host.keeps(construct.target);
421
538
  if (construct.embed && host.keeps(construct.target) && !touches(from, to)) {
422
539
  const source = text.slice(from, to);
423
- out.push(Decoration.replace({ widget: new BlockWidget(`attachment:${source}`, host, () => createElement(Snippet, { text: source }), true, true) }).range(from, to));
540
+ out.push(Decoration.replace({ widget: new BlockWidget(`attachment:${source}`, host, () => createElement(Snippet, { text: source }), true, { inline: true, source }) }).range(from, to));
424
541
  break;
425
542
  }
426
543
  out.push(mark(`cm-ms-wikilink${pending ? ' cm-ms-pending' : ''}`, { 'data-target': construct.target, title: construct.target }).range(construct.label.from, construct.label.to));
@@ -660,10 +777,16 @@ const grammarLoader = ViewPlugin.fromClass(class {
660
777
  }
661
778
  }
662
779
  });
663
- /** The live page: the analysis, the decorations over it, and what the focus and a modifier-click do. */
664
- export function livePage() {
780
+ /**
781
+ * The live page: the analysis, the decorations over it, and what the focus and a modifier-click do.
782
+ * `metrics` are the editor's text column measured before the editor is made, which the first drawn
783
+ * blocks estimate their height by; without them they assume `DEFAULT_METRICS` until it is measured.
784
+ */
785
+ export function livePage(metrics) {
665
786
  return [
666
787
  analysisField,
788
+ metricsField.init(() => metrics ?? DEFAULT_METRICS),
789
+ metricsWatcher,
667
790
  focusField,
668
791
  decorationsField,
669
792
  EditorView.focusChangeEffect.of((_state, focusing) => setFocused.of(focusing)),
package/dist/host.d.ts CHANGED
@@ -92,9 +92,21 @@ export interface NoteHost {
92
92
  /**
93
93
  * Draws a file the notebook keeps where a note embeds it: an image, audio, video, or a card. The
94
94
  * dimensions are the ones the embed declared, in CSS pixels, or `null`.
95
+ *
96
+ * In edit mode the node may be drawn outside the host's React tree, in the root of a block the
97
+ * editor draws, so it must not depend on the host's context; a node that throws there leaves the
98
+ * block's Markdown in its place (#90).
95
99
  */
96
100
  readonly renderAttachment?: (file: AttachmentRequest) => ReactNode;
97
- /** Draws a link to an internal address, through the host's router. */
101
+ /**
102
+ * Draws a link to an internal address, through the host's router.
103
+ *
104
+ * In edit mode the node may be drawn outside the host's React tree, in the root of a block the
105
+ * editor draws — a callout, a table, an embed, the properties — so it must not depend on the host's
106
+ * context: a router's `<Link>`, which reads its router from context, throws there. Hand it what it
107
+ * needs (a `navigate` captured where the router is) or draw an anchor whose click the host follows.
108
+ * A node that throws leaves the block's Markdown in its place (#90).
109
+ */
98
110
  readonly renderLink?: (link: LinkRequest) => ReactNode;
99
111
  /** The words the page shows, in the reader's language; each one left out is MemorySmith's English. */
100
112
  readonly strings?: NoteStringsInput;
package/dist/strings.d.ts CHANGED
@@ -109,6 +109,8 @@ export interface EditorWords {
109
109
  readonly attachFailed: string;
110
110
  /** Said after the host's step while more files wait their turn (#88). */
111
111
  readonly attachQueued: (count: number) => string;
112
+ /** Said over a drawn block's lines when drawing it failed, so the block is never left empty (#90). */
113
+ readonly blockFailed: string;
112
114
  /** A code block's language, chosen at its top right (#84). */
113
115
  readonly language: string;
114
116
  readonly plainText: string;
package/dist/strings.js CHANGED
@@ -113,6 +113,7 @@ export const ENGLISH = {
113
113
  attaching: 'Attaching…',
114
114
  attachFailed: 'The file was not attached',
115
115
  attachQueued: (count) => (count === 1 ? '1 more file waiting' : `${count} more files waiting`),
116
+ blockFailed: 'This block could not be drawn; its Markdown is below',
116
117
  language: 'Language',
117
118
  plainText: 'Plain text',
118
119
  mermaidDiagram: 'Mermaid diagram',
package/dist/theme.d.ts CHANGED
@@ -315,7 +315,10 @@ export interface Theme {
315
315
  };
316
316
  /** An icon per callout kind, drawn in the title in place of the stylesheet's. */
317
317
  readonly icons?: Readonly<Record<string, ReactNode>>;
318
- /** The slots the theme fills. */
318
+ /**
319
+ * The slots the theme fills. In edit mode a slot may be drawn outside the host's React tree, in
320
+ * the root of a block the editor draws, so it must not depend on the host's context (#90).
321
+ */
319
322
  readonly components?: Partial<ThemeComponents>;
320
323
  }
321
324
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@memorysmithapp/markdown-renderer",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "A React component that draws a MemorySmith note exactly as MemorySmith's reading page does.",
5
5
  "keywords": [
6
6
  "markdown",
@@ -79,7 +79,7 @@
79
79
  "unified": "11.0.5"
80
80
  },
81
81
  "devDependencies": {
82
- "@memorysmithapp/markdown-spec": "0.5.0",
82
+ "@memorysmithapp/markdown-spec": "0.6.0",
83
83
  "@types/hast": "3.0.5",
84
84
  "@types/react": "19.2.18",
85
85
  "@types/react-dom": "19.2.4",
package/structure.css CHANGED
@@ -15,6 +15,13 @@
15
15
  box-sizing: border-box;
16
16
  }
17
17
 
18
+ /* What is laid out of the flow hangs from the note, never from the page: a
19
+ host that scrolls the note in a pane of its own keeps a page of its own
20
+ height (#93). */
21
+ .markdown {
22
+ position: relative;
23
+ }
24
+
18
25
  .markdown img {
19
26
  max-width: 100%;
20
27
  }
@@ -397,6 +404,12 @@
397
404
  .markdown .cm-ms-math-block {
398
405
  display: flow-root;
399
406
  }
407
+ /* A block that could not be drawn shows its lines as they were written (#90). */
408
+ .markdown .cm-ms-block-failed pre {
409
+ margin: 0;
410
+ white-space: pre-wrap;
411
+ overflow-wrap: anywhere;
412
+ }
400
413
  /* A table's cell is where a click edits it: it fills its column and keeps a
401
414
  size of its own when it is empty, as every cell of a new table is, or it
402
415
  would be nothing wide and nothing could be clicked (#68). */
package/theme-default.css CHANGED
@@ -1913,6 +1913,20 @@
1913
1913
  .markdown .cm-ms-block:hover {
1914
1914
  box-shadow: 0 0 0 1px var(--ms-border);
1915
1915
  }
1916
+ .markdown .cm-ms-block-failed {
1917
+ padding: 0.5rem 0.75rem;
1918
+ border: 1px dashed var(--ms-border);
1919
+ border-radius: var(--ms-radius);
1920
+ }
1921
+ .markdown .cm-ms-block-failed-notice {
1922
+ margin: 0 0 0.35rem;
1923
+ color: var(--ms-text-soft);
1924
+ font-size: 0.85em;
1925
+ }
1926
+ .markdown .cm-ms-block-failed pre {
1927
+ font-family: var(--ms-font-mono);
1928
+ font-size: 0.9em;
1929
+ }
1916
1930
  .markdown .edit-block-controls {
1917
1931
  opacity: 0;
1918
1932
  padding: 0.1rem;