{
+ dragMode = 'resizing'
+ resizeHandle = handle.name
+ dragAnchor = pointFromEvent(event)
+ startRect = rect ? { ...rect } : null
+ }
+ "
+ />
+
{
{{ sizeLabel }}
-
+
+
Cancel
@@ -224,7 +527,6 @@ const sizeLabel = computed(() => {
-
Drag to select an area · Esc to cancel
@@ -248,6 +550,38 @@ const sizeLabel = computed(() => {
pointer-events: none;
}
+.selection-hit-area {
+ position: absolute;
+ cursor: crosshair;
+}
+
+.resize-handle {
+ position: absolute;
+ width: 10px;
+ height: 10px;
+ transform: translate(-50%, -50%);
+ border: 1px solid #fff;
+ background: #3b82f6;
+ box-shadow: 0 1px 5px rgba(0, 0, 0, 0.35);
+ z-index: 3;
+}
+.handle-n,
+.handle-s {
+ cursor: ns-resize;
+}
+.handle-e,
+.handle-w {
+ cursor: ew-resize;
+}
+.handle-nw,
+.handle-se {
+ cursor: nwse-resize;
+}
+.handle-ne,
+.handle-sw {
+ cursor: nesw-resize;
+}
+
.size-pill {
position: absolute;
padding: 2px 8px;
@@ -258,7 +592,6 @@ const sizeLabel = computed(() => {
border-radius: 4px;
pointer-events: none;
font-variant-numeric: tabular-nums;
- letter-spacing: 0.02em;
}
.toolbar {
@@ -266,11 +599,26 @@ const sizeLabel = computed(() => {
display: flex;
gap: 6px;
align-items: center;
+ height: 32px;
padding: 4px;
background: rgba(28, 28, 32, 0.94);
border-radius: 8px;
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.4);
cursor: default;
+ z-index: 4;
+}
+
+.mark-toolbar {
+ width: 190px;
+}
+.action-toolbar {
+ width: 220px;
+}
+
+.btn,
+.icon-btn,
+.swatch {
+ font-family: inherit;
}
.btn {
@@ -280,8 +628,6 @@ const sizeLabel = computed(() => {
font-size: 12px;
font-weight: 500;
cursor: pointer;
- font-family: inherit;
- transition: background-color 120ms ease;
}
.btn.cancel {
background: transparent;
@@ -298,6 +644,69 @@ const sizeLabel = computed(() => {
background: #2563eb;
}
+.icon-btn {
+ display: grid;
+ place-items: center;
+ width: 28px;
+ height: 28px;
+ border: 0;
+ border-radius: 5px;
+ color: #d1d5db;
+ background: transparent;
+ cursor: pointer;
+}
+.icon-btn:hover:not(:disabled),
+.icon-btn.active {
+ color: #fff;
+ background: rgba(255, 255, 255, 0.12);
+}
+.icon-btn:disabled {
+ opacity: 0.35;
+ cursor: not-allowed;
+}
+.icon-btn svg {
+ width: 18px;
+ height: 18px;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.color-wrap {
+ position: relative;
+}
+.color-btn span {
+ width: 16px;
+ height: 16px;
+ border: 1px solid rgba(255, 255, 255, 0.7);
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.18);
+}
+.palette {
+ position: absolute;
+ left: 0;
+ bottom: 36px;
+ display: grid;
+ grid-template-columns: repeat(5, 22px);
+ gap: 6px;
+ padding: 8px;
+ background: rgba(28, 28, 32, 0.96);
+ border-radius: 8px;
+ box-shadow: 0 8px 26px rgba(0, 0, 0, 0.45);
+}
+.swatch {
+ width: 22px;
+ height: 22px;
+ border: 1px solid rgba(255, 255, 255, 0.55);
+ border-radius: 4px;
+ cursor: pointer;
+}
+.swatch.selected {
+ outline: 2px solid #fff;
+ outline-offset: 2px;
+}
+
.hint {
position: absolute;
left: 50%;
@@ -309,6 +718,5 @@ const sizeLabel = computed(() => {
background: rgba(0, 0, 0, 0.5);
border-radius: 999px;
pointer-events: none;
- letter-spacing: 0.02em;
}
diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts
index dac05f3..db9b728 100755
--- a/frontend/wailsjs/go/main/App.d.ts
+++ b/frontend/wailsjs/go/main/App.d.ts
@@ -9,9 +9,9 @@ export function CancelRegion():Promise
;
export function CaptureNow():Promise;
-export function ConfirmNativeRegion(arg1:main.RegionRect):Promise;
+export function ConfirmNativeRegion(arg1:main.CaptureResult):Promise;
-export function ConfirmRegion(arg1:main.RegionRect):Promise;
+export function ConfirmRegion(arg1:main.CaptureResult):Promise;
export function GetConfig():Promise;
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts
index 79d2781..3198b1b 100755
--- a/frontend/wailsjs/go/models.ts
+++ b/frontend/wailsjs/go/models.ts
@@ -1,5 +1,58 @@
+export namespace application {
+
+ export class Point {
+ x: number;
+ y: number;
+
+ static createFrom(source: any = {}) {
+ return new Point(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.x = source["x"];
+ this.y = source["y"];
+ }
+ }
+ export class Annotation {
+ tool: string;
+ color: string;
+ points: Point[];
+
+ static createFrom(source: any = {}) {
+ return new Annotation(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.tool = source["tool"];
+ this.color = source["color"];
+ this.points = this.convertValues(source["points"], Point);
+ }
+
+ convertValues(a: any, classs: any, asMap: boolean = false): any {
+ if (!a) {
+ return a;
+ }
+ if (a.slice && a.map) {
+ return (a as any[]).map(elem => this.convertValues(elem, classs));
+ } else if ("object" === typeof a) {
+ if (asMap) {
+ for (const key of Object.keys(a)) {
+ a[key] = new classs(a[key]);
+ }
+ return a;
+ }
+ return new classs(a);
+ }
+ return a;
+ }
+ }
+
+}
+
export namespace domain {
-
+
export class S3Config {
endpoint: string;
region: string;
@@ -9,11 +62,11 @@ export namespace domain {
pathPrefix: string;
publicUrlBase: string;
usePathStyle: boolean;
-
+
static createFrom(source: any = {}) {
return new S3Config(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.endpoint = source["endpoint"];
@@ -29,17 +82,17 @@ export namespace domain {
export class AppConfig {
hotkey: string;
s3: S3Config;
-
+
static createFrom(source: any = {}) {
return new AppConfig(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.hotkey = source["hotkey"];
this.s3 = this.convertValues(source["s3"], S3Config);
}
-
+
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
@@ -62,17 +115,17 @@ export namespace domain {
}
export namespace main {
-
+
export class RegionRect {
x: number;
y: number;
w: number;
h: number;
-
+
static createFrom(source: any = {}) {
return new RegionRect(source);
}
-
+
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.x = source["x"];
@@ -81,6 +134,38 @@ export namespace main {
this.h = source["h"];
}
}
+ export class CaptureResult {
+ rect: RegionRect;
+ annotations: application.Annotation[];
+
+ static createFrom(source: any = {}) {
+ return new CaptureResult(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.rect = this.convertValues(source["rect"], RegionRect);
+ this.annotations = this.convertValues(source["annotations"], application.Annotation);
+ }
+
+ convertValues(a: any, classs: any, asMap: boolean = false): any {
+ if (!a) {
+ return a;
+ }
+ if (a.slice && a.map) {
+ return (a as any[]).map(elem => this.convertValues(elem, classs));
+ } else if ("object" === typeof a) {
+ if (asMap) {
+ for (const key of Object.keys(a)) {
+ a[key] = new classs(a[key]);
+ }
+ return a;
+ }
+ return new classs(a);
+ }
+ return a;
+ }
+ }
}
diff --git a/internal/application/annotation.go b/internal/application/annotation.go
new file mode 100644
index 0000000..dd99041
--- /dev/null
+++ b/internal/application/annotation.go
@@ -0,0 +1,182 @@
+package application
+
+import (
+ "bytes"
+ "fmt"
+ "image"
+ "image/color"
+ "image/draw"
+ "image/png"
+ "math"
+ "strconv"
+ "strings"
+)
+
+// Annotation describes a user-drawn mark relative to the selected screenshot.
+// Coordinates are logical pixels in the same coordinate system as the overlay.
+type Annotation struct {
+ Tool string `json:"tool"`
+ Color string `json:"color"`
+ Points []Point `json:"points"`
+}
+
+type Point struct {
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+}
+
+// ApplyAnnotations decodes a PNG, draws all annotations, and re-encodes it.
+func ApplyAnnotations(pngBytes []byte, annotations []Annotation, scale float64) ([]byte, error) {
+ if len(annotations) == 0 {
+ return pngBytes, nil
+ }
+ if scale <= 0 {
+ scale = 1
+ }
+
+ src, err := png.Decode(bytes.NewReader(pngBytes))
+ if err != nil {
+ return nil, fmt.Errorf("decode annotated png: %w", err)
+ }
+ bounds := src.Bounds()
+ dst := image.NewRGBA(image.Rect(0, 0, bounds.Dx(), bounds.Dy()))
+ draw.Draw(dst, dst.Bounds(), src, bounds.Min, draw.Src)
+
+ for _, ann := range annotations {
+ c, err := parseHexColor(ann.Color)
+ if err != nil {
+ c = color.RGBA{R: 59, G: 130, B: 246, A: 255}
+ }
+ width := int(math.Max(2, math.Round(3*scale)))
+ switch ann.Tool {
+ case "pen":
+ drawPolyline(dst, ann.Points, scale, width, c)
+ case "rect":
+ drawRectOutline(dst, ann.Points, scale, width, c)
+ case "ellipse":
+ drawEllipseOutline(dst, ann.Points, scale, width, c)
+ }
+ }
+
+ var buf bytes.Buffer
+ if err := png.Encode(&buf, dst); err != nil {
+ return nil, fmt.Errorf("encode annotated png: %w", err)
+ }
+ return buf.Bytes(), nil
+}
+
+func parseHexColor(hex string) (color.RGBA, error) {
+ value := strings.TrimPrefix(strings.TrimSpace(hex), "#")
+ if len(value) != 6 {
+ return color.RGBA{}, fmt.Errorf("invalid color %q", hex)
+ }
+ n, err := strconv.ParseUint(value, 16, 32)
+ if err != nil {
+ return color.RGBA{}, err
+ }
+ return color.RGBA{
+ R: uint8(n >> 16),
+ G: uint8(n >> 8),
+ B: uint8(n),
+ A: 255,
+ }, nil
+}
+
+func drawPolyline(img *image.RGBA, points []Point, scale float64, width int, c color.RGBA) {
+ if len(points) == 1 {
+ drawDot(img, scalePoint(points[0], scale), width, c)
+ return
+ }
+ for i := 1; i < len(points); i++ {
+ drawLine(img, scalePoint(points[i-1], scale), scalePoint(points[i], scale), width, c)
+ }
+}
+
+func drawRectOutline(img *image.RGBA, points []Point, scale float64, width int, c color.RGBA) {
+ if len(points) < 2 {
+ return
+ }
+ a := scalePoint(points[0], scale)
+ b := scalePoint(points[len(points)-1], scale)
+ x1, x2 := ordered(a.X, b.X)
+ y1, y2 := ordered(a.Y, b.Y)
+ drawLine(img, Point{X: x1, Y: y1}, Point{X: x2, Y: y1}, width, c)
+ drawLine(img, Point{X: x2, Y: y1}, Point{X: x2, Y: y2}, width, c)
+ drawLine(img, Point{X: x2, Y: y2}, Point{X: x1, Y: y2}, width, c)
+ drawLine(img, Point{X: x1, Y: y2}, Point{X: x1, Y: y1}, width, c)
+}
+
+func drawEllipseOutline(img *image.RGBA, points []Point, scale float64, width int, c color.RGBA) {
+ if len(points) < 2 {
+ return
+ }
+ a := scalePoint(points[0], scale)
+ b := scalePoint(points[len(points)-1], scale)
+ x1, x2 := ordered(a.X, b.X)
+ y1, y2 := ordered(a.Y, b.Y)
+ rx := (x2 - x1) / 2
+ ry := (y2 - y1) / 2
+ if rx <= 0 || ry <= 0 {
+ return
+ }
+ cx := x1 + rx
+ cy := y1 + ry
+ steps := int(math.Max(48, math.Ceil(2*math.Pi*math.Max(rx, ry)/4)))
+ prev := Point{
+ X: cx + rx,
+ Y: cy,
+ }
+ for i := 1; i <= steps; i++ {
+ theta := 2 * math.Pi * float64(i) / float64(steps)
+ next := Point{
+ X: cx + rx*math.Cos(theta),
+ Y: cy + ry*math.Sin(theta),
+ }
+ drawLine(img, prev, next, width, c)
+ prev = next
+ }
+}
+
+func drawLine(img *image.RGBA, a, b Point, width int, c color.RGBA) {
+ dx := b.X - a.X
+ dy := b.Y - a.Y
+ steps := int(math.Max(math.Abs(dx), math.Abs(dy)))
+ if steps == 0 {
+ drawDot(img, a, width, c)
+ return
+ }
+ for i := 0; i <= steps; i++ {
+ t := float64(i) / float64(steps)
+ drawDot(img, Point{X: a.X + dx*t, Y: a.Y + dy*t}, width, c)
+ }
+}
+
+func drawDot(img *image.RGBA, p Point, width int, c color.RGBA) {
+ radius := float64(width) / 2
+ minX := int(math.Floor(p.X - radius))
+ maxX := int(math.Ceil(p.X + radius))
+ minY := int(math.Floor(p.Y - radius))
+ maxY := int(math.Ceil(p.Y + radius))
+ bounds := img.Bounds()
+ for y := minY; y <= maxY; y++ {
+ for x := minX; x <= maxX; x++ {
+ if !image.Pt(x, y).In(bounds) {
+ continue
+ }
+ if math.Hypot(float64(x)-p.X, float64(y)-p.Y) <= radius {
+ img.SetRGBA(x, y, c)
+ }
+ }
+ }
+}
+
+func scalePoint(p Point, scale float64) Point {
+ return Point{X: p.X * scale, Y: p.Y * scale}
+}
+
+func ordered(a, b float64) (float64, float64) {
+ if a < b {
+ return a, b
+ }
+ return b, a
+}
diff --git a/internal/application/annotation_test.go b/internal/application/annotation_test.go
new file mode 100644
index 0000000..994c5b9
--- /dev/null
+++ b/internal/application/annotation_test.go
@@ -0,0 +1,53 @@
+package application
+
+import (
+ "bytes"
+ "image"
+ "image/color"
+ "image/draw"
+ "image/png"
+ "testing"
+)
+
+func TestApplyAnnotationsDrawsIntoPNG(t *testing.T) {
+ src := image.NewRGBA(image.Rect(0, 0, 40, 40))
+ draw.Draw(src, src.Bounds(), &image.Uniform{C: color.White}, image.Point{}, draw.Src)
+
+ var buf bytes.Buffer
+ if err := png.Encode(&buf, src); err != nil {
+ t.Fatalf("encode source: %v", err)
+ }
+
+ out, err := ApplyAnnotations(buf.Bytes(), []Annotation{
+ {
+ Tool: "rect",
+ Color: "#ef4444",
+ Points: []Point{
+ {X: 5, Y: 5},
+ {X: 30, Y: 30},
+ },
+ },
+ }, 1)
+ if err != nil {
+ t.Fatalf("apply annotations: %v", err)
+ }
+
+ img, err := png.Decode(bytes.NewReader(out))
+ if err != nil {
+ t.Fatalf("decode output: %v", err)
+ }
+ if got := color.RGBAModel.Convert(img.At(5, 5)).(color.RGBA); got.R != 0xef || got.G != 0x44 || got.B != 0x44 {
+ t.Fatalf("expected annotated pixel at rectangle edge, got %#v", got)
+ }
+}
+
+func TestApplyAnnotationsNoAnnotationsReturnsOriginalBytes(t *testing.T) {
+ original := []byte("not decoded when no annotations")
+ out, err := ApplyAnnotations(original, nil, 1)
+ if err != nil {
+ t.Fatalf("apply annotations: %v", err)
+ }
+ if !bytes.Equal(out, original) {
+ t.Fatalf("expected original bytes")
+ }
+}
diff --git a/native_overlay_callbacks_darwin.go b/native_overlay_callbacks_darwin.go
index 8185bf8..9649b04 100644
--- a/native_overlay_callbacks_darwin.go
+++ b/native_overlay_callbacks_darwin.go
@@ -5,7 +5,7 @@ package main
import "C"
//export nativeOverlayConfirm
-func nativeOverlayConfirm(x, y, w, h C.int) {
+func nativeOverlayConfirm(x, y, w, h C.int, annotationsJSON *C.char) {
nativeOverlayState.Lock()
app := nativeOverlayState.app
nativeOverlayState.app = nil
@@ -13,12 +13,16 @@ func nativeOverlayConfirm(x, y, w, h C.int) {
if app == nil {
return
}
+ rawAnnotations := C.GoString(annotationsJSON)
go func() {
- _ = app.ConfirmNativeRegion(RegionRect{
- X: int(x),
- Y: int(y),
- W: int(w),
- H: int(h),
+ _ = app.ConfirmNativeRegion(CaptureResult{
+ Rect: RegionRect{
+ X: int(x),
+ Y: int(y),
+ W: int(w),
+ H: int(h),
+ },
+ Annotations: parseNativeAnnotations(rawAnnotations),
})
}()
}
diff --git a/native_overlay_darwin.go b/native_overlay_darwin.go
index 1a7e019..b887b07 100644
--- a/native_overlay_darwin.go
+++ b/native_overlay_darwin.go
@@ -7,9 +7,10 @@ package main
#cgo LDFLAGS: -framework AppKit -framework CoreGraphics
#include
#include
+#import
#import
-extern void nativeOverlayConfirm(int x, int y, int w, int h);
+extern void nativeOverlayConfirm(int x, int y, int w, int h, const char *annotationsJSON);
extern void nativeOverlayCancel(void);
static NSWindow *nativeOverlayWindow = nil;
@@ -27,11 +28,25 @@ static id nativeOverlayKeyMonitor = nil;
@property BOOL hasSelection;
@property BOOL creating;
@property BOOL moving;
+@property BOOL resizing;
+@property BOOL annotating;
@property NSRect selection;
@property NSPoint anchor;
@property NSPoint moveOffset;
+@property NSRect resizeStart;
+@property NSString *resizeHandle;
+@property NSString *activeTool;
+@property(strong) NSColor *activeColor;
+@property(strong) NSMutableArray *annotations;
+@property(strong) NSMutableDictionary *draftAnnotation;
@property(strong) NSButton *cancelButton;
@property(strong) NSButton *uploadButton;
+@property(strong) NSButton *penButton;
+@property(strong) NSButton *rectButton;
+@property(strong) NSButton *ellipseButton;
+@property(strong) NSButton *colorButton;
+@property(strong) NSButton *undoButton;
+@property(strong) NSView *paletteView;
@property(strong) NSTextField *sizeLabel;
@property(strong) NSTextField *hintLabel;
- (void)syncControls;
@@ -50,17 +65,28 @@ static id nativeOverlayKeyMonitor = nil;
[self setWantsLayer:YES];
[[self layer] setBackgroundColor:[[NSColor clearColor] CGColor]];
+ _activeTool = nil;
+ _activeColor = [NSColor colorWithCalibratedRed:239.0/255.0 green:68.0/255.0 blue:68.0/255.0 alpha:1.0];
+ _annotations = [NSMutableArray array];
+
_cancelButton = [NSButton buttonWithTitle:@"Cancel" target:self action:@selector(cancelSelection)];
_uploadButton = [NSButton buttonWithTitle:@"Upload & copy" target:self action:@selector(confirmSelection)];
+ _penButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectPen)];
+ _rectButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectRect)];
+ _ellipseButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectEllipse)];
+ _colorButton = [NSButton buttonWithTitle:@"" target:self action:@selector(togglePalette)];
+ _undoButton = [NSButton buttonWithTitle:@"" target:self action:@selector(undoAnnotation)];
+ _paletteView = [[NSView alloc] initWithFrame:NSZeroRect];
_sizeLabel = [NSTextField labelWithString:@""];
_hintLabel = [NSTextField labelWithString:@"Drag to select an area · Esc to cancel"];
[self styleControls];
- for (NSView *view in @[_cancelButton, _uploadButton, _sizeLabel, _hintLabel]) {
+ for (NSView *view in @[_cancelButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _colorButton, _undoButton, _paletteView, _sizeLabel, _hintLabel]) {
[self addSubview:view];
}
- [_cancelButton setHidden:YES];
- [_uploadButton setHidden:YES];
+ for (NSView *view in @[_cancelButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _colorButton, _undoButton, _paletteView]) {
+ [view setHidden:YES];
+ }
[_sizeLabel setHidden:YES];
[_sizeLabel setTextColor:[NSColor whiteColor]];
@@ -79,6 +105,21 @@ static id nativeOverlayKeyMonitor = nil;
return self;
}
+- (NSString *)hexForColor:(NSColor *)color {
+ NSColor *rgb = [color colorUsingColorSpace:[NSColorSpace sRGBColorSpace]];
+ NSInteger r = (NSInteger)llround([rgb redComponent] * 255.0);
+ NSInteger g = (NSInteger)llround([rgb greenComponent] * 255.0);
+ NSInteger b = (NSInteger)llround([rgb blueComponent] * 255.0);
+ return [NSString stringWithFormat:@"#%02lx%02lx%02lx", (long)r, (long)g, (long)b];
+}
+
+- (NSImage *)iconFromSVG:(NSString *)svg {
+ NSData *data = [svg dataUsingEncoding:NSUTF8StringEncoding];
+ NSImage *image = [[NSImage alloc] initWithData:data];
+ [image setTemplate:YES];
+ return image;
+}
+
- (void)styleButton:(NSButton *)button background:(NSColor *)background foreground:(NSColor *)foreground {
[button setBordered:NO];
[button setBezelStyle:NSBezelStyleRegularSquare];
@@ -91,6 +132,20 @@ static id nativeOverlayKeyMonitor = nil;
[button setAttributedTitle:title];
}
+- (void)styleIconButton:(NSButton *)button image:(NSImage *)image {
+ [button setBordered:NO];
+ [button setBezelStyle:NSBezelStyleRegularSquare];
+ [button setImagePosition:NSImageOnly];
+ [button setImageScaling:NSImageScaleProportionallyDown];
+ if (@available(macOS 10.14, *)) {
+ [button setContentTintColor:[NSColor whiteColor]];
+ }
+ [button setImage:image];
+ [button setWantsLayer:YES];
+ [[button layer] setCornerRadius:5];
+ [[button layer] setBackgroundColor:[[NSColor clearColor] CGColor]];
+}
+
- (void)styleControls {
[self styleButton:_cancelButton
background:[NSColor colorWithCalibratedWhite:0.12 alpha:0.96]
@@ -98,6 +153,14 @@ static id nativeOverlayKeyMonitor = nil;
[self styleButton:_uploadButton
background:[NSColor colorWithCalibratedRed:59.0/255.0 green:130.0/255.0 blue:246.0/255.0 alpha:1.0]
foreground:[NSColor whiteColor]];
+ [self styleIconButton:_penButton image:[self iconFromSVG:@" "]];
+ [self styleIconButton:_rectButton image:[self iconFromSVG:@" "]];
+ [self styleIconButton:_ellipseButton image:[self iconFromSVG:@" "]];
+ [self styleIconButton:_colorButton image:nil];
+ [self styleIconButton:_undoButton image:[self iconFromSVG:@" "]];
+ [_paletteView setWantsLayer:YES];
+ [[_paletteView layer] setCornerRadius:8];
+ [[_paletteView layer] setBackgroundColor:[[NSColor colorWithCalibratedWhite:0.11 alpha:0.96] CGColor]];
}
- (void)drawRect:(NSRect)dirtyRect {
@@ -133,23 +196,156 @@ static id nativeOverlayKeyMonitor = nil;
[corners lineToPoint:NSMakePoint(NSMinX(_selection), NSMaxY(_selection) - corner)];
[corners setLineWidth:3];
[corners stroke];
+
+ [self drawAnnotations];
+ [self drawResizeHandles];
} else {
NSRectFill(self.bounds);
}
}
+- (void)drawResizeHandles {
+ NSArray *points = @[
+ [NSValue valueWithPoint:NSMakePoint(NSMinX(_selection), NSMinY(_selection))],
+ [NSValue valueWithPoint:NSMakePoint(NSMidX(_selection), NSMinY(_selection))],
+ [NSValue valueWithPoint:NSMakePoint(NSMaxX(_selection), NSMinY(_selection))],
+ [NSValue valueWithPoint:NSMakePoint(NSMaxX(_selection), NSMidY(_selection))],
+ [NSValue valueWithPoint:NSMakePoint(NSMaxX(_selection), NSMaxY(_selection))],
+ [NSValue valueWithPoint:NSMakePoint(NSMidX(_selection), NSMaxY(_selection))],
+ [NSValue valueWithPoint:NSMakePoint(NSMinX(_selection), NSMaxY(_selection))],
+ [NSValue valueWithPoint:NSMakePoint(NSMinX(_selection), NSMidY(_selection))]
+ ];
+ [[NSColor whiteColor] setStroke];
+ [[NSColor colorWithCalibratedRed:59.0/255.0 green:130.0/255.0 blue:246.0/255.0 alpha:1.0] setFill];
+ for (NSValue *value in points) {
+ NSPoint p = [value pointValue];
+ NSRect handleRect = NSMakeRect(p.x - 5, p.y - 5, 10, 10);
+ NSBezierPath *path = [NSBezierPath bezierPathWithRect:handleRect];
+ [path fill];
+ [path stroke];
+ }
+}
+
+- (void)drawAnnotations {
+ NSMutableArray *items = [NSMutableArray arrayWithArray:_annotations];
+ if (_draftAnnotation != nil) {
+ [items addObject:_draftAnnotation];
+ }
+ for (NSDictionary *item in items) {
+ NSString *tool = item[@"tool"];
+ NSColor *color = item[@"nsColor"];
+ NSArray *points = item[@"points"];
+ if (points.count == 0) {
+ continue;
+ }
+ [color setStroke];
+ NSBezierPath *path = [NSBezierPath bezierPath];
+ [path setLineWidth:3];
+ [path setLineCapStyle:NSLineCapStyleRound];
+ [path setLineJoinStyle:NSLineJoinStyleRound];
+ if ([tool isEqualToString:@"pen"]) {
+ NSPoint first = [points[0] pointValue];
+ [path moveToPoint:NSMakePoint(_selection.origin.x + first.x, _selection.origin.y + first.y)];
+ for (NSUInteger i = 1; i < points.count; i++) {
+ NSPoint point = [points[i] pointValue];
+ [path lineToPoint:NSMakePoint(_selection.origin.x + point.x, _selection.origin.y + point.y)];
+ }
+ [path stroke];
+ } else if (points.count >= 2) {
+ NSPoint a = [points[0] pointValue];
+ NSPoint b = [[points lastObject] pointValue];
+ NSRect r = NSMakeRect(
+ _selection.origin.x + MIN(a.x, b.x),
+ _selection.origin.y + MIN(a.y, b.y),
+ fabs(b.x - a.x),
+ fabs(b.y - a.y));
+ if ([tool isEqualToString:@"rect"]) {
+ NSBezierPath *rectPath = [NSBezierPath bezierPathWithRect:r];
+ [rectPath setLineWidth:3];
+ [rectPath stroke];
+ } else if ([tool isEqualToString:@"ellipse"]) {
+ NSBezierPath *ellipsePath = [NSBezierPath bezierPathWithOvalInRect:r];
+ [ellipsePath setLineWidth:3];
+ [ellipsePath stroke];
+ }
+ }
+ }
+}
+
+- (NSString *)resizeHandleAtPoint:(NSPoint)p {
+ if (!_hasSelection) {
+ return nil;
+ }
+ CGFloat t = 9;
+ NSDictionary *handles = @{
+ @"nw": [NSValue valueWithPoint:NSMakePoint(NSMinX(_selection), NSMinY(_selection))],
+ @"n": [NSValue valueWithPoint:NSMakePoint(NSMidX(_selection), NSMinY(_selection))],
+ @"ne": [NSValue valueWithPoint:NSMakePoint(NSMaxX(_selection), NSMinY(_selection))],
+ @"e": [NSValue valueWithPoint:NSMakePoint(NSMaxX(_selection), NSMidY(_selection))],
+ @"se": [NSValue valueWithPoint:NSMakePoint(NSMaxX(_selection), NSMaxY(_selection))],
+ @"s": [NSValue valueWithPoint:NSMakePoint(NSMidX(_selection), NSMaxY(_selection))],
+ @"sw": [NSValue valueWithPoint:NSMakePoint(NSMinX(_selection), NSMaxY(_selection))],
+ @"w": [NSValue valueWithPoint:NSMakePoint(NSMinX(_selection), NSMidY(_selection))]
+ };
+ for (NSString *key in handles) {
+ NSPoint hp = [handles[key] pointValue];
+ if (fabs(p.x - hp.x) <= t && fabs(p.y - hp.y) <= t) {
+ return key;
+ }
+ }
+ BOOL nearX = p.x >= NSMinX(_selection) - t && p.x <= NSMaxX(_selection) + t;
+ BOOL nearY = p.y >= NSMinY(_selection) - t && p.y <= NSMaxY(_selection) + t;
+ if (nearX && fabs(p.y - NSMinY(_selection)) <= t) return @"n";
+ if (nearX && fabs(p.y - NSMaxY(_selection)) <= t) return @"s";
+ if (nearY && fabs(p.x - NSMinX(_selection)) <= t) return @"w";
+ if (nearY && fabs(p.x - NSMaxX(_selection)) <= t) return @"e";
+ return nil;
+}
+
+- (NSPoint)localPoint:(NSPoint)p {
+ return NSMakePoint(
+ MAX(0, MIN(p.x - _selection.origin.x, _selection.size.width)),
+ MAX(0, MIN(p.y - _selection.origin.y, _selection.size.height)));
+}
+
- (void)mouseDown:(NSEvent *)event {
NSPoint p = [self convertPoint:[event locationInWindow] fromView:nil];
- if (_hasSelection && NSPointInRect(p, _selection)) {
+ NSString *handle = [self resizeHandleAtPoint:p];
+ if (handle != nil) {
+ _resizing = YES;
+ _creating = NO;
+ _moving = NO;
+ _annotating = NO;
+ _resizeHandle = handle;
+ _resizeStart = _selection;
+ } else if (_hasSelection && NSPointInRect(p, _selection) && _activeTool != nil) {
+ _annotating = YES;
+ _creating = NO;
+ _moving = NO;
+ _resizing = NO;
+ NSPoint local = [self localPoint:p];
+ _draftAnnotation = [@{
+ @"tool": _activeTool,
+ @"color": [self hexForColor:_activeColor],
+ @"nsColor": _activeColor,
+ @"points": [NSMutableArray arrayWithObject:[NSValue valueWithPoint:local]]
+ } mutableCopy];
+ } else if (_hasSelection && NSPointInRect(p, _selection)) {
_moving = YES;
_creating = NO;
+ _resizing = NO;
+ _annotating = NO;
_moveOffset = NSMakePoint(p.x - _selection.origin.x, p.y - _selection.origin.y);
} else {
_creating = YES;
_moving = NO;
+ _resizing = NO;
+ _annotating = NO;
_hasSelection = YES;
_anchor = p;
_selection = NSMakeRect(p.x, p.y, 0, 0);
+ [_annotations removeAllObjects];
+ _draftAnnotation = nil;
}
[self syncControls];
}
@@ -166,6 +362,32 @@ static id nativeOverlayKeyMonitor = nil;
x = MAX(0, MIN(x, self.bounds.size.width - _selection.size.width));
y = MAX(0, MIN(y, self.bounds.size.height - _selection.size.height));
_selection.origin = NSMakePoint(x, y);
+ } else if (_resizing) {
+ CGFloat left = NSMinX(_resizeStart);
+ CGFloat top = NSMinY(_resizeStart);
+ CGFloat right = NSMaxX(_resizeStart);
+ CGFloat bottom = NSMaxY(_resizeStart);
+ if ([_resizeHandle containsString:@"w"]) left = p.x;
+ if ([_resizeHandle containsString:@"e"]) right = p.x;
+ if ([_resizeHandle containsString:@"n"]) top = p.y;
+ if ([_resizeHandle containsString:@"s"]) bottom = p.y;
+ left = MAX(0, MIN(left, self.bounds.size.width));
+ right = MAX(0, MIN(right, self.bounds.size.width));
+ top = MAX(0, MIN(top, self.bounds.size.height));
+ bottom = MAX(0, MIN(bottom, self.bounds.size.height));
+ _selection = NSMakeRect(MIN(left, right), MIN(top, bottom), fabs(right - left), fabs(bottom - top));
+ } else if (_annotating && _draftAnnotation != nil) {
+ NSMutableArray *points = _draftAnnotation[@"points"];
+ NSPoint local = [self localPoint:p];
+ if ([_activeTool isEqualToString:@"pen"]) {
+ [points addObject:[NSValue valueWithPoint:local]];
+ } else {
+ if (points.count == 1) {
+ [points addObject:[NSValue valueWithPoint:local]];
+ } else {
+ points[1] = [NSValue valueWithPoint:local];
+ }
+ }
}
[self syncControls];
[self setNeedsDisplay:YES];
@@ -174,6 +396,12 @@ static id nativeOverlayKeyMonitor = nil;
- (void)mouseUp:(NSEvent *)event {
_creating = NO;
_moving = NO;
+ _resizing = NO;
+ if (_annotating && _draftAnnotation != nil) {
+ [_annotations addObject:_draftAnnotation];
+ _draftAnnotation = nil;
+ }
+ _annotating = NO;
if (_hasSelection && (_selection.size.width < 4 || _selection.size.height < 4)) {
_hasSelection = NO;
}
@@ -199,6 +427,11 @@ static id nativeOverlayKeyMonitor = nil;
BOOL visible = _hasSelection && _selection.size.width >= 4 && _selection.size.height >= 4;
[_cancelButton setHidden:!visible];
[_uploadButton setHidden:!visible];
+ [_penButton setHidden:!visible];
+ [_rectButton setHidden:!visible];
+ [_ellipseButton setHidden:!visible];
+ [_colorButton setHidden:!visible];
+ [_undoButton setHidden:!visible];
[_sizeLabel setHidden:!visible];
[_hintLabel setHidden:_hasSelection];
@@ -220,6 +453,98 @@ static id nativeOverlayKeyMonitor = nil;
[_cancelButton setFrame:NSMakeRect(x, y, 86, 32)];
[_uploadButton setFrame:NSMakeRect(x + 92, y, 128, 32)];
+
+ CGFloat markW = 170;
+ CGFloat markX = MAX(0, MIN(_selection.origin.x, self.bounds.size.width - markW));
+ [_penButton setFrame:NSMakeRect(markX + 4, y + 4, 28, 28)];
+ [_rectButton setFrame:NSMakeRect(markX + 36, y + 4, 28, 28)];
+ [_ellipseButton setFrame:NSMakeRect(markX + 68, y + 4, 28, 28)];
+ [_colorButton setFrame:NSMakeRect(markX + 104, y + 7, 22, 22)];
+ [_undoButton setFrame:NSMakeRect(markX + 134, y + 4, 28, 28)];
+ [[_colorButton layer] setBackgroundColor:[_activeColor CGColor]];
+ [_undoButton setEnabled:_annotations.count > 0];
+ [_paletteView setFrame:NSMakeRect(markX, MAX(0, y - 78), 150, 70)];
+ [self updateToolButtonStates];
+}
+
+- (void)updateToolButtonStates {
+ NSDictionary *buttons = @{@"pen": _penButton, @"rect": _rectButton, @"ellipse": _ellipseButton};
+ for (NSString *tool in buttons) {
+ NSButton *button = buttons[tool];
+ NSColor *bg = [tool isEqualToString:_activeTool]
+ ? [NSColor colorWithCalibratedWhite:1.0 alpha:0.14]
+ : [NSColor clearColor];
+ [[button layer] setBackgroundColor:[bg CGColor]];
+ }
+}
+
+- (void)toggleTool:(NSString *)tool {
+ _activeTool = [_activeTool isEqualToString:tool] ? nil : tool;
+ [self syncControls];
+}
+- (void)selectPen { [self toggleTool:@"pen"]; }
+- (void)selectRect { [self toggleTool:@"rect"]; }
+- (void)selectEllipse { [self toggleTool:@"ellipse"]; }
+- (void)undoAnnotation {
+ if (_annotations.count > 0) {
+ [_annotations removeLastObject];
+ [self syncControls];
+ [self setNeedsDisplay:YES];
+ }
+}
+
+- (void)togglePalette {
+ [_paletteView setHidden:![_paletteView isHidden]];
+ if (![_paletteView isHidden] && _paletteView.subviews.count == 0) {
+ NSArray *colors = @[
+ [NSColor colorWithCalibratedRed:239.0/255.0 green:68.0/255.0 blue:68.0/255.0 alpha:1.0],
+ [NSColor colorWithCalibratedRed:249.0/255.0 green:115.0/255.0 blue:22.0/255.0 alpha:1.0],
+ [NSColor colorWithCalibratedRed:250.0/255.0 green:204.0/255.0 blue:21.0/255.0 alpha:1.0],
+ [NSColor colorWithCalibratedRed:34.0/255.0 green:197.0/255.0 blue:94.0/255.0 alpha:1.0],
+ [NSColor colorWithCalibratedRed:59.0/255.0 green:130.0/255.0 blue:246.0/255.0 alpha:1.0],
+ [NSColor colorWithCalibratedRed:139.0/255.0 green:92.0/255.0 blue:246.0/255.0 alpha:1.0],
+ [NSColor colorWithCalibratedRed:236.0/255.0 green:72.0/255.0 blue:153.0/255.0 alpha:1.0],
+ [NSColor whiteColor],
+ [NSColor colorWithCalibratedWhite:0.07 alpha:1.0]
+ ];
+ for (NSUInteger i = 0; i < colors.count; i++) {
+ NSButton *button = [NSButton buttonWithTitle:@"" target:self action:@selector(selectPaletteColor:)];
+ [button setBordered:NO];
+ [button setWantsLayer:YES];
+ [[button layer] setCornerRadius:4];
+ [[button layer] setBackgroundColor:[colors[i] CGColor]];
+ [button setTag:(NSInteger)i];
+ [button setFrame:NSMakeRect(8 + (i % 5) * 28, 38 - (i / 5) * 28, 22, 22)];
+ [_paletteView addSubview:button];
+ }
+ objc_setAssociatedObject(_paletteView, "snapgoColors", colors, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
+ }
+}
+
+- (void)selectPaletteColor:(NSButton *)sender {
+ NSArray *colors = objc_getAssociatedObject(_paletteView, "snapgoColors");
+ if (sender.tag >= 0 && sender.tag < (NSInteger)colors.count) {
+ _activeColor = colors[(NSUInteger)sender.tag];
+ [_paletteView setHidden:YES];
+ [self syncControls];
+ }
+}
+
+- (NSString *)annotationsJSON {
+ NSMutableArray *payload = [NSMutableArray array];
+ for (NSDictionary *item in _annotations) {
+ NSMutableArray *points = [NSMutableArray array];
+ for (NSValue *value in item[@"points"]) {
+ NSPoint p = [value pointValue];
+ [points addObject:@{@"x": @(p.x), @"y": @(p.y)}];
+ }
+ [payload addObject:@{@"tool": item[@"tool"], @"color": item[@"color"], @"points": points}];
+ }
+ NSData *data = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil];
+ if (data == nil) {
+ return @"[]";
+ }
+ return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
}
- (void)confirmSelection {
@@ -233,7 +558,8 @@ static id nativeOverlayKeyMonitor = nil;
nativeOverlayKeyMonitor = nil;
}
nativeOverlayWindow = nil;
- nativeOverlayConfirm((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height));
+ NSString *json = [self annotationsJSON];
+ nativeOverlayConfirm((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]);
}
- (void)cancelSelection {