|
19 | 19 | // bucketing, while the sweep guard wants exact (0.01) rounding because it asks |
20 | 20 | // whether the seek moved anything at all. |
21 | 21 | // |
22 | | -// Adding a channel (e.g. SVG stroke-dasharray/dashoffset): append one reader |
| 22 | +// Adding a channel (e.g. SVG fill-opacity): append one reader |
23 | 23 | // `(element, ctx) => string` to BOX_CHANNELS (reads the element's own box, |
24 | | -// including a control's widget type and checked state) or CONTENT_CHANNELS (reads what the |
25 | | -// element's contents paint — text, pseudo content, control values, media |
26 | | -// pixels — which `content-visibility: hidden` skips). `ctx` |
27 | | -// carries the element's computed style, its ::before/::after styles, its |
28 | | -// inherited opacity, and the quantize flag. A reader returns a string that is |
29 | | -// equal between two samples iff that channel did not visibly change; return "" |
30 | | -// for elements the channel does not apply to so ordinary compositions gain no |
31 | | -// payload. |
| 24 | +// including a control's widget type and checked state, or an SVG shape's |
| 25 | +// stroke) or CONTENT_CHANNELS (reads what the element's contents paint — text, |
| 26 | +// pseudo content, control values, media pixels — which |
| 27 | +// `content-visibility: hidden` skips). `ctx` carries the element's computed |
| 28 | +// style, its ::before/::after styles, its inherited opacity, and the quantize |
| 29 | +// flag. A reader returns a string that is equal between two samples iff that |
| 30 | +// channel did not visibly change; return "" for elements the channel does not |
| 31 | +// apply to so ordinary compositions gain no payload. |
32 | 32 | // |
33 | 33 | // Signatures are a single opaque string per sample (not a structured array): |
34 | 34 | // Node only ever needs equality, never per-element diffing. Textual channels |
|
37 | 37 | (function () { |
38 | 38 | const IGNORE_TAGS = new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT", "META", "LINK"]); |
39 | 39 | const MEDIA_TAGS = new Set(["CANVAS", "VIDEO", "IMG"]); |
| 40 | + // SVG containers whose direct content is never painted: <defs> and |
| 41 | + // <clipPath> only lend geometry to a referencing element, and <symbol> |
| 42 | + // renders only as <use> instances, whose shadow trees querySelectorAll |
| 43 | + // cannot reach. Blink happens to report an empty box for their descendants, |
| 44 | + // but a never-painted subtree should be excluded by rule, not by one |
| 45 | + // engine's bbox behaviour. SVG tag names are case-preserved (`clipPath`), |
| 46 | + // hence the lower-cased match. |
| 47 | + const UNPAINTED_SVG_CONTAINERS = new Set(["defs", "clippath", "symbol"]); |
| 48 | + // A computed stroke that paints nothing: `none`, or a fully transparent |
| 49 | + // colour (`transparent` computes to rgba(0, 0, 0, 0)). |
| 50 | + const TRANSPARENT_COLOR = /^(?:transparent|rgba\([^)]*,\s*0(?:\.0+)?\))$/; |
40 | 51 | const FNV_OFFSET_BASIS = 2166136261; |
41 | 52 | const FNV_PRIME = 16777619; |
42 | 53 | const LIVENESS_POSITION_BUCKET_PX = 2; |
|
109 | 120 | ); |
110 | 121 | } |
111 | 122 |
|
| 123 | + // Whether `element` starts an unrendered subtree: nothing under it paints |
| 124 | + // and nothing under it can feed a painted counter(). `display` is not |
| 125 | + // inherited — a child of a display:none parent still computes display:block, |
| 126 | + // and a shape inside <defs> computes as painted — so compositionSignature |
| 127 | + // propagates this to descendants itself. The platform decides where it can |
| 128 | + // (checkVisibility: display:none and skipped contents); the fallback is |
| 129 | + // display:none. display:contents has no box of its own but its |
| 130 | + // pseudo-elements and children render, so it stays an owner — except as |
| 131 | + // the child of a host that skips its contents, where its pseudo-elements |
| 132 | + // paint nothing either. The platform check cannot tell that from an |
| 133 | + // ordinary display:contents host (both have no box), so the parent's |
| 134 | + // skipsContents verdict (`parentSkipsContents`) decides; a display:contents |
| 135 | + // child of an off-screen `auto` host is not caught (see |
| 136 | + // compositionSignature). Without the platform check a skipped host is |
| 137 | + // itself unrendered, so the caller never reaches this with a true |
| 138 | + // `parentSkipsContents`. An unpainted SVG container |
| 139 | + // (UNPAINTED_SVG_CONTAINERS) is excluded by rule: its shapes have layout |
| 140 | + // boxes, so checkVisibility cannot know they never reach the screen. |
| 141 | + function startsUnrenderedSubtree(element, style, platformDecides, parentSkipsContents) { |
| 142 | + if (UNPAINTED_SVG_CONTAINERS.has(element.tagName.toLowerCase())) return true; |
| 143 | + if (style.display === "contents") return parentSkipsContents; |
| 144 | + return platformDecides |
| 145 | + ? !element.checkVisibility(RENDERED_BOX_OPTIONS) |
| 146 | + : style.display === "none"; |
| 147 | + } |
| 148 | + |
| 149 | + function paintsStroke(style) { |
| 150 | + const stroke = cssValue(style.stroke); |
| 151 | + return ( |
| 152 | + stroke !== "" && |
| 153 | + !TRANSPARENT_COLOR.test(stroke) && |
| 154 | + Number.parseFloat(style.strokeWidth) > 0 && |
| 155 | + Number.parseFloat(style.strokeOpacity) > 0 |
| 156 | + ); |
| 157 | + } |
| 158 | + |
| 159 | + // An SVG geometry element (path/circle/ellipse/rect/line/polyline/polygon) |
| 160 | + // with a painted stroke. Chromium's getBoundingClientRect for SVG shapes is |
| 161 | + // the object bounding box WITHOUT the stroke, so a straight horizontal or |
| 162 | + // vertical connector reports 0 height or 0 width regardless of stroke-width |
| 163 | + // even though it is plainly on screen. |
| 164 | + function isStrokedShape(element, style) { |
| 165 | + return element instanceof SVGGeometryElement && paintsStroke(style); |
| 166 | + } |
| 167 | + |
112 | 168 | // Visibility floor: checkVisibility (as layout-audit.browser.js |
113 | 169 | // isVisibleElement's opacity-floor path; its default path skips it and so |
114 | 170 | // cannot see skipped contents), then display/visibility, then inherited |
115 | | - // opacity, then a non-empty box. Kept local rather than shared because |
116 | | - // layout-audit is also installed and tested on its own; this module owns the |
117 | | - // decision for both motion samplers. The author opt-out (data-layout-ignore / |
118 | | - // data-layout-check=ignore) is NOT applied here: motion-sample reports this |
119 | | - // bit for explicitly asserted selectors, and an assertion naming an element |
120 | | - // outranks a layout-audit opt-out. compositionSignature applies the opt-out |
121 | | - // itself (see there). clip-path is not probed either; it is a channel, so a |
122 | | - // clip-path wipe over a static box counts as motion directly. |
| 171 | + // opacity, then a non-empty box — widened by one case: a stroked SVG shape |
| 172 | + // whose geometry bbox is degenerate along one axis (see isStrokedShape) is |
| 173 | + // on screen even though that floor rejects it. Kept local rather than shared |
| 174 | + // because layout-audit is also installed and tested on its own; this module |
| 175 | + // owns the decision for both motion samplers. The author opt-out |
| 176 | + // (data-layout-ignore / data-layout-check=ignore) is NOT applied here: |
| 177 | + // motion-sample reports this bit for explicitly asserted selectors, and an |
| 178 | + // assertion naming an element outranks a layout-audit opt-out. |
| 179 | + // compositionSignature applies the opt-out itself (see there). clip-path is |
| 180 | + // not probed either; it is a channel, so a clip-path wipe over a static box |
| 181 | + // counts as motion directly. |
123 | 182 | // fallow-ignore-next-line complexity |
124 | 183 | function isVisibleElement(element, style, opacity) { |
125 | 184 | if (IGNORE_TAGS.has(element.tagName)) return false; |
|
129 | 188 | ) { |
130 | 189 | return false; |
131 | 190 | } |
132 | | - if (isHiddenStyle(style || getComputedStyle(element))) return false; |
| 191 | + const computed = style || getComputedStyle(element); |
| 192 | + if (isHiddenStyle(computed)) return false; |
133 | 193 | if ((opacity === undefined ? opacityChain(element) : opacity) < 0.2) return false; |
134 | 194 | const rect = element.getBoundingClientRect(); |
135 | | - return rect.width > 0.5 && rect.height > 0.5; |
| 195 | + if (rect.width > 0.5 && rect.height > 0.5) return true; |
| 196 | + // A stroked shape paints along its one non-degenerate axis. |
| 197 | + return isStrokedShape(element, computed) && (rect.width > 0.5 || rect.height > 0.5); |
136 | 198 | } |
137 | 199 |
|
138 | 200 | function foldField(hash, value) { |
|
190 | 252 | return clip ? hashFields([clip]) : ""; |
191 | 253 | } |
192 | 254 |
|
| 255 | + // `none` and an all-zero list (`0`, `0px 0px`) both render a solid stroke, |
| 256 | + // on which the offset has no visible effect. |
| 257 | + function dashPattern(style) { |
| 258 | + const dashes = cssValue(style.strokeDasharray); |
| 259 | + if (!dashes) return ""; |
| 260 | + return dashes.split(/[\s,]+/).some((dash) => Number.parseFloat(dash) > 0) ? dashes : ""; |
| 261 | + } |
| 262 | + |
| 263 | + // A "draw the line in" SVG entrance animates stroke-dasharray / |
| 264 | + // stroke-dashoffset on a shape whose geometry never changes: no box, no |
| 265 | + // opacity, only how much of the stroke is currently dash-visible. Without a |
| 266 | + // dash pattern the offset has no visible effect, and without a painted |
| 267 | + // stroke neither does, so ordinary shapes stay "". |
| 268 | + function strokeDashChannel(element, ctx) { |
| 269 | + if (!isStrokedShape(element, ctx.style)) return ""; |
| 270 | + const dashes = dashPattern(ctx.style); |
| 271 | + return dashes ? hashFields([dashes, ctx.style.strokeDashoffset || ""]) : ""; |
| 272 | + } |
| 273 | + |
193 | 274 | // Direct text nodes only: descendants are signed separately, and a hidden |
194 | 275 | // descendant's text mutation must not masquerade as visible motion. |
195 | 276 | function textChannel(element) { |
|
271 | 352 | } |
272 | 353 |
|
273 | 354 | // The element's own box still paints when its contents are skipped |
274 | | - // (content-visibility: hidden) — including a checkbox's check glyph; its |
275 | | - // text, pseudo boxes, control value, and replaced content (a canvas/video/img's |
276 | | - // pixels) do not. |
| 355 | + // (content-visibility: hidden) — including a checkbox's check glyph and an |
| 356 | + // SVG shape's stroke; its text, pseudo boxes, control value, and replaced |
| 357 | + // content (a canvas/video/img's pixels) do not. |
277 | 358 | const BOX_CHANNELS = [ |
278 | 359 | boxChannel, |
279 | 360 | opacityChannel, |
280 | 361 | fontAxesChannel, |
281 | 362 | clipPathChannel, |
| 363 | + strokeDashChannel, |
282 | 364 | controlWidgetChannel, |
283 | 365 | ]; |
284 | 366 | const CONTENT_CHANNELS = [ |
|
390 | 472 | const quantize = !!(options && options.quantize); |
391 | 473 | const parts = []; |
392 | 474 | const boxOwners = []; |
393 | | - // Unrendered elements (display:none subtrees, skipped contents) paint |
394 | | - // nothing and cannot feed a painted counter(). The platform decides where it |
395 | | - // can; the fallback is display:none and content-visibility:hidden hosts, |
396 | | - // propagated to descendants. display:contents has no box of its own but its |
397 | | - // pseudo-elements and children render, so it stays an owner — except as |
398 | | - // the child of a host that skips its contents, where its pseudo-elements |
399 | | - // paint nothing either. The platform check cannot tell that from an |
400 | | - // ordinary display:contents host (both have no box), so the parent's |
401 | | - // skipsContents verdict decides; a display:contents child of an off-screen |
402 | | - // `auto` host is not caught (see below). |
| 475 | + // Unrendered subtrees (see startsUnrenderedSubtree; without the platform |
| 476 | + // check, content-visibility:hidden hosts too) paint nothing and cannot feed |
| 477 | + // a painted counter(); membership is propagated to descendants here, and |
| 478 | + // hosts that skip their contents are remembered so a display:contents |
| 479 | + // child of one is pruned as well. |
403 | 480 | const unrenderedBelow = new Set(); |
404 | 481 | const skippedHosts = new Set(); |
405 | 482 | for (const element of [root, ...root.querySelectorAll("*")]) { |
406 | 483 | if (IGNORE_TAGS.has(element.tagName)) continue; |
407 | 484 | const style = getComputedStyle(element); |
408 | 485 | const parent = element.parentElement; |
409 | 486 | const platformDecides = typeof element.checkVisibility === "function"; |
410 | | - const noBox = platformDecides |
411 | | - ? !element.checkVisibility(RENDERED_BOX_OPTIONS) |
412 | | - : style.display === "none"; |
413 | | - const boxlessOwner = style.display === "contents" && !skippedHosts.has(parent); |
414 | | - if (unrenderedBelow.has(parent) || (noBox && !boxlessOwner)) { |
| 487 | + if ( |
| 488 | + unrenderedBelow.has(parent) || |
| 489 | + startsUnrenderedSubtree(element, style, platformDecides, skippedHosts.has(parent)) |
| 490 | + ) { |
415 | 491 | unrenderedBelow.add(element); |
416 | 492 | continue; |
417 | 493 | } |
|
0 commit comments