|
| 1 | +/** |
| 2 | + * MageForge Toolbar – Shared highlight helpers |
| 3 | + * |
| 4 | + * Audits that mark elements by adding a CSS class use these two helpers |
| 5 | + * instead of duplicating the same logic. The CSS class is derived from the |
| 6 | + * audit key: `mageforge-audit-<key>`. |
| 7 | + */ |
| 8 | + |
| 9 | +const OVERLAY_CLASS = 'mageforge-audit-img-overlay'; |
| 10 | + |
| 11 | +/** |
| 12 | + * Module-level registry: tracks one overlay cleanup function per <img> |
| 13 | + * element and the set of audit keys currently relying on that overlay. |
| 14 | + * Using a WeakMap means entries are automatically eligible for GC once |
| 15 | + * the image node itself is collected. |
| 16 | + * |
| 17 | + * @type {WeakMap<HTMLImageElement, { cleanup: function, keys: Set<string> }>} |
| 18 | + */ |
| 19 | +const imgOverlayRegistry = new WeakMap(); |
| 20 | + |
| 21 | +/** |
| 22 | + * Shared update machinery – a single ResizeObserver and capturing scroll |
| 23 | + * listener serve all active overlays, throttled via requestAnimationFrame. |
| 24 | + * Attached on the first overlay, torn down when the last one is removed. |
| 25 | + * |
| 26 | + * @type {Map<HTMLSpanElement, function>} |
| 27 | + */ |
| 28 | +const activeOverlays = new Map(); |
| 29 | +let rafPending = false; |
| 30 | +let sharedRo = null; |
| 31 | + |
| 32 | +function scheduleUpdate() { |
| 33 | + if (rafPending) return; |
| 34 | + rafPending = true; |
| 35 | + requestAnimationFrame(() => { |
| 36 | + rafPending = false; |
| 37 | + // Snapshot before iterating: update() calls may delete entries |
| 38 | + // (image disconnected → cleanup) while we are looping. |
| 39 | + for (const updateFn of [...activeOverlays.values()]) { |
| 40 | + updateFn(); |
| 41 | + } |
| 42 | + }); |
| 43 | +} |
| 44 | + |
| 45 | +/** |
| 46 | + * Creates a fixed-position overlay <span> that tracks an <img> element's |
| 47 | + * position in the viewport. Shares a single RAF-throttled scroll/resize |
| 48 | + * handler across all active overlays instead of creating one per image. |
| 49 | + * Returns a cleanup function that removes the overlay and deregisters it. |
| 50 | + * |
| 51 | + * @param {HTMLImageElement} img |
| 52 | + * @returns {function} cleanup |
| 53 | + */ |
| 54 | +function createImgOverlay(img) { |
| 55 | + const overlay = document.createElement('span'); |
| 56 | + overlay.className = OVERLAY_CLASS; |
| 57 | + document.body.appendChild(overlay); |
| 58 | + |
| 59 | + function update() { |
| 60 | + if (!img.isConnected) { |
| 61 | + cleanup(); |
| 62 | + imgOverlayRegistry.delete(img); |
| 63 | + return; |
| 64 | + } |
| 65 | + const rect = img.getBoundingClientRect(); |
| 66 | + overlay.style.top = `${rect.top}px`; |
| 67 | + overlay.style.left = `${rect.left}px`; |
| 68 | + overlay.style.width = `${rect.width}px`; |
| 69 | + overlay.style.height = `${rect.height}px`; |
| 70 | + } |
| 71 | + |
| 72 | + update(); |
| 73 | + |
| 74 | + activeOverlays.set(overlay, update); |
| 75 | + |
| 76 | + // Attach shared listeners only when the first overlay is created. |
| 77 | + if (activeOverlays.size === 1) { |
| 78 | + sharedRo = new ResizeObserver(scheduleUpdate); |
| 79 | + sharedRo.observe(document.documentElement); |
| 80 | + window.addEventListener('scroll', scheduleUpdate, { passive: true, capture: true }); |
| 81 | + } |
| 82 | + |
| 83 | + // Named so update() can reference it before its var declaration (hoisting). |
| 84 | + function cleanup() { |
| 85 | + activeOverlays.delete(overlay); |
| 86 | + // Tear down shared listeners once no overlays remain. |
| 87 | + if (activeOverlays.size === 0) { |
| 88 | + sharedRo?.disconnect(); |
| 89 | + sharedRo = null; |
| 90 | + window.removeEventListener('scroll', scheduleUpdate, { capture: true }); |
| 91 | + } |
| 92 | + overlay.remove(); |
| 93 | + } |
| 94 | + |
| 95 | + return cleanup; |
| 96 | +} |
| 97 | + |
| 98 | +/** |
| 99 | + * Removes the highlight class from all previously marked elements and |
| 100 | + * destroys any associated image overlays. |
| 101 | + * |
| 102 | + * @param {string} key - Audit key (e.g. 'images-without-alt') |
| 103 | + */ |
| 104 | +export function clearHighlight(key) { |
| 105 | + const cls = `mageforge-audit-${key}`; |
| 106 | + document.querySelectorAll(`.${cls}`).forEach(el => { |
| 107 | + el.classList.remove(cls); |
| 108 | + if (el.tagName === 'IMG') { |
| 109 | + const entry = imgOverlayRegistry.get(el); |
| 110 | + if (entry) { |
| 111 | + entry.keys.delete(key); |
| 112 | + if (entry.keys.size === 0) { |
| 113 | + entry.cleanup(); |
| 114 | + imgOverlayRegistry.delete(el); |
| 115 | + } |
| 116 | + } |
| 117 | + } |
| 118 | + }); |
| 119 | +} |
| 120 | + |
| 121 | +/** |
| 122 | + * Highlights a set of elements by adding the audit CSS class, scrolls to the |
| 123 | + * first result, and updates the counter badge on the toolbar menu item. |
| 124 | + * |
| 125 | + * For <img> elements a fixed-position overlay is injected so the red |
| 126 | + * background is visible regardless of parent overflow or border-radius. |
| 127 | + * |
| 128 | + * @param {Element[]} elements - Elements to mark |
| 129 | + * @param {string} key - Audit key (e.g. 'images-without-alt') |
| 130 | + * @param {object} context - Alpine toolbar component instance |
| 131 | + */ |
| 132 | +export function applyHighlight(elements, key, context) { |
| 133 | + if (elements.length === 0) { |
| 134 | + context.setAuditCounterBadge(key, '0', 'success'); |
| 135 | + return; |
| 136 | + } |
| 137 | + const cls = `mageforge-audit-${key}`; |
| 138 | + elements.forEach(el => { |
| 139 | + el.classList.add(cls); |
| 140 | + if (el.tagName === 'IMG') { |
| 141 | + const existing = imgOverlayRegistry.get(el); |
| 142 | + if (existing) { |
| 143 | + existing.keys.add(key); |
| 144 | + } else { |
| 145 | + imgOverlayRegistry.set(el, { |
| 146 | + cleanup: createImgOverlay(el), |
| 147 | + keys: new Set([key]), |
| 148 | + }); |
| 149 | + } |
| 150 | + } |
| 151 | + }); |
| 152 | + elements[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); |
| 153 | + context.setAuditCounterBadge(key, `${elements.length}`, 'error'); |
| 154 | +} |
0 commit comments