ntk 2.1.1 → 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.
- package/README.md +115 -110
- package/lib/app.js +48 -15
- package/lib/cleanup.js +14 -0
- package/lib/drawable.js +10 -16
- package/lib/events_map.js +81 -87
- package/lib/fontconfig.js +122 -14
- package/lib/glyphset.js +36 -18
- package/lib/image.js +138 -0
- package/lib/index.js +110 -52
- package/lib/path.js +734 -0
- package/lib/picture.js +76 -38
- package/lib/pixmap.js +34 -25
- package/lib/rasterize.js +178 -0
- package/lib/renderingcontext_2d.js +994 -517
- package/lib/renderingcontext_opengl.js +38 -31
- package/lib/renderingcontext_x11.js +56 -64
- package/lib/text/font.js +196 -0
- package/lib/text/fontmanager.js +233 -0
- package/lib/text/fontsource.js +179 -0
- package/lib/text/glyphs.js +456 -0
- package/lib/text/layout.js +356 -0
- package/lib/text/shape.js +141 -0
- package/lib/trapezoid.js +131 -0
- package/lib/widgets/css.js +676 -0
- package/lib/widgets/highlight.js +97 -0
- package/lib/widgets/htmlview.js +879 -0
- package/lib/widgets/markdown.js +148 -0
- package/lib/widgets/markdownview.js +487 -0
- package/lib/widgets/mermaid.js +720 -0
- package/lib/widgets/svgview.js +528 -0
- package/lib/widgets/tex.js +681 -0
- package/lib/window.js +568 -246
- package/package.json +35 -32
- package/.npmignore +0 -15
- package/examples/example-await.js +0 -23
- package/examples/example-co.js +0 -13
- package/examples/example.js +0 -14
- package/examples/fill-styles.js +0 -39
- package/examples/glclock.js +0 -100
- package/examples/glxpixmap.js +0 -12
- package/examples/gradient.js +0 -41
- package/examples/package.json +0 -17
- package/examples/simpletex.js +0 -72
- package/examples/teapot.js +0 -104
- package/examples/teapot.json +0 -6
- package/examples/text-rendering.js +0 -44
- package/lib/fontface.js +0 -109
- 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 = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" };
|
|
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
|
+
}
|