@memorysmithapp/markdown-renderer 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,162 @@
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
+ import { readImageSize } from './image-size.js';
12
+ /** What an estimate assumes before the editor has been measured, as in a test with no layout. */
13
+ export const DEFAULT_METRICS = { width: 700, lineHeight: 24, charWidth: 8 };
14
+ /**
15
+ * The metrics of a text column laid out in `parent`, read before the editor exists there, so the
16
+ * first estimates are made at the width the note is drawn at. Falls back to `DEFAULT_METRICS` where
17
+ * nothing is laid out.
18
+ */
19
+ export function measureMetrics(parent) {
20
+ const probe = document.createElement('div');
21
+ probe.style.cssText = PROBE;
22
+ probe.textContent = SAMPLE;
23
+ parent.appendChild(probe);
24
+ const box = probe.getBoundingClientRect();
25
+ const width = parent.clientWidth;
26
+ probe.remove();
27
+ return withinReason({ width, lineHeight: box.height, charWidth: box.width / SAMPLE.length, ...measureLines(parent) });
28
+ }
29
+ const PROBE = 'position: absolute; visibility: hidden; white-space: pre; pointer-events: none';
30
+ const SAMPLE = 'abcdefghijklmnopqrstuvwxyz';
31
+ /**
32
+ * Each level of heading and a line of code laid out in `parent` as the editor draws them, by the
33
+ * classes of their line, so whatever the theme gives them — a face, a size, the space above, the
34
+ * sides of a code block — is what is measured (#94).
35
+ */
36
+ export function measureLines(parent) {
37
+ const classes = [1, 2, 3, 4, 5, 6].map((level) => `cm-ms-heading cm-ms-h${level}`).concat('cm-ms-code-line');
38
+ const probes = classes.map((className) => {
39
+ const probe = document.createElement('div');
40
+ probe.className = `cm-line ${className}`;
41
+ probe.style.cssText = PROBE;
42
+ probe.textContent = SAMPLE;
43
+ parent.appendChild(probe);
44
+ return probe;
45
+ });
46
+ const read = probes.map((probe) => {
47
+ const box = probe.getBoundingClientRect();
48
+ const style = getComputedStyle(probe);
49
+ const number = (value) => Number.parseFloat(value) || 0;
50
+ const padding = number(style.paddingTop);
51
+ const inset = number(style.paddingLeft) + number(style.paddingRight) + number(style.borderLeftWidth) + number(style.borderRightWidth);
52
+ return { lineHeight: box.height - padding - number(style.paddingBottom), charWidth: (box.width - inset) / SAMPLE.length, padding, inset };
53
+ });
54
+ for (const probe of probes)
55
+ probe.remove();
56
+ return { headings: read.slice(0, 6), code: read[6] };
57
+ }
58
+ /** Metrics as measured, any that could not be read replaced by the default. */
59
+ export function withinReason(metrics) {
60
+ const usable = (value, fallback) => (Number.isFinite(value) && value > 1 ? value : fallback);
61
+ const laidOut = (line) => line.lineHeight > 1 && line.charWidth > 0.5;
62
+ const headings = metrics.headings?.length === 6 && metrics.headings.every(laidOut) ? metrics.headings : undefined;
63
+ const code = metrics.code && laidOut(metrics.code) ? metrics.code : undefined;
64
+ return {
65
+ width: usable(metrics.width, DEFAULT_METRICS.width),
66
+ lineHeight: usable(metrics.lineHeight, DEFAULT_METRICS.lineHeight),
67
+ charWidth: usable(metrics.charWidth, DEFAULT_METRICS.charWidth),
68
+ ...(headings ? { headings } : {}),
69
+ ...(code ? { code } : {}),
70
+ };
71
+ }
72
+ /** A line of `length` visible characters drawn as `line`, wrapped at the column's width. */
73
+ function lineOf(line, length, width) {
74
+ const perLine = Math.max(4, Math.floor((width - line.inset) / line.charWidth));
75
+ return Math.round(line.padding + line.lineHeight * Math.max(1, Math.ceil(length / perLine)));
76
+ }
77
+ /**
78
+ * The height of a line of the text the editor shows, `length` characters visible, as the page draws
79
+ * it where CodeMirror would guess it from the text's metrics alone (#94): a heading at its `level`,
80
+ * a line of code, or a line of text, which on a narrow column wraps sooner than CodeMirror counts.
81
+ * `null` where that kind of line was not measured.
82
+ */
83
+ export function lineHeightOf(kind, length, metrics) {
84
+ if (kind === 'text')
85
+ return lineOf({ lineHeight: metrics.lineHeight, charWidth: metrics.charWidth, padding: 0, inset: 0 }, length, metrics.width);
86
+ const line = kind === 'code' ? metrics.code : metrics.headings?.[kind.heading - 1];
87
+ return line ? lineOf(line, length, metrics.width) : null;
88
+ }
89
+ /** The lines a text takes in a column `width` wide, each of its lines at least one. */
90
+ function wrapped(lines, width, metrics) {
91
+ const perLine = Math.max(8, Math.floor(width / metrics.charWidth));
92
+ return lines.reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / perLine)), 0);
93
+ }
94
+ /**
95
+ * Below this width the default theme lays a property's value under its key, not beside it; it is
96
+ * the text column of its 560px breakpoint.
97
+ */
98
+ const STACKED_PROPERTIES = 528;
99
+ /** What an image declares no height for is drawn at before it loads, near enough (#91). */
100
+ const UNDECLARED_IMAGE_HEIGHT = 200;
101
+ /** The height a block of this kind and these lines is drawn at, near enough to lay out the page. */
102
+ export function estimateHeight(kind, source, metrics) {
103
+ const { lineHeight } = metrics;
104
+ const lines = source.split('\n');
105
+ switch (kind) {
106
+ case 'frontmatter': {
107
+ // The name's field, then a row per key of the top level, which a list's items share.
108
+ const keys = lines.filter((line) => /^[^\s#-][^:]*:/.test(line)).length;
109
+ const row = (metrics.width < STACKED_PROPERTIES ? 2.8 : 1.4) * lineHeight;
110
+ return Math.round(2.5 * lineHeight + keys * row);
111
+ }
112
+ case 'callout': {
113
+ // Its title and its body, as text inside its padding and its border.
114
+ const text = lines.map((line) => line.replace(/^(?:\s*>)+\s?/, ''));
115
+ return Math.round(lineHeight + wrapped(text, metrics.width - 32, metrics) * lineHeight);
116
+ }
117
+ case 'table': {
118
+ // A row per line but the delimiter row. The columns share the width by what they hold, so a
119
+ // row wraps as the text of all its cells would in the width their padding leaves.
120
+ const rows = lines.filter((line, index) => index !== 1 || !/^[\s|:-]+$/.test(line));
121
+ const cells = (row) => row.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map((cell) => cell.trim());
122
+ const columns = Math.max(1, ...rows.map((row) => cells(row).length));
123
+ const width = metrics.width - 16 * columns;
124
+ const height = rows.reduce((sum, row) => sum + 9 + lineHeight * wrapped([cells(row).join('')], width, metrics), 0);
125
+ return Math.round(0.75 * lineHeight + height);
126
+ }
127
+ case 'embed':
128
+ // What a note or a file embeds is not known before it is drawn: a card's height.
129
+ return Math.round(8 * lineHeight);
130
+ case 'diagram':
131
+ return Math.round(12 * lineHeight);
132
+ case 'preview':
133
+ return Math.round(lineHeight + wrapped(lines, metrics.width, metrics) * lineHeight);
134
+ case 'add-properties':
135
+ return Math.round(1.5 * lineHeight);
136
+ case 'image': {
137
+ // The height it declares, or its width at a photograph's proportions, or a guess (#91).
138
+ const size = readImageSize(source.trim());
139
+ if (size?.height != null)
140
+ return size.height;
141
+ return size?.width != null ? Math.round(size.width * 0.75) : UNDECLARED_IMAGE_HEIGHT;
142
+ }
143
+ }
144
+ }
145
+ /** The heights blocks were drawn at, by width and content; the oldest are let go past the limit. */
146
+ const measured = new Map();
147
+ const MEASURED_LIMIT = 2000;
148
+ const measuredKey = (identity, metrics) => `${Math.round(metrics.width)}\u0000${identity}`;
149
+ /** The height a block of this content was drawn at at this width, if it was. */
150
+ export function measuredHeight(identity, metrics) {
151
+ return measured.get(measuredKey(identity, metrics));
152
+ }
153
+ /** Remembers the height a block was drawn at. */
154
+ export function rememberHeight(identity, metrics, height) {
155
+ if (!(height > 0))
156
+ return;
157
+ const key = measuredKey(identity, metrics);
158
+ measured.delete(key);
159
+ measured.set(key, height);
160
+ if (measured.size > MEASURED_LIMIT)
161
+ measured.delete(measured.keys().next().value);
162
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The size an image is drawn at, as its notation writes it (#98): the dimensions after the pipe
3
+ * of a Markdown image's description, `![description|300](address)` (`SPEC.md` §3.14), or of the
4
+ * embed of a file the notebook keeps, `![[photo.jpg|300]]` (§5.7). The editor reads them and
5
+ * rewrites them, and no other byte.
6
+ */
7
+ import type { TextChange } from './table.js';
8
+ export interface ImageSize {
9
+ /** The width it declares, or `null` when it is drawn at its own size. */
10
+ readonly width: number | null;
11
+ readonly height: number | null;
12
+ /**
13
+ * Where in the source the dimensions are written, their pipe included; an empty span where they
14
+ * would be written when there are none.
15
+ */
16
+ readonly at: {
17
+ readonly from: number;
18
+ readonly to: number;
19
+ };
20
+ /** What separates them from the rest: `|`, or `\|` in an embed whose pipe is escaped. */
21
+ readonly pipe: string;
22
+ }
23
+ /** The largest width the notation writes: five digits (`SPEC.md` §3.14). */
24
+ export declare const MAX_IMAGE_WIDTH = 99999;
25
+ /**
26
+ * The size an image's source declares, and where it is written. `null` for a source that is not an
27
+ * image's notation, and for an embed whose pipe holds something other than dimensions, which the
28
+ * editor does not overwrite.
29
+ */
30
+ export declare function readImageSize(source: string): ImageSize | null;
31
+ /**
32
+ * The change that draws the image whose source starts at `from` at `width`, its height following,
33
+ * or at its own size for `null`: the dimensions written, replaced or taken away, and nothing else.
34
+ * Empty when the source is not an image's notation or already says so.
35
+ */
36
+ export declare function resizeImage(source: string, from: number, width: number | null): TextChange[];
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The size an image is drawn at, as its notation writes it (#98): the dimensions after the pipe
3
+ * of a Markdown image's description, `![description|300](address)` (`SPEC.md` §3.14), or of the
4
+ * embed of a file the notebook keeps, `![[photo.jpg|300]]` (§5.7). The editor reads them and
5
+ * rewrites them, and no other byte.
6
+ */
7
+ import { readDimensions, readImageAlt } from '../image-dimensions.js';
8
+ /** The largest width the notation writes: five digits (`SPEC.md` §3.14). */
9
+ export const MAX_IMAGE_WIDTH = 99999;
10
+ const MARKDOWN_IMAGE = /^!\[([^\]]*)\]\(/;
11
+ const EMBED = /^!\[\[([^\]]*)\]\]$/;
12
+ /**
13
+ * The size an image's source declares, and where it is written. `null` for a source that is not an
14
+ * image's notation, and for an embed whose pipe holds something other than dimensions, which the
15
+ * editor does not overwrite.
16
+ */
17
+ export function readImageSize(source) {
18
+ // A block's line may be indented, or end in spaces, around its one embed.
19
+ const lead = source.length - source.trimStart().length;
20
+ const size = readTrimmed(source.trim());
21
+ return size && lead ? { ...size, at: { from: size.at.from + lead, to: size.at.to + lead } } : size;
22
+ }
23
+ function readTrimmed(source) {
24
+ const image = MARKDOWN_IMAGE.exec(source);
25
+ if (image) {
26
+ const alt = image[1] ?? '';
27
+ const start = 2;
28
+ const read = readImageAlt(alt);
29
+ if (read.width === null)
30
+ return { width: null, height: null, at: { from: start + alt.length, to: start + alt.length }, pipe: '|' };
31
+ return { width: read.width, height: read.height, at: { from: start + alt.lastIndexOf('|'), to: start + alt.length }, pipe: '|' };
32
+ }
33
+ const embed = EMBED.exec(source);
34
+ if (embed) {
35
+ const inner = embed[1] ?? '';
36
+ const start = 3;
37
+ const pipe = /\\?\|/.exec(inner);
38
+ if (!pipe)
39
+ return { width: null, height: null, at: { from: start + inner.length, to: start + inner.length }, pipe: '|' };
40
+ const measured = readDimensions(inner.slice(pipe.index + pipe[0].length));
41
+ if (!measured)
42
+ return null;
43
+ return { width: measured.width, height: measured.height, at: { from: start + pipe.index, to: start + inner.length }, pipe: pipe[0] };
44
+ }
45
+ return null;
46
+ }
47
+ /**
48
+ * The change that draws the image whose source starts at `from` at `width`, its height following,
49
+ * or at its own size for `null`: the dimensions written, replaced or taken away, and nothing else.
50
+ * Empty when the source is not an image's notation or already says so.
51
+ */
52
+ export function resizeImage(source, from, width) {
53
+ const size = readImageSize(source);
54
+ if (!size)
55
+ return [];
56
+ const wanted = width === null ? null : Math.min(MAX_IMAGE_WIDTH, Math.max(1, Math.round(width)));
57
+ if (wanted === size.width && (wanted === null || size.height === null))
58
+ return [];
59
+ const insert = wanted === null ? '' : `${size.pipe}${wanted}`;
60
+ return [{ from: from + size.at.from, to: from + size.at.to, insert }];
61
+ }
@@ -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;
@@ -23,6 +24,12 @@ export declare const analysisField: StateField<Analysis>;
23
24
  export declare const setFocused: import("@codemirror/state").StateEffectType<boolean>;
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>;
27
+ /** The metrics of the editor's text column, which a drawn block estimates its height by (#91). */
28
+ export declare const metricsField: StateField<BlockMetrics>;
26
29
  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;
30
+ /**
31
+ * The live page: the analysis, the decorations over it, and what the focus and a modifier-click do.
32
+ * `metrics` are the editor's text column measured before the editor is made, which the first drawn
33
+ * blocks estimate their height by; without them they assume `DEFAULT_METRICS` until it is measured.
34
+ */
35
+ export declare function livePage(metrics?: BlockMetrics): Extension;