diff --git a/package/src/components/icons.tsx b/package/src/components/icons.tsx index 43f7b961..8666661e 100644 --- a/package/src/components/icons.tsx +++ b/package/src/components/icons.tsx @@ -792,6 +792,25 @@ export const IconChevronRight = ({ size = 16 }: { size?: number }) => ( ); +// Pencil icon for draw mode +export const IconPencil = ({ size = 24 }: { size?: number }) => ( + + + + +); + // Animated Bunny mascot export const AnimatedBunny = ({ size = 20, diff --git a/package/src/components/page-toolbar-css/index.tsx b/package/src/components/page-toolbar-css/index.tsx index a2b26b15..765301a5 100644 --- a/package/src/components/page-toolbar-css/index.tsx +++ b/package/src/components/page-toolbar-css/index.tsx @@ -38,6 +38,7 @@ import { IconEdit, IconChevronLeft, IconChevronRight, + IconPencil, } from "../icons"; import { identifyElement, @@ -237,6 +238,86 @@ function isElementFixed(element: HTMLElement): boolean { return false; } +function findStrokeAtPoint( + x: number, + y: number, + strokes: Array<{ points: Array<{ x: number; y: number }>; fixed: boolean }>, + threshold = 12, +): number | null { + const scrollY = window.scrollY; + // Reverse order — last drawn is on top + for (let i = strokes.length - 1; i >= 0; i--) { + const stroke = strokes[i]; + if (stroke.points.length < 2) continue; + for (let j = 0; j < stroke.points.length - 1; j++) { + const a = stroke.points[j]; + const b = stroke.points[j + 1]; + // Convert to viewport coords + const ay = stroke.fixed ? a.y : a.y - scrollY; + const by = stroke.fixed ? b.y : b.y - scrollY; + const ax = a.x; + const bx = b.x; + // Point-to-segment distance + const dx = bx - ax; + const dy = by - ay; + const lenSq = dx * dx + dy * dy; + let t = lenSq === 0 ? 0 : ((x - ax) * dx + (y - ay) * dy) / lenSq; + t = Math.max(0, Math.min(1, t)); + const projX = ax + t * dx; + const projY = ay + t * dy; + const dist = Math.hypot(x - projX, y - projY); + if (dist < threshold) return i; + } + } + return null; +} + +function classifyStrokeGesture( + points: Array<{ x: number; y: number }>, + fixed: boolean, +): string { + if (points.length < 2) return "Mark"; + const scrollY = window.scrollY; + const viewportPoints = fixed + ? points + : points.map((p) => ({ x: p.x, y: p.y - scrollY })); + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const p of viewportPoints) { + minX = Math.min(minX, p.x); + minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x); + maxY = Math.max(maxY, p.y); + } + const bboxW = maxX - minX; + const bboxH = maxY - minY; + const bboxDiag = Math.hypot(bboxW, bboxH); + + const start = viewportPoints[0]; + const end = viewportPoints[viewportPoints.length - 1]; + const startEndDist = Math.hypot(end.x - start.x, end.y - start.y); + const closedLoop = startEndDist < bboxDiag * 0.35; + const aspectRatio = bboxW / Math.max(bboxH, 1); + + if (closedLoop && bboxDiag > 20) { + const edgeThreshold = Math.max(bboxW, bboxH) * 0.15; + let edgePoints = 0; + for (const p of viewportPoints) { + const nearLeft = p.x - minX < edgeThreshold; + const nearRight = maxX - p.x < edgeThreshold; + const nearTop = p.y - minY < edgeThreshold; + const nearBottom = maxY - p.y < edgeThreshold; + if ((nearLeft || nearRight) && (nearTop || nearBottom)) edgePoints++; + } + return edgePoints > viewportPoints.length * 0.15 ? "Box" : "Circle"; + } else if (aspectRatio > 3 && bboxH < 40) { + return "Underline"; + } else if (startEndDist > bboxDiag * 0.5) { + return "Arrow"; + } + return "Drawing"; +} + function hexToRgba(hex: string, alpha: number): string { const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); @@ -460,6 +541,8 @@ export function PageFeedbackToolbarCSS({ }: PageFeedbackToolbarCSSProps = {}) { const [isActive, setIsActive] = useState(false); const [annotations, setAnnotations] = useState([]); + const annotationsRef = useRef([]); + annotationsRef.current = annotations; const [showMarkers, setShowMarkers] = useState(true); // Unified marker visibility state - controls both toolbar and eye toggle @@ -495,6 +578,8 @@ export function PageFeedbackToolbarCSS({ multiSelectElements?: HTMLElement[]; // Element reference for single-select (for live position queries) targetElement?: HTMLElement; + drawingIndex?: number; + strokeId?: string; } | null>(null); const [copied, setCopied] = useState(false); const [sendState, setSendState] = useState< @@ -503,6 +588,7 @@ export function PageFeedbackToolbarCSS({ const [cleared, setCleared] = useState(false); const [isClearing, setIsClearing] = useState(false); const [hoveredMarkerId, setHoveredMarkerId] = useState(null); + const [tooltipExitingId, setTooltipExitingId] = useState(null); const [hoveredTargetElement, setHoveredTargetElement] = useState(null); const [hoveredTargetElements, setHoveredTargetElements] = useState< @@ -530,6 +616,21 @@ export function PageFeedbackToolbarCSS({ const [isTransitioning, setIsTransitioning] = useState(false); const [tooltipsHidden, setTooltipsHidden] = useState(false); + // Draw mode state + const [isDrawMode, setIsDrawMode] = useState(false); + const [drawStrokes, setDrawStrokes] = useState; color: string; fixed: boolean }>>([]); + const drawStrokesRef = useRef(drawStrokes); + drawStrokesRef.current = drawStrokes; + const [hoveredDrawingIdx, setHoveredDrawingIdx] = useState(null); + const drawCanvasRef = useRef(null); + const isDrawingRef = useRef(false); + const currentStrokeRef = useRef>([]); + const dimAmountRef = useRef(0); + const visualHighlightRef = useRef(null); + const exitingStrokeIdRef = useRef(null); + const exitingAlphaRef = useRef(1); + + // Cmd+shift+click multi-select state const [pendingMultiSelectElements, setPendingMultiSelectElements] = useState< Array<{ @@ -752,21 +853,25 @@ export function PageFeedbackToolbarCSS({ setMarkersVisible(true); setAnimatedMarkers(new Set()); // After enter animations complete, mark all as animated + // Must wait for max stagger delay (count * 20ms) + animation duration (250ms) + const enterMaxDelay = Math.max(0, annotations.length - 1) * 20; const timer = originalSetTimeout(() => { setAnimatedMarkers((prev) => { const newSet = new Set(prev); annotations.forEach((a) => newSet.add(a.id)); return newSet; }); - }, 350); + }, enterMaxDelay + 250 + 50); return () => clearTimeout(timer); } else if (markersVisible) { // Hide markers - start exit animation, then unmount + // Timeout must cover max stagger delay + animation duration (200ms) setMarkersExiting(true); + const maxDelay = Math.max(0, annotations.length - 1) * 20; const timer = originalSetTimeout(() => { setMarkersVisible(false); setMarkersExiting(false); - }, 250); + }, maxDelay + 200 + 50); // +50ms buffer return () => clearTimeout(timer); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -1074,7 +1179,7 @@ export function PageFeedbackToolbarCSS({ return () => clearInterval(interval); }, [endpoint, mounted]); - // Listen for server-side annotation updates (e.g. resolved by agent) + // Listen for server-side annotation events (real-time sync with other users) useEffect(() => { if (!endpoint || !mounted || !currentSessionId) return; @@ -1084,11 +1189,30 @@ export function PageFeedbackToolbarCSS({ const removedStatuses = ["resolved", "dismissed"]; - const handler = (e: MessageEvent) => { + const handleCreated = (e: MessageEvent) => { + try { + const event = JSON.parse(e.data); + const annotation = event.payload as Annotation; + if (!annotation?.id) return; + // Skip if we already have this annotation locally (our own create). + // Ref check guards the callback; setter check guards state against races. + if (annotationsRef.current.some((a) => a.id === annotation.id)) return; + setAnnotations((prev) => + prev.some((a) => a.id === annotation.id) ? prev : [...prev, annotation] + ); + onAnnotationAdd?.(annotation); + } catch { + // Ignore parse errors + } + }; + + const handleUpdated = (e: MessageEvent) => { try { const event = JSON.parse(e.data); - if (removedStatuses.includes(event.payload?.status)) { - const id = event.payload.id as string; + const annotation = event.payload as Annotation; + if (!annotation?.id) return; + if (removedStatuses.includes(annotation.status!)) { + const id = annotation.id; // Trigger exit animation then remove setExitingMarkers((prev) => new Set(prev).add(id)); originalSetTimeout(() => { @@ -1099,16 +1223,47 @@ export function PageFeedbackToolbarCSS({ return next; }); }, 150); + } else { + setAnnotations((prev) => + prev.map((a) => (a.id === annotation.id ? { ...a, ...annotation } : a)) + ); } + onAnnotationUpdate?.(annotation); + } catch { + // Ignore parse errors + } + }; + + const handleDeleted = (e: MessageEvent) => { + try { + const event = JSON.parse(e.data); + const id = (event.payload?.id ?? event.payload) as string; + if (!id) return; + const existing = annotationsRef.current.find((a) => a.id === id); + // Always attempt removal (ref may be stale), callback only if found + setExitingMarkers((prev) => new Set(prev).add(id)); + originalSetTimeout(() => { + setAnnotations((prev) => prev.filter((a) => a.id !== id)); + setExitingMarkers((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + }, 150); + if (existing) onAnnotationDelete?.(existing); } catch { // Ignore parse errors } }; - eventSource.addEventListener("annotation.updated", handler); + eventSource.addEventListener("annotation.created", handleCreated); + eventSource.addEventListener("annotation.updated", handleUpdated); + eventSource.addEventListener("annotation.deleted", handleDeleted); return () => { - eventSource.removeEventListener("annotation.updated", handler); + eventSource.removeEventListener("annotation.created", handleCreated); + eventSource.removeEventListener("annotation.updated", handleUpdated); + eventSource.removeEventListener("annotation.deleted", handleDeleted); eventSource.close(); }; }, [endpoint, mounted, currentSessionId]); @@ -1421,6 +1576,7 @@ export function PageFeedbackToolbarCSS({ setShowSettings(false); // Close settings when toolbar closes setPendingMultiSelectElements([]); // Clear multi-select modifiersHeldRef.current = { cmd: false, shift: false }; // Reset modifier tracking + setIsDrawMode(false); // Exit draw mode if (isFrozen) { unfreezeAnimations(); } @@ -1478,6 +1634,9 @@ export function PageFeedbackToolbarCSS({ [data-annotation-marker], [data-annotation-marker] * { cursor: pointer !important; } + html[data-drawing-hover], html[data-drawing-hover] * { + cursor: pointer !important; + } `; document.head.appendChild(style); @@ -1487,18 +1646,41 @@ export function PageFeedbackToolbarCSS({ }; }, [isActive]); + // Cursor change when hovering a drawing stroke (both draw mode and normal mode) + useEffect(() => { + if (hoveredDrawingIdx !== null && isActive) { + document.documentElement.setAttribute("data-drawing-hover", ""); + return () => document.documentElement.removeAttribute("data-drawing-hover"); + } + }, [hoveredDrawingIdx, isActive]); + // Handle mouse move useEffect(() => { - if (!isActive || pendingAnnotation) return; + if (!isActive || pendingAnnotation || isDrawMode) 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); + // Only clear drawing hover when NOT over a marker — markers manage their own via handleMarkerHover + if (!target.closest("[data-annotation-marker]")) { + setHoveredDrawingIdx(null); + } return; } + // Check if hovering over a completed drawing stroke + if (drawStrokes.length > 0) { + const strokeIdx = findStrokeAtPoint(e.clientX, e.clientY, drawStrokes); + if (strokeIdx !== null) { + setHoveredDrawingIdx(strokeIdx); + setHoverInfo(null); + return; + } + } + setHoveredDrawingIdx(null); + const elementUnder = deepElementFromPoint(e.clientX, e.clientY); if ( !elementUnder || @@ -1524,11 +1706,60 @@ export function PageFeedbackToolbarCSS({ document.addEventListener("mousemove", handleMouseMove); return () => document.removeEventListener("mousemove", handleMouseMove); - }, [isActive, pendingAnnotation, effectiveReactMode]); + }, [isActive, pendingAnnotation, isDrawMode, effectiveReactMode, drawStrokes]); + + // Start editing an annotation (right-click or click on drawing stroke) + const startEditAnnotation = useCallback((annotation: Annotation) => { + setEditingAnnotation(annotation); + setHoveredMarkerId(null); + setHoveredTargetElement(null); + setHoveredTargetElements([]); + + // Try to find elements at the annotation's position(s) for live tracking + if (annotation.elementBoundingBoxes?.length) { + // Cmd+shift+click: find element at each bounding box center + const elements: HTMLElement[] = []; + for (const bb of annotation.elementBoundingBoxes) { + const centerX = bb.x + bb.width / 2; + const centerY = bb.y + bb.height / 2 - window.scrollY; + const el = deepElementFromPoint(centerX, centerY); + if (el) elements.push(el); + } + setEditingTargetElements(elements); + setEditingTargetElement(null); + } else if (annotation.boundingBox) { + // Single element + const bb = annotation.boundingBox; + const centerX = bb.x + bb.width / 2; + // Convert document coords to viewport coords (unless fixed) + const centerY = annotation.isFixed + ? bb.y + bb.height / 2 + : bb.y + bb.height / 2 - window.scrollY; + const el = deepElementFromPoint(centerX, centerY); + + // Validate found element's size roughly matches stored bounding box + if (el) { + const elRect = el.getBoundingClientRect(); + const widthRatio = elRect.width / bb.width; + const heightRatio = elRect.height / bb.height; + if (widthRatio < 0.5 || heightRatio < 0.5) { + setEditingTargetElement(null); + } else { + setEditingTargetElement(el); + } + } else { + setEditingTargetElement(null); + } + setEditingTargetElements([]); + } else { + setEditingTargetElement(null); + setEditingTargetElements([]); + } + }, []); // Handle click useEffect(() => { - if (!isActive) return; + if (!isActive || isDrawMode) return; const handleClick = (e: MouseEvent) => { if (justFinishedDragRef.current) { @@ -1543,6 +1774,94 @@ export function PageFeedbackToolbarCSS({ if (closestCrossingShadow(target, "[data-annotation-popup]")) return; if (closestCrossingShadow(target, "[data-annotation-marker]")) return; + // Check if clicking on a completed drawing stroke + if (drawStrokes.length > 0 && !pendingAnnotation && !editingAnnotation) { + const strokeIdx = findStrokeAtPoint(e.clientX, e.clientY, drawStrokes); + if (strokeIdx !== null) { + e.preventDefault(); + e.stopPropagation(); + + // If annotation already exists for this drawing, open it in edit mode + const existingAnnotation = annotations.find(a => a.strokeId === drawStrokes[strokeIdx]?.id || a.drawingIndex === strokeIdx); + if (existingAnnotation) { + startEditAnnotation(existingAnnotation); + return; + } + + const stroke = drawStrokes[strokeIdx]; + const scrollYNow = window.scrollY; + + // Temporarily hide canvas to find element underneath at click point + const canvas = drawCanvasRef.current; + if (canvas) canvas.style.visibility = "hidden"; + const elementUnder = deepElementFromPoint(e.clientX, e.clientY); + if (canvas) canvas.style.visibility = ""; + + const gestureShape = classifyStrokeGesture(stroke.points, stroke.fixed); + let name = `Drawing: ${gestureShape}`; + let path = ""; + let reactComponents: string | null = null; + let nearbyText: string | undefined; + let cssClasses: string | undefined; + let fullPath: string | undefined; + let accessibility: string | undefined; + let computedStylesStr: string | undefined; + let computedStylesObj: Record | undefined; + let nearbyElements: string | undefined; + let isFixed = stroke.fixed; + let boundingBox: { x: number; y: number; width: number; height: number } | undefined; + + if (elementUnder) { + const info = identifyElementWithReact(elementUnder, effectiveReactMode); + name = `Drawing: ${gestureShape} → ${info.name}`; + path = info.path; + reactComponents = info.reactComponents; + nearbyText = getNearbyText(elementUnder); + cssClasses = getElementClasses(elementUnder); + fullPath = getFullElementPath(elementUnder); + accessibility = getAccessibilityInfo(elementUnder); + computedStylesStr = getForensicComputedStyles(elementUnder); + computedStylesObj = getDetailedComputedStyles(elementUnder); + nearbyElements = getNearbyElements(elementUnder); + const rect = elementUnder.getBoundingClientRect(); + boundingBox = { + x: rect.left, + y: isFixed ? rect.top : rect.top + scrollYNow, + width: rect.width, + height: rect.height, + }; + } + + // Position marker at click point (on the stroke) + const annX = (e.clientX / window.innerWidth) * 100; + const annY = isFixed ? e.clientY : e.clientY + scrollYNow; + + setPendingAnnotation({ + x: annX, + y: annY, + clientY: e.clientY, + element: name, + elementPath: path, + boundingBox, + nearbyText, + cssClasses, + isFixed, + fullPath, + accessibility, + computedStyles: computedStylesStr, + computedStylesObj, + nearbyElements, + reactComponents: reactComponents ?? undefined, + targetElement: elementUnder ?? undefined, + drawingIndex: strokeIdx, + strokeId: stroke.id, + }); + setHoverInfo(null); + setHoveredDrawingIdx(null); + return; + } + } + // Handle cmd+shift+click for multi-element selection if (e.metaKey && e.shiftKey && !pendingAnnotation && !editingAnnotation) { e.preventDefault(); @@ -1670,11 +1989,15 @@ export function PageFeedbackToolbarCSS({ return () => document.removeEventListener("click", handleClick, true); }, [ isActive, + isDrawMode, pendingAnnotation, editingAnnotation, settings.blockInteractions, effectiveReactMode, pendingMultiSelectElements, + drawStrokes, + annotations, + startEditAnnotation, ]); // Cmd+shift+click multi-select: keyup listener for modifier release @@ -1724,7 +2047,7 @@ export function PageFeedbackToolbarCSS({ // Multi-select drag - mousedown useEffect(() => { - if (!isActive || pendingAnnotation) return; + if (!isActive || pendingAnnotation || isDrawMode) return; const handleMouseDown = (e: MouseEvent) => { // Use composedPath to get actual target inside shadow DOM @@ -1784,7 +2107,7 @@ export function PageFeedbackToolbarCSS({ document.addEventListener("mousedown", handleMouseDown); return () => document.removeEventListener("mousedown", handleMouseDown); - }, [isActive, pendingAnnotation]); + }, [isActive, pendingAnnotation, isDrawMode]); // Multi-select drag - mousemove (fully optimized with direct DOM updates) useEffect(() => { @@ -2151,6 +2474,430 @@ export function PageFeedbackToolbarCSS({ return () => document.removeEventListener("mouseup", handleMouseUp); }, [isActive, isDragging]); + // Draw mode: redraw helper — dimAmount 0–1 controls non-hovered stroke opacity + const redrawCanvas = useCallback((ctx: CanvasRenderingContext2D, strokes: typeof drawStrokes, hoveredIdx?: number | null, dimAmount = 0) => { + const scrollY = window.scrollY; + const dpr = window.devicePixelRatio || 1; + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + ctx.save(); + ctx.scale(dpr, dpr); + + const tracePath = (stroke: typeof strokes[0], offsetY: number) => { + const p0 = stroke.points[0]; + ctx.moveTo(p0.x, p0.y - offsetY); + for (let i = 1; i < stroke.points.length - 1; i++) { + const curr = stroke.points[i]; + const next = stroke.points[i + 1]; + const midX = (curr.x + next.x) / 2; + const midY = (curr.y + next.y - 2 * offsetY) / 2; + ctx.quadraticCurveTo(curr.x, curr.y - offsetY, midX, midY); + } + const last = stroke.points[stroke.points.length - 1]; + ctx.lineTo(last.x, last.y - offsetY); + }; + + for (let si = 0; si < strokes.length; si++) { + const stroke = strokes[si]; + if (stroke.points.length < 2) continue; + const offsetY = stroke.fixed ? 0 : scrollY; + // Per-stroke alpha: dim non-hovered strokes, fade exiting stroke + let alpha = (hoveredIdx != null && si !== hoveredIdx) ? 1 - 0.7 * dimAmount : 1; + if (exitingStrokeIdRef.current && stroke.id === exitingStrokeIdRef.current) { + alpha *= exitingAlphaRef.current; + } + ctx.globalAlpha = alpha; + ctx.beginPath(); + ctx.strokeStyle = stroke.color; + ctx.lineWidth = 3; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + tracePath(stroke, offsetY); + ctx.stroke(); + } + ctx.globalAlpha = 1; + ctx.restore(); + }, []); + + // Draw mode: drawing logic (also handles hover/click-to-annotate on completed strokes) + const drawClickStartRef = useRef<{ x: number; y: number; strokeIdx: number | null } | null>(null); + useEffect(() => { + if (!isDrawMode || !isActive) return; + + const canvas = drawCanvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + + const handleMouseDown = (e: MouseEvent) => { + // If an annotation popup is open, shake it instead of drawing + if (pendingAnnotation) { + popupRef.current?.shake(); + return; + } + if (editingAnnotation) { + editPopupRef.current?.shake(); + return; + } + + // Check if clicking on an existing stroke + const strokeIdx = findStrokeAtPoint(e.clientX, e.clientY, drawStrokes); + drawClickStartRef.current = { x: e.clientX, y: e.clientY, strokeIdx }; + + isDrawingRef.current = true; + currentStrokeRef.current = [{ x: e.clientX, y: e.clientY }]; + ctx.save(); + ctx.scale(dpr, dpr); + ctx.beginPath(); + ctx.strokeStyle = settings.annotationColor; + ctx.lineWidth = 3; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.moveTo(e.clientX, e.clientY); + }; + + const handleMouseMove = (e: MouseEvent) => { + if (!isDrawingRef.current) { + // Hover detection on completed strokes (not actively drawing) + const strokeIdx = findStrokeAtPoint(e.clientX, e.clientY, drawStrokes); + setHoveredDrawingIdx(strokeIdx); + if (strokeIdx !== null) canvas.setAttribute("data-stroke-hover", ""); + else canvas.removeAttribute("data-stroke-hover"); + return; + } + const point = { x: e.clientX, y: e.clientY }; + const prev = currentStrokeRef.current[currentStrokeRef.current.length - 1]; + // Skip points that are very close together (reduces jitter) + const dist = Math.hypot(point.x - prev.x, point.y - prev.y); + if (dist < 2) return; + currentStrokeRef.current.push(point); + // Quadratic curve to midpoint for smooth live drawing + const midX = (prev.x + point.x) / 2; + const midY = (prev.y + point.y) / 2; + ctx.quadraticCurveTo(prev.x, prev.y, midX, midY); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(midX, midY); + }; + + const handleMouseUp = (e: MouseEvent) => { + if (!isDrawingRef.current) return; + isDrawingRef.current = false; + ctx.restore(); + const pts = currentStrokeRef.current; + + // Detect click (not drag) on existing stroke — open annotation popup + const clickStart = drawClickStartRef.current; + if (clickStart && clickStart.strokeIdx !== null && pts.length <= 3) { + const movedDist = Math.hypot(e.clientX - clickStart.x, e.clientY - clickStart.y); + if (movedDist < 5) { + // It's a click on an existing stroke + currentStrokeRef.current = []; + drawClickStartRef.current = null; + // Redraw to clear any partial stroke artifact + redrawCanvas(ctx, drawStrokes, clickStart.strokeIdx, dimAmountRef.current); + + const strokeIdx = clickStart.strokeIdx; + + // If annotation already exists for this drawing, open it in edit mode + const existingAnnotation = annotations.find(a => a.drawingIndex === strokeIdx); + if (existingAnnotation) { + startEditAnnotation(existingAnnotation); + setHoveredDrawingIdx(null); + return; + } + + const stroke = drawStrokes[strokeIdx]; + const scrollYNow = window.scrollY; + + // Use click position for element detection + const centerX = e.clientX; + const centerY = e.clientY; + + // Temporarily hide canvas to find element underneath + canvas.style.visibility = "hidden"; + const elementUnder = deepElementFromPoint(centerX, centerY); + canvas.style.visibility = ""; + + const gestureShape = classifyStrokeGesture(stroke.points, stroke.fixed); + let name = `Drawing: ${gestureShape}`; + let path = ""; + let reactComponents: string | null = null; + let nearbyText: string | undefined; + let cssClasses: string | undefined; + let fullPath: string | undefined; + let accessibility: string | undefined; + let computedStylesStr: string | undefined; + let computedStylesObj: Record | undefined; + let nearbyElements: string | undefined; + const isFixed = stroke.fixed; + let boundingBox: { x: number; y: number; width: number; height: number } | undefined; + + if (elementUnder) { + const info = identifyElementWithReact(elementUnder, effectiveReactMode); + name = `Drawing: ${gestureShape} → ${info.name}`; + path = info.path; + reactComponents = info.reactComponents; + nearbyText = getNearbyText(elementUnder); + cssClasses = getElementClasses(elementUnder); + fullPath = getFullElementPath(elementUnder); + accessibility = getAccessibilityInfo(elementUnder); + computedStylesStr = getForensicComputedStyles(elementUnder); + computedStylesObj = getDetailedComputedStyles(elementUnder); + nearbyElements = getNearbyElements(elementUnder); + const rect = elementUnder.getBoundingClientRect(); + boundingBox = { + x: rect.left, + y: isFixed ? rect.top : rect.top + scrollYNow, + width: rect.width, + height: rect.height, + }; + } + + // Position marker at click point (on the stroke) + const annX = (e.clientX / window.innerWidth) * 100; + const annY = isFixed ? e.clientY : e.clientY + scrollYNow; + + setPendingAnnotation({ + x: annX, + y: annY, + clientY: e.clientY, + element: name, + elementPath: path, + boundingBox, + nearbyText, + cssClasses, + isFixed, + fullPath, + accessibility, + computedStyles: computedStylesStr, + computedStylesObj, + nearbyElements, + reactComponents: reactComponents ?? undefined, + targetElement: elementUnder ?? undefined, + drawingIndex: strokeIdx, + strokeId: stroke.id, + }); + setHoverInfo(null); + setHoveredDrawingIdx(null); + return; + } + } + drawClickStartRef.current = null; + + if (pts.length > 1) { + // Determine if stroke is over fixed/sticky elements + // Check the bounding-box center — most reliable signal for what the stroke targets + canvas.style.visibility = "hidden"; + + const isElFixed = (el: HTMLElement): boolean => { + let node: HTMLElement | null = el; + while (node && node !== document.documentElement) { + const pos = getComputedStyle(node).position; + if (pos === "fixed" || pos === "sticky") return true; + node = node.parentElement; + } + return false; + }; + + // Bounding box center + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const p of pts) { + minX = Math.min(minX, p.x); + minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x); + maxY = Math.max(maxY, p.y); + } + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + const centerEl = deepElementFromPoint(centerX, centerY); + let isFixed = centerEl ? isElFixed(centerEl) : false; + + // If center is ambiguous (e.g. arrow spanning fixed→scrollable), sample edges too + if (!isFixed) { + let fixedCount = 0; + let totalSampled = 0; + const sampleCount = Math.min(6, pts.length); + const step = Math.max(1, Math.floor(pts.length / sampleCount)); + for (let i = 0; i < pts.length; i += step) { + const el = deepElementFromPoint(pts[i].x, pts[i].y); + if (!el) continue; + totalSampled++; + if (isElFixed(el)) fixedCount++; + } + // If majority of edge samples are fixed, override + if (totalSampled > 0 && fixedCount > totalSampled * 0.6) isFixed = true; + } + + // Fixed strokes stay in viewport coords; scrollable strokes convert to page coords + const finalPoints = isFixed + ? [...pts] + : pts.map(p => ({ x: p.x, y: p.y + window.scrollY })); + + const newStrokeIdx = drawStrokes.length; + const newStrokeId = crypto.randomUUID(); + const newStroke = { id: newStrokeId, points: finalPoints, color: settings.annotationColor, fixed: isFixed }; + + // Identify element underneath for annotation + const gestureShape = classifyStrokeGesture(finalPoints, isFixed); + let name = `Drawing: ${gestureShape}`; + let elPath = ""; + let reactComponents: string | null = null; + let nearbyText: string | undefined; + let cssClasses: string | undefined; + let fullPath: string | undefined; + let accessibility: string | undefined; + let computedStylesStr: string | undefined; + let computedStylesObj: Record | undefined; + let nearbyElements: string | undefined; + let boundingBox: { x: number; y: number; width: number; height: number } | undefined; + + if (centerEl) { + const info = identifyElementWithReact(centerEl, effectiveReactMode); + name = `Drawing: ${gestureShape} → ${info.name}`; + elPath = info.path; + reactComponents = info.reactComponents; + nearbyText = getNearbyText(centerEl); + cssClasses = getElementClasses(centerEl); + fullPath = getFullElementPath(centerEl); + accessibility = getAccessibilityInfo(centerEl); + computedStylesStr = getForensicComputedStyles(centerEl); + computedStylesObj = getDetailedComputedStyles(centerEl); + nearbyElements = getNearbyElements(centerEl); + const rect = centerEl.getBoundingClientRect(); + boundingBox = { + x: rect.left, + y: isFixed ? rect.top : rect.top + window.scrollY, + width: rect.width, + height: rect.height, + }; + } + + canvas.style.visibility = ""; + + setDrawStrokes(prev => [...prev, newStroke]); + + // Position marker at the end of the stroke (where the pen was released) + const lastPt = finalPoints[finalPoints.length - 1]; + const lastPtViewY = isFixed ? lastPt.y : lastPt.y - window.scrollY; + const annX = (lastPt.x / window.innerWidth) * 100; + const annY = lastPt.y; // Already in page coords (finalPoints are converted) + + setPendingAnnotation({ + x: annX, + y: annY, + clientY: lastPtViewY, + element: name, + elementPath: elPath, + boundingBox, + nearbyText, + cssClasses, + isFixed, + fullPath, + accessibility, + computedStyles: computedStylesStr, + computedStylesObj, + nearbyElements, + reactComponents: reactComponents ?? undefined, + targetElement: centerEl ?? undefined, + drawingIndex: newStrokeIdx, + strokeId: newStrokeId, + }); + setHoverInfo(null); + } + currentStrokeRef.current = []; + }; + + const handleMouseLeave = () => { + setHoveredDrawingIdx(null); + canvas.removeAttribute("data-stroke-hover"); + }; + + canvas.addEventListener("mousedown", handleMouseDown); + canvas.addEventListener("mousemove", handleMouseMove); + canvas.addEventListener("mouseup", handleMouseUp); + canvas.addEventListener("mouseleave", handleMouseLeave); + + return () => { + canvas.removeEventListener("mousedown", handleMouseDown); + canvas.removeEventListener("mousemove", handleMouseMove); + canvas.removeEventListener("mouseup", handleMouseUp); + canvas.removeEventListener("mouseleave", handleMouseLeave); + }; + }, [isDrawMode, isActive, settings.annotationColor, drawStrokes, annotations, effectiveReactMode, redrawCanvas, startEditAnnotation, pendingAnnotation, editingAnnotation]); + + // Draw mode: resize canvas, redraw on scroll + useEffect(() => { + if (!isActive) return; + const canvas = drawCanvasRef.current; + if (!canvas) return; + + const resize = () => { + const dpr = window.devicePixelRatio || 1; + canvas.style.width = window.innerWidth + "px"; + canvas.style.height = window.innerHeight + "px"; + canvas.width = window.innerWidth * dpr; + canvas.height = window.innerHeight * dpr; + const ctx = canvas.getContext("2d"); + if (ctx) redrawCanvas(ctx, drawStrokes, visualHighlightRef.current, dimAmountRef.current); + }; + + const onScroll = () => { + const ctx = canvas.getContext("2d"); + if (ctx) redrawCanvas(ctx, drawStrokes, visualHighlightRef.current, dimAmountRef.current); + }; + + resize(); + window.addEventListener("resize", resize); + window.addEventListener("scroll", onScroll, { passive: true }); + return () => { + window.removeEventListener("resize", resize); + window.removeEventListener("scroll", onScroll); + }; + }, [isActive, drawStrokes, redrawCanvas]); + + // Animate dim in/out when hovering drawings + useEffect(() => { + const canvas = drawCanvasRef.current; + if (!canvas || !isActive || drawStrokes.length === 0) return; + + const effectiveHighlight = hoveredDrawingIdx ?? pendingAnnotation?.drawingIndex ?? editingAnnotation?.drawingIndex ?? null; + const targetDim = effectiveHighlight != null ? 1 : 0; + + // Update visual highlight ref — keep old value during fade-out + if (effectiveHighlight != null) { + visualHighlightRef.current = effectiveHighlight; + } + + // Already at target — just redraw with current value + if (Math.abs(dimAmountRef.current - targetDim) < 0.01) { + dimAmountRef.current = targetDim; + if (targetDim === 0) visualHighlightRef.current = null; + const ctx = canvas.getContext("2d"); + if (ctx) redrawCanvas(ctx, drawStrokes, visualHighlightRef.current, targetDim); + return; + } + + let raf: number; + const animate = () => { + const diff = targetDim - dimAmountRef.current; + if (Math.abs(diff) < 0.01) { + dimAmountRef.current = targetDim; + if (targetDim === 0) visualHighlightRef.current = null; + } else { + dimAmountRef.current += diff * 0.25; + } + const ctx = canvas.getContext("2d"); + if (ctx) redrawCanvas(ctx, drawStrokes, visualHighlightRef.current, dimAmountRef.current); + if (Math.abs(dimAmountRef.current - targetDim) > 0.01) { + raf = requestAnimationFrame(animate); + } + }; + raf = requestAnimationFrame(animate); + return () => cancelAnimationFrame(raf); + }, [isActive, hoveredDrawingIdx, pendingAnnotation?.drawingIndex, editingAnnotation?.drawingIndex, drawStrokes, redrawCanvas]); + // Fire webhook for annotation events - returns true on success, false on failure const fireWebhook = useCallback( async ( @@ -2209,6 +2956,8 @@ export function PageFeedbackToolbarCSS({ nearbyElements: pendingAnnotation.nearbyElements, reactComponents: pendingAnnotation.reactComponents, elementBoundingBoxes: pendingAnnotation.elementBoundingBoxes, + drawingIndex: pendingAnnotation.drawingIndex, + strokeId: pendingAnnotation.strokeId, // Protocol fields for server sync ...(endpoint && currentSessionId ? { @@ -2284,18 +3033,52 @@ export function PageFeedbackToolbarCSS({ // Cancel annotation with exit animation const cancelAnnotation = useCallback(() => { + const strokeId = pendingAnnotation?.strokeId; setPendingExiting(true); + + // Fade the linked stroke on canvas in parallel with popup exit + if (strokeId) { + exitingStrokeIdRef.current = strokeId; + exitingAlphaRef.current = 1; + const canvas = drawCanvasRef.current; + const ctx = canvas?.getContext("2d"); + if (ctx) { + const start = performance.now(); + const fade = (now: number) => { + const t = Math.min((now - start) / 150, 1); + exitingAlphaRef.current = 1 - t; + redrawCanvas(ctx, drawStrokesRef.current, visualHighlightRef.current, dimAmountRef.current); + if (t < 1) requestAnimationFrame(fade); + }; + requestAnimationFrame(fade); + } + } + originalSetTimeout(() => { + exitingStrokeIdRef.current = null; + if (strokeId) { + const currentStrokes = drawStrokesRef.current; + const drawingIdx = currentStrokes.findIndex(s => s.id === strokeId); + if (drawingIdx >= 0) { + setDrawStrokes(prev => prev.filter(s => s.id !== strokeId)); + setAnnotations(prev => prev.map(a => + a.drawingIndex != null && a.drawingIndex > drawingIdx + ? { ...a, drawingIndex: a.drawingIndex - 1 } + : a + )); + } + } setPendingAnnotation(null); setPendingExiting(false); - }, 150); // Match exit animation duration - }, []); + }, 150); + }, [pendingAnnotation]); // Delete annotation with exit animation const deleteAnnotation = useCallback( (id: string) => { - const deletedIndex = annotations.findIndex((a) => a.id === id); - const deletedAnnotation = annotations[deletedIndex]; + const currentAnnotations = annotationsRef.current; + const deletedIndex = currentAnnotations.findIndex((a) => a.id === id); + const deletedAnnotation = currentAnnotations[deletedIndex]; // Close edit panel with exit animation if deleting the annotation being edited if (editingAnnotation?.id === id) { @@ -2327,9 +3110,46 @@ export function PageFeedbackToolbarCSS({ }); } - // Wait for exit animation then remove + // Fade the linked stroke on canvas in parallel with marker exit + if (deletedAnnotation?.strokeId) { + exitingStrokeIdRef.current = deletedAnnotation.strokeId; + exitingAlphaRef.current = 1; + const canvas = drawCanvasRef.current; + const ctx = canvas?.getContext("2d"); + if (ctx) { + const start = performance.now(); + const fade = (now: number) => { + const t = Math.min((now - start) / 150, 1); + exitingAlphaRef.current = 1 - t; + redrawCanvas(ctx, drawStrokesRef.current, visualHighlightRef.current, dimAmountRef.current); + if (t < 1) requestAnimationFrame(fade); + }; + requestAnimationFrame(fade); + } + } + + // Wait for marker exit animation then remove annotation + linked stroke originalSetTimeout(() => { - setAnnotations((prev) => prev.filter((a) => a.id !== id)); + exitingStrokeIdRef.current = null; + // Use strokeId (stable) to find the correct stroke — drawingIndex can be + // stale if other strokes were added/removed during the 150ms wait + const latestAnn = annotationsRef.current.find(a => a.id === id); + const strokeId = latestAnn?.strokeId; + const currentStrokes = drawStrokesRef.current; + const drawingIdx = strokeId ? currentStrokes.findIndex(s => s.id === strokeId) : -1; + + if (drawingIdx >= 0) { + setDrawStrokes(prev => prev.filter(s => s.id !== strokeId)); + setAnnotations(prev => prev + .filter(a => a.id !== id) + .map(a => + a.drawingIndex != null && a.drawingIndex > drawingIdx + ? { ...a, drawingIndex: a.drawingIndex - 1 } + : a + )); + } else { + setAnnotations((prev) => prev.filter((a) => a.id !== id)); + } setExitingMarkers((prev) => { const next = new Set(prev); next.delete(id); @@ -2338,76 +3158,43 @@ export function PageFeedbackToolbarCSS({ setDeletingMarkerId(null); // Trigger renumber animation for markers after deleted one - if (deletedIndex < annotations.length - 1) { - setRenumberFrom(deletedIndex); + const latestAnnotations = annotationsRef.current; + const currentIndex = latestAnnotations.findIndex(a => a.id === id); + if (currentIndex >= 0 && currentIndex < latestAnnotations.length - 1) { + setRenumberFrom(currentIndex); originalSetTimeout(() => setRenumberFrom(null), 200); } }, 150); }, - [annotations, editingAnnotation, onAnnotationDelete, fireWebhook, endpoint], + [editingAnnotation, onAnnotationDelete, fireWebhook, endpoint], ); - // Start editing an annotation (right-click) - const startEditAnnotation = useCallback((annotation: Annotation) => { - setEditingAnnotation(annotation); - setHoveredMarkerId(null); - setHoveredTargetElement(null); - setHoveredTargetElements([]); - - // Try to find elements at the annotation's position(s) for live tracking - if (annotation.elementBoundingBoxes?.length) { - // Cmd+shift+click: find element at each bounding box center - const elements: HTMLElement[] = []; - for (const bb of annotation.elementBoundingBoxes) { - const centerX = bb.x + bb.width / 2; - const centerY = bb.y + bb.height / 2 - window.scrollY; - const el = deepElementFromPoint(centerX, centerY); - if (el) elements.push(el); - } - setEditingTargetElements(elements); - setEditingTargetElement(null); - } else if (annotation.boundingBox) { - // Single element - const bb = annotation.boundingBox; - const centerX = bb.x + bb.width / 2; - // Convert document coords to viewport coords (unless fixed) - const centerY = annotation.isFixed - ? bb.y + bb.height / 2 - : bb.y + bb.height / 2 - window.scrollY; - const el = deepElementFromPoint(centerX, centerY); - - // Validate found element's size roughly matches stored bounding box - if (el) { - const elRect = el.getBoundingClientRect(); - const widthRatio = elRect.width / bb.width; - const heightRatio = elRect.height / bb.height; - if (widthRatio < 0.5 || heightRatio < 0.5) { - setEditingTargetElement(null); - } else { - setEditingTargetElement(el); - } - } else { - setEditingTargetElement(null); - } - setEditingTargetElements([]); - } else { - setEditingTargetElement(null); - setEditingTargetElements([]); - } - }, []); - // Handle marker hover - finds element(s) for live position tracking const handleMarkerHover = useCallback( (annotation: Annotation | null) => { if (!annotation) { + // Start tooltip exit animation synchronously (before clearing hoveredMarkerId) + if (hoveredMarkerId) { + setTooltipExitingId(hoveredMarkerId); + originalSetTimeout(() => setTooltipExitingId(null), 100); + } setHoveredMarkerId(null); setHoveredTargetElement(null); setHoveredTargetElements([]); + setHoveredDrawingIdx(null); return; } + setTooltipExitingId(null); setHoveredMarkerId(annotation.id); + // Highlight linked drawing stroke when marker is hovered + if (annotation.drawingIndex != null && annotation.drawingIndex < drawStrokes.length) { + setHoveredDrawingIdx(annotation.drawingIndex); + } else { + setHoveredDrawingIdx(null); + } + // Find elements at the annotation's position(s) for live tracking if (annotation.elementBoundingBoxes?.length) { // Cmd+shift+click: find element at each bounding box center @@ -2454,7 +3241,7 @@ export function PageFeedbackToolbarCSS({ setHoveredTargetElements([]); } }, - [], + [drawStrokes, hoveredMarkerId], ); // Update annotation (edit mode submit) @@ -2512,7 +3299,7 @@ export function PageFeedbackToolbarCSS({ // Clear all with staggered animation const clearAll = useCallback(() => { const count = annotations.length; - if (count === 0) return; + if (count === 0 && drawStrokes.length === 0) return; // Fire callback with all annotations before clearing onAnnotationsClear?.(annotations); @@ -2535,6 +3322,14 @@ export function PageFeedbackToolbarCSS({ setIsClearing(true); setCleared(true); + // Clear draw strokes + setDrawStrokes([]); + const canvas = drawCanvasRef.current; + if (canvas) { + const ctx = canvas.getContext("2d"); + if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height); + } + const totalAnimationTime = count * 30 + 200; originalSetTimeout(() => { setAnnotations([]); @@ -2544,7 +3339,7 @@ export function PageFeedbackToolbarCSS({ }, totalAnimationTime); originalSetTimeout(() => setCleared(false), 1500); - }, [pathname, annotations, onAnnotationsClear, fireWebhook, endpoint]); + }, [pathname, annotations, drawStrokes, onAnnotationsClear, fireWebhook, endpoint]); // Copy output const copyOutput = useCallback(async () => { @@ -2554,13 +3349,136 @@ export function PageFeedbackToolbarCSS({ window.location.search + window.location.hash : pathname; - const output = generateOutput( + let output = generateOutput( annotations, displayUrl, settings.outputDetail, effectiveReactMode, ); - if (!output) return; + if (!output && drawStrokes.length === 0) return; + if (!output) output = `## Page Feedback: ${displayUrl}\n`; + + // Describe draw strokes as text by detecting elements underneath + if (drawStrokes.length > 0) { + // Collect drawing indices that have linked annotations (skip those in standalone section) + const linkedDrawingIndices = new Set(); + for (const a of annotations) { + if (a.drawingIndex != null) linkedDrawingIndices.add(a.drawingIndex); + } + + // Temporarily hide the draw canvas so elementFromPoint hits real page elements + const canvas = drawCanvasRef.current; + if (canvas) canvas.style.visibility = "hidden"; + + const strokeDescriptions: string[] = []; + const scrollY = window.scrollY; + for (let strokeIdx = 0; strokeIdx < drawStrokes.length; strokeIdx++) { + // Skip strokes that have a linked annotation — their info is in the annotation output + if (linkedDrawingIndices.has(strokeIdx)) continue; + const stroke = drawStrokes[strokeIdx]; + if (stroke.points.length < 2) continue; + + // Get viewport coords for analysis (fixed strokes are already in viewport coords) + const viewportPoints = stroke.fixed + ? stroke.points + : stroke.points.map(p => ({ x: p.x, y: p.y - scrollY })); + + // Bounding box (viewport coords) + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const p of viewportPoints) { + minX = Math.min(minX, p.x); + minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x); + maxY = Math.max(maxY, p.y); + } + const bboxW = maxX - minX; + const bboxH = maxY - minY; + const bboxDiag = Math.hypot(bboxW, bboxH); + + // Start/end analysis + const start = viewportPoints[0]; + const end = viewportPoints[viewportPoints.length - 1]; + const startEndDist = Math.hypot(end.x - start.x, end.y - start.y); + + // Gesture classification + let gesture: "circle" | "box" | "underline" | "arrow" | "drawing"; + const closedLoop = startEndDist < bboxDiag * 0.35; + const aspectRatio = bboxW / Math.max(bboxH, 1); + + if (closedLoop && bboxDiag > 20) { + // Closed loop — circle vs box: measure how many points hug the bbox edges + // Box strokes spend time near edges; circles stay more centered + const edgeThreshold = Math.max(bboxW, bboxH) * 0.15; + let edgePoints = 0; + for (const p of viewportPoints) { + const nearLeft = p.x - minX < edgeThreshold; + const nearRight = maxX - p.x < edgeThreshold; + const nearTop = p.y - minY < edgeThreshold; + const nearBottom = maxY - p.y < edgeThreshold; + if ((nearLeft || nearRight) && (nearTop || nearBottom)) edgePoints++; + } + // If many points are near corners, it's a box + gesture = edgePoints > viewportPoints.length * 0.15 ? "box" : "circle"; + } else if (aspectRatio > 3 && bboxH < 40) { + gesture = "underline"; + } else if (startEndDist > bboxDiag * 0.5) { + gesture = "arrow"; + } else { + gesture = "drawing"; + } + + // Sample elements along the stroke + const sampleCount = Math.min(10, viewportPoints.length); + const step = Math.max(1, Math.floor(viewportPoints.length / sampleCount)); + const seenElements = new Set(); + const elementNames: string[] = []; + + const samplePoints = [start]; + for (let i = step; i < viewportPoints.length - 1; i += step) { + samplePoints.push(viewportPoints[i]); + } + samplePoints.push(end); + + for (const p of samplePoints) { + const el = deepElementFromPoint(p.x, p.y); + if (!el || seenElements.has(el)) continue; + if (closestCrossingShadow(el, "[data-feedback-toolbar]")) continue; + seenElements.add(el); + const { name } = identifyElement(el); + if (!elementNames.includes(name)) { + elementNames.push(name); + } + } + + // Format description + const region = `${Math.round(minX)},${Math.round(minY)} → ${Math.round(maxX)},${Math.round(maxY)}`; + let desc: string; + + if ((gesture === "circle" || gesture === "box") && elementNames.length > 0) { + const verb = gesture === "box" ? "Boxed" : "Circled"; + desc = `${verb} **${elementNames[0]}**${elementNames.length > 1 ? ` (and ${elementNames.slice(1).join(", ")})` : ""} (region: ${region})`; + } else if (gesture === "underline" && elementNames.length > 0) { + desc = `Underlined **${elementNames[0]}** (${region})`; + } else if (gesture === "arrow" && elementNames.length >= 2) { + desc = `Arrow from **${elementNames[0]}** to **${elementNames[elementNames.length - 1]}** (${Math.round(start.x)},${Math.round(start.y)} → ${Math.round(end.x)},${Math.round(end.y)})`; + } else if (elementNames.length > 0) { + desc = `${gesture === "arrow" ? "Arrow" : "Drawing"} near **${elementNames.join("**, **")}** (region: ${region})`; + } else { + desc = `Drawing at ${region}`; + } + strokeDescriptions.push(desc); + } + + // Restore canvas + if (canvas) canvas.style.visibility = ""; + + if (strokeDescriptions.length > 0) { + output += `\n**Drawings:**\n`; + strokeDescriptions.forEach((d, i) => { + output += `${i + 1}. ${d}\n`; + }); + } + } if (copyToClipboard) { try { @@ -2581,6 +3499,7 @@ export function PageFeedbackToolbarCSS({ } }, [ annotations, + drawStrokes, pathname, settings.outputDetail, effectiveReactMode, @@ -2662,15 +3581,15 @@ export function PageFeedbackToolbarCSS({ // Constrain to viewport const padding = 20; - const wrapperWidth = 297; // .toolbar wrapper width + const wrapperWidth = 337; // .toolbar wrapper width const toolbarHeight = 44; // Content is right-aligned within wrapper via margin-left: auto // Calculate content width based on state const contentWidth = isActive ? connectionStatus === "connected" - ? 297 - : 257 + ? 337 + : 297 : 44; // collapsed circle // Content offset from wrapper left edge @@ -2751,7 +3670,7 @@ export function PageFeedbackToolbarCSS({ const constrainPosition = () => { const padding = 20; - const wrapperWidth = 297; // .toolbar wrapper width + const wrapperWidth = 337; // .toolbar wrapper width const toolbarHeight = 44; let newX = toolbarPosition.x; @@ -2803,6 +3722,11 @@ export function PageFeedbackToolbarCSS({ target.isContentEditable; if (e.key === "Escape") { + // Exit draw mode first if active + if (isDrawMode) { + setIsDrawMode(false); + return; + } // Clear multi-select if active if (pendingMultiSelectElements.length > 0) { setPendingMultiSelectElements([]); @@ -2824,6 +3748,21 @@ export function PageFeedbackToolbarCSS({ return; } + // Cmd+Z in draw mode: undo last stroke + if ((e.metaKey || e.ctrlKey) && (e.key === "z" || e.key === "Z") && isDrawMode && !e.shiftKey) { + e.preventDefault(); + setDrawStrokes(prev => { + const next = prev.slice(0, -1); + const canvas = drawCanvasRef.current; + if (canvas) { + const ctx = canvas.getContext("2d"); + if (ctx) redrawCanvas(ctx, next); + } + return next; + }); + return; + } + // Skip other shortcuts if typing or modifier keys are held if (isTyping || e.metaKey || e.ctrlKey) return; @@ -2834,12 +3773,20 @@ export function PageFeedbackToolbarCSS({ toggleFreeze(); } + // "D" to toggle draw mode + if (e.key === "d" || e.key === "D") { + e.preventDefault(); + hideTooltipsUntilMouseLeave(); + setIsDrawMode(prev => !prev); + } + // "H" to toggle marker visibility if (e.key === "h" || e.key === "H") { if (annotations.length > 0) { e.preventDefault(); hideTooltipsUntilMouseLeave(); setShowMarkers((prev) => !prev); + if (isDrawMode) setIsDrawMode(false); } } @@ -2881,6 +3828,7 @@ export function PageFeedbackToolbarCSS({ return () => document.removeEventListener("keydown", handleKeyDown); }, [ isActive, + isDrawMode, pendingAnnotation, annotations.length, settings.webhookUrl, @@ -2890,6 +3838,7 @@ export function PageFeedbackToolbarCSS({ toggleFreeze, copyOutput, clearAll, + redrawCanvas, pendingMultiSelectElements, ]); @@ -3043,6 +3992,24 @@ export function PageFeedbackToolbarCSS({ +
+ + + {isDrawMode ? "Exit draw mode" : "Draw mode"} + D + +
+
+ {/* Draw canvas — outside overlay so it can fade on toolbar close */} + + {/* Markers layer - normal scrolling markers */}
{markersVisible && @@ -3613,6 +4589,7 @@ export function PageFeedbackToolbarCSS({ const showDeleteHover = showDeleteState && settings.markerClickBehavior === "delete"; + return (
!markersExiting && @@ -3667,9 +4646,9 @@ export function PageFeedbackToolbarCSS({ {globalIndex + 1} )} - {isHovered && !editingAnnotation && ( + {(isHovered || tooltipExitingId === annotation.id) && !editingAnnotation && (
@@ -3741,6 +4720,7 @@ export function PageFeedbackToolbarCSS({ const showDeleteHover = showDeleteState && settings.markerClickBehavior === "delete"; + return (
!markersExiting && @@ -3795,9 +4778,9 @@ export function PageFeedbackToolbarCSS({ {globalIndex + 1} )} - {isHovered && !editingAnnotation && ( + {(isHovered || tooltipExitingId === annotation.id) && !editingAnnotation && (
@@ -3852,7 +4835,8 @@ export function PageFeedbackToolbarCSS({ {hoverInfo?.rect && !pendingAnnotation && !isScrolling && - !isDragging && ( + !isDragging && + !isDrawMode && (
a.id === hoveredMarkerId, ); if (!hoveredAnnotation?.boundingBox) return null; + // Drawing-linked annotations highlight the stroke via canvas, not an element box + if (hoveredAnnotation.drawingIndex != null) return null; // Render individual element boxes if available (cmd+shift+click multi-select) if (hoveredAnnotation.elementBoundingBoxes?.length) { @@ -3984,7 +4970,7 @@ export function PageFeedbackToolbarCSS({ })()} {/* Hover tooltip */} - {hoverInfo && !pendingAnnotation && !isScrolling && !isDragging && ( + {hoverInfo && !pendingAnnotation && !isScrolling && !isDragging && !isDrawMode && (
- {/* Show element/area outline while adding annotation */} - {pendingAnnotation.multiSelectElements?.length + {/* Show element/area outline while adding annotation (skip for drawing-linked annotations — drawing is the highlight) */} + {pendingAnnotation.drawingIndex != null + ? null + : pendingAnnotation.multiSelectElements?.length ? // Cmd+shift+click multi-select: show individual boxes with live positions pendingAnnotation.multiSelectElements .filter((el) => document.contains(el)) @@ -4140,8 +5128,10 @@ export function PageFeedbackToolbarCSS({ {/* Edit annotation popup */} {editingAnnotation && ( <> - {/* Show element/area outline while editing */} - {editingAnnotation.elementBoundingBoxes?.length + {/* Show element/area outline while editing (skip for drawing-linked — drawing is the highlight) */} + {editingAnnotation.drawingIndex != null + ? null + : editingAnnotation.elementBoundingBoxes?.length ? // Cmd+shift+click: show individual element boxes (use live rects when available) (() => { // Use live positions from editingTargetElements when available diff --git a/package/src/components/page-toolbar-css/styles.module.scss b/package/src/components/page-toolbar-css/styles.module.scss index ea5102ee..56ab62b8 100644 --- a/package/src/components/page-toolbar-css/styles.module.scss +++ b/package/src/components/page-toolbar-css/styles.module.scss @@ -134,6 +134,17 @@ $green: #34c759; } } +@keyframes tooltipOut { + from { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(0.909); + } + to { + opacity: 0; + transform: translateX(-50%) translateY(2px) scale(0.891); + } +} + @keyframes hoverHighlightIn { from { opacity: 0; @@ -190,7 +201,7 @@ $green: #34c759; position: fixed; bottom: 1.25rem; right: 1.25rem; - width: 297px; + width: 337px; z-index: 100000; font-family: system-ui, @@ -262,10 +273,10 @@ $green: #34c759; height: 44px; border-radius: 1.5rem; padding: 0.375rem; - width: 257px; + width: 297px; &.serverConnected { - width: 297px; + width: 337px; } } } @@ -1007,6 +1018,10 @@ $green: #34c759; &.enter { animation: tooltipIn 0.1s ease-out forwards; } + + &.exit { + animation: tooltipOut 0.1s ease-out forwards; + } } .markerQuote { @@ -2045,6 +2060,27 @@ $green: #34c759; } } +// ============================================================================= +// Draw Canvas +// ============================================================================= + +.drawCanvas { + position: fixed; + inset: 0; + z-index: 99996; // Below markers (99998) and overlay (99997) + // !important needed to override .overlay > * { pointer-events: auto } + pointer-events: none !important; + + &.active { + pointer-events: auto !important; + cursor: crosshair !important; + + &[data-stroke-hover] { + cursor: pointer !important; + } + } +} + // ============================================================================= // Drag Selection // ============================================================================= diff --git a/package/src/types.ts b/package/src/types.ts index 08b6ec6c..265d2088 100644 --- a/package/src/types.ts +++ b/package/src/types.ts @@ -27,6 +27,8 @@ export type Annotation = { width: number; height: number; }>; // Individual bounding boxes for multi-select hover highlighting + drawingIndex?: number; // Index of linked drawing stroke (click-to-annotate) + strokeId?: string; // Unique ID of linked drawing stroke // Protocol fields (added when syncing to server) sessionId?: string;