From cbc4305af0cd3dabf73c9ea41525ec99bba4af46 Mon Sep 17 00:00:00 2001 From: Mathias Elle Date: Mon, 13 Apr 2026 09:43:14 +0200 Subject: [PATCH 1/7] feat: add highlight helpers and improve audit highlighting logic --- src/view/frontend/web/css/toolbar.css | 3 ++ .../web/js/toolbar/audits/highlight.js | 36 +++++++++++++++++++ .../js/toolbar/audits/images-without-alt.js | 13 ++----- .../audits/images-without-dimensions.js | 13 ++----- .../audits/images-without-lazy-load.js | 13 ++----- .../js/toolbar/audits/inputs-without-label.js | 13 ++----- .../js/toolbar/audits/low-contrast-text.js | 13 ++----- 7 files changed, 54 insertions(+), 50 deletions(-) create mode 100644 src/view/frontend/web/js/toolbar/audits/highlight.js diff --git a/src/view/frontend/web/css/toolbar.css b/src/view/frontend/web/css/toolbar.css index f1aae65..4e071f2 100644 --- a/src/view/frontend/web/css/toolbar.css +++ b/src/view/frontend/web/css/toolbar.css @@ -403,6 +403,9 @@ .mageforge-audit-low-contrast { outline: 3px solid var(--mageforge-color-red) !important; outline-offset: 2px; + box-shadow: inset 0 0 0 3px var(--mageforge-color-red) !important; + opacity: 0.5 !important; + background-color: var(--mageforge-color-red) !important; } /* ============================================================================ diff --git a/src/view/frontend/web/js/toolbar/audits/highlight.js b/src/view/frontend/web/js/toolbar/audits/highlight.js new file mode 100644 index 0000000..168b1ba --- /dev/null +++ b/src/view/frontend/web/js/toolbar/audits/highlight.js @@ -0,0 +1,36 @@ +/** + * MageForge Toolbar – Shared highlight helpers + * + * Audits that mark elements by adding a CSS class use these two helpers + * instead of duplicating the same logic. The CSS class is derived from the + * audit key: `mageforge-audit-`. + */ + +/** + * Removes the highlight class from all previously marked elements. + * + * @param {string} key - Audit key (e.g. 'images-without-alt') + */ +export function clearHighlight(key) { + const cls = `mageforge-audit-${key}`; + document.querySelectorAll(`.${cls}`).forEach(el => el.classList.remove(cls)); +} + +/** + * Highlights a set of elements by adding the audit CSS class, scrolls to the + * first result, and updates the counter badge on the toolbar menu item. + * + * @param {Element[]} elements - Elements to mark + * @param {string} key - Audit key (e.g. 'images-without-alt') + * @param {object} context - Alpine toolbar component instance + */ +export function applyHighlight(elements, key, context) { + if (elements.length === 0) { + context.setAuditCounterBadge(key, '0', 'success'); + return; + } + const cls = `mageforge-audit-${key}`; + elements.forEach(el => el.classList.add(cls)); + elements[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); + context.setAuditCounterBadge(key, `${elements.length}`, 'error'); +} diff --git a/src/view/frontend/web/js/toolbar/audits/images-without-alt.js b/src/view/frontend/web/js/toolbar/audits/images-without-alt.js index 07d10c9..ba7c1e2 100644 --- a/src/view/frontend/web/js/toolbar/audits/images-without-alt.js +++ b/src/view/frontend/web/js/toolbar/audits/images-without-alt.js @@ -2,7 +2,7 @@ * MageForge Toolbar Audit – Images without ALT */ -const HIGHLIGHT_CLASS = 'mageforge-audit-images-without-alt'; +import { applyHighlight, clearHighlight } from './highlight.js'; /** @type {import('./index.js').AuditDefinition} */ export default { @@ -17,7 +17,7 @@ export default { */ run(context, active) { if (!active) { - document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach(el => el.classList.remove(HIGHLIGHT_CLASS)); + clearHighlight(this.key); return; } @@ -28,13 +28,6 @@ export default { return !img.hasAttribute('alt') || img.getAttribute('alt').trim() === ''; }); - if (images.length === 0) { - context.setAuditCounterBadge('images-without-alt', '0', 'success'); - return; - } - - images.forEach(img => img.classList.add(HIGHLIGHT_CLASS)); - images[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); - context.setAuditCounterBadge('images-without-alt', `${images.length}`, 'error'); + applyHighlight(images, this.key, context); }, }; diff --git a/src/view/frontend/web/js/toolbar/audits/images-without-dimensions.js b/src/view/frontend/web/js/toolbar/audits/images-without-dimensions.js index 2898ed2..40e1388 100644 --- a/src/view/frontend/web/js/toolbar/audits/images-without-dimensions.js +++ b/src/view/frontend/web/js/toolbar/audits/images-without-dimensions.js @@ -4,7 +4,7 @@ * Images missing explicit width and height attributes cause Cumulative Layout Shift (CLS). */ -const HIGHLIGHT_CLASS = 'mageforge-audit-images-without-dimensions'; +import { applyHighlight, clearHighlight } from './highlight.js'; /** @type {import('./index.js').AuditDefinition} */ export default { @@ -19,7 +19,7 @@ export default { */ run(context, active) { if (!active) { - document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach(el => el.classList.remove(HIGHLIGHT_CLASS)); + clearHighlight(this.key); return; } @@ -30,13 +30,6 @@ export default { return !img.hasAttribute('width') || !img.hasAttribute('height'); }); - if (images.length === 0) { - context.setAuditCounterBadge('images-without-dimensions', '0', 'success'); - return; - } - - images.forEach(img => img.classList.add(HIGHLIGHT_CLASS)); - images[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); - context.setAuditCounterBadge('images-without-dimensions', `${images.length}`, 'error'); + applyHighlight(images, this.key, context); }, }; diff --git a/src/view/frontend/web/js/toolbar/audits/images-without-lazy-load.js b/src/view/frontend/web/js/toolbar/audits/images-without-lazy-load.js index c6e3029..967ed2c 100644 --- a/src/view/frontend/web/js/toolbar/audits/images-without-lazy-load.js +++ b/src/view/frontend/web/js/toolbar/audits/images-without-lazy-load.js @@ -5,7 +5,7 @@ * wasting bandwidth and slowing initial page load. */ -const HIGHLIGHT_CLASS = 'mageforge-audit-images-without-lazy-load'; +import { applyHighlight, clearHighlight } from './highlight.js'; /** @type {import('./index.js').AuditDefinition} */ export default { @@ -20,7 +20,7 @@ export default { */ run(context, active) { if (!active) { - document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach(el => el.classList.remove(HIGHLIGHT_CLASS)); + clearHighlight(this.key); return; } @@ -35,13 +35,6 @@ export default { return rect.top > viewportBottom; }); - if (images.length === 0) { - context.setAuditCounterBadge('images-without-lazy-load', '0', 'success'); - return; - } - - images.forEach(img => img.classList.add(HIGHLIGHT_CLASS)); - images[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); - context.setAuditCounterBadge('images-without-lazy-load', `${images.length}`, 'error'); + applyHighlight(images, this.key, context); }, }; diff --git a/src/view/frontend/web/js/toolbar/audits/inputs-without-label.js b/src/view/frontend/web/js/toolbar/audits/inputs-without-label.js index 8ccd562..4edab9a 100644 --- a/src/view/frontend/web/js/toolbar/audits/inputs-without-label.js +++ b/src/view/frontend/web/js/toolbar/audits/inputs-without-label.js @@ -5,7 +5,7 @@ * to screen reader users. */ -const HIGHLIGHT_CLASS = 'mageforge-audit-inputs-without-label'; +import { applyHighlight, clearHighlight } from './highlight.js'; /** @type {import('./index.js').AuditDefinition} */ export default { @@ -20,7 +20,7 @@ export default { */ run(context, active) { if (!active) { - document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach(el => el.classList.remove(HIGHLIGHT_CLASS)); + clearHighlight(this.key); return; } @@ -39,13 +39,6 @@ export default { return true; }); - if (inputs.length === 0) { - context.setAuditCounterBadge('inputs-without-label', '0', 'success'); - return; - } - - inputs.forEach(el => el.classList.add(HIGHLIGHT_CLASS)); - inputs[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); - context.setAuditCounterBadge('inputs-without-label', `${inputs.length}`, 'error'); + applyHighlight(inputs, this.key, context); }, }; diff --git a/src/view/frontend/web/js/toolbar/audits/low-contrast-text.js b/src/view/frontend/web/js/toolbar/audits/low-contrast-text.js index ede5db7..7d028a8 100644 --- a/src/view/frontend/web/js/toolbar/audits/low-contrast-text.js +++ b/src/view/frontend/web/js/toolbar/audits/low-contrast-text.js @@ -6,7 +6,7 @@ * - 3:1 for large text (>=18pt or >=14pt bold) */ -const HIGHLIGHT_CLASS = 'mageforge-audit-low-contrast'; +import { applyHighlight, clearHighlight } from './highlight.js'; const _colorCanvas = document.createElement('canvas'); _colorCanvas.width = _colorCanvas.height = 1; @@ -164,7 +164,7 @@ export default { */ run(context, active) { if (!active) { - document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach(el => el.classList.remove(HIGHLIGHT_CLASS)); + clearHighlight(this.key); return; } @@ -189,13 +189,6 @@ export default { return ratio < threshold; }); - if (failing.length === 0) { - context.setAuditCounterBadge('low-contrast-text', '0', 'success'); - return; - } - - failing.forEach(el => el.classList.add(HIGHLIGHT_CLASS)); - failing[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); - context.setAuditCounterBadge('low-contrast-text', `${failing.length}`, 'error'); + applyHighlight(failing, this.key, context); }, }; From 5c4b65605cfb74ad5e13eff5b40250184608ae3e Mon Sep 17 00:00:00 2001 From: Mathias Elle Date: Mon, 13 Apr 2026 10:16:51 +0200 Subject: [PATCH 2/7] fix: update toolbar active state styling for better visibility --- src/view/frontend/web/css/toolbar.css | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/view/frontend/web/css/toolbar.css b/src/view/frontend/web/css/toolbar.css index 4e071f2..648e29e 100644 --- a/src/view/frontend/web/css/toolbar.css +++ b/src/view/frontend/web/css/toolbar.css @@ -404,8 +404,7 @@ outline: 3px solid var(--mageforge-color-red) !important; outline-offset: 2px; box-shadow: inset 0 0 0 3px var(--mageforge-color-red) !important; - opacity: 0.5 !important; - background-color: var(--mageforge-color-red) !important; + filter: sepia(1) saturate(8) hue-rotate(315deg) brightness(0.85) !important; } /* ============================================================================ From fd4fd38a01f799c97a836560168c85e3d76ae4b2 Mon Sep 17 00:00:00 2001 From: Mathias Elle Date: Mon, 13 Apr 2026 10:24:08 +0200 Subject: [PATCH 3/7] fix: enhance audit highlighting for image elements and parent containers --- src/view/frontend/web/css/toolbar.css | 19 ++++++++++++++++++- .../web/js/toolbar/audits/highlight.js | 19 +++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/view/frontend/web/css/toolbar.css b/src/view/frontend/web/css/toolbar.css index 648e29e..f46cd95 100644 --- a/src/view/frontend/web/css/toolbar.css +++ b/src/view/frontend/web/css/toolbar.css @@ -404,7 +404,24 @@ outline: 3px solid var(--mageforge-color-red) !important; outline-offset: 2px; box-shadow: inset 0 0 0 3px var(--mageforge-color-red) !important; - filter: sepia(1) saturate(8) hue-rotate(315deg) brightness(0.85) !important; +} + +/* Image elements: semi-transparent so the red parent background shines through */ +img.mageforge-audit-images-without-alt, +img.mageforge-audit-images-without-dimensions, +img.mageforge-audit-images-without-lazy-load { + opacity: 0.5 !important; +} + +/* Parent container of a highlighted image gets a red background */ +.mageforge-audit-img-parent { + background-color: var(--mageforge-color-red) !important; +} + +/* Non-image elements (inputs, text) get a colour filter for visibility */ +.mageforge-audit-inputs-without-label, +.mageforge-audit-low-contrast { + filter: brightness(0.5) sepia(1) saturate(12) hue-rotate(330deg) !important; } /* ============================================================================ diff --git a/src/view/frontend/web/js/toolbar/audits/highlight.js b/src/view/frontend/web/js/toolbar/audits/highlight.js index 168b1ba..c6189f7 100644 --- a/src/view/frontend/web/js/toolbar/audits/highlight.js +++ b/src/view/frontend/web/js/toolbar/audits/highlight.js @@ -6,6 +6,8 @@ * audit key: `mageforge-audit-`. */ +const IMG_PARENT_CLASS = 'mageforge-audit-img-parent'; + /** * Removes the highlight class from all previously marked elements. * @@ -13,13 +15,21 @@ */ export function clearHighlight(key) { const cls = `mageforge-audit-${key}`; - document.querySelectorAll(`.${cls}`).forEach(el => el.classList.remove(cls)); + document.querySelectorAll(`.${cls}`).forEach(el => { + el.classList.remove(cls); + if (el.tagName === 'IMG') { + el.parentElement?.classList.remove(IMG_PARENT_CLASS); + } + }); } /** * Highlights a set of elements by adding the audit CSS class, scrolls to the * first result, and updates the counter badge on the toolbar menu item. * + * For elements the direct parent also receives a background class so the + * red colour is visible even when the image fills its container. + * * @param {Element[]} elements - Elements to mark * @param {string} key - Audit key (e.g. 'images-without-alt') * @param {object} context - Alpine toolbar component instance @@ -30,7 +40,12 @@ export function applyHighlight(elements, key, context) { return; } const cls = `mageforge-audit-${key}`; - elements.forEach(el => el.classList.add(cls)); + elements.forEach(el => { + el.classList.add(cls); + if (el.tagName === 'IMG') { + el.parentElement?.classList.add(IMG_PARENT_CLASS); + } + }); elements[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); context.setAuditCounterBadge(key, `${elements.length}`, 'error'); } From 4bf317785cee9366a375d48b368c55a7c98576fb Mon Sep 17 00:00:00 2001 From: Mathias Elle Date: Mon, 13 Apr 2026 10:37:56 +0200 Subject: [PATCH 4/7] fix: replace image parent background with fixed overlay for visibility --- src/view/frontend/web/css/toolbar.css | 18 +++---- .../web/js/toolbar/audits/highlight.js | 53 ++++++++++++++++--- 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/src/view/frontend/web/css/toolbar.css b/src/view/frontend/web/css/toolbar.css index f46cd95..c787aeb 100644 --- a/src/view/frontend/web/css/toolbar.css +++ b/src/view/frontend/web/css/toolbar.css @@ -406,16 +406,14 @@ box-shadow: inset 0 0 0 3px var(--mageforge-color-red) !important; } -/* Image elements: semi-transparent so the red parent background shines through */ -img.mageforge-audit-images-without-alt, -img.mageforge-audit-images-without-dimensions, -img.mageforge-audit-images-without-lazy-load { - opacity: 0.5 !important; -} - -/* Parent container of a highlighted image gets a red background */ -.mageforge-audit-img-parent { - background-color: var(--mageforge-color-red) !important; +/* Fixed-position overlay injected over highlighted elements */ +.mageforge-audit-img-overlay { + position: fixed; + pointer-events: none; + background-color: rgba(239, 68, 68, 0.5); + outline: 3px solid var(--mageforge-color-red); + outline-offset: 0; + z-index: 99998; } /* Non-image elements (inputs, text) get a colour filter for visibility */ diff --git a/src/view/frontend/web/js/toolbar/audits/highlight.js b/src/view/frontend/web/js/toolbar/audits/highlight.js index c6189f7..2a19ace 100644 --- a/src/view/frontend/web/js/toolbar/audits/highlight.js +++ b/src/view/frontend/web/js/toolbar/audits/highlight.js @@ -6,10 +6,48 @@ * audit key: `mageforge-audit-`. */ -const IMG_PARENT_CLASS = 'mageforge-audit-img-parent'; +const OVERLAY_CLASS = 'mageforge-audit-img-overlay'; /** - * Removes the highlight class from all previously marked elements. + * Creates a fixed-position overlay that tracks an element's + * position in the viewport. Updates on scroll (all containers) and resize. + * Returns a cleanup function that removes the overlay and all listeners. + * + * @param {HTMLImageElement} img + * @returns {function} cleanup + */ +function createImgOverlay(img) { + const overlay = document.createElement('span'); + overlay.className = OVERLAY_CLASS; + document.body.appendChild(overlay); + + function update() { + const rect = img.getBoundingClientRect(); + overlay.style.top = `${rect.top}px`; + overlay.style.left = `${rect.left}px`; + overlay.style.width = `${rect.width}px`; + overlay.style.height = `${rect.height}px`; + } + + update(); + + // ResizeObserver on catches both window resize and layout shifts + const ro = new ResizeObserver(update); + ro.observe(document.documentElement); + + // capture: true catches scroll events from any scrollable container + window.addEventListener('scroll', update, { passive: true, capture: true }); + + return () => { + ro.disconnect(); + window.removeEventListener('scroll', update, { capture: true }); + overlay.remove(); + }; +} + +/** + * Removes the highlight class from all previously marked elements and + * destroys any associated image overlays. * * @param {string} key - Audit key (e.g. 'images-without-alt') */ @@ -17,8 +55,9 @@ export function clearHighlight(key) { const cls = `mageforge-audit-${key}`; document.querySelectorAll(`.${cls}`).forEach(el => { el.classList.remove(cls); - if (el.tagName === 'IMG') { - el.parentElement?.classList.remove(IMG_PARENT_CLASS); + if (el.tagName === 'IMG' && el._mfOverlayCleanup) { + el._mfOverlayCleanup(); + delete el._mfOverlayCleanup; } }); } @@ -27,8 +66,8 @@ export function clearHighlight(key) { * Highlights a set of elements by adding the audit CSS class, scrolls to the * first result, and updates the counter badge on the toolbar menu item. * - * For elements the direct parent also receives a background class so the - * red colour is visible even when the image fills its container. + * For elements a fixed-position overlay is injected so the red + * background is visible regardless of parent overflow or border-radius. * * @param {Element[]} elements - Elements to mark * @param {string} key - Audit key (e.g. 'images-without-alt') @@ -43,7 +82,7 @@ export function applyHighlight(elements, key, context) { elements.forEach(el => { el.classList.add(cls); if (el.tagName === 'IMG') { - el.parentElement?.classList.add(IMG_PARENT_CLASS); + el._mfOverlayCleanup = createImgOverlay(el); } }); elements[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); From 0ea5140e04cf18589fb2643daa4aea297061c2c0 Mon Sep 17 00:00:00 2001 From: Mathias Elle Date: Mon, 13 Apr 2026 10:53:45 +0200 Subject: [PATCH 5/7] fix: improve overlay cleanup for disconnected image elements --- src/view/frontend/web/css/toolbar.css | 2 +- .../frontend/web/js/toolbar/audits/highlight.js | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/view/frontend/web/css/toolbar.css b/src/view/frontend/web/css/toolbar.css index c787aeb..394412a 100644 --- a/src/view/frontend/web/css/toolbar.css +++ b/src/view/frontend/web/css/toolbar.css @@ -413,7 +413,7 @@ background-color: rgba(239, 68, 68, 0.5); outline: 3px solid var(--mageforge-color-red); outline-offset: 0; - z-index: 99998; + z-index: 9999997; } /* Non-image elements (inputs, text) get a colour filter for visibility */ diff --git a/src/view/frontend/web/js/toolbar/audits/highlight.js b/src/view/frontend/web/js/toolbar/audits/highlight.js index 2a19ace..6ade242 100644 --- a/src/view/frontend/web/js/toolbar/audits/highlight.js +++ b/src/view/frontend/web/js/toolbar/audits/highlight.js @@ -22,6 +22,10 @@ function createImgOverlay(img) { document.body.appendChild(overlay); function update() { + if (!img.isConnected) { + cleanup(); + return; + } const rect = img.getBoundingClientRect(); overlay.style.top = `${rect.top}px`; overlay.style.left = `${rect.left}px`; @@ -38,11 +42,17 @@ function createImgOverlay(img) { // capture: true catches scroll events from any scrollable container window.addEventListener('scroll', update, { passive: true, capture: true }); - return () => { + // Named function so update() can reference it via hoisting. + // ro is always assigned before cleanup() is ever invoked (the initial + // update() call only triggers cleanup() when img.isConnected is false, + // which cannot happen at construction time). + function cleanup() { ro.disconnect(); window.removeEventListener('scroll', update, { capture: true }); overlay.remove(); - }; + } + + return cleanup; } /** From 4d02bf3139fef829645a96c5c04ef76a40dd958a Mon Sep 17 00:00:00 2001 From: Mathias Elle Date: Mon, 13 Apr 2026 10:57:44 +0200 Subject: [PATCH 6/7] fix: improve overlay management for image elements in audits --- .../web/js/toolbar/audits/highlight.js | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/view/frontend/web/js/toolbar/audits/highlight.js b/src/view/frontend/web/js/toolbar/audits/highlight.js index 6ade242..1eb166f 100644 --- a/src/view/frontend/web/js/toolbar/audits/highlight.js +++ b/src/view/frontend/web/js/toolbar/audits/highlight.js @@ -8,6 +8,16 @@ const OVERLAY_CLASS = 'mageforge-audit-img-overlay'; +/** + * Module-level registry: tracks one overlay cleanup function per + * element and the set of audit keys currently relying on that overlay. + * Using a WeakMap means entries are automatically eligible for GC once + * the image node itself is collected. + * + * @type {WeakMap }>} + */ +const imgOverlayRegistry = new WeakMap(); + /** * Creates a fixed-position overlay that tracks an element's * position in the viewport. Updates on scroll (all containers) and resize. @@ -24,6 +34,7 @@ function createImgOverlay(img) { function update() { if (!img.isConnected) { cleanup(); + imgOverlayRegistry.delete(img); return; } const rect = img.getBoundingClientRect(); @@ -65,9 +76,15 @@ export function clearHighlight(key) { const cls = `mageforge-audit-${key}`; document.querySelectorAll(`.${cls}`).forEach(el => { el.classList.remove(cls); - if (el.tagName === 'IMG' && el._mfOverlayCleanup) { - el._mfOverlayCleanup(); - delete el._mfOverlayCleanup; + if (el.tagName === 'IMG') { + const entry = imgOverlayRegistry.get(el); + if (entry) { + entry.keys.delete(key); + if (entry.keys.size === 0) { + entry.cleanup(); + imgOverlayRegistry.delete(el); + } + } } }); } @@ -92,7 +109,15 @@ export function applyHighlight(elements, key, context) { elements.forEach(el => { el.classList.add(cls); if (el.tagName === 'IMG') { - el._mfOverlayCleanup = createImgOverlay(el); + const existing = imgOverlayRegistry.get(el); + if (existing) { + existing.keys.add(key); + } else { + imgOverlayRegistry.set(el, { + cleanup: createImgOverlay(el), + keys: new Set([key]), + }); + } } }); elements[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); From cb5c9b703d197ddc9bfac02887c2a0a7b259389e Mon Sep 17 00:00:00 2001 From: Mathias Elle Date: Mon, 13 Apr 2026 11:14:12 +0200 Subject: [PATCH 7/7] fix: optimize overlay management and event handling for audits --- .../web/js/toolbar/audits/highlight.js | 55 ++++++++++++++----- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/src/view/frontend/web/js/toolbar/audits/highlight.js b/src/view/frontend/web/js/toolbar/audits/highlight.js index 1eb166f..9b5d523 100644 --- a/src/view/frontend/web/js/toolbar/audits/highlight.js +++ b/src/view/frontend/web/js/toolbar/audits/highlight.js @@ -18,10 +18,35 @@ const OVERLAY_CLASS = 'mageforge-audit-img-overlay'; */ const imgOverlayRegistry = new WeakMap(); +/** + * Shared update machinery – a single ResizeObserver and capturing scroll + * listener serve all active overlays, throttled via requestAnimationFrame. + * Attached on the first overlay, torn down when the last one is removed. + * + * @type {Map} + */ +const activeOverlays = new Map(); +let rafPending = false; +let sharedRo = null; + +function scheduleUpdate() { + if (rafPending) return; + rafPending = true; + requestAnimationFrame(() => { + rafPending = false; + // Snapshot before iterating: update() calls may delete entries + // (image disconnected → cleanup) while we are looping. + for (const updateFn of [...activeOverlays.values()]) { + updateFn(); + } + }); +} + /** * Creates a fixed-position overlay that tracks an element's - * position in the viewport. Updates on scroll (all containers) and resize. - * Returns a cleanup function that removes the overlay and all listeners. + * position in the viewport. Shares a single RAF-throttled scroll/resize + * handler across all active overlays instead of creating one per image. + * Returns a cleanup function that removes the overlay and deregisters it. * * @param {HTMLImageElement} img * @returns {function} cleanup @@ -46,20 +71,24 @@ function createImgOverlay(img) { update(); - // ResizeObserver on catches both window resize and layout shifts - const ro = new ResizeObserver(update); - ro.observe(document.documentElement); + activeOverlays.set(overlay, update); - // capture: true catches scroll events from any scrollable container - window.addEventListener('scroll', update, { passive: true, capture: true }); + // Attach shared listeners only when the first overlay is created. + if (activeOverlays.size === 1) { + sharedRo = new ResizeObserver(scheduleUpdate); + sharedRo.observe(document.documentElement); + window.addEventListener('scroll', scheduleUpdate, { passive: true, capture: true }); + } - // Named function so update() can reference it via hoisting. - // ro is always assigned before cleanup() is ever invoked (the initial - // update() call only triggers cleanup() when img.isConnected is false, - // which cannot happen at construction time). + // Named so update() can reference it before its var declaration (hoisting). function cleanup() { - ro.disconnect(); - window.removeEventListener('scroll', update, { capture: true }); + activeOverlays.delete(overlay); + // Tear down shared listeners once no overlays remain. + if (activeOverlays.size === 0) { + sharedRo?.disconnect(); + sharedRo = null; + window.removeEventListener('scroll', scheduleUpdate, { capture: true }); + } overlay.remove(); }