From e7411c66d95c5d8c909fa4c9b1c30c53e33031b7 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Fri, 29 May 2026 16:37:29 +0200 Subject: [PATCH] fix: hoc causing flat indentation This occurs in a deep tree with a few hoc components where they can cause the tree to become entirely flat from an indentation perspective. The fix is to treat hoc data as optional and shorten it via ellipsis-like badges. --- .../components/elements/TreeView.test.tsx | 52 ++++- src/view/components/elements/TreeView.tsx | 64 ++++-- .../components/elements/VirtualizedList.tsx | 2 +- src/view/components/elements/useAutoIndent.ts | 200 +++++++++++++++++- src/view/components/sidebar/HocPanel.tsx | 8 +- 5 files changed, 300 insertions(+), 26 deletions(-) diff --git a/src/view/components/elements/TreeView.test.tsx b/src/view/components/elements/TreeView.test.tsx index 306802a4..da44ced6 100644 --- a/src/view/components/elements/TreeView.test.tsx +++ b/src/view/components/elements/TreeView.test.tsx @@ -1,6 +1,6 @@ import { h } from "preact"; import { render } from "@testing-library/preact"; -import { TreeItem } from "./TreeView"; +import { HocLabels, TreeItem } from "./TreeView"; import { expect } from "vitest"; import { AppCtx } from "../../store/react-bindings"; import { createStore } from "../../store"; @@ -39,4 +39,54 @@ describe("TreeItem", () => { expect(container.textContent).to.equal('foo key="foobar",'); }); + + it("should collapse overflowing HOC labels behind a count badge", () => { + const { container } = render( + , + ); + + const labels = container.querySelectorAll('[data-hoc-kind="label"]'); + expect(labels).to.have.length(2); + expect(labels[0].textContent).to.equal("withFoo"); + expect(labels[1].textContent).to.equal("withBar"); + expect( + container.querySelector('[data-hoc-kind="overflow"]')?.textContent, + ).to.equal("+2"); + }); + + it("should show only the HOC count badge when maxVisible is 0", () => { + const { container } = render( + , + ); + + expect( + container.querySelectorAll('[data-hoc-kind="label"]'), + ).to.have.length(0); + expect( + container.querySelector('[data-hoc-kind="overflow"]')?.textContent, + ).to.equal("+3"); + }); + + it("should show all HOC labels when maxVisible is not set", () => { + const { container } = render( + , + ); + + expect( + container.querySelectorAll('[data-hoc-kind="label"]'), + ).to.have.length(2); + expect(container.querySelector('[data-hoc-kind="overflow"]')).to.equal( + null, + ); + }); }); diff --git a/src/view/components/elements/TreeView.tsx b/src/view/components/elements/TreeView.tsx index f90bef27..0a1334b8 100644 --- a/src/view/components/elements/TreeView.tsx +++ b/src/view/components/elements/TreeView.tsx @@ -58,8 +58,16 @@ export function TreeView() { const search = useSearch(); const [updateCount, setUpdateCount] = useState(0); + const [hocLimits, setHocLimits] = useState>(() => new Map()); useResize(() => setUpdateCount(updateCount + 1), [updateCount]); + const renderRow = useCallback( + (id: ID, _: number, top: number) => ( + + ), + [hocLimits], + ); + const { children: listItems, containerHeight, @@ -69,8 +77,7 @@ export function TreeView() { minBufferCount: 5, container: ref, items: nodeList, - // eslint-disable-next-line react/display-name - renderRow: (id, _, top) => , + renderRow, }); // Scroll to item on selection change @@ -84,7 +91,7 @@ export function TreeView() { scrollToItem(searchSelectedId); }, [searchSelectedId, scrollToItem]); - useAutoIndent(paneRef, [listItems]); + useAutoIndent(paneRef, [listItems], setHocLimits); // When the devtools is connected, but nothing has been sent to the panel yet const isOnlyConnected = nodeList.length === 0 && roots.length === 0; @@ -170,7 +177,12 @@ export function MarkResult(props: { text: string; id: ID }) { return {text}; } -export function TreeItem(props: { key: any; id: ID; top: number }) { +export function TreeItem(props: { + key: any; + id: ID; + top: number; + maxHocVisible?: number; +}) { const { id } = props; const store = useStore(); const as = useSelection(); @@ -233,7 +245,11 @@ export function TreeItem(props: { key: any; id: ID; top: number }) { "" )} {filterHoc && node.hocs && node.hocs.length > 0 && ( - + )} {isRoot ? (Root) : ""} @@ -246,20 +262,42 @@ export function HocLabels({ hocs, nodeId, canMark = true, + maxVisible, }: { hocs: string[]; nodeId: number; canMark?: boolean; + maxVisible?: number; }) { + const visibleCount = + maxVisible == null + ? hocs.length + : Math.max(0, Math.min(maxVisible, hocs.length)); + const hiddenCount = hocs.length - visibleCount; + const labels = []; + for (let i = 0; i < visibleCount; i++) { + const hoc = hocs[i]; + labels.push( + + {canMark ? : hoc} + , + ); + } + return ( - - {hocs.map((hoc, i) => { - return ( - - {canMark ? : hoc} - - ); - })} + + {labels} + {hiddenCount > 0 && ( + + +{hiddenCount} + + )} ); } diff --git a/src/view/components/elements/VirtualizedList.tsx b/src/view/components/elements/VirtualizedList.tsx index 140730ee..b72a4fd5 100644 --- a/src/view/components/elements/VirtualizedList.tsx +++ b/src/view/components/elements/VirtualizedList.tsx @@ -120,7 +120,7 @@ export function useVirtualizedList({ idx++; } return vnodes; - }, [items, idx, max, top]); + }, [items, idx, max, top, renderRow]); return { containerHeight: rowHeight * items.length, diff --git a/src/view/components/elements/useAutoIndent.ts b/src/view/components/elements/useAutoIndent.ts index a145fa96..8f4a2d39 100644 --- a/src/view/components/elements/useAutoIndent.ts +++ b/src/view/components/elements/useAutoIndent.ts @@ -1,21 +1,145 @@ import { RefObject } from "preact"; -import { useLayoutEffect, useRef, useState } from "preact/hooks"; +import { + type Dispatch, + type StateUpdater, + useLayoutEffect, + useRef, + useState, +} from "preact/hooks"; import { useResize } from "../utils"; const INITIAL = 14; const RIGHT_MARGIN = 16; +const MIN_HOC_INDENT = INITIAL / 2; +const HOC_BADGE_PADDING = 12; +const HOC_BADGE_CHAR_WIDTH = 7; +const HOC_GAP = 4; + +interface RowMeasurement { + fullWidth: number; + compactWidth: number; + hocCount: number; + hocGap: number; + firstHocWidth: number; +} + +function getDigitCount(value: number) { + if (value < 10) return 1; + if (value < 100) return 2; + if (value < 1000) return 3; + + let digits = 4; + while (value >= 10000) { + value /= 10; + digits++; + } + return digits; +} + +function getOverflowBadgeWidth(hiddenCount: number) { + return ( + HOC_BADGE_PADDING + (getDigitCount(hiddenCount) + 1) * HOC_BADGE_CHAR_WIDTH + ); +} + +function getRowMeasurement( + cache: Map, + id: string, + el: HTMLElement, +): RowMeasurement { + const hocLabels = el.querySelector("[data-hoc-labels]"); + const hocCount = Number(hocLabels?.getAttribute("data-hoc-count") || 0); + const visibleHocCount = Number( + hocLabels?.getAttribute("data-hoc-visible") || hocCount, + ); + const cached = cache.get(id); + + // Collapsed rows cannot be used to recompute the full label width. Keep the + // previous full measurement until a full-label render refreshes the cache. + if (cached && cached.hocCount === hocCount && visibleHocCount < hocCount) { + return cached; + } + + const fullWidth = el.offsetWidth + RIGHT_MARGIN; + if ( + cached && + cached.hocCount === hocCount && + cached.fullWidth === fullWidth + ) { + return cached; + } + + let firstHocWidth = 0; + let hocGap = HOC_GAP; + + if (hocLabels && hocCount > 0) { + let firstHoc: HTMLElement | null = null; + let secondHoc: HTMLElement | null = null; + let child = hocLabels.firstElementChild as HTMLElement | null; + while (child && !secondHoc) { + if (child.getAttribute("data-hoc-kind") === "label") { + if (!firstHoc) { + firstHoc = child; + } else { + secondHoc = child; + } + } + child = child.nextElementSibling as HTMLElement | null; + } + + if (firstHoc) { + firstHocWidth = firstHoc.offsetWidth; + } + if (firstHoc && secondHoc) { + hocGap = Math.max( + 0, + secondHoc.offsetLeft - firstHoc.offsetLeft - firstHoc.offsetWidth, + ); + } + } + + const baseWidth = hocLabels + ? Math.max(0, hocLabels.offsetLeft - el.offsetLeft + RIGHT_MARGIN) + : fullWidth; + const compactWidth = + hocCount > 0 + ? Math.min(fullWidth, baseWidth + getOverflowBadgeWidth(hocCount)) + : fullWidth; + const measure = { + fullWidth, + compactWidth, + hocCount, + hocGap, + firstHocWidth, + }; + cache.set(id, measure); + return measure; +} export function useAutoIndent( container: RefObject, deps: any[], + setHocLimits: Dispatch>>, ) { const indent = useRef(INITIAL); const [available, setAvailable] = useState(0); - const cacheRef = useRef(new Map()); + const hocLimitsRef = useRef(new Map()); + const cacheRef = useRef(new Map()); + const nextHocLimitsRef = useRef(new Map()); + + const updateHocLimits = (next: Map) => { + const copy = new Map(next); + hocLimitsRef.current = copy; + setHocLimits(copy); + }; useResize( () => { indent.current = INITIAL; + nextHocLimitsRef.current.clear(); + if (hocLimitsRef.current.size > 0) { + updateHocLimits(nextHocLimitsRef.current); + } if (container.current) { setAvailable(container.current.clientWidth); } @@ -35,6 +159,8 @@ export function useAutoIndent( const { childNodes } = container.current; let nextIndent = indent.current; + let fullIndent = indent.current; + let compactIndent = indent.current; for (let i = 0; i < childNodes.length; i++) { const child = childNodes[i] as HTMLElement; @@ -47,18 +173,74 @@ export function useAutoIndent( const el = child.firstChild as HTMLElement; if (!el) continue; - let width = cache.get(id); - if (!width) { - width = el.offsetWidth + RIGHT_MARGIN; - cache.set(id, width); - } - const depth = +(child.getAttribute("data-depth") || 0); - nextIndent = Math.min(nextIndent, Math.max(0, (space - width) / depth)); + if (depth <= 0) continue; + + const measure = getRowMeasurement(cache, id, el); + fullIndent = Math.min( + fullIndent, + Math.max(0, (space - measure.fullWidth) / depth), + ); + compactIndent = Math.min( + compactIndent, + Math.max(0, (space - measure.compactWidth) / depth), + ); + nextIndent = fullIndent; + } + + const currentHocLimits = hocLimitsRef.current; + const nextHocLimits = nextHocLimitsRef.current; + nextHocLimits.clear(); + let hocLimitsChanged = false; + if (fullIndent < MIN_HOC_INDENT && compactIndent > fullIndent) { + nextIndent = Math.min(INITIAL, compactIndent); + + for (let i = 0; i < childNodes.length; i++) { + const child = childNodes[i] as HTMLElement; + if (!child) continue; + + const id = child.getAttribute("data-id"); + if (!id) continue; + + const el = child.firstChild as HTMLElement; + if (!el) continue; + + const depth = +(child.getAttribute("data-depth") || 0); + if (depth <= 0) continue; + + const measure = getRowMeasurement(cache, id, el); + if (measure.hocCount === 0) continue; + if (measure.compactWidth >= measure.fullWidth) continue; + if (depth * nextIndent + measure.fullWidth <= space) continue; + + const fullHocWidth = measure.fullWidth - measure.compactWidth; + const firstAndBadgeDelta = + measure.firstHocWidth + + measure.hocGap + + getOverflowBadgeWidth(measure.hocCount - 1) - + getOverflowBadgeWidth(measure.hocCount); + const availableHocDelta = + space - depth * nextIndent - measure.compactWidth; + const visible = + measure.hocCount > 1 && + firstAndBadgeDelta <= availableHocDelta && + firstAndBadgeDelta < fullHocWidth + ? 1 + : 0; + + const nodeId = +id; + if (currentHocLimits.get(nodeId) !== visible) { + hocLimitsChanged = true; + } + nextHocLimits.set(nodeId, visible); + } } container.current.style.setProperty("--indent-depth", `${nextIndent}px`); indent.current = nextIndent; + if (hocLimitsChanged || nextHocLimits.size !== currentHocLimits.size) { + updateHocLimits(nextHocLimits); + } } }, [...deps, available]); diff --git a/src/view/components/sidebar/HocPanel.tsx b/src/view/components/sidebar/HocPanel.tsx index 8021607d..916b8b97 100644 --- a/src/view/components/sidebar/HocPanel.tsx +++ b/src/view/components/sidebar/HocPanel.tsx @@ -4,9 +4,13 @@ export interface Props { hocs: string[]; } -export function Hoc(props: { children: any; small?: boolean }) { +export function Hoc(props: { children: any; small?: boolean; kind?: string }) { return ( - + {props.children} );