@zumer/snapdom-plugins 3.0.0 → 4.0.1
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/CHANGELOG.md +21 -0
- package/README.md +50 -16
- package/agent-map.d.ts +32 -0
- package/agent-map.js +5 -3
- package/ascii-export.d.ts +10 -0
- package/ascii-export.js +21 -6
- package/capture-frames.js +5 -5
- package/color-tint.js +4 -0
- package/context-export.d.ts +23 -0
- package/context-export.js +4 -1
- package/filter.js +1 -1
- package/gif-export.d.ts +6 -0
- package/gif-export.js +3 -3
- package/html-export.d.ts +8 -0
- package/html-export.js +6 -4
- package/index.d.ts +2 -1
- package/index.js +2 -1
- package/package.json +22 -6
- package/pdf/LICENSE +21 -0
- package/pdf/README.md +22 -0
- package/pdf/REFERENCE.md +520 -0
- package/pdf/src/frontmatter.js +200 -0
- package/pdf/src/furniture.js +398 -0
- package/pdf/src/index.js +2415 -0
- package/pdf/src/mega/README.md +47 -0
- package/pdf/src/mega/diag.js +148 -0
- package/pdf/src/mega/flow.js +56 -0
- package/pdf/src/mega/fonts.js +287 -0
- package/pdf/src/mega/forms.js +479 -0
- package/pdf/src/mega/labels.js +43 -0
- package/pdf/src/mega/meta.js +145 -0
- package/pdf/src/nav.js +357 -0
- package/pdf/src/paginate.js +583 -0
- package/pdf/src/tagged.js +355 -0
- package/pdf/src/text-layer.js +1771 -0
- package/pdf/src/writer/encrypt.js +218 -0
- package/pdf/src/writer/font.js +507 -0
- package/pdf/src/writer/index.js +1135 -0
- package/pdf/src/writer/subset.js +387 -0
- package/pdf.d.ts +294 -0
- package/pdf.js +1 -0
- package/redact-inputs.js +6 -6
- package/vector/CONTRACT.md +906 -0
- package/vector/LICENSE +21 -0
- package/vector/README.md +35 -0
- package/vector/src/adapters/snapdom.js +795 -0
- package/vector/src/collect/box.js +1676 -0
- package/vector/src/collect/font.js +487 -0
- package/vector/src/collect/image.js +1458 -0
- package/vector/src/collect/mathml.js +272 -0
- package/vector/src/collect/text.js +3027 -0
- package/vector/src/css/color.js +823 -0
- package/vector/src/css/gradient.js +1126 -0
- package/vector/src/css/matrix.js +398 -0
- package/vector/src/css/radius.js +124 -0
- package/vector/src/css/shadow.js +201 -0
- package/vector/src/emit/figma-json.js +1995 -0
- package/vector/src/emit/h2d-vector.js +215 -0
- package/vector/src/emit/h2d.js +1147 -0
- package/vector/src/emit/pdf.js +2571 -0
- package/vector/src/emit/svg-flat.js +4842 -0
- package/vector/src/exclusion.js +13 -0
- package/vector/src/index.js +2910 -0
- package/vector/src/paint.js +2908 -0
- package/vector/src/public.js +57 -0
- package/vector/src/writer/encrypt.js +227 -0
- package/vector/src/writer/font.js +507 -0
- package/vector/src/writer/index.js +1148 -0
- package/vector/src/writer/subset.js +387 -0
- package/vector/svd/index.js +72 -0
- package/vector/svd/schema.js +985 -0
- package/vector.d.ts +28 -0
- package/vector.js +1 -0
- package/video-export.d.ts +6 -0
- package/video-export.js +7 -7
- package/pdf-image.d.ts +0 -18
- package/pdf-image.js +0 -114
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* snapdom Pro — the snapdom adapter.
|
|
3
|
+
*
|
|
4
|
+
* **This is the only file in the engine allowed to know snapdom exists.** Every
|
|
5
|
+
* other module takes elements and computed styles and does not care which tree
|
|
6
|
+
* they came from; this one produces that tree.
|
|
7
|
+
*
|
|
8
|
+
* The engine walks snapdom's CLONE, not the live DOM (see `packages/CONTRACT.md`).
|
|
9
|
+
* The clone is where snapdom has already solved membership: pseudo-elements are
|
|
10
|
+
* materialised into real `<span>`s, shadow DOM is flattened with its CSS
|
|
11
|
+
* rewritten, icon fonts are resolved to `<img>`, backgrounds and images are
|
|
12
|
+
* inlined past CORS, scroll is applied as a translate. Only geometry and paint
|
|
13
|
+
* are left, and those are what the collectors do.
|
|
14
|
+
*
|
|
15
|
+
* **The clone arrives through the plugin system, like everything else that
|
|
16
|
+
* extends snapdom.** `vector()` in `../index.js` is a plugin of exactly the shape
|
|
17
|
+
* the twelve official ones have (`snapdom/packages/plugins/`): a factory returning
|
|
18
|
+
* `{name, hook…, defineExports}`. `handoffFrom` below is the one function that
|
|
19
|
+
* knows what a hook state looks like, and `mountClone` takes it from there.
|
|
20
|
+
*
|
|
21
|
+
* The hook is `beforeRender`, after v3 has added `fontsCSS`, `baseCSS` and
|
|
22
|
+
* `scrollbarCSS` to the same capture context that already carries `clone`,
|
|
23
|
+
* `classCSS`, `styleCache` and `nodeMap`. `styleMap` is on `options.__session`.
|
|
24
|
+
*
|
|
25
|
+
* **`afterClone` is too early**, which is why the later hook: the
|
|
26
|
+
* images, background images and fonts have not been inlined yet (that is the
|
|
27
|
+
* `assetsPhase`/`fontsPhase` pair further down) and there is no `fontsCSS` at
|
|
28
|
+
* all. A clone whose assets are already resolved past CORS is half of what this
|
|
29
|
+
* engine walks the clone FOR.
|
|
30
|
+
*
|
|
31
|
+
* The hand-off rides on the capture context. `state.options` aliases `state`
|
|
32
|
+
* in v3; `buildResult` spreads that context into each export view, so the
|
|
33
|
+
* hand-off survives without storing capture state on the plugin instance.
|
|
34
|
+
*
|
|
35
|
+
* One value the hand-off cannot get that way is `classPrefixCSS`: `captureDOM`
|
|
36
|
+
* keeps it local and only passes it to internal `__retain`, not public hooks.
|
|
37
|
+
* It is recovered here from `classCSS`, which begins with it — see
|
|
38
|
+
* `classPrefixFrom`.
|
|
39
|
+
*
|
|
40
|
+
* Mounting is not a detail either:
|
|
41
|
+
*
|
|
42
|
+
* - **light DOM, never a shadow root.** Chromium ignores `@font-face` inside a
|
|
43
|
+
* shadow root, and a clone laid out with substituted fonts measures nothing
|
|
44
|
+
* useful — every `textLength` in the export would be a lie.
|
|
45
|
+
* - **the page's own stylesheets still apply.** The clone keeps its original
|
|
46
|
+
* class names and ids, so mounting it in the same document reproduces the
|
|
47
|
+
* live cascade rather than re-deriving it. `classPrefixCSS` supplies what the
|
|
48
|
+
* cascade cannot: the shadow-scoped rules, the scoped `::marker`/
|
|
49
|
+
* `::first-line` rules, and the `PSEUDO_SUPPRESS` rule that stops a
|
|
50
|
+
* materialised `::before` from being painted twice.
|
|
51
|
+
* - **offscreen, not hidden.** `display:none` has no layout and `visibility:
|
|
52
|
+
* hidden` still reflows the page; `position:fixed; left:-99999px` with
|
|
53
|
+
* `contain:layout style` has a full layout, is off every screen, and cannot
|
|
54
|
+
* disturb the document it is borrowed into.
|
|
55
|
+
*
|
|
56
|
+
* Borrowing the page's cascade is what makes the mount cheap and faithful, and
|
|
57
|
+
* it is also where all four ways it goes wrong come from. Each has its own
|
|
58
|
+
* repair below, each was found by `drift` rather than by reasoning, and each
|
|
59
|
+
* carries the number it was worth:
|
|
60
|
+
*
|
|
61
|
+
* 1. the host has no width, so a `width:auto` root shrink-wraps instead of
|
|
62
|
+
* filling the space it filled on the page — `sizeHost`, 628px;
|
|
63
|
+
* 2. nothing in the page styles a node snapdom INVENTED, so a materialised
|
|
64
|
+
* `::before` sized only by CSS arrives 0×0 — `synthRulesFrom`, 18px;
|
|
65
|
+
* 3. a scroll wrapper interposes itself and breaks every `>` chain that crosses
|
|
66
|
+
* it, for the whole subtree — `synthRulesFrom` again, 268px;
|
|
67
|
+
* 4. connecting the clone UPGRADES its custom elements and the browser gives
|
|
68
|
+
* them back the shadow roots snapdom flattened away —
|
|
69
|
+
* `defuseUpgradedShadowRoots`, 37.72px.
|
|
70
|
+
*
|
|
71
|
+
* With all four in, nine fixtures across two demo pages and a scrolled-container
|
|
72
|
+
* fixture measure 0.00px of drift — median, p95 and max. `drift` re-measures it
|
|
73
|
+
* on every capture, because the whole architecture rests on the number and a
|
|
74
|
+
* number nobody checks is a promise.
|
|
75
|
+
*
|
|
76
|
+
* @module adapters/snapdom
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Stamped on the two nodes this module adds to the page, so anything it ever
|
|
81
|
+
* leaks is one `querySelectorAll` away instead of anonymous debris.
|
|
82
|
+
*/
|
|
83
|
+
const MARK = 'data-snapdom-vector'
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* See the module comment: offscreen with a real layout, contained.
|
|
87
|
+
*
|
|
88
|
+
* `position:fixed` with no width shrink-wraps, and a block-level clone root then
|
|
89
|
+
* sizes to its own content instead of to the width it had on the page. Measured:
|
|
90
|
+
* a `<div>` that was 968px wide live came out 340px wide in the clone, and every
|
|
91
|
+
* line in it wrapped somewhere else. `sizeHost` puts the width back — this
|
|
92
|
+
* constant is only what does not depend on the element.
|
|
93
|
+
*/
|
|
94
|
+
const HOST_CSS = 'position:fixed;left:-99999px;top:0;contain:layout style;display:block;'
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Give the host the box the clone root had on the page, so a root with
|
|
98
|
+
* `width:auto` resolves against the same available space.
|
|
99
|
+
*
|
|
100
|
+
* The size is read from the computed style rather than `getBoundingClientRect`
|
|
101
|
+
* on purpose: `outerTransforms:true` keeps the root's own transform on the
|
|
102
|
+
* clone, and a bounding rect of a rotated element is its bounding box, not its
|
|
103
|
+
* layout box. `cs.width`/`cs.height` are the used content box, in subpixels,
|
|
104
|
+
* with no transform applied.
|
|
105
|
+
*
|
|
106
|
+
* Margins are added back because the host is the clone root's containing block
|
|
107
|
+
* now: a block child with `margin:0 24px` would otherwise lay out 48px narrower
|
|
108
|
+
* than it did on the page.
|
|
109
|
+
*
|
|
110
|
+
* @param {HTMLElement} host
|
|
111
|
+
* @param {Element} element live capture root
|
|
112
|
+
*/
|
|
113
|
+
function sizeHost (host, element) {
|
|
114
|
+
const cs = typeof getComputedStyle === 'function' ? getComputedStyle(element) : null
|
|
115
|
+
if (!cs) return
|
|
116
|
+
const n = (v) => {
|
|
117
|
+
const value = parseFloat(v)
|
|
118
|
+
return Number.isFinite(value) ? value : 0
|
|
119
|
+
}
|
|
120
|
+
const w = n(cs.width) + n(cs.paddingLeft) + n(cs.paddingRight) +
|
|
121
|
+
n(cs.borderLeftWidth) + n(cs.borderRightWidth) + n(cs.marginLeft) + n(cs.marginRight)
|
|
122
|
+
const h = n(cs.height) + n(cs.paddingTop) + n(cs.paddingBottom) +
|
|
123
|
+
n(cs.borderTopWidth) + n(cs.borderBottomWidth) + n(cs.marginTop) + n(cs.marginBottom)
|
|
124
|
+
// A zero here would be worse than leaving the host to shrink-wrap: it would
|
|
125
|
+
// collapse the clone instead of merely letting it choose its own width.
|
|
126
|
+
if (w > 0) host.style.width = `${w}px`
|
|
127
|
+
if (h > 0) host.style.height = `${h}px`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Stamped on the clone nodes that get a snapshot rule of their own. */
|
|
131
|
+
const SYNTH = 'data-snapdom-vector-synth'
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* snapdom's own suppressor for the pseudo-elements it materialised, verbatim
|
|
135
|
+
* from `snapdom/src/core/prepare.js:130`. That file is the source of truth; this
|
|
136
|
+
* is a copy because the string is also the ANCHOR the split below needs, and a
|
|
137
|
+
* copy that stops matching is detected (see `classPrefixFrom`) rather than
|
|
138
|
+
* silently wrong.
|
|
139
|
+
*/
|
|
140
|
+
const PSEUDO_SUPPRESS =
|
|
141
|
+
'[data-snapdom-has-after]::after,[data-snapdom-has-before]::before{content:none!important;display:none!important}'
|
|
142
|
+
|
|
143
|
+
/** One scoped `::marker`/`::first-line` rule, as `pseudo.js:460` writes them. */
|
|
144
|
+
const SCOPED_PSEUDO_RE = /^\[data-sd-p\d+\]::(?:marker|first-line)\{[^}]*\}/
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* `classPrefixCSS` back out of `classCSS`.
|
|
148
|
+
*
|
|
149
|
+
* The mount needs the front half of snapdom's CSS and must not have the back
|
|
150
|
+
* half. `prepare.js:133-134` builds the string as
|
|
151
|
+
*
|
|
152
|
+
* classCSS = shadowScopedCSS + PSEUDO_SUPPRESS + __pseudoCSS + `.c1{…}.c2{…}…`
|
|
153
|
+
* └───────────────── classPrefixCSS ─────────────┘
|
|
154
|
+
*
|
|
155
|
+
* and the boundary is findable exactly, without parsing CSS or guessing: the
|
|
156
|
+
* middle term is a fixed literal, and the third is a run of rules with one
|
|
157
|
+
* grammar (`[data-sd-pN]::marker{…}`). Everything after that run is the
|
|
158
|
+
* generated per-node style snapshots, which are the rules that must NOT be
|
|
159
|
+
* injected — mounting those on top of the page's cascade freezes used widths and
|
|
160
|
+
* re-wraps text (measured: fx-table's pixel diff 2.071 → 10.254).
|
|
161
|
+
*
|
|
162
|
+
* `exact:false` means the anchor was not found — a snapdom whose suppressor
|
|
163
|
+
* string moved. The caller declares that rather than guessing a boundary: the
|
|
164
|
+
* literal alone still stops a materialised `::before` painting twice, and what
|
|
165
|
+
* is lost is the shadow-scoped rules, which is a fidelity claim someone has to
|
|
166
|
+
* be told about.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} classCSS
|
|
169
|
+
* @returns {{css: string, exact: boolean}}
|
|
170
|
+
*/
|
|
171
|
+
function classPrefixFrom (classCSS) {
|
|
172
|
+
const css = typeof classCSS === 'string' ? classCSS : ''
|
|
173
|
+
const at = css.indexOf(PSEUDO_SUPPRESS)
|
|
174
|
+
if (at < 0) return { css: PSEUDO_SUPPRESS, exact: false }
|
|
175
|
+
let end = at + PSEUDO_SUPPRESS.length
|
|
176
|
+
for (;;) {
|
|
177
|
+
const m = SCOPED_PSEUDO_RE.exec(css.slice(end))
|
|
178
|
+
if (!m) break
|
|
179
|
+
end += m[0].length
|
|
180
|
+
}
|
|
181
|
+
return { css: css.slice(0, end), exact: true }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Stamped on a clone node whose shadow root this module emptied. `paint.js`
|
|
186
|
+
* reads it: a shadow root it did not expect is an error, and this one it did.
|
|
187
|
+
*/
|
|
188
|
+
export const DEFUSED_SHADOW_ATTR = 'data-snapdom-vector-defused'
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Undo the damage that mounting does to custom elements.
|
|
192
|
+
*
|
|
193
|
+
* `cloneNode` never copies a shadow root, so snapdom's clone arrives with every
|
|
194
|
+
* shadow tree already flattened into light DOM and its CSS rewritten to scoped
|
|
195
|
+
* classes. Then we connect it to the document — and the browser UPGRADES every
|
|
196
|
+
* custom element in it. `connectedCallback` runs, `attachShadow` gives the clone
|
|
197
|
+
* a brand-new shadow tree, and the flattened content snapdom worked out is
|
|
198
|
+
* either hidden behind it or, worse, slotted INSIDE the component's own markup
|
|
199
|
+
* and rendered twice.
|
|
200
|
+
*
|
|
201
|
+
* Measured on a `<sd-badge>` whose shadow root is `<span class="b"><slot></slot></span>`:
|
|
202
|
+
* the clone came out 130.63×34.50 against a live 92.91×25.75 — the badge inside
|
|
203
|
+
* the badge — and it dragged its three flex siblings 37.72px with it.
|
|
204
|
+
*
|
|
205
|
+
* The repair is exact, not a heuristic: **any** shadow root on a clone node was
|
|
206
|
+
* created by that upgrade, because a clone cannot have inherited one. Replacing
|
|
207
|
+
* its content with a bare `<slot>` is the identity transform for the flattened
|
|
208
|
+
* light DOM — it renders as if there were no shadow root at all. Measured on the
|
|
209
|
+
* same badge: 92.91×25.75, and the fixture's drift went 37.72px → 0.00px.
|
|
210
|
+
*
|
|
211
|
+
* Two things it cannot reach, both declared rather than papered over: a `closed`
|
|
212
|
+
* shadow root is not exposed to script at all, and a `connectedCallback` that
|
|
213
|
+
* rewrites its own light DOM has already done so by the time we look.
|
|
214
|
+
*
|
|
215
|
+
* @param {Element} clone mounted clone root
|
|
216
|
+
* @returns {Element[]} the hosts that were defused
|
|
217
|
+
*/
|
|
218
|
+
function defuseUpgradedShadowRoots (clone) {
|
|
219
|
+
const defused = []
|
|
220
|
+
const all = [clone]
|
|
221
|
+
for (const el of clone.querySelectorAll('*')) all.push(el)
|
|
222
|
+
for (const el of all) {
|
|
223
|
+
if (!el.shadowRoot) continue
|
|
224
|
+
try {
|
|
225
|
+
el.shadowRoot.replaceChildren(el.ownerDocument.createElement('slot'))
|
|
226
|
+
el.setAttribute(DEFUSED_SHADOW_ATTR, '')
|
|
227
|
+
defused.push(el)
|
|
228
|
+
} catch { /* a shadow root we cannot rewrite stays as it is, and paint.js says so */ }
|
|
229
|
+
}
|
|
230
|
+
return defused
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** snapdom's marker on the span it materialises a `::first-letter` into. */
|
|
234
|
+
const FIRST_LETTER = '::first-letter'
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The inner div `wrapScrolledClone` (snapdom/src/core/prepare.js) puts inside a
|
|
238
|
+
* scrolled container to carry `translate(-scrollX, -scrollY)`.
|
|
239
|
+
*
|
|
240
|
+
* Identified by signature because snapdom gives it no attribute of its own; this
|
|
241
|
+
* is the same test `paint.js` uses, kept as a copy rather than an import so the
|
|
242
|
+
* adapter stays the bottom of the stack and depends on nothing above it. Note
|
|
243
|
+
* that `all: unset` is read off the CSSOM, never off the style ATTRIBUTE, which
|
|
244
|
+
* Chromium serializes expanded into every longhand the shorthand covers.
|
|
245
|
+
*
|
|
246
|
+
* @param {Element} el
|
|
247
|
+
*/
|
|
248
|
+
function isScrollWrapper (el) {
|
|
249
|
+
if (!el || el.tagName !== 'DIV' || !el.style) return false
|
|
250
|
+
if (el.attributes.length !== 1 || !el.hasAttribute('style')) return false
|
|
251
|
+
const s = el.style
|
|
252
|
+
return s.all === 'unset' && s.display === 'inline-block' && s.width === '100%' &&
|
|
253
|
+
s.willChange === 'transform' && /^translate\(/.test(s.transform || '')
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Style rules for the clone nodes the page cannot style, and only those.
|
|
258
|
+
*
|
|
259
|
+
* snapdom keeps every clone node's computed style in a style key and stamps the
|
|
260
|
+
* node with a generated class (`c1`, `c2`, …) that carries it; those rules go
|
|
261
|
+
* into the SVG it renders and are deliberately left out of the mount — see
|
|
262
|
+
* `classPrefixFrom`, which cuts them off the front half of `classCSS`. Mounted in
|
|
263
|
+
* the page, the clone is painted by the page's own stylesheets instead, which
|
|
264
|
+
* is what makes the mount faithful — the clone kept its class names, so it gets
|
|
265
|
+
* the same cascade the original had. Measured: drift 0.00–0.03px that way.
|
|
266
|
+
*
|
|
267
|
+
* Injecting ALL of snapdom's rules on top of that is worse, not better, and this
|
|
268
|
+
* was measured too: scoped to the host they outrank the page's own selectors,
|
|
269
|
+
* and a frozen used-width where the page had none re-wrapped a paragraph
|
|
270
|
+
* (`<b>` 395×47 live vs 119×19 in the clone) and shifted a table row by 27.81px.
|
|
271
|
+
* fx-table's pixel diff went 2.071 → 10.254 on that experiment alone.
|
|
272
|
+
*
|
|
273
|
+
* What the page genuinely cannot style is the nodes snapdom INVENTED: a
|
|
274
|
+
* materialised `::before` is a `<span>` no author selector matches. Left to the
|
|
275
|
+
* cascade it has no box at all — measured, a `::before` with
|
|
276
|
+
* `content:""; width:6px; height:6px` came out 0×0 and every pill carrying one
|
|
277
|
+
* was 6px narrow. Those nodes, and no others, get their snapshot back here.
|
|
278
|
+
*
|
|
279
|
+
* A node with no live counterpart in `nodeMap` is exactly the definition of
|
|
280
|
+
* invented, so the map is the test rather than a list of snapdom's attribute
|
|
281
|
+
* names that would rot the next time it grows one.
|
|
282
|
+
*
|
|
283
|
+
* **`::first-letter` is the one exception, and it is not a special case for its
|
|
284
|
+
* own sake.** snapdom stops a materialised `::before`/`::after` from painting
|
|
285
|
+
* twice with `PSEUDO_SUPPRESS` (`[data-snapdom-has-before]::before{content:none}`),
|
|
286
|
+
* which leaves the span as the only carrier; there is no such suppressor for
|
|
287
|
+
* `::first-letter`, so the page's own rule still matches the clone paragraph and
|
|
288
|
+
* still draws the drop cap. Styling the span as well makes two floats out of one.
|
|
289
|
+
* Measured on fx-type: the paragraph re-wrapped and a `<b>` moved a whole line,
|
|
290
|
+
* 276.61px of drift, so the span is left alone here.
|
|
291
|
+
*
|
|
292
|
+
* That leaves a known hole, and it is a hole in the DESCRIPTION rather than the
|
|
293
|
+
* layout: the browser paints the drop cap from the page's rule, but the engine
|
|
294
|
+
* reads `getComputedStyle(span)`, which knows nothing about it. `collect/text.js`
|
|
295
|
+
* closes it by asking the parent for its `::first-letter` style — a paint-only
|
|
296
|
+
* subset applied here does not work, because a `font-size` on the span changes
|
|
297
|
+
* its own strut and moves the line (measured: 32.69px median drift, pixel diff
|
|
298
|
+
* 5.58 → 17.86).
|
|
299
|
+
*
|
|
300
|
+
* @param {Element} clone the clone root, not yet mounted
|
|
301
|
+
* @param {Map<Node, Node>|undefined} nodeMap clone node -> live node
|
|
302
|
+
* @param {Map<Node, string>|undefined} styleMap clone node -> style key
|
|
303
|
+
* @returns {string} CSS, '' when nothing was synthesized
|
|
304
|
+
*/
|
|
305
|
+
function synthRulesFrom (clone, nodeMap, styleMap) {
|
|
306
|
+
if (!styleMap || typeof styleMap.get !== 'function') return ''
|
|
307
|
+
if (!nodeMap || typeof nodeMap.get !== 'function') return ''
|
|
308
|
+
let css = ''
|
|
309
|
+
let n = 0
|
|
310
|
+
const all = [clone]
|
|
311
|
+
for (const el of clone.querySelectorAll('*')) all.push(el)
|
|
312
|
+
// Everything below a scroll wrapper, found in one pass: `querySelectorAll`
|
|
313
|
+
// yields document order, so a parent is always seen before its children.
|
|
314
|
+
const stranded = new Set()
|
|
315
|
+
|
|
316
|
+
for (const el of all) {
|
|
317
|
+
// A real node under a scroll wrapper is in the same position as an invented
|
|
318
|
+
// one. The wrapper is interposed between a container and its content, and
|
|
319
|
+
// that breaks every `>` chain that crosses it — not only at the first level:
|
|
320
|
+
// `.scroller > div` fails, and so does `.scroller > div > p:first-child`
|
|
321
|
+
// three nodes down. Measured on a 300×150 scroller: the direct child laid
|
|
322
|
+
// out 300×48 against a live 300×316 (268px), and with only direct children
|
|
323
|
+
// repaired the paragraphs inside it were still 14px out, having picked up
|
|
324
|
+
// the UA's `margin: 1em 0` in place of the author's `margin: 0 0 8px`.
|
|
325
|
+
const parent = el.parentElement
|
|
326
|
+
if (parent && (stranded.has(parent) || isScrollWrapper(parent))) stranded.add(el)
|
|
327
|
+
// Not `!nodeMap.get(el.parentElement)`, which is the same idea and wrong:
|
|
328
|
+
// `nodeMap` does not hold every element, so "unmapped parent" also catches
|
|
329
|
+
// ordinary nodes and hands them a frozen snapshot. Measured when it did —
|
|
330
|
+
// every fixture regressed at once, fx-table's pixel diff 1.83 → 11.39 and
|
|
331
|
+
// fx-card's drift 0.00 → 20.88px. The wrapper's signature is exact; the
|
|
332
|
+
// absence of a map entry is not evidence of anything.
|
|
333
|
+
if (nodeMap.get(el) && !stranded.has(el)) continue
|
|
334
|
+
if (el.getAttribute && el.getAttribute('data-snapdom-pseudo') === FIRST_LETTER) continue
|
|
335
|
+
const key = styleMap.get(el)
|
|
336
|
+
if (!key) continue
|
|
337
|
+
// Keyed on a per-node attribute rather than the shared `cN` class: one key
|
|
338
|
+
// can be shared by an invented node and a real one, and the real one must
|
|
339
|
+
// keep the cascade it already lays out correctly under.
|
|
340
|
+
el.setAttribute(SYNTH, String(n))
|
|
341
|
+
// `all:initial` first, and it is load-bearing rather than tidiness. snapdom
|
|
342
|
+
// builds a style key by dropping every property whose value equals the CSS
|
|
343
|
+
// INITIAL value (`getDefaultStyleForTag` measures a bare tag with
|
|
344
|
+
// `style.all = 'initial'`), because inside its own SVG the key is paired with
|
|
345
|
+
// a generated base reset that neutralises the UA stylesheet. Without that
|
|
346
|
+
// reset the key is only half a style: measured on a scrolled container, a
|
|
347
|
+
// `<p>` whose author rule sets `margin-top:0` had no `margin-top` in its key
|
|
348
|
+
// at all, kept the UA's `1em`, and pushed the whole subtree 14px down.
|
|
349
|
+
css += `[${MARK}="clone-host"] [${SYNTH}="${n}"]{all:initial;${key}}`
|
|
350
|
+
n++
|
|
351
|
+
}
|
|
352
|
+
return css
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* @typedef {object} CloneDrift
|
|
357
|
+
* @property {number} pairs clone elements that had a live counterpart in `nodeMap`
|
|
358
|
+
* AND take part in layout on at least one side — the nodes the numbers below are
|
|
359
|
+
* computed over. A zero across 3 pairs is not the same promise as a zero across 300.
|
|
360
|
+
* @property {number} skipped mapped pairs left out because neither side generates a
|
|
361
|
+
* box (see `laysOut`). Reported so the reader can tell a small denominator from a
|
|
362
|
+
* filtered one.
|
|
363
|
+
* @property {number|null} median px, over `pairs`; `null` only when `pairs` is 0
|
|
364
|
+
* @property {number|null} p95 px
|
|
365
|
+
* @property {number|null} max px
|
|
366
|
+
* @property {{delta: number, tag: string, id: string, cls: string, clone: string,
|
|
367
|
+
* live: string, missing?: 'clone'|'live'}[]} worst
|
|
368
|
+
* the worst offenders, biggest first, so the number comes with somewhere to look.
|
|
369
|
+
* `missing` names the side that has no box at all, when only one of them does.
|
|
370
|
+
*/
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Does this element take part in layout at all?
|
|
374
|
+
*
|
|
375
|
+
* The nodes that do not are not drift and never were: `<stop>`, `<clipPath>`,
|
|
376
|
+
* `<defs>`, `<linearGradient>` and everything under them describe paint, not
|
|
377
|
+
* boxes, and an `<option>` is drawn by the platform's popup rather than by the
|
|
378
|
+
* document. All of them return the all-zero rect, and subtracting a root origin
|
|
379
|
+
* from a zero rect measures the OFFSCREEN OFFSET of the host instead of any
|
|
380
|
+
* disagreement — which is exactly the 100451.00px that `fx-svg` and `fx-form`
|
|
381
|
+
* used to report, i.e. `HOST_CSS`'s -99999px plus wherever the fixture sat.
|
|
382
|
+
*
|
|
383
|
+
* The test is the client-rect LIST, not a list of tag names that would rot the
|
|
384
|
+
* next time SVG grows an element, and not `display`/`checkVisibility` either.
|
|
385
|
+
* Measured on `fx-svg`, over the 22 mapped nodes that have no box on either
|
|
386
|
+
* side: `getComputedStyle(el).display` is `inline` for **all 22** (Chromium's
|
|
387
|
+
* SVG UA sheet does not use `display:none` for them) and
|
|
388
|
+
* `el.checkVisibility({contentVisibilityAuto: true})` is `true` for 15 of them —
|
|
389
|
+
* only the 7 `<stop>`s come out false (`fx-form`'s 3 `<option>`s do too, which is
|
|
390
|
+
* the whole of what the two agree on). An element that generates no box
|
|
391
|
+
* has an EMPTY rect list, and that is true for `display:none`, for a
|
|
392
|
+
* `content-visibility` subtree the browser skipped, and for the SVG paint-server
|
|
393
|
+
* elements all at once.
|
|
394
|
+
*
|
|
395
|
+
* `rect` is the caller's already-taken bounding rect, so the rect list is only
|
|
396
|
+
* consulted for the degenerate case that could mean either "no box" or "a real
|
|
397
|
+
* empty box at the viewport origin".
|
|
398
|
+
*
|
|
399
|
+
* @param {Element} el
|
|
400
|
+
* @param {DOMRect} rect `el.getBoundingClientRect()`, already measured
|
|
401
|
+
* @returns {boolean}
|
|
402
|
+
*/
|
|
403
|
+
function laysOut (el, rect) {
|
|
404
|
+
if (rect.width || rect.height || rect.left || rect.top) return true
|
|
405
|
+
if (!el.getClientRects) return true
|
|
406
|
+
return el.getClientRects().length > 0
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* @typedef {object} CloneHandoff what one `beforeRender` hook state is worth
|
|
411
|
+
* @property {Element} element the LIVE capture root
|
|
412
|
+
* @property {Element} clone snapdom's resolved clone, still detached
|
|
413
|
+
* @property {Map<Node, Node>} nodeMap clone node -> source node
|
|
414
|
+
* @property {Map<Node, string>} styleMap clone node -> style key
|
|
415
|
+
* @property {string} fontsCSS `@font-face` rules snapdom embedded, '' when none
|
|
416
|
+
* @property {string} classCSS every rule snapdom generated for this capture
|
|
417
|
+
*/
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* @typedef {object} CloneCapture
|
|
421
|
+
* @property {Element} clone snapdom's resolved clone, MOUNTED and laid out
|
|
422
|
+
* @property {Map<Node, Node>} nodeMap clone node -> source node
|
|
423
|
+
* @property {() => void} unmount idempotent; removes the host and the injected <style>
|
|
424
|
+
* @property {CloneDrift} drift the mounted clone measured against its live source
|
|
425
|
+
* @property {boolean} classPrefixExact false when `classPrefixCSS` could not be cut
|
|
426
|
+
* out of `classCSS` and the mount ran on the suppressor alone. See `classPrefixFrom`.
|
|
427
|
+
* @property {number} defusedShadowHosts custom elements the browser upgraded on
|
|
428
|
+
* mount and whose new shadow root was emptied again. See `defuseUpgradedShadowRoots`.
|
|
429
|
+
*/
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* How far the mounted clone's boxes are from the live boxes they came from.
|
|
433
|
+
*
|
|
434
|
+
* Both sides are measured relative to their OWN root, so the offscreen offset
|
|
435
|
+
* cancels and what is left is real disagreement. This is the safety net for the
|
|
436
|
+
* whole clone architecture: if it is not ~0, every coordinate the engine emits
|
|
437
|
+
* is measured against a layout the user never saw.
|
|
438
|
+
*
|
|
439
|
+
* The offset only cancels for nodes that HAVE a box, which is why `laysOut`
|
|
440
|
+
* filters first: a node with no box on either side is not a node that agrees or
|
|
441
|
+
* disagrees, it is a node with nothing to compare, and running it through the
|
|
442
|
+
* subtraction anyway turned the host's own -99999px into a drift reading. Those
|
|
443
|
+
* are counted in `skipped` rather than dropped in silence — a number computed
|
|
444
|
+
* over a filtered population has to say how much it filtered, or the filter is
|
|
445
|
+
* just a nicer-looking version of the same lie.
|
|
446
|
+
*
|
|
447
|
+
* A node that lays out on ONE side is the opposite case and stays in: it is an
|
|
448
|
+
* element the export either invented or lost, which is the loudest thing this
|
|
449
|
+
* measurement can find. Its delta is the size of the box that does exist, since
|
|
450
|
+
* two origins cannot be subtracted when one of them is not a position.
|
|
451
|
+
*
|
|
452
|
+
* @param {Element} clone mounted clone root
|
|
453
|
+
* @param {Element} element live capture root
|
|
454
|
+
* @param {Map<Node, Node>} nodeMap
|
|
455
|
+
* @returns {CloneDrift}
|
|
456
|
+
*/
|
|
457
|
+
function measureDrift (clone, element, nodeMap) {
|
|
458
|
+
const rows = []
|
|
459
|
+
let skipped = 0
|
|
460
|
+
if (nodeMap && typeof nodeMap.get === 'function') {
|
|
461
|
+
const cloneRoot = clone.getBoundingClientRect()
|
|
462
|
+
const liveRoot = element.getBoundingClientRect()
|
|
463
|
+
const all = [clone]
|
|
464
|
+
for (const el of clone.querySelectorAll('*')) all.push(el)
|
|
465
|
+
for (const el of all) {
|
|
466
|
+
const src = nodeMap.get(el)
|
|
467
|
+
// A clone node with no live counterpart is not drift: pseudo-element spans,
|
|
468
|
+
// scroll wrappers and synthesized backdrops have nothing to be compared to.
|
|
469
|
+
if (!src || src.nodeType !== 1 || src.isConnected === false) continue
|
|
470
|
+
const a = el.getBoundingClientRect()
|
|
471
|
+
const b = src.getBoundingClientRect()
|
|
472
|
+
const inClone = laysOut(el, a)
|
|
473
|
+
const inLive = laysOut(src, b)
|
|
474
|
+
if (!inClone && !inLive) { skipped++; continue }
|
|
475
|
+
const missing = inClone ? (inLive ? null : 'live') : 'clone'
|
|
476
|
+
rows.push({
|
|
477
|
+
delta: missing
|
|
478
|
+
? Math.max(a.width, a.height, b.width, b.height)
|
|
479
|
+
: Math.max(
|
|
480
|
+
Math.abs((a.left - cloneRoot.left) - (b.left - liveRoot.left)),
|
|
481
|
+
Math.abs((a.top - cloneRoot.top) - (b.top - liveRoot.top)),
|
|
482
|
+
Math.abs(a.width - b.width),
|
|
483
|
+
Math.abs(a.height - b.height),
|
|
484
|
+
),
|
|
485
|
+
missing,
|
|
486
|
+
el,
|
|
487
|
+
src,
|
|
488
|
+
a,
|
|
489
|
+
b,
|
|
490
|
+
})
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
// No pair means NOT MEASURED, and that is a different answer from "zero drift".
|
|
494
|
+
// Reporting 0 here would hand the orchestrator a clean bill of health for a
|
|
495
|
+
// mount nobody checked, which is the one failure this number exists to catch.
|
|
496
|
+
// `skipped` travels even then: "nothing to measure" and "nothing measurABLE"
|
|
497
|
+
// are read the same way by a human and are not the same fault.
|
|
498
|
+
if (!rows.length) return { pairs: 0, skipped, median: null, p95: null, max: null, worst: [] }
|
|
499
|
+
|
|
500
|
+
rows.sort((x, y) => x.delta - y.delta)
|
|
501
|
+
const q = (p) => rows[Math.min(rows.length - 1, Math.floor(rows.length * p))].delta
|
|
502
|
+
return {
|
|
503
|
+
pairs: rows.length,
|
|
504
|
+
skipped,
|
|
505
|
+
median: q(0.5),
|
|
506
|
+
p95: q(0.95),
|
|
507
|
+
max: rows[rows.length - 1].delta,
|
|
508
|
+
// A number without a culprit is a shrug. The worst few nodes, named, are the
|
|
509
|
+
// difference between "the clone is off by 628px somewhere" and a selector to
|
|
510
|
+
// go and look at — and the outlier case is exactly where the median is useless.
|
|
511
|
+
worst: rows.slice(-WORST_KEPT).reverse().filter((r) => r.delta > 0).map(describe),
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** How many named offenders `drift.worst` keeps. Enough to see a pattern, not a dump. */
|
|
516
|
+
const WORST_KEPT = 6
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* @param {{delta: number, missing: 'clone'|'live'|null, el: Element, src: Element,
|
|
520
|
+
* a: DOMRect, b: DOMRect}} row
|
|
521
|
+
* @returns {{delta: number, tag: string, id: string, cls: string, clone: string,
|
|
522
|
+
* live: string, missing?: 'clone'|'live'}}
|
|
523
|
+
*/
|
|
524
|
+
function describe (row) {
|
|
525
|
+
const { el, a, b } = row
|
|
526
|
+
// `no box` rather than `0×0 @ 0,0`: the all-zero rect of an element that does
|
|
527
|
+
// not lay out reads like a measurement, and it is the absence of one.
|
|
528
|
+
const box = (r, gone) => gone ? 'no box' : `${round(r.width)}×${round(r.height)} @ ${round(r.left)},${round(r.top)}`
|
|
529
|
+
const out = {
|
|
530
|
+
delta: row.delta,
|
|
531
|
+
tag: el.tagName ? el.tagName.toLowerCase() : '?',
|
|
532
|
+
id: el.id || '',
|
|
533
|
+
cls: (el.getAttribute && el.getAttribute('class')) || '',
|
|
534
|
+
clone: box(a, row.missing === 'clone'),
|
|
535
|
+
live: box(b, row.missing === 'live'),
|
|
536
|
+
}
|
|
537
|
+
if (row.missing) out.missing = row.missing
|
|
538
|
+
return out
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function round (v) { return Math.round(v * 100) / 100 }
|
|
542
|
+
|
|
543
|
+
/** Distinguishes one mounted clone's radio groups from the next one's. */
|
|
544
|
+
let radioEpoch = 0
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Renames the clone's radio groups before the clone is CONNECTED.
|
|
548
|
+
*
|
|
549
|
+
* A radio button's group is every radio with the same `name` under the same form
|
|
550
|
+
* owner, and a radio with no form owner belongs to the whole DOCUMENT. Mounting the
|
|
551
|
+
* clone in `document.body` therefore drops copies of the page's radios into the page's
|
|
552
|
+
* own groups, and the browser enforces "one checked per group" at insertion time: the
|
|
553
|
+
* clone's checked radio wins and **the live one is unchecked**. Measured on
|
|
554
|
+
* `demo/challenges.html` → `fx-form`: `fo-scale=on:1` before a capture and
|
|
555
|
+
* `fo-scale=on:0` after it. Capturing a page must not change the page, and this is the
|
|
556
|
+
* worst way to break that rule — the damage is to the user's own state, it survives the
|
|
557
|
+
* capture, and nothing says it happened. The suite's leak check (#9) cannot see it
|
|
558
|
+
* either: `checked` is IDL state, and the signature it compares is attributes.
|
|
559
|
+
*
|
|
560
|
+
* Renaming cannot change how the clone paints. snapdom gives every clone node a
|
|
561
|
+
* generated class carrying the element's COMPUTED style (`prepare.js`,
|
|
562
|
+
* `applyStyleClass`), so a page rule like `input[name="x"]{…}` has already been
|
|
563
|
+
* resolved into that snapshot; losing the selector match loses nothing that is not
|
|
564
|
+
* already baked in.
|
|
565
|
+
*
|
|
566
|
+
* @param {Element} clone the clone, still detached
|
|
567
|
+
* @returns {number} how many radios were renamed
|
|
568
|
+
*/
|
|
569
|
+
function isolateRadioGroups (clone) {
|
|
570
|
+
const prefix = `snapdom-vector-clone-${++radioEpoch}-`
|
|
571
|
+
let n = 0
|
|
572
|
+
const rename = (el) => {
|
|
573
|
+
if (!el || el.tagName !== 'INPUT' || el.type !== 'radio') return
|
|
574
|
+
const name = el.getAttribute('name')
|
|
575
|
+
if (!name) return
|
|
576
|
+
el.setAttribute('name', prefix + name)
|
|
577
|
+
n++
|
|
578
|
+
}
|
|
579
|
+
try {
|
|
580
|
+
rename(clone)
|
|
581
|
+
// `input` and not `input[type=radio]`: `type` can be an IDL-only value on a node
|
|
582
|
+
// built by script, and the property is what the browser groups by.
|
|
583
|
+
for (const el of clone.querySelectorAll ? clone.querySelectorAll('input') : []) rename(el)
|
|
584
|
+
} catch { /* a clone we cannot walk is one we cannot damage the page with either */ }
|
|
585
|
+
return n
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Puts the capture root's own outer paint back on the clone.
|
|
590
|
+
*
|
|
591
|
+
* snapdom strips four properties from the CLONE ROOT and only from it —
|
|
592
|
+
* `box-shadow`, `text-shadow`, `outline` and any `drop-shadow()` in `filter`
|
|
593
|
+
* (`snapdom/src/utils/capture.helpers.js`, `stripRootShadows`). For a raster
|
|
594
|
+
* capture that is right: the bleed would fall outside the bitmap and snapdom
|
|
595
|
+
* would either clip it or grow the image. For a VECTOR export it is a hole. The
|
|
596
|
+
* properties are written as inline style on the clone root, so nothing in this
|
|
597
|
+
* engine can see them at all — `collect/box.js` reads a computed style that says
|
|
598
|
+
* `none`, finds no effects, and files no diagnostic, because from where it
|
|
599
|
+
* stands nothing was lost. Measured on `demo/challenges.html`: every one of the
|
|
600
|
+
* ten fixtures is a card with its own `box-shadow`, and all ten arrived with
|
|
601
|
+
* `effects: []` on the root and not one word about it.
|
|
602
|
+
*
|
|
603
|
+
* They are restored from the LIVE element's computed style, which is the only
|
|
604
|
+
* place they still exist. What a backend then does with a shadow that paints
|
|
605
|
+
* outside the frame is the backend's decision to declare — Figma draws it, a
|
|
606
|
+
* standalone SVG clips it to the viewport — and that is a decision it can only
|
|
607
|
+
* make if the shadow is in the document.
|
|
608
|
+
*
|
|
609
|
+
* @param {Element} element the live capture root
|
|
610
|
+
* @param {Element} clone the clone root
|
|
611
|
+
* @returns {string[]} the properties put back, for the caller to declare
|
|
612
|
+
*/
|
|
613
|
+
function restoreRootPaint (element, clone) {
|
|
614
|
+
const restored = []
|
|
615
|
+
if (!clone || !clone.style) return restored
|
|
616
|
+
let cs
|
|
617
|
+
try { cs = getComputedStyle(element) } catch { return restored }
|
|
618
|
+
if (!cs) return restored
|
|
619
|
+
// The clone's own values are read ONCE, before a single write. Interleaving
|
|
620
|
+
// read and write here is a forced style recalc per property on a tree that was
|
|
621
|
+
// mounted milliseconds ago: measured, five of them took `demo/figma.html`'s
|
|
622
|
+
// fx-card capture from 4ms to 14ms.
|
|
623
|
+
let cloneCs
|
|
624
|
+
try { cloneCs = getComputedStyle(clone) } catch { return restored }
|
|
625
|
+
// `outline` is compared through its longhands: the shorthand serialises in a
|
|
626
|
+
// different order on each side, so comparing the strings would always disagree
|
|
627
|
+
// and report a restore that put back what was already there.
|
|
628
|
+
const outlineOf = (s) => `${s.outlineWidth} ${s.outlineStyle} ${s.outlineColor}`
|
|
629
|
+
const before = {
|
|
630
|
+
boxShadow: cloneCs.boxShadow || '',
|
|
631
|
+
textShadow: cloneCs.textShadow || '',
|
|
632
|
+
outline: outlineOf(cloneCs),
|
|
633
|
+
outlineOffset: cloneCs.outlineOffset || '',
|
|
634
|
+
filter: cloneCs.filter || '',
|
|
635
|
+
}
|
|
636
|
+
const put = (prop, value) => {
|
|
637
|
+
if (!value || value === 'none' || value === '0px none rgb(0, 0, 0)') return
|
|
638
|
+
// Only where the clone has actually been emptied: a value that already
|
|
639
|
+
// matches must not be touched, and neither must one the page still cascades.
|
|
640
|
+
if (before[prop] === value) return
|
|
641
|
+
try { clone.style[prop] = value } catch { return }
|
|
642
|
+
restored.push(prop)
|
|
643
|
+
}
|
|
644
|
+
put('boxShadow', cs.boxShadow)
|
|
645
|
+
put('textShadow', cs.textShadow)
|
|
646
|
+
// An outline that paints nothing must not be "restored": the serialisation of
|
|
647
|
+
// no-outline carries the element's own `color`, so comparing the strings
|
|
648
|
+
// reported a restore on every root that had no outline at all.
|
|
649
|
+
if (cs.outlineStyle && cs.outlineStyle !== 'none' && parseFloat(cs.outlineWidth) > 0) {
|
|
650
|
+
put('outline', outlineOf(cs))
|
|
651
|
+
if (cs.outlineOffset && cs.outlineOffset !== '0px') put('outlineOffset', cs.outlineOffset)
|
|
652
|
+
}
|
|
653
|
+
// `filter` is only half-stripped: `stripRootShadows` removes the
|
|
654
|
+
// `drop-shadow()` functions and keeps the rest, so putting the live value back
|
|
655
|
+
// restores the shadows without disturbing a `blur()` that survived.
|
|
656
|
+
if (/drop-shadow\(/i.test(cs.filter || '')) put('filter', cs.filter)
|
|
657
|
+
return restored
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* One `beforeRender` hook state, reduced to the six things this engine needs.
|
|
662
|
+
*
|
|
663
|
+
* **This is the only function in the package that knows the shape of snapdom's
|
|
664
|
+
* pipeline state.** It runs inside the capture, does no work beyond reading, and
|
|
665
|
+
* hands back a plain object the export can keep — see the module comment for
|
|
666
|
+
* where each field comes from and why the hook is `beforeRender`.
|
|
667
|
+
*
|
|
668
|
+
* @param {{element: Element, clone: Element, nodeMap: Map, classCSS: string,
|
|
669
|
+
* fontsCSS: string, options: object}} state
|
|
670
|
+
* @returns {CloneHandoff}
|
|
671
|
+
*/
|
|
672
|
+
export function handoffFrom (state) {
|
|
673
|
+
const session = state && state.options && state.options.__session
|
|
674
|
+
return {
|
|
675
|
+
element: state.element,
|
|
676
|
+
clone: state.clone,
|
|
677
|
+
nodeMap: state.nodeMap,
|
|
678
|
+
// Not on the hook state itself: `prepareClone` returns it on the session's
|
|
679
|
+
// own map (`snapdom/src/core/prepare.js:33-37`), which is where it stays.
|
|
680
|
+
styleMap: session ? session.styleMap : undefined,
|
|
681
|
+
fontsCSS: state.fontsCSS || '',
|
|
682
|
+
classCSS: state.classCSS || '',
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Mount the clone a capture handed over, lay it out, and measure it against the
|
|
688
|
+
* live element it came from.
|
|
689
|
+
*
|
|
690
|
+
* The caller OWNS `unmount()` and must call it — from a `finally`, not from the
|
|
691
|
+
* happy path. Every export that does not leaves a full copy of the user's
|
|
692
|
+
* subtree in their document.
|
|
693
|
+
*
|
|
694
|
+
* Three snapdom options change what arrives here, and NONE of them can be forced
|
|
695
|
+
* from a plugin — `dpr` is resolved in `createContext` and `outerTransforms` is
|
|
696
|
+
* read at `captureDOM`'s line 74, both before the first hook runs, and `burst`
|
|
697
|
+
* decides whether `captureDOM` runs at all. That is not a loss of control, it is
|
|
698
|
+
* where the control actually lives now: the caller writes the `snapdom()` call.
|
|
699
|
+
* What each one does if the caller changes it:
|
|
700
|
+
*
|
|
701
|
+
* - `outerTransforms:false` strips the root's own transform from the clone, so
|
|
702
|
+
* it lays out unlike the element it copies — `drift` measures exactly that and
|
|
703
|
+
* `declareDrift` files it;
|
|
704
|
+
* - `burst` left unset cannot bite: a plugin with render hooks that has not
|
|
705
|
+
* declared `pure: true` suspends auto-burst by construction
|
|
706
|
+
* (`snapdom/src/core/plugins.js:184`, `hasImpureRenderPlugins`) — MEASURED,
|
|
707
|
+
* five consecutive captures of one element all ran the pipeline, where the
|
|
708
|
+
* old loose API had to pass `burst: false` to survive the third. Explicit
|
|
709
|
+
* `burst: true` is the caller taking that back; four in a row still re-entered
|
|
710
|
+
* `captureDOM` when measured, so a memo serve is a defended case and not an
|
|
711
|
+
* observed one — if one is ever served the hook does not fire, and the export
|
|
712
|
+
* throws instead of walking a clone from some other capture;
|
|
713
|
+
* - `dpr` scales the inlined raster assets, not the layout: every measurement
|
|
714
|
+
* here is a CSS-pixel `getBoundingClientRect` on the mounted clone.
|
|
715
|
+
*
|
|
716
|
+
* @param {CloneHandoff} handoff
|
|
717
|
+
* @returns {CloneCapture}
|
|
718
|
+
* @throws when the hand-off carries no clone, or when the mounted clone cannot
|
|
719
|
+
* be measured — the engine has no second way to get a clone and will not
|
|
720
|
+
* quietly fall back to walking the live DOM, which is a different and worse
|
|
721
|
+
* capture.
|
|
722
|
+
*/
|
|
723
|
+
export function mountClone (handoff) {
|
|
724
|
+
const { element, clone, nodeMap, styleMap, fontsCSS, classCSS } = handoff || {}
|
|
725
|
+
if (!element || element.nodeType !== 1) {
|
|
726
|
+
throw new Error('mountClone: the hand-off carries no live element')
|
|
727
|
+
}
|
|
728
|
+
if (!clone || clone.nodeType !== 1) {
|
|
729
|
+
throw new Error(
|
|
730
|
+
'mountClone: the capture handed over no clone. The `beforeRender` hook never ran, so the ' +
|
|
731
|
+
'capture did not go through captureDOM — leave `burst` unset (the plugin suspends it) so a ' +
|
|
732
|
+
'memoized URL is not served instead of a capture.'
|
|
733
|
+
)
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const prefix = classPrefixFrom(classCSS)
|
|
737
|
+
const synthCSS = synthRulesFrom(clone, nodeMap, styleMap)
|
|
738
|
+
|
|
739
|
+
// `fontsCSS` first: it is `@font-face` and has to be parsed before anything
|
|
740
|
+
// that uses the families. All three go in ONE <style> so unmounting is one
|
|
741
|
+
// removal, and the synthesized-node rules go last because nothing else in the
|
|
742
|
+
// document says anything about those nodes.
|
|
743
|
+
const style = document.createElement('style')
|
|
744
|
+
style.setAttribute(MARK, 'clone-css')
|
|
745
|
+
style.textContent = (fontsCSS || '') + prefix.css + synthCSS
|
|
746
|
+
document.head.appendChild(style)
|
|
747
|
+
|
|
748
|
+
const host = document.createElement('div')
|
|
749
|
+
host.setAttribute(MARK, 'clone-host')
|
|
750
|
+
host.setAttribute('aria-hidden', 'true')
|
|
751
|
+
host.style.cssText = HOST_CSS
|
|
752
|
+
sizeHost(host, element)
|
|
753
|
+
host.appendChild(clone)
|
|
754
|
+
const isolatedRadios = isolateRadioGroups(clone)
|
|
755
|
+
document.body.appendChild(host)
|
|
756
|
+
|
|
757
|
+
let unmounted = false
|
|
758
|
+
const unmount = () => {
|
|
759
|
+
if (unmounted) return
|
|
760
|
+
unmounted = true
|
|
761
|
+
host.remove()
|
|
762
|
+
style.remove()
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// After mounting and before measuring: connecting the clone is what upgrades
|
|
766
|
+
// its custom elements, and drift measured over their doubled boxes would be
|
|
767
|
+
// measuring our own damage.
|
|
768
|
+
let defused = []
|
|
769
|
+
try {
|
|
770
|
+
defused = defuseUpgradedShadowRoots(clone)
|
|
771
|
+
} catch { /* leave them; paint.js files B4.unresolved-shadow-root on each */ }
|
|
772
|
+
|
|
773
|
+
const restoredRootPaint = restoreRootPaint(element, clone)
|
|
774
|
+
|
|
775
|
+
let drift
|
|
776
|
+
try {
|
|
777
|
+
drift = measureDrift(clone, element, nodeMap)
|
|
778
|
+
} catch (error) {
|
|
779
|
+
unmount()
|
|
780
|
+
throw new Error(`mountClone: the mounted clone could not be measured: ${error && error.message}`)
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
return {
|
|
784
|
+
clone,
|
|
785
|
+
nodeMap,
|
|
786
|
+
unmount,
|
|
787
|
+
drift,
|
|
788
|
+
classPrefixExact: prefix.exact,
|
|
789
|
+
defusedShadowHosts: defused.length,
|
|
790
|
+
restoredRootPaint,
|
|
791
|
+
isolatedRadios,
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
export default mountClone
|