import type { FileNode, FolderNode, TreeNode } from "#types/folder.types"; import { NODE_ATTRIBUTE, PATH_SEPARATOR, TREE_FOLDER_CLASS } from "#configuration/constants/anatomy.constants"; import { OPEN_FILE_TITLE, OPEN_FOLDER_TITLE, ROOT_LABEL, TREE_NAV_LABEL } from "#configuration/strings/folder.strings"; import { TREE_CHEVRON_ICON, TREE_FILE_ICON, TREE_FOLDER_ICON } from "#configuration/icons/anatomy.icons"; import { appendChildren, createElement } from "#core/factories/element.factory"; import { ACTIVE_CLASS } from "#configuration/constants/element.constants"; import { ANCHOR_PREFIX } from "#configuration/constants/document.constants"; import { ICON_BASE_CLASS } from "#configuration/icons/element.icons"; import type { Section } from "#types/document.types"; import { createLink } from "#presentation/components/link.component"; import { subsectionId } from "#presentation/renderers/section.renderer"; const DETAILS_TAG = "details"; const folderNode = function folderNode(section: Section, path: string, name: string): FolderNode { const files = section.subsections.map((sub, index) => ({ id: subsectionId(section.id, index, sub.id), name: sub.title, section, sub, })); return { files, folders: [], id: section.id, name, path, section }; }; export const buildTree = function buildTree(sections: readonly Section[]): FolderNode | null { const [rootSection] = sections; if (rootSection === undefined) { return null; } const root = folderNode(rootSection, "", ROOT_LABEL); const byPath = new Map([["", root]]); for (const section of sections.slice(1)) { const path = section.title; const cut = path.lastIndexOf(PATH_SEPARATOR); const parent = byPath.get(cut === -1 ? "" : path.slice(0, cut)) ?? root; const node = folderNode(section, path, path.slice(cut + 1)); byPath.set(path, node); parent.folders.push(node); } return root; }; export const isFolder = function isFolder(node: TreeNode): node is FolderNode { return "folders" in node; }; export const ancestorsOf = function ancestorsOf(root: FolderNode, path: string): readonly FolderNode[] { const crumbs: FolderNode[] = [root]; let current = root; for (const segment of path.length === 0 ? [] : path.split(PATH_SEPARATOR)) { const next = current.folders.find((folder) => folder.name === segment); if (next === undefined) { break; } crumbs.push(next); current = next; } return crumbs; }; export const flatten = function flatten(root: FolderNode): readonly TreeNode[] { return [root, ...root.files, ...root.folders.flatMap(flatten)]; }; export const treeLink = function treeLink(node: TreeNode, className: string): HTMLAnchorElement { const folder = isFolder(node); const link = createLink(ANCHOR_PREFIX + node.id, className, [ createElement("i", { className: `${ICON_BASE_CLASS} ${folder ? TREE_FOLDER_ICON : TREE_FILE_ICON} tree-icon` }), createElement("span", { className: "tree-name", text: node.name }), ]); link.setAttribute(NODE_ATTRIBUTE, node.id); link.title = folder ? OPEN_FOLDER_TITLE : OPEN_FILE_TITLE; return link; }; const renderFileEntry = function renderFileEntry(file: FileNode): Element { return createElement("li", { children: [treeLink(file, "tree-link tree-file")] }); }; const renderFolderEntry = function renderFolderEntry(node: FolderNode, open: boolean): Element { const list = createElement("ul", { className: "tree-list" }); appendChildren(list, [ ...node.folders.map((child) => renderFolderEntry(child, false)), ...node.files.map(renderFileEntry), ]); const summary = createElement("summary", { children: [ createElement("i", { className: `${ICON_BASE_CLASS} ${TREE_CHEVRON_ICON} tree-chevron` }), treeLink(node, "tree-link"), ], className: "tree-summary", }); const details = createElement(DETAILS_TAG, { children: [summary, list], className: TREE_FOLDER_CLASS }); details.open = open; return createElement("li", { children: [details] }); }; export const renderTree = function renderTree(root: FolderNode): Element { const list = createElement("ul", { className: "tree-list tree-root" }); list.append(renderFolderEntry(root, true)); const scroll = createElement("div", { children: [createElement("h2", { className: "tree-title", text: TREE_NAV_LABEL }), list], className: "tree-scroll", }); return createElement("nav", { children: [scroll], className: "filesystem-tree" }); }; export const markSelected = function markSelected(tree: Element, id: string): void { for (const link of tree.querySelectorAll(`[${NODE_ATTRIBUTE}]`)) { link.classList.toggle(ACTIVE_CLASS, link.getAttribute(NODE_ATTRIBUTE) === id); } const selected = tree.querySelector(`[${NODE_ATTRIBUTE}="${id}"]`); let ancestor = selected?.closest(DETAILS_TAG) ?? null; while (ancestor !== null) { ancestor.open = true; ancestor = ancestor.parentElement?.closest(DETAILS_TAG) ?? null; } };