From 857e79d56fd17bacb839bcebd87900dd106455bd Mon Sep 17 00:00:00 2001 From: James Cowan Date: Thu, 30 Apr 2026 17:23:37 -0300 Subject: [PATCH] feat: simplify multi-select modifiers --- package/README.md | 2 +- package/example/src/app/changelog/page.tsx | 4 +- .../src/app/components/FeaturesDemo.tsx | 2 +- .../src/components/page-toolbar-css/index.tsx | 235 +++++++++++------- 4 files changed, 150 insertions(+), 93 deletions(-) diff --git a/package/README.md b/package/README.md index 617e2971..9797e1ef 100644 --- a/package/README.md +++ b/package/README.md @@ -37,7 +37,7 @@ The toolbar appears in the bottom-right corner. Click to activate, then click an - **Click to annotate** – Click any element with automatic selector identification - **Text selection** – Select text to annotate specific content -- **Multi-select** – Drag to select multiple elements at once +- **Multi-select** – Hold Cmd/Ctrl to build a selection by clicking elements individually, or drag to select multiple at once - **Area selection** – Drag to annotate any region, even empty space - **Animation pause** – Freeze all animations (CSS, JS, videos) to capture specific states - **Structured output** – Copy markdown with selectors, positions, and context diff --git a/package/example/src/app/changelog/page.tsx b/package/example/src/app/changelog/page.tsx index f3df35cb..495b4fad 100644 --- a/package/example/src/app/changelog/page.tsx +++ b/package/example/src/app/changelog/page.tsx @@ -146,10 +146,10 @@ const releases: Release[] = [ { type: "added", text: <>React component detection — shows full component hierarchy on hover, not just DOM elements }, { type: "added", text: <>Shadow DOM support — annotate elements inside modals, web components, and design systems that use shadow DOM }, { type: "added", text: "Toolbar position persists in localStorage — drag it once, it stays where you put it" }, - { type: "added", text: <>Cmd+Shift+Click multi-element selection — hold + and click elements to select multiple individually, release to annotate the group }, + { type: "added", text: <>Modifier-click multi-element selection — hold or Ctrl and click elements to select multiple individually, release to annotate the group }, { type: "improved", text: "Component detection adapts to output detail level (Compact, Standard, Detailed, Forensic)" }, { type: "improved", text: "Cursor styles in settings panel — I-beam for text inputs, pointer for clickable items" }, - { type: "improved", text: "Individual element highlights on hover — cmd+shift multi-select annotations show each element separately, not one combined box" }, + { type: "improved", text: "Individual element highlights on hover — modifier-click multi-select annotations show each element separately, not one combined box" }, { type: "fixed", text: "Fixed/sticky element positioning — annotations on fixed navs and sticky headers now position correctly regardless of scroll" }, { type: "improved", text: "\"Block page interactions\" now enabled by default — prevents accidental clicks while annotating (can be toggled off in settings)" }, { type: "fixed", text: "SVG icons broken by host page fill styles — now uses attribute selectors to avoid conflicts" }, diff --git a/package/example/src/app/components/FeaturesDemo.tsx b/package/example/src/app/components/FeaturesDemo.tsx index da8d6c78..34819236 100644 --- a/package/example/src/app/components/FeaturesDemo.tsx +++ b/package/example/src/app/components/FeaturesDemo.tsx @@ -25,7 +25,7 @@ const features: Feature[] = [ { key: "multi-select", label: "Multi-Select", - caption: "Hold `⌘`+`⇧` and click elements individually, or drag to select multiple at once.\nAll selected elements are included in a single annotation.", + caption: "Hold `⌘` or `Ctrl` and click elements individually, or keep the modifier held while dragging to add a group.\nRelease the modifier to annotate the selection.", }, { key: "area-selection", diff --git a/package/src/components/page-toolbar-css/index.tsx b/package/src/components/page-toolbar-css/index.tsx index 42df435a..4791879a 100644 --- a/package/src/components/page-toolbar-css/index.tsx +++ b/package/src/components/page-toolbar-css/index.tsx @@ -139,6 +139,14 @@ type HoverInfo = { reactComponents?: string | null; }; +type PendingMultiSelectElement = { + element: HTMLElement; + rect: DOMRect; + name: string; + path: string; + reactComponents?: string; +}; + export type OutputDetailLevel = "compact" | "standard" | "detailed" | "forensic"; // ReactComponentMode is now derived from outputDetail when reactEnabled is true export type ReactComponentMode = "smart" | "filtered" | "all" | "off"; @@ -185,6 +193,11 @@ const OUTPUT_TO_REACT_MODE: Record = { forensic: "all", }; +const isPrimaryMultiSelectModifierActive = (event: { + metaKey: boolean; + ctrlKey: boolean; +}): boolean => event.metaKey || event.ctrlKey; + export const COLOR_OPTIONS = [ { id: "indigo", label: "Indigo", srgb: "#6155F5", p3: "color(display-p3 0.38 0.33 0.96)" }, { id: "blue", label: "Blue", srgb: "#0088FF", p3: "color(display-p3 0.00 0.53 1.00)" }, @@ -396,7 +409,7 @@ export function PageFeedbackToolbarCSS({ width: number; height: number; }>; - // Element references for cmd+shift+click multi-select (for live position queries) + // Element references for modifier-click multi-select (for live position queries) multiSelectElements?: HTMLElement[]; // Element reference for single-select (for live position queries) targetElement?: HTMLElement; @@ -412,7 +425,7 @@ export function PageFeedbackToolbarCSS({ useState(null); const [hoveredTargetElements, setHoveredTargetElements] = useState< HTMLElement[] - >([]); // For cmd+shift+click multi-select hover + >([]); // For modifier-click multi-select hover const [deletingMarkerId, setDeletingMarkerId] = useState(null); const [renumberFrom, setRenumberFrom] = useState(null); const [editingAnnotation, setEditingAnnotation] = useState( @@ -422,7 +435,7 @@ export function PageFeedbackToolbarCSS({ useState(null); const [editingTargetElements, setEditingTargetElements] = useState< HTMLElement[] - >([]); // For cmd+shift+click multi-select + >([]); // For modifier-click multi-select const [scrollY, setScrollY] = useState(0); const [isScrolling, setIsScrolling] = useState(false); const [mounted, setMounted] = useState(false); @@ -502,17 +515,11 @@ export function PageFeedbackToolbarCSS({ null, ); - // Cmd+shift+click multi-select state + // Primary-modifier multi-select state const [pendingMultiSelectElements, setPendingMultiSelectElements] = useState< - Array<{ - element: HTMLElement; - rect: DOMRect; - name: string; - path: string; - reactComponents?: string; - }> + PendingMultiSelectElement[] >([]); - const modifiersHeldRef = useRef({ cmd: false, shift: false }); + const multiSelectModifiersHeldRef = useRef({ meta: false, ctrl: false }); // Hide tooltips after button click until mouse leaves const hideTooltipsUntilMouseLeave = () => { @@ -1660,7 +1667,25 @@ const [settings, setSettings] = useState(() => { } }, [isFrozen, freezeAnimations, unfreezeAnimations]); - // Create pending annotation from cmd+shift+click multi-select + const appendPendingMultiSelectElements = useCallback( + (elements: PendingMultiSelectElement[]) => { + if (elements.length === 0) return; + + setPendingMultiSelectElements((prev) => { + const next = [...prev]; + for (const item of elements) { + if (next.some((existing) => existing.element === item.element)) { + continue; + } + next.push(item); + } + return next; + }); + }, + [], + ); + + // Create pending annotation from modifier-click multi-select const createMultiSelectPendingAnnotation = useCallback(() => { if (pendingMultiSelectElements.length === 0) return; @@ -1776,7 +1801,7 @@ const [settings, setSettings] = useState(() => { setHoverInfo(null); setShowSettings(false); // Close settings when toolbar closes setPendingMultiSelectElements([]); // Clear multi-select - modifiersHeldRef.current = { cmd: false, shift: false }; // Reset modifier tracking + multiSelectModifiersHeldRef.current = { meta: false, ctrl: false }; // Reset modifier tracking if (isFrozen) { unfreezeAnimations(); } @@ -1940,8 +1965,12 @@ const [settings, setSettings] = useState(() => { if (closestCrossingShadow(target, "[data-annotation-popup]")) return; if (closestCrossingShadow(target, "[data-annotation-marker]")) return; - // Handle cmd+shift+click for multi-element selection - if (e.metaKey && e.shiftKey && !pendingAnnotation && !editingAnnotation) { + // Handle modifier-click for multi-element selection + if ( + isPrimaryMultiSelectModifierActive(e) && + !pendingAnnotation && + !editingAnnotation + ) { e.preventDefault(); e.stopPropagation(); @@ -2077,38 +2106,36 @@ const [settings, setSettings] = useState(() => { pendingMultiSelectElements, ]); - // Cmd+shift+click multi-select: keyup listener for modifier release + // Modifier-click multi-select: keyup listener for modifier release useEffect(() => { if (!isActive) return; const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Meta") modifiersHeldRef.current.cmd = true; - if (e.key === "Shift") modifiersHeldRef.current.shift = true; + if (e.key === "Meta") multiSelectModifiersHeldRef.current.meta = true; + if (e.key === "Control") multiSelectModifiersHeldRef.current.ctrl = true; }; const handleKeyUp = (e: KeyboardEvent) => { - const wasHoldingBoth = - modifiersHeldRef.current.cmd && modifiersHeldRef.current.shift; + const wasHoldingModifier = + multiSelectModifiersHeldRef.current.meta || + multiSelectModifiersHeldRef.current.ctrl; - if (e.key === "Meta") modifiersHeldRef.current.cmd = false; - if (e.key === "Shift") modifiersHeldRef.current.shift = false; + if (e.key === "Meta") multiSelectModifiersHeldRef.current.meta = false; + if (e.key === "Control") multiSelectModifiersHeldRef.current.ctrl = false; - const nowHoldingBoth = - modifiersHeldRef.current.cmd && modifiersHeldRef.current.shift; + const nowHoldingModifier = + multiSelectModifiersHeldRef.current.meta || + multiSelectModifiersHeldRef.current.ctrl; // Released modifier while holding elements → trigger popup - if ( - wasHoldingBoth && - !nowHoldingBoth && - pendingMultiSelectElements.length > 0 - ) { + if (wasHoldingModifier && !nowHoldingModifier && pendingMultiSelectElements.length > 0) { createMultiSelectPendingAnnotation(); } }; // Reset modifier state AND clear selection when window loses focus (e.g., cmd+tab away) const handleBlur = () => { - modifiersHeldRef.current = { cmd: false, shift: false }; + multiSelectModifiersHeldRef.current = { meta: false, ctrl: false }; setPendingMultiSelectElements([]); }; @@ -2175,7 +2202,10 @@ const [settings, setSettings] = useState(() => { "SUP", ]); - if (textTags.has(target.tagName) || target.isContentEditable) { + if ( + !isPrimaryMultiSelectModifierActive(e) && + (textTags.has(target.tagName) || target.isContentEditable) + ) { return; } @@ -2457,63 +2487,83 @@ const [settings, setSettings] = useState(() => { const x = (e.clientX / window.innerWidth) * 100; const y = e.clientY + window.scrollY; + const shouldAccumulateMultiSelect = + isPrimaryMultiSelectModifierActive(e) && + !pendingAnnotation && + !editingAnnotation; if (finalElements.length > 0) { - const bounds = finalElements.reduce( - (acc, { rect }) => ({ - left: Math.min(acc.left, rect.left), - top: Math.min(acc.top, rect.top), - right: Math.max(acc.right, rect.right), - bottom: Math.max(acc.bottom, rect.bottom), - }), - { - left: Infinity, - top: Infinity, - right: -Infinity, - bottom: -Infinity, - }, - ); + if (shouldAccumulateMultiSelect) { + appendPendingMultiSelectElements( + finalElements.map(({ element, rect }) => { + const { name, path, reactComponents } = + identifyElementWithReact(element, effectiveReactMode); + return { + element, + rect, + name, + path, + reactComponents: reactComponents ?? undefined, + }; + }), + ); + } else { + const bounds = finalElements.reduce( + (acc, { rect }) => ({ + left: Math.min(acc.left, rect.left), + top: Math.min(acc.top, rect.top), + right: Math.max(acc.right, rect.right), + bottom: Math.max(acc.bottom, rect.bottom), + }), + { + left: Infinity, + top: Infinity, + right: -Infinity, + bottom: -Infinity, + }, + ); - const elementNames = finalElements - .slice(0, 5) - .map(({ element }) => identifyElement(element).name) - .join(", "); - const suffix = - finalElements.length > 5 - ? ` +${finalElements.length - 5} more` - : ""; - - // Capture computed styles from first element - filtered for popup, full for forensic output - const firstElement = finalElements[0].element; - const firstElementComputedStyles = - getDetailedComputedStyles(firstElement); - const firstElementComputedStylesStr = - getForensicComputedStyles(firstElement); - - setPendingAnnotation({ - x, - y, - clientY: e.clientY, - element: `${finalElements.length} elements: ${elementNames}${suffix}`, - elementPath: "multi-select", - boundingBox: { - x: bounds.left, - y: bounds.top + window.scrollY, - width: bounds.right - bounds.left, - height: bounds.bottom - bounds.top, - }, - isMultiSelect: true, - // Forensic data from first element - fullPath: getFullElementPath(firstElement), - accessibility: getAccessibilityInfo(firstElement), - computedStyles: firstElementComputedStylesStr, - computedStylesObj: firstElementComputedStyles, - nearbyElements: getNearbyElements(firstElement), - cssClasses: getElementClasses(firstElement), - nearbyText: getNearbyText(firstElement), - sourceFile: detectSourceFile(firstElement), - }); - } else { + const elementNames = finalElements + .slice(0, 5) + .map(({ element }) => identifyElement(element).name) + .join(", "); + const suffix = + finalElements.length > 5 + ? ` +${finalElements.length - 5} more` + : ""; + + // Capture computed styles from first element - filtered for popup, full for forensic output + const firstElement = finalElements[0].element; + const firstElementComputedStyles = + getDetailedComputedStyles(firstElement); + const firstElementComputedStylesStr = + getForensicComputedStyles(firstElement); + + setPendingAnnotation({ + x, + y, + clientY: e.clientY, + element: `${finalElements.length} elements: ${elementNames}${suffix}`, + elementPath: "multi-select", + boundingBox: { + x: bounds.left, + y: bounds.top + window.scrollY, + width: bounds.right - bounds.left, + height: bounds.bottom - bounds.top, + }, + isMultiSelect: true, + // Forensic data from first element + fullPath: getFullElementPath(firstElement), + accessibility: getAccessibilityInfo(firstElement), + computedStyles: firstElementComputedStylesStr, + computedStylesObj: firstElementComputedStyles, + nearbyElements: getNearbyElements(firstElement), + cssClasses: getElementClasses(firstElement), + nearbyText: getNearbyText(firstElement), + sourceFile: detectSourceFile(firstElement), + }); + } + } else if (!shouldAccumulateMultiSelect) { // No elements selected, but allow annotation on empty area const width = Math.abs(right - left); const height = Math.abs(bottom - top); @@ -2552,7 +2602,14 @@ const [settings, setSettings] = useState(() => { document.addEventListener("mouseup", handleMouseUp); return () => document.removeEventListener("mouseup", handleMouseUp); - }, [isActive, isDragging]); + }, [ + isActive, + isDragging, + pendingAnnotation, + editingAnnotation, + effectiveReactMode, + appendPendingMultiSelectElements, + ]); // Fire webhook for annotation events - returns true on success, false on failure const fireWebhook = useCallback( @@ -4294,7 +4351,7 @@ const [settings, setSettings] = useState(() => { /> )} - {/* Cmd+shift+click multi-select highlights (during selection, before releasing modifiers) */} + {/* Modifier-click multi-select highlights (during selection, before releasing modifiers) */} {pendingMultiSelectElements .filter((item) => document.contains(item.element)) .map((item, index) => { @@ -4335,7 +4392,7 @@ const [settings, setSettings] = useState(() => { ); if (!hoveredAnnotation?.boundingBox) return null; - // Render individual element boxes if available (cmd+shift+click multi-select) + // Render individual element boxes if available (modifier-click multi-select) if (hoveredAnnotation.elementBoundingBoxes?.length) { // Use live positions from hoveredTargetElements when available if (hoveredTargetElements.length > 0) {