ntk 7.6.1 → 8.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 +9 -10
- package/lib/image.js +12 -0
- package/lib/index.js +9 -30
- package/lib/maskcluster.js +191 -0
- package/lib/renderingcontext_2d.js +590 -175
- package/lib/text/layout.js +4 -4
- package/lib/widgets/svgview.js +5 -4
- package/package.json +4 -9
- package/lib/widgets/css.js +0 -673
- package/lib/widgets/highlight.js +0 -97
- package/lib/widgets/htmlview.js +0 -893
- package/lib/widgets/markdown.js +0 -148
- package/lib/widgets/markdownview.js +0 -492
- package/lib/widgets/tex.js +0 -679
- package/lib/yoga.js +0 -119
package/lib/widgets/htmlview.js
DELETED
|
@@ -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
|
-
}
|