ntk 7.7.0 → 8.1.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.
@@ -1,893 +0,0 @@
1
- import { selectAll } from 'css-select';
2
- import { textContent } from 'domutils';
3
- import { parseDocument } from 'htmlparser2';
4
- import { builtin } from '../builtin.js';
5
- import Yoga from '../yoga.js';
6
-
7
- import { decodeImage, Image } from '../image.js';
8
- import { TextLayout } from '../text/layout.js';
9
- import { computeStyles, cssColor } from './css.js';
10
- import SvgView from './svgview.js';
11
-
12
- const DEFAULT_THEME = {
13
- family: 'sans-serif',
14
- size: 16,
15
- color: '#222222',
16
- background: 'white'
17
- };
18
-
19
- const FLEX_DIRECTION = {
20
- column: Yoga.FLEX_DIRECTION_COLUMN,
21
- 'column-reverse': Yoga.FLEX_DIRECTION_COLUMN_REVERSE,
22
- row: Yoga.FLEX_DIRECTION_ROW,
23
- 'row-reverse': Yoga.FLEX_DIRECTION_ROW_REVERSE
24
- };
25
- const JUSTIFY = {
26
- 'flex-start': Yoga.JUSTIFY_FLEX_START,
27
- center: Yoga.JUSTIFY_CENTER,
28
- 'flex-end': Yoga.JUSTIFY_FLEX_END,
29
- 'space-between': Yoga.JUSTIFY_SPACE_BETWEEN,
30
- 'space-around': Yoga.JUSTIFY_SPACE_AROUND,
31
- 'space-evenly': Yoga.JUSTIFY_SPACE_EVENLY
32
- };
33
- const ALIGN = {
34
- auto: Yoga.ALIGN_AUTO,
35
- 'flex-start': Yoga.ALIGN_FLEX_START,
36
- center: Yoga.ALIGN_CENTER,
37
- 'flex-end': Yoga.ALIGN_FLEX_END,
38
- stretch: Yoga.ALIGN_STRETCH,
39
- baseline: Yoga.ALIGN_BASELINE
40
- };
41
- const WRAP = {
42
- nowrap: Yoga.WRAP_NO_WRAP,
43
- wrap: Yoga.WRAP_WRAP,
44
- 'wrap-reverse': Yoga.WRAP_WRAP_REVERSE
45
- };
46
- const EDGES = [Yoga.EDGE_TOP, Yoga.EDGE_RIGHT, Yoga.EDGE_BOTTOM, Yoga.EDGE_LEFT];
47
-
48
- const VOID_SKIP = new Set(['script', 'style', 'head', 'title', 'meta', 'link', 'template', 'noscript', 'iframe', 'object', 'embed', 'audio', 'video', 'canvas', 'input', 'select', 'textarea', 'button']);
49
-
50
- /**
51
- * A widget that renders a static subset of HTML + CSS into a window (or any
52
- * 2d context target). No scripting, no network: documents are provided as
53
- * strings, resources resolve only through an explicit allow-path (see
54
- * "Resources" in docs/html.md).
55
- *
56
- * const view = new HtmlView(wnd, {
57
- * onLink: (href) => console.log('clicked', href)
58
- * });
59
- * view.setHtml('<h1>Hello</h1><p>Some <b>html</b>.</p>');
60
- * wnd.map();
61
- *
62
- * Layout: block flow and flexbox are computed by yoga-layout; inline runs
63
- * are shaped and wrapped by the ntk text pipeline (TextLayout). Supported
64
- * markup and CSS are documented in docs/html.md.
65
- *
66
- * Standalone (windowless) use mirrors MarkdownView:
67
- * const view = new HtmlView(null, { fonts });
68
- * view.setHtml(html); view.layout(width); view.draw(ctx, x, y);
69
- * Content that arrives asynchronously (images) invalidates the layout —
70
- * pass `onInvalidate` to re-layout/redraw when that happens (window mode
71
- * repaints automatically).
72
- */
73
- export default class HtmlView {
74
- constructor(window, opts = {}) {
75
- this.window = window ?? null;
76
- this._standaloneFonts = opts.fonts ?? null;
77
- this.theme = { ...DEFAULT_THEME, ...(opts.theme || {}) };
78
-
79
- /** called with (href, event, element) when a link is activated */
80
- this.onLink = opts.onLink ?? null;
81
- /** called when async content (an image) invalidates the layout;
82
- * standalone hosts re-layout/redraw here — window mode also repaints
83
- * on its own */
84
- this.onInvalidate = opts.onInvalidate ?? null;
85
- /** extra author stylesheet(s) applied after document <style>s */
86
- this._userSheets = [].concat(opts.stylesheet ?? []);
87
- /**
88
- * resource loading policy:
89
- * - a function `(url, { element }) => Promise<Buffer|Image|null>`
90
- * - undefined: default loader — data: URIs always; file reads only
91
- * when `baseUrl` is set, resolved relative to it
92
- * - null/false: data: URIs only
93
- */
94
- this._loadResource = opts.loadResource;
95
- this.baseUrl = opts.baseUrl ?? null;
96
-
97
- this._dom = null;
98
- this._styles = null;
99
- this._root = null; // box tree root
100
- this._images = new Map(); // <img> element -> { image, promise, failed }
101
- this._links = [];
102
- this._layoutWidth = -1;
103
- this.contentHeight = 0;
104
- this.scrollY = 0;
105
-
106
- if (this.window) {
107
- this._ctx = this.window.getContext('2d');
108
- this.window.on('expose', () => this.render());
109
- // a move is a ConfigureNotify too; re-laying out a document per step
110
- // of a window drag is exactly what ev.resized is for
111
- this.window.on('resize', (ev) => {
112
- if (ev.resized) this._layoutWidth = -1;
113
- });
114
- this.window.on('mousedown', (ev) => {
115
- if (ev.keycode === 4 || ev.keycode === 5) {
116
- // X11 wheel: buttons 4 (up) / 5 (down)
117
- this.scrollBy(ev.keycode === 4 ? -48 : 48);
118
- return;
119
- }
120
- if (ev.keycode !== 1) return;
121
- const hit = this.linkAt(ev.x, ev.y);
122
- if (hit && this.onLink) this.onLink(hit.href, ev, hit.element);
123
- });
124
- }
125
- }
126
-
127
- // -------------------------------------------------------------------------
128
- // document lifecycle
129
-
130
- /**
131
- * Parse and adopt a new document. The widget never loads or replaces
132
- * documents on its own — link clicks only fire `onLink`; navigation (e.g.
133
- * fetching new content and calling `setHtml` again) belongs to the app.
134
- *
135
- * @param {string} html document or fragment markup
136
- * @param {object} [opts] { baseUrl } — overrides the constructor baseUrl
137
- * for this document (used to resolve relative image `src`)
138
- */
139
- setHtml(html, opts = {}) {
140
- if (opts.baseUrl !== undefined) this.baseUrl = opts.baseUrl;
141
- this._dom = parseDocument(String(html ?? ''));
142
- const sheets = selectAll('style', this._dom).map((el) => textContent(el));
143
- this._styles = computeStyles(this._dom, [...sheets, ...this._userSheets], {
144
- rootFontSize: this.theme.size,
145
- baseStyle: {
146
- fontFamily: this.theme.family,
147
- fontSize: this.theme.size,
148
- color: cssColor(this.theme.color) ?? undefined
149
- }
150
- });
151
- this._disposeTree();
152
- for (const entry of this._images.values()) entry.image?.destroy();
153
- this._images.clear();
154
- const rootStyle = this._blockStyle();
155
- this._root = this._buildBox(null, rootStyle, this._partition(this._dom.children, rootStyle));
156
- this._collectImages(this._root);
157
- this._layoutWidth = -1;
158
- this.scrollY = 0;
159
- if (this.window && this.window._mapped) this.render();
160
- return this;
161
- }
162
-
163
- _blockStyle() {
164
- return {
165
- display: 'block',
166
- color: cssColor(this.theme.color),
167
- background: null,
168
- fontFamily: this.theme.family,
169
- fontSize: this.theme.size,
170
- fontWeight: 400,
171
- fontStyle: 'normal',
172
- lineHeight: { mult: 1.25 },
173
- textAlign: 'left',
174
- textDecoration: 'none',
175
- whiteSpace: 'normal',
176
- listStyleType: 'disc',
177
- margin: [{ px: 0 }, { px: 0 }, { px: 0 }, { px: 0 }],
178
- padding: [{ px: 0 }, { px: 0 }, { px: 0 }, { px: 0 }],
179
- borderWidth: [0, 0, 0, 0],
180
- borderColor: [null, null, null, null],
181
- borderStyle: ['none', 'none', 'none', 'none'],
182
- width: 'auto',
183
- height: 'auto',
184
- minWidth: null,
185
- maxWidth: null,
186
- minHeight: null,
187
- maxHeight: null,
188
- flexDirection: 'column',
189
- flexWrap: 'nowrap',
190
- justifyContent: 'flex-start',
191
- alignItems: 'stretch',
192
- alignSelf: 'auto',
193
- flexGrow: 0,
194
- flexShrink: 1,
195
- flexBasis: 'auto',
196
- rowGap: 0,
197
- columnGap: 0
198
- };
199
- }
200
-
201
- _fonts() {
202
- if (this.window) return this.window.app.fonts;
203
- if (!this._standaloneFonts) {
204
- throw new Error('HtmlView without a window needs { fonts: new FontManager() }');
205
- }
206
- return this._standaloneFonts;
207
- }
208
-
209
- // -------------------------------------------------------------------------
210
- // box tree construction
211
-
212
- /**
213
- * Partition an element's children into block-level boxes and anonymous
214
- * inline runs (which become text boxes). Returns child box descriptors.
215
- */
216
- _partition(children, containerStyle, inheritedHref = null) {
217
- const out = [];
218
- let run = []; // pending inline content: { el|text, style, href }
219
-
220
- const flushRun = () => {
221
- if (!run.length) return;
222
- const spans = run;
223
- run = [];
224
- // drop runs that collapse to pure whitespace
225
- const hasContent = spans.some((s) => s.text !== undefined ? /\S/.test(s.text) || s.pre : true);
226
- if (!hasContent) return;
227
- out.push({ kind: 'text', parts: spans });
228
- };
229
-
230
- const visitInline = (el, href) => {
231
- const st = this._styles.get(el);
232
- for (const child of el.children ?? []) {
233
- if (child.type === 'text') {
234
- run.push({ text: child.data, style: st, href, pre: st.whiteSpace.startsWith('pre') });
235
- } else if (child.type === 'tag' || child.type === 'style' || child.type === 'script') {
236
- dispatch(child, href);
237
- }
238
- }
239
- };
240
-
241
- const dispatch = (el, href) => {
242
- if (VOID_SKIP.has(el.name)) return;
243
- const st = this._styles.get(el);
244
- if (!st || st.display === 'none') return;
245
- const elHref = el.name === 'a' && el.attribs?.href ? el.attribs.href : href;
246
- if (el.name === 'br') {
247
- run.push({ text: '\n', style: st, href: elHref, pre: true });
248
- return;
249
- }
250
- if (el.name === 'img' || el.name === 'svg') {
251
- // replaced elements: inline <svg> renders through SvgView, like an
252
- // image with an intrinsic size (its children never join text flow)
253
- flushRun();
254
- out.push({ kind: 'image', element: el, style: st, href: elHref });
255
- return;
256
- }
257
- if (st.display === 'inline') {
258
- visitInline(el, elHref);
259
- return;
260
- }
261
- // block, flex, list-item, inline-block: a box of its own
262
- flushRun();
263
- out.push({ kind: 'box', element: el, style: st, href: elHref });
264
- };
265
-
266
- const containerPre = !!containerStyle?.whiteSpace?.startsWith('pre');
267
- for (const node of children) {
268
- if (node.type === 'text') {
269
- run.push({ text: node.data, style: null, href: inheritedHref, pre: containerPre });
270
- } else if (node.type === 'tag' || node.type === 'style' || node.type === 'script') {
271
- dispatch(node, inheritedHref);
272
- }
273
- }
274
- flushRun();
275
- return out;
276
- }
277
-
278
- /** build a container box (and recursively its subtree) */
279
- _buildBox(element, style, childDescs) {
280
- const box = {
281
- element,
282
- style,
283
- kind: 'container',
284
- children: [],
285
- href: null,
286
- x: 0,
287
- y: 0,
288
- w: 0,
289
- h: 0
290
- };
291
- let liIndex = 0;
292
- for (const desc of childDescs) {
293
- if (desc.kind === 'text') {
294
- box.children.push(this._makeTextBox(desc.parts, style));
295
- } else if (desc.kind === 'image') {
296
- const child = {
297
- element: desc.element,
298
- style: desc.style,
299
- kind: 'image',
300
- children: [],
301
- href: desc.href,
302
- x: 0, y: 0, w: 0, h: 0
303
- };
304
- box.children.push(child);
305
- } else {
306
- const el = desc.element;
307
- const st = desc.style;
308
- if (st.display === 'list-item') st._liIndex = ++liIndex;
309
- const child = this._buildBox(el, st, this._partition(el.children ?? [], st, desc.href));
310
- child.href = desc.href;
311
- box.children.push(child);
312
- }
313
- }
314
- return box;
315
- }
316
-
317
- /** anonymous inline run -> text box with TextLayout spans */
318
- _makeTextBox(parts, containerStyle) {
319
- const fonts = this._fonts();
320
- const spans = [];
321
- let endedWithSpace = true; // strip leading whitespace at run start
322
- for (const part of parts) {
323
- const own = part.style != null; // inline element vs bare text node
324
- const st = part.style ?? containerStyle;
325
- let text = part.text;
326
- if (!part.pre) {
327
- text = text.replace(/[\t\n\r ]+/g, ' ');
328
- if (endedWithSpace && text.startsWith(' ')) text = text.replace(/^ +/, '');
329
- if (!text) continue;
330
- endedWithSpace = text.endsWith(' ');
331
- } else {
332
- endedWithSpace = /[\s]$/.test(text);
333
- }
334
- // container background/decoration is drawn at box level; only inline
335
- // elements' own decorations become span decorations
336
- const deco =
337
- own && (st.textDecoration !== 'none' || st.background)
338
- ? {
339
- underline: st.textDecoration === 'underline' ? st.color : null,
340
- strike: st.textDecoration === 'line-through' ? st.color : null,
341
- bg: st.background
342
- }
343
- : null;
344
- spans.push({
345
- text,
346
- family: st.fontFamily,
347
- size: st.fontSize,
348
- weight: st.fontWeight,
349
- style: st.fontStyle,
350
- color: st.color,
351
- _href: part.href,
352
- _deco: deco
353
- });
354
- }
355
- // strip trailing collapsible whitespace of the run
356
- for (let i = spans.length - 1; i >= 0; i--) {
357
- spans[i].text = spans[i].text.replace(/ +$/, '');
358
- if (spans[i].text) break;
359
- spans.splice(i, 1);
360
- }
361
-
362
- const style = containerStyle;
363
- const m = fonts.match(style.fontFamily, { weight: style.fontWeight, style: style.fontStyle });
364
- const natural = m.metrics(style.fontSize).lineHeight || style.fontSize * 1.2;
365
- const desired = style.lineHeight.px !== undefined ? style.lineHeight.px : style.lineHeight.mult * style.fontSize;
366
-
367
- return {
368
- element: null,
369
- style,
370
- kind: 'text',
371
- children: [],
372
- spans,
373
- align: style.textAlign,
374
- nowrap: style.whiteSpace === 'nowrap' || style.whiteSpace === 'pre',
375
- lineHeightMult: natural > 0 ? desired / natural : 1,
376
- _layouts: new Map(),
377
- x: 0, y: 0, w: 0, h: 0
378
- };
379
- }
380
-
381
- _textLayout(box, width) {
382
- const key = box.nowrap ? -1 : Math.max(0, Math.round(width));
383
- let layout = box._layouts.get(key);
384
- if (!layout) {
385
- layout = new TextLayout(this._fonts(), box.spans.length ? box.spans : '', box.style, {
386
- maxWidth: box.nowrap ? Infinity : key,
387
- align: box.align,
388
- lineHeight: box.lineHeightMult
389
- });
390
- box._layouts.set(key, layout);
391
- }
392
- return layout;
393
- }
394
-
395
- _collectImages(box) {
396
- if (box.kind === 'image') {
397
- const el = box.element;
398
- if (el.name === 'svg') {
399
- // inline svg: adopt the already-parsed element synchronously, so
400
- // its intrinsic size is known on the first layout pass
401
- if (!this._images.has(el)) {
402
- const entry = { image: null, svg: null, failed: false };
403
- this._images.set(el, entry);
404
- try {
405
- entry.svg = new SvgView(null).setSvgDom(el);
406
- } catch {
407
- entry.failed = true;
408
- }
409
- }
410
- } else {
411
- const src = el.attribs?.src;
412
- if (src && !this._images.has(el)) {
413
- const entry = { image: null, svg: null, failed: false };
414
- this._images.set(el, entry);
415
- this._fetchImage(src, el).then(
416
- (res) => {
417
- if (res instanceof SvgView) entry.svg = res;
418
- else entry.image = res;
419
- if (!res) entry.failed = true;
420
- this._invalidate();
421
- },
422
- () => {
423
- entry.failed = true;
424
- this._invalidate();
425
- }
426
- );
427
- }
428
- }
429
- }
430
- for (const child of box.children) this._collectImages(child);
431
- }
432
-
433
- /** async content arrived: drop the cached layout, repaint and/or notify */
434
- _invalidate() {
435
- this._layoutWidth = -1;
436
- if (this.window && this.window._mapped) this.render();
437
- if (this.onInvalidate) this.onInvalidate();
438
- }
439
-
440
- // decoded bytes -> Image (PNG/JPEG) or SvgView (sniffed SVG markup)
441
- _decodeResource(buf) {
442
- const head = buf.subarray(0, 1024).toString('utf8');
443
- if (head.trimStart().startsWith('<') && /<svg[\s>]/i.test(head)) {
444
- return new SvgView(null).setSvg(buf.toString('utf8'));
445
- }
446
- return decodeImage(buf);
447
- }
448
-
449
- /** resolve + decode an image source according to the resource policy */
450
- async _fetchImage(src, element) {
451
- if (src.startsWith('data:')) {
452
- const svg = /^data:image\/svg\+xml(?:;charset=[\w-]+)?(;base64)?,(.*)$/s.exec(src);
453
- if (svg) {
454
- const text = svg[1] ? Buffer.from(svg[2], 'base64').toString('utf8') : decodeURIComponent(svg[2]);
455
- return new SvgView(null).setSvg(text);
456
- }
457
- const m = /^data:image\/[\w+.-]+;base64,(.*)$/s.exec(src);
458
- if (!m) return null;
459
- return decodeImage(Buffer.from(m[1], 'base64'));
460
- }
461
- if (typeof this._loadResource === 'function') {
462
- const res = await this._loadResource(src, { element });
463
- if (res instanceof Image || res instanceof SvgView) return res;
464
- if (res) return this._decodeResource(res);
465
- return null;
466
- }
467
- if (this._loadResource === null || this._loadResource === false) return null;
468
- // default policy: local files, only relative to an explicit baseUrl.
469
- // node builtins are fetched lazily — in environments without them
470
- // (browser bundles) pass a `loadResource` callback instead.
471
- const fs = builtin('node:fs');
472
- if (!fs || !this.baseUrl) return null;
473
- let base = this.baseUrl;
474
- if (typeof base === 'string' && !/^[a-z][a-z0-9+.-]*:/i.test(base)) {
475
- // filesystem path; point at the directory itself when given one
476
- let p = base;
477
- if (!/[/\\]$/.test(p) && fs.existsSync(p) && fs.statSync(p).isDirectory()) p += '/';
478
- base = builtin('node:url').pathToFileURL(p);
479
- }
480
- const url = new URL(src, base);
481
- if (url.protocol !== 'file:') return null;
482
- return this._decodeResource(await fs.promises.readFile(url));
483
- }
484
-
485
- // -------------------------------------------------------------------------
486
- // layout
487
-
488
- /** lay the document out for a container width; returns content height */
489
- layout(width) {
490
- if (!this._root) return 0;
491
- this._disposeYoga(this._root);
492
- const yroot = this._yogaFor(this._root, true);
493
- yroot.setWidth(width);
494
- yroot.calculateLayout(width, undefined, Yoga.DIRECTION_LTR);
495
- this._absolutize(this._root, 0, 0);
496
- this.contentHeight = Math.ceil(this._root.h);
497
- this._layoutWidth = width;
498
- return this.contentHeight;
499
- }
500
-
501
- _yogaFor(box, isRoot = false) {
502
- const node = Yoga.Node.create();
503
- box.yoga = node;
504
- const st = box.style;
505
-
506
- if (box.kind === 'text') {
507
- node.setMeasureFunc((w, wMode) => {
508
- const maxW = wMode === Yoga.MEASURE_MODE_UNDEFINED ? Infinity : w;
509
- const layout = this._textLayout(box, maxW === Infinity ? Infinity : maxW);
510
- return { width: Math.ceil(layout.width), height: Math.ceil(layout.height) };
511
- });
512
- return node;
513
- }
514
-
515
- if (box.kind === 'image') {
516
- this._applyBoxStyle(node, st);
517
- const entry = this._images.get(box.element);
518
- const img = entry?.image;
519
- const cssW = st.width;
520
- const cssH = st.height;
521
- const natW = img?.width ?? entry?.svg?.naturalWidth ?? (entry?.failed ? 80 : 0);
522
- const natH = img?.height ?? entry?.svg?.naturalHeight ?? (entry?.failed ? 40 : 0);
523
- if (cssW === 'auto' && cssH === 'auto') {
524
- // natural size, shrunk (ratio-preserving) to the available width; a
525
- // measure function is used because yoga's min/max clamping does not
526
- // recompute the aspect-ratio-derived dimension
527
- if (st.alignSelf === 'auto') node.setAlignSelf(Yoga.ALIGN_FLEX_START);
528
- node.setMeasureFunc((w, wMode) => {
529
- let iw = natW;
530
- if (wMode !== Yoga.MEASURE_MODE_UNDEFINED && w < iw) iw = w;
531
- return { width: iw, height: natW > 0 ? (iw * natH) / natW : natH };
532
- });
533
- } else if (natW > 0 && natH > 0 && (cssW === 'auto' || cssH === 'auto')) {
534
- // one dimension styled: the other follows the natural ratio
535
- node.setAspectRatio(natW / natH);
536
- }
537
- return node;
538
- }
539
-
540
- // container
541
- this._applyBoxStyle(node, st);
542
- if (st.display === 'flex') {
543
- node.setFlexDirection(FLEX_DIRECTION[st.flexDirection]);
544
- node.setFlexWrap(WRAP[st.flexWrap]);
545
- node.setJustifyContent(JUSTIFY[st.justifyContent]);
546
- node.setAlignItems(ALIGN[st.alignItems] ?? Yoga.ALIGN_STRETCH);
547
- node.setGap(Yoga.GUTTER_ROW, st.rowGap);
548
- node.setGap(Yoga.GUTTER_COLUMN, st.columnGap);
549
- } else {
550
- node.setFlexDirection(Yoga.FLEX_DIRECTION_COLUMN);
551
- node.setAlignItems(Yoga.ALIGN_STRETCH);
552
- }
553
-
554
- // children; approximate CSS adjacent-margin collapsing in block flow
555
- const collapse = st.display !== 'flex';
556
- let prevBottom = 0;
557
- box.children.forEach((child, i) => {
558
- const cnode = this._yogaFor(child);
559
- if (collapse && child.style) {
560
- const mt = child.style.margin?.[0];
561
- const mb = child.style.margin?.[2];
562
- const topPx = mt && mt.px !== undefined ? mt.px : 0;
563
- if (i > 0 && mt && mt.px !== undefined) {
564
- cnode.setMargin(Yoga.EDGE_TOP, Math.max(topPx, prevBottom) - prevBottom);
565
- }
566
- prevBottom = mb && mb.px !== undefined ? mb.px : 0;
567
- }
568
- node.insertChild(cnode, i);
569
- });
570
- return node;
571
- }
572
-
573
- _applyBoxStyle(node, st) {
574
- const setLen = (val, pxFn, pctFn, autoFn) => {
575
- if (val === 'auto') {
576
- if (autoFn) autoFn();
577
- } else if (val && val.px !== undefined) {
578
- pxFn(val.px);
579
- } else if (val && val.pct !== undefined && pctFn) {
580
- pctFn(val.pct);
581
- }
582
- };
583
-
584
- for (let i = 0; i < 4; i++) {
585
- const edge = EDGES[i];
586
- setLen(
587
- st.margin[i],
588
- (v) => node.setMargin(edge, v),
589
- (v) => node.setMarginPercent(edge, v),
590
- () => node.setMarginAuto(edge)
591
- );
592
- setLen(
593
- st.padding[i],
594
- (v) => node.setPadding(edge, v),
595
- (v) => node.setPaddingPercent(edge, v)
596
- );
597
- if (st.borderWidth[i] > 0) node.setBorder(edge, st.borderWidth[i]);
598
- }
599
-
600
- setLen(st.width, (v) => node.setWidth(v), (v) => node.setWidthPercent(v), () => node.setWidthAuto());
601
- setLen(st.height, (v) => node.setHeight(v), (v) => node.setHeightPercent(v), () => node.setHeightAuto());
602
- setLen(st.minWidth, (v) => node.setMinWidth(v), (v) => node.setMinWidthPercent(v));
603
- setLen(st.maxWidth, (v) => node.setMaxWidth(v), (v) => node.setMaxWidthPercent(v));
604
- setLen(st.minHeight, (v) => node.setMinHeight(v), (v) => node.setMinHeightPercent(v));
605
- setLen(st.maxHeight, (v) => node.setMaxHeight(v), (v) => node.setMaxHeightPercent(v));
606
-
607
- node.setFlexGrow(st.flexGrow);
608
- node.setFlexShrink(st.flexShrink);
609
- setLen(st.flexBasis, (v) => node.setFlexBasis(v), (v) => node.setFlexBasisPercent(v), () => node.setFlexBasisAuto());
610
- if (st.alignSelf !== 'auto') node.setAlignSelf(ALIGN[st.alignSelf] ?? Yoga.ALIGN_AUTO);
611
- }
612
-
613
- /** copy computed yoga geometry into absolute box coordinates */
614
- _absolutize(box, ox, oy) {
615
- const node = box.yoga;
616
- box.x = ox + node.getComputedLeft();
617
- box.y = oy + node.getComputedTop();
618
- box.w = node.getComputedWidth();
619
- box.h = node.getComputedHeight();
620
- box.contentX = box.x + node.getComputedBorder(Yoga.EDGE_LEFT) + node.getComputedPadding(Yoga.EDGE_LEFT);
621
- box.contentY = box.y + node.getComputedBorder(Yoga.EDGE_TOP) + node.getComputedPadding(Yoga.EDGE_TOP);
622
- box.contentW =
623
- box.w -
624
- node.getComputedBorder(Yoga.EDGE_LEFT) - node.getComputedBorder(Yoga.EDGE_RIGHT) -
625
- node.getComputedPadding(Yoga.EDGE_LEFT) - node.getComputedPadding(Yoga.EDGE_RIGHT);
626
- box.contentH =
627
- box.h -
628
- node.getComputedBorder(Yoga.EDGE_TOP) - node.getComputedBorder(Yoga.EDGE_BOTTOM) -
629
- node.getComputedPadding(Yoga.EDGE_TOP) - node.getComputedPadding(Yoga.EDGE_BOTTOM);
630
- for (const child of box.children) this._absolutize(child, box.x, box.y);
631
- }
632
-
633
- _disposeYoga(box) {
634
- if (box.yoga) {
635
- if (!box.yoga.getParent()) box.yoga.freeRecursive();
636
- box.yoga = null;
637
- for (const child of box.children) child.yoga = null, this._disposeYoga(child);
638
- }
639
- if (box._layouts) box._layouts.clear();
640
- }
641
-
642
- _disposeTree() {
643
- if (this._root) {
644
- this._disposeYoga(this._root);
645
- this._root = null;
646
- }
647
- }
648
-
649
- // -------------------------------------------------------------------------
650
- // drawing and hit testing
651
-
652
- /** draw the last layout() onto a 2d context at (ox, oy) */
653
- draw(ctx, ox = 0, oy = 0) {
654
- this._links = [];
655
- if (this._root) this._drawBox(ctx, this._root, ox, oy);
656
- return this;
657
- }
658
-
659
- _drawBox(ctx, box, ox, oy) {
660
- const st = box.style;
661
- const x = ox + box.x;
662
- const y = oy + box.y;
663
-
664
- if (box.kind === 'text') {
665
- // anonymous text boxes share the container's style object — the
666
- // container already painted its background and borders
667
- this._drawText(ctx, box, ox, oy);
668
- return;
669
- }
670
-
671
- if (st?.background) {
672
- ctx.fillStyle = st.background;
673
- ctx.fillRect(Math.round(x), Math.round(y), Math.ceil(box.w), Math.ceil(box.h));
674
- }
675
- if (st) this._drawBorders(ctx, box, x, y);
676
-
677
- if (box.kind === 'image') {
678
- this._drawImage(ctx, box, x, y);
679
- if (box.href) {
680
- this._links.push({ x, y, w: box.w, h: box.h, href: box.href, element: box.element });
681
- }
682
- return;
683
- }
684
-
685
- if (st?.display === 'list-item') this._drawListMarker(ctx, box, ox, oy);
686
- for (const child of box.children) this._drawBox(ctx, child, ox, oy);
687
- }
688
-
689
- _drawBorders(ctx, box, x, y) {
690
- const st = box.style;
691
- const bw = st.borderWidth;
692
- if (!bw.some((w, i) => w > 0 && st.borderStyle[i] !== 'none')) return;
693
- const color = (i) => st.borderColor[i] ?? st.color;
694
- const w = Math.ceil(box.w);
695
- const h = Math.ceil(box.h);
696
- x = Math.round(x);
697
- y = Math.round(y);
698
- if (bw[0] > 0 && st.borderStyle[0] !== 'none') {
699
- ctx.fillStyle = color(0);
700
- ctx.fillRect(x, y, w, Math.round(bw[0]));
701
- }
702
- if (bw[2] > 0 && st.borderStyle[2] !== 'none') {
703
- ctx.fillStyle = color(2);
704
- ctx.fillRect(x, y + h - Math.round(bw[2]), w, Math.round(bw[2]));
705
- }
706
- if (bw[3] > 0 && st.borderStyle[3] !== 'none') {
707
- ctx.fillStyle = color(3);
708
- ctx.fillRect(x, y, Math.round(bw[3]), h);
709
- }
710
- if (bw[1] > 0 && st.borderStyle[1] !== 'none') {
711
- ctx.fillStyle = color(1);
712
- ctx.fillRect(x + w - Math.round(bw[1]), y, Math.round(bw[1]), h);
713
- }
714
- }
715
-
716
- _drawText(ctx, box, ox, oy) {
717
- const layout = this._textLayout(box, box.w);
718
- const x = ox + box.x;
719
- const y = oy + box.y;
720
- for (const line of layout.lines) {
721
- for (const r of line.runs) {
722
- const m = r.run.font.metrics(r.run.size);
723
- if (r.span._href) {
724
- this._links.push({
725
- x: x + line.x + r.x,
726
- y: y + line.baseline - m.ascent,
727
- w: r.width,
728
- h: m.ascent + m.descent,
729
- href: r.span._href,
730
- element: null
731
- });
732
- }
733
- const deco = r.span._deco;
734
- if (!deco) continue;
735
- if (deco.bg) {
736
- ctx.fillStyle = deco.bg;
737
- ctx.fillRect(
738
- Math.round(x + line.x + r.x),
739
- Math.round(y + line.baseline - m.ascent),
740
- Math.ceil(r.width),
741
- Math.ceil(m.ascent + m.descent)
742
- );
743
- }
744
- if (deco.underline) {
745
- ctx.fillStyle = deco.underline;
746
- ctx.fillRect(Math.round(x + line.x + r.x), Math.round(y + line.baseline + 2), Math.ceil(r.width), 1);
747
- }
748
- if (deco.strike) {
749
- ctx.fillStyle = deco.strike;
750
- ctx.fillRect(
751
- Math.round(x + line.x + r.x),
752
- Math.round(y + line.baseline - m.ascent * 0.3),
753
- Math.ceil(r.width),
754
- 1
755
- );
756
- }
757
- }
758
- }
759
- layout.draw(ctx, x, y);
760
- }
761
-
762
- _drawImage(ctx, box, x, y) {
763
- const entry = this._images.get(box.element);
764
- const img = entry?.image;
765
- const w = box.contentW ?? box.w;
766
- const h = box.contentH ?? box.h;
767
- if (img) {
768
- if (w > 0 && h > 0) ctx.drawImage(img, Math.round(box.contentX ?? x), Math.round(box.contentY ?? y), Math.round(w), Math.round(h));
769
- return;
770
- }
771
- if (entry?.svg) {
772
- if (w > 0 && h > 0) entry.svg.draw(ctx, Math.round(box.contentX ?? x), Math.round(box.contentY ?? y), Math.round(w), Math.round(h));
773
- return;
774
- }
775
- // placeholder: light box with alt text
776
- if (box.w > 2 && box.h > 2) {
777
- ctx.fillStyle = '#eeeeee';
778
- ctx.fillRect(Math.round(x), Math.round(y), Math.ceil(box.w), Math.ceil(box.h));
779
- const alt = box.element.attribs?.alt;
780
- if (alt) {
781
- const layout = new TextLayout(this._fonts(), alt, { ...box.style, fontSize: 12 }, {
782
- maxWidth: Math.max(10, box.w - 8)
783
- });
784
- ctx.fillStyle = '#666666';
785
- layout.draw(ctx, x + 4, y + 4);
786
- }
787
- }
788
- }
789
-
790
- _drawListMarker(ctx, box, ox, oy) {
791
- const st = box.style;
792
- const n = st._liIndex ?? 1;
793
- let marker;
794
- switch (st.listStyleType) {
795
- case 'none':
796
- return;
797
- case 'decimal':
798
- marker = `${n}.`;
799
- break;
800
- case 'lower-alpha':
801
- case 'lower-roman':
802
- marker = `${String.fromCharCode(96 + ((n - 1) % 26) + 1)}.`;
803
- break;
804
- case 'upper-alpha':
805
- case 'upper-roman':
806
- marker = `${String.fromCharCode(64 + ((n - 1) % 26) + 1)}.`;
807
- break;
808
- case 'circle':
809
- marker = '◦';
810
- break;
811
- case 'square':
812
- marker = '▪';
813
- break;
814
- default:
815
- marker = '•';
816
- }
817
- const layout = new TextLayout(this._fonts(), marker, st, {});
818
- layout.draw(ctx, ox + box.x - layout.width - st.fontSize * 0.4, oy + box.y);
819
- }
820
-
821
- /**
822
- * The link under a point (device coordinates of the last draw), or null.
823
- * Returns `{ href, element }`.
824
- */
825
- linkAt(x, y) {
826
- for (let i = this._links.length - 1; i >= 0; i--) {
827
- const l = this._links[i];
828
- if (x >= l.x && x < l.x + l.w && y >= l.y && y < l.y + l.h) return l;
829
- }
830
- return null;
831
- }
832
-
833
- /**
834
- * The deepest DOM element whose box contains the point (coordinates as in
835
- * the last draw), or null. Text runs report their nearest element ancestor.
836
- */
837
- elementAt(x, y) {
838
- const scroll = this.window ? this.scrollY : 0;
839
- const hit = (box, px, py, best) => {
840
- if (px >= box.x && px < box.x + box.w && py >= box.y && py < box.y + box.h) {
841
- if (box.element) best = box.element;
842
- for (const child of box.children) best = hit(child, px, py, best);
843
- }
844
- return best;
845
- };
846
- return this._root ? hit(this._root, x, y + scroll, null) : null;
847
- }
848
-
849
- // -------------------------------------------------------------------------
850
- // window mode
851
-
852
- /** scroll the document vertically (window mode); clamps to content */
853
- scrollBy(dy) {
854
- return this.scrollTo(this.scrollY + dy);
855
- }
856
-
857
- scrollTo(yPos) {
858
- if (!this.window) return this;
859
- const max = Math.max(0, this.contentHeight - this.window.height);
860
- const next = Math.max(0, Math.min(max, yPos));
861
- if (next !== this.scrollY) {
862
- this.scrollY = next;
863
- this.render();
864
- }
865
- return this;
866
- }
867
-
868
- /** full window render: (re)layout on size change, clear, draw */
869
- render() {
870
- if (!this.window) throw new Error('HtmlView.render() needs a window');
871
- const ctx = this._ctx;
872
- if (this._layoutWidth !== this.window.width) {
873
- this.layout(this.window.width);
874
- const max = Math.max(0, this.contentHeight - this.window.height);
875
- if (this.scrollY > max) this.scrollY = max;
876
- }
877
- ctx.fillStyle = this.theme.background;
878
- ctx.fillRect(0, 0, this.window.width, this.window.height);
879
- this.draw(ctx, 0, -this.scrollY);
880
- return this;
881
- }
882
-
883
- /** free yoga nodes and server-side image uploads */
884
- destroy() {
885
- this._disposeTree();
886
- for (const entry of this._images.values()) entry.image?.destroy();
887
- this._images.clear();
888
- }
889
-
890
- [Symbol.dispose]() {
891
- this.destroy();
892
- }
893
- }