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) {