| 1 |
// A partner-coloured outline disappears against a background close to it. |
| 2 |
|
| 3 |
const MIN_CONTRAST = 3; |
| 4 |
|
| 5 |
export const MEDIA_RING = '0 0 0 4px rgba(255, 255, 255, 0.2)'; |
| 6 |
|
| 7 |
const parseColor = (value = '') => { |
| 8 |
const rgb = value.match(/rgba?\(([^)]+)\)/); |
| 9 |
if (rgb) { |
| 10 |
const parts = rgb[1] |
| 11 |
.split(/[\s,/]+/) |
| 12 |
.filter(Boolean) |
| 13 |
.map(Number); |
| 14 |
const [r, g, b, a = 1] = parts; |
| 15 |
return a === 0 ? null : [r, g, b]; |
| 16 |
} |
| 17 |
const hex = value.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); |
| 18 |
if (!hex) return null; |
| 19 |
const h = |
| 20 |
hex[1].length === 3 |
| 21 |
? hex[1] |
| 22 |
.split('') |
| 23 |
.map((c) => c + c) |
| 24 |
.join('') |
| 25 |
: hex[1]; |
| 26 |
return [0, 2, 4].map((i) => Number.parseInt(h.slice(i, i + 2), 16)); |
| 27 |
}; |
| 28 |
|
| 29 |
const luminance = ([r, g, b]) => { |
| 30 |
const channel = (v) => { |
| 31 |
const s = v / 255; |
| 32 |
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; |
| 33 |
}; |
| 34 |
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); |
| 35 |
}; |
| 36 |
|
| 37 |
const contrast = (a, b) => { |
| 38 |
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); |
| 39 |
return (hi + 0.05) / (lo + 0.05); |
| 40 |
}; |
| 41 |
|
| 42 |
// A photo leaves every ancestor transparent, so there is no colour to judge. |
| 43 |
const backdropOf = (el) => { |
| 44 |
let node = el; |
| 45 |
while (node?.nodeType === 1) { |
| 46 |
// A cover paints its image and dim into a child, not itself. |
| 47 |
if (node.classList?.contains('wp-block-cover')) { |
| 48 |
const dim = node.querySelector(':scope > .wp-block-cover__background'); |
| 49 |
return dim ? parseColor(getComputedStyle(dim).backgroundColor) : null; |
| 50 |
} |
| 51 |
const color = parseColor(getComputedStyle(node).backgroundColor); |
| 52 |
if (color) return color; |
| 53 |
node = node.parentElement; |
| 54 |
} |
| 55 |
return null; |
| 56 |
}; |
| 57 |
|
| 58 |
const overMedia = (el) => |
| 59 |
Boolean( |
| 60 |
el?.closest?.('.wp-block-cover') || |
| 61 |
el?.matches?.('.wp-block-image, .wp-block-media-text') || |
| 62 |
el?.querySelector?.('img'), |
| 63 |
); |
| 64 |
|
| 65 |
const decide = (el) => { |
| 66 |
const backdrop = backdropOf(el); |
| 67 |
// A staged section nearly always contains an image somewhere. |
| 68 |
if (!backdrop) return overMedia(el); |
| 69 |
const partner = parseColor( |
| 70 |
getComputedStyle(el).getPropertyValue('--wp--preset--color--primary'), |
| 71 |
); |
| 72 |
if (!partner) return false; |
| 73 |
return contrast(partner, backdrop) < MIN_CONTRAST; |
| 74 |
}; |
| 75 |
|
| 76 |
// Callers run on scroll; the walk above reads styles up the whole chain. |
| 77 |
const decided = new WeakMap(); |
| 78 |
|
| 79 |
export const needsContrastRing = (el) => { |
| 80 |
if (!el) return false; |
| 81 |
if (!decided.has(el)) decided.set(el, decide(el)); |
| 82 |
return decided.get(el); |
| 83 |
}; |
| 84 |
|