Skip to content

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

Open
acwelst wants to merge 6 commits into
CapSoftware:mainfrom
acwelst:feat/screenshot-draw-tool
Open

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

Conversation

@acwelst

@acwelst acwelst commented Aug 19, 2026

Copy link
Copy Markdown

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

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

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.

1 participant