222 lines
6.5 KiB
JavaScript
222 lines
6.5 KiB
JavaScript
// @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);
|