From d3aaa1ca6a1b3d6f90eb61ad7bba83b5459e791b Mon Sep 17 00:00:00 2001 From: acwelst Date: Wed, 19 Aug 2026 18:10:28 +1000 Subject: [PATCH 1/6] feat: add freehand draw tool to screenshot editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I needed the ability to quickly annotate and mark up screenshots with freehand strokes — circling things, underlining, scribbling notes — without reaching for an external tool. The existing shape tools (arrow, rectangle, circle) are great for precise callouts but too rigid for quick markup. This adds a Draw tool (pencil icon, keyboard shortcut D) to the screenshot editor toolbar, sitting between Select and Arrow. It records freehand strokes as normalized point data so the drawing scales correctly with resize and preserves its shape across edits. - New `draw` variant in AnnotationType (Rust + TS) - `points` field on Annotation struct for path data - Smooth quadratic bezier rendering (SVG path + canvas export) - Minimum distance filter to keep point arrays reasonable - Works with existing stroke color, width, and opacity controls Co-authored-by: Cursor --- .../screenshot-editor/AnnotationLayer.tsx | 97 ++++++++++++++++++- .../screenshot-editor/AnnotationTools.tsx | 7 ++ .../src/routes/screenshot-editor/Editor.tsx | 4 + .../screenshot-editor/screenshotExport.ts | 20 ++++ apps/desktop/src/utils/tauri.ts | 4 +- crates/project/src/configuration.rs | 3 + 6 files changed, 132 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx b/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx index 385f81b77dd..997257854ab 100644 --- a/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx +++ b/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx @@ -215,6 +215,7 @@ export function AnnotationLayer(props: { text: tool === "text" ? "Text" : null, maskType: tool === "mask" ? "pixelate" : null, maskLevel: tool === "mask" ? 7 : null, + points: tool === "draw" ? [[startX, startY]] : null, }; if (tool === "text") { @@ -238,6 +239,29 @@ export function AnnotationLayer(props: { if (!temp) return; if (temp.type === "text") return; + if (temp.type === "draw" && temp.points) { + const last = temp.points[temp.points.length - 1]; + const dx2 = point.x - last[0]; + const dy2 = point.y - last[1]; + if (dx2 * dx2 + dy2 * dy2 < 4) return; + const newPoints: [number, number][] = [...temp.points, [point.x, point.y]]; + const xs = newPoints.map((p) => p[0]); + const ys = newPoints.map((p) => p[1]); + const minX = Math.min(...xs); + const minY = Math.min(...ys); + const maxX = Math.max(...xs); + const maxY = Math.max(...ys); + setTempAnnotation({ + ...temp, + points: newPoints, + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }); + return; + } + const currentX = temp.type === "mask" ? clampValue( @@ -403,6 +427,30 @@ export function AnnotationLayer(props: { const tempAnn = tempAnnotation(); if (isDrawing() && tempAnn) { const ann = { ...tempAnn }; + + if (ann.type === "draw") { + if (!ann.points || ann.points.length < 2) { + setTempAnnotation(null); + setIsDrawing(false); + drawSnapshot = null; + return; + } + const w = ann.width || 1; + const h = ann.height || 1; + ann.points = ann.points.map((p) => [ + (p[0] - ann.x) / w, + (p[1] - ann.y) / h, + ] as [number, number]); + if (drawSnapshot) projectHistory.push(drawSnapshot); + drawSnapshot = null; + setAnnotations((prev) => [...prev, ann]); + setTempAnnotation(null); + setIsDrawing(false); + setActiveTool("select"); + setSelectedAnnotationId(ann.id); + return; + } + if ( ann.type === "rectangle" || ann.type === "circle" || @@ -683,12 +731,45 @@ export function AnnotationLayer(props: { )} - {(ann) => } + {(ann) => ( + = 2} + fallback={} + > + + + )} ); } +function smoothPathFromPoints(points: [number, number][]): string { + if (points.length < 2) return ""; + if (points.length === 2) { + return `M ${points[0][0]},${points[0][1]} L ${points[1][0]},${points[1][1]}`; + } + let d = `M ${points[0][0]},${points[0][1]}`; + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[i]; + const p1 = points[i + 1]; + const mx = (p0[0] + p1[0]) / 2; + const my = (p0[1] + p1[1]) / 2; + d += ` Q ${p0[0]},${p0[1]} ${mx},${my}`; + } + const last = points[points.length - 1]; + d += ` L ${last[0]},${last[1]}`; + return d; +} + function RenderAnnotation(props: { annotation: Annotation }) { return ( <> @@ -786,6 +867,20 @@ function RenderAnnotation(props: { annotation: Annotation }) { style={{ "pointer-events": "all" }} /> )} + {props.annotation.type === "draw" && props.annotation.points && props.annotation.points.length >= 2 && ( + [ + props.annotation.x + p[0] * (props.annotation.width || 1), + props.annotation.y + p[1] * (props.annotation.height || 1), + ] as [number, number]))} + fill="none" + stroke={props.annotation.strokeColor} + stroke-width={props.annotation.strokeWidth} + stroke-linecap="round" + stroke-linejoin="round" + opacity={props.annotation.opacity} + /> + )} ); } diff --git a/apps/desktop/src/routes/screenshot-editor/AnnotationTools.tsx b/apps/desktop/src/routes/screenshot-editor/AnnotationTools.tsx index 905a0824e7d..18cb25a0582 100644 --- a/apps/desktop/src/routes/screenshot-editor/AnnotationTools.tsx +++ b/apps/desktop/src/routes/screenshot-editor/AnnotationTools.tsx @@ -6,6 +6,7 @@ import IconLucideCircle from "~icons/lucide/circle"; import IconLucideEyeOff from "~icons/lucide/eye-off"; import IconLucideLayers from "~icons/lucide/layers"; import IconLucideMousePointer2 from "~icons/lucide/mouse-pointer-2"; +import IconLucidePencil from "~icons/lucide/pencil"; import IconLucideSquare from "~icons/lucide/square"; import IconLucideType from "~icons/lucide/type"; import { @@ -39,6 +40,12 @@ export function AnnotationTools() { label="Select" shortcut="V" /> + = 2) { + ctx.beginPath(); + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + const w = ann.width || 1; + const h = ann.height || 1; + const pts = ann.points.map((p) => [ann.x + p[0] * w, ann.y + p[1] * h] as [number, number]); + ctx.moveTo(pts[0][0], pts[0][1]); + if (pts.length === 2) { + ctx.lineTo(pts[1][0], pts[1][1]); + } else { + for (let i = 0; i < pts.length - 1; i++) { + const mx = (pts[i][0] + pts[i + 1][0]) / 2; + const my = (pts[i][1] + pts[i + 1][1]) / 2; + ctx.quadraticCurveTo(pts[i][0], pts[i][1], mx, my); + } + const last = pts[pts.length - 1]; + ctx.lineTo(last[0], last[1]); + } + ctx.stroke(); } else if (ann.type === "text" && ann.text) { ctx.fillStyle = ann.strokeColor; ctx.font = `${ann.height}px sans-serif`; diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 862333938f3..f4705e44fc8 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -569,8 +569,8 @@ videoImportProgress: "video-import-progress" /** user-defined types **/ export type Action = { type: "copyToClipboard"; source?: ClipboardSource } | { type: "saveToLocation"; dir: string; filenameTemplate?: string | null } | { type: "export"; profile: ExportProfile; destination?: ExportDestination } | { type: "upload"; organizationId?: string | null; copyLink?: boolean; openInBrowser?: boolean } | { type: "revealInFileManager" } | { type: "openFile" } | { type: "runCommand"; program: string; args?: string[]; cwd?: string | null; env?: { [key in string]: string }; useShell?: boolean } | { type: "webhook"; url: string; method?: string; headers?: { [key in string]: string }; bodyTemplate?: string | null } | { type: "recognizeTextToClipboard" } | { type: "notify"; titleTemplate?: string; bodyTemplate?: string } | { type: "openEditor" } | { type: "skipEditor" } | { type: "applyPreset"; name: string } | { type: "deleteLocalFiles" } -export type Annotation = { id: string; type: AnnotationType; x: number; y: number; width: number; height: number; strokeColor: string; strokeWidth: number; fillColor: string; opacity: number; rotation: number; text: string | null; maskType?: MaskType | null; maskLevel?: number | null } -export type AnnotationType = "arrow" | "circle" | "rectangle" | "text" | "mask" +export type Annotation = { id: string; type: AnnotationType; x: number; y: number; width: number; height: number; strokeColor: string; strokeWidth: number; fillColor: string; opacity: number; rotation: number; text: string | null; maskType?: MaskType | null; maskLevel?: number | null; points?: ([number, number])[] | null } +export type AnnotationType = "arrow" | "circle" | "rectangle" | "text" | "mask" | "draw" export type AppTheme = "system" | "light" | "dark" export type AspectRatio = "wide" | "vertical" | "square" | "classic" | "tall" export type Audio = { duration: number; sample_rate: number; channels: number; start_time: number } diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 6b647c7b024..078c6aa6bab 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -2106,6 +2106,7 @@ pub enum AnnotationType { Rectangle, Text, Mask, + Draw, } #[derive(Type, Serialize, Deserialize, Clone, Copy, Debug, PartialEq)] @@ -2178,6 +2179,8 @@ pub struct Annotation { pub mask_type: Option, #[serde(default)] pub mask_level: Option, + #[serde(default)] + pub points: Option>, } impl Annotation { From c51f69e1dc830c0a613ab41b6512e742e2cc6fd4 Mon Sep 17 00:00:00 2001 From: acwelst Date: Wed, 19 Aug 2026 20:32:28 +1000 Subject: [PATCH 2/6] fix: commit flipped draw bounds after resize When a draw stroke is resized past the opposite edge, persist the normalized bounding box and flipped points on mouse-up so the next handle drag starts from the new visual origin. Co-authored-by: Cursor --- .../screenshot-editor/AnnotationLayer.tsx | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx b/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx index 997257854ab..8bb528cbcbe 100644 --- a/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx +++ b/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx @@ -520,12 +520,24 @@ export function AnnotationLayer(props: { } if (dragState()) { - // Commit history if changed - // We can check if current annotations differ from snapshot, but that's expensive. - // Instead, we assume if we dragged, we changed. - // We need to know if we actually moved. - // But we don't have "current" vs "original" easily without checking. - // Simpler: always push if dragSnapshot exists. + const state = dragState(); + if (state?.action === "resize") { + const ann = annotations.find((a) => a.id === state.id); + if (ann && ann.type === "draw" && ann.points && (ann.width < 0 || ann.height < 0)) { + const flipX = ann.width < 0; + const flipY = ann.height < 0; + setAnnotations((a) => a.id === state.id, { + x: flipX ? ann.x + ann.width : ann.x, + y: flipY ? ann.y + ann.height : ann.y, + width: Math.abs(ann.width), + height: Math.abs(ann.height), + points: ann.points!.map((p) => [ + flipX ? 1 - p[0] : p[0], + flipY ? 1 - p[1] : p[1], + ] as [number, number]), + }); + } + } if (dragSnapshot) { projectHistory.push(dragSnapshot); } From b27bdf6d4db3e0a42578e1ca97de9e3baac3b098 Mon Sep 17 00:00:00 2001 From: acwelst Date: Wed, 19 Aug 2026 21:13:12 +1000 Subject: [PATCH 3/6] chore: format draw-tool editor code and drop non-null assertions Regenerated desktop TypeScript bindings via export_typescript_bindings; specta output already matches the committed tauri.ts types. Co-authored-by: Cursor --- .../screenshot-editor/AnnotationLayer.tsx | 102 +++++++++++------- .../screenshot-editor/screenshotExport.ts | 4 +- 2 files changed, 65 insertions(+), 41 deletions(-) diff --git a/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx b/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx index 8bb528cbcbe..c2c5ea203d1 100644 --- a/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx +++ b/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx @@ -244,7 +244,10 @@ export function AnnotationLayer(props: { const dx2 = point.x - last[0]; const dy2 = point.y - last[1]; if (dx2 * dx2 + dy2 * dy2 < 4) return; - const newPoints: [number, number][] = [...temp.points, [point.x, point.y]]; + const newPoints: [number, number][] = [ + ...temp.points, + [point.x, point.y], + ]; const xs = newPoints.map((p) => p[0]); const ys = newPoints.map((p) => p[1]); const minX = Math.min(...xs); @@ -437,10 +440,9 @@ export function AnnotationLayer(props: { } const w = ann.width || 1; const h = ann.height || 1; - ann.points = ann.points.map((p) => [ - (p[0] - ann.x) / w, - (p[1] - ann.y) / h, - ] as [number, number]); + ann.points = ann.points.map( + (p) => [(p[0] - ann.x) / w, (p[1] - ann.y) / h] as [number, number], + ); if (drawSnapshot) projectHistory.push(drawSnapshot); drawSnapshot = null; setAnnotations((prev) => [...prev, ann]); @@ -523,18 +525,27 @@ export function AnnotationLayer(props: { const state = dragState(); if (state?.action === "resize") { const ann = annotations.find((a) => a.id === state.id); - if (ann && ann.type === "draw" && ann.points && (ann.width < 0 || ann.height < 0)) { + if ( + ann && + ann.type === "draw" && + ann.points && + (ann.width < 0 || ann.height < 0) + ) { const flipX = ann.width < 0; const flipY = ann.height < 0; + const points = ann.points; setAnnotations((a) => a.id === state.id, { x: flipX ? ann.x + ann.width : ann.x, y: flipY ? ann.y + ann.height : ann.y, width: Math.abs(ann.width), height: Math.abs(ann.height), - points: ann.points!.map((p) => [ - flipX ? 1 - p[0] : p[0], - flipY ? 1 - p[1] : p[1], - ] as [number, number]), + points: points.map( + (p) => + [flipX ? 1 - p[0] : p[0], flipY ? 1 - p[1] : p[1]] as [ + number, + number, + ], + ), }); } } @@ -743,22 +754,26 @@ export function AnnotationLayer(props: { )} - {(ann) => ( - = 2} - fallback={} - > - - - )} + {(ann) => { + const livePoints = () => + ann().type === "draw" ? (ann().points ?? []) : []; + return ( + = 2} + fallback={} + > + + + ); + }} ); @@ -879,20 +894,27 @@ function RenderAnnotation(props: { annotation: Annotation }) { style={{ "pointer-events": "all" }} /> )} - {props.annotation.type === "draw" && props.annotation.points && props.annotation.points.length >= 2 && ( - [ - props.annotation.x + p[0] * (props.annotation.width || 1), - props.annotation.y + p[1] * (props.annotation.height || 1), - ] as [number, number]))} - fill="none" - stroke={props.annotation.strokeColor} - stroke-width={props.annotation.strokeWidth} - stroke-linecap="round" - stroke-linejoin="round" - opacity={props.annotation.opacity} - /> - )} + {props.annotation.type === "draw" && + props.annotation.points && + props.annotation.points.length >= 2 && ( + + [ + props.annotation.x + p[0] * (props.annotation.width || 1), + props.annotation.y + p[1] * (props.annotation.height || 1), + ] as [number, number], + ), + )} + fill="none" + stroke={props.annotation.strokeColor} + stroke-width={props.annotation.strokeWidth} + stroke-linecap="round" + stroke-linejoin="round" + opacity={props.annotation.opacity} + /> + )} ); } diff --git a/apps/desktop/src/routes/screenshot-editor/screenshotExport.ts b/apps/desktop/src/routes/screenshot-editor/screenshotExport.ts index 4e32e535f92..9f5cc6a9aad 100644 --- a/apps/desktop/src/routes/screenshot-editor/screenshotExport.ts +++ b/apps/desktop/src/routes/screenshot-editor/screenshotExport.ts @@ -94,7 +94,9 @@ const drawAnnotations = ( ctx.lineJoin = "round"; const w = ann.width || 1; const h = ann.height || 1; - const pts = ann.points.map((p) => [ann.x + p[0] * w, ann.y + p[1] * h] as [number, number]); + const pts = ann.points.map( + (p) => [ann.x + p[0] * w, ann.y + p[1] * h] as [number, number], + ); ctx.moveTo(pts[0][0], pts[0][1]); if (pts.length === 2) { ctx.lineTo(pts[1][0], pts[1][1]); From d24dff1ba532857c753183d721915e59b708db18 Mon Sep 17 00:00:00 2001 From: acwelst Date: Wed, 19 Aug 2026 21:50:11 +1000 Subject: [PATCH 4/6] fix: keep draw tool active across multiple strokes Each mouse-up still commits a separate annotation, but Draw stays selected so you can keep sketching without clicking Done between strokes. New strokes inherit the last stroke color, width, and opacity. Co-authored-by: Cursor --- .../screenshot-editor/AnnotationLayer.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx b/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx index c2c5ea203d1..ef951c6cf7e 100644 --- a/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx +++ b/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx @@ -200,6 +200,11 @@ export function AnnotationLayer(props: { setIsDrawing(true); const id = crypto.randomUUID(); + const styleSource = + tool === "draw" + ? (annotations.find((a) => a.id === selectedAnnotationId()) ?? + [...annotations].reverse().find((a) => a.type === "draw")) + : undefined; const newAnn: Annotation = { id, type: tool as AnnotationType, @@ -207,10 +212,13 @@ export function AnnotationLayer(props: { y: startY, width: 0, height: 0, - strokeColor: tool === "mask" ? "transparent" : "#F05656", - strokeWidth: tool === "mask" ? 0 : 4, + strokeColor: + tool === "mask" + ? "transparent" + : (styleSource?.strokeColor ?? "#F05656"), + strokeWidth: tool === "mask" ? 0 : (styleSource?.strokeWidth ?? 4), fillColor: "transparent", - opacity: 1, + opacity: styleSource?.opacity ?? 1, rotation: 0, text: tool === "text" ? "Text" : null, maskType: tool === "mask" ? "pixelate" : null, @@ -448,7 +456,6 @@ export function AnnotationLayer(props: { setAnnotations((prev) => [...prev, ann]); setTempAnnotation(null); setIsDrawing(false); - setActiveTool("select"); setSelectedAnnotationId(ann.id); return; } @@ -559,9 +566,9 @@ export function AnnotationLayer(props: { }; const startDrag = (e: MouseEvent, id: string, handle?: string) => { + if (activeTool() !== "select") return; e.preventDefault(); e.stopPropagation(); - if (activeTool() !== "select") return; window.getSelection()?.removeAllRanges(); const svg = (e.currentTarget as Element).closest("svg"); From 05f94fa57d7b24dd77d4a2f2fb8b638f83e39846 Mon Sep 17 00:00:00 2001 From: acwelst Date: Wed, 19 Aug 2026 22:00:33 +1000 Subject: [PATCH 5/6] fix: add draw type to screenshot layers panel The layers list crashed on mouse-up because it had no icon or label for draw annotations. Co-authored-by: Cursor --- apps/desktop/src/routes/screenshot-editor/LayersPanel.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/desktop/src/routes/screenshot-editor/LayersPanel.tsx b/apps/desktop/src/routes/screenshot-editor/LayersPanel.tsx index e7cf0ada98c..0e186c093b4 100644 --- a/apps/desktop/src/routes/screenshot-editor/LayersPanel.tsx +++ b/apps/desktop/src/routes/screenshot-editor/LayersPanel.tsx @@ -5,6 +5,7 @@ import IconLucideCircle from "~icons/lucide/circle"; import IconLucideEyeOff from "~icons/lucide/eye-off"; import IconLucideGripVertical from "~icons/lucide/grip-vertical"; import IconLucideLayers from "~icons/lucide/layers"; +import IconLucidePencil from "~icons/lucide/pencil"; import IconLucideSquare from "~icons/lucide/square"; import IconLucideType from "~icons/lucide/type"; import IconLucideX from "~icons/lucide/x"; @@ -16,6 +17,7 @@ const ANNOTATION_TYPE_ICONS = { circle: IconLucideCircle, mask: IconLucideEyeOff, text: IconLucideType, + draw: IconLucidePencil, }; const ANNOTATION_TYPE_LABELS = { @@ -24,6 +26,7 @@ const ANNOTATION_TYPE_LABELS = { circle: "Circle", mask: "Mask", text: "Text", + draw: "Draw", }; export function LayersPanel() { From 2e2994871c4e94d1ae0d030b2ce29678bb5ef3a9 Mon Sep 17 00:00:00 2001 From: acwelst Date: Wed, 19 Aug 2026 22:06:58 +1000 Subject: [PATCH 6/6] fix: hide draw transform handles until select mode Keep Draw focused on sketching. Bounding-box handles only appear after switching to the Select tool. Co-authored-by: Cursor --- .../src/routes/screenshot-editor/AnnotationLayer.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx b/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx index ef951c6cf7e..dd41a7482ba 100644 --- a/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx +++ b/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx @@ -750,7 +750,13 @@ export function AnnotationLayer(props: { /> - +