From c45864e44cd10834ad4149e0b3c9ab0a71510ff7 Mon Sep 17 00:00:00 2001 From: mamamiyear Date: Mon, 1 Jun 2026 23:12:54 +0800 Subject: [PATCH] feat(app): support to edit screenshot area - relocate or resize the area - add anotations on the area --- app.go | 44 +- frontend/src/App.vue | 17 +- frontend/src/views/CaptureOverlay.vue | 610 ++++++++++++++++++++---- frontend/wailsjs/go/main/App.d.ts | 4 +- frontend/wailsjs/go/models.ts | 103 +++- internal/application/annotation.go | 182 +++++++ internal/application/annotation_test.go | 53 ++ native_overlay_callbacks_darwin.go | 16 +- native_overlay_darwin.go | 338 ++++++++++++- 9 files changed, 1234 insertions(+), 133 deletions(-) create mode 100644 internal/application/annotation.go create mode 100644 internal/application/annotation_test.go diff --git a/app.go b/app.go index 5d10582..55e3f08 100644 --- a/app.go +++ b/app.go @@ -4,6 +4,7 @@ package main import ( "context" + "encoding/json" "fmt" "image" "log/slog" @@ -43,11 +44,11 @@ type App struct { // capturing prevents re-entrant capture sessions when the user mashes // the hotkey while a previous one is still running. It stays true for -// the entire lifecycle of one capture: from overlay start until the + // the entire lifecycle of one capture: from overlay start until the // overlay is either confirmed (ConfirmRegion) or discarded (CancelRegion). capturing atomic.Bool -// pendingMu guards `pending`. We keep this separate from `mu` so that + // pendingMu guards `pending`. We keep this separate from `mu` so that // frontend-triggered RPC handlers (which may run concurrently with the // capture goroutine) can take it cheaply. pendingMu sync.Mutex @@ -80,6 +81,13 @@ type RegionRect struct { H int `json:"h"` } +// CaptureResult is returned by the overlay with the final selection and any +// annotations drawn inside the selected area. +type CaptureResult struct { + Rect RegionRect `json:"rect"` + Annotations []application.Annotation `json:"annotations"` +} + // NewApp creates a new App with collaborators already initialised. func NewApp() *App { store, err := config.NewFileStore() @@ -357,7 +365,7 @@ func (a *App) CaptureNow() { // Returning the error to the frontend lets the overlay decide whether to // keep showing the screenshot (e.g. for a retry) — currently it just // dismisses regardless and surfaces the error via the upload:failure toast. -func (a *App) ConfirmRegion(rect RegionRect) error { +func (a *App) ConfirmRegion(result CaptureResult) error { a.pendingMu.Lock() pc := a.pending a.pending = nil @@ -371,6 +379,7 @@ func (a *App) ConfirmRegion(rect RegionRect) error { a.dismissOverlay() }() + rect := result.Rect captureRect := image.Rect(rect.X, rect.Y, rect.X+rect.W, rect.Y+rect.H) a.dismissOverlay() flushFrame() @@ -380,6 +389,13 @@ func (a *App) ConfirmRegion(rect RegionRect) error { wruntime.EventsEmit(a.ctx, "upload:failure", err.Error()) return err } + if len(result.Annotations) > 0 { + cropped, err = application.ApplyAnnotations(cropped, result.Annotations, pc.Display.Scale) + if err != nil { + wruntime.EventsEmit(a.ctx, "upload:failure", err.Error()) + return err + } + } if err := a.runUploadPipeline(pc.Provider, cropped); err != nil { return err } @@ -389,7 +405,7 @@ func (a *App) ConfirmRegion(rect RegionRect) error { // ConfirmNativeRegion mirrors ConfirmRegion for the macOS native overlay. // The native AppKit panel has already been closed by the time this method is // called, so we must not dismiss/restore the Wails overlay window here. -func (a *App) ConfirmNativeRegion(rect RegionRect) error { +func (a *App) ConfirmNativeRegion(result CaptureResult) error { a.pendingMu.Lock() pc := a.pending a.pending = nil @@ -403,6 +419,7 @@ func (a *App) ConfirmNativeRegion(rect RegionRect) error { hideDockIcon() }() + rect := result.Rect captureRect := image.Rect(rect.X, rect.Y, rect.X+rect.W, rect.Y+rect.H) flushFrame() cropped, err := a.capturer.CaptureRegion(captureRect) @@ -410,9 +427,28 @@ func (a *App) ConfirmNativeRegion(rect RegionRect) error { wruntime.EventsEmit(a.ctx, "upload:failure", err.Error()) return err } + if len(result.Annotations) > 0 { + cropped, err = application.ApplyAnnotations(cropped, result.Annotations, pc.Display.Scale) + if err != nil { + wruntime.EventsEmit(a.ctx, "upload:failure", err.Error()) + return err + } + } return a.runUploadPipeline(pc.Provider, cropped) } +func parseNativeAnnotations(raw string) []application.Annotation { + if raw == "" { + return nil + } + var annotations []application.Annotation + if err := json.Unmarshal([]byte(raw), &annotations); err != nil { + slog.Warn("parse native annotations failed", "err", err) + return nil + } + return annotations +} + // CancelRegion is invoked when the user dismisses the overlay (Esc / // Cancel button / right-click). Frees the pending PNG, releases the // capture lock, and hides the overlay. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 648a0c7..10f2001 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -62,17 +62,24 @@ async function retryHotkey() { } async function onOverlayConfirm(rect: { - x: number - y: number - w: number - h: number + rect: { + x: number + y: number + w: number + h: number + } + annotations: Array<{ + tool: string + color: string + points: Array<{ x: number; y: number }> + }> }) { // Optimistically swap back so the window does not visually lag the // Go-side hide. If upload fails, the toast surfaces the reason. mode.value = 'settings' overlayPayload.value = null try { - await ConfirmRegion(rect) + await ConfirmRegion(rect as any) } catch { /* Surfaced via upload:failure */ } diff --git a/frontend/src/views/CaptureOverlay.vue b/frontend/src/views/CaptureOverlay.vue index 851544a..8e2d3ca 100644 --- a/frontend/src/views/CaptureOverlay.vue +++ b/frontend/src/views/CaptureOverlay.vue @@ -1,142 +1,322 @@