<!-- This is an auto-generated description by cubic. -->
## Summary by cubic
Adds an in-app screenshot annotator to the Preview panel for Pro users
so you can capture the current app view, draw or add text, and submit an
annotated image to chat.

- **New Features**
- Pen button in PreviewIframe to toggle annotator; captures a screenshot
via worker messaging and displays it in a Konva canvas.
- Tools: select, freehand draw, and draggable text; supports undo/redo,
delete, and resizing with Transformer. Canvas scales to the container.
Includes a color picker.
- Submit exports a PNG and attaches it to the chat via useAttachments;
prefills the chat input; annotator auto-closes after submit.
  - Pro-only: non-Pro users see an upsell screen.
- State atoms added: annotatorModeAtom, screenshotDataUrlAtom,
attachmentsAtom; PreviewIframe now handles dyad-screenshot-response
messages.

- **Dependencies**
  - Added konva, react-konva, perfect-freehand, and html-to-image.
- Proxy now injects html-to-image and the new dyad-screenshot-client.js
for screenshot capture.

<sup>Written for commit 580aca271c5993a0dc7426e36e34393e073bd67b.
Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Mohamed Aziz Mejri
2025-12-13 19:40:31 +01:00
committed by GitHub
parent 86e4005795
commit a4ab1a7f84
17 changed files with 1740 additions and 244 deletions

View File

@@ -0,0 +1,167 @@
import React from "react";
import {
Stage,
Layer,
Image as KonvaImage,
Path,
Text,
Transformer,
} from "react-konva";
import { getStroke } from "perfect-freehand";
// Helper to convert stroke points to SVG path data
function getSvgPathFromStroke(stroke: number[][]) {
if (!stroke.length) return "";
const d = stroke.reduce(
(acc, [x0, y0], i, arr) => {
const [x1, y1] = arr[(i + 1) % arr.length];
acc.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2);
return acc;
},
["M", ...stroke[0], "Q"],
);
d.push("Z");
return d.join(" ");
}
type Point = [number, number];
type Shape =
| {
id: string;
type: "line";
points: Point[];
color: string;
size: number;
isComplete: boolean;
}
| {
id: string;
type: "text";
x: number;
y: number;
text: string;
fontSize: number;
color: string;
};
interface AnnotationCanvasProps {
image: HTMLImageElement | null;
shapes: Shape[];
selectedId: string | null;
tool: "select" | "draw" | "text";
scale: number;
stageDimensions: { width: number; height: number };
containerSize: { width: number; height: number };
stageRef: React.RefObject<any>;
transformerRef: React.RefObject<any>;
onMouseDown: (e: any) => void;
onMouseMove: (e: any) => void;
onMouseUp: () => void;
onShapeSelect: (id: string) => void;
onShapeDragEnd: (id: string, x: number, y: number) => void;
}
export const AnnotationCanvas = ({
image,
shapes,
selectedId,
tool,
scale,
stageDimensions,
containerSize,
stageRef,
transformerRef,
onMouseDown,
onMouseMove,
onMouseUp,
onShapeSelect,
onShapeDragEnd,
}: AnnotationCanvasProps) => {
if (!image || containerSize.width === 0) {
return null;
}
return (
<div className="w-full h-full">
<Stage
width={stageDimensions.width}
height={stageDimensions.height}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onTouchStart={onMouseDown}
onTouchMove={onMouseMove}
onTouchEnd={onMouseUp}
ref={stageRef}
style={{ touchAction: "none" }}
>
<Layer>
<KonvaImage
image={image}
listening={false}
scaleX={scale}
scaleY={scale}
/>
{shapes.map((shape) => {
if (shape.type === "line") {
const stroke = getStroke(shape.points, {
size: shape.size,
thinning: 0.5,
smoothing: 0.5,
streamline: 0.5,
});
const pathData = getSvgPathFromStroke(stroke);
return (
<Path
key={shape.id}
id={shape.id}
data={pathData}
fill={shape.color}
scaleX={scale}
scaleY={scale}
onClick={() => tool === "select" && onShapeSelect(shape.id)}
onTap={() => tool === "select" && onShapeSelect(shape.id)}
draggable={tool === "select"}
/>
);
} else if (shape.type === "text") {
return (
<Text
key={shape.id}
id={shape.id}
x={shape.x}
y={shape.y}
scaleX={scale}
scaleY={scale}
text={shape.text}
fontSize={shape.fontSize * scale}
fill={shape.color}
draggable={tool === "select"}
onClick={() => tool === "select" && onShapeSelect(shape.id)}
onTap={() => tool === "select" && onShapeSelect(shape.id)}
onDragEnd={(e) => {
const node = e.target;
onShapeDragEnd(shape.id, node.x(), node.y());
}}
/>
);
}
return null;
})}
{selectedId && (
<Transformer
ref={transformerRef}
boundBoxFunc={(oldBox, newBox) => {
// Limit resize if needed
if (newBox.width < 5 || newBox.height < 5) {
return oldBox;
}
return newBox;
}}
/>
)}
</Layer>
</Stage>
</div>
);
};

View File

@@ -0,0 +1,408 @@
import React, { useState, useRef, useEffect, useMemo } from "react";
import { AnnotationCanvas } from "./AnnotationCanvas";
import { AnnotatorToolbar } from "@/components/preview_panel/AnnotatorToolbar";
import { DraggableTextInput } from "@/components/preview_panel/DraggableTextInput";
import { useSetAtom } from "jotai";
import { chatInputValueAtom } from "@/atoms/chatAtoms";
// Types
type Point = [number, number];
type Shape =
| {
id: string;
type: "line";
points: Point[];
color: string;
size: number;
isComplete: boolean;
}
| {
id: string;
type: "text";
x: number;
y: number;
text: string;
fontSize: number;
color: string;
};
// Custom Image Hook
const useImage = (url: string) => {
const [image, setImage] = useState<HTMLImageElement | null>(null);
useEffect(() => {
const img = new Image();
img.src = url;
img.onload = () => setImage(img);
}, [url]);
return image;
};
export const Annotator = ({
screenshotUrl,
onSubmit,
handleAnnotatorClick,
}: {
screenshotUrl: string;
onSubmit?: (
file: File[],
type?: "chat-context" | "upload-to-codebase",
) => void;
handleAnnotatorClick: () => void;
}) => {
const image = useImage(screenshotUrl);
const [tool, setTool] = useState<"select" | "draw" | "text">("draw");
const [color, setColor] = useState<string>("#7f22fe");
const [shapes, setShapes] = useState<Shape[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [history, setHistory] = useState<Shape[][]>([]);
const [historyStep, setHistoryStep] = useState(0);
const spanRef = useRef<HTMLSpanElement[]>([]);
const inputRef = useRef<HTMLInputElement[]>([]);
const setChatInput = useSetAtom(chatInputValueAtom);
// Text input state - now supports multiple inputs
const [textInputs, setTextInputs] = useState<
Array<{
id: string;
x: number;
y: number;
adjustedX: number;
adjustedY: number;
value: string;
color: string;
}>
>([]);
// Drawing state
const isDrawing = useRef(false);
const stageRef = useRef<any>(null);
const transformerRef = useRef<any>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Container dimensions
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
// Track container size
useEffect(() => {
if (!containerRef.current) return;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
setContainerSize({ width, height });
}
});
resizeObserver.observe(containerRef.current);
return () => resizeObserver.disconnect();
}, []);
// Initialize history
useEffect(() => {
if (history.length === 0) {
setHistory([[]]);
}
}, []);
// Save history
const saveHistory = (newShapes: Shape[]) => {
const newHistory = history.slice(0, historyStep + 1);
newHistory.push(newShapes);
setHistory(newHistory);
setHistoryStep(newHistory.length - 1);
};
const handleSubmit = async () => {
if (!stageRef.current || !onSubmit) return;
try {
// Auto-submit any pending text inputs
textInputs.forEach((input) => {
if (input.value.trim()) {
const newShape: Shape = {
id: Date.now().toString(),
type: "text",
x: input.x + 32,
y: input.y + 8,
text: input.value,
fontSize: 24,
color: input.color,
};
setShapes((prev) => [...prev, newShape]);
}
});
setTextInputs([]);
// Wait a tick for state to update
await new Promise((resolve) => setTimeout(resolve, 100));
// Export the stage as a blob
const uri = stageRef.current.toDataURL({ pixelRatio: 2 });
// Convert data URL to blob
const response = await fetch(uri);
const blob = await response.blob();
// Create a File from the blob
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const file = new File([blob], `annotated-screenshot-${timestamp}.png`, {
type: "image/png",
});
onSubmit([file], "chat-context");
setChatInput("Please update the UI based on these screenshots");
handleAnnotatorClick();
} catch (error) {
console.error("Failed to export annotated image:", error);
}
};
const handleUndo = () => {
if (historyStep > 0) {
setHistoryStep(historyStep - 1);
setShapes(history[historyStep - 1]);
}
};
const handleRedo = () => {
if (historyStep < history.length - 1) {
setHistoryStep(historyStep + 1);
setShapes(history[historyStep + 1]);
}
};
const handleDelete = () => {
if (selectedId) {
const newShapes = shapes.filter((s) => s.id !== selectedId);
setShapes(newShapes);
setSelectedId(null);
saveHistory(newShapes);
}
};
const handleTextInputChange = (inputId: string, value: string) => {
setTextInputs((prev) =>
prev.map((i) => (i.id === inputId ? { ...i, value } : i)),
);
};
const handleTextInputMove = (
inputId: string,
x: number,
y: number,
adjustedX: number,
adjustedY: number,
) => {
setTextInputs((prev) =>
prev.map((i) =>
i.id === inputId ? { ...i, x, y, adjustedX, adjustedY } : i,
),
);
};
const handleTextInputKeyDown = (
inputId: string,
e: React.KeyboardEvent,
index: number,
) => {
if (e.key === "Enter") {
if (!spanRef.current[index] || !inputRef.current[index]) return;
spanRef.current[index].textContent = inputRef.current[index].value || "";
const width = spanRef.current[index].offsetWidth + 8; // padding
inputRef.current[index].style.width = width + "px";
} else if (e.key === "Escape") {
setTextInputs((prev) => prev.filter((i) => i.id !== inputId));
}
};
const handleTextInputRemove = (inputId: string) => {
setTextInputs((prev) => prev.filter((i) => i.id !== inputId));
};
const handleMouseDown = (e: any) => {
if (tool === "select") {
const clickedOnEmpty = e.target === e.target.getStage();
if (clickedOnEmpty) {
setSelectedId(null);
}
return;
}
const pos = e.target.getStage().getPointerPosition();
if (!pos) return;
// Adjust coordinates for scale
const adjustedPos = {
x: pos.x / scale,
y: pos.y / scale,
};
if (tool === "draw") {
isDrawing.current = true;
const id = Date.now().toString();
const newShape: Shape = {
id,
type: "line",
points: [[adjustedPos.x, adjustedPos.y]],
color: color,
size: 6,
isComplete: false,
};
setShapes([...shapes, newShape]);
setSelectedId(null);
} else if (tool === "text") {
const newInput = {
id: Date.now().toString(),
x: pos.x,
y: pos.y,
adjustedX: adjustedPos.x,
adjustedY: adjustedPos.y,
value: "",
color: color,
};
setTextInputs([...textInputs, newInput]);
}
};
const handleMouseMove = (e: any) => {
if (tool !== "draw" || !isDrawing.current) return;
const stage = e.target.getStage();
const point = stage.getPointerPosition();
if (!point) return;
// Adjust coordinates for scale
const adjustedPoint = {
x: point.x / scale,
y: point.y / scale,
};
const lastShape = shapes[shapes.length - 1];
if (lastShape && lastShape.type === "line") {
// Append point
const newPoints = [
...lastShape.points,
[adjustedPoint.x, adjustedPoint.y] as Point,
];
const updatedShape = { ...lastShape, points: newPoints };
// Update shapes without saving history yet (performance)
const newShapes = shapes.slice(0, -1).concat(updatedShape);
setShapes(newShapes);
}
};
const handleMouseUp = () => {
if (tool === "draw" && isDrawing.current) {
isDrawing.current = false;
const lastShape = shapes[shapes.length - 1];
if (lastShape && lastShape.type === "line") {
const completedShape = { ...lastShape, isComplete: true };
const newShapes = shapes.slice(0, -1).concat(completedShape);
setShapes(newShapes);
saveHistory(newShapes);
}
}
};
// Update transformer selection
useEffect(() => {
if (selectedId && transformerRef.current && stageRef.current) {
const node = stageRef.current.findOne("#" + selectedId);
if (node) {
transformerRef.current.nodes([node]);
transformerRef.current.getLayer().batchDraw();
}
}
}, [selectedId, shapes]);
// Calculate scale to fit image in container
const scale = useMemo(() => {
if (!image || containerSize.width === 0 || containerSize.height === 0)
return 1;
const scaleX = containerSize.width / image.width;
// Fit width and allow scrolling for height
return scaleX;
}, [image, containerSize]);
// Calculate actual stage dimensions
const stageDimensions = useMemo(() => {
if (!image)
return {
width: containerSize.width || 800,
height: containerSize.height || 600,
};
return {
width: image.width * scale,
height: image.height * scale,
};
}, [image, scale, containerSize]);
return (
<div className="w-full h-full flex flex-col bg-gray-50 dark:bg-gray-900">
{/* Toolbar */}
<AnnotatorToolbar
tool={tool}
color={color}
selectedId={selectedId}
historyStep={historyStep}
historyLength={history.length}
onToolChange={setTool}
onColorChange={setColor}
onDelete={handleDelete}
onUndo={handleUndo}
onRedo={handleRedo}
onSubmit={handleSubmit}
onDeactivate={handleAnnotatorClick}
hasSubmitHandler={!!onSubmit}
/>
{/* Canvas Container - Scrollable */}
<div ref={containerRef} className="flex-1 relative overflow-auto">
{textInputs.map((input, index) => (
<DraggableTextInput
key={input.id}
input={input}
index={index}
totalInputs={textInputs.length}
scale={scale}
onMove={handleTextInputMove}
onChange={handleTextInputChange}
onKeyDown={handleTextInputKeyDown}
onRemove={handleTextInputRemove}
spanRef={spanRef}
inputRef={inputRef}
color={input.color}
/>
))}
<AnnotationCanvas
image={image}
shapes={shapes}
selectedId={selectedId}
tool={tool}
scale={scale}
stageDimensions={stageDimensions}
containerSize={containerSize}
stageRef={stageRef}
transformerRef={transformerRef}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onShapeSelect={setSelectedId}
onShapeDragEnd={(id, x, y) => {
const newShapes = shapes.map((s) =>
s.id === id ? { ...s, x, y } : s,
);
setShapes(newShapes);
saveHistory(newShapes);
}}
/>
</div>
</div>
);
};