@memorysmithapp/markdown-renderer 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,22 @@ export interface BlockMetrics {
13
13
  readonly width: number;
14
14
  readonly lineHeight: number;
15
15
  readonly charWidth: number;
16
+ /**
17
+ * Each level of heading as the theme draws it in edit mode, `h1` first, and a line of a code
18
+ * block: its line height, its characters, the space above it and the width its sides take (#94).
19
+ * Absent where nothing is laid out.
20
+ */
21
+ readonly headings?: readonly LineMetrics[];
22
+ readonly code?: LineMetrics;
23
+ }
24
+ /** A kind of line as the theme draws it, which differs from the text's: a heading, a line of code. */
25
+ export interface LineMetrics {
26
+ readonly lineHeight: number;
27
+ readonly charWidth: number;
28
+ /** The space above it, on a heading. */
29
+ readonly padding: number;
30
+ /** The width its sides take from the column: a code block's padding and border. */
31
+ readonly inset: number;
16
32
  }
17
33
  /** What an estimate assumes before the editor has been measured, as in a test with no layout. */
18
34
  export declare const DEFAULT_METRICS: BlockMetrics;
@@ -22,10 +38,28 @@ export declare const DEFAULT_METRICS: BlockMetrics;
22
38
  * nothing is laid out.
23
39
  */
24
40
  export declare function measureMetrics(parent: HTMLElement): BlockMetrics;
41
+ /**
42
+ * Each level of heading and a line of code laid out in `parent` as the editor draws them, by the
43
+ * classes of their line, so whatever the theme gives them — a face, a size, the space above, the
44
+ * sides of a code block — is what is measured (#94).
45
+ */
46
+ export declare function measureLines(parent: HTMLElement): {
47
+ headings: LineMetrics[];
48
+ code: LineMetrics;
49
+ };
25
50
  /** Metrics as measured, any that could not be read replaced by the default. */
26
51
  export declare function withinReason(metrics: BlockMetrics): BlockMetrics;
52
+ /**
53
+ * The height of a line of the text the editor shows, `length` characters visible, as the page draws
54
+ * it where CodeMirror would guess it from the text's metrics alone (#94): a heading at its `level`,
55
+ * a line of code, or a line of text, which on a narrow column wraps sooner than CodeMirror counts.
56
+ * `null` where that kind of line was not measured.
57
+ */
58
+ export declare function lineHeightOf(kind: 'text' | 'code' | {
59
+ readonly heading: number;
60
+ }, length: number, metrics: BlockMetrics): number | null;
27
61
  /** What a block is, for its estimate. */
28
- export type BlockKind = 'frontmatter' | 'callout' | 'table' | 'embed' | 'diagram' | 'preview' | 'add-properties';
62
+ export type BlockKind = 'frontmatter' | 'callout' | 'table' | 'embed' | 'diagram' | 'preview' | 'add-properties' | 'image';
29
63
  /** The height a block of this kind and these lines is drawn at, near enough to lay out the page. */
30
64
  export declare function estimateHeight(kind: BlockKind, source: string, metrics: BlockMetrics): number;
31
65
  /** The height a block of this content was drawn at at this width, if it was. */
@@ -8,6 +8,7 @@
8
8
  * properties — and a block drawn once at a width is remembered at its measured height, so the same
9
9
  * block made again, moved by an edit above it or in the next edit of the note, starts right.
10
10
  */
11
+ import { readImageSize } from './image-size.js';
11
12
  /** What an estimate assumes before the editor has been measured, as in a test with no layout. */
12
13
  export const DEFAULT_METRICS = { width: 700, lineHeight: 24, charWidth: 8 };
13
14
  /**
@@ -17,23 +18,74 @@ export const DEFAULT_METRICS = { width: 700, lineHeight: 24, charWidth: 8 };
17
18
  */
18
19
  export function measureMetrics(parent) {
19
20
  const probe = document.createElement('div');
20
- probe.style.cssText = 'position: absolute; visibility: hidden; white-space: pre; pointer-events: none';
21
- probe.textContent = 'abcdefghijklmnopqrstuvwxyz';
21
+ probe.style.cssText = PROBE;
22
+ probe.textContent = SAMPLE;
22
23
  parent.appendChild(probe);
23
24
  const box = probe.getBoundingClientRect();
24
25
  const width = parent.clientWidth;
25
26
  probe.remove();
26
- return withinReason({ width, lineHeight: box.height, charWidth: box.width / 26 });
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] };
27
57
  }
28
58
  /** Metrics as measured, any that could not be read replaced by the default. */
29
59
  export function withinReason(metrics) {
30
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;
31
64
  return {
32
65
  width: usable(metrics.width, DEFAULT_METRICS.width),
33
66
  lineHeight: usable(metrics.lineHeight, DEFAULT_METRICS.lineHeight),
34
67
  charWidth: usable(metrics.charWidth, DEFAULT_METRICS.charWidth),
68
+ ...(headings ? { headings } : {}),
69
+ ...(code ? { code } : {}),
35
70
  };
36
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
+ }
37
89
  /** The lines a text takes in a column `width` wide, each of its lines at least one. */
38
90
  function wrapped(lines, width, metrics) {
39
91
  const perLine = Math.max(8, Math.floor(width / metrics.charWidth));
@@ -44,6 +96,8 @@ function wrapped(lines, width, metrics) {
44
96
  * the text column of its 560px breakpoint.
45
97
  */
46
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;
47
101
  /** The height a block of this kind and these lines is drawn at, near enough to lay out the page. */
48
102
  export function estimateHeight(kind, source, metrics) {
49
103
  const { lineHeight } = metrics;
@@ -79,6 +133,13 @@ export function estimateHeight(kind, source, metrics) {
79
133
  return Math.round(lineHeight + wrapped(lines, metrics.width, metrics) * lineHeight);
80
134
  case 'add-properties':
81
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
+ }
82
143
  }
83
144
  }
84
145
  /** The heights blocks were drawn at, by width and content; the oldest are let go past the limit. */
@@ -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
+ }
@@ -24,6 +24,8 @@ export declare const analysisField: StateField<Analysis>;
24
24
  export declare const setFocused: import("@codemirror/state").StateEffectType<boolean>;
25
25
  /** A grammar a code block names has arrived: its tokens can be coloured now (#84). */
26
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>;
27
29
  export declare const decorationsField: StateField<DecorationSet>;
28
30
  /**
29
31
  * The live page: the analysis, the decorations over it, and what the focus and a modifier-click do.