Skip to content

Commit

Permalink
components tree
Browse files Browse the repository at this point in the history
  • Loading branch information
pivanov committed Jan 28, 2025
1 parent 9c1d9e8 commit f400147
Show file tree
Hide file tree
Showing 14 changed files with 1,032 additions and 824 deletions.
65 changes: 56 additions & 9 deletions packages/scan/src/web/assets/css/styles.tailwind.css
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ svg {
@apply z-[2147483678];
@apply animate-fade-in animation-duration-300 animation-delay-300;
@apply shadow-[0_4px_12px_rgba(0,0,0,0.2)];

@apply place-self-start;
}

.button {
Expand Down Expand Up @@ -263,7 +265,7 @@ svg {

@keyframes shimmer {
100% {
transform: translateX(100%);
@apply translate-x-full;
}
}

Expand Down Expand Up @@ -326,8 +328,8 @@ svg {
}

@keyframes blink {
from { opacity: 1; }
to { opacity: 0; }
from { @apply opacity-100; }
to { @apply opacity-0; }
}

.react-scan-arrow {
Expand Down Expand Up @@ -437,21 +439,21 @@ svg {
}

.react-scan-flash-active {
opacity: 0.4;
transition: opacity 300ms ease-in-out;
@apply opacity-40;
@apply transition-opacity duration-300;
}

.react-scan-inspector-overlay {
@apply flex flex-col;
opacity: 0;
transition: opacity 300ms ease-in-out;
@apply opacity-0;
@apply transition-opacity duration-300;

&.fade-out {
opacity: 0;
@apply opacity-0;
}

&.fade-in {
opacity: 1;
@apply opacity-100;
}
}

Expand Down Expand Up @@ -544,3 +546,48 @@ svg {
} */
}
}

.resize-v-line {
@apply flex items-center justify-center;
@apply min-w-3 max-w-3;
@apply w-full h-full;
@apply transition-colors duration-150;
@apply cursor-col-resize;

&:hover,
&:active {
&::before {
@apply bg-white/10;
}

> span {
@apply bg-white/10;
}

svg {
@apply opacity-100;
}
}

&::before {
@apply content-[""];
@apply absolute inset-0 left-1/2 -translate-x-1/2;
@apply w-[1px];
@apply transition-colors duration-150;
}

> span {
@apply absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2;
@apply w-1.5 h-4.5;
@apply rounded;
@apply transition-colors duration-150;
}

svg {
@apply absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2;
@apply text-neutral-400 rotate-90;
@apply opacity-0;
@apply transition-opacity duration-150;
@apply z-50;
}
}
7 changes: 5 additions & 2 deletions packages/scan/src/web/components/inspector/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,10 @@ export const Inspector = constant(() => {

if (!parentCompositeFiber) return;

const isNewComponent = refLastInspectedFiber.current?.type !== parentCompositeFiber.type;
const isNewComponent =
refLastInspectedFiber.current?.type !== parentCompositeFiber.type ||
refLastInspectedFiber.current?.key !== parentCompositeFiber.key ||
refLastInspectedFiber.current?.index !== parentCompositeFiber.index;

if (isNewComponent) {
refLastInspectedFiber.current = parentCompositeFiber;
Expand Down Expand Up @@ -167,8 +170,8 @@ export const Inspector = constant(() => {
ref={refInspector}
className={cn(
'react-scan-inspector',
'flex-1',
'opacity-0',
'h-full',
'overflow-y-auto overflow-x-hidden',
'transition-opacity duration-150 delay-0',
'pointer-events-none',
Expand Down
1 change: 1 addition & 0 deletions packages/scan/src/web/components/inspector/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@ export const getInspectableElements = (
element: HTMLElement | null,
): HTMLElement | null => {
if (!element) return null;

const { parentCompositeFiber } = getCompositeComponentFromElement(element);
if (!parentCompositeFiber) return null;

Expand Down
259 changes: 259 additions & 0 deletions packages/scan/src/web/components/widget/components-tree/breadcrumb.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import {
ClassComponentTag,
type Fiber,
ForwardRefTag,
FunctionComponentTag,
MemoComponentTag,
SimpleMemoComponentTag,
} from 'bippy';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
import { Store } from '~core/index';
import { Icon } from '~web/components/icon';
import {
getCompositeFiberFromElement,
getInspectableAncestors,
} from '~web/components/inspector/utils';
import { cn } from '~web/utils/helpers';
import { type TreeItem, inspectedElementSignal } from './state';

export const Breadcrumb = () => {
const containerRef = useRef<HTMLDivElement>(null);
const [path, setPath] = useState<TreeItem[]>([]);
const [visibleItems, setVisibleItems] = useState<TreeItem[]>([]);
const updateTimeoutRef = useRef<number>();
const lastWidthRef = useRef<number>(0);

const updateVisibleItems = useCallback(() => {
const containerWidth = containerRef.current?.clientWidth || 0;
if (Math.abs(containerWidth - lastWidthRef.current) < 5) return;
lastWidthRef.current = containerWidth;

if (!path.length) return;

const ROOT_WIDTH = 80;
const ITEM_WIDTH = 80;

const result: TreeItem[] = [path[0]];
if (path.length <= 1) {
setVisibleItems(result);
return;
}

const remainingWidth = containerWidth - ROOT_WIDTH;
const maxItemsAfterRoot = Math.floor(remainingWidth / ITEM_WIDTH);

if (maxItemsAfterRoot >= path.length - 1) {
result.push(...path.slice(1));
setVisibleItems(result);
return;
}

if (maxItemsAfterRoot <= 0 && path.length > 1) {
result.push({
name: '…',
depth: 0,
element: path[path.length - 1].element,
fiber: null,
childrenCount: 0,
updates: {
count: 0,
lastUpdate: 0,
renderDuration: 0,
cascadeLevel: 0,
hasStructuralChanges: false,
},
}, path[path.length - 1]);
setVisibleItems(result);
return;
}

if (maxItemsAfterRoot >= 2) {
const MAX_END_ITEMS = 10;
const availableSlots = Math.min(maxItemsAfterRoot - 1, MAX_END_ITEMS);
const endItems = path.slice(Math.max(1, path.length - availableSlots));

const ellipsisItem: TreeItem = {
name: '…',
depth: 0,
element: path[Math.max(1, path.length - availableSlots - 1)].element,
fiber: null,
childrenCount: 0,
updates: {
count: 0,
lastUpdate: 0,
renderDuration: 0,
cascadeLevel: 0,
hasStructuralChanges: false,
},
};
result.push(ellipsisItem, ...endItems);
} else if (path.length > 1) {
const ellipsisItem: TreeItem = {
name: '…',
depth: 0,
element: path[path.length - 2].element,
fiber: null,
childrenCount: 0,
updates: {
count: 0,
lastUpdate: 0,
renderDuration: 0,
cascadeLevel: 0,
hasStructuralChanges: false,
},
};
result.push(ellipsisItem, path[path.length - 1]);
}

setVisibleItems(result);
}, [path]);

useEffect(() => {
const handleElementChange = (focusedDomElement: HTMLElement | null) => {
if (!focusedDomElement) return;

const ancestors = getInspectableAncestors(focusedDomElement);
const items = ancestors.map((item) => {
const { parentCompositeFiber } = getCompositeFiberFromElement(
item.element,
);

const getChildrenCount = (
fiber: Fiber | null | undefined,
): number => {
if (!fiber) return 0;
let count = 0;
let child = fiber.child;
while (child) {
if (
child.tag === FunctionComponentTag ||
child.tag === ForwardRefTag ||
child.tag === SimpleMemoComponentTag ||
child.tag === MemoComponentTag ||
child.tag === ClassComponentTag
) {
count++;
}
child = child.sibling;
}
return count;
};

return {
...item,
fiber: parentCompositeFiber || null,
childrenCount: getChildrenCount(parentCompositeFiber),
updates: {
count: 0,
lastUpdate: 0,
renderDuration: 0,
cascadeLevel: 0,
hasStructuralChanges: false,
},
};
});

setPath(items);

lastWidthRef.current = 0;
updateVisibleItems();
};

const unsubscribeStore = Store.inspectState.subscribe((state) => {
if (state.kind === 'focused' && state.focusedDomElement) {
handleElementChange(state.focusedDomElement as HTMLElement);
}
});

if (Store.inspectState.value.kind === 'focused') {
handleElementChange(Store.inspectState.value.focusedDomElement as HTMLElement);
}

return () => {
unsubscribeStore();
};
}, [updateVisibleItems]);

useEffect(() => {
if (path.length > 0) {
updateVisibleItems();
}
}, [path, updateVisibleItems]);

useLayoutEffect(() => {
const handleResize = () => {
if (updateTimeoutRef.current) {
cancelAnimationFrame(updateTimeoutRef.current);
}
updateTimeoutRef.current = requestAnimationFrame(() => {
if (path.length > 0) {
updateVisibleItems();
}
});
};

handleResize();

const resizeObserver = new ResizeObserver(handleResize);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}

return () => {
resizeObserver.disconnect();
if (updateTimeoutRef.current) {
cancelAnimationFrame(updateTimeoutRef.current);
}
};
}, [path.length, updateVisibleItems]);

return (
<div
ref={containerRef}
className={cn(
'flex items-center gap-x-1',
'py-1 px-2',
'text-xs text-neutral-400',
'border-b border-white/10',
'overflow-hidden w-full'
)}
>
{visibleItems.map((item, index) => (
<div
key={`${item.name}-${index}`}
className="flex items-center gap-x-1 overflow-hidden h-6"
>
{index > 0 && (
<span className="w-2.5 h-2.5 flex items-center justify-center text-neutral-400">
<Icon name="icon-chevron-right" size={10} />
</span>
)}
{item.name === '…' ? (
<span className="text-sm h-4"></span>
) : (
<button
type="button"
title={`${item.name}${item.childrenCount ? ` (${item.childrenCount})` : ''}`}
className={cn('rounded truncate max-w-20', {
'text-white': index === visibleItems.length - 1,
'text-neutral-400': index !== visibleItems.length - 1,
})}
onClick={() => {
inspectedElementSignal.value = item.element;
Store.inspectState.value = {
kind: 'focused',
focusedDomElement: item.element,
};
}}
>
{item.name}
{item.childrenCount > 0 && (
<span className="ml-1 opacity-50">({item.childrenCount})</span>
)}
</button>
)}
</div>
))}
</div>
);
};
Loading

0 comments on commit f400147

Please sign in to comment.