htmlboard 0.6.0 → 0.8.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.
@@ -29,9 +29,28 @@
29
29
  <kbd aria-hidden="true">/</kbd>
30
30
  </div>
31
31
  <div class="filters" id="filters" role="group" aria-label="Show" hidden></div>
32
+ <div class="sections-bar" id="sections-bar" hidden>
33
+ <span class="sections-label">Sections</span>
34
+ <div class="sections-tools" role="group" aria-label="All sections">
35
+ <button class="mini" type="button" id="expand-all" title="Expand all" aria-label="Expand all sections">
36
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m7 15 5 5 5-5M7 9l5-5 5 5"/></svg>
37
+ </button>
38
+ <button class="mini" type="button" id="collapse-all" title="Collapse all" aria-label="Collapse all sections">
39
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m7 20 5-5 5 5M7 4l5 5 5-5"/></svg>
40
+ </button>
41
+ <span class="mini-gap" aria-hidden="true"></span>
42
+ <button class="mini" type="button" id="show-all" title="Show all on the canvas" aria-label="Show all sections">
43
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.06 12.35a1 1 0 0 1 0-.7 10.75 10.75 0 0 1 19.88 0 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-19.88 0"/><circle cx="12" cy="12" r="3"/></svg>
44
+ </button>
45
+ <button class="mini" type="button" id="hide-all" title="Hide all from the canvas" aria-label="Hide all sections">
46
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10.73 5.08a10.74 10.74 0 0 1 11.2 6.57 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-1.44 2.49M14.08 14.16a3 3 0 0 1-4.24-4.24M17.48 17.5a10.75 10.75 0 0 1-15.42-5.15 1 1 0 0 1 0-.7 10.75 10.75 0 0 1 4.45-5.14M2 2l20 20"/></svg>
47
+ </button>
48
+ </div>
49
+ </div>
32
50
  <p class="no-match" id="no-match" hidden>No design matches the search.</p>
33
51
  <nav class="list" id="list" aria-label="Designs by section"></nav>
34
52
  <p class="sidebar-hint">Drag or scroll to move, <kbd>Ctrl</kbd> + scroll or pinch to zoom, double-click a design to fit it. <kbd>?</kbd> lists the shortcuts.</p>
53
+
35
54
  </aside>
36
55
 
37
56
  <main class="viewport" id="viewport" aria-label="Canvas">
@@ -41,6 +60,14 @@
41
60
  <div class="outline outline--selected" id="selection" hidden></div>
42
61
  <div class="size" id="size" hidden></div>
43
62
  </div>
63
+ <div class="actions" id="actions" role="toolbar" aria-label="Design" hidden>
64
+ <button class="mini" type="button" id="copy-image" title="Copy as an image (C)" aria-label="Copy as an image">
65
+ <svg viewBox="0 0 24 24" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
66
+ </button>
67
+ <button class="mini" type="button" id="save-image" title="Save as an image (S); Shift + click: choose the folder" aria-label="Save as an image">
68
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
69
+ </button>
70
+ </div>
44
71
 
45
72
  <div class="toolbar" role="toolbar" aria-label="Canvas">
46
73
  <button class="tool" type="button" id="toggle-list" title="Show or hide the list (\)" aria-label="Show or hide the list" aria-pressed="true">
@@ -83,6 +110,7 @@
83
110
  <div><dt><kbd>Shift</kbd> + <kbd>2</kbd>, double-click</dt><dd>Fit the design</dd></div>
84
111
  <div><dt><kbd>←</kbd> <kbd>→</kbd></dt><dd>Previous, next design</dd></div>
85
112
  <div><dt><kbd>Enter</kbd></dt><dd>Open the design on its own</dd></div>
113
+ <div><dt><kbd>C</kbd> <kbd>S</kbd></dt><dd>Copy, save the design as an image</dd></div>
86
114
  <div><dt><kbd>/</kbd></dt><dd>Search</dd></div>
87
115
  <div><dt><kbd>1</kbd> – <kbd>4</kbd></dt><dd>All, mobile, tablet, desktop</dd></div>
88
116
  <div><dt><kbd>R</kbd></dt><dd>Reload the designs</dd></div>
@@ -91,6 +119,11 @@
91
119
  </dl>
92
120
  </section>
93
121
 
122
+ <div class="empty" id="empty" hidden>
123
+ <p id="empty-text">All sections are hidden.</p>
124
+ <button class="empty-show" type="button" id="empty-show">Show all</button>
125
+ </div>
126
+
94
127
  <div class="toast" id="toast" role="status" aria-live="polite"></div>
95
128
  <div class="notice" id="notice" hidden>
96
129
  <img class="notice-mark" src="/__explorer/mark.svg" alt="" height="72">
@@ -0,0 +1,375 @@
1
+ // A design as an image, to copy or save it. The browser draws it from the page as its frame has it:
2
+ // the page's HTML and CSS go into an SVG <foreignObject>, with the files they use (stylesheets, fonts,
3
+ // images) inlined, since an image can't load anything. Scripts don't run in an image, so it shows the
4
+ // page as built when it was taken.
5
+ //
6
+ // What it can't show: images from another site that doesn't allow reading them (CORS), pages inside
7
+ // the page (iframes), shadow DOM. A video shows the frame it is at, or its poster.
8
+
9
+ const MAX_SIDE = 16384; // px: the longest side browsers draw a canvas at
10
+ const MAX_AREA = 64e6; // px: about 256 MB of pixels
11
+ const LOAD_MS = 10000;
12
+ const TRANSPARENT = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
13
+ const URL_RE = /url\(\s*(['"]?)(.*?)\1\s*\)/gi;
14
+ const IMPORT_RULE = 3;
15
+ const FONT_FACE_RULE = 5;
16
+ const LEFT_OUT = new Set(['script', 'noscript', 'style', 'link', 'source', 'track']);
17
+
18
+ // What pictures of several designs share, read once for all of them: the files (fonts, images) as
19
+ // data: URLs, and each linked stylesheet's CSS, for each set of its fonts the designs use. Kept until
20
+ // forgetFiles(): after a file changes, or once every picture is made.
21
+ const shared = { files: new Map(), sheets: new Map(), svgs: new Map() };
22
+
23
+ export function forgetFiles() {
24
+ shared.files.clear();
25
+ shared.sheets.clear();
26
+ shared.svgs.clear();
27
+ }
28
+
29
+ /**
30
+ * The page in `iframe` drawn on a canvas, `width` × `height` CSS px at `scale` device px per CSS px
31
+ * (less when that would make a canvas too big to draw).
32
+ */
33
+ export async function drawDesign(iframe, { width, height, scale = 1 }) {
34
+ const doc = await loaded(iframe);
35
+ const win = iframe.contentWindow;
36
+ await doc.fonts?.ready;
37
+ const files = reader(win);
38
+ const [css, root] = await Promise.all([styles(doc, files), copyPage(doc, files)]);
39
+ const style = doc.createElement('style');
40
+ style.textContent = css;
41
+ (root.querySelector('head') || root).append(style);
42
+ const markup = new XMLSerializer().serializeToString(root);
43
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">`
44
+ + `<foreignObject x="0" y="0" width="${width}" height="${height}">${markup}</foreignObject></svg>`;
45
+ const image = new Image();
46
+ // A data: URL, not a blob: one, which would keep the canvas from being read (Chromium)
47
+ image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
48
+ try {
49
+ await image.decode();
50
+ } catch {
51
+ throw new Error('the browser couldn’t draw this page');
52
+ }
53
+ scale = Math.min(scale, MAX_SIDE / width, MAX_SIDE / height, Math.sqrt(MAX_AREA / (width * height)));
54
+ const canvas = document.createElement('canvas');
55
+ canvas.width = Math.max(1, Math.round(width * scale));
56
+ canvas.height = Math.max(1, Math.round(height * scale));
57
+ const context = canvas.getContext('2d');
58
+ // The page's background fills the frame in a browser; in an image it would cover only its box.
59
+ context.fillStyle = backdrop(doc, win);
60
+ context.fillRect(0, 0, canvas.width, canvas.height);
61
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
62
+ image.src = ''; // the copy of the page it holds can go now, not when memory runs short
63
+ return canvas;
64
+ }
65
+
66
+ /** The frame's page once it has loaded (a frame reloading after a save is waited for) */
67
+ function loaded(iframe) {
68
+ const ready = () => {
69
+ const doc = iframe.contentDocument;
70
+ return doc && doc.readyState === 'complete' && doc.URL !== 'about:blank' ? doc : null;
71
+ };
72
+ if (ready()) return Promise.resolve(ready());
73
+ return new Promise((resolve, reject) => {
74
+ const done = () => {
75
+ clearTimeout(timer);
76
+ iframe.removeEventListener('load', done);
77
+ if (ready()) resolve(ready());
78
+ else reject(new Error('the page didn’t load'));
79
+ };
80
+ const timer = setTimeout(done, LOAD_MS);
81
+ iframe.addEventListener('load', done);
82
+ });
83
+ }
84
+
85
+ // ---------------------------------------------------------------- the files the page uses
86
+
87
+ // Reads the files the page uses as data: URLs, each once. `used` holds the files the page loaded, to
88
+ // leave out the fonts it never needed (another alphabet's part of a font); null when it can't tell.
89
+ function reader(win) {
90
+ let used = null;
91
+ try {
92
+ const entries = win.performance.getEntriesByType('resource');
93
+ if (entries.length < 250) used = new Set(entries.map((entry) => entry.name)); // browsers stop listing at 250
94
+ } catch {
95
+ // Can't tell
96
+ }
97
+ const read = shared.files;
98
+ return {
99
+ used,
100
+ // '' when it can't be read: missing, or another site's that doesn't allow it
101
+ dataUrl(url) {
102
+ if (!read.has(url)) {
103
+ read.set(url, fetch(url)
104
+ .then((res) => (res.ok ? res.blob() : null))
105
+ .then((blob) => (blob ? asDataUrl(blob) : ''))
106
+ .catch(() => ''));
107
+ }
108
+ return read.get(url);
109
+ },
110
+ // An SVG file, parsed (a sprite of icons that <use> draws from); null when it can't be read
111
+ svg(url) {
112
+ if (!shared.svgs.has(url)) {
113
+ shared.svgs.set(url, fetch(url)
114
+ .then((res) => (res.ok ? res.text() : ''))
115
+ .then((text) => {
116
+ const parsed = text && new DOMParser().parseFromString(text, 'image/svg+xml');
117
+ return parsed && !parsed.querySelector('parsererror') ? parsed : null;
118
+ })
119
+ .catch(() => null));
120
+ }
121
+ return shared.svgs.get(url);
122
+ },
123
+ };
124
+ }
125
+
126
+ function asDataUrl(blob) {
127
+ return new Promise((resolve) => {
128
+ const fileReader = new FileReader();
129
+ fileReader.onload = () => resolve(String(fileReader.result));
130
+ fileReader.onerror = () => resolve('');
131
+ fileReader.readAsDataURL(blob);
132
+ });
133
+ }
134
+
135
+ function absolute(url, base) {
136
+ try {
137
+ return new URL(url, base).href;
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ /** CSS with every url(…) in it as a data: URL (an empty one for a file it can't read) */
144
+ async function inlineUrls(css, base, files) {
145
+ const urls = new Map();
146
+ for (const [, , raw] of css.matchAll(URL_RE)) {
147
+ const url = raw.trim();
148
+ if (!url || urls.has(url) || /^(data:|#|about:)/i.test(url)) continue;
149
+ const full = absolute(url, base);
150
+ if (full) urls.set(url, files.dataUrl(full));
151
+ }
152
+ if (!urls.size) return css;
153
+ const data = new Map();
154
+ for (const [url, reading] of urls) data.set(url, await reading);
155
+ return css.replace(URL_RE, (whole, quote, raw) => {
156
+ const value = data.get(raw.trim());
157
+ return value === undefined ? whole : `url("${value || 'data:,'}")`;
158
+ });
159
+ }
160
+
161
+ // ---------------------------------------------------------------- the page's CSS
162
+
163
+ /** All the CSS the page has, in its order, with the files it uses inlined */
164
+ async function styles(doc, files) {
165
+ const parts = [];
166
+ for (const sheet of doc.styleSheets) if (!sheet.disabled) parts.push(await sheetCss(sheet, doc.baseURI, files, []));
167
+ for (const sheet of doc.adoptedStyleSheets || []) parts.push(await rulesCss(sheet.cssRules, doc.baseURI, files, []));
168
+ return parts.join('\n');
169
+ }
170
+
171
+ // `fonts` collects the files of the font faces read, to tell which of them a page used.
172
+ async function sheetCss(sheet, base, files, fonts, media = sheet.media?.mediaText) {
173
+ let css;
174
+ if (sheet.href) {
175
+ // A linked stylesheet, often the same in every design: read once for each set of its fonts used
176
+ const saved = shared.sheets.get(sheet.href);
177
+ const key = saved && fontsKey(saved.fonts, files.used);
178
+ if (saved?.css.has(key)) {
179
+ css = saved.css.get(key);
180
+ fonts.push(...saved.fonts);
181
+ } else {
182
+ const own = [];
183
+ css = await readSheet(sheet, sheet.href, files, own);
184
+ fonts.push(...own);
185
+ const entry = saved || { fonts: own, css: new Map() };
186
+ entry.css.set(fontsKey(entry.fonts, files.used), css);
187
+ shared.sheets.set(sheet.href, entry);
188
+ }
189
+ } else {
190
+ css = await readSheet(sheet, base, files, fonts);
191
+ }
192
+ return media && media !== 'all' ? `@media ${media} {\n${css}\n}` : css;
193
+ }
194
+
195
+ async function readSheet(sheet, base, files, fonts) {
196
+ try {
197
+ return await rulesCss(sheet.cssRules, base, files, fonts);
198
+ } catch {
199
+ // Another site's stylesheet, which the page can use but not read: fetched again, as most allow
200
+ // (font services and CDNs do).
201
+ const css = sheet.href ? await fetch(sheet.href).then((res) => (res.ok ? res.text() : '')).catch(() => '') : '';
202
+ return inlineUrls(css, base, files);
203
+ }
204
+ }
205
+
206
+ async function rulesCss(rules, base, files, fonts) {
207
+ const parts = [];
208
+ for (const rule of rules) {
209
+ if (rule.type === IMPORT_RULE) {
210
+ let css = rule.styleSheet ? await sheetCss(rule.styleSheet, base, files, fonts, rule.media?.mediaText) : '';
211
+ if (rule.supportsText) css = `@supports ${rule.supportsText} {\n${css}\n}`;
212
+ if (rule.layerName != null) css = `@layer${rule.layerName ? ` ${rule.layerName}` : ''} {\n${css}\n}`;
213
+ parts.push(css);
214
+ } else if (rule.type === FONT_FACE_RULE) {
215
+ const urls = fontFiles(rule, base);
216
+ fonts.push(...urls);
217
+ if (!unusedFont(urls, files.used)) parts.push(rule.cssText);
218
+ } else {
219
+ parts.push(rule.cssText);
220
+ }
221
+ }
222
+ return inlineUrls(parts.join('\n'), base, files);
223
+ }
224
+
225
+ // The files a font face loads from (not the ones inside it, as data: URLs)
226
+ function fontFiles(rule, base) {
227
+ return [...rule.cssText.matchAll(URL_RE)].map((match) => match[2].trim())
228
+ .filter((url) => !/^data:/i.test(url)).map((url) => absolute(url, base)).filter(Boolean);
229
+ }
230
+
231
+ // A font face whose files the page never loaded
232
+ function unusedFont(urls, used) {
233
+ return Boolean(used) && urls.length > 0 && !urls.some((url) => used.has(url));
234
+ }
235
+
236
+ // Which of a stylesheet's fonts a page loaded: pages that loaded the same ones get the same CSS
237
+ function fontsKey(fonts, used) {
238
+ return used ? fonts.filter((url) => used.has(url)).join(' ') : '*';
239
+ }
240
+
241
+ // ---------------------------------------------------------------- the page's HTML
242
+
243
+ /**
244
+ * A copy of the page as it is now: without scripts and stylesheets (their CSS comes whole), with what
245
+ * the page's elements hold but their markup doesn't say (the image each <img> chose, form values, a
246
+ * canvas's drawing) and its files inlined.
247
+ */
248
+ async function copyPage(doc, files) {
249
+ const root = doc.documentElement;
250
+ const copy = root.cloneNode(true);
251
+ const live = [root, ...root.querySelectorAll('*')];
252
+ const copies = [copy, ...copy.querySelectorAll('*')]; // the same elements, in the same order
253
+ const waits = [];
254
+ const symbols = new Map(); // another file's symbol that <use> draws (`file#id`) → its id in the copy
255
+ let sprite = null; // where the copy keeps them
256
+ let count = 0;
257
+ const symbolFrom = async (file, id) => {
258
+ const key = `${file}#${id}`;
259
+ if (!symbols.has(key)) {
260
+ symbols.set(key, files.svg(file).then((svg) => {
261
+ const symbol = svg?.getElementById(id);
262
+ if (!symbol) return '';
263
+ if (!sprite) {
264
+ sprite = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
265
+ sprite.setAttribute('aria-hidden', 'true');
266
+ sprite.setAttribute('style', 'position: absolute; width: 0; height: 0; overflow: hidden');
267
+ (copy.querySelector('body') || copy).append(sprite);
268
+ }
269
+ const inlined = doc.importNode(symbol, true);
270
+ inlined.id = `htmlboard-symbol-${++count}`;
271
+ sprite.append(inlined);
272
+ return inlined.id;
273
+ }));
274
+ }
275
+ return symbols.get(key);
276
+ };
277
+ live.forEach((el, i) => {
278
+ const out = copies[i];
279
+ const tag = el.localName;
280
+ if (LEFT_OUT.has(tag)) {
281
+ out.remove();
282
+ return;
283
+ }
284
+ if (tag === 'img') {
285
+ const src = el.currentSrc || el.src;
286
+ for (const attribute of ['srcset', 'sizes', 'loading']) out.removeAttribute(attribute);
287
+ if (src) waits.push(files.dataUrl(src).then((data) => out.setAttribute('src', data || TRANSPARENT)));
288
+ } else if (tag === 'image' && (el.getAttribute('href') || el.getAttribute('xlink:href'))) {
289
+ const full = absolute(el.getAttribute('href') || el.getAttribute('xlink:href'), doc.baseURI);
290
+ if (full) {
291
+ waits.push(files.dataUrl(full).then((data) => {
292
+ out.removeAttribute('xlink:href');
293
+ out.setAttribute('href', data || TRANSPARENT);
294
+ }));
295
+ }
296
+ } else if (tag === 'use') {
297
+ // A symbol from another file (a sprite of icons), which an image can't load: brought in
298
+ const href = el.getAttribute('href') ?? el.getAttribute('xlink:href') ?? '';
299
+ const hash = href.indexOf('#');
300
+ const file = hash > 0 ? absolute(href.slice(0, hash), doc.baseURI) : null;
301
+ if (file && hash < href.length - 1) {
302
+ waits.push(symbolFrom(file, href.slice(hash + 1)).then((id) => {
303
+ if (!id) return;
304
+ out.removeAttribute('xlink:href');
305
+ out.setAttribute('href', `#${id}`);
306
+ }));
307
+ }
308
+ } else if (tag === 'canvas') {
309
+ // Without scripts (as in an image), a canvas shows what it holds instead of its drawing
310
+ asImage(el, out, canvasData(el));
311
+ } else if (tag === 'video') {
312
+ const frame = videoFrame(el);
313
+ waits.push((frame || !el.poster ? Promise.resolve(frame) : files.dataUrl(el.poster)).then((data) => asImage(el, out, data, 'contain')));
314
+ } else if (tag === 'iframe' || tag === 'frame' || tag === 'embed' || tag === 'object') {
315
+ for (const attribute of ['src', 'srcdoc', 'data']) out.removeAttribute(attribute);
316
+ } else if (tag === 'input') {
317
+ if (el.type === 'checkbox' || el.type === 'radio') out.toggleAttribute('checked', el.checked);
318
+ else if (el.type !== 'file') out.setAttribute('value', el.value);
319
+ } else if (tag === 'textarea') {
320
+ out.textContent = el.value;
321
+ } else if (tag === 'option') {
322
+ out.toggleAttribute('selected', el.selected);
323
+ }
324
+ const style = out.getAttribute('style');
325
+ if (style && /url\(/i.test(style)) waits.push(inlineUrls(style, doc.baseURI, files).then((css) => out.setAttribute('style', css)));
326
+ });
327
+ await Promise.all(waits);
328
+ return copy;
329
+ }
330
+
331
+ // An <img> of what a canvas or a video shows, in its place: its attributes (for the page's CSS) and
332
+ // the size the page gave it.
333
+ function asImage(el, out, data, fit = 'fill') {
334
+ const img = el.ownerDocument.createElement('img');
335
+ for (const { name, value } of out.attributes) {
336
+ if (!['src', 'poster', 'autoplay', 'controls', 'width', 'height'].includes(name)) img.setAttribute(name, value);
337
+ }
338
+ const style = el.ownerDocument.defaultView.getComputedStyle(el);
339
+ img.style.width = style.width;
340
+ img.style.height = style.height;
341
+ img.style.objectFit = fit;
342
+ img.src = data || TRANSPARENT;
343
+ out.replaceWith(img);
344
+ }
345
+
346
+ function canvasData(canvas) {
347
+ try {
348
+ return canvas.toDataURL();
349
+ } catch {
350
+ return 'data:,'; // drawn from another site: unreadable
351
+ }
352
+ }
353
+
354
+ function videoFrame(video) {
355
+ try {
356
+ if (video.readyState < 2 || !video.videoWidth) return '';
357
+ const canvas = document.createElement('canvas');
358
+ canvas.width = video.videoWidth;
359
+ canvas.height = video.videoHeight;
360
+ canvas.getContext('2d').drawImage(video, 0, 0);
361
+ return canvas.toDataURL('image/jpeg', 0.9);
362
+ } catch {
363
+ return '';
364
+ }
365
+ }
366
+
367
+ /** The colour that fills the page behind its content: the root's background, or the body's, or white */
368
+ function backdrop(doc, win) {
369
+ for (const el of [doc.documentElement, doc.body]) {
370
+ if (!el) continue;
371
+ const color = win.getComputedStyle(el).backgroundColor;
372
+ if (color && !/^transparent$|,\s*0\)$|\/\s*0\)$/.test(color)) return color;
373
+ }
374
+ return '#FFFFFF';
375
+ }