@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.
- package/README.md +24 -6
- package/dist/NoteBody.d.ts +16 -4
- package/dist/NoteBody.js +9 -8
- package/dist/edit/AttachDialog.d.ts +3 -1
- package/dist/edit/AttachDialog.js +2 -2
- package/dist/edit/NoteEditor.d.ts +2 -1
- package/dist/edit/NoteEditor.js +83 -23
- package/dist/edit/SaveNoticeDialog.d.ts +15 -0
- package/dist/edit/SaveNoticeDialog.js +41 -0
- package/dist/edit/blocks.d.ts +1 -0
- package/dist/edit/blocks.js +1 -0
- package/dist/edit/heights.d.ts +34 -0
- package/dist/edit/heights.js +101 -0
- package/dist/edit/index.d.ts +1 -0
- package/dist/edit/index.js +1 -0
- package/dist/edit/live.d.ts +7 -2
- package/dist/edit/live.js +150 -27
- package/dist/edit/saves.d.ts +32 -2
- package/dist/edit/saves.js +33 -12
- package/dist/host.d.ts +37 -3
- package/dist/host.js +8 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/strings.d.ts +4 -0
- package/dist/strings.js +2 -0
- package/dist/theme.d.ts +4 -1
- package/package.json +2 -2
- package/structure.css +37 -0
- package/theme-default.css +44 -3
|
@@ -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
|
+
}
|
package/dist/edit/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/edit/index.js
CHANGED
package/dist/edit/live.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
28
|
-
|
|
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'
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
664
|
-
|
|
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/edit/saves.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/edit/saves.js
CHANGED
|
@@ -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 [
|
|
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
|
-
|
|
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 = (
|
|
42
|
+
const settle = (state, notice = null) => {
|
|
28
43
|
if (self.alive && count === self.count)
|
|
29
|
-
|
|
44
|
+
setStatus({ state, notice });
|
|
30
45
|
};
|
|
31
46
|
let result;
|
|
32
47
|
try {
|
|
33
|
-
result =
|
|
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(
|
|
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
|
-
|
|
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
|
}
|