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} );