diff --git a/app.go b/app.go index 36a5c7e..add8ff1 100644 --- a/app.go +++ b/app.go @@ -3,10 +3,12 @@ package main import ( + "bytes" "context" "encoding/json" "fmt" "image" + "image/png" "log/slog" "os" "path/filepath" @@ -519,7 +521,8 @@ func (a *App) captureSelectedPNG(result CaptureResult, pc *pendingCapture) ([]by return nil, err } if len(result.Annotations) > 0 { - cropped, err = application.ApplyAnnotations(cropped, result.Annotations, pc.Display.Scale) + scaleX, scaleY := annotationScalesForCapture(cropped, rect, pc.Display.Scale) + cropped, err = application.ApplyAnnotationsWithScale(cropped, result.Annotations, scaleX, scaleY) if err != nil { return nil, err } @@ -527,6 +530,33 @@ func (a *App) captureSelectedPNG(result CaptureResult, pc *pendingCapture) ([]by return cropped, nil } +func annotationScalesForCapture(pngBytes []byte, rect RegionRect, fallback float64) (float64, float64) { + if fallback <= 0 { + fallback = 1 + } + scaleX := fallback + scaleY := fallback + + cfg, err := png.DecodeConfig(bytes.NewReader(pngBytes)) + if err != nil { + return scaleX, scaleY + } + if rect.W > 0 && cfg.Width > 0 { + scaleX = saneAnnotationScale(float64(cfg.Width)/float64(rect.W), fallback) + } + if rect.H > 0 && cfg.Height > 0 { + scaleY = saneAnnotationScale(float64(cfg.Height)/float64(rect.H), fallback) + } + return scaleX, scaleY +} + +func saneAnnotationScale(scale, fallback float64) float64 { + if scale >= 0.25 && scale <= 8 { + return scale + } + return fallback +} + func (a *App) chooseSaveDirectory() (string, error) { return wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{ Title: "Save screenshot to folder", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 0858d66..1a474ba 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -77,6 +77,24 @@ interface OverlayPayload { } const overlayPayload = ref(null) +interface OverlayAnnotation { + tool: string + color: string + points: Array<{ x: number; y: number }> + text?: string + fontSize?: number +} + +interface OverlayResult { + rect: { + x: number + y: number + w: number + h: number + } + annotations: OverlayAnnotation[] +} + const capturing = ref(false) const toast = ref<{ kind: 'success' | 'error'; text: string } | null>(null) @@ -121,19 +139,7 @@ async function retryHotkey() { } } -async function onOverlayConfirm(rect: { - rect: { - x: number - y: number - w: number - h: number - } - annotations: Array<{ - tool: string - color: string - points: Array<{ x: number; y: number }> - }> -}) { +async function onOverlayConfirm(rect: OverlayResult) { // 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' @@ -145,19 +151,7 @@ async function onOverlayConfirm(rect: { } } -async function onOverlayCopy(rect: { - rect: { - x: number - y: number - w: number - h: number - } - annotations: Array<{ - tool: string - color: string - points: Array<{ x: number; y: number }> - }> -}) { +async function onOverlayCopy(rect: OverlayResult) { mode.value = 'settings' overlayPayload.value = null try { @@ -167,19 +161,7 @@ async function onOverlayCopy(rect: { } } -async function onOverlaySave(rect: { - rect: { - x: number - y: number - w: number - h: number - } - annotations: Array<{ - tool: string - color: string - points: Array<{ x: number; y: number }> - }> -}) { +async function onOverlaySave(rect: OverlayResult) { mode.value = 'settings' overlayPayload.value = null try { @@ -189,19 +171,7 @@ async function onOverlaySave(rect: { } } -async function onOverlaySaveRemote(rect: { - rect: { - x: number - y: number - w: number - h: number - } - annotations: Array<{ - tool: string - color: string - points: Array<{ x: number; y: number }> - }> -}) { +async function onOverlaySaveRemote(rect: OverlayResult) { mode.value = 'settings' overlayPayload.value = null try { @@ -211,19 +181,7 @@ async function onOverlaySaveRemote(rect: { } } -async function onOverlaySummarize(rect: { - rect: { - x: number - y: number - w: number - h: number - } - annotations: Array<{ - tool: string - color: string - points: Array<{ x: number; y: number }> - }> -}) { +async function onOverlaySummarize(rect: OverlayResult) { mode.value = 'settings' overlayPayload.value = null try { diff --git a/frontend/src/views/CaptureOverlay.vue b/frontend/src/views/CaptureOverlay.vue index 7889b06..fe4c315 100644 --- a/frontend/src/views/CaptureOverlay.vue +++ b/frontend/src/views/CaptureOverlay.vue @@ -33,6 +33,7 @@ interface Annotation { color: string points: Point[] text?: string + fontSize?: number } const emit = defineEmits<{ @@ -65,15 +66,31 @@ const draftAnnotation = ref(null) const activeTool = ref('pen') const activeColor = ref('#ef4444') const paletteOpen = ref(false) -const textDraft = ref<{ point: Point; value: string } | null>(null) +const textDraft = ref<{ + point: Point + value: string + color: string + index: number | null +} | null>(null) const textInputRef = ref(null) +const selectedTextIndex = ref(null) type ResizeHandle = 'n' | 's' | 'e' | 'w' | 'nw' | 'ne' | 'sw' | 'se' -type DragMode = 'idle' | 'creating' | 'moving' | 'resizing' | 'annotating' +type DragMode = + | 'idle' + | 'creating' + | 'moving' + | 'resizing' + | 'annotating' + | 'moving-text' const dragMode = ref('idle') const resizeHandle = ref(null) const dragAnchor = ref({ x: 0, y: 0 }) const startRect = ref(null) +const textDragStartPoint = ref(null) +const textDragOriginalPoint = ref(null) + +const TEXT_FONT_SIZE = 20 const colors = [ '#ef4444', @@ -207,6 +224,77 @@ function insideRect(p: Point, r: Rect | null) { return p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h } +let measureCanvas: HTMLCanvasElement | null = null + +function textFont(fontSize = TEXT_FONT_SIZE) { + return `600 ${fontSize}px -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif` +} + +function textSize(annotation: Pick) { + const fontSize = annotation.fontSize || TEXT_FONT_SIZE + const lines = (annotation.text || ' ').split('\n') + if (typeof document === 'undefined') { + return { + w: Math.max(1, ...lines.map((line) => line.length * fontSize)), + h: Math.max(fontSize * 1.2, lines.length * fontSize * 1.2), + } + } + measureCanvas ||= document.createElement('canvas') + const ctx = measureCanvas.getContext('2d') + if (!ctx) { + return { + w: Math.max(1, ...lines.map((line) => line.length * fontSize)), + h: Math.max(fontSize * 1.2, lines.length * fontSize * 1.2), + } + } + ctx.font = textFont(fontSize) + return { + w: Math.ceil(Math.max(1, ...lines.map((line) => ctx.measureText(line || ' ').width))), + h: Math.ceil(Math.max(fontSize * 1.2, lines.length * fontSize * 1.2)), + } +} + +function textRenderBox(annotation: Annotation) { + const point = annotation.points[0] || { x: 0, y: 0 } + const size = textSize(annotation) + return { + x: point.x - 4, + y: point.y - 3, + w: size.w + 8, + h: size.h + 6, + } +} + +function textAnnotationIndexAtPoint(p: Point) { + if (!rect.value) return null + for (let i = annotations.value.length - 1; i >= 0; i--) { + const annotation = annotations.value[i] + if (annotation.tool !== 'text' || annotation.points.length === 0) continue + const point = annotation.points[0] + const size = textSize(annotation) + const x = rect.value.x + point.x + const y = rect.value.y + point.y + if ( + p.x >= x - 6 && + p.x <= x + size.w + 6 && + p.y >= y - 6 && + p.y <= y + size.h + 6 + ) { + return i + } + } + return null +} + +function clampTextLocalPoint(point: Point, annotation: Pick) { + if (!rect.value) return point + const size = textSize(annotation) + return { + x: clamp(point.x, 0, Math.max(0, rect.value.w - size.w)), + y: clamp(point.y, 0, Math.max(0, rect.value.h - size.h)), + } +} + function hitHandle(p: Point): ResizeHandle | null { if (!rect.value) return null const tolerance = 9 @@ -232,6 +320,8 @@ function onMouseDown(e: MouseEvent) { if (e.button !== 0) return commitTextDraft() paletteOpen.value = false + textDragStartPoint.value = null + textDragOriginalPoint.value = null const p = pointFromEvent(e) const handle = hitHandle(p) if (handle && rect.value) { @@ -250,15 +340,32 @@ function onMouseDown(e: MouseEvent) { dragAnchor.value = p rect.value = { x: p.x, y: p.y, w: 0, h: 0 } annotations.value = [] + selectedTextIndex.value = null draftAnnotation.value = null } function onSelectionMouseDown(e: MouseEvent) { if (e.button !== 0 || !rect.value) return e.stopPropagation() + commitTextDraft() paletteOpen.value = false + const p = pointFromEvent(e) + const hitTextIndex = textAnnotationIndexAtPoint(p) + if (hitTextIndex !== null) { + selectedTextIndex.value = hitTextIndex + const annotation = annotations.value[hitTextIndex] + if (e.detail >= 2) { + beginTextAnnotation(annotation.points[0], hitTextIndex) + return + } + dragMode.value = 'moving-text' + textDragStartPoint.value = localPoint(p) + textDragOriginalPoint.value = { ...annotation.points[0] } + return + } + selectedTextIndex.value = null if (activeTool.value === 'text') { - beginTextAnnotation(localPoint(pointFromEvent(e))) + beginTextAnnotation(localPoint(p)) return } if (e.detail >= 2) { @@ -266,7 +373,6 @@ function onSelectionMouseDown(e: MouseEvent) { onCopy() return } - const p = pointFromEvent(e) const handle = hitHandle(p) if (handle) { dragMode.value = 'resizing' @@ -297,6 +403,21 @@ function onMouseMove(e: MouseEvent) { } } else if (dragMode.value === 'resizing') { resizeSelection(p) + } else if ( + dragMode.value === 'moving-text' && + selectedTextIndex.value !== null && + textDragStartPoint.value && + textDragOriginalPoint.value + ) { + const annotation = annotations.value[selectedTextIndex.value] + if (annotation?.tool === 'text') { + const local = localPoint(p) + const next = { + x: textDragOriginalPoint.value.x + local.x - textDragStartPoint.value.x, + y: textDragOriginalPoint.value.y + local.y - textDragStartPoint.value.y, + } + annotation.points[0] = clampTextLocalPoint(next, annotation) + } } else if (dragMode.value === 'annotating' && draftAnnotation.value) { if (activeTool.value === 'pen') { draftAnnotation.value.points.push(localPoint(p)) @@ -324,6 +445,8 @@ function onMouseUp() { dragMode.value = 'idle' resizeHandle.value = null startRect.value = null + textDragStartPoint.value = null + textDragOriginalPoint.value = null } function resizeSelection(p: Point) { @@ -417,6 +540,12 @@ function onKeydown(e: KeyboardEvent) { function undoAnnotation() { cancelTextDraft() annotations.value.pop() + if ( + selectedTextIndex.value !== null && + selectedTextIndex.value >= annotations.value.length + ) { + selectedTextIndex.value = null + } } function removeSinglePointAnnotation() { @@ -426,11 +555,18 @@ function removeSinglePointAnnotation() { } } -function beginTextAnnotation(point: Point) { +function beginTextAnnotation(point: Point, index: number | null = null) { commitTextDraft() dragMode.value = 'idle' draftAnnotation.value = null - textDraft.value = { point, value: '' } + const existing = index !== null ? annotations.value[index] : null + const color = existing?.color || activeColor.value + textDraft.value = { + point: clampTextLocalPoint(point, existing || { text: '', fontSize: TEXT_FONT_SIZE }), + value: existing?.text || '', + color, + index, + } void nextTick(() => { textInputRef.value?.focus() textInputRef.value?.select() @@ -440,13 +576,31 @@ function beginTextAnnotation(point: Point) { function commitTextDraft() { if (!textDraft.value) return const text = textDraft.value.value.trim() - if (text !== '') { + const draft = textDraft.value + if (draft.index !== null) { + const target = annotations.value[draft.index] + if (target?.tool === 'text') { + if (text !== '') { + target.points = [clampTextLocalPoint(draft.point, { text, fontSize: target.fontSize || TEXT_FONT_SIZE })] + target.text = text + target.color = draft.color + target.fontSize = target.fontSize || TEXT_FONT_SIZE + selectedTextIndex.value = draft.index + } else { + annotations.value.splice(draft.index, 1) + selectedTextIndex.value = null + } + } + } else if (text !== '') { + const point = clampTextLocalPoint(draft.point, { text, fontSize: TEXT_FONT_SIZE }) annotations.value.push({ tool: 'text', - color: activeColor.value, - points: [textDraft.value.point], + color: draft.color, + points: [point], text, + fontSize: TEXT_FONT_SIZE, }) + selectedTextIndex.value = annotations.value.length - 1 } textDraft.value = null } @@ -531,17 +685,27 @@ onUnmounted(() => { stroke-width="3" fill="none" /> - - {{ annotation.text }} - + @@ -565,7 +729,7 @@ onUnmounted(() => { :style="{ left: rect.x + textDraft.point.x + 'px', top: rect.y + textDraft.point.y + 'px', - color: activeColor, + color: textDraft.color, }" spellcheck="false" @mousedown.stop @@ -761,6 +925,17 @@ onUnmounted(() => { pointer-events: none; } +.annotation-text { + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif; +} + +.text-selection-box { + fill: transparent; + stroke: #3b82f6; + stroke-width: 1.5; + stroke-dasharray: 4 3; +} + .selection-hit-area { position: absolute; cursor: crosshair; diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 118f622..409e6b6 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -19,6 +19,7 @@ export namespace application { color: string; points: Point[]; text?: string; + fontSize?: number; static createFrom(source: any = {}) { return new Annotation(source); @@ -30,6 +31,7 @@ export namespace application { this.color = source["color"]; this.points = this.convertValues(source["points"], Point); this.text = source["text"]; + this.fontSize = source["fontSize"]; } convertValues(a: any, classs: any, asMap: boolean = false): any { diff --git a/internal/application/annotation.go b/internal/application/annotation.go index a8fc93c..8a4fe10 100644 --- a/internal/application/annotation.go +++ b/internal/application/annotation.go @@ -22,10 +22,11 @@ import ( // 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"` - Text string `json:"text,omitempty"` + Tool string `json:"tool"` + Color string `json:"color"` + Points []Point `json:"points"` + Text string `json:"text,omitempty"` + FontSize float64 `json:"fontSize,omitempty"` } type Point struct { @@ -35,12 +36,22 @@ type Point struct { // ApplyAnnotations decodes a PNG, draws all annotations, and re-encodes it. func ApplyAnnotations(pngBytes []byte, annotations []Annotation, scale float64) ([]byte, error) { + return ApplyAnnotationsWithScale(pngBytes, annotations, scale, scale) +} + +// ApplyAnnotationsWithScale decodes a PNG, draws all annotations using the +// actual device-pixel scale of the captured image, and re-encodes it. +func ApplyAnnotationsWithScale(pngBytes []byte, annotations []Annotation, scaleX, scaleY float64) ([]byte, error) { if len(annotations) == 0 { return pngBytes, nil } - if scale <= 0 { - scale = 1 + if scaleX <= 0 { + scaleX = 1 } + if scaleY <= 0 { + scaleY = scaleX + } + strokeScale := math.Max(scaleX, scaleY) src, err := png.Decode(bytes.NewReader(pngBytes)) if err != nil { @@ -55,16 +66,16 @@ func ApplyAnnotations(pngBytes []byte, annotations []Annotation, scale float64) if err != nil { c = color.RGBA{R: 59, G: 130, B: 246, A: 255} } - width := int(math.Max(2, math.Round(3*scale))) + width := int(math.Max(2, math.Round(3*strokeScale))) switch ann.Tool { case "pen": - drawPolyline(dst, ann.Points, scale, width, c) + drawPolyline(dst, ann.Points, scaleX, scaleY, width, c) case "rect": - drawRectOutline(dst, ann.Points, scale, width, c) + drawRectOutline(dst, ann.Points, scaleX, scaleY, width, c) case "ellipse": - drawEllipseOutline(dst, ann.Points, scale, width, c) + drawEllipseOutline(dst, ann.Points, scaleX, scaleY, width, c) case "text": - drawTextAnnotation(dst, ann, scale, c) + drawTextAnnotation(dst, ann, scaleX, scaleY, c) } } @@ -92,22 +103,22 @@ func parseHexColor(hex string) (color.RGBA, error) { }, nil } -func drawPolyline(img *image.RGBA, points []Point, scale float64, width int, c color.RGBA) { +func drawPolyline(img *image.RGBA, points []Point, scaleX, scaleY float64, width int, c color.RGBA) { if len(points) == 1 { - drawDot(img, scalePoint(points[0], scale), width, c) + drawDot(img, scalePoint(points[0], scaleX, scaleY), width, c) return } for i := 1; i < len(points); i++ { - drawLine(img, scalePoint(points[i-1], scale), scalePoint(points[i], scale), width, c) + drawLine(img, scalePoint(points[i-1], scaleX, scaleY), scalePoint(points[i], scaleX, scaleY), width, c) } } -func drawRectOutline(img *image.RGBA, points []Point, scale float64, width int, c color.RGBA) { +func drawRectOutline(img *image.RGBA, points []Point, scaleX, scaleY float64, width int, c color.RGBA) { if len(points) < 2 { return } - a := scalePoint(points[0], scale) - b := scalePoint(points[len(points)-1], scale) + a := scalePoint(points[0], scaleX, scaleY) + b := scalePoint(points[len(points)-1], scaleX, scaleY) 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) @@ -116,12 +127,12 @@ func drawRectOutline(img *image.RGBA, points []Point, scale float64, width int, 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) { +func drawEllipseOutline(img *image.RGBA, points []Point, scaleX, scaleY float64, width int, c color.RGBA) { if len(points) < 2 { return } - a := scalePoint(points[0], scale) - b := scalePoint(points[len(points)-1], scale) + a := scalePoint(points[0], scaleX, scaleY) + b := scalePoint(points[len(points)-1], scaleX, scaleY) x1, x2 := ordered(a.X, b.X) y1, y2 := ordered(a.Y, b.Y) rx := (x2 - x1) / 2 @@ -180,8 +191,8 @@ func drawDot(img *image.RGBA, p Point, width int, c color.RGBA) { } } -func scalePoint(p Point, scale float64) Point { - return Point{X: p.X * scale, Y: p.Y * scale} +func scalePoint(p Point, scaleX, scaleY float64) Point { + return Point{X: p.X * scaleX, Y: p.Y * scaleY} } func ordered(a, b float64) (float64, float64) { @@ -191,7 +202,7 @@ func ordered(a, b float64) (float64, float64) { return b, a } -func drawTextAnnotation(img *image.RGBA, ann Annotation, scale float64, c color.RGBA) { +func drawTextAnnotation(img *image.RGBA, ann Annotation, scaleX, scaleY float64, c color.RGBA) { if len(ann.Points) == 0 { return } @@ -199,16 +210,20 @@ func drawTextAnnotation(img *image.RGBA, ann Annotation, scale float64, c color. if text == "" { return } - face := annotationFontFace(20 * scale) + fontSize := ann.FontSize + if fontSize <= 0 { + fontSize = 20 + } + face := annotationFontFace(fontSize * scaleY) if face == nil { face = basicfont.Face7x13 } - origin := scalePoint(ann.Points[0], scale) + origin := scalePoint(ann.Points[0], scaleX, scaleY) metrics := face.Metrics() lineHeight := metrics.Height if lineHeight <= 0 { - lineHeight = fixed.I(int(math.Ceil(24 * scale))) + lineHeight = fixed.I(int(math.Ceil(fontSize * 1.2 * scaleY))) } d := &xfont.Drawer{ Dst: img, diff --git a/internal/application/annotation_test.go b/internal/application/annotation_test.go index 134e496..5160222 100644 --- a/internal/application/annotation_test.go +++ b/internal/application/annotation_test.go @@ -93,3 +93,50 @@ func TestApplyAnnotationsDrawsTextIntoPNG(t *testing.T) { t.Fatalf("expected red text pixels near annotation point") } } + +func TestApplyAnnotationsWithScaleDrawsTextAtDevicePosition(t *testing.T) { + src := image.NewRGBA(image.Rect(0, 0, 220, 120)) + 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 := ApplyAnnotationsWithScale(buf.Bytes(), []Annotation{ + { + Tool: "text", + Color: "#ef4444", + Points: []Point{{X: 50, Y: 20}}, + Text: "T", + FontSize: 20, + }, + }, 2, 2) + 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) + } + minX := 999 + found := false + for y := 0; y < img.Bounds().Dy(); y++ { + for x := 0; x < img.Bounds().Dx(); x++ { + got := color.RGBAModel.Convert(img.At(x, y)).(color.RGBA) + if got.R > 180 && got.G < 180 && got.B < 180 { + found = true + if x < minX { + minX = x + } + } + } + } + if !found { + t.Fatalf("expected red text pixels") + } + if minX < 90 { + t.Fatalf("expected text to be drawn near scaled x=100, min red x=%d", minX) + } +} diff --git a/native_overlay_darwin.go b/native_overlay_darwin.go index f5beaf2..29c0219 100644 --- a/native_overlay_darwin.go +++ b/native_overlay_darwin.go @@ -115,6 +115,12 @@ static id nativeOverlayKeyMonitor = nil; @property(strong) NSView *actionToolbarBg; @property(strong) NSTextField *textEditor; @property NSPoint textEditorLocalPoint; +@property NSInteger textEditorAnnotationIndex; +@property(strong) NSColor *textEditorColor; +@property NSInteger selectedTextAnnotationIndex; +@property BOOL draggingTextAnnotation; +@property NSPoint textDragStartLocalPoint; +@property NSPoint textDragOriginalLocalPoint; @property(strong) NSTextField *sizeLabel; @property(strong) NSTextField *hintLabel; - (void)syncControls; @@ -127,6 +133,10 @@ static id nativeOverlayKeyMonitor = nil; - (void)summarizeSelection; - (void)cancelSelection; - (void)beginTextAnnotationAtPoint:(NSPoint)localPoint; +- (void)beginEditingTextAnnotationAtIndex:(NSInteger)index; +- (NSInteger)textAnnotationIndexAtPoint:(NSPoint)p; +- (NSRect)textAnnotationRectForItem:(NSDictionary *)item; +- (NSPoint)clampedTextLocalPoint:(NSPoint)localPoint text:(NSString *)text fontSize:(CGFloat)fontSize; - (BOOL)isEditingText; - (void)commitTextEditor; - (void)cancelTextEditor; @@ -145,6 +155,8 @@ static id nativeOverlayKeyMonitor = nil; _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]; + _textEditorAnnotationIndex = -1; + _selectedTextAnnotationIndex = -1; // Use SnipHoverButton for the 5 action buttons so we get hover-color // animation. Annotation buttons stay as plain NSButton because they @@ -364,12 +376,73 @@ static id nativeOverlayKeyMonitor = nil; } } +- (CGFloat)fontSizeForTextItem:(NSDictionary *)item { + NSNumber *fontSize = item[@"fontSize"]; + if (fontSize != nil && [fontSize doubleValue] > 0) { + return [fontSize doubleValue]; + } + return 20.0; +} + +- (NSDictionary *)textAttributesWithFontSize:(CGFloat)fontSize color:(NSColor *)color { + return @{ + NSFontAttributeName: [NSFont systemFontOfSize:fontSize weight:NSFontWeightSemibold], + NSForegroundColorAttributeName: color ?: [NSColor whiteColor] + }; +} + +- (NSSize)textSizeForString:(NSString *)text fontSize:(CGFloat)fontSize { + NSString *measured = text.length > 0 ? text : @" "; + NSDictionary *attrs = [self textAttributesWithFontSize:fontSize color:[NSColor whiteColor]]; + NSRect bounds = [measured boundingRectWithSize:NSMakeSize(CGFLOAT_MAX, CGFLOAT_MAX) + options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading + attributes:attrs]; + return NSMakeSize(ceil(MAX(1.0, bounds.size.width)), ceil(MAX(fontSize * 1.2, bounds.size.height))); +} + +- (NSRect)textAnnotationRectForItem:(NSDictionary *)item { + if (![item[@"tool"] isEqualToString:@"text"]) { + return NSZeroRect; + } + NSArray *points = item[@"points"]; + NSString *text = item[@"text"]; + if (points.count == 0 || text.length == 0) { + return NSZeroRect; + } + NSPoint p = [points[0] pointValue]; + CGFloat fontSize = [self fontSizeForTextItem:item]; + NSSize size = [self textSizeForString:text fontSize:fontSize]; + return NSMakeRect(_selection.origin.x + p.x, _selection.origin.y + p.y, size.width, size.height); +} + +- (NSPoint)clampedTextLocalPoint:(NSPoint)localPoint text:(NSString *)text fontSize:(CGFloat)fontSize { + NSSize size = [self textSizeForString:text fontSize:fontSize]; + CGFloat maxX = MAX(0, _selection.size.width - size.width); + CGFloat maxY = MAX(0, _selection.size.height - size.height); + return NSMakePoint(MAX(0, MIN(localPoint.x, maxX)), MAX(0, MIN(localPoint.y, maxY))); +} + +- (NSInteger)textAnnotationIndexAtPoint:(NSPoint)p { + for (NSInteger i = (NSInteger)_annotations.count - 1; i >= 0; i--) { + NSDictionary *item = _annotations[(NSUInteger)i]; + if (![item[@"tool"] isEqualToString:@"text"]) { + continue; + } + NSRect hitRect = NSInsetRect([self textAnnotationRectForItem:item], -6, -6); + if (!NSIsEmptyRect(hitRect) && NSPointInRect(p, hitRect)) { + return i; + } + } + return -1; +} + - (void)drawAnnotations { NSMutableArray *items = [NSMutableArray arrayWithArray:_annotations]; if (_draftAnnotation != nil) { [items addObject:_draftAnnotation]; } - for (NSDictionary *item in items) { + for (NSUInteger index = 0; index < items.count; index++) { + NSDictionary *item = items[index]; NSString *tool = item[@"tool"]; NSColor *color = item[@"nsColor"]; NSArray *points = item[@"points"]; @@ -382,11 +455,18 @@ static id nativeOverlayKeyMonitor = nil; continue; } NSPoint p = [points[0] pointValue]; - NSDictionary *attrs = @{ - NSFontAttributeName: [NSFont systemFontOfSize:20 weight:NSFontWeightSemibold], - NSForegroundColorAttributeName: color ?: [NSColor whiteColor] - }; + CGFloat fontSize = [self fontSizeForTextItem:item]; + NSDictionary *attrs = [self textAttributesWithFontSize:fontSize color:color]; [text drawAtPoint:NSMakePoint(_selection.origin.x + p.x, _selection.origin.y + p.y) withAttributes:attrs]; + if ((NSInteger)index == _selectedTextAnnotationIndex && index < _annotations.count) { + NSRect rect = NSInsetRect([self textAnnotationRectForItem:item], -4, -3); + NSBezierPath *selectionPath = [NSBezierPath bezierPathWithRoundedRect:rect xRadius:4 yRadius:4]; + [selectionPath setLineWidth:1.5]; + CGFloat dash[] = {4.0, 3.0}; + [selectionPath setLineDash:dash count:2 phase:0]; + [[NSColor colorWithCalibratedRed:59.0/255.0 green:130.0/255.0 blue:246.0/255.0 alpha:0.95] setStroke]; + [selectionPath stroke]; + } continue; } [color setStroke]; @@ -501,6 +581,7 @@ static id nativeOverlayKeyMonitor = nil; if (_textEditor != nil) { [self commitTextEditor]; } + _draggingTextAnnotation = NO; NSString *handle = [self resizeHandleAtPoint:p]; if (handle != nil) { _resizing = YES; @@ -512,6 +593,31 @@ static id nativeOverlayKeyMonitor = nil; [self syncControls]; return; } + NSInteger textIndex = _hasSelection ? [self textAnnotationIndexAtPoint:p] : -1; + if (textIndex >= 0) { + _selectedTextAnnotationIndex = textIndex; + _creating = NO; + _moving = NO; + _resizing = NO; + _annotating = NO; + if ([event clickCount] >= 2) { + [self beginEditingTextAnnotationAtIndex:textIndex]; + [self syncControls]; + [self setNeedsDisplay:YES]; + return; + } + NSDictionary *item = _annotations[(NSUInteger)textIndex]; + NSArray *points = item[@"points"]; + if (points.count > 0) { + _draggingTextAnnotation = YES; + _textDragStartLocalPoint = [self localPoint:p]; + _textDragOriginalLocalPoint = [points[0] pointValue]; + } + [self syncControls]; + [self setNeedsDisplay:YES]; + return; + } + _selectedTextAnnotationIndex = -1; if (_hasSelection && NSPointInRect(p, _selection) && [_activeTool isEqualToString:@"text"]) { _creating = NO; _moving = NO; @@ -553,6 +659,7 @@ static id nativeOverlayKeyMonitor = nil; _selection = NSMakeRect(p.x, p.y, 0, 0); [_annotations removeAllObjects]; _draftAnnotation = nil; + _selectedTextAnnotationIndex = -1; } [self syncControls]; } @@ -583,6 +690,17 @@ static id nativeOverlayKeyMonitor = nil; 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 (_draggingTextAnnotation && _selectedTextAnnotationIndex >= 0 && _selectedTextAnnotationIndex < (NSInteger)_annotations.count) { + NSMutableDictionary *item = (NSMutableDictionary *)_annotations[(NSUInteger)_selectedTextAnnotationIndex]; + NSMutableArray *points = (NSMutableArray *)item[@"points"]; + if (points.count > 0) { + NSPoint local = [self localPoint:p]; + NSPoint delta = NSMakePoint(local.x - _textDragStartLocalPoint.x, local.y - _textDragStartLocalPoint.y); + NSPoint next = NSMakePoint(_textDragOriginalLocalPoint.x + delta.x, _textDragOriginalLocalPoint.y + delta.y); + NSString *text = item[@"text"] ?: @""; + CGFloat fontSize = [self fontSizeForTextItem:item]; + points[0] = [NSValue valueWithPoint:[self clampedTextLocalPoint:next text:text fontSize:fontSize]]; + } } else if (_annotating && _draftAnnotation != nil) { NSMutableArray *points = _draftAnnotation[@"points"]; NSPoint local = [self localPoint:p]; @@ -604,6 +722,7 @@ static id nativeOverlayKeyMonitor = nil; _creating = NO; _moving = NO; _resizing = NO; + _draggingTextAnnotation = NO; if (_annotating && _draftAnnotation != nil) { [_annotations addObject:_draftAnnotation]; _draftAnnotation = nil; @@ -731,32 +850,68 @@ static id nativeOverlayKeyMonitor = nil; [self cancelTextEditor]; if (_annotations.count > 0) { [_annotations removeLastObject]; + if (_selectedTextAnnotationIndex >= (NSInteger)_annotations.count) { + _selectedTextAnnotationIndex = -1; + } [self syncControls]; [self setNeedsDisplay:YES]; } } - (void)beginTextAnnotationAtPoint:(NSPoint)localPoint { + [self beginTextEditorAtLocalPoint:localPoint text:@"" color:_activeColor annotationIndex:-1]; +} + +- (void)beginEditingTextAnnotationAtIndex:(NSInteger)index { + if (index < 0 || index >= (NSInteger)_annotations.count) { + return; + } + NSDictionary *item = _annotations[(NSUInteger)index]; + if (![item[@"tool"] isEqualToString:@"text"]) { + return; + } + NSArray *points = item[@"points"]; + if (points.count == 0) { + return; + } + NSColor *color = item[@"nsColor"] ?: _activeColor; + [self beginTextEditorAtLocalPoint:[points[0] pointValue] + text:item[@"text"] ?: @"" + color:color + annotationIndex:index]; +} + +- (void)beginTextEditorAtLocalPoint:(NSPoint)localPoint text:(NSString *)text color:(NSColor *)color annotationIndex:(NSInteger)index { [self cancelTextEditor]; - _textEditorLocalPoint = localPoint; - NSRect frame = NSMakeRect(_selection.origin.x + localPoint.x, _selection.origin.y + localPoint.y, 180, 30); + CGFloat fontSize = 20.0; + NSPoint clampedLocal = [self clampedTextLocalPoint:localPoint text:text fontSize:fontSize]; + _textEditorLocalPoint = clampedLocal; + _textEditorAnnotationIndex = index; + _textEditorColor = color ?: _activeColor; + + NSSize textSize = [self textSizeForString:text fontSize:fontSize]; + CGFloat editorWidth = MAX(180, MIN(360, textSize.width + 24)); + NSRect frame = NSMakeRect(_selection.origin.x + clampedLocal.x, _selection.origin.y + clampedLocal.y, editorWidth, 30); CGFloat maxX = self.bounds.size.width - frame.size.width - 8; CGFloat maxY = self.bounds.size.height - frame.size.height - 8; frame.origin.x = MAX(8, MIN(frame.origin.x, maxX)); frame.origin.y = MAX(8, MIN(frame.origin.y, maxY)); + _textEditorLocalPoint = [self localPoint:frame.origin]; _textEditor = [[NSTextField alloc] initWithFrame:frame]; [_textEditor setBezeled:YES]; [_textEditor setBordered:YES]; [_textEditor setDrawsBackground:YES]; [_textEditor setBackgroundColor:[NSColor colorWithCalibratedWhite:0.08 alpha:0.78]]; - [_textEditor setTextColor:_activeColor]; - [_textEditor setFont:[NSFont systemFontOfSize:20 weight:NSFontWeightSemibold]]; + [_textEditor setTextColor:_textEditorColor]; + [_textEditor setFont:[NSFont systemFontOfSize:fontSize weight:NSFontWeightSemibold]]; + [_textEditor setStringValue:text ?: @""]; [_textEditor setFocusRingType:NSFocusRingTypeNone]; [_textEditor setTarget:self]; [_textEditor setAction:@selector(commitTextEditorFromSender:)]; [self addSubview:_textEditor]; [[self window] makeFirstResponder:_textEditor]; + [_textEditor selectText:nil]; } - (BOOL)isEditingText { @@ -772,17 +927,35 @@ static id nativeOverlayKeyMonitor = nil; return; } NSString *text = [[_textEditor stringValue] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; - if (text.length > 0) { + NSColor *textColor = _textEditorColor ?: _activeColor; + if (_textEditorAnnotationIndex >= 0 && _textEditorAnnotationIndex < (NSInteger)_annotations.count) { + if (text.length > 0) { + NSMutableDictionary *item = (NSMutableDictionary *)_annotations[(NSUInteger)_textEditorAnnotationIndex]; + item[@"color"] = [self hexForColor:textColor]; + item[@"nsColor"] = textColor; + item[@"points"] = [NSMutableArray arrayWithObject:[NSValue valueWithPoint:_textEditorLocalPoint]]; + item[@"text"] = text; + item[@"fontSize"] = @20.0; + _selectedTextAnnotationIndex = _textEditorAnnotationIndex; + } else { + [_annotations removeObjectAtIndex:(NSUInteger)_textEditorAnnotationIndex]; + _selectedTextAnnotationIndex = -1; + } + } else if (text.length > 0) { [_annotations addObject:[@{ @"tool": @"text", - @"color": [self hexForColor:_activeColor], - @"nsColor": _activeColor, + @"color": [self hexForColor:textColor], + @"nsColor": textColor, @"points": [NSMutableArray arrayWithObject:[NSValue valueWithPoint:_textEditorLocalPoint]], - @"text": text + @"text": text, + @"fontSize": @20.0 } mutableCopy]]; + _selectedTextAnnotationIndex = (NSInteger)_annotations.count - 1; } [_textEditor removeFromSuperview]; _textEditor = nil; + _textEditorAnnotationIndex = -1; + _textEditorColor = nil; [[self window] makeFirstResponder:self]; [self syncControls]; [self setNeedsDisplay:YES]; @@ -794,6 +967,8 @@ static id nativeOverlayKeyMonitor = nil; } [_textEditor removeFromSuperview]; _textEditor = nil; + _textEditorAnnotationIndex = -1; + _textEditorColor = nil; [[self window] makeFirstResponder:self]; [self setNeedsDisplay:YES]; } @@ -848,6 +1023,7 @@ static id nativeOverlayKeyMonitor = nil; NSString *text = item[@"text"]; if (text.length > 0) { entry[@"text"] = text; + entry[@"fontSize"] = item[@"fontSize"] ?: @20.0; } [payload addObject:entry]; } @@ -872,8 +1048,8 @@ static id nativeOverlayKeyMonitor = nil; return; } NSRect r = [self globalRectForSelection:_selection]; - [self closeOverlayWindow]; NSString *json = [self annotationsJSON]; + [self closeOverlayWindow]; nativeOverlayConfirm((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]); } @@ -882,8 +1058,8 @@ static id nativeOverlayKeyMonitor = nil; return; } NSRect r = [self globalRectForSelection:_selection]; - [self closeOverlayWindow]; NSString *json = [self annotationsJSON]; + [self closeOverlayWindow]; nativeOverlayCopy((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]); } @@ -925,8 +1101,8 @@ static id nativeOverlayKeyMonitor = nil; return; } NSRect r = [self globalRectForSelection:_selection]; - [self closeOverlayWindow]; NSString *json = [self annotationsJSON]; + [self closeOverlayWindow]; nativeOverlaySaveRemote((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]); } @@ -937,8 +1113,8 @@ static id nativeOverlayKeyMonitor = nil; return; } NSRect r = [self globalRectForSelection:_selection]; - [self closeOverlayWindow]; NSString *json = [self annotationsJSON]; + [self closeOverlayWindow]; nativeOverlaySummarize((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]); } @end