Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package/example/src/app/changelog/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,10 @@ const releases: Release[] = [
{ type: "added", text: <><a href="/features#react-detection" className="styled-link">React component detection</a> — 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 <code>⌘</code>+<code></code> and click elements to select multiple individually, release to annotate the group</> },
{ type: "added", text: <>Modifier-click multi-element selection — hold <code>⌘</code> or <code>Ctrl</code> 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" },
Expand Down
2 changes: 1 addition & 1 deletion package/example/src/app/components/FeaturesDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
235 changes: 146 additions & 89 deletions package/src/components/page-toolbar-css/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -185,6 +193,11 @@ const OUTPUT_TO_REACT_MODE: Record<OutputDetailLevel, ReactComponentMode> = {
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)" },
Expand Down Expand Up @@ -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;
Expand All @@ -412,7 +425,7 @@ export function PageFeedbackToolbarCSS({
useState<HTMLElement | null>(null);
const [hoveredTargetElements, setHoveredTargetElements] = useState<
HTMLElement[]
>([]); // For cmd+shift+click multi-select hover
>([]); // For modifier-click multi-select hover
const [deletingMarkerId, setDeletingMarkerId] = useState<string | null>(null);
const [renumberFrom, setRenumberFrom] = useState<number | null>(null);
const [editingAnnotation, setEditingAnnotation] = useState<Annotation | null>(
Expand All @@ -422,7 +435,7 @@ export function PageFeedbackToolbarCSS({
useState<HTMLElement | null>(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);
Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -1660,7 +1667,25 @@ const [settings, setSettings] = useState<ToolbarSettings>(() => {
}
}, [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;

Expand Down Expand Up @@ -1776,7 +1801,7 @@ const [settings, setSettings] = useState<ToolbarSettings>(() => {
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();
}
Expand Down Expand Up @@ -1940,8 +1965,12 @@ const [settings, setSettings] = useState<ToolbarSettings>(() => {
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();

Expand Down Expand Up @@ -2077,38 +2106,36 @@ const [settings, setSettings] = useState<ToolbarSettings>(() => {
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([]);
};

Expand Down Expand Up @@ -2175,7 +2202,10 @@ const [settings, setSettings] = useState<ToolbarSettings>(() => {
"SUP",
]);

if (textTags.has(target.tagName) || target.isContentEditable) {
if (
!isPrimaryMultiSelectModifierActive(e) &&
(textTags.has(target.tagName) || target.isContentEditable)
) {
return;
}

Expand Down Expand Up @@ -2457,63 +2487,83 @@ const [settings, setSettings] = useState<ToolbarSettings>(() => {

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);
Expand Down Expand Up @@ -2552,7 +2602,14 @@ const [settings, setSettings] = useState<ToolbarSettings>(() => {

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(
Expand Down Expand Up @@ -4294,7 +4351,7 @@ const [settings, setSettings] = useState<ToolbarSettings>(() => {
/>
)}

{/* 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) => {
Expand Down Expand Up @@ -4335,7 +4392,7 @@ const [settings, setSettings] = useState<ToolbarSettings>(() => {
);
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) {
Expand Down