ntk 2.2.0 → 3.0.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.
Files changed (47) hide show
  1. package/README.md +156 -151
  2. package/lib/app.js +54 -21
  3. package/lib/cleanup.js +14 -0
  4. package/lib/drawable.js +12 -18
  5. package/lib/events_map.js +92 -98
  6. package/lib/fontconfig.js +123 -17
  7. package/lib/glyphset.js +40 -21
  8. package/lib/image.js +138 -0
  9. package/lib/index.js +123 -57
  10. package/lib/path.js +734 -0
  11. package/lib/picture.js +84 -41
  12. package/lib/pixmap.js +50 -39
  13. package/lib/rasterize.js +178 -0
  14. package/lib/renderingcontext_2d.js +1078 -695
  15. package/lib/renderingcontext_opengl.js +53 -46
  16. package/lib/renderingcontext_x11.js +59 -67
  17. package/lib/text/font.js +196 -0
  18. package/lib/text/fontmanager.js +233 -0
  19. package/lib/text/fontsource.js +179 -0
  20. package/lib/text/glyphs.js +456 -0
  21. package/lib/text/layout.js +356 -0
  22. package/lib/text/shape.js +141 -0
  23. package/lib/trapezoid.js +131 -0
  24. package/lib/widgets/css.js +676 -0
  25. package/lib/widgets/highlight.js +97 -0
  26. package/lib/widgets/htmlview.js +879 -0
  27. package/lib/widgets/markdown.js +148 -0
  28. package/lib/widgets/markdownview.js +487 -0
  29. package/lib/widgets/mermaid.js +720 -0
  30. package/lib/widgets/svgview.js +528 -0
  31. package/lib/widgets/tex.js +681 -0
  32. package/lib/window.js +642 -308
  33. package/package.json +60 -56
  34. package/examples/example-await.js +0 -23
  35. package/examples/example-co.js +0 -13
  36. package/examples/example.js +0 -14
  37. package/examples/fill-styles.js +0 -39
  38. package/examples/glclock.js +0 -100
  39. package/examples/glxpixmap.js +0 -12
  40. package/examples/gradient.js +0 -41
  41. package/examples/package.json +0 -17
  42. package/examples/simpletex.js +0 -72
  43. package/examples/teapot.js +0 -104
  44. package/examples/teapot.json +0 -6
  45. package/examples/text-rendering.js +0 -44
  46. package/lib/fontface.js +0 -103
  47. package/lib/widgets/webview.js +0 -33
@@ -0,0 +1,148 @@
1
+ // Markdown parsing for MarkdownView — a thin adapter over the `marked`
2
+ // lexer (CommonMark + GFM), mapping its token stream onto the small block /
3
+ // inline AST the widget renders. The parser used to be hand-rolled here;
4
+ // it was replaced after subtle divergences from CommonMark surfaced
5
+ // (e.g. intra-word `WM_DELETE_WINDOW` sprouting emphasis).
6
+ //
7
+ // block: { type: 'heading', level, children }
8
+ // | { type: 'paragraph', children }
9
+ // | { type: 'code', text, lang }
10
+ // | { type: 'blockquote', blocks }
11
+ // | { type: 'list', ordered, start, items: [ blocks ] }
12
+ // | { type: 'hr' }
13
+ // | { type: 'table', align: ['left'|'center'|'right'],
14
+ // header: [ inline[] ], rows: [ [ inline[] ] ] }
15
+ // inline: { type: 'text', text }
16
+ // | { type: 'strong'|'em', children }
17
+ // | { type: 'code', text }
18
+ // | { type: 'link', children, href }
19
+
20
+ import { Lexer } from 'marked';
21
+
22
+ export function parseMarkdown(src) {
23
+ return mapBlocks(new Lexer({ gfm: true }).lex(String(src ?? '')));
24
+ }
25
+
26
+ /** inline-only parsing (exported for tests / embedders) */
27
+ export function parseInline(text) {
28
+ return mapInline(Lexer.lexInline(String(text ?? '')));
29
+ }
30
+
31
+ function mapBlocks(tokens) {
32
+ const blocks = [];
33
+ for (const t of tokens) {
34
+ switch (t.type) {
35
+ case 'heading':
36
+ blocks.push({ type: 'heading', level: t.depth, children: mapInline(t.tokens) });
37
+ break;
38
+ case 'paragraph':
39
+ // tight list items carry their inline content in a block-level 'text'
40
+ case 'text':
41
+ blocks.push({ type: 'paragraph', children: mapInline(t.tokens ?? [t]) });
42
+ break;
43
+ case 'code':
44
+ blocks.push({
45
+ type: 'code',
46
+ text: t.text,
47
+ lang: (t.lang || '').split(/\s+/)[0]
48
+ });
49
+ break;
50
+ case 'blockquote':
51
+ blocks.push({ type: 'blockquote', blocks: mapBlocks(t.tokens) });
52
+ break;
53
+ case 'list':
54
+ blocks.push({
55
+ type: 'list',
56
+ ordered: !!t.ordered,
57
+ start: typeof t.start === 'number' ? t.start : 1,
58
+ items: t.items.map((item) => mapBlocks(item.tokens))
59
+ });
60
+ break;
61
+ case 'hr':
62
+ blocks.push({ type: 'hr' });
63
+ break;
64
+ case 'table':
65
+ blocks.push({
66
+ type: 'table',
67
+ align: t.align.map((a) => a ?? 'left'),
68
+ header: t.header.map((cell) => mapInline(cell.tokens)),
69
+ rows: t.rows.map((row) => row.map((cell) => mapInline(cell.tokens)))
70
+ });
71
+ break;
72
+ case 'html':
73
+ blocks.push({ type: 'paragraph', children: [{ type: 'text', text: t.text.trim() }] });
74
+ break;
75
+ case 'space':
76
+ case 'def':
77
+ break;
78
+ default:
79
+ if (t.tokens) blocks.push({ type: 'paragraph', children: mapInline(t.tokens) });
80
+ else if (t.text) blocks.push({ type: 'paragraph', children: [{ type: 'text', text: t.text }] });
81
+ break;
82
+ }
83
+ }
84
+ return blocks;
85
+ }
86
+
87
+ function mapInline(tokens) {
88
+ const nodes = [];
89
+ const pushText = (text) => {
90
+ // soft line breaks inside a paragraph render as spaces (the layout
91
+ // engine treats \n as a hard break)
92
+ text = text.replace(/\n/g, ' ');
93
+ const last = nodes[nodes.length - 1];
94
+ if (last && last.type === 'text') last.text += text;
95
+ else nodes.push({ type: 'text', text });
96
+ };
97
+ for (const t of tokens ?? []) {
98
+ switch (t.type) {
99
+ case 'text':
100
+ if (t.tokens) nodes.push(...mapInline(t.tokens));
101
+ else pushText(t.escaped ? unescapeHtml(t.text) : t.text);
102
+ break;
103
+ case 'escape':
104
+ pushText(t.text);
105
+ break;
106
+ case 'strong':
107
+ nodes.push({ type: 'strong', children: mapInline(t.tokens) });
108
+ break;
109
+ case 'em':
110
+ nodes.push({ type: 'em', children: mapInline(t.tokens) });
111
+ break;
112
+ case 'del':
113
+ // no strikethrough decoration (yet): render the content plainly
114
+ nodes.push(...mapInline(t.tokens));
115
+ break;
116
+ case 'codespan':
117
+ nodes.push({ type: 'code', text: t.text });
118
+ break;
119
+ case 'link':
120
+ nodes.push({ type: 'link', children: mapInline(t.tokens), href: t.href });
121
+ break;
122
+ case 'image':
123
+ // no image rendering: alt text as a link to the source
124
+ nodes.push({
125
+ type: 'link',
126
+ children: [{ type: 'text', text: t.text || t.href }],
127
+ href: t.href
128
+ });
129
+ break;
130
+ case 'br':
131
+ pushText(' ');
132
+ break;
133
+ case 'html':
134
+ pushText(t.raw);
135
+ break;
136
+ default:
137
+ if (t.text) pushText(t.text);
138
+ break;
139
+ }
140
+ }
141
+ return nodes;
142
+ }
143
+
144
+ const ENTITIES = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'" };
145
+
146
+ function unescapeHtml(text) {
147
+ return text.replace(/&(?:amp|lt|gt|quot|#39);/g, (m) => ENTITIES[m]);
148
+ }
@@ -0,0 +1,487 @@
1
+ import { TextLayout } from '../text/layout.js';
2
+ import { tokenize } from './highlight.js';
3
+ import { parseMarkdown } from './markdown.js';
4
+ import { layoutMermaid, parseMermaid } from './mermaid.js';
5
+ import { layoutTex } from './tex.js';
6
+
7
+ const MATH_LANGS = new Set(['math', 'tex', 'latex', 'katex']);
8
+
9
+ const DEFAULT_THEME = {
10
+ family: 'sans-serif',
11
+ monoFamily: 'monospace',
12
+ size: 16,
13
+ color: '#222222',
14
+ background: 'white',
15
+ lineHeight: 1.35,
16
+ headingScales: [2, 1.5, 1.25, 1.1, 1, 0.9],
17
+ linkColor: '#0b61c9',
18
+ codeColor: '#8f2b4f',
19
+ codeBg: '#f2f2f2',
20
+ quoteColor: '#555555',
21
+ quoteBar: '#d8d8d8',
22
+ hrColor: '#dddddd',
23
+ tableBorder: '#d8d8d8',
24
+ tableHeaderBg: '#f7f7f7',
25
+ blockSpacing: 0.7, // em (of base size) between blocks
26
+ padding: 16,
27
+ // fence syntax highlighting (lib/widgets/highlight.js token kinds);
28
+ // unlisted kinds (incl. 'plain') fall back to the base text color
29
+ codeTheme: {
30
+ keyword: '#a626a4',
31
+ literal: '#0184bc',
32
+ string: '#50a14f',
33
+ number: '#986801',
34
+ comment: '#a0a1a7',
35
+ tag: '#e45649',
36
+ attr: '#986801',
37
+ function: '#4078f2'
38
+ }
39
+ };
40
+
41
+ /**
42
+ * A widget that renders markdown into a window (or any 2d context target).
43
+ *
44
+ * All layout runs client-side through the text API (`FontManager` /
45
+ * `TextLayout`): heading/paragraph/list/quote/code blocks become styled
46
+ * spans, get shaped (kerning, fallback, bidi) and wrapped to the container
47
+ * width; drawing batches glyphs into minimal CompositeGlyphs requests.
48
+ *
49
+ * const view = new MarkdownView(wnd, { theme: { size: 15 } });
50
+ * view.setMarkdown('# Hello\n\nSome *markdown* here.');
51
+ * wnd.map();
52
+ *
53
+ * Standalone use (no window attach): `view.layout(width)` then
54
+ * `view.draw(ctx, x, y)`; `view.contentHeight` after layout.
55
+ */
56
+ export default class MarkdownView {
57
+ constructor(window, opts = {}) {
58
+ this.window = window ?? null;
59
+ this._standaloneFonts = opts.fonts ?? null; // for headless layout without a window
60
+ this.theme = { ...DEFAULT_THEME, ...(opts.theme || {}) };
61
+ if (opts.theme?.codeTheme) {
62
+ this.theme.codeTheme = { ...DEFAULT_THEME.codeTheme, ...opts.theme.codeTheme };
63
+ }
64
+ if (opts.padding !== undefined) this.theme.padding = opts.padding;
65
+ this._blocks = [];
66
+ this._items = []; // placed draw items from the last layout()
67
+ this._links = []; // clickable link rects from the last draw()
68
+ this._layoutWidth = -1;
69
+ this.contentHeight = 0;
70
+ /** called with (href, event) when a rendered link is clicked (window
71
+ * mode wires mousedown automatically; standalone callers use linkAt) */
72
+ this.onLink = opts.onLink ?? null;
73
+
74
+ if (this.window) {
75
+ this._ctx = this.window.getContext('2d');
76
+ this.window.on('expose', () => this.render());
77
+ this.window.on('resize', () => {
78
+ this._layoutWidth = -1; // invalidate
79
+ });
80
+ this.window.on('mousedown', (ev) => {
81
+ if (ev.keycode !== 1 || !this.onLink) return; // left button only
82
+ const href = this.linkAt(ev.x, ev.y);
83
+ if (href) this.onLink(href, ev);
84
+ });
85
+ }
86
+ }
87
+
88
+ /**
89
+ * The link href under a point, or null. Coordinates are in the same
90
+ * device space as the last `draw()` (window mode: window coordinates).
91
+ */
92
+ linkAt(x, y) {
93
+ for (const l of this._links) {
94
+ if (x >= l.x && x < l.x + l.w && y >= l.y && y < l.y + l.h) return l.href;
95
+ }
96
+ return null;
97
+ }
98
+
99
+ setMarkdown(src) {
100
+ this._blocks = parseMarkdown(src);
101
+ this._mermaid = new Map();
102
+ this._layoutWidth = -1;
103
+ if (this.window && this.window._mapped) this.render();
104
+ return this;
105
+ }
106
+
107
+ // ```mermaid fences parse asynchronously (the mermaid grammar loads
108
+ // lazily); the fence shows as a code block until the model arrives, then
109
+ // layout is invalidated — same pattern as image loading in HtmlView
110
+ _mermaidEntry(text) {
111
+ if (!this._mermaid) this._mermaid = new Map();
112
+ let entry = this._mermaid.get(text);
113
+ if (!entry) {
114
+ entry = { model: null, failed: false };
115
+ this._mermaid.set(text, entry);
116
+ parseMermaid(text).then(
117
+ (model) => {
118
+ entry.model = model;
119
+ this._layoutWidth = -1;
120
+ if (this.window && this.window._mapped) this.render();
121
+ },
122
+ () => {
123
+ entry.failed = true; // unsupported/invalid: stays a code block
124
+ }
125
+ );
126
+ }
127
+ return entry;
128
+ }
129
+
130
+ /** lay content out for a container width; returns total content height */
131
+ layout(width) {
132
+ const fonts = this._fonts();
133
+ this._items = [];
134
+ const t = this.theme;
135
+ const inner = Math.max(20, width - t.padding * 2);
136
+ const y = this._layoutBlocks(fonts, this._blocks, this._blockStyles(), t.padding, 0, inner);
137
+ this.contentHeight = y + t.padding * 2;
138
+ this._layoutWidth = width;
139
+ return this.contentHeight;
140
+ }
141
+
142
+ _fonts() {
143
+ if (this.window) return this.window.app.fonts;
144
+ if (!this._standaloneFonts) {
145
+ throw new Error('MarkdownView without a window needs { fonts: new FontManager() }');
146
+ }
147
+ return this._standaloneFonts;
148
+ }
149
+
150
+ _blockStyles() {
151
+ const t = this.theme;
152
+ return {
153
+ base: { family: t.family, size: t.size, color: t.color },
154
+ mono: { family: t.monoFamily, size: t.size * 0.9, color: t.codeColor }
155
+ };
156
+ }
157
+
158
+ /** recursive block layout; returns the y after the last block */
159
+ _layoutBlocks(fonts, blocks, styles, x, y, width) {
160
+ const t = this.theme;
161
+ const spacing = t.blockSpacing * t.size;
162
+ let first = true;
163
+
164
+ for (const block of blocks) {
165
+ if (!first) y += spacing;
166
+ first = false;
167
+
168
+ switch (block.type) {
169
+ case 'heading': {
170
+ const scale = t.headingScales[block.level - 1] ?? 1;
171
+ const style = {
172
+ ...styles.base,
173
+ size: Math.round(t.size * scale),
174
+ weight: 700
175
+ };
176
+ y += block.level <= 2 ? spacing * 0.5 : 0;
177
+ const layout = new TextLayout(fonts, this._spans(block.children, style), style, {
178
+ maxWidth: width,
179
+ lineHeight: t.lineHeight
180
+ });
181
+ this._items.push({ kind: 'text', x, y, layout });
182
+ y += layout.height;
183
+ break;
184
+ }
185
+
186
+ case 'paragraph': {
187
+ const style = styles.base;
188
+ const layout = new TextLayout(fonts, this._spans(block.children, style), style, {
189
+ maxWidth: width,
190
+ lineHeight: t.lineHeight
191
+ });
192
+ this._items.push({ kind: 'text', x, y, layout });
193
+ y += layout.height;
194
+ break;
195
+ }
196
+
197
+ case 'code': {
198
+ // ```mermaid fences render as diagrams (flowchart / sequence);
199
+ // unsupported types or parse errors fall back to the code block
200
+ if ((block.lang || '').toLowerCase() === 'mermaid') {
201
+ let box = null;
202
+ const entry = this._mermaidEntry(block.text);
203
+ if (entry.model) {
204
+ try {
205
+ box = layoutMermaid(entry.model, {
206
+ fonts,
207
+ size: Math.round(t.size * 0.82),
208
+ family: t.family,
209
+ maxWidth: width,
210
+ theme: { labelBg: t.background, text: t.color }
211
+ });
212
+ } catch {
213
+ // fall through to the plain code block
214
+ }
215
+ }
216
+ if (box) {
217
+ const bx = x + Math.max(0, (width - box.width) / 2);
218
+ this._items.push({ kind: 'tex', x: bx, y, box });
219
+ y += box.height;
220
+ break;
221
+ }
222
+ }
223
+ // ```math / ```latex fences render as display-mode formulas
224
+ if (MATH_LANGS.has((block.lang || '').toLowerCase())) {
225
+ let box = null;
226
+ try {
227
+ // 1.21 mirrors katex.css's font-size on .katex in body text
228
+ box = layoutTex(block.text, {
229
+ size: t.size * 1.21,
230
+ displayMode: true,
231
+ color: t.color
232
+ });
233
+ } catch {
234
+ // KaTeX parse error: fall through to the plain code block
235
+ }
236
+ if (box) {
237
+ const bx = x + Math.max(0, (width - box.width) / 2);
238
+ this._items.push({ kind: 'tex', x: bx, y, box });
239
+ y += box.height;
240
+ break;
241
+ }
242
+ }
243
+ const pad = t.size * 0.5;
244
+ const style = { ...styles.mono, color: t.color };
245
+ const spans = tokenize(block.text, block.lang).map((tok) => ({
246
+ text: tok.text,
247
+ color: t.codeTheme?.[tok.kind] ?? style.color
248
+ }));
249
+ const layout = new TextLayout(fonts, spans.length ? spans : '', style, {
250
+ maxWidth: width - pad * 2,
251
+ lineHeight: 1.25
252
+ });
253
+ this._items.push({
254
+ kind: 'rect',
255
+ x,
256
+ y,
257
+ width,
258
+ height: layout.height + pad * 2,
259
+ color: t.codeBg
260
+ });
261
+ this._items.push({ kind: 'text', x: x + pad, y: y + pad, layout });
262
+ y += layout.height + pad * 2;
263
+ break;
264
+ }
265
+
266
+ case 'blockquote': {
267
+ const indent = t.size;
268
+ const startY = y;
269
+ const bar = { kind: 'rect', x, y, width: 3, height: 0, color: t.quoteBar };
270
+ this._items.push(bar);
271
+ const quoted = {
272
+ base: { ...styles.base, color: t.quoteColor },
273
+ mono: styles.mono
274
+ };
275
+ y = this._layoutBlocks(fonts, block.blocks, quoted, x + indent, y, width - indent);
276
+ bar.height = y - startY;
277
+ break;
278
+ }
279
+
280
+ case 'list': {
281
+ const indent = t.size * 1.6;
282
+ let n = block.start;
283
+ for (let it = 0; it < block.items.length; it++) {
284
+ if (it > 0) y += spacing * 0.4;
285
+ const marker = block.ordered ? `${n++}.` : '•';
286
+ const markerLayout = new TextLayout(fonts, marker, styles.base, {});
287
+ this._items.push({ kind: 'text', x, y, layout: markerLayout });
288
+ y = this._layoutBlocks(fonts, block.items[it], styles, x + indent, y, width - indent);
289
+ }
290
+ break;
291
+ }
292
+
293
+ case 'table': {
294
+ y = this._layoutTable(fonts, block, styles, x, y, width);
295
+ break;
296
+ }
297
+
298
+ case 'hr': {
299
+ this._items.push({
300
+ kind: 'rect',
301
+ x,
302
+ y: y + spacing * 0.5,
303
+ width,
304
+ height: 1,
305
+ color: t.hrColor
306
+ });
307
+ y += spacing;
308
+ break;
309
+ }
310
+ }
311
+ }
312
+ return y;
313
+ }
314
+
315
+ /**
316
+ * Table layout: columns take their natural (unwrapped) width when the
317
+ * table fits, otherwise shrink proportionally (with a floor) and cells
318
+ * wrap. The grid and header background are emitted as rect items, so the
319
+ * two-pass draw() paints them under the cell text.
320
+ */
321
+ _layoutTable(fonts, block, styles, x, y, width) {
322
+ const t = this.theme;
323
+ const hpad = t.size * 0.55;
324
+ const vpad = t.size * 0.3;
325
+ const ncols = block.header.length;
326
+ const allRows = [block.header, ...block.rows];
327
+
328
+ // natural width per column: widest unwrapped cell
329
+ const cellStyle = (isHeader) => (isHeader ? { ...styles.base, weight: 700 } : styles.base);
330
+ const cellSpans = allRows.map((row, r) =>
331
+ Array.from({ length: ncols }, (_, c) => this._spans(row[c] ?? [], cellStyle(r === 0)))
332
+ );
333
+ const natural = new Array(ncols).fill(0);
334
+ for (const row of cellSpans) {
335
+ for (let c = 0; c < ncols; c++) {
336
+ if (!row[c].length) continue;
337
+ const measure = new TextLayout(fonts, row[c], styles.base, { lineHeight: t.lineHeight });
338
+ natural[c] = Math.max(natural[c], measure.width);
339
+ }
340
+ }
341
+
342
+ const cols = natural.map((w) => w + hpad * 2);
343
+ const total = cols.reduce((a, b) => a + b, 0);
344
+ if (total > width) {
345
+ // proportional shrink; the floor keeps narrow columns readable even
346
+ // if wide neighbors then push the table slightly past the container
347
+ const minCol = Math.min(t.size * 2.5 + hpad * 2, width / ncols);
348
+ for (let c = 0; c < ncols; c++) {
349
+ cols[c] = Math.max(minCol, (cols[c] / total) * width);
350
+ }
351
+ }
352
+ const tableW = cols.reduce((a, b) => a + b, 0);
353
+
354
+ const topY = y;
355
+ const rowLines = [y];
356
+ for (let r = 0; r < cellSpans.length; r++) {
357
+ const layouts = cellSpans[r].map((spans, c) =>
358
+ new TextLayout(fonts, spans.length ? spans : '', styles.base, {
359
+ maxWidth: cols[c] - hpad * 2,
360
+ align: block.align[c] ?? 'left',
361
+ lineHeight: t.lineHeight
362
+ })
363
+ );
364
+ const rowH = Math.max(...layouts.map((l) => l.height), t.size * t.lineHeight) + vpad * 2;
365
+ if (r === 0) {
366
+ this._items.push({ kind: 'rect', x, y, width: tableW, height: rowH, color: t.tableHeaderBg });
367
+ }
368
+ let cx = x;
369
+ for (let c = 0; c < ncols; c++) {
370
+ this._items.push({ kind: 'text', x: cx + hpad, y: y + vpad, layout: layouts[c] });
371
+ cx += cols[c];
372
+ }
373
+ y += rowH;
374
+ rowLines.push(y);
375
+ }
376
+
377
+ // grid: horizontal rule per row boundary, vertical per column boundary
378
+ for (const ly of rowLines) {
379
+ this._items.push({ kind: 'rect', x, y: ly, width: tableW, height: 1, color: t.tableBorder });
380
+ }
381
+ let cx = x;
382
+ for (let c = 0; c <= ncols; c++) {
383
+ this._items.push({ kind: 'rect', x: cx, y: topY, width: 1, height: y - topY + 1, color: t.tableBorder });
384
+ if (c < ncols) cx += cols[c];
385
+ }
386
+ return y + 1;
387
+ }
388
+
389
+ /** inline AST -> styled spans for TextLayout */
390
+ _spans(children, base, state = {}, out = []) {
391
+ const t = this.theme;
392
+ for (const node of children) {
393
+ switch (node.type) {
394
+ case 'text':
395
+ out.push({
396
+ text: node.text,
397
+ family: state.code ? t.monoFamily : base.family,
398
+ size: state.code ? Math.round(base.size * 0.9) : base.size,
399
+ weight: state.bold ? 700 : base.weight,
400
+ style: state.italic ? 'italic' : base.style,
401
+ color: state.code ? t.codeColor : state.link ? t.linkColor : base.color,
402
+ _deco: state.code ? { bg: t.codeBg } : state.link ? { underline: t.linkColor } : null,
403
+ _href: state.link ? state.href : null
404
+ });
405
+ break;
406
+ case 'code':
407
+ this._spans([{ type: 'text', text: node.text }], base, { ...state, code: true }, out);
408
+ break;
409
+ case 'strong':
410
+ this._spans(node.children, base, { ...state, bold: true }, out);
411
+ break;
412
+ case 'em':
413
+ this._spans(node.children, base, { ...state, italic: true }, out);
414
+ break;
415
+ case 'link':
416
+ this._spans(node.children, base, { ...state, link: true, href: node.href }, out);
417
+ break;
418
+ }
419
+ }
420
+ return out;
421
+ }
422
+
423
+ /** draw the last layout()'s items onto a 2d context */
424
+ draw(ctx, ox = 0, oy = 0) {
425
+ this._links = [];
426
+ for (const item of this._items) {
427
+ if (item.kind === 'rect') {
428
+ ctx.fillStyle = item.color;
429
+ ctx.fillRect(Math.round(ox + item.x), Math.round(oy + item.y), Math.ceil(item.width), Math.ceil(item.height));
430
+ }
431
+ }
432
+ for (const item of this._items) {
433
+ if (item.kind === 'tex') {
434
+ item.box.draw(ctx, ox + item.x, oy + item.y);
435
+ continue;
436
+ }
437
+ if (item.kind !== 'text') continue;
438
+ const x = ox + item.x;
439
+ const y = oy + item.y;
440
+ // span decorations (inline-code background, link underline) first
441
+ for (const line of item.layout.lines) {
442
+ for (const r of line.runs) {
443
+ if (r.span._href) {
444
+ // clickable region in the device coordinates of this draw
445
+ const m = r.run.font.metrics(r.run.size);
446
+ this._links.push({
447
+ x: x + line.x + r.x,
448
+ y: y + line.baseline - m.ascent,
449
+ w: r.width,
450
+ h: m.ascent + m.descent,
451
+ href: r.span._href
452
+ });
453
+ }
454
+ const deco = r.span._deco;
455
+ if (!deco) continue;
456
+ if (deco.bg) {
457
+ const m = r.run.font.metrics(r.run.size);
458
+ ctx.fillStyle = deco.bg;
459
+ ctx.fillRect(
460
+ Math.round(x + line.x + r.x - 2),
461
+ Math.round(y + line.baseline - m.ascent),
462
+ Math.ceil(r.width + 4),
463
+ Math.ceil(m.ascent + m.descent)
464
+ );
465
+ }
466
+ if (deco.underline) {
467
+ ctx.fillStyle = deco.underline;
468
+ ctx.fillRect(Math.round(x + line.x + r.x), Math.round(y + line.baseline + 2), Math.ceil(r.width), 1);
469
+ }
470
+ }
471
+ }
472
+ item.layout.draw(ctx, x, y);
473
+ }
474
+ return this;
475
+ }
476
+
477
+ /** full window render: clear background, (re)layout on size change, draw */
478
+ render() {
479
+ if (!this.window) throw new Error('MarkdownView.render() needs a window');
480
+ const ctx = this._ctx;
481
+ if (this._layoutWidth !== this.window.width) this.layout(this.window.width);
482
+ ctx.fillStyle = this.theme.background;
483
+ ctx.fillRect(0, 0, this.window.width, this.window.height);
484
+ this.draw(ctx, 0, this.theme.padding);
485
+ return this;
486
+ }
487
+ }