@zumer/snapdom-plugins 2.2.2 → 3.0.0-beta.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.
@@ -0,0 +1,175 @@
1
+ /**
2
+ * redactInputs - Official SnapDOM Plugin
3
+ * Mask typed values and optionally exclude blocks or remove named attributes in captures.
4
+ *
5
+ * Core captures what the browser paints, which is the whole point of a fidelity-first
6
+ * renderer: an `email` or `tel` field shows its value in plain text on screen, so the
7
+ * capture shows it too. The ONE exception core makes is `type="password"`, and only because
8
+ * the control already paints bullets, so masking it costs zero fidelity.
9
+ *
10
+ * Redacting anything else is a deliberate loss of fidelity, so it is opt-in and lives here.
11
+ * Defaults cover the fields that are usually sensitive AND rendered in the clear.
12
+ *
13
+ * The default mask keeps the string's length. Glyph widths can differ, so wrapping and
14
+ * truncation may change. `mask: () => ''` blanks a field outright.
15
+ *
16
+ * The built-in mask without a selector permits unchanged-repeat memoization. Custom maskers
17
+ * and selectors (including block/attribute rules) run on every capture because they may
18
+ * read changing external state. Official semantic exporters share this capture's policy.
19
+ * A changed tree rebuilds fully: the differential path cannot skip this afterClone transformation.
20
+ * Pinned by __tests__/plugins.transforms.v3.test.js.
21
+ * @module plugins/redact-inputs
22
+ *
23
+ * @param {Object} [options]
24
+ * @param {string[]} [options.types] - input `type` values to redact. Default:
25
+ * ['email', 'tel']. Note 'password' is already masked by core.
26
+ * @param {string[]} [options.autocomplete] - autocomplete tokens to redact. A trailing '*'
27
+ * matches by prefix. Default: ['cc-*', 'current-password', 'new-password', 'one-time-code'].
28
+ * @param {string} [options.selector] - extra CSS selector for inputs and textareas
29
+ * (use it for textareas and app-specific fields).
30
+ * @param {boolean} [options.all=false] - redact EVERY input and textarea, ignoring the lists.
31
+ * @param {(value: string, el: Element) => string} [options.mask] - custom masker.
32
+ * @param {string|string[]} [options.blocks] - CSS selectors for subtrees to exclude,
33
+ * honoring the capture's excludeMode (hide by default).
34
+ * @param {{selector: string, names: string[]}[]} [options.attributes] - exact attributes
35
+ * to remove from matching source elements. Does not remove copies in visible text/CSS.
36
+ * @returns {Object} SnapDOM plugin
37
+ */
38
+ import { createPrivacyPolicy, getPrivacyPolicy, setPrivacyPolicy } from './privacy-policy.js';
39
+ import { sanitizeClone } from './redact-clone.js';
40
+
41
+ export function redactInputs(options = {}) {
42
+ const {
43
+ types = ['email', 'tel'],
44
+ autocomplete = ['cc-*', 'current-password', 'new-password', 'one-time-code'],
45
+ selector = '',
46
+ all = false,
47
+ mask = (value) => '•'.repeat(value.length),
48
+ } = options;
49
+
50
+ const blockInput = options.blocks ?? [];
51
+ const blocks = typeof blockInput === 'string' ? [blockInput] : blockInput;
52
+ if (!Array.isArray(blocks) || blocks.some(value => typeof value !== 'string' || !value.trim())) {
53
+ throw new TypeError('[snapdom] redact-inputs: blocks must be a selector or an array of non-empty selectors');
54
+ }
55
+ const blockSelectors = [...new Set(blocks.map(value => value.trim()))];
56
+ const attributeInput = options.attributes ?? [];
57
+ if (!Array.isArray(attributeInput)) throw new TypeError('[snapdom] redact-inputs: attributes must be an array of rules');
58
+ const attributes = attributeInput.map(rule => {
59
+ if (!rule || typeof rule.selector !== 'string' || !rule.selector.trim() || !Array.isArray(rule.names) ||
60
+ rule.names.some(name => typeof name !== 'string' || !/^[a-zA-Z_][\w:.-]*$/.test(name))) {
61
+ throw new TypeError('[snapdom] redact-inputs: attribute rules need a selector and exact attribute names');
62
+ }
63
+ return { selector: rule.selector.trim(), names: [...new Set(rule.names)] };
64
+ }).filter(rule => rule.names.length);
65
+ const extended = blockSelectors.length > 0 || attributes.length > 0;
66
+ const validatedDocuments = new WeakSet();
67
+ const validateSelectors = (doc) => {
68
+ if (!extended || validatedDocuments.has(doc)) return;
69
+ const probe = doc.createElement('div');
70
+ for (const value of [...blockSelectors, ...attributes.map(rule => rule.selector)]) {
71
+ try { probe.matches(value); }
72
+ catch { throw new TypeError(`[snapdom] redact-inputs: invalid privacy selector ${JSON.stringify(value)}`); }
73
+ }
74
+ validatedDocuments.add(doc);
75
+ };
76
+
77
+ const typeSet = new Set(types.map((t) => String(t).toLowerCase()));
78
+ const acExact = new Set();
79
+ const acPrefixes = [];
80
+ for (const token of autocomplete) {
81
+ const t = String(token).toLowerCase();
82
+ if (t.endsWith('*')) acPrefixes.push(t.slice(0, -1));
83
+ else acExact.add(t);
84
+ }
85
+
86
+ function matchesAutocomplete(el) {
87
+ const ac = (el.getAttribute('autocomplete') || '').toLowerCase();
88
+ if (!ac) return false;
89
+ for (const token of ac.split(/\s+/)) {
90
+ if (acExact.has(token)) return true;
91
+ for (const prefix of acPrefixes) {
92
+ if (token.startsWith(prefix)) return true;
93
+ }
94
+ }
95
+ return false;
96
+ }
97
+
98
+ function shouldRedact(el) {
99
+ if (all) return true;
100
+ if (selector) {
101
+ try {
102
+ if (el.matches(selector)) return true;
103
+ } catch { /* invalid selector: the other rules still apply */ }
104
+ }
105
+ // Read the ATTRIBUTE, not el.type: an unknown type attribute reflects as 'text' through
106
+ // the property, so a `type="email"` the engine does not implement would slip through.
107
+ if (el.tagName === 'INPUT' && typeSet.has((el.getAttribute('type') || 'text').toLowerCase())) return true;
108
+ return matchesAutocomplete(el);
109
+ }
110
+
111
+ return {
112
+ name: 'redact-inputs',
113
+ pure: options.mask === undefined && !selector && !extended,
114
+
115
+ beforeSnap(ctx) {
116
+ validateSelectors(ctx.element.ownerDocument);
117
+ const policy = createPrivacyPolicy(blockSelectors, attributes, shouldRedact);
118
+ setPrivacyPolicy(ctx, policy);
119
+ if (blockSelectors.length) {
120
+ // createContext's compiled exclusion policy closes over this array. Extend it
121
+ // in place so core and semantic exporters see the same policy before cloning.
122
+ const exclude = ctx.exclude;
123
+ if (!Array.isArray(exclude)) throw new Error('[snapdom] redact-inputs requires normalized capture exclusions');
124
+ for (const value of blockSelectors) if (!exclude.includes(value)) exclude.push(value);
125
+ // Capturing a child of a private region must not escape that region's policy.
126
+ if (policy.isBlocked(ctx.element) && !exclude.includes('*')) exclude.push('*');
127
+ const previous = ctx.shouldExclude;
128
+ ctx.shouldExclude = (el) => policy.isBlocked(el) || previous(el);
129
+ if (ctx.excludeMode === 'remove' && policy.isBlocked(ctx.element)) {
130
+ throw new Error('[snapdom] redact-inputs: cannot remove the capture root; use excludeMode: "hide"');
131
+ }
132
+ }
133
+ },
134
+
135
+ // Asset/pseudo phases can create attributes and clone referenced SVG definitions.
136
+ // Only the extended rules need this pass; the default field plugin keeps its route.
137
+ ...(extended ? { beforeRender(ctx) { sanitizeClone(ctx, getPrivacyPolicy(ctx), { final: true }); } } : {}),
138
+
139
+ afterClone(ctx) {
140
+ const root = ctx.clone;
141
+ if (!root || !root.querySelectorAll) return;
142
+ // querySelectorAll skips the root itself, and the capture root IS routinely a single
143
+ // field. Include it explicitly (this is the #461 shape of bug).
144
+ const nodes = [...root.querySelectorAll('input, textarea')];
145
+ if (root.matches?.('input, textarea')) nodes.unshift(root);
146
+
147
+ for (const el of nodes) {
148
+ // Core turns date/time controls into text on Firefox/WebKit. Their source still
149
+ // carries the type and selector state the caller asked to match.
150
+ if (!shouldRedact(ctx.nodeMap?.get(el) || el)) continue;
151
+ if (el.tagName === 'TEXTAREA') {
152
+ const masked = String(mask(el.textContent || '', el));
153
+ // Native canvas rendering and later plugins read value; SVG serialization reads text.
154
+ el.value = masked;
155
+ el.textContent = masked;
156
+ continue;
157
+ }
158
+ const masked = String(mask(el.value || '', el));
159
+ try { el.value = masked; } catch {
160
+ // File inputs reject non-empty values. A redacted clone must display the mask,
161
+ // not abort the capture before the remaining fields have been processed.
162
+ el.type = 'text';
163
+ el.value = masked;
164
+ }
165
+ if (el.value !== masked) {
166
+ // Number/date controls silently sanitize arbitrary mask text to an empty value.
167
+ el.type = 'text';
168
+ el.value = masked;
169
+ }
170
+ el.setAttribute('value', masked);
171
+ }
172
+ if (extended) sanitizeClone(ctx, getPrivacyPolicy(ctx) || createPrivacyPolicy(blockSelectors, attributes, shouldRedact));
173
+ }
174
+ };
175
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Type declarations for `@zumer/snapdom-plugins/replace-text`.
3
+ *
4
+ * The plugin instance type comes from the core package: one definition of what a plugin IS,
5
+ * so a plugin that stops matching the host's contract fails to typecheck instead of failing
6
+ * at capture time.
7
+ */
8
+ import type { SnapdomPlugin } from '@zumer/snapdom';
9
+
10
+ export interface TextReplacement {
11
+ find: string | RegExp;
12
+ replace: string;
13
+ }
14
+
15
+ export interface ReplaceTextOptions {
16
+ replacements?: TextReplacement[];
17
+ }
18
+
19
+ /** Replaces DOM text in the clone, leaving stylesheet/script text unchanged. */
20
+ export declare function replaceText(options?: ReplaceTextOptions): SnapdomPlugin;
package/replace-text.js CHANGED
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * replaceText - Official SnapDOM Plugin
3
3
  * Find-and-replace text in the captured clone.
4
+ * Preserves stylesheet/script text; replacement arrays remain live between captures.
5
+ * Pinned by __tests__/plugins.transforms.v3.test.js.
6
+ * @module plugins/replace-text
4
7
  *
5
8
  * @param {Object} [options]
6
9
  * @param {Array<{find: string|RegExp, replace: string}>} [options.replacements=[]]
@@ -12,12 +15,15 @@ export function replaceText(options = {}) {
12
15
  } = options;
13
16
 
14
17
  function walkTextNodes(node, fn) {
18
+ // Retained CSS is text too, but changing it would replace styles, URLs and selectors.
19
+ if (node.nodeType === Node.ELEMENT_NODE && /^(style|script)$/i.test(node.localName)) return;
15
20
  if (node.nodeType === Node.TEXT_NODE) {
16
21
  fn(node);
17
22
  } else {
18
23
  for (const child of node.childNodes) {
19
24
  walkTextNodes(child, fn);
20
25
  }
26
+ if (node.localName === 'textarea') node.value = node.textContent;
21
27
  }
22
28
  }
23
29
 
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Type declarations for `@zumer/snapdom-plugins/timestamp-overlay`.
3
+ *
4
+ * The plugin instance type comes from the core package: one definition of what a plugin IS,
5
+ * so a plugin that stops matching the host's contract fails to typecheck instead of failing
6
+ * at capture time.
7
+ */
8
+ import type { SnapdomPlugin } from '@zumer/snapdom';
9
+
10
+ export interface TimestampOverlayOptions {
11
+ format?: 'datetime' | 'date' | 'time' | 'iso' | ((date: Date) => string);
12
+ position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
13
+ background?: string;
14
+ color?: string;
15
+ fontSize?: number;
16
+ }
17
+
18
+ /** Adds a timestamp label, evaluating the clock/formatter again for every capture. */
19
+ export declare function timestampOverlay(options?: TimestampOverlayOptions): SnapdomPlugin;
@@ -1,9 +1,12 @@
1
1
  /**
2
2
  * timestampOverlay - Official SnapDOM Plugin
3
3
  * Adds a translucent timestamp label to the captured clone.
4
+ * Reads the clock/formatter for every capture; it does not opt into unchanged-repeat memoization.
5
+ * Pinned by __tests__/plugins.transforms.v3.test.js.
6
+ * @module plugins/timestamp-overlay
4
7
  *
5
8
  * @param {Object} [options]
6
- * @param {string} [options.format='datetime'] - 'datetime' | 'date' | 'time' | 'iso' | custom function
9
+ * @param {string|((date: Date) => string)} [options.format='datetime'] - 'datetime' | 'date' | 'time' | 'iso' | custom formatter
7
10
  * @param {string} [options.position='bottom-right'] - 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
8
11
  * @param {string} [options.background='rgba(0,0,0,0.6)']
9
12
  * @param {string} [options.color='#fff']
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Type declarations for `@zumer/snapdom-plugins/video-export`.
3
+ *
4
+ * The plugin instance type comes from the core package: one definition of what a plugin IS,
5
+ * so a plugin that stops matching the host's contract fails to typecheck instead of failing
6
+ * at capture time.
7
+ */
8
+ import type { SnapdomPlugin } from '@zumer/snapdom';
9
+
10
+ export interface VideoExportOptions {
11
+ fps?: number;
12
+ /** Total duration in ms. Ignored when `frames` is set. */
13
+ duration?: number;
14
+ /** Explicit frame count; overrides `duration`. */
15
+ frames?: number;
16
+ /** Color composited under transparent pixels. */
17
+ background?: string;
18
+ /** Capture scale; inherits the original capture when omitted. */
19
+ scale?: number;
20
+ /** `videoBitsPerSecond` passed to MediaRecorder. */
21
+ bitrate?: number;
22
+ /** Download filename; the default follows the actual .mp4/.webm container. */
23
+ filename?: string;
24
+ }
25
+
26
+ /** Adds `result.toMp4()`: records through MediaRecorder, with WebM fallback when needed. */
27
+ export declare function videoExport(options?: VideoExportOptions): SnapdomPlugin;
28
+ export default videoExport;
package/video-export.js CHANGED
@@ -13,12 +13,12 @@
13
13
  * @param {number} [options.duration=2000] - Total duration in ms (ignored if options.frames is set)
14
14
  * @param {number} [options.frames] - Explicit frame count (overrides duration)
15
15
  * @param {string} [options.background='#ffffff'] - Color composited under transparent pixels
16
- * @param {number} [options.scale=1] - Capture scale
16
+ * @param {number} [options.scale] - Capture scale (inherits the capture when omitted)
17
17
  * @param {number} [options.bitrate] - videoBitsPerSecond passed to MediaRecorder
18
- * @param {string} [options.filename] - Download filename (extension auto-set to .mp4/.webm)
18
+ * @param {string} [options.filename] - Download filename (default follows the recorded container)
19
19
  * @returns {Object} SnapDOM plugin
20
20
  */
21
- import { snapdom } from '@zumer/snapdom';
21
+ import { rememberCanvas, exportOption, frameOptions } from './capture-frames.js';
22
22
 
23
23
  const MIME_CANDIDATES = [
24
24
  'video/mp4;codecs=avc1',
@@ -34,7 +34,7 @@ export function videoExport(options = {}) {
34
34
  duration = 2000,
35
35
  frames: frameOpt = null,
36
36
  background = '#ffffff',
37
- scale = 1,
37
+ scale,
38
38
  bitrate = null,
39
39
  filename = null,
40
40
  } = options;
@@ -42,45 +42,28 @@ export function videoExport(options = {}) {
42
42
  return {
43
43
  name: 'video-export',
44
44
 
45
- // The export ctx comes from createContext (no `element`). Stash the live
46
- // element during a capture hook so toMp4() can re-capture frames from it.
47
- beforeSnap(ctx) {
48
- if (ctx && ctx.options) ctx.options.__snapSource = ctx.element;
49
- },
50
-
51
- defineExports() {
45
+ defineExports(context) {
46
+ rememberCanvas(context);
52
47
  return {
53
48
  mp4: async (ctx, opts = {}) => {
54
49
  if (typeof MediaRecorder === 'undefined') {
55
50
  throw new Error('[snapdom] video-export: MediaRecorder is not available in this environment');
56
51
  }
57
- const el = ctx.__snapSource || ctx.element;
52
+ if (typeof HTMLCanvasElement.prototype.captureStream !== 'function') {
53
+ throw new Error('[snapdom] video-export: canvas.captureStream is not available in this environment');
54
+ }
55
+ const el = ctx.element;
58
56
  if (!el) throw new Error('[snapdom] video-export: no source element on context');
59
57
 
60
- const _fps = opts.fps ?? fps;
61
- const _dur = opts.duration ?? duration;
62
- const _count = Math.max(1, opts.frames ?? frameOpt ?? Math.round((_dur / 1000) * _fps));
63
- const _bg = opts.background ?? background;
64
- const _scale = opts.scale ?? scale ?? ctx.scale ?? 1;
65
- const _bitrate = opts.bitrate ?? bitrate;
58
+ const recording = frameOptions(ctx, opts, { fps, duration, frames: frameOpt, scale, background }, 'video-export');
59
+ const { fps: _fps, count: _count, background: _bg } = recording;
60
+ const _bitrate = exportOption(ctx, opts, 'bitrate', bitrate);
66
61
  const frameMs = 1000 / _fps;
67
62
 
68
- // 1) Pre-render every frame onto a fixed-size canvas.
69
- let W = 0, H = 0;
70
- const frames = [];
71
- for (let i = 0; i < _count; i++) {
72
- const cap = await snapdom(el, { scale: _scale, backgroundColor: _bg, fast: true });
73
- const src = await cap.toCanvas();
74
- if (i === 0) { W = src.width; H = src.height; }
75
- const fc = document.createElement('canvas');
76
- fc.width = W; fc.height = H;
77
- const fx = fc.getContext('2d');
78
- fx.fillStyle = _bg;
79
- fx.fillRect(0, 0, W, H);
80
- fx.drawImage(src, 0, 0, W, H);
81
- frames.push(fc);
82
- if (i < _count - 1) await new Promise(r => setTimeout(r, frameMs));
83
- }
63
+ // 1) Recapture the live first frame, retaining the caller's capture policy.
64
+ // Later frames use the same options and the engine's normal freshness checks.
65
+ const firstSrc = await recording.next();
66
+ const W = firstSrc.width, H = firstSrc.height;
84
67
 
85
68
  // 2) Pick the best supported container/codec.
86
69
  const mimeType = MIME_CANDIDATES.find(t =>
@@ -90,33 +73,68 @@ export function videoExport(options = {}) {
90
73
  console.warn(`[snapdom] video-export: MP4 not supported by this browser's MediaRecorder; falling back to ${mimeType}`);
91
74
  }
92
75
 
93
- // 3) Play the frames onto a stage canvas while recording its stream.
76
+ // 3) Request each painted frame where supported. MediaRecorder uses a real-time
77
+ // clock: slow captures can still stretch timing; requestFrame supplies no timestamp.
94
78
  const stage = document.createElement('canvas');
95
79
  stage.width = W; stage.height = H;
96
80
  const sctx = stage.getContext('2d');
97
- const stream = stage.captureStream(_fps);
98
-
81
+ const paint = (src) => {
82
+ sctx.fillStyle = _bg;
83
+ sctx.fillRect(0, 0, W, H);
84
+ sctx.drawImage(src, 0, 0, W, H);
85
+ };
99
86
  const recOpts = {};
100
87
  if (mimeType) recOpts.mimeType = mimeType;
101
88
  if (_bitrate) recOpts.videoBitsPerSecond = _bitrate;
102
- const rec = new MediaRecorder(stream, recOpts);
103
-
104
89
  const chunks = [];
105
- rec.ondataavailable = e => { if (e.data && e.data.size) chunks.push(e.data); };
106
- const stopped = new Promise(res => { rec.onstop = res; });
90
+ let stream, rec;
91
+ try {
92
+ stream = stage.captureStream(0);
93
+ let track = stream.getVideoTracks()[0];
94
+ if (!track) throw new Error('[snapdom] video-export: captureStream produced no video track');
95
+ if (typeof track.requestFrame !== 'function') {
96
+ // A zero-rate track without requestFrame never receives later paintings.
97
+ for (const t of stream.getTracks()) t.stop();
98
+ stream = stage.captureStream(_fps);
99
+ track = stream.getVideoTracks()[0];
100
+ if (!track) throw new Error('[snapdom] video-export: captureStream produced no video track');
101
+ }
102
+ const pushFrame = () => track.requestFrame?.();
103
+ rec = new MediaRecorder(stream, recOpts);
104
+ rec.ondataavailable = e => { if (e.data && e.data.size) chunks.push(e.data); };
105
+ const stopped = new Promise(resolve => { rec.onstop = resolve; });
106
+ const failed = new Promise((_, reject) => {
107
+ rec.onerror = event => reject(event.error || new Error('[snapdom] video-export: MediaRecorder failed'));
108
+ });
109
+ // An error can arrive between awaited frame operations. Mark it handled now;
110
+ // every wait below still observes the original rejection and exits the recorder.
111
+ failed.catch(() => {});
112
+ const wait = promise => Promise.race([promise, failed]);
113
+ const sleep = ms => wait(new Promise(resolve => setTimeout(resolve, ms)));
107
114
 
108
- rec.start();
109
- for (let i = 0; i < frames.length; i++) {
110
- sctx.clearRect(0, 0, W, H);
111
- sctx.drawImage(frames[i], 0, 0);
112
- await new Promise(r => setTimeout(r, frameMs));
115
+ rec.start();
116
+ paint(firstSrc);
117
+ pushFrame();
118
+ // Hold the first frame too; immediately painting the second used to erase it.
119
+ await sleep(frameMs);
120
+ for (let i = 1; i < _count; i++) {
121
+ const t0 = performance.now();
122
+ paint(await wait(recording.next()));
123
+ pushFrame();
124
+ const remaining = frameMs - (performance.now() - t0);
125
+ // Give the last painting a full frame interval before stopping the encoder.
126
+ if (i === _count - 1 || remaining > 0) await sleep(i === _count - 1 ? frameMs : remaining);
127
+ }
128
+ rec.stop();
129
+ await wait(stopped);
130
+ } finally {
131
+ if (rec && rec.state !== 'inactive') { try { rec.stop(); } catch { /* already failed */ } }
132
+ for (const track of stream?.getTracks() || []) track.stop();
113
133
  }
114
- await new Promise(r => setTimeout(r, frameMs)); // let the last frame land
115
- rec.stop();
116
- await stopped;
117
134
 
118
- const isMp4 = mimeType.startsWith('video/mp4');
119
- const blob = new Blob(chunks, { type: (mimeType || 'video/webm').split(';')[0] });
135
+ const actualType = (rec.mimeType || chunks.find(chunk => chunk.type)?.type || mimeType || 'video/webm').split(';')[0];
136
+ const isMp4 = actualType.startsWith('video/mp4');
137
+ const blob = new Blob(chunks, { type: actualType });
120
138
 
121
139
  const dl = opts.download;
122
140
  if (dl) {
@@ -124,7 +142,7 @@ export function videoExport(options = {}) {
124
142
  const a = document.createElement('a');
125
143
  a.href = objUrl;
126
144
  const fallbackName = isMp4 ? 'capture.mp4' : 'capture.webm';
127
- a.download = typeof dl === 'string' ? dl : (opts.filename || filename || fallbackName);
145
+ a.download = typeof dl === 'string' ? dl : exportOption(ctx, opts, 'filename', filename || fallbackName);
128
146
  a.click();
129
147
  setTimeout(() => URL.revokeObjectURL(objUrl), 5000);
130
148
  }
package/html-in-canvas.js DELETED
@@ -1,139 +0,0 @@
1
- /**
2
- * Experimental plugin for WICG html-in-canvas (issue #172).
3
- * Uses drawElementImage() to render the snapdom clone directly into canvas,
4
- * bypassing SVG/foreignObject.
5
- *
6
- * Requires: Chrome with chrome://flags/#canvas-draw-element enabled.
7
- * @see https://github.com/WICG/html-in-canvas
8
- */
9
-
10
- const PLUGIN_NAME = 'html-in-canvas'
11
-
12
- /**
13
- * The WICG canvas-place-element spec evolved: Chrome ~130+ exposes drawElement(),
14
- * earlier flagged builds shipped drawElementImage(). Detect either.
15
- * @returns {'drawElement'|'drawElementImage'|null}
16
- */
17
- function detectDrawApi() {
18
- try {
19
- const c = document.createElement('canvas')
20
- const ctx = c.getContext('2d')
21
- if (!ctx) return null
22
- if (typeof ctx.drawElement === 'function') return 'drawElement'
23
- if (typeof ctx.drawElementImage === 'function') return 'drawElementImage'
24
- return null
25
- } catch {
26
- return null
27
- }
28
- }
29
-
30
- /**
31
- * @returns {import('../../src/core/plugins.js').Plugin}
32
- */
33
- export function htmlInCanvasPlugin() {
34
- const drawApi = detectDrawApi()
35
- const available = !!drawApi
36
- if (!available) {
37
- console.warn('[snapdom] html-in-canvas plugin: drawElement / drawElementImage not available. Enable chrome://flags/#canvas-draw-element')
38
- }
39
-
40
- return {
41
- name: PLUGIN_NAME,
42
-
43
- beforeRender(state) {
44
- if (!available) return
45
- if (!state.clone || !state.element) return
46
- state.options.__htmlInCanvas = {
47
- clone: state.clone,
48
- baseCSS: state.baseCSS || '',
49
- fontsCSS: state.fontsCSS || '',
50
- classCSS: state.classCSS || '',
51
- element: state.element,
52
- w0: null,
53
- h0: null
54
- }
55
- },
56
-
57
- afterRender(state) {
58
- if (!available) return
59
- const meta = state.options?.meta
60
- const stored = state.options?.__htmlInCanvas
61
- if (meta && stored) {
62
- stored.w0 = meta.w0
63
- stored.h0 = meta.h0
64
- }
65
- },
66
-
67
- async defineExports(ctx) {
68
- if (!available) return {}
69
- const stored = ctx.__htmlInCanvas
70
- if (!stored) return {}
71
-
72
- return {
73
- htmlInCanvas: async (opts = {}) => {
74
- const { clone, baseCSS, fontsCSS, classCSS, element } = stored
75
- const w0 = stored.w0 ?? element?.offsetWidth
76
- const h0 = stored.h0 ?? element?.offsetHeight
77
- const scale = opts.scale ?? ctx.scale ?? 1
78
- const dpr = opts.dpr ?? (typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1)
79
-
80
- const rect = element?.getBoundingClientRect?.()
81
- const width = w0 ?? rect?.width ?? 100
82
- const height = h0 ?? rect?.height ?? 100
83
- const outW = Math.round(width * scale * dpr)
84
- const outH = Math.round(height * scale * dpr)
85
-
86
- const canvas = document.createElement('canvas')
87
- canvas.width = outW
88
- canvas.height = outH
89
- canvas.setAttribute('layoutsubtree', '')
90
-
91
- const wrapper = document.createElement('div')
92
- wrapper.style.cssText = `width:${width}px;height:${height}px;overflow:visible;box-sizing:border-box;`
93
- wrapper.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml')
94
-
95
- const styleTag = document.createElement('style')
96
- styleTag.textContent = `${baseCSS}${fontsCSS}svg{overflow:visible;}${classCSS}`
97
- wrapper.appendChild(styleTag)
98
-
99
- const cloneCopy = clone.cloneNode(true)
100
- wrapper.appendChild(cloneCopy)
101
-
102
- canvas.appendChild(wrapper)
103
-
104
- // Append directly to body, taken out of flow with position:fixed + z-index:-1
105
- // so it sits behind the page's content (covered by body/main backgrounds)
106
- // while still being painted. visibility:hidden / opacity:0 / left:-9999px
107
- // skip the paint pass and trigger "No cached paint record".
108
- canvas.style.cssText = 'position:fixed;top:0;left:0;z-index:-1;'
109
- document.body.appendChild(canvas)
110
-
111
- try {
112
- canvas.getBoundingClientRect()
113
- await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))
114
-
115
- const ctx2d = canvas.getContext('2d')
116
- const fn = ctx2d && (ctx2d[drawApi] || ctx2d.drawElement || ctx2d.drawElementImage)
117
- if (typeof fn !== 'function') {
118
- throw new Error('drawElement / drawElementImage not available on this canvas context')
119
- }
120
- ctx2d.save()
121
- ctx2d.scale(dpr * scale, dpr * scale)
122
- fn.call(ctx2d, wrapper, 0, 0, width, height)
123
- ctx2d.restore()
124
- return canvas
125
- } catch (e) {
126
- if (e && /paint record/i.test(e.message || '')) {
127
- throw new Error('Browser had no paint record for the element. Make sure the document is fully loaded and visible before calling html-in-canvas (drawElement requires a real paint pass).')
128
- }
129
- throw e
130
- } finally {
131
- try { canvas.remove() } catch {}
132
- }
133
- }
134
- }
135
- }
136
- }
137
- }
138
-
139
- export default htmlInCanvasPlugin