// @ts-check import crel, { fragment } from "./crel.js"; import pluralize from "./pluralize.js"; import formatBytes from "./formatBytes.js"; import allChildrenOf from "./allChildrenOf.js"; import * as nodePath from "./nodePath.js"; /** @typedef {import("./nodePath.js").NodePath} NodePath */ /** @typedef {import("../../types/png.d.ts").PngNode} PngNode */ /** * @typedef {Function} NodeUiFn * @param {PngNode} node * @returns {NodeUi} */ /** * @typedef {object} HoveringOver * @prop {"bytes" | "tree"} side * @prop {NodePath} path */ /** * @param {PngNode} rootNode * @param {NodeUiFn} getNodeUi * @returns {DocumentFragment} */ export default (rootNode, getNodeUi) => { const [bytesRootEl, treeRootEl] = traverse(rootNode, [], getNodeUi); const outerBytesEl = crel("div", { class: "bytes" }, bytesRootEl); const outerTreeEl = crel("div", { class: "tree" }, treeRootEl); /** @type {null | HoveringOver} */ let hoveringOver = null; const rerender = () => { /** * @callback IsActivatedFn * @param {NodePath} path * @returns {boolean} */ /** @type {IsActivatedFn} */ let isByteElActivated; /** @type {IsActivatedFn} */ let isTreeElActivated; if (hoveringOver) { const hoveredPath = hoveringOver.path; switch (hoveringOver.side) { case "bytes": isByteElActivated = (path) => nodePath.isEqualTo(path, hoveredPath); isTreeElActivated = (path) => nodePath.isSupersetOf(path, hoveredPath); break; case "tree": isByteElActivated = (path) => nodePath.isSupersetOf(hoveredPath, path); isTreeElActivated = (path) => nodePath.isEqualTo(path, hoveredPath); break; default: throw new Error("Unexpected hovering over"); } } else { isByteElActivated = isTreeElActivated = () => false; } for (const bytesEl of allChildrenOf(bytesRootEl)) { const path = elPath(bytesEl); if (path) { bytesEl.classList.toggle("activated", isByteElActivated(path)); } else { throw new Error("Expected a path"); } } for (const treeEl of allChildrenOf(treeRootEl)) { const path = elPath(treeEl); if (!path) continue; treeEl.classList.toggle("activated", isTreeElActivated(path)); } }; outerBytesEl.addEventListener("click", (event) => { if (!(event.target instanceof HTMLElement)) return; const rawPath = event.target?.dataset?.path; if (!rawPath) return; for (const explorerEl of allChildrenOf(outerTreeEl)) { if (explorerEl.dataset.path) explorerEl.removeAttribute("open"); } const leafEl = outerTreeEl.querySelector( `details[data-path="${rawPath}"]`, ); let currentTreeEl = leafEl; while (currentTreeEl && (currentTreeEl !== outerTreeEl)) { currentTreeEl.setAttribute("open", "open"); currentTreeEl = currentTreeEl.parentElement; } leafEl?.scrollIntoView({ behavior: "smooth" }); }); outerBytesEl.addEventListener("mousemove", (event) => { if (!(event.target instanceof HTMLElement)) return; const path = elPath(event.target); if (!path) return; hoveringOver = { side: "bytes", path }; rerender(); }); outerBytesEl.addEventListener("mouseout", (event) => { if (event.target !== outerBytesEl) return; if (hoveringOver?.side === "bytes") { hoveringOver = null; rerender(); } }); /** @param {UIEvent} event */ const onFocusExplorerTree = (event) => { if (!(event.target instanceof HTMLElement)) return; // We don't update the state in these situations to avoid UI jitter. if (event.target.classList.contains("children")) return; /** @type {null | HTMLElement} */ let nodeToCheckForPath = event.target; while (nodeToCheckForPath && (nodeToCheckForPath !== outerTreeEl)) { if (nodeToCheckForPath.dataset.path) break; nodeToCheckForPath = nodeToCheckForPath.parentElement; } const path = nodeToCheckForPath && elPath(nodeToCheckForPath); if (!path) return; hoveringOver = { side: "tree", path }; rerender(); }; /** @param {UIEvent} event */ const onBlurExplorerTree = (event) => { if ( (event.target !== outerTreeEl) && (event.target !== outerTreeEl.children[0]) && (hoveringOver?.side !== "tree") ) return; hoveringOver = null; rerender(); }; outerTreeEl.addEventListener("mousemove", onFocusExplorerTree); outerTreeEl.addEventListener("focusin", onFocusExplorerTree); outerTreeEl.addEventListener("mouseout", onBlurExplorerTree); outerTreeEl.addEventListener("focusout", onBlurExplorerTree); outerTreeEl.addEventListener("click", (event) => { if (!(event.target instanceof HTMLElement)) return; /** @type {null | HTMLElement} */ let nodeToCheckForPath = event.target; while (nodeToCheckForPath && (nodeToCheckForPath !== outerTreeEl)) { if (nodeToCheckForPath.dataset.path) break; nodeToCheckForPath = nodeToCheckForPath.parentElement; } const rawPath = nodeToCheckForPath?.dataset.path; if (!rawPath) return; const bytesEl = outerBytesEl.querySelector(`[data-path="${rawPath}"]`); bytesEl?.scrollIntoView({ behavior: "smooth" }); }); return fragment(outerBytesEl, outerTreeEl); }; /** * @param {PngNode} node * @param {NodePath} path * @param {NodeUiFn} getNodeUi * @returns [HTMLElement, HTMLElement] Each node's bytes and tree elements. */ const traverse = (node, path, getNodeUi) => { const nodeBytesEl = crel("span", { "data-path": path }); const isRoot = path.length === 0; const { title, description } = getNodeUi(node); const nodeTreeEl = crel( "details", { "data-path": path, ...(isRoot ? { open: "open" } : {}) }, crel( "summary", {}, crel("span", { "class": "title" }, title), crel( "span", { "class": "bytecount" }, pluralize(node.bytes.byteLength, "byte"), ), ), description, ); if (node.children) { const treeChildrenEl = crel("div", { class: "children" }); node.children.forEach((child, index) => { const [childBytesEl, childTreeEl] = traverse( child, path.concat(index), getNodeUi, ); if (index > 0) nodeBytesEl.append(" "); nodeBytesEl.append(childBytesEl); treeChildrenEl.append(childTreeEl); }); nodeTreeEl.append(treeChildrenEl); } else { nodeBytesEl.innerHTML = formatBytes(node.bytes, 256); } return [nodeBytesEl, nodeTreeEl]; }; /** * @param {HTMLElement} el * @returns {null | NodePath} */ const elPath = (el) => nodePath.parse(el.dataset?.path);