diff --git a/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx b/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx index 385f81b77dd..dd41a7482ba 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,14 +212,18 @@ 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, maskLevel: tool === "mask" ? 7 : null, + points: tool === "draw" ? [[startX, startY]] : null, }; if (tool === "text") { @@ -238,6 +247,32 @@ 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 +438,28 @@ 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); + setSelectedAnnotationId(ann.id); + return; + } + if ( ann.type === "rectangle" || ann.type === "circle" || @@ -472,12 +529,33 @@ 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; + 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: points.map( + (p) => + [flipX ? 1 - p[0] : p[0], flipY ? 1 - p[1] : p[1]] as [ + number, + number, + ], + ), + }); + } + } if (dragSnapshot) { projectHistory.push(dragSnapshot); } @@ -488,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"); @@ -672,7 +750,13 @@ export function AnnotationLayer(props: { /> - + - {(ann) => } + {(ann) => { + const livePoints = () => + ann().type === "draw" ? (ann().points ?? []) : []; + return ( + = 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 +907,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} + /> + )} ); } 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 {