@zumer/snapdom-plugins 2.2.2 → 3.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.
@@ -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
+ }
@@ -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,14 +18,15 @@ export function colorTint(options = {}) {
16
18
 
17
19
  return {
18
20
  name: 'color-tint',
21
+ pure: true,
19
22
 
20
- async afterClone(context) {
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
- // Realm-agnostic: a clone rooted in a same-origin iframe is not an instance of this
23
- // window's HTMLElement (#494).
24
27
  if (!root || root.nodeType !== 1 || root.namespaceURI !== 'http://www.w3.org/1999/xhtml') return;
25
28
  root.style.position = 'relative';
26
- const overlay = document.createElement('div');
29
+ const overlay = root.ownerDocument.createElement('div');
27
30
  overlay.style.position = 'absolute';
28
31
  overlay.style.inset = '0';
29
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
- const cssFilter = preset ? (presets[preset] || '') : filterValue;
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
- ctx.clone.style.filter = cssFilter;
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
  }
@@ -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=1] - Capture 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 { snapdom } from '@zumer/snapdom';
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 = 1,
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
- // The export ctx comes from createContext (no `element`). Stash the live
38
- // element during a capture hook so toGif() can re-capture frames from it.
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.__snapSource || ctx.element;
41
+ const el = ctx.element;
47
42
  if (!el) throw new Error('[snapdom] gif-export: no source element on context');
48
43
 
49
- const _fps = opts.fps ?? fps;
50
- const _dur = opts.duration ?? duration;
51
- const _count = Math.max(1, opts.frames ?? frameOpt ?? Math.round((_dur / 1000) * _fps));
52
- const _max = Math.min(256, Math.max(2, opts.maxColors ?? maxColors));
53
- const _bg = opts.background ?? background;
54
- const _scale = opts.scale ?? scale ?? ctx.scale ?? 1;
55
- const _repeat = opts.repeat ?? repeat;
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 cap = await snapdom(el, { scale: _scale, backgroundColor: _bg, fast: true });
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
  }
@@ -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;