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 +4810,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))
diff --git a/package/src/components/page-toolbar-css/styles.module.scss b/package/src/components/page-toolbar-css/styles.module.scss
index ea5102ee..a4a92b3c 100644
--- a/package/src/components/page-toolbar-css/styles.module.scss
+++ b/package/src/components/page-toolbar-css/styles.module.scss
@@ -190,7 +190,7 @@ $green: #34c759;
position: fixed;
bottom: 1.25rem;
right: 1.25rem;
- width: 297px;
+ width: 337px;
z-index: 100000;
font-family:
system-ui,
@@ -262,10 +262,10 @@ $green: #34c759;
height: 44px;
border-radius: 1.5rem;
padding: 0.375rem;
- width: 257px;
+ width: 297px;
&.serverConnected {
- width: 297px;
+ width: 337px;
}
}
}
@@ -2045,6 +2045,23 @@ $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;
+ }
+}
+
// =============================================================================
// Drag Selection
// =============================================================================
diff --git a/package/src/types.ts b/package/src/types.ts
index 08b6ec6c..f4478750 100644
--- a/package/src/types.ts
+++ b/package/src/types.ts
@@ -27,6 +27,7 @@ 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)
// Protocol fields (added when syncing to server)
sessionId?: string;
From a547e77a9add967dc5ab0d30583d56c2c1c0e821 Mon Sep 17 00:00:00 2001
From: Benji Taylor <30378142+benjitaylor@users.noreply.github.com>
Date: Wed, 18 Feb 2026 12:46:34 -0800
Subject: [PATCH 02/13] Block drawing while annotation popup is open, improve
hover glow
Shake the popup if you try to draw while it's open. Replace stroke
width change on hover with a soft translucent glow behind the stroke.
---
.../src/components/page-toolbar-css/index.tsx | 64 +++++++++++++------
1 file changed, 43 insertions(+), 21 deletions(-)
diff --git a/package/src/components/page-toolbar-css/index.tsx b/package/src/components/page-toolbar-css/index.tsx
index d648b97d..c13f4d2e 100644
--- a/package/src/components/page-toolbar-css/index.tsx
+++ b/package/src/components/page-toolbar-css/index.tsx
@@ -2420,23 +2420,10 @@ export function PageFeedbackToolbarCSS({
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.save();
ctx.scale(dpr, dpr);
- for (let si = 0; si < strokes.length; si++) {
- const stroke = strokes[si];
- if (stroke.points.length < 2) continue;
- const isHovered = si === hoveredIdx;
- const offsetY = stroke.fixed ? 0 : scrollY;
- ctx.beginPath();
- ctx.strokeStyle = isHovered ? stroke.color : stroke.color;
- ctx.lineWidth = isHovered ? 5 : 3;
- ctx.lineCap = "round";
- ctx.lineJoin = "round";
- if (isHovered) {
- ctx.shadowColor = stroke.color;
- ctx.shadowBlur = 6;
- }
+
+ const tracePath = (stroke: typeof strokes[0], offsetY: number) => {
const p0 = stroke.points[0];
ctx.moveTo(p0.x, p0.y - offsetY);
- // Quadratic curve smoothing through midpoints
for (let i = 1; i < stroke.points.length - 1; i++) {
const curr = stroke.points[i];
const next = stroke.points[i + 1];
@@ -2444,15 +2431,40 @@ export function PageFeedbackToolbarCSS({
const midY = (curr.y + next.y - 2 * offsetY) / 2;
ctx.quadraticCurveTo(curr.x, curr.y - offsetY, midX, midY);
}
- // Line to last point
const last = stroke.points[stroke.points.length - 1];
ctx.lineTo(last.x, last.y - offsetY);
- ctx.stroke();
- if (isHovered) {
- ctx.shadowColor = "transparent";
- ctx.shadowBlur = 0;
+ };
+
+ // Pass 1: glow behind hovered stroke
+ if (hoveredIdx != null && hoveredIdx < strokes.length) {
+ const stroke = strokes[hoveredIdx];
+ if (stroke.points.length >= 2) {
+ const offsetY = stroke.fixed ? 0 : scrollY;
+ ctx.beginPath();
+ ctx.strokeStyle = stroke.color;
+ ctx.lineWidth = 10;
+ ctx.lineCap = "round";
+ ctx.lineJoin = "round";
+ ctx.globalAlpha = 0.25;
+ tracePath(stroke, offsetY);
+ ctx.stroke();
+ ctx.globalAlpha = 1;
}
}
+
+ // Pass 2: all strokes at normal weight
+ for (let si = 0; si < strokes.length; si++) {
+ const stroke = strokes[si];
+ if (stroke.points.length < 2) continue;
+ const offsetY = stroke.fixed ? 0 : scrollY;
+ ctx.beginPath();
+ ctx.strokeStyle = stroke.color;
+ ctx.lineWidth = 3;
+ ctx.lineCap = "round";
+ ctx.lineJoin = "round";
+ tracePath(stroke, offsetY);
+ ctx.stroke();
+ }
ctx.restore();
}, []);
@@ -2469,6 +2481,16 @@ export function PageFeedbackToolbarCSS({
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 };
@@ -2755,7 +2777,7 @@ export function PageFeedbackToolbarCSS({
canvas.removeEventListener("mouseup", handleMouseUp);
canvas.removeEventListener("mouseleave", handleMouseLeave);
};
- }, [isDrawMode, isActive, settings.annotationColor, drawStrokes, annotations, effectiveReactMode, redrawCanvas, startEditAnnotation]);
+ }, [isDrawMode, isActive, settings.annotationColor, drawStrokes, annotations, effectiveReactMode, redrawCanvas, startEditAnnotation, pendingAnnotation, editingAnnotation]);
// Draw mode: resize canvas, redraw on scroll
useEffect(() => {
From 2ba64e0675a4d96f477d60449c5a4d9956e3dd84 Mon Sep 17 00:00:00 2001
From: Benji Taylor <30378142+benjitaylor@users.noreply.github.com>
Date: Wed, 18 Feb 2026 13:05:41 -0800
Subject: [PATCH 03/13] Animate drawing glow highlight, hide drawings with
markers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Drawing glow animates in/out via rAF lerp (0→0.25 alpha) instead of
snapping. Works for both stroke hover and marker hover directions.
- Canvas hidden when marker visibility is toggled off (H key / eye
button), unless actively in draw mode.
---
.../src/components/page-toolbar-css/index.tsx | 59 ++++++++++++++-----
1 file changed, 45 insertions(+), 14 deletions(-)
diff --git a/package/src/components/page-toolbar-css/index.tsx b/package/src/components/page-toolbar-css/index.tsx
index c13f4d2e..e4eef024 100644
--- a/package/src/components/page-toolbar-css/index.tsx
+++ b/package/src/components/page-toolbar-css/index.tsx
@@ -619,6 +619,9 @@ export function PageFeedbackToolbarCSS({
const drawCanvasRef = useRef
(null);
const isDrawingRef = useRef(false);
const currentStrokeRef = useRef>([]);
+ const glowAlphaRef = useRef(0);
+ const glowAnimRef = useRef(null);
+ const glowTargetRef = useRef(null);
// Cmd+shift+click multi-select state
const [pendingMultiSelectElements, setPendingMultiSelectElements] = useState<
@@ -2414,7 +2417,7 @@ export function PageFeedbackToolbarCSS({
}, [isActive, isDragging]);
// Draw mode: redraw helper
- const redrawCanvas = useCallback((ctx: CanvasRenderingContext2D, strokes: typeof drawStrokes, hoveredIdx?: number | null) => {
+ const redrawCanvas = useCallback((ctx: CanvasRenderingContext2D, strokes: typeof drawStrokes, hoveredIdx?: number | null, glowAlpha?: number) => {
const scrollY = window.scrollY;
const dpr = window.devicePixelRatio || 1;
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
@@ -2436,7 +2439,8 @@ export function PageFeedbackToolbarCSS({
};
// Pass 1: glow behind hovered stroke
- if (hoveredIdx != null && hoveredIdx < strokes.length) {
+ const alpha = glowAlpha ?? 0;
+ if (hoveredIdx != null && hoveredIdx < strokes.length && alpha > 0) {
const stroke = strokes[hoveredIdx];
if (stroke.points.length >= 2) {
const offsetY = stroke.fixed ? 0 : scrollY;
@@ -2445,7 +2449,7 @@ export function PageFeedbackToolbarCSS({
ctx.lineWidth = 10;
ctx.lineCap = "round";
ctx.lineJoin = "round";
- ctx.globalAlpha = 0.25;
+ ctx.globalAlpha = alpha;
tracePath(stroke, offsetY);
ctx.stroke();
ctx.globalAlpha = 1;
@@ -2779,7 +2783,7 @@ export function PageFeedbackToolbarCSS({
};
}, [isDrawMode, isActive, settings.annotationColor, drawStrokes, annotations, effectiveReactMode, redrawCanvas, startEditAnnotation, pendingAnnotation, editingAnnotation]);
- // Draw mode: resize canvas, redraw on scroll
+ // Draw mode: resize canvas, redraw on scroll, animate glow highlight
useEffect(() => {
if (!isActive) return;
const canvas = drawCanvasRef.current;
@@ -2787,27 +2791,53 @@ export function PageFeedbackToolbarCSS({
const effectiveHighlight = hoveredDrawingIdx ?? pendingAnnotation?.drawingIndex ?? null;
+ // Animate glow alpha toward target
+ const targetAlpha = effectiveHighlight != null ? 0.25 : 0;
+ if (glowTargetRef.current !== effectiveHighlight) {
+ glowTargetRef.current = effectiveHighlight;
+ // If highlight target changed (different stroke or null), start animating
+ if (glowAnimRef.current) cancelAnimationFrame(glowAnimRef.current);
+
+ const animate = () => {
+ const diff = targetAlpha - glowAlphaRef.current;
+ if (Math.abs(diff) < 0.01) {
+ glowAlphaRef.current = targetAlpha;
+ } else {
+ glowAlphaRef.current += diff * 0.2;
+ }
+ const ctx = canvas.getContext("2d");
+ if (ctx) redrawCanvas(ctx, drawStrokes, effectiveHighlight, glowAlphaRef.current);
+ if (Math.abs(glowAlphaRef.current - targetAlpha) > 0.005) {
+ glowAnimRef.current = requestAnimationFrame(animate);
+ } else {
+ glowAlphaRef.current = targetAlpha;
+ glowAnimRef.current = null;
+ }
+ };
+ glowAnimRef.current = requestAnimationFrame(animate);
+ }
+
+ const draw = () => {
+ const ctx = canvas.getContext("2d");
+ if (ctx) redrawCanvas(ctx, drawStrokes, effectiveHighlight, glowAlphaRef.current);
+ };
+
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, effectiveHighlight);
- };
-
- const onScroll = () => {
- const ctx = canvas.getContext("2d");
- if (ctx) redrawCanvas(ctx, drawStrokes, effectiveHighlight);
+ draw();
};
resize();
window.addEventListener("resize", resize);
- window.addEventListener("scroll", onScroll, { passive: true });
+ window.addEventListener("scroll", draw, { passive: true });
return () => {
window.removeEventListener("resize", resize);
- window.removeEventListener("scroll", onScroll);
+ window.removeEventListener("scroll", draw);
+ if (glowAnimRef.current) cancelAnimationFrame(glowAnimRef.current);
};
}, [isActive, drawStrokes, hoveredDrawingIdx, pendingAnnotation?.drawingIndex, redrawCanvas]);
@@ -4687,10 +4717,11 @@ export function PageFeedbackToolbarCSS({
: undefined
}
>
- {/* Draw canvas */}
+ {/* Draw canvas — hidden when markers are hidden (unless actively in draw mode) */}
{/* Hover highlight */}
From c3731865687c0735f1ff8403385f10c088345147 Mon Sep 17 00:00:00 2001
From: Benji Taylor <30378142+benjitaylor@users.noreply.github.com>
Date: Wed, 18 Feb 2026 13:08:01 -0800
Subject: [PATCH 04/13] Revert "Animate drawing glow highlight, hide drawings
with markers"
This reverts commit fbd9b593df59ae67b8a4440743d2d34c385442f2.
---
.../src/components/page-toolbar-css/index.tsx | 59 +++++--------------
1 file changed, 14 insertions(+), 45 deletions(-)
diff --git a/package/src/components/page-toolbar-css/index.tsx b/package/src/components/page-toolbar-css/index.tsx
index e4eef024..c13f4d2e 100644
--- a/package/src/components/page-toolbar-css/index.tsx
+++ b/package/src/components/page-toolbar-css/index.tsx
@@ -619,9 +619,6 @@ export function PageFeedbackToolbarCSS({
const drawCanvasRef = useRef(null);
const isDrawingRef = useRef(false);
const currentStrokeRef = useRef>([]);
- const glowAlphaRef = useRef(0);
- const glowAnimRef = useRef(null);
- const glowTargetRef = useRef(null);
// Cmd+shift+click multi-select state
const [pendingMultiSelectElements, setPendingMultiSelectElements] = useState<
@@ -2417,7 +2414,7 @@ export function PageFeedbackToolbarCSS({
}, [isActive, isDragging]);
// Draw mode: redraw helper
- const redrawCanvas = useCallback((ctx: CanvasRenderingContext2D, strokes: typeof drawStrokes, hoveredIdx?: number | null, glowAlpha?: number) => {
+ const redrawCanvas = useCallback((ctx: CanvasRenderingContext2D, strokes: typeof drawStrokes, hoveredIdx?: number | null) => {
const scrollY = window.scrollY;
const dpr = window.devicePixelRatio || 1;
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
@@ -2439,8 +2436,7 @@ export function PageFeedbackToolbarCSS({
};
// Pass 1: glow behind hovered stroke
- const alpha = glowAlpha ?? 0;
- if (hoveredIdx != null && hoveredIdx < strokes.length && alpha > 0) {
+ if (hoveredIdx != null && hoveredIdx < strokes.length) {
const stroke = strokes[hoveredIdx];
if (stroke.points.length >= 2) {
const offsetY = stroke.fixed ? 0 : scrollY;
@@ -2449,7 +2445,7 @@ export function PageFeedbackToolbarCSS({
ctx.lineWidth = 10;
ctx.lineCap = "round";
ctx.lineJoin = "round";
- ctx.globalAlpha = alpha;
+ ctx.globalAlpha = 0.25;
tracePath(stroke, offsetY);
ctx.stroke();
ctx.globalAlpha = 1;
@@ -2783,7 +2779,7 @@ export function PageFeedbackToolbarCSS({
};
}, [isDrawMode, isActive, settings.annotationColor, drawStrokes, annotations, effectiveReactMode, redrawCanvas, startEditAnnotation, pendingAnnotation, editingAnnotation]);
- // Draw mode: resize canvas, redraw on scroll, animate glow highlight
+ // Draw mode: resize canvas, redraw on scroll
useEffect(() => {
if (!isActive) return;
const canvas = drawCanvasRef.current;
@@ -2791,53 +2787,27 @@ export function PageFeedbackToolbarCSS({
const effectiveHighlight = hoveredDrawingIdx ?? pendingAnnotation?.drawingIndex ?? null;
- // Animate glow alpha toward target
- const targetAlpha = effectiveHighlight != null ? 0.25 : 0;
- if (glowTargetRef.current !== effectiveHighlight) {
- glowTargetRef.current = effectiveHighlight;
- // If highlight target changed (different stroke or null), start animating
- if (glowAnimRef.current) cancelAnimationFrame(glowAnimRef.current);
-
- const animate = () => {
- const diff = targetAlpha - glowAlphaRef.current;
- if (Math.abs(diff) < 0.01) {
- glowAlphaRef.current = targetAlpha;
- } else {
- glowAlphaRef.current += diff * 0.2;
- }
- const ctx = canvas.getContext("2d");
- if (ctx) redrawCanvas(ctx, drawStrokes, effectiveHighlight, glowAlphaRef.current);
- if (Math.abs(glowAlphaRef.current - targetAlpha) > 0.005) {
- glowAnimRef.current = requestAnimationFrame(animate);
- } else {
- glowAlphaRef.current = targetAlpha;
- glowAnimRef.current = null;
- }
- };
- glowAnimRef.current = requestAnimationFrame(animate);
- }
-
- const draw = () => {
- const ctx = canvas.getContext("2d");
- if (ctx) redrawCanvas(ctx, drawStrokes, effectiveHighlight, glowAlphaRef.current);
- };
-
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;
- draw();
+ const ctx = canvas.getContext("2d");
+ if (ctx) redrawCanvas(ctx, drawStrokes, effectiveHighlight);
+ };
+
+ const onScroll = () => {
+ const ctx = canvas.getContext("2d");
+ if (ctx) redrawCanvas(ctx, drawStrokes, effectiveHighlight);
};
resize();
window.addEventListener("resize", resize);
- window.addEventListener("scroll", draw, { passive: true });
+ window.addEventListener("scroll", onScroll, { passive: true });
return () => {
window.removeEventListener("resize", resize);
- window.removeEventListener("scroll", draw);
- if (glowAnimRef.current) cancelAnimationFrame(glowAnimRef.current);
+ window.removeEventListener("scroll", onScroll);
};
}, [isActive, drawStrokes, hoveredDrawingIdx, pendingAnnotation?.drawingIndex, redrawCanvas]);
@@ -4717,11 +4687,10 @@ export function PageFeedbackToolbarCSS({
: undefined
}
>
- {/* Draw canvas — hidden when markers are hidden (unless actively in draw mode) */}
+ {/* Draw canvas */}
{/* Hover highlight */}
From 46b6ce42446ddb4b82a32d0663017850a407af09 Mon Sep 17 00:00:00 2001
From: Benji Taylor <30378142+benjitaylor@users.noreply.github.com>
Date: Wed, 18 Feb 2026 13:09:41 -0800
Subject: [PATCH 05/13] Animate drawing glow in/out, hide drawings with markers
Glow highlight lerps via rAF (separate effect, doesn't touch resize/
scroll). Canvas hidden when marker visibility toggled off.
---
.../src/components/page-toolbar-css/index.tsx | 38 ++++++++++++++++++-
1 file changed, 36 insertions(+), 2 deletions(-)
diff --git a/package/src/components/page-toolbar-css/index.tsx b/package/src/components/page-toolbar-css/index.tsx
index c13f4d2e..c3421b14 100644
--- a/package/src/components/page-toolbar-css/index.tsx
+++ b/package/src/components/page-toolbar-css/index.tsx
@@ -619,6 +619,7 @@ export function PageFeedbackToolbarCSS({
const drawCanvasRef = useRef(null);
const isDrawingRef = useRef(false);
const currentStrokeRef = useRef>([]);
+ const glowAlphaRef = useRef(0);
// Cmd+shift+click multi-select state
const [pendingMultiSelectElements, setPendingMultiSelectElements] = useState<
@@ -2414,7 +2415,7 @@ export function PageFeedbackToolbarCSS({
}, [isActive, isDragging]);
// Draw mode: redraw helper
- const redrawCanvas = useCallback((ctx: CanvasRenderingContext2D, strokes: typeof drawStrokes, hoveredIdx?: number | null) => {
+ const redrawCanvas = useCallback((ctx: CanvasRenderingContext2D, strokes: typeof drawStrokes, hoveredIdx?: number | null, glowAlpha = 0.25) => {
const scrollY = window.scrollY;
const dpr = window.devicePixelRatio || 1;
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
@@ -2445,7 +2446,7 @@ export function PageFeedbackToolbarCSS({
ctx.lineWidth = 10;
ctx.lineCap = "round";
ctx.lineJoin = "round";
- ctx.globalAlpha = 0.25;
+ ctx.globalAlpha = glowAlpha;
tracePath(stroke, offsetY);
ctx.stroke();
ctx.globalAlpha = 1;
@@ -2811,6 +2812,38 @@ export function PageFeedbackToolbarCSS({
};
}, [isActive, drawStrokes, hoveredDrawingIdx, pendingAnnotation?.drawingIndex, redrawCanvas]);
+ // Animate glow highlight in/out
+ useEffect(() => {
+ const canvas = drawCanvasRef.current;
+ if (!canvas || !isActive || drawStrokes.length === 0) return;
+
+ const effectiveHighlight = hoveredDrawingIdx ?? pendingAnnotation?.drawingIndex ?? null;
+ const targetAlpha = effectiveHighlight != null ? 0.25 : 0;
+
+ // Already at target — no animation needed
+ if (Math.abs(glowAlphaRef.current - targetAlpha) < 0.005) {
+ glowAlphaRef.current = targetAlpha;
+ return;
+ }
+
+ let raf: number;
+ const animate = () => {
+ const diff = targetAlpha - glowAlphaRef.current;
+ if (Math.abs(diff) < 0.005) {
+ glowAlphaRef.current = targetAlpha;
+ } else {
+ glowAlphaRef.current += diff * 0.2;
+ }
+ const ctx = canvas.getContext("2d");
+ if (ctx) redrawCanvas(ctx, drawStrokes, effectiveHighlight, glowAlphaRef.current);
+ if (Math.abs(glowAlphaRef.current - targetAlpha) > 0.005) {
+ raf = requestAnimationFrame(animate);
+ }
+ };
+ raf = requestAnimationFrame(animate);
+ return () => cancelAnimationFrame(raf);
+ }, [isActive, hoveredDrawingIdx, pendingAnnotation?.drawingIndex, drawStrokes, redrawCanvas]);
+
// Fire webhook for annotation events - returns true on success, false on failure
const fireWebhook = useCallback(
async (
@@ -4691,6 +4724,7 @@ export function PageFeedbackToolbarCSS({
{/* Hover highlight */}
From ec2b54d51fdc1866f0134279f5489bf20fbc1ce6 Mon Sep 17 00:00:00 2001
From: Benji Taylor <30378142+benjitaylor@users.noreply.github.com>
Date: Wed, 18 Feb 2026 13:12:12 -0800
Subject: [PATCH 06/13] Replace glow with shadowBlur, animate via rAF
Single-pass rendering with canvas shadowBlur on hovered stroke instead
of the two-pass wide translucent stroke. glowIntensity 0-1 controls
blur radius (0-12px), animated in/out via rAF lerp.
---
.../src/components/page-toolbar-css/index.tsx | 37 +++++++------------
1 file changed, 14 insertions(+), 23 deletions(-)
diff --git a/package/src/components/page-toolbar-css/index.tsx b/package/src/components/page-toolbar-css/index.tsx
index c3421b14..f6ccf5cd 100644
--- a/package/src/components/page-toolbar-css/index.tsx
+++ b/package/src/components/page-toolbar-css/index.tsx
@@ -2414,8 +2414,8 @@ export function PageFeedbackToolbarCSS({
return () => document.removeEventListener("mouseup", handleMouseUp);
}, [isActive, isDragging]);
- // Draw mode: redraw helper
- const redrawCanvas = useCallback((ctx: CanvasRenderingContext2D, strokes: typeof drawStrokes, hoveredIdx?: number | null, glowAlpha = 0.25) => {
+ // Draw mode: redraw helper — glowIntensity 0–1 controls shadow on hovered stroke
+ const redrawCanvas = useCallback((ctx: CanvasRenderingContext2D, strokes: typeof drawStrokes, hoveredIdx?: number | null, glowIntensity = 0) => {
const scrollY = window.scrollY;
const dpr = window.devicePixelRatio || 1;
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
@@ -2436,35 +2436,26 @@ export function PageFeedbackToolbarCSS({
ctx.lineTo(last.x, last.y - offsetY);
};
- // Pass 1: glow behind hovered stroke
- if (hoveredIdx != null && hoveredIdx < strokes.length) {
- const stroke = strokes[hoveredIdx];
- if (stroke.points.length >= 2) {
- const offsetY = stroke.fixed ? 0 : scrollY;
- ctx.beginPath();
- ctx.strokeStyle = stroke.color;
- ctx.lineWidth = 10;
- ctx.lineCap = "round";
- ctx.lineJoin = "round";
- ctx.globalAlpha = glowAlpha;
- tracePath(stroke, offsetY);
- ctx.stroke();
- ctx.globalAlpha = 1;
- }
- }
-
- // Pass 2: all strokes at normal weight
for (let si = 0; si < strokes.length; si++) {
const stroke = strokes[si];
if (stroke.points.length < 2) continue;
const offsetY = stroke.fixed ? 0 : scrollY;
+ const isHovered = si === hoveredIdx && glowIntensity > 0;
ctx.beginPath();
ctx.strokeStyle = stroke.color;
ctx.lineWidth = 3;
ctx.lineCap = "round";
ctx.lineJoin = "round";
+ if (isHovered) {
+ ctx.shadowColor = stroke.color;
+ ctx.shadowBlur = 12 * glowIntensity;
+ }
tracePath(stroke, offsetY);
ctx.stroke();
+ if (isHovered) {
+ ctx.shadowColor = "transparent";
+ ctx.shadowBlur = 0;
+ }
}
ctx.restore();
}, []);
@@ -2795,12 +2786,12 @@ export function PageFeedbackToolbarCSS({
canvas.width = window.innerWidth * dpr;
canvas.height = window.innerHeight * dpr;
const ctx = canvas.getContext("2d");
- if (ctx) redrawCanvas(ctx, drawStrokes, effectiveHighlight);
+ if (ctx) redrawCanvas(ctx, drawStrokes, effectiveHighlight, glowAlphaRef.current);
};
const onScroll = () => {
const ctx = canvas.getContext("2d");
- if (ctx) redrawCanvas(ctx, drawStrokes, effectiveHighlight);
+ if (ctx) redrawCanvas(ctx, drawStrokes, effectiveHighlight, glowAlphaRef.current);
};
resize();
@@ -2818,7 +2809,7 @@ export function PageFeedbackToolbarCSS({
if (!canvas || !isActive || drawStrokes.length === 0) return;
const effectiveHighlight = hoveredDrawingIdx ?? pendingAnnotation?.drawingIndex ?? null;
- const targetAlpha = effectiveHighlight != null ? 0.25 : 0;
+ const targetAlpha = effectiveHighlight != null ? 1 : 0;
// Already at target — no animation needed
if (Math.abs(glowAlphaRef.current - targetAlpha) < 0.005) {
From 298b2792718a070603d29e5abccbc96226642d1b Mon Sep 17 00:00:00 2001
From: Benji Taylor <30378142+benjitaylor@users.noreply.github.com>
Date: Wed, 18 Feb 2026 14:55:23 -0800
Subject: [PATCH 07/13] Drawing animation overhaul: dim instead of glow,
tooltip exit, canvas fade
- Replace shadowBlur glow with globalAlpha dim (non-hovered strokes at 30%)
- Add tooltip exit animation (100ms fade out on unhover)
- Move canvas outside overlay so it fades on toolbar close
- Fix marker exit timing: compute timeout from stagger delay + animation
- Fix marker enter timing: same, prevents class removal mid-animation
- Skip stagger delay for individually added markers (only batch entrance)
- Remove scale(1.3) on markers when linked drawing is hovered
- Add drawCanvasFading state for deletion fade
- Use visibility:hidden for hit-test hiding (canvas uses opacity now)
---
.../src/components/page-toolbar-css/index.tsx | 137 ++++++++----------
.../page-toolbar-css/styles.module.scss | 15 ++
2 files changed, 76 insertions(+), 76 deletions(-)
diff --git a/package/src/components/page-toolbar-css/index.tsx b/package/src/components/page-toolbar-css/index.tsx
index f6ccf5cd..33e2d6cb 100644
--- a/package/src/components/page-toolbar-css/index.tsx
+++ b/package/src/components/page-toolbar-css/index.tsx
@@ -585,6 +585,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<
@@ -619,7 +620,8 @@ export function PageFeedbackToolbarCSS({
const drawCanvasRef = useRef(null);
const isDrawingRef = useRef(false);
const currentStrokeRef = useRef>([]);
- const glowAlphaRef = useRef(0);
+ const [drawCanvasFading, setDrawCanvasFading] = useState(false);
+
// Cmd+shift+click multi-select state
const [pendingMultiSelectElements, setPendingMultiSelectElements] = useState<
@@ -843,21 +845,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
@@ -1596,7 +1602,10 @@ export function PageFeedbackToolbarCSS({
const target = (e.composedPath()[0] || e.target) as HTMLElement;
if (closestCrossingShadow(target, "[data-feedback-toolbar]")) {
setHoverInfo(null);
- setHoveredDrawingIdx(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;
}
@@ -1735,9 +1744,9 @@ export function PageFeedbackToolbarCSS({
// Temporarily hide canvas to find element underneath
const canvas = drawCanvasRef.current;
- if (canvas) canvas.style.display = "none";
+ if (canvas) canvas.style.visibility = "hidden";
const elementUnder = deepElementFromPoint(centerX, centerY);
- if (canvas) canvas.style.display = "";
+ if (canvas) canvas.style.visibility = "";
const gestureShape = classifyStrokeGesture(stroke.points, stroke.fixed);
let name = `Drawing: ${gestureShape}`;
@@ -2414,8 +2423,8 @@ export function PageFeedbackToolbarCSS({
return () => document.removeEventListener("mouseup", handleMouseUp);
}, [isActive, isDragging]);
- // Draw mode: redraw helper — glowIntensity 0–1 controls shadow on hovered stroke
- const redrawCanvas = useCallback((ctx: CanvasRenderingContext2D, strokes: typeof drawStrokes, hoveredIdx?: number | null, glowIntensity = 0) => {
+ // Draw mode: redraw helper — dims non-hovered strokes to 30% when one is highlighted
+ const redrawCanvas = useCallback((ctx: CanvasRenderingContext2D, strokes: typeof drawStrokes, hoveredIdx?: number | null) => {
const scrollY = window.scrollY;
const dpr = window.devicePixelRatio || 1;
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
@@ -2440,23 +2449,16 @@ export function PageFeedbackToolbarCSS({
const stroke = strokes[si];
if (stroke.points.length < 2) continue;
const offsetY = stroke.fixed ? 0 : scrollY;
- const isHovered = si === hoveredIdx && glowIntensity > 0;
+ ctx.globalAlpha = (hoveredIdx != null && si !== hoveredIdx) ? 0.3 : 1;
ctx.beginPath();
ctx.strokeStyle = stroke.color;
ctx.lineWidth = 3;
ctx.lineCap = "round";
ctx.lineJoin = "round";
- if (isHovered) {
- ctx.shadowColor = stroke.color;
- ctx.shadowBlur = 12 * glowIntensity;
- }
tracePath(stroke, offsetY);
ctx.stroke();
- if (isHovered) {
- ctx.shadowColor = "transparent";
- ctx.shadowBlur = 0;
- }
}
+ ctx.globalAlpha = 1;
ctx.restore();
}, []);
@@ -2565,9 +2567,9 @@ export function PageFeedbackToolbarCSS({
const centerY = (minY + maxY) / 2;
// Temporarily hide canvas to find element underneath
- canvas.style.display = "none";
+ canvas.style.visibility = "hidden";
const elementUnder = deepElementFromPoint(centerX, centerY);
- canvas.style.display = "";
+ canvas.style.visibility = "";
const gestureShape = classifyStrokeGesture(stroke.points, stroke.fixed);
let name = `Drawing: ${gestureShape}`;
@@ -2636,7 +2638,7 @@ export function PageFeedbackToolbarCSS({
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.display = "none";
+ canvas.style.visibility = "hidden";
const isElFixed = (el: HTMLElement): boolean => {
let node: HTMLElement | null = el;
@@ -2720,7 +2722,7 @@ export function PageFeedbackToolbarCSS({
};
}
- canvas.style.display = "";
+ canvas.style.visibility = "";
setDrawStrokes(prev => [...prev, newStroke]);
@@ -2786,12 +2788,12 @@ export function PageFeedbackToolbarCSS({
canvas.width = window.innerWidth * dpr;
canvas.height = window.innerHeight * dpr;
const ctx = canvas.getContext("2d");
- if (ctx) redrawCanvas(ctx, drawStrokes, effectiveHighlight, glowAlphaRef.current);
+ if (ctx) redrawCanvas(ctx, drawStrokes, effectiveHighlight);
};
const onScroll = () => {
const ctx = canvas.getContext("2d");
- if (ctx) redrawCanvas(ctx, drawStrokes, effectiveHighlight, glowAlphaRef.current);
+ if (ctx) redrawCanvas(ctx, drawStrokes, effectiveHighlight);
};
resize();
@@ -2803,36 +2805,14 @@ export function PageFeedbackToolbarCSS({
};
}, [isActive, drawStrokes, hoveredDrawingIdx, pendingAnnotation?.drawingIndex, redrawCanvas]);
- // Animate glow highlight in/out
+ // Redraw canvas when hover highlight changes (dim non-hovered strokes)
useEffect(() => {
const canvas = drawCanvasRef.current;
if (!canvas || !isActive || drawStrokes.length === 0) return;
-
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
const effectiveHighlight = hoveredDrawingIdx ?? pendingAnnotation?.drawingIndex ?? null;
- const targetAlpha = effectiveHighlight != null ? 1 : 0;
-
- // Already at target — no animation needed
- if (Math.abs(glowAlphaRef.current - targetAlpha) < 0.005) {
- glowAlphaRef.current = targetAlpha;
- return;
- }
-
- let raf: number;
- const animate = () => {
- const diff = targetAlpha - glowAlphaRef.current;
- if (Math.abs(diff) < 0.005) {
- glowAlphaRef.current = targetAlpha;
- } else {
- glowAlphaRef.current += diff * 0.2;
- }
- const ctx = canvas.getContext("2d");
- if (ctx) redrawCanvas(ctx, drawStrokes, effectiveHighlight, glowAlphaRef.current);
- if (Math.abs(glowAlphaRef.current - targetAlpha) > 0.005) {
- raf = requestAnimationFrame(animate);
- }
- };
- raf = requestAnimationFrame(animate);
- return () => cancelAnimationFrame(raf);
+ redrawCanvas(ctx, drawStrokes, effectiveHighlight);
}, [isActive, hoveredDrawingIdx, pendingAnnotation?.drawingIndex, drawStrokes, redrawCanvas]);
// Fire webhook for annotation events - returns true on success, false on failure
@@ -3025,6 +3005,7 @@ export function PageFeedbackToolbarCSS({
// Also delete linked drawing stroke
const drawingIdx = deletedAnnotation?.drawingIndex;
+ if (drawingIdx != null) setDrawCanvasFading(true);
// Wait for exit animation then remove
originalSetTimeout(() => {
@@ -3043,13 +3024,7 @@ export function PageFeedbackToolbarCSS({
// Remove the linked drawing stroke
if (drawingIdx != null) {
setDrawStrokes(prev => prev.filter((_, i) => i !== drawingIdx));
- const canvas = drawCanvasRef.current;
- if (canvas) {
- const ctx = canvas.getContext("2d");
- if (ctx) {
- // Redraw will happen via the effect when drawStrokes changes
- }
- }
+ setDrawCanvasFading(false);
}
setExitingMarkers((prev) => {
const next = new Set(prev);
@@ -3072,6 +3047,11 @@ export function PageFeedbackToolbarCSS({
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([]);
@@ -3079,6 +3059,7 @@ export function PageFeedbackToolbarCSS({
return;
}
+ setTooltipExitingId(null);
setHoveredMarkerId(annotation.id);
// Highlight linked drawing stroke when marker is hovered
@@ -3134,7 +3115,7 @@ export function PageFeedbackToolbarCSS({
setHoveredTargetElements([]);
}
},
- [drawStrokes],
+ [drawStrokes, hoveredMarkerId],
);
// Update annotation (edit mode submit)
@@ -3261,7 +3242,7 @@ export function PageFeedbackToolbarCSS({
// Temporarily hide the draw canvas so elementFromPoint hits real page elements
const canvas = drawCanvasRef.current;
- if (canvas) canvas.style.display = "none";
+ if (canvas) canvas.style.visibility = "hidden";
const strokeDescriptions: string[] = [];
const scrollY = window.scrollY;
@@ -3363,7 +3344,7 @@ export function PageFeedbackToolbarCSS({
}
// Restore canvas
- if (canvas) canvas.style.display = "";
+ if (canvas) canvas.style.visibility = "";
if (strokeDescriptions.length > 0) {
output += `\n**Drawings:**\n`;
@@ -4443,6 +4424,14 @@ export function PageFeedbackToolbarCSS({