diff --git a/packages/next-playground/components/inspector-context-menu.tsx b/packages/next-playground/components/inspector-context-menu.tsx
new file mode 100644
index 00000000..e4242e53
--- /dev/null
+++ b/packages/next-playground/components/inspector-context-menu.tsx
@@ -0,0 +1,66 @@
+'use client';
+
+import { cn } from './cn';
+
+export interface InspectorContextMenuEntry {
+ entryId: string;
+ elementTag: string;
+ previewText: string;
+}
+
+export interface InspectorContextMenuPosition {
+ left: number;
+ top: number;
+}
+
+interface InspectorContextMenuProps {
+ activeEntryId: string | null;
+ entries: InspectorContextMenuEntry[];
+ onHoverEntry: (entryId: string) => void;
+ onSelectEntry: (entryId: string) => void;
+ position: InspectorContextMenuPosition;
+}
+
+export const InspectorContextMenu = ({
+ activeEntryId,
+ entries,
+ onHoverEntry,
+ onSelectEntry,
+ position,
+}: InspectorContextMenuProps): React.JSX.Element => {
+ return (
+
+ {entries.map((innerEntry) => (
+
+ ))}
+
+ );
+};
diff --git a/packages/next-playground/components/inspector.tsx b/packages/next-playground/components/inspector.tsx
index e3738f1d..2be79675 100644
--- a/packages/next-playground/components/inspector.tsx
+++ b/packages/next-playground/components/inspector.tsx
@@ -3,30 +3,352 @@
import 'bippy';
import { getFiberFromHostInstance, getLatestFiber } from 'bippy';
import { getSource } from 'bippy/dist/source';
-import { useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { cn } from './cn';
+import {
+ InspectorContextMenu,
+ type InspectorContextMenuEntry,
+ type InspectorContextMenuPosition,
+} from './inspector-context-menu';
-export function Inspector() {
- const [rect, setRect] = useState(null);
+interface BuildContextMenuEntriesResult {
+ activeEntryId: string | null;
+ entries: InspectorContextMenuEntry[];
+ entryElementMap: Map;
+}
+
+interface InspectorElementSummary {
+ elementTag: string;
+ previewText: string;
+}
+
+const INSPECTOR_UI_SELECTOR = '[data-inspector-ui="true"]';
+
+const isInspectableElement = (candidateElement: Element | null): candidateElement is Element => {
+ if (!candidateElement) {
+ return false;
+ }
+ return !candidateElement.closest(INSPECTOR_UI_SELECTOR);
+};
+
+const getElementPreviewText = (element: Element): string => {
+ const elementTextContent = element.textContent?.replace(/\s+/g, ' ').trim() ?? '';
+ if (elementTextContent.length > 0) {
+ return elementTextContent.length > 60
+ ? `${elementTextContent.slice(0, 60)}…`
+ : elementTextContent;
+ }
+ const ariaLabel = element.getAttribute('aria-label');
+ if (ariaLabel && ariaLabel.length > 0) {
+ return ariaLabel;
+ }
+ return 'No preview text';
+};
+
+const getElementTag = (element: Element): string => {
+ const tagName = element.tagName.toLowerCase();
+ const idSegment = element.id.length > 0 ? `#${element.id}` : '';
+ const classSegments = Array.from(element.classList).slice(0, 2);
+ const classSegment = classSegments.length > 0 ? `.${classSegments.join('.')}` : '';
+ return `${tagName}${idSegment}${classSegment}`;
+};
+
+const getElementSummary = (element: Element): InspectorElementSummary => {
+ return {
+ elementTag: getElementTag(element),
+ previewText: getElementPreviewText(element),
+ };
+};
+
+const getLabelPosition = (elementRect: DOMRect): InspectorContextMenuPosition => {
+ const minimumPadding = 8;
+ const estimatedLabelWidth = 320;
+ const clampedLeft = Math.min(
+ Math.max(elementRect.left, minimumPadding),
+ window.innerWidth - estimatedLabelWidth - minimumPadding,
+ );
+ const top = Math.max(minimumPadding, elementRect.top - 34);
+ return {
+ left: clampedLeft,
+ top,
+ };
+};
+
+const clampMenuPosition = (
+ requestedPosition: InspectorContextMenuPosition,
+): InspectorContextMenuPosition => {
+ const minimumPadding = 8;
+ const estimatedMenuWidth = 360;
+ const estimatedMenuHeight = 280;
+ return {
+ left: Math.min(
+ Math.max(requestedPosition.left, minimumPadding),
+ window.innerWidth - estimatedMenuWidth - minimumPadding,
+ ),
+ top: Math.min(
+ Math.max(requestedPosition.top, minimumPadding),
+ window.innerHeight - estimatedMenuHeight - minimumPadding,
+ ),
+ };
+};
+
+const buildContextMenuEntries = (
+ selectedElement: Element,
+): BuildContextMenuEntriesResult => {
+ const candidateElements: Element[] = [selectedElement];
+ const parentElement = selectedElement.parentElement;
+ if (isInspectableElement(parentElement)) {
+ candidateElements.push(parentElement);
+ }
+ const childElements = Array.from(selectedElement.children)
+ .filter((innerChildElement) => isInspectableElement(innerChildElement))
+ .slice(0, 8);
+ candidateElements.push(...childElements);
+
+ const deduplicatedElements: Element[] = [];
+ const seenElements = new Set();
+ for (const candidateElement of candidateElements) {
+ if (seenElements.has(candidateElement)) {
+ continue;
+ }
+ seenElements.add(candidateElement);
+ deduplicatedElements.push(candidateElement);
+ }
+
+ const getElementPathId = (element: Element): string => {
+ const pathSegments: string[] = [];
+ let currentElement: Element | null = element;
+ while (currentElement && pathSegments.length < 8) {
+ const currentParentElement: Element | null = currentElement.parentElement;
+ const siblingIndex = currentParentElement
+ ? Array.from(currentParentElement.children).indexOf(currentElement)
+ : 0;
+ pathSegments.unshift(`${currentElement.tagName.toLowerCase()}-${siblingIndex}`);
+ currentElement = currentParentElement;
+ }
+ return pathSegments.join('/');
+ };
+
+ const entryElementMap = new Map();
+ const entries = deduplicatedElements.map((innerElement) => {
+ const entryId = getElementPathId(innerElement);
+ entryElementMap.set(entryId, innerElement);
+ const elementSummary = getElementSummary(innerElement);
+ return {
+ entryId,
+ elementTag: elementSummary.elementTag,
+ previewText: elementSummary.previewText,
+ };
+ });
+ const activeEntryId = entryElementMap.size > 0
+ ? Array.from(entryElementMap.entries()).find(([, innerElement]) => innerElement === selectedElement)?.[0] ?? null
+ : null;
+ return {
+ activeEntryId,
+ entries,
+ entryElementMap,
+ };
+};
+
+export const Inspector = (): React.JSX.Element => {
+ const [activeEntryId, setActiveEntryId] = useState(null);
+ const [contextMenuEntries, setContextMenuEntries] = useState([]);
+ const [contextMenuPosition, setContextMenuPosition] = useState({
+ left: 0,
+ top: 0,
+ });
+ const [highlightRect, setHighlightRect] = useState(null);
+ const [isContextMenuVisible, setIsContextMenuVisible] = useState(false);
const [isEnabled, setIsEnabled] = useState(false);
+ const [selectedElement, setSelectedElement] = useState(null);
+ const activeEntryIdRef = useRef(activeEntryId);
+ const contextMenuEntriesRef = useRef(contextMenuEntries);
+ const entryElementMapRef = useRef