@memorysmithapp/markdown-renderer 0.4.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.
@@ -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
+ }
@@ -2,3 +2,4 @@ export { lineBreakOf, NoteEditor, type NoteEditorProps } from './NoteEditor.js';
2
2
  export { analyze, frontmatterSpan, type Analysis, type Construct, type Span } from './analyze.js';
3
3
  export { readTable, type Alignment, type Table, type TextChange } from './table.js';
4
4
  export { readFrontmatter, type Frontmatter, type FrontmatterEntry } from './frontmatter.js';
5
+ export { isSaveNotice, type SaveAction, type SaveNotice, type SaveOutcome, type SaveRefusal } from './saves.js';
@@ -4,3 +4,4 @@ export { lineBreakOf, NoteEditor } from './NoteEditor.js';
4
4
  export { analyze, frontmatterSpan } from './analyze.js';
5
5
  export { readTable } from './table.js';
6
6
  export { readFrontmatter } from './frontmatter.js';
7
+ export { isSaveNotice } from './saves.js';
@@ -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)),
@@ -1,18 +1,48 @@
1
1
  /**
2
- * Where the save of the author's edits stands (#64).
2
+ * Where the save of the author's edits stands (#64, #86).
3
3
  *
4
4
  * The host saves each change it is handed. When `onChange` answers with a promise, the editor
5
5
  * follows it: saving while it is pending, saved once it resolves, not saved when it rejects. Only
6
6
  * the latest change counts: a save that settles after a later change was handed says nothing, so
7
7
  * "Saved" always means the last text the author wrote. A host that answers with nothing has no
8
8
  * status, and the editor shows none.
9
+ *
10
+ * A host may also say why (#86): a save that resolves with a `SaveOutcome` succeeded with
11
+ * something the author must decide, the alert; one rejected with a `SaveRefusal` was refused for
12
+ * a reason the host explains, the error. Either carries the actions the author chooses from, and
13
+ * an action the author runs is followed like a save.
9
14
  */
10
- export type EditSave = 'idle' | 'saving' | 'saved' | 'failed';
15
+ /** One choice the host offers the author: its word, and what choosing it does. */
16
+ export interface SaveAction {
17
+ /** The button's word, in the reader's language: the host words it. */
18
+ readonly label: string;
19
+ /** Runs the choice; a promise answered is followed like a save, and may end in another notice. */
20
+ readonly run: () => Promise<void | SaveOutcome> | void;
21
+ }
22
+ /** What the host has to say about a save, and the choices it gives the author. */
23
+ export interface SaveNotice {
24
+ /** The dialog's title, and the status button's tooltip. */
25
+ readonly title: string;
26
+ /** The explanation in the dialog. */
27
+ readonly message: string;
28
+ readonly actions: readonly SaveAction[];
29
+ }
30
+ /** A save that succeeded with something the author must decide: what `onChange`'s promise resolves with. */
31
+ export type SaveOutcome = SaveNotice;
32
+ /** A save the host refused, with its reason and its choices: what `onChange`'s promise rejects with. */
33
+ export type SaveRefusal = SaveNotice;
34
+ export type EditSave = 'idle' | 'saving' | 'saved' | 'alert' | 'failed';
11
35
  export interface EditSaves {
12
36
  readonly state: EditSave;
37
+ /** What the host said of the save: present in `alert`, and in `failed` when it gave a reason. */
38
+ readonly notice: SaveNotice | null;
13
39
  /** Hands a change to the host and follows its save. */
14
40
  readonly hand: (markdown: string) => void;
15
41
  /** Hands the latest change to the host again, after a save failed. */
16
42
  readonly retry: () => void;
43
+ /** Runs one of the notice's actions and follows it like a save. */
44
+ readonly run: (action: SaveAction) => void;
17
45
  }
46
+ /** Whether a value is a notice a host gave: a title, a message and its actions. */
47
+ export declare function isSaveNotice(value: unknown): value is SaveNotice;
18
48
  export declare function useEditSaves(onChange: ((markdown: string) => unknown) | undefined): EditSaves;
@@ -1,16 +1,31 @@
1
1
  /**
2
- * Where the save of the author's edits stands (#64).
2
+ * Where the save of the author's edits stands (#64, #86).
3
3
  *
4
4
  * The host saves each change it is handed. When `onChange` answers with a promise, the editor
5
5
  * follows it: saving while it is pending, saved once it resolves, not saved when it rejects. Only
6
6
  * the latest change counts: a save that settles after a later change was handed says nothing, so
7
7
  * "Saved" always means the last text the author wrote. A host that answers with nothing has no
8
8
  * status, and the editor shows none.
9
+ *
10
+ * A host may also say why (#86): a save that resolves with a `SaveOutcome` succeeded with
11
+ * something the author must decide, the alert; one rejected with a `SaveRefusal` was refused for
12
+ * a reason the host explains, the error. Either carries the actions the author chooses from, and
13
+ * an action the author runs is followed like a save.
9
14
  */
10
15
  import { useCallback, useEffect, useRef, useState } from 'react';
11
16
  import { isThenable } from '../task-saves.js';
17
+ /** Whether a value is a notice a host gave: a title, a message and its actions. */
18
+ export function isSaveNotice(value) {
19
+ if (typeof value !== 'object' || value === null)
20
+ return false;
21
+ const notice = value;
22
+ return (typeof notice.title === 'string' &&
23
+ typeof notice.message === 'string' &&
24
+ Array.isArray(notice.actions) &&
25
+ notice.actions.every((action) => typeof action === 'object' && action !== null && typeof action.label === 'string' && typeof action.run === 'function'));
26
+ }
12
27
  export function useEditSaves(onChange) {
13
- const [state, setState] = useState('idle');
28
+ const [status, setStatus] = useState({ state: 'idle', notice: null });
14
29
  const latest = useRef({ onChange, count: 0, text: null, alive: true });
15
30
  latest.current.onChange = onChange;
16
31
  useEffect(() => {
@@ -20,33 +35,39 @@ export function useEditSaves(onChange) {
20
35
  self.alive = false;
21
36
  };
22
37
  }, []);
23
- const hand = useCallback((markdown) => {
38
+ /** Follows what the host answered, a change's save or an action; `done` is the state of an answer that is not a promise. */
39
+ const follow = useCallback((answer, done) => {
24
40
  const self = latest.current;
25
- self.text = markdown;
26
41
  const count = ++self.count;
27
- const settle = (next) => {
42
+ const settle = (state, notice = null) => {
28
43
  if (self.alive && count === self.count)
29
- setState(next);
44
+ setStatus({ state, notice });
30
45
  };
31
46
  let result;
32
47
  try {
33
- result = self.onChange?.(markdown);
48
+ result = answer();
34
49
  }
35
- catch {
36
- settle('failed');
50
+ catch (reason) {
51
+ settle('failed', isSaveNotice(reason) ? reason : null);
37
52
  return;
38
53
  }
39
54
  if (!isThenable(result)) {
40
- settle('idle');
55
+ settle(done);
41
56
  return;
42
57
  }
43
58
  settle('saving');
44
- result.then(() => settle('saved'), () => settle('failed'));
59
+ result.then((value) => (isSaveNotice(value) ? settle('alert', value) : settle('saved')), (reason) => settle('failed', isSaveNotice(reason) ? reason : null));
45
60
  }, []);
61
+ const hand = useCallback((markdown) => {
62
+ latest.current.text = markdown;
63
+ follow(() => latest.current.onChange?.(markdown), 'idle');
64
+ }, [follow]);
46
65
  const retry = useCallback(() => {
47
66
  const text = latest.current.text;
48
67
  if (text !== null)
49
68
  hand(text);
50
69
  }, [hand]);
51
- return { state, hand, retry };
70
+ // An action that answers nothing has done what it does: the author decided, and nothing is left to say.
71
+ const run = useCallback((action) => follow(() => action.run(), 'saved'), [follow]);
72
+ return { state: status.state, notice: status.notice, hand, retry, run };
52
73
  }