Skip to content

feat: add freehand draw tool to screenshot editor - #2136

Merged
richiemcilroy merged 6 commits into
CapSoftware:mainfrom
acwelst:feat/screenshot-draw-tool
Aug 20, 2026
Merged

feat: add freehand draw tool to screenshot editor#2136
richiemcilroy merged 6 commits into
CapSoftware:mainfrom
acwelst:feat/screenshot-draw-tool

Conversation

@acwelst

@acwelst acwelst commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

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 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.

Changes

  • New draw variant in AnnotationType (Rust + TS)
  • points field on Annotation struct for storing path data
  • Smooth quadratic bezier rendering in both SVG (editor) and canvas (export)
  • Minimum distance filter to keep point arrays reasonable
  • Works with existing stroke color, width, and opacity controls
  • Keyboard shortcut D to activate

Notes

  • The points field uses #[serde(default)] so existing projects without it deserialize fine
  • Points are stored normalized (0–1 relative to bounding box) so resize/move work identically to rectangles and circles
  • The TS type in tauri.ts was updated manually to match the Rust change — will need regeneration on next specta run

Example video

Cap-Screenshot-Draw-Feature.mp4

Test plan

  • Take a screenshot, select the Draw tool, draw a freehand stroke
  • Verify stroke renders live while drawing
  • Select the stroke, move it — confirm it moves correctly
  • Resize the stroke via handles — confirm shape scales proportionally
  • Press Done and verify the stroke appears in the exported image
  • Change stroke color/width/opacity before drawing — confirm it applies

Greptile Summary

This PR adds persistent freehand screenshot annotations with editor controls and matching SVG/canvas rendering.

  • Adds the Draw toolbar action and keyboard shortcut.
  • Records normalized stroke points and supports selection, movement, resizing, styling, and history.
  • Extends the persisted Rust annotation schema and screenshot export renderer.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains.

No blocking failure remains.

Important Files Changed

Filename Overview
apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx Implements freehand point capture, live SVG rendering, normalized persistence, resizing, and flipped-bound canonicalization without a new eligible blocking finding.
apps/desktop/src/routes/screenshot-editor/screenshotExport.ts Adds canvas rendering for normalized draw paths using the same quadratic smoothing approach as the editor.
crates/project/src/configuration.rs Extends the persisted annotation schema with the draw variant and a backward-compatible optional points field.
apps/desktop/src/utils/tauri.ts Mirrors the Rust annotation additions in the TypeScript binding.

Reviews (2): Last reviewed commit: "fix: hide draw transform handles until s..." | Re-trigger Greptile

Context used:

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 <cursoragent@cursor.com>
Comment on lines +872 to +875
d={smoothPathFromPoints(props.annotation.points.map((p) => [
props.annotation.x + p[0] * (props.annotation.width || 1),
props.annotation.y + p[1] * (props.annotation.height || 1),
] as [number, number]))}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Signed dimensions mirror draw paths

When a west or north resize handle is dragged past the opposite edge, the shared resize logic produces a negative dimension and this mapping applies it directly to the normalized points, causing the stroke to be mirrored or displaced relative to its selection box; the export path reproduces the same incorrect geometry.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx
Line: 872-875

Comment:
**Signed dimensions mirror draw paths**

When a west or north resize handle is dragged past the opposite edge, the shared resize logic produces a negative dimension and this mapping applies it directly to the normalized points, causing the stroke to be mirrored or displaced relative to its selection box; the export path reproduces the same incorrect geometry.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional. Mirroring the stroke when dragged past the opposite edge is consistent with how rectangles and circles behave. The negative dimension flips the rendering, which is the expected UX when you "flip" an annotation by crossing its bounds.

Users can redrag it up in order to revert it to the "Correct" render.

Flip.mp4

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it — thanks for the video. Mirroring on negative dimensions being consistent with the existing shape tools is a good reason to keep it as-is, and the ability to revert by redragging makes it recoverable. Withdrawing the concern.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

Comment on lines +572 to +573
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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Generated binding edited manually

This generated Tauri binding was changed by hand instead of through the repository's Specta generation process, so a routine debug run or binding-generation test can replace the committed output and create generated-file drift.

Context Used: AGENTS.md (source)

Knowledge Base Used: Desktop Frontend (apps/desktop/src)

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/utils/tauri.ts
Line: 572-573

Comment:
**Generated binding edited manually**

This generated Tauri binding was changed by hand instead of through the repository's Specta generation process, so a routine debug run or binding-generation test can replace the committed output and create generated-file drift.

**Context Used:** AGENTS.md ([source](https://github.com/capsoftware/cap/blob/main/AGENTS.md))

**Knowledge Base Used:** [Desktop Frontend (apps/desktop/src)](https://app.greptile.com/cap/-/custom-context/knowledge-base/capsoftware/cap/-/docs/desktop-frontend.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@acwelst

acwelst commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Re: the two review comments from greptile-apps:

P1 (signed dimensions mirror draw paths): This is intentional. Mirroring the stroke when dragged past the opposite edge is consistent with how rectangles and circles behave — the negative dimension flips the rendering, which is the expected UX when you "flip" an annotation by crossing its bounds.

P2 (generated binding edited manually): Acknowledged in the PR description. The manual edit to tauri.ts matches what specta would generate. It will be overwritten cleanly on the next specta run after this lands.

acwelst and others added 2 commits August 19, 2026 20:32
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 <cursoragent@cursor.com>
Regenerated desktop TypeScript bindings via export_typescript_bindings;
specta output already matches the committed tauri.ts types.

Co-authored-by: Cursor <cursoragent@cursor.com>
@acwelst

acwelst commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up on the generated-bindings comment:

I regenerated apps/desktop/src/utils/tauri.ts with the repo's Specta export test (cargo test -p cap-desktop --lib typescript_bindings_tests::export_typescript_bindings). The output matches what is already in the PR, so there is no extra bindings diff to commit.

The Rust Annotation / AnnotationType change is the source of truth; the committed tauri.ts is the generated result of that.

acwelst and others added 3 commits August 19, 2026 21:50
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 <cursoragent@cursor.com>
The layers list crashed on mouse-up because it had no icon or label
for draw annotations.

Co-authored-by: Cursor <cursoragent@cursor.com>
Keep Draw focused on sketching. Bounding-box handles only appear
after switching to the Select tool.

Co-authored-by: Cursor <cursoragent@cursor.com>
@acwelst

acwelst commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up on the draw-tool UX after trying it in the editor:

Multiple strokes in one session. Draw used to jump back to Select on mouse-up, so a smiley face (two eyes + a mouth) meant Done + reselect Draw between every stroke. It now stays on Draw. Each click-drag-release is still its own annotation, and the next stroke inherits the last color / width / opacity.

Layers crash. Finishing a stroke blew up the layers panel because it had no icon or label for draw. That’s mapped now (pencil / “Draw”).

No transform handles while drawing. Bounding-box handles were showing as soon as a stroke landed, which got in the way of sketching. Handles only appear after you switch to Select (V).

Multi-Draw.mp4

@richiemcilroy

Copy link
Copy Markdown
Member

hey @greptileai please review

@richiemcilroy
richiemcilroy merged commit 22afc3f into CapSoftware:main Aug 20, 2026
16 of 18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants