@zumer/snapdom-plugins 2.2.1 → 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.
- package/LICENSE +21 -0
- package/README.md +192 -50
- package/agent-map.d.ts +31 -0
- package/agent-map.js +248 -88
- package/ascii-export.d.ts +20 -0
- package/ascii-export.js +17 -12
- package/capture-frames.js +85 -0
- package/color-tint.d.ts +18 -0
- package/color-tint.js +8 -4
- package/context-export.d.ts +25 -0
- package/context-export.js +283 -0
- package/filter.d.ts +17 -0
- package/filter.js +10 -3
- package/gif-export.d.ts +29 -0
- package/gif-export.js +15 -21
- package/html-export.d.ts +31 -0
- package/html-export.js +62 -16
- package/index.d.ts +19 -0
- package/index.js +2 -1
- package/package.json +12 -5
- package/pdf-image.d.ts +18 -0
- package/pdf-image.js +14 -14
- package/privacy-policy.js +55 -0
- package/redact-clone.js +150 -0
- package/redact-inputs.d.ts +43 -0
- package/redact-inputs.js +175 -0
- package/replace-text.d.ts +20 -0
- package/replace-text.js +6 -0
- package/timestamp-overlay.d.ts +19 -0
- package/timestamp-overlay.js +4 -1
- package/video-export.d.ts +28 -0
- package/video-export.js +70 -52
- package/html-in-canvas.js +0 -139
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recapture live frames for the GIF/video plugins without losing the capture's options or
|
|
3
|
+
* entering its export queue again. A weak lookup associates each result's frozen geometry
|
|
4
|
+
* with that capture's core canvas facade. A memo hit may return the result already running
|
|
5
|
+
* toGif/toMp4; calling its public toCanvas would wait on that occupied queue forever.
|
|
6
|
+
* @module plugins/capture-frames
|
|
7
|
+
*/
|
|
8
|
+
import { snapdom } from '@zumer/snapdom';
|
|
9
|
+
|
|
10
|
+
const canvases = new WeakMap();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Associate the capture's silent canvas exporter with its result identity.
|
|
14
|
+
* Pinned by __tests__/plugin.recording.memo.test.js.
|
|
15
|
+
* @param {object} [ctx] - defineExports context with meta and core exports
|
|
16
|
+
* @returns {void}
|
|
17
|
+
*/
|
|
18
|
+
export function rememberCanvas(ctx) {
|
|
19
|
+
if (ctx?.meta && ctx.exports?.canvas) canvases.set(ctx.meta, ctx.exports.canvas);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve an explicit export override ahead of a plugin default.
|
|
24
|
+
* v3's normalized opts include inherited defaults; requestedOptions distinguishes explicit
|
|
25
|
+
* scale:1 from inherited scale:1. Pinned by __tests__/plugin.recording.contract.test.js.
|
|
26
|
+
* @param {object} ctx - Capture/export context
|
|
27
|
+
* @param {object} opts - Normalized export options, including beforeExport mutations
|
|
28
|
+
* @param {string} key - Option name
|
|
29
|
+
* @param {any} fallback - Plugin or inherited default
|
|
30
|
+
* @returns {any}
|
|
31
|
+
*/
|
|
32
|
+
export function exportOption(ctx, opts, key, fallback) {
|
|
33
|
+
const requested = ctx.export?.requestedOptions || opts;
|
|
34
|
+
return Object.prototype.hasOwnProperty.call(requested, key) || opts[key] !== ctx[key]
|
|
35
|
+
? (opts[key] ?? fallback) : fallback;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build a live frame reader that preserves capture options and validates finite pacing.
|
|
40
|
+
* Recombine v3's separated exclusion predicates before recapturing. Pinned by
|
|
41
|
+
* __tests__/plugin.recording.contract.test.js and __tests__/plugin.recording.memo.test.js.
|
|
42
|
+
* @param {object} ctx - Export context containing the live source element
|
|
43
|
+
* @param {object} opts - Normalized per-export options
|
|
44
|
+
* @param {{fps:number,duration:number,frames?:number|null,scale?:number,background:string}} defaults
|
|
45
|
+
* @param {string} name - Plugin name for validation errors
|
|
46
|
+
* @returns {{fps:number,count:number,background:string,next:()=>Promise<HTMLCanvasElement>}}
|
|
47
|
+
*/
|
|
48
|
+
export function frameOptions(ctx, opts, defaults, name) {
|
|
49
|
+
const fps = exportOption(ctx, opts, 'fps', defaults.fps);
|
|
50
|
+
const duration = exportOption(ctx, opts, 'duration', defaults.duration);
|
|
51
|
+
const frames = exportOption(ctx, opts, 'frames', defaults.frames);
|
|
52
|
+
const scale = exportOption(ctx, opts, 'scale', defaults.scale ?? ctx.scale ?? 1);
|
|
53
|
+
if (!Number.isFinite(fps) || fps <= 0) throw new RangeError(`[snapdom] ${name}: fps must be positive and finite`);
|
|
54
|
+
if (!Number.isFinite(scale) || scale <= 0) throw new RangeError(`[snapdom] ${name}: scale must be positive and finite`);
|
|
55
|
+
if (frames != null && (!Number.isInteger(frames) || frames < 1)) throw new RangeError(`[snapdom] ${name}: frames must be a positive integer`);
|
|
56
|
+
if (frames == null && (!Number.isFinite(duration) || duration <= 0)) throw new RangeError(`[snapdom] ${name}: duration must be positive and finite`);
|
|
57
|
+
const count = frames ?? Math.max(1, Math.round(duration / 1000 * fps));
|
|
58
|
+
if (!Number.isFinite(count)) throw new RangeError(`[snapdom] ${name}: frame count must be finite`);
|
|
59
|
+
|
|
60
|
+
// Copy capture inputs, not released stage artifacts or export-session internals. In
|
|
61
|
+
// particular createContext splits exclusion predicates out of exclude: put them back.
|
|
62
|
+
const capture = {};
|
|
63
|
+
for (const key of ['debug', 'width', 'height', 'dpr', 'backgroundColor', 'quality', 'format',
|
|
64
|
+
'exclude', 'excludeMode', 'filter', 'filterMode', 'placeholders', 'captureSelection', 'canvas', 'embedFonts',
|
|
65
|
+
'iconFonts', 'localFonts', 'excludeFonts', 'fontStylesheetDomains', 'fallbackURL', 'cache',
|
|
66
|
+
'useProxy', 'outerTransforms', 'outerShadows', 'reconcile', 'burst', 'engine', 'invalidate',
|
|
67
|
+
'clip', 'compress', 'excludeStyleProps', 'resolvePicturePlaceholders', 'plugins']) {
|
|
68
|
+
if (opts[key] !== undefined) capture[key] = opts[key];
|
|
69
|
+
else if (ctx[key] !== undefined) capture[key] = ctx[key];
|
|
70
|
+
}
|
|
71
|
+
if (capture.exclude === ctx.exclude && ctx.excludePredicates?.length) {
|
|
72
|
+
capture.exclude = [...(ctx.exclude || []), ...ctx.excludePredicates];
|
|
73
|
+
}
|
|
74
|
+
capture.scale = scale;
|
|
75
|
+
return {
|
|
76
|
+
fps, count,
|
|
77
|
+
background: exportOption(ctx, opts, 'background', defaults.background),
|
|
78
|
+
async next() {
|
|
79
|
+
// Always recapture the live element, including the FIRST frame of a deferred export.
|
|
80
|
+
const result = await snapdom(ctx.element, capture);
|
|
81
|
+
const canvas = canvases.get(result.meta);
|
|
82
|
+
return canvas ? canvas({ crop: opts.crop }) : result.toCanvas({ crop: opts.crop });
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
package/color-tint.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for `@zumer/snapdom-plugins/color-tint`.
|
|
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 ColorTintOptions {
|
|
11
|
+
/** CSS color value. */
|
|
12
|
+
color?: string;
|
|
13
|
+
/** Overlay opacity, 0-1. */
|
|
14
|
+
opacity?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Tints an HTML capture using mix-blend-mode. Opacity zero leaves it unchanged; repeats may reuse a memo. */
|
|
18
|
+
export declare function colorTint(options?: ColorTintOptions): SnapdomPlugin;
|
package/color-tint.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* colorTint - Official SnapDOM Plugin
|
|
3
3
|
* Tints the entire capture to a specified color using an overlay
|
|
4
4
|
* with mix-blend-mode, following the same pattern as the overlay plugin.
|
|
5
|
+
* Pinned by __tests__/plugins.transforms.v3.test.js.
|
|
6
|
+
* @module plugins/color-tint
|
|
5
7
|
*
|
|
6
8
|
* @param {Object} [options]
|
|
7
9
|
* @param {string} [options.color='red'] - CSS color value
|
|
@@ -16,13 +18,15 @@ export function colorTint(options = {}) {
|
|
|
16
18
|
|
|
17
19
|
return {
|
|
18
20
|
name: 'color-tint',
|
|
21
|
+
pure: true,
|
|
19
22
|
|
|
20
|
-
|
|
23
|
+
afterClone(context) {
|
|
24
|
+
// Even an invisible blend layer changes WebKit's transformed-edge rasterization.
|
|
25
|
+
if (opacity === 0) return;
|
|
21
26
|
const root = context.clone;
|
|
22
|
-
if (!root ||
|
|
27
|
+
if (!root || root.nodeType !== 1 || root.namespaceURI !== 'http://www.w3.org/1999/xhtml') return;
|
|
23
28
|
root.style.position = 'relative';
|
|
24
|
-
root.
|
|
25
|
-
const overlay = document.createElement('div');
|
|
29
|
+
const overlay = root.ownerDocument.createElement('div');
|
|
26
30
|
overlay.style.position = 'absolute';
|
|
27
31
|
overlay.style.inset = '0';
|
|
28
32
|
overlay.style.backgroundColor = color;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for `@zumer/snapdom-plugins/context-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 ContextExportOptions {
|
|
11
|
+
format?: 'outline' | 'json';
|
|
12
|
+
/** Per-node text cap; longer text is ellipsised. */
|
|
13
|
+
maxTextLength?: number;
|
|
14
|
+
/** Hard cap on nodes; the output notes the truncation. */
|
|
15
|
+
maxNodes?: number;
|
|
16
|
+
/** Include bounding boxes. */
|
|
17
|
+
geometry?: boolean;
|
|
18
|
+
/** How far the capture runs. */
|
|
19
|
+
needs?: 'clone' | 'render';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Adds `result.toContext()`: the captured tree as a text outline or JSON instead of pixels. */
|
|
23
|
+
export declare function contextExport(options?: ContextExportOptions): SnapdomPlugin;
|
|
24
|
+
|
|
25
|
+
export default contextExport;
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* contextExport – Official SnapDOM Plugin
|
|
3
|
+
*
|
|
4
|
+
* Adds a toContext() export: a compact, LLM-friendly structured view of the captured
|
|
5
|
+
* UI — structure, roles, visible text and geometry — designed to be READ by a model
|
|
6
|
+
* without pixels. Complements agent-map (which is about ACTING: numbered badges on
|
|
7
|
+
* interactive elements): together a capture yields "what does this UI say" and
|
|
8
|
+
* "what can I click", from the same call.
|
|
9
|
+
*
|
|
10
|
+
* Token economy is the design constraint: the default output is an indented outline
|
|
11
|
+
* (cheaper to tokenize than JSON), wrapper elements that add no information are
|
|
12
|
+
* collapsed, invisible content is skipped, and text is whitespace-normalized.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* import { contextExport } from '@zumer/snapdom-plugins/context-export';
|
|
16
|
+
* const result = await snapdom(el, { plugins: [contextExport()] });
|
|
17
|
+
* const outline = await result.toContext(); // string outline (default)
|
|
18
|
+
* const tree = await result.toContext({ format: 'json' });
|
|
19
|
+
*
|
|
20
|
+
* Output line shape (outline format):
|
|
21
|
+
* tag[#id][.class] [x,y wxh] "visible text" {state}
|
|
22
|
+
*
|
|
23
|
+
* The snapshot is taken during the capture, not when toContext() is called: a deferred read
|
|
24
|
+
* would describe whatever the page looks like THEN, which is a different instant from the
|
|
25
|
+
* image. toContext() only formats what was already frozen. `format`, `maxTextLength` and
|
|
26
|
+
* `geometry` apply per call, but a per-call `geometry: true` can only show boxes the
|
|
27
|
+
* snapshot recorded; `maxNodes` and whether boxes are recorded at all are decided when the
|
|
28
|
+
* snapshot is taken.
|
|
29
|
+
*
|
|
30
|
+
* @param {Object} [options]
|
|
31
|
+
* @param {'outline'|'json'} [options.format='outline']
|
|
32
|
+
* @param {number} [options.maxTextLength=120] - Per-node text cap (ellipsised)
|
|
33
|
+
* @param {number} [options.maxNodes=800] - Hard cap; output notes truncation
|
|
34
|
+
* @param {boolean} [options.geometry=true] - Include bounding boxes
|
|
35
|
+
* @param {'clone'|'render'} [options.needs='render'] - How far the capture runs.
|
|
36
|
+
* 'clone' skips the render; result.url then throws.
|
|
37
|
+
* @returns {Object} SnapDOM plugin
|
|
38
|
+
*/
|
|
39
|
+
import { getPrivacyPolicy } from './privacy-policy.js'
|
|
40
|
+
|
|
41
|
+
const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'META', 'LINK', 'TITLE'])
|
|
42
|
+
|
|
43
|
+
/* Sensitive-input guard (self-contained: this package publishes standalone).
|
|
44
|
+
* Secrets are EXCLUDED, never truncated — a truncated password is still a leak. */
|
|
45
|
+
const SENSITIVE_AC = new Set(['current-password', 'new-password', 'one-time-code'])
|
|
46
|
+
function isSensitiveInput(el) {
|
|
47
|
+
if (!el || el.tagName !== 'INPUT') return false
|
|
48
|
+
const type = (el.getAttribute('type') || 'text').toLowerCase()
|
|
49
|
+
if (type === 'password' || type === 'email' || type === 'tel') return true
|
|
50
|
+
const ac = (el.getAttribute('autocomplete') || '').toLowerCase()
|
|
51
|
+
if (!ac) return false
|
|
52
|
+
for (const token of ac.split(/\s+/)) {
|
|
53
|
+
if (SENSITIVE_AC.has(token) || token.startsWith('cc-')) return true
|
|
54
|
+
}
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
function maskedValue(value) {
|
|
58
|
+
return '\u2022'.repeat(Math.min(String(value ?? '').length, 12))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Tags that carry structure/meaning on their own — never collapsed. */
|
|
62
|
+
const MEANINGFUL_TAGS = new Set([
|
|
63
|
+
'A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL', 'FORM', 'IMG', 'VIDEO', 'AUDIO',
|
|
64
|
+
'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'P', 'UL', 'OL', 'LI', 'TABLE', 'TR', 'TD', 'TH',
|
|
65
|
+
'NAV', 'MAIN', 'HEADER', 'FOOTER', 'ARTICLE', 'SECTION', 'ASIDE', 'FIGURE', 'FIGCAPTION',
|
|
66
|
+
'BLOCKQUOTE', 'PRE', 'CODE', 'DETAILS', 'SUMMARY', 'DIALOG', 'CANVAS', 'SVG', 'IFRAME',
|
|
67
|
+
])
|
|
68
|
+
|
|
69
|
+
export function contextExport(options = {}) {
|
|
70
|
+
const {
|
|
71
|
+
format = 'outline',
|
|
72
|
+
maxTextLength = 120,
|
|
73
|
+
maxNodes = 800,
|
|
74
|
+
geometry = true,
|
|
75
|
+
} = options
|
|
76
|
+
// `needs` names how far the capture has to run. This export reads the live page in
|
|
77
|
+
// beforeClone, so it does not need pixels: `contextExport({ needs: 'clone' })` skips the
|
|
78
|
+
// render. It cannot go shallower than that any more, and it should not: a capture that
|
|
79
|
+
// takes no clone is not a capture, it is this plugin calling its own function.
|
|
80
|
+
const needs = options.needs ?? 'render'
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
name: 'context-export',
|
|
84
|
+
needs,
|
|
85
|
+
|
|
86
|
+
// The freeze. Everything below reads the live DOM, so it has to happen while the
|
|
87
|
+
// capture's instant is still the page's instant.
|
|
88
|
+
beforeClone(ctx) {
|
|
89
|
+
const el = ctx.element
|
|
90
|
+
const policy = getPrivacyPolicy(ctx)
|
|
91
|
+
const shouldExclude = typeof ctx.shouldExclude === 'function' ? ctx.shouldExclude : NEVER
|
|
92
|
+
const state = {
|
|
93
|
+
count: 0,
|
|
94
|
+
truncated: false,
|
|
95
|
+
// The capture's ONE exclusion policy (src/core/context.js). Absent only when a
|
|
96
|
+
// caller drives the hook with a hand-built context.
|
|
97
|
+
exclude: policy ? node => shouldExclude(node) || policy.isBlocked(node) : shouldExclude,
|
|
98
|
+
attribute: policy ? policy.attribute : sourceAttribute,
|
|
99
|
+
policy,
|
|
100
|
+
}
|
|
101
|
+
const root = buildNode(el, el.getBoundingClientRect(), maxNodes, geometry, state)
|
|
102
|
+
ctx.__contextSnapshot = { root, truncated: state.truncated, nodes: state.count }
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
defineExports() {
|
|
106
|
+
return {
|
|
107
|
+
context: async (ctx, opts = {}) => {
|
|
108
|
+
const snap = ctx.__contextSnapshot
|
|
109
|
+
if (!snap) throw new Error('[snapdom] context-export: this capture carries no snapshot (it never ran beforeClone). It is not re-read on demand — that would be a different instant.')
|
|
110
|
+
// Core carries its image format in every normalized export bag. Only this
|
|
111
|
+
// plugin's two formats override its factory default.
|
|
112
|
+
const _format = opts.format === 'json' || opts.format === 'outline' ? opts.format : format
|
|
113
|
+
const _maxText = opts.maxTextLength ?? maxTextLength
|
|
114
|
+
const _geometry = opts.geometry ?? geometry
|
|
115
|
+
|
|
116
|
+
const root = snap.root && project(snap.root, _maxText, _geometry)
|
|
117
|
+
if (_format === 'json') {
|
|
118
|
+
return { root, truncated: snap.truncated, nodes: snap.nodes }
|
|
119
|
+
}
|
|
120
|
+
const lines = []
|
|
121
|
+
renderOutline(root, 0, lines)
|
|
122
|
+
if (snap.truncated) lines.push(`… (truncated at ${maxNodes} nodes)`)
|
|
123
|
+
return lines.join('\n')
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const NEVER = () => false
|
|
131
|
+
const sourceAttribute = (el, name) => el.getAttribute(name)
|
|
132
|
+
|
|
133
|
+
function visibleText(node) {
|
|
134
|
+
// A textarea's child text is its default value, not a label. nodeState already emits
|
|
135
|
+
// only a mask/hasValue; reading the children would leak that value through `text`.
|
|
136
|
+
if (node.tagName === 'TEXTAREA') return ''
|
|
137
|
+
let out = ''
|
|
138
|
+
for (let c = node.firstChild; c; c = c.nextSibling) {
|
|
139
|
+
if (c.nodeType === 3) out += c.nodeValue
|
|
140
|
+
}
|
|
141
|
+
return out.replace(/\s+/g, ' ').trim()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** What actually paints under `el`, mirroring deepClone: a <slot> renders its assigned
|
|
145
|
+
* elements, and an open shadow root renders in place of the host's light children —
|
|
146
|
+
* except unassigned ones, which core clones after the shadow fragment. Walking only
|
|
147
|
+
* `children` made every shadow-DOM component invisible to the export. */
|
|
148
|
+
function renderedChildren(el) {
|
|
149
|
+
if (el.localName === 'slot') {
|
|
150
|
+
const assigned = el.assignedElements?.({ flatten: true }) || []
|
|
151
|
+
return assigned.length ? assigned : el.children
|
|
152
|
+
}
|
|
153
|
+
const sr = el.shadowRoot
|
|
154
|
+
if (!sr) return el.children
|
|
155
|
+
const slotted = new Set()
|
|
156
|
+
for (const s of sr.querySelectorAll('slot')) for (const n of s.assignedElements()) slotted.add(n)
|
|
157
|
+
return [...sr.children, ...Array.from(el.children).filter((c) => !slotted.has(c))]
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isHidden(el) {
|
|
161
|
+
try {
|
|
162
|
+
const cs = getComputedStyle(el)
|
|
163
|
+
return cs.display === 'none' || cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0
|
|
164
|
+
} catch { return false }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function permittedOptionText(node, exclude) {
|
|
168
|
+
if (node.nodeType === 3) return node.nodeValue || ''
|
|
169
|
+
if (node.nodeType !== 1 || node.tagName === 'TEXTAREA' || exclude(node)) return ''
|
|
170
|
+
return Array.from(node.childNodes, child => permittedOptionText(child, exclude)).join('')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function nodeState(el, { attribute, policy, exclude }) {
|
|
174
|
+
const s = {}
|
|
175
|
+
const redacts = policy ? name => policy.redactsAttribute(el, name) : NEVER
|
|
176
|
+
if (el.tagName === 'A' && attribute(el, 'href')) s.href = attribute(el, 'href')
|
|
177
|
+
if (el.disabled && !redacts('disabled')) s.disabled = true
|
|
178
|
+
if (!redacts('checked') && el.checked !== undefined && (el.type === 'checkbox' || el.type === 'radio')) s.checked = !!el.checked
|
|
179
|
+
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
|
180
|
+
// Sensitive inputs: value NEVER emitted (exclusion, not truncation — a truncated
|
|
181
|
+
// password is still a leak). Other inputs: masked; the agent sees "has content".
|
|
182
|
+
if (el.value) {
|
|
183
|
+
if (!isSensitiveInput(el) && !policy?.isField(el)) s.value = maskedValue(el.value)
|
|
184
|
+
s.hasValue = true
|
|
185
|
+
}
|
|
186
|
+
if (!redacts('placeholder') && el.placeholder) s.placeholder = el.placeholder
|
|
187
|
+
}
|
|
188
|
+
if (el.tagName === 'SELECT' && el.selectedOptions?.[0] && !redacts('value')) {
|
|
189
|
+
const option = el.selectedOptions[0]
|
|
190
|
+
let blocked = false
|
|
191
|
+
for (let ancestor = option; ancestor; ancestor = ancestor.parentElement || ancestor.getRootNode()?.host) {
|
|
192
|
+
if (exclude(ancestor)) { blocked = true; break }
|
|
193
|
+
}
|
|
194
|
+
if (!blocked) {
|
|
195
|
+
// DOM-created/customizable options can contain excluded descendants. Their text
|
|
196
|
+
// must not return through selected state after the regular tree walk pruned it.
|
|
197
|
+
const text = policy ? permittedOptionText(option, exclude) : option.textContent
|
|
198
|
+
s.value = text.trim().slice(0, 40)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (el.tagName === 'IMG' && !redacts('alt')) s.alt = attribute(el, 'alt') || ''
|
|
202
|
+
const aria = attribute(el, 'aria-label')
|
|
203
|
+
if (aria) s.label = aria
|
|
204
|
+
const role = attribute(el, 'role')
|
|
205
|
+
if (role) s.role = role
|
|
206
|
+
if (el.tagName === 'DETAILS' && !redacts('open')) s.open = attribute(el, 'open') !== null
|
|
207
|
+
return Object.keys(s).length ? s : null
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function buildNode(el, rootRect, maxNodes, geometry, state) {
|
|
211
|
+
if (state.count >= maxNodes) { state.truncated = true; return null }
|
|
212
|
+
// Excluded subtrees are not walked at all: `exclude` is the redaction feature, and a
|
|
213
|
+
// semantic export that ignored it would hand the redacted text straight to a model.
|
|
214
|
+
if (state.exclude(el)) return null
|
|
215
|
+
state.count++
|
|
216
|
+
const node = { tag: el.localName }
|
|
217
|
+
const id = state.policy?.redactsAttribute(el, 'id') ? '' : el.id
|
|
218
|
+
if (id) node.id = id
|
|
219
|
+
const cls = (state.attribute(el, 'class') || '').trim().split(/\s+/).filter(Boolean).slice(0, 2)
|
|
220
|
+
if (cls.length) node.class = cls.join('.')
|
|
221
|
+
if (geometry) {
|
|
222
|
+
try {
|
|
223
|
+
const r = el.getBoundingClientRect()
|
|
224
|
+
node.box = [
|
|
225
|
+
Math.round(r.left - rootRect.left), Math.round(r.top - rootRect.top),
|
|
226
|
+
Math.round(r.width), Math.round(r.height),
|
|
227
|
+
]
|
|
228
|
+
} catch { }
|
|
229
|
+
}
|
|
230
|
+
const text = visibleText(el)
|
|
231
|
+
if (text) node.text = text
|
|
232
|
+
const st = nodeState(el, state)
|
|
233
|
+
if (st) node.state = st
|
|
234
|
+
|
|
235
|
+
const children = []
|
|
236
|
+
for (const c of renderedChildren(el)) {
|
|
237
|
+
if (SKIP_TAGS.has(c.tagName) || isHidden(c)) continue
|
|
238
|
+
const child = buildNode(c, rootRect, maxNodes, geometry, state)
|
|
239
|
+
if (child) children.push(child)
|
|
240
|
+
}
|
|
241
|
+
if (children.length) node.children = children
|
|
242
|
+
|
|
243
|
+
// Collapse information-free wrappers: a lone child under a meaning-less container
|
|
244
|
+
// hoists up (its own geometry survives; the wrapper's adds nothing an LLM needs).
|
|
245
|
+
if (
|
|
246
|
+
!MEANINGFUL_TAGS.has(el.tagName) && !node.id && !node.state && !node.text &&
|
|
247
|
+
children.length === 1
|
|
248
|
+
) {
|
|
249
|
+
state.count--
|
|
250
|
+
return children[0]
|
|
251
|
+
}
|
|
252
|
+
return node
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Export-time formatting of the frozen snapshot: text caps and geometry are presentation,
|
|
256
|
+
* so they stay per-call, and neither touches the DOM. */
|
|
257
|
+
function project(node, maxText, geometry) {
|
|
258
|
+
const out = { tag: node.tag }
|
|
259
|
+
if (node.id) out.id = node.id
|
|
260
|
+
if (node.class) out.class = node.class
|
|
261
|
+
if (geometry && node.box) out.box = [...node.box]
|
|
262
|
+
if (node.text) out.text = node.text.length > maxText ? node.text.slice(0, maxText - 1) + '…' : node.text
|
|
263
|
+
if (node.state) out.state = { ...node.state }
|
|
264
|
+
if (node.children) out.children = node.children.map((c) => project(c, maxText, geometry))
|
|
265
|
+
return out
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function renderOutline(node, depth, lines) {
|
|
269
|
+
if (!node) return
|
|
270
|
+
let line = ' '.repeat(depth) + node.tag
|
|
271
|
+
if (node.id) line += `#${node.id}`
|
|
272
|
+
if (node.class) line += `.${node.class}`
|
|
273
|
+
if (node.box) line += ` [${node.box[0]},${node.box[1]} ${node.box[2]}x${node.box[3]}]`
|
|
274
|
+
if (node.text) line += ` "${node.text}"`
|
|
275
|
+
if (node.state) {
|
|
276
|
+
const bits = Object.entries(node.state).map(([k, v]) => (v === true ? k : `${k}=${v}`))
|
|
277
|
+
line += ` {${bits.join(' ')}}`
|
|
278
|
+
}
|
|
279
|
+
lines.push(line)
|
|
280
|
+
if (node.children) for (const c of node.children) renderOutline(c, depth + 1, lines)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export default contextExport
|
package/filter.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for `@zumer/snapdom-plugins/filter`.
|
|
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 FilterOptions {
|
|
11
|
+
/** CSS filter string, e.g. 'grayscale(1) blur(2px)'. Applies when `preset` is unset or unknown. */
|
|
12
|
+
filter?: string;
|
|
13
|
+
preset?: 'grayscale' | 'sepia' | 'blur' | 'invert' | 'vintage' | 'dramatic';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Applies CSS filter effects, overriding authored filters. Unchanged captures may reuse a memo. */
|
|
17
|
+
export declare function filter(options?: FilterOptions): SnapdomPlugin;
|
package/filter.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* filter - Official SnapDOM Plugin
|
|
3
3
|
* Applies CSS filter effects to the captured clone.
|
|
4
|
+
* Pinned by __tests__/plugins.transforms.v3.test.js.
|
|
5
|
+
* @module plugins/filter
|
|
4
6
|
*
|
|
5
7
|
* @param {Object} [options]
|
|
6
8
|
* @param {string} [options.filter=''] - CSS filter string, e.g. 'grayscale(1) blur(2px)'
|
|
7
|
-
* @param {string} [options.preset] - 'grayscale' | 'sepia' | 'blur' | 'invert' | 'vintage' | 'dramatic'
|
|
9
|
+
* @param {string} [options.preset] - 'grayscale' | 'sepia' | 'blur' | 'invert' | 'vintage' | 'dramatic'.
|
|
10
|
+
* An unknown preset logs a warning and `filter` applies.
|
|
8
11
|
* @returns {Object} SnapDOM plugin
|
|
9
12
|
*/
|
|
10
13
|
export function filter(options = {}) {
|
|
@@ -22,14 +25,18 @@ export function filter(options = {}) {
|
|
|
22
25
|
preset,
|
|
23
26
|
} = options;
|
|
24
27
|
|
|
25
|
-
|
|
28
|
+
// An unknown preset must not turn the plugin off when a filter string was also given.
|
|
29
|
+
if (preset && !presets[preset]) console.warn(`[snapdom] filter: unknown preset ${JSON.stringify(preset)}`);
|
|
30
|
+
const cssFilter = presets[preset] || filterValue;
|
|
26
31
|
|
|
27
32
|
return {
|
|
28
33
|
name: 'filter',
|
|
34
|
+
pure: true,
|
|
29
35
|
|
|
30
36
|
afterClone(ctx) {
|
|
31
37
|
if (!cssFilter) return;
|
|
32
|
-
|
|
38
|
+
// Retained author !important rules must not cancel the explicitly requested effect.
|
|
39
|
+
ctx.clone.style.setProperty('filter', cssFilter, 'important');
|
|
33
40
|
}
|
|
34
41
|
};
|
|
35
42
|
}
|
package/gif-export.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for `@zumer/snapdom-plugins/gif-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 GifExportOptions {
|
|
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
|
+
/** Palette size per frame, 2-256. */
|
|
17
|
+
maxColors?: number;
|
|
18
|
+
/** Color composited under transparent pixels. */
|
|
19
|
+
background?: string;
|
|
20
|
+
/** Capture scale; inherits the original capture when omitted. */
|
|
21
|
+
scale?: number;
|
|
22
|
+
/** Loop count: 0 = forever, -1 = play once. */
|
|
23
|
+
repeat?: number;
|
|
24
|
+
filename?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Adds `result.toGif()`: records a sequence of captures into an animated GIF. */
|
|
28
|
+
export declare function gifExport(options?: GifExportOptions): SnapdomPlugin;
|
|
29
|
+
export default gifExport;
|
package/gif-export.js
CHANGED
|
@@ -12,12 +12,12 @@
|
|
|
12
12
|
* @param {number} [options.frames] - Explicit frame count (overrides duration)
|
|
13
13
|
* @param {number} [options.maxColors=256] - Palette size per frame (2-256)
|
|
14
14
|
* @param {string} [options.background='#ffffff'] - Color composited under transparent pixels
|
|
15
|
-
* @param {number} [options.scale
|
|
15
|
+
* @param {number} [options.scale] - Capture scale (inherits the capture when omitted)
|
|
16
16
|
* @param {number} [options.repeat=0] - Loop count (0 = forever, -1 = play once)
|
|
17
17
|
* @param {string} [options.filename='capture.gif'] - Download filename
|
|
18
18
|
* @returns {Object} SnapDOM plugin
|
|
19
19
|
*/
|
|
20
|
-
import {
|
|
20
|
+
import { rememberCanvas, exportOption, frameOptions } from './capture-frames.js';
|
|
21
21
|
|
|
22
22
|
export function gifExport(options = {}) {
|
|
23
23
|
const {
|
|
@@ -26,7 +26,7 @@ export function gifExport(options = {}) {
|
|
|
26
26
|
frames: frameOpt = null,
|
|
27
27
|
maxColors = 256,
|
|
28
28
|
background = '#ffffff',
|
|
29
|
-
scale
|
|
29
|
+
scale,
|
|
30
30
|
repeat = 0,
|
|
31
31
|
filename = 'capture.gif',
|
|
32
32
|
} = options;
|
|
@@ -34,32 +34,26 @@ export function gifExport(options = {}) {
|
|
|
34
34
|
return {
|
|
35
35
|
name: 'gif-export',
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
beforeSnap(ctx) {
|
|
40
|
-
if (ctx && ctx.options) ctx.options.__snapSource = ctx.element;
|
|
41
|
-
},
|
|
42
|
-
|
|
43
|
-
defineExports() {
|
|
37
|
+
defineExports(context) {
|
|
38
|
+
rememberCanvas(context);
|
|
44
39
|
return {
|
|
45
40
|
gif: async (ctx, opts = {}) => {
|
|
46
|
-
const el = ctx.
|
|
41
|
+
const el = ctx.element;
|
|
47
42
|
if (!el) throw new Error('[snapdom] gif-export: no source element on context');
|
|
48
43
|
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
|
|
44
|
+
const recording = frameOptions(ctx, opts, { fps, duration, frames: frameOpt, scale, background }, 'gif-export');
|
|
45
|
+
const { fps: _fps, count: _count, background: _bg } = recording;
|
|
46
|
+
const colors = exportOption(ctx, opts, 'maxColors', maxColors);
|
|
47
|
+
if (!Number.isFinite(colors)) throw new RangeError('[snapdom] gif-export: maxColors must be finite');
|
|
48
|
+
const _max = Math.min(256, Math.max(2, Math.floor(colors)));
|
|
49
|
+
const _repeat = exportOption(ctx, opts, 'repeat', repeat);
|
|
50
|
+
if (!Number.isInteger(_repeat) || _repeat < -1 || _repeat > 65535) throw new RangeError('[snapdom] gif-export: repeat must be an integer from -1 to 65535');
|
|
56
51
|
const delayCs = Math.max(2, Math.round(100 / _fps)); // GIF delay unit is 1/100 s
|
|
57
52
|
|
|
58
53
|
let W = 0, H = 0;
|
|
59
54
|
const frames = [];
|
|
60
55
|
for (let i = 0; i < _count; i++) {
|
|
61
|
-
const
|
|
62
|
-
const src = await cap.toCanvas();
|
|
56
|
+
const src = await recording.next();
|
|
63
57
|
if (i === 0) { W = src.width; H = src.height; }
|
|
64
58
|
const fc = document.createElement('canvas');
|
|
65
59
|
fc.width = W; fc.height = H;
|
|
@@ -79,7 +73,7 @@ export function gifExport(options = {}) {
|
|
|
79
73
|
const objUrl = URL.createObjectURL(blob);
|
|
80
74
|
const a = document.createElement('a');
|
|
81
75
|
a.href = objUrl;
|
|
82
|
-
a.download = typeof dl === 'string' ? dl : filename;
|
|
76
|
+
a.download = typeof dl === 'string' ? dl : exportOption(ctx, opts, 'filename', filename);
|
|
83
77
|
a.click();
|
|
84
78
|
setTimeout(() => URL.revokeObjectURL(objUrl), 5000);
|
|
85
79
|
}
|
package/html-export.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for `@zumer/snapdom-plugins/html-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 HtmlExportOptions {
|
|
11
|
+
/** Wrap in a full `<!DOCTYPE html>` document; false returns `<style>` + fragment. */
|
|
12
|
+
fullDocument?: boolean;
|
|
13
|
+
/** Download filename when `toHtml({ download: true })` is used. */
|
|
14
|
+
filename?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ToHtmlOptions extends HtmlExportOptions {
|
|
18
|
+
/** true, or a filename string, to also trigger a download. */
|
|
19
|
+
download?: boolean | string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Adds `result.toHtml()`: the capture as a self-contained, re-renderable HTML document —
|
|
24
|
+
* markup plus inlined styles and fonts, not pixels.
|
|
25
|
+
*
|
|
26
|
+
* The returned document carries the captured markup as-is, INCLUDING any event-handler
|
|
27
|
+
* attributes the original nodes had. Treat it as untrusted content when you serve it.
|
|
28
|
+
*/
|
|
29
|
+
export declare function htmlExport(options?: HtmlExportOptions): SnapdomPlugin;
|
|
30
|
+
|
|
31
|
+
export default htmlExport;
|