Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/view/frontend/web/js/toolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ function _registerMageforgeToolbar() {
/** @type {Function|null} Global keydown handler for keyboard shortcuts */
_keyboardShortcutHandler: null,

/** @type {Map<string, 'success'|'warning'|'error'>} In-memory audit badge status (avoids DOM reads in score calc) */
_auditStatus: new Map(),

// ====================================================================
// Lifecycle
// ====================================================================
Expand Down
25 changes: 13 additions & 12 deletions src/view/frontend/web/js/toolbar/audits.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ export const auditMethods = {
},

/**
* Calculate a 0–100 score for the given audit list based on current DOM state.
* Calculate a 0–100 score for the given audit list.
* Reads from the in-memory _auditStatus map; unrun audits count as perfect (100).
*
* @param {import('./audits/index.js').AuditDefinition[]} auditList
* @returns {number}
Expand All @@ -72,21 +73,15 @@ export const auditMethods = {
let total = 0;
let max = 0;
auditList.forEach((audit) => {
const item = this.menu?.querySelector(`[data-audit-key="${audit.key}"]`);
if (!item) return;
if (!this.menu?.querySelector(`[data-audit-key="${audit.key}"]`)) return;
max += 100;
const status = item.querySelector(".mageforge-toolbar-menu-status");
if (!status || !status.textContent.trim()) {
total += 100;
} else if (
status.classList.contains("mageforge-toolbar-menu-status--success")
) {
const status = this._auditStatus.get(audit.key);
if (!status || status === "success") {
total += 100;
} else if (
status.classList.contains("mageforge-toolbar-menu-status--warning")
) {
} else if (status === "warning") {
total += 50;
}
// error → 0 pts, nothing added
});
return max > 0 ? Math.round((total / max) * 100) : 100;
},
Expand Down Expand Up @@ -471,6 +466,12 @@ export const auditMethods = {
* @param {'success'|'error'} type
*/
setAuditCounterBadge(key, message, type = "success") {
// Keep in-memory status in sync; delete key when badge is cleared (unrun state)
if (message.trim()) {
this._auditStatus.set(key, type);
} else {
this._auditStatus.delete(key);
}
if (!this.menu) return;
const item = this.menu.querySelector(`[data-audit-key="${key}"]`);
if (!item) return;
Expand Down
138 changes: 101 additions & 37 deletions src/view/frontend/web/js/toolbar/audits/highlight.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,32 +21,117 @@ const overlayRegistry = 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.
* listener serve all active overlays and any registered extra callbacks,
* throttled via requestAnimationFrame.
* Attached when the first overlay is created or the first extra callback is
* registered; torn down when both collections are empty again.
*
* @type {Map<HTMLSpanElement, function>}
* activeOverlays maps each overlay <span> to its tracked element and cleanup
* function so the RAF can perform a single batched read phase followed by a
* single write phase, avoiding forced layout reflows between reads and writes.
*
* @type {Map<HTMLSpanElement, { el: Element, cleanup: function }>}
*/
const activeOverlays = new Map();

/**
* Additional per-frame callbacks registered by other audit modules (e.g.
* tab-order) that need to piggyback on the shared scroll/resize listeners.
*
* @type {Set<function>}
*/
const extraCallbacks = new Set();

let rafPending = false;
let sharedRo = null;

/** Attach the shared ResizeObserver and scroll listener if not already active. */
function attachSharedListeners() {
if (sharedRo) return;
sharedRo = new ResizeObserver(scheduleUpdate);
sharedRo.observe(document.documentElement);
window.addEventListener("scroll", scheduleUpdate, {
passive: true,
capture: true,
});
}

/** Detach shared listeners once neither overlays nor extra callbacks need them. */
function detachSharedListeners() {
if (activeOverlays.size > 0 || extraCallbacks.size > 0) return;
sharedRo?.disconnect();
sharedRo = null;
window.removeEventListener("scroll", scheduleUpdate, { capture: true });
}

/**
* Schedule a single RAF-throttled update pass that:
* 1. Reads all overlay bounding rects in one batched read phase
* 2. Writes all overlay positions in one batched write phase
* 3. Runs any registered extra callbacks (e.g. tab-order repositioning)
*
* Separating reads from writes prevents the browser from performing a full
* layout reflow between every read–write pair ("layout thrashing").
*/
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();

// --- Batched read phase: snapshot all bounding rects before any write ---
const entries = [...activeOverlays.entries()];
const rects = entries.map(([, { el }]) =>
el.isConnected ? el.getBoundingClientRect() : null,
);

// --- Batched write phase: update all overlay positions ---
entries.forEach(([overlay, { cleanup }], i) => {
const rect = rects[i];
if (!rect) {
// Element removed from the DOM – clean up its overlay.
cleanup();
return;
}
overlay.style.top = `${rect.top}px`;
overlay.style.left = `${rect.left}px`;
overlay.style.width = `${rect.width}px`;
overlay.style.height = `${rect.height}px`;
});

// --- Extra per-frame callbacks (e.g. tab-order badge repositioning) ---
for (const cb of extraCallbacks) {
cb();
}
});
}

/**
* Creates a fixed-position overlay <span> that tracks any element's
* bounding box in the viewport. Shares a single RAF-throttled scroll/resize
* handler across all active overlays instead of creating one per element.
* Register a callback to be invoked every animation frame alongside overlay
* updates. The shared scroll / resize listeners are kept alive while at least
* one callback is registered, even if no overlay highlights are active.
*
* @param {function} fn
*/
export function addSharedCallback(fn) {
extraCallbacks.add(fn);
attachSharedListeners();
}

/**
* Remove a previously registered per-frame callback. Tears down shared
* listeners when no overlays or callbacks remain.
*
* @param {function} fn
*/
export function removeSharedCallback(fn) {
extraCallbacks.delete(fn);
detachSharedListeners();
}

/**
* Creates a fixed-position overlay <span> that tracks any element's bounding
* box in the viewport. Participates in the shared RAF-throttled update cycle.
* Returns a cleanup function that removes the overlay and deregisters it.
*
* @param {Element} el
Expand All @@ -60,45 +145,24 @@ function createOverlay(el, severity = "error") {
overlay.classList.add("mageforge-audit-overlay--warning");
document.body.appendChild(overlay);

function update() {
if (!el.isConnected) {
cleanup();

return;
}
// Set initial position synchronously so the overlay appears immediately.
if (el.isConnected) {
const rect = el.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();

activeOverlays.set(overlay, update);

// 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 so update() can reference it before its var declaration (hoisting).
function cleanup() {
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();
detachSharedListeners();
}

activeOverlays.set(overlay, { el, cleanup });
attachSharedListeners();

return cleanup;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default createAudit(
return (
style.visibility !== "hidden" &&
style.display !== "none" &&
style.opacity !== "0"
parseFloat(style.opacity) !== 0
);
},
);
Expand Down
Loading
Loading