diff --git a/deno.json b/deno.json index 2fc4811..cc57bbd 100644 --- a/deno.json +++ b/deno.json @@ -8,6 +8,6 @@ "mock": "https://deno.land/std@0.196.0/testing/mock.ts" }, "compilerOptions": { - "lib": ["deno.ns", "dom"] + "lib": ["deno.ns", "dom", "dom.iterable"] } } diff --git a/public/common/allChildrenOf.js b/public/common/allChildrenOf.js new file mode 100644 index 0000000..edfee1d --- /dev/null +++ b/public/common/allChildrenOf.js @@ -0,0 +1,12 @@ +// @ts-check + +/** + * @param {HTMLElement} element + * @returns {Generator} + */ +export default function* allChildrenOf(element) { + yield element; + for (const child of element.children ?? []) { + yield* allChildrenOf(/** @type {HTMLElement} */ (child)); + } +} diff --git a/public/png/explorer.js b/public/png/explorer.js index 52ac1ef..d72d590 100644 --- a/public/png/explorer.js +++ b/public/png/explorer.js @@ -2,6 +2,8 @@ import crel, { fragment } from "../common/crel.js"; import formatBytes from "../common/formatBytes.js"; +import allChildrenOf from "../common/allChildrenOf.js"; +import * as nodePath from "./nodePath.js"; /** @typedef {import("./nodePath.js").NodePath} NodePath */ /** @typedef {import("../../types/png.d.ts").PngNode} PngNode */ @@ -11,6 +13,12 @@ import formatBytes from "../common/formatBytes.js"; * @returns {NodeUi} */ +/** + * @typedef {object} HoveringOver + * @prop {"bytes" | "tree"} side + * @prop {NodePath} path + */ + /** * @param {PngNode} rootNode * @param {NodeUiFn} getNodeUi @@ -22,6 +30,142 @@ export default (rootNode, getNodeUi) => { const outerBytesEl = crel("div", { class: "bytes" }, bytesRootEl); const outerTreeEl = crel("div", { class: "tree" }, treeRootEl); + /** @type {null | HoveringOver} */ + let hoveringOver = null; + + const rerender = () => { + // TODO: Remove this console.log + console.log("rerendering", JSON.stringify(hoveringOver)); + + /** + * @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); }; @@ -71,3 +215,9 @@ const traverse = (node, path, getNodeUi) => { return [nodeBytesEl, nodeTreeEl]; }; + +/** + * @param {HTMLElement} el + * @returns {null | NodePath} + */ +const elPath = (el) => nodePath.parse(el.dataset?.path); diff --git a/public/png/nodePath.js b/public/png/nodePath.js index 54fdbd6..092cf9c 100644 --- a/public/png/nodePath.js +++ b/public/png/nodePath.js @@ -26,5 +26,29 @@ */ export const parse = (value) => { if (typeof value !== "string") return null; - return value.split(".").map(Number); + if (!value) return []; + return value.split(",").map((str) => { + const result = parseInt(str, 10); + if (Number.isFinite(result)) return result; + throw new Error(`Invalid node path: ${value}`); + }); +}; + +/** + * @param {NodePath} a + * @param {NodePath} b + * @returns {boolean} + */ +export const isEqualTo = (a, b) => a.length === b.length && isSupersetOf(a, b); + +/** + * @param {NodePath} smaller + * @param {NodePath} bigger + * @returns {boolean} + */ +export const isSupersetOf = (smaller, bigger) => { + for (let i = 0; i < smaller.length; i++) { + if (smaller[i] !== bigger[i]) return false; + } + return true; }; diff --git a/test/png/nodePath.test.ts b/test/png/nodePath.test.ts new file mode 100644 index 0000000..06ec4fb --- /dev/null +++ b/test/png/nodePath.test.ts @@ -0,0 +1,20 @@ +import { assert, assertEquals } from "assert"; +import { isEqualTo, isSupersetOf, parse } from "../../public/png/nodePath.js"; + +Deno.test("parse", () => { + assertEquals(parse(""), []); + assertEquals(parse("1"), [1]); + assertEquals(parse("1,2,3"), [1, 2, 3]); +}); + +Deno.test("isEqualTo", () => { + assert(isEqualTo([], [])); + assert(isEqualTo([1, 2, 3], [1, 2, 3])); + assert(!isEqualTo([1, 2], [1, 2, 3])); +}); + +Deno.test("isSupersetOf", () => { + assert(isSupersetOf([1, 2, 3], [1, 2, 3])); + assert(isSupersetOf([1, 2], [1, 2, 3])); + assert(!isSupersetOf([1, 2, 3], [1, 2])); +});