diff --git a/package/example/src/app/SideNav.tsx b/package/example/src/app/SideNav.tsx index db3d87ab..c5398165 100644 --- a/package/example/src/app/SideNav.tsx +++ b/package/example/src/app/SideNav.tsx @@ -391,6 +391,7 @@ export function SideNav() { { id: 'smart-identification', text: 'Smart Identification' }, { id: 'computed-styles', text: 'Computed Styles' }, { id: 'react-detection', text: 'React Detection' }, + { id: 'deep-select', text: 'Deep Select' }, { id: 'keyboard-shortcuts', text: 'Keyboard Shortcuts' }, { id: 'agent-sync', text: 'Agent Sync' }, { id: 'settings', text: 'Settings' }, diff --git a/package/example/src/app/components/DeepSelectDemo.css b/package/example/src/app/components/DeepSelectDemo.css new file mode 100644 index 00000000..bf26a7a3 --- /dev/null +++ b/package/example/src/app/components/DeepSelectDemo.css @@ -0,0 +1,236 @@ +/* ───────────────────────────────────────────────────────── + * Deep Select Demo — Landing page hero scenario + * Reuses shared demo-window / demo-browser-bar / demo-content + * from FeaturesDemo.css + * ───────────────────────────────────────────────────────── */ + +/* Mini landing page layout */ +.dsd-page { + display: flex; + flex-direction: column; + height: 100%; + background: white; +} + +/* Nav bar */ +.dsd-nav { + display: flex; + align-items: center; + padding: 10px 16px; + gap: 12px; + border-bottom: 1px solid rgba(0,0,0,0.04); +} + +.dsd-logo { + font-size: 11px; + font-weight: 700; + color: rgba(0,0,0,0.8); + font-family: system-ui, sans-serif; + letter-spacing: -0.3px; + display: flex; + align-items: center; + gap: 5px; +} + +.dsd-logo-mark { + width: 14px; + height: 14px; + background: linear-gradient(135deg, #6366f1, #3b82f6); + border-radius: 4px; +} + +.dsd-nav-links { + display: flex; + gap: 12px; + margin-left: auto; +} + +.dsd-nav-link { + font-size: 10px; + font-weight: 500; + color: rgba(0,0,0,0.45); + font-family: system-ui, sans-serif; +} + +/* Hero section — the animation wrapper covers this */ +.dsd-hero-wrapper { + position: relative; + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(180deg, #f8faff 0%, #f0f4ff 100%); +} + +/* Invisible animation overlay — the problem this feature solves */ +.dsd-overlay { + position: absolute; + inset: 0; + z-index: 2; + background: rgba(239, 68, 68, 0); + transition: background 0.3s ease; +} + +.dsd-overlay.flash { + background: rgba(239, 68, 68, 0.03); +} + +/* Wrapper label hint — shows motion.div boundary */ +.dsd-wrapper-label { + position: absolute; + top: 6px; + right: 8px; + font-size: 8px; + font-weight: 500; + font-family: 'SF Mono', SFMono-Regular, ui-monospace, monospace; + color: rgba(0,0,0,0.15); + z-index: 1; + pointer-events: none; + letter-spacing: 0.2px; +} + +/* Hero content — entrance animation mimics what motion.div actually does */ +.dsd-hero { + text-align: center; + padding: 0 24px; + opacity: 0; + transform: translateY(12px) scale(0.97); + transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1), + transform 0.5s cubic-bezier(0.16, 1, 0.3, 1); +} + +.dsd-hero.entered { + opacity: 1; + transform: translateY(0) scale(1); +} + +.dsd-heading { + font-size: 20px; + font-weight: 750; + color: rgba(0,0,0,0.88); + font-family: system-ui, sans-serif; + letter-spacing: -0.6px; + line-height: 1.15; + margin-bottom: 8px; +} + +.dsd-heading-accent { + background: linear-gradient(135deg, #6366f1, #3b82f6); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.dsd-subtitle { + font-size: 10.5px; + color: rgba(0,0,0,0.4); + font-family: system-ui, sans-serif; + line-height: 1.5; + margin-bottom: 16px; +} + +/* CTA button — the target element */ +.dsd-cta { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 8px 18px; + background: linear-gradient(135deg, #6366f1, #4f46e5); + color: white; + border-radius: 8px; + font-size: 10.5px; + font-weight: 600; + font-family: system-ui, sans-serif; + letter-spacing: -0.1px; + box-shadow: 0 1px 3px rgba(99, 102, 241, 0.3); +} + +.dsd-cta-arrow { + font-size: 11px; + opacity: 0.7; +} + +/* Social proof row */ +.dsd-social-proof { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + margin-top: 14px; +} + +.dsd-avatars { + display: flex; +} + +.dsd-mini-avatar { + width: 14px; + height: 14px; + border-radius: 50%; + border: 1.5px solid white; + margin-left: -4px; +} + +.dsd-mini-avatar:first-child { + margin-left: 0; +} + +.dsd-social-text { + font-size: 8.5px; + color: rgba(0,0,0,0.3); + font-family: system-ui, sans-serif; + font-weight: 500; +} + +/* Hover highlight */ +.ds-highlight { + position: absolute; + border-radius: 6px; + pointer-events: none; + z-index: 30; + opacity: 0; + transition: opacity 0.15s ease, border-color 0.15s ease; +} + +.ds-highlight.visible { + opacity: 1; +} + +.ds-highlight.normal { + border: 2px solid #3b82f6; +} + +.ds-highlight.pierce { + border: 2px dashed #3b82f6; +} + +/* Hover tooltip — shows element name */ +.ds-tooltip { + position: absolute; + padding: 3px 8px; + border-radius: 6px; + font-size: 10px; + font-weight: 500; + font-family: 'SF Mono', SFMono-Regular, ui-monospace, monospace; + white-space: nowrap; + pointer-events: none; + z-index: 35; + opacity: 0; + transform: translateY(2px); + transition: opacity 0.15s ease, transform 0.15s ease; +} + +.ds-tooltip.visible { + opacity: 1; + transform: translateY(0); +} + +.ds-tooltip.wrong { + background: rgba(0,0,0,0.75); + color: rgba(255,255,255,0.5); +} + +.ds-tooltip.correct { + background: rgba(0,0,0,0.85); + color: rgba(255,255,255,0.95); +} diff --git a/package/example/src/app/components/DeepSelectDemo.tsx b/package/example/src/app/components/DeepSelectDemo.tsx new file mode 100644 index 00000000..23723f90 --- /dev/null +++ b/package/example/src/app/components/DeepSelectDemo.tsx @@ -0,0 +1,449 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import "./FeaturesDemo.css"; +import "./DeepSelectDemo.css"; + +function delay(ms: number) { + return new Promise((r) => setTimeout(r, ms)); +} + +/* ───────────────────────────────────────────────────────── + * ANIMATION STORYBOARD + * + * 0ms reset — hero content hidden, cursor top-right + * 200ms hero entrance (fade up + scale — the motion.div animation) + * 800ms crosshair cursor moves toward CTA button + * 1200ms NORMAL: solid highlight on entire hero wrapper + * → tooltip: div.motion-container (wrong/dimmed) + * → overlay flash + * 2800ms highlight + tooltip fade out + * 3000ms caption: "Hold ⌘ to select through invisible layers." + * 4600ms PIERCE: dashed highlight on just the CTA button + * → tooltip: button "Get Started" (correct/bright) + * 6000ms click — popup appears, highlight/tooltip hide + * → cursor switches to pointer, moves to input area + * 6600ms typing feedback + * 7800ms cursor moves to Add button + * 8200ms click Add — popup closes, marker placed + * → cursor switches back to crosshair + *10200ms marker fades, loop + * ───────────────────────────────────────────────────────── */ + +const LOOP_INTERVAL = 11800; + +type CaptionKey = "idle" | "cmd" | "correct"; + +const CAPTIONS: Record = { + idle: "Animation wrappers intercept hover on the element you want.", + cmd: "Hold \u2318 to select through invisible layers.", + correct: "Deep select finds what\u2019s actually underneath.", +}; + +export function DeepSelectDemo() { + const [cursorPos, setCursorPos] = useState({ x: 280, y: 40 }); + const [activeCaption, setActiveCaption] = useState("idle"); + + const [highlight, setHighlight] = useState<{ + visible: boolean; + mode: "normal" | "pierce"; + rect: { x: number; y: number; w: number; h: number }; + }>({ visible: false, mode: "normal", rect: { x: 0, y: 0, w: 0, h: 0 } }); + + const [tooltip, setTooltip] = useState<{ + visible: boolean; + text: string; + type: "wrong" | "correct"; + x: number; + y: number; + }>({ visible: false, text: "", type: "wrong", x: 0, y: 0 }); + + const [showPopup, setShowPopup] = useState(false); + const [typedText, setTypedText] = useState(""); + const [showMarker, setShowMarker] = useState(false); + const [overlayFlash, setOverlayFlash] = useState(false); + const [heroEntered, setHeroEntered] = useState(false); + const [isCrosshair, setIsCrosshair] = useState(true); + + const wrapperRef = useRef(null); + const ctaRef = useRef(null); + const contentRef = useRef(null); + const addBtnRef = useRef(null); + const addBtnPosRef = useRef({ x: 0, y: 0 }); + + const wrapperPosRef = useRef({ x: 0, y: 0, w: 0, h: 0 }); + const ctaPosRef = useRef({ x: 0, y: 0, w: 0, h: 0 }); + + const measure = () => { + if (!wrapperRef.current || !ctaRef.current || !contentRef.current) return; + const cRect = contentRef.current.getBoundingClientRect(); + const wRect = wrapperRef.current.getBoundingClientRect(); + const bRect = ctaRef.current.getBoundingClientRect(); + wrapperPosRef.current = { + x: wRect.left - cRect.left, + y: wRect.top - cRect.top, + w: wRect.width, + h: wRect.height, + }; + ctaPosRef.current = { + x: bRect.left - cRect.left, + y: bRect.top - cRect.top, + w: bRect.width, + h: bRect.height, + }; + }; + + useEffect(() => { + const timer = setTimeout(measure, 100); + window.addEventListener("resize", measure); + return () => { + clearTimeout(timer); + window.removeEventListener("resize", measure); + }; + }, []); + + useEffect(() => { + let cancelled = false; + const feedbackText = "Add hover state"; + + const run = async () => { + // Reset + setCursorPos({ x: 280, y: 40 }); + setHighlight({ visible: false, mode: "normal", rect: { x: 0, y: 0, w: 0, h: 0 } }); + setTooltip({ visible: false, text: "", type: "wrong", x: 0, y: 0 }); + setShowPopup(false); + setTypedText(""); + setShowMarker(false); + setOverlayFlash(false); + setHeroEntered(false); + setIsCrosshair(true); + setActiveCaption("idle"); + + await delay(200); + if (cancelled) return; + + // Hero entrance — the motion.div animation + setHeroEntered(true); + await delay(600); + if (cancelled) return; + + // Re-measure after entrance (content is now in final position) + measure(); + const wrapper = wrapperPosRef.current; + const cta = ctaPosRef.current; + setCursorPos({ x: cta.x + cta.w / 2 - 8.5, y: cta.y + cta.h / 2 - 8.5 }); + await delay(400); + if (cancelled) return; + + // Normal hover — highlights the entire hero wrapper (animation container intercepts) + setOverlayFlash(true); + setHighlight({ + visible: true, + mode: "normal", + rect: { x: wrapper.x - 3, y: wrapper.y - 3, w: wrapper.w + 6, h: wrapper.h + 6 }, + }); + setTooltip({ + visible: true, + text: "div.motion-container", + type: "wrong", + x: wrapper.x + wrapper.w / 2, + y: wrapper.y - 10, + }); + await delay(1600); + if (cancelled) return; + + // Fade out + setHighlight((h) => ({ ...h, visible: false })); + setTooltip((t) => ({ ...t, visible: false })); + setOverlayFlash(false); + await delay(400); + if (cancelled) return; + + // ⌘ beat + setActiveCaption("cmd"); + await delay(1600); + if (cancelled) return; + + // Pierce hover — highlights just the CTA button + setActiveCaption("correct"); + setHighlight({ + visible: true, + mode: "pierce", + rect: { x: cta.x - 3, y: cta.y - 3, w: cta.w + 6, h: cta.h + 6 }, + }); + setTooltip({ + visible: true, + text: 'button "Get Started"', + type: "correct", + x: cta.x + cta.w / 2, + y: cta.y - 10, + }); + await delay(1400); + if (cancelled) return; + + // Click — show popup, hide highlight/tooltip + setShowPopup(true); + setHighlight((h) => ({ ...h, visible: false })); + setTooltip((t) => ({ ...t, visible: false })); + await delay(300); + if (cancelled) return; + + // Switch to pointer cursor, move to input area + setIsCrosshair(false); + setCursorPos({ x: 280, y: 100 }); + await delay(300); + if (cancelled) return; + + // Type feedback + for (let i = 0; i <= feedbackText.length; i++) { + if (cancelled) return; + setTypedText(feedbackText.slice(0, i)); + await delay(35); + } + await delay(400); + if (cancelled) return; + + // Move cursor to Add button + if (addBtnRef.current && contentRef.current) { + const abr = addBtnRef.current.getBoundingClientRect(); + const cr = contentRef.current.getBoundingClientRect(); + addBtnPosRef.current = { x: abr.left - cr.left + abr.width / 2, y: abr.top - cr.top + abr.height / 2 }; + } + setCursorPos({ x: addBtnPosRef.current.x, y: addBtnPosRef.current.y }); + await delay(400); + if (cancelled) return; + + // Click Add — close popup, show marker, switch back to crosshair + setShowPopup(false); + setIsCrosshair(true); + await delay(200); + if (cancelled) return; + setShowMarker(true); + + await delay(2000); + if (cancelled) return; + + // Clean up for next loop + setShowMarker(false); + setHighlight((h) => ({ ...h, visible: false })); + await delay(300); + }; + + run(); + let interval = setInterval(run, LOOP_INTERVAL); + + const handleVisibility = () => { + if (document.visibilityState === "visible") { + cancelled = true; + clearInterval(interval); + setTimeout(() => { + cancelled = false; + run(); + interval = setInterval(run, LOOP_INTERVAL); + }, 100); + } + }; + document.addEventListener("visibilitychange", handleVisibility); + + return () => { + cancelled = true; + clearInterval(interval); + document.removeEventListener("visibilitychange", handleVisibility); + }; + }, []); + + return ( +
+
+
+
+
+
+
localhost:3000
+
+ +
+
+ {/* Nav */} +
+
+
+ Acme +
+
+ Features + Pricing +
+
+ + {/* Hero wrapper — the animation container */} +
+ {/* Invisible animation overlay — the problem */} +
+
<motion.div>
+ +
+
+ Ship faster with +
+ better feedback +
+
+ The modern way to collect design annotations. +
+
+ Get Started +
+
+
+
+
+
+
+
+ Trusted by 500+ teams +
+
+
+
+ + {/* Highlight */} +
+ + {/* Tooltip */} +
+ {tooltip.text} +
+ + {/* Popup */} +
+
button "Get Started"
+
+ {typedText}| +
+
+
Cancel
+
Add
+
+
+ + {/* Marker */} +
+ 1 +
+ + {/* Cursor — dual mode like Computed Styles demo */} +
+
+ + + + + + +
+
+ + + + +
+
+ + {/* Toolbar */} +
+
+ + + + + +
+ +
+
+
+
+ + {/* Caption */} +

+ {CAPTIONS[activeCaption]} +

+
+ ); +} + +/* ───────────────────────────────────────────────────────── + * Shared toolbar icon — same as FeaturesDemo + * ───────────────────────────────────────────────────────── */ + +function ToolbarIcon({ icon, disabled }: { icon: string; disabled?: boolean }) { + const disabledStyle = disabled ? { opacity: 0.35 } : undefined; + + const icons: Record = { + pause: ( + + + + + ), + eye: ( + + + + + ), + copy: ( + + + + + ), + trash: ( + + + + + + + + ), + settings: ( + + + + + ), + close: ( + + + + + ), + }; + + return ( +
+
+ {icons[icon]} +
+
+ ); +} diff --git a/package/example/src/app/features/page.tsx b/package/example/src/app/features/page.tsx index 0996afff..a7301e9e 100644 --- a/package/example/src/app/features/page.tsx +++ b/package/example/src/app/features/page.tsx @@ -2,6 +2,7 @@ import { Footer } from "../Footer"; import { FeaturesDemo, SettingsDemo, SmartIdentificationDemo, MarkerKeyDemo, ComputedStylesDemo, ReactDetectionDemo, AgentChatDemo } from "../components/FeaturesDemo"; +import { DeepSelectDemo } from "../components/DeepSelectDemo"; export default function FeaturesPage() { return ( @@ -82,6 +83,15 @@ export default function FeaturesPage() { +
+

Deep select

+

+ Hold while hovering to pierce through invisible wrappers and select the actual element underneath. + Useful when gesture libraries, animation frameworks, or overlay patterns render empty divs on top of your content. +

+ +
+

Keyboard shortcuts

@@ -107,8 +117,12 @@ export default function FeaturesPage() { - - + + + + + +
Copy feedback
XClear all annotationsXClear all annotations
/ Ctrl + hoverDeep select (pierce overlays)
diff --git a/package/src/components/page-toolbar-css/index.tsx b/package/src/components/page-toolbar-css/index.tsx index d49d2c71..779f1758 100644 --- a/package/src/components/page-toolbar-css/index.tsx +++ b/package/src/components/page-toolbar-css/index.tsx @@ -137,6 +137,7 @@ type HoverInfo = { elementPath: string; rect: DOMRect | null; reactComponents?: string | null; + isPiercing?: boolean; }; type OutputDetailLevel = "compact" | "standard" | "detailed" | "forensic"; @@ -246,22 +247,113 @@ injectAgentationColorTokens(); // ============================================================================= /** - * Recursively pierces shadow DOMs to find the deepest element at a point. - * document.elementFromPoint() stops at shadow hosts, so we need to - * recursively check inside open shadow roots to find the actual target. + * Pierce through shadow DOMs to find the actual element. + */ +function pierceShadowDOM( + element: HTMLElement, + x: number, + y: number, +): HTMLElement { + let el = element; + while (el?.shadowRoot) { + const deeper = el.shadowRoot.elementFromPoint(x, y) as HTMLElement | null; + if (!deeper || deeper === el) break; + el = deeper; + } + return el; +} + +/** + * Finds the deepest element at a point, piercing shadow DOMs. */ function deepElementFromPoint(x: number, y: number): HTMLElement | null { - let element = document.elementFromPoint(x, y) as HTMLElement | null; + const element = document.elementFromPoint(x, y) as HTMLElement | null; if (!element) return null; + return pierceShadowDOM(element, x, y); +} - // Keep drilling down through shadow roots - while (element?.shadowRoot) { - const deeper = element.shadowRoot.elementFromPoint(x, y) as HTMLElement | null; - if (!deeper || deeper === element) break; - element = deeper; +// ============================================================================= +// Pierce mode (Cmd+hover) — scans through container wrappers and invisible +// elements to find the actual content underneath. Useful for annotation in +// animation-heavy frameworks (Remotion, Framer Motion, etc.) where empty +// overlay divs intercept pointer events. +// ============================================================================= + +const GENERIC_CONTAINER_TAGS = new Set([ + "DIV", "SPAN", "SECTION", "ARTICLE", "MAIN", "ASIDE", "HEADER", "FOOTER", "NAV", +]); + +function isEffectivelyInvisible(el: HTMLElement): boolean { + if (typeof el.checkVisibility === "function") { + return !el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }); + } + let current: HTMLElement | null = el; + while (current && current !== document.body) { + if (window.getComputedStyle(current).opacity === "0") return true; + current = current.parentElement; + } + return false; +} + +function hasDirectContent(el: HTMLElement): boolean { + if (!GENERIC_CONTAINER_TAGS.has(el.tagName)) return true; + for (const child of el.childNodes) { + if (child.nodeType === Node.TEXT_NODE && child.textContent?.trim()) return true; + } + return false; +} + +/** + * Pierce mode: scans all elements at a point to find the deepest one with + * visible, meaningful content. Two-pass approach: + * 1. Find elements with direct text content (skips empty wrappers) + * 2. If no text found, return the smallest visible element (catches + * visual-only elements like timeline bars, chart segments, swatches) + * Activated by holding Cmd (Mac) / Ctrl (Windows) while hovering. + */ +function pierceElementFromPoint(x: number, y: number): HTMLElement | null { + const topElement = document.elementFromPoint(x, y) as HTMLElement | null; + if (!topElement) return null; + + const pierced = pierceShadowDOM(topElement, x, y); + if (hasDirectContent(pierced) && !isEffectivelyInvisible(pierced)) + return pierced; + + const allElements = document.elementsFromPoint(x, y) as HTMLElement[]; + + // Pass 1: find first element with direct text content + for (const candidate of allElements) { + if (candidate === topElement) continue; + if (candidate === document.documentElement || candidate === document.body) + continue; + const deep = pierceShadowDOM(candidate, x, y); + if (hasDirectContent(deep) && !isEffectivelyInvisible(deep)) + return deep; + } + + // Pass 2: no text content found — return the smallest visible element. + // Catches visual-only elements (timeline bars, color swatches, progress + // indicators, chart segments) that communicate through color/size, not text. + const topRect = pierced.getBoundingClientRect(); + const topArea = topRect.width * topRect.height; + let smallest: HTMLElement | null = null; + let smallestArea = topArea; + + for (const candidate of allElements) { + if (candidate === topElement) continue; + if (candidate === document.documentElement || candidate === document.body) + continue; + if (isEffectivelyInvisible(candidate)) continue; + const deep = pierceShadowDOM(candidate, x, y); + const rect = deep.getBoundingClientRect(); + const area = rect.width * rect.height; + if (area > 0 && area < smallestArea) { + smallest = deep; + smallestArea = area; + } } - return element; + return smallest || pierced; } function isElementFixed(element: HTMLElement): boolean { @@ -1529,19 +1621,16 @@ const [settings, setSettings] = useState(() => { }; }, [isActive]); - // Handle mouse move + // Handle mouse move + instant Cmd/Ctrl re-evaluation via keydown/keyup useEffect(() => { if (!isActive || pendingAnnotation) return; - const handleMouseMove = (e: MouseEvent) => { - // Use composedPath to get actual target inside shadow DOM - const target = (e.composedPath()[0] || e.target) as HTMLElement; - if (closestCrossingShadow(target, "[data-feedback-toolbar]")) { - setHoverInfo(null); - return; - } + const lastMouse = { x: 0, y: 0, hasMoved: false }; - const elementUnder = deepElementFromPoint(e.clientX, e.clientY); + const evaluateHover = (clientX: number, clientY: number, piercing: boolean) => { + const elementUnder = piercing + ? pierceElementFromPoint(clientX, clientY) + : deepElementFromPoint(clientX, clientY); if ( !elementUnder || closestCrossingShadow(elementUnder, "[data-feedback-toolbar]") @@ -1550,6 +1639,15 @@ const [settings, setSettings] = useState(() => { return; } + // When piercing, suppress indicator if result is same as normal hover + let effectivePiercing = piercing; + if (piercing) { + const normalElement = deepElementFromPoint(clientX, clientY); + if (normalElement === elementUnder) { + effectivePiercing = false; + } + } + const { name, elementName, path, reactComponents } = identifyElementWithReact(elementUnder, effectiveReactMode); const rect = elementUnder.getBoundingClientRect(); @@ -1560,12 +1658,39 @@ const [settings, setSettings] = useState(() => { elementPath: path, rect, reactComponents, + isPiercing: effectivePiercing, }); - setHoverPosition({ x: e.clientX, y: e.clientY }); + setHoverPosition({ x: clientX, y: clientY }); + }; + + const handleMouseMove = (e: MouseEvent) => { + // Use composedPath to get actual target inside shadow DOM + const target = (e.composedPath()[0] || e.target) as HTMLElement; + if (closestCrossingShadow(target, "[data-feedback-toolbar]")) { + setHoverInfo(null); + return; + } + lastMouse.x = e.clientX; + lastMouse.y = e.clientY; + lastMouse.hasMoved = true; + evaluateHover(e.clientX, e.clientY, e.metaKey || e.ctrlKey); + }; + + // Re-evaluate immediately when Cmd/Ctrl is pressed or released + const handleKeyChange = (e: KeyboardEvent) => { + if ((e.key === "Meta" || e.key === "Control") && lastMouse.hasMoved) { + evaluateHover(lastMouse.x, lastMouse.y, e.metaKey || e.ctrlKey); + } }; document.addEventListener("mousemove", handleMouseMove); - return () => document.removeEventListener("mousemove", handleMouseMove); + document.addEventListener("keydown", handleKeyChange); + document.addEventListener("keyup", handleKeyChange); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("keydown", handleKeyChange); + document.removeEventListener("keyup", handleKeyChange); + }; }, [isActive, pendingAnnotation, effectiveReactMode]); // Handle click @@ -1586,11 +1711,12 @@ const [settings, setSettings] = useState(() => { if (closestCrossingShadow(target, "[data-annotation-marker]")) return; // Handle cmd+shift+click for multi-element selection + // Cmd is held so pierce mode is active — use pierceElementFromPoint if (e.metaKey && e.shiftKey && !pendingAnnotation && !editingAnnotation) { e.preventDefault(); e.stopPropagation(); - const elementUnder = deepElementFromPoint(e.clientX, e.clientY); + const elementUnder = pierceElementFromPoint(e.clientX, e.clientY); if (!elementUnder) return; const rect = elementUnder.getBoundingClientRect(); @@ -1657,7 +1783,11 @@ const [settings, setSettings] = useState(() => { e.preventDefault(); - const elementUnder = deepElementFromPoint(e.clientX, e.clientY); + // Cmd (Mac) / Ctrl (Win) without Shift = pierce mode click + const piercing = (e.metaKey || e.ctrlKey) && !e.shiftKey; + const elementUnder = piercing + ? pierceElementFromPoint(e.clientX, e.clientY) + : deepElementFromPoint(e.clientX, e.clientY); if (!elementUnder) return; const { name, path, reactComponents } = identifyElementWithReact( @@ -3886,6 +4016,7 @@ const [settings, setSettings] = useState(() => { height: hoverInfo.rect.height, borderColor: "color-mix(in srgb, var(--agentation-color-accent) 50%, transparent)", backgroundColor: "color-mix(in srgb, var(--agentation-color-accent) 4%, transparent)", + ...(hoverInfo.isPiercing ? { borderStyle: "dashed" } : {}), }} /> )} diff --git a/package/src/utils/element-identification.ts b/package/src/utils/element-identification.ts index f86eb97e..e505ce8b 100644 --- a/package/src/utils/element-identification.ts +++ b/package/src/utils/element-identification.ts @@ -97,6 +97,21 @@ export function getElementPath(target: HTMLElement, maxDepth = 4): string { return parts.join(" > "); } +/** + * Gets concatenated direct text node content from an element. + * Only includes immediate text nodes, not text from child elements. + */ +function getDirectTextContent(el: HTMLElement): string { + let text = ""; + for (const child of el.childNodes) { + if (child.nodeType === Node.TEXT_NODE) { + const t = child.textContent?.trim(); + if (t) text += (text ? " " : "") + t; + } + } + return text; +} + /** * Identifies an element and returns a human-readable name + path */ @@ -200,6 +215,12 @@ export function identifyElement(target: HTMLElement): { name: string; path: stri if (ariaLabel) return { name: `${tag} [${ariaLabel}]`, path }; if (role) return { name: `${role}`, path }; + // Prefer direct text content over class names — "$54" is more useful than "styles productPrice" + const directText = getDirectTextContent(target); + if (directText && directText.length < 50) { + return { name: `"${directText}"`, path }; + } + if (typeof className === "string" && className) { const words = className .split(/[\s_-]+/) diff --git a/package/src/utils/react-detection.ts b/package/src/utils/react-detection.ts index 8068621d..984f84f2 100644 --- a/package/src/utils/react-detection.ts +++ b/package/src/utils/react-detection.ts @@ -87,6 +87,9 @@ export const DEFAULT_SKIP_EXACT = new Set([ "ErrorBoundaryHandler", "HotReload", "Hot", + // Video framework internals + "SeriesSequence", + "AbsoluteFill", ]); /** @@ -114,6 +117,11 @@ export const DEFAULT_SKIP_PATTERNS: RegExp[] = [ /^With[A-Z]/, // withRouter, WithAuth (HOCs) /Wrapper$/, // Generic wrappers /^Root$/, // Generic Root component + // React internal wrappers + /RefForwarding/, // React.forwardRef wrapper names + // Video framework internals (Remotion, etc.) + /^Remotion/, // Remotion-prefixed internals + /^TransitionSeries/, // TransitionSeries wrappers ]; /**