UNPKG

webpack-dev-middleware

Version:
234 lines (217 loc) 7.78 kB
// Small badge shown while a rebuild is in progress. It lives in a shadow root // so page styles cannot affect it; styles go through the CSSOM and SVG // presentation attributes, which a strict `style-src` CSP allows. import theme from "./theme.js"; var INDICATOR_ID = "webpack-dev-middleware-building-indicator"; var SVG_NS = "http://www.w3.org/2000/svg"; // Circumference of the progress ring (r = 6). var RING_LENGTH = 2 * Math.PI * 6; // eslint-disable-next-line jsdoc/reject-any-type /** @typedef {any} EXPECTED_ANY */ /** * @typedef {object} IndicatorState * @property {HTMLElement | null} host badge host element * @property {HTMLElement | null} label label inside the badge * @property {HTMLElement | null} dot pulsing dot (indeterminate mode) * @property {SVGSVGElement | null} ring progress ring (determinate mode) * @property {SVGCircleElement | null} ringValue ring value circle * @property {Record<string, true>} building sources with a build in progress — the badge hides only when every source finished */ /** @returns {IndicatorState} fresh indicator state */ function createIndicatorState() { return { host: null, label: null, dot: null, ring: null, ringValue: null, building: {} }; } // Shared through `window` so every bundled copy of this module drives one // single badge instead of stacking duplicates (same pattern as the overlay). var INDICATOR_STATE_KEY = "__webpack_dev_middleware_hot_indicator_state__"; /** @type {IndicatorState} */ var state = function () { // The browser suite cannot produce a document-less environment; this is the // guard for a server-side import of the bundle. /* istanbul ignore next -- @preserve */ if (typeof window === "undefined") { return createIndicatorState(); } var holder = /** @type {EXPECTED_ANY} */window; if (!holder[INDICATOR_STATE_KEY]) { holder[INDICATOR_STATE_KEY] = createIndicatorState(); } else { // Fill fields another package version may not have created, in place. var defaults = createIndicatorState(); for (var _i = 0, _Object$keys = Object.keys(defaults); _i < _Object$keys.length; _i++) { var key = _Object$keys[_i]; if (!(key in holder[INDICATOR_STATE_KEY])) { holder[INDICATOR_STATE_KEY][key] = defaults[(/** @type {keyof IndicatorState} */key)]; } } } return holder[INDICATOR_STATE_KEY]; }(); /** * @param {EXPECTED_ANY} element element * @param {Record<string, string | number>} style style map */ function applyStyle(element, style) { for (var _i2 = 0, _Object$keys2 = Object.keys(style); _i2 < _Object$keys2.length; _i2++) { var key = _Object$keys2[_i2]; element.style[key] = style[key]; } } /** * Create (or reuse) the indicator host element. */ function ensureIndicator() { if (state.host && state.host.parentNode) { return; } // Only reachable from a script running before <body> exists. /* istanbul ignore next -- @preserve */ if (!document.body) { return; } state.host = document.createElement("div"); state.host.id = INDICATOR_ID; applyStyle(state.host, { position: "fixed", right: "16px", bottom: "16px", zIndex: 9999, pointerEvents: "none" }); var root = state.host.attachShadow({ mode: "open" }); var badge = document.createElement("div"); applyStyle(badge, { display: "flex", alignItems: "center", gap: "8px", background: theme.panelTranslucent, color: theme.text, fontFamily: "Menlo, Consolas, 'Courier New', monospace", fontSize: "12px", padding: "6px 12px", borderRadius: "16px", boxShadow: "0 2px 12px rgba(0,0,0,0.35)" }); // Indeterminate mode: a pulsing dot. state.dot = document.createElement("span"); applyStyle(state.dot, { width: "8px", height: "8px", borderRadius: "50%", background: theme.accent }); // Pulse through the Web Animations API — no <style> element involved. if (typeof state.dot.animate === "function") { state.dot.animate([{ opacity: 1 }, { opacity: 0.2 }, { opacity: 1 }], { duration: 1000, iterations: Number.POSITIVE_INFINITY }); } // Determinate mode: a progress ring drawn with SVG presentation attributes. state.ring = document.createElementNS(SVG_NS, "svg"); state.ring.setAttribute("viewBox", "0 0 16 16"); applyStyle(state.ring, { width: "14px", height: "14px", display: "none" }); var track = document.createElementNS(SVG_NS, "circle"); track.setAttribute("cx", "8"); track.setAttribute("cy", "8"); track.setAttribute("r", "6"); track.setAttribute("fill", "none"); track.setAttribute("stroke", "rgba(255,255,255,0.25)"); track.setAttribute("stroke-width", "2.5"); state.ringValue = document.createElementNS(SVG_NS, "circle"); state.ringValue.setAttribute("cx", "8"); state.ringValue.setAttribute("cy", "8"); state.ringValue.setAttribute("r", "6"); state.ringValue.setAttribute("fill", "none"); state.ringValue.setAttribute("stroke", theme.accent); state.ringValue.setAttribute("stroke-width", "2.5"); state.ringValue.setAttribute("stroke-linecap", "round"); state.ringValue.setAttribute("stroke-dasharray", String(RING_LENGTH)); state.ringValue.setAttribute("stroke-dashoffset", String(RING_LENGTH)); // Start the ring at 12 o'clock. state.ringValue.setAttribute("transform", "rotate(-90 8 8)"); state.ring.appendChild(track); state.ring.appendChild(state.ringValue); state.label = document.createElement("span"); badge.appendChild(state.dot); badge.appendChild(state.ring); badge.appendChild(state.label); root.appendChild(badge); document.body.appendChild(state.host); } /** * Show the indicator (idempotent). With a percent the badge renders a * progress ring; without one it renders a pulsing dot. * @param {string=} text label text * @param {number=} percent compilation progress (0-100) * @param {string=} source who is building (e.g. a compilation name, or a * client sharing the badge) — the badge stays until every source finished */ export function show(text, percent) { var source = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : ""; state.building[source] = true; ensureIndicator(); // `ensureIndicator` above builds the label, so it is missing only in the // document-less case that function already guards. /* istanbul ignore next -- @preserve */ if (!state.label) { return; } state.label.textContent = text || "Rebuilding…"; var determinate = typeof percent === "number"; /** @type {HTMLElement} */ state.dot.style.display = determinate ? "none" : "inline-block"; /** @type {SVGSVGElement} */ state.ring.style.display = determinate ? "block" : "none"; if (determinate) { var clamped = Math.min(100, Math.max(0, percent)); /** @type {SVGCircleElement} */ state.ringValue.setAttribute("stroke-dashoffset", String(RING_LENGTH * (1 - clamped / 100))); } } /** * Mark one source's build as finished, or remove the indicator entirely. * @param {string=} source when given, only that source is dropped and the * badge stays while any other source is still building; without it the badge * is removed unconditionally */ export function hide(source) { if (source !== undefined) { if (!Object.prototype.hasOwnProperty.call(state.building, source)) { return; } delete state.building[source]; if (Object.keys(state.building).length > 0) { return; } } if (state.host && state.host.parentNode) { /** @type {ParentNode & Node} */ state.host.parentNode.removeChild(state.host); } state.host = null; state.label = null; state.dot = null; state.ring = null; state.ringValue = null; state.building = {}; }