From 75c9b96fbf413f1202b0d06d08dfb6c136b9f8d2 Mon Sep 17 00:00:00 2001 From: mamamiyear Date: Mon, 13 Jul 2026 13:43:14 +0800 Subject: [PATCH] fix: preserve annotations while resizing selection --- frontend/package.json | 1 + frontend/package.json.md5 | 2 +- frontend/src/utils/annotationGeometry.ts | 28 ++++++++++ frontend/src/views/CaptureOverlay.vue | 7 ++- frontend/tests/annotationGeometry.test.ts | 65 +++++++++++++++++++++++ native_overlay_darwin.go | 22 +++++++- 6 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 frontend/src/utils/annotationGeometry.ts create mode 100644 frontend/tests/annotationGeometry.test.ts diff --git a/frontend/package.json b/frontend/package.json index e65d0ef..339bbb5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "vue-tsc --noEmit && vite build", + "test": "node --test tests/*.test.ts", "preview": "vite preview" }, "dependencies": { diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 706e5db..098328d 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -bb7ffb87329c9ad4990374471d4ce9a4 \ No newline at end of file +e2d56b98c3c8ae5968db0bbb461b5615 \ No newline at end of file diff --git a/frontend/src/utils/annotationGeometry.ts b/frontend/src/utils/annotationGeometry.ts new file mode 100644 index 0000000..b55655d --- /dev/null +++ b/frontend/src/utils/annotationGeometry.ts @@ -0,0 +1,28 @@ +export interface AnnotationPoint { + x: number + y: number +} + +interface PointCollection { + points: AnnotationPoint[] +} + +// Annotations are stored relative to the selection origin. Resizing from the +// top or left moves that origin, so offset the local points in the opposite +// direction to keep them attached to the same screen pixels. +export function preserveAnnotationScreenPositions( + annotations: PointCollection[], + previousOrigin: AnnotationPoint, + nextOrigin: AnnotationPoint +) { + const dx = previousOrigin.x - nextOrigin.x + const dy = previousOrigin.y - nextOrigin.y + if (dx === 0 && dy === 0) return + + for (const annotation of annotations) { + for (const point of annotation.points) { + point.x += dx + point.y += dy + } + } +} diff --git a/frontend/src/views/CaptureOverlay.vue b/frontend/src/views/CaptureOverlay.vue index 1408449..fd26c77 100644 --- a/frontend/src/views/CaptureOverlay.vue +++ b/frontend/src/views/CaptureOverlay.vue @@ -10,6 +10,7 @@ import saveIcon from '../assets/icons/save-local.svg?raw' import saveRemoteIcon from '../assets/icons/save-remote.svg?raw' import ftpIcon from '../assets/icons/ftp.svg?raw' import uploadIcon from '../assets/icons/upload.svg?raw' +import { preserveAnnotationScreenPositions } from '../utils/annotationGeometry' interface Props { width: number @@ -539,12 +540,16 @@ function resizeSelection(p: Point) { right = clamp(right, 0, props.width) top = clamp(top, 0, props.height) bottom = clamp(bottom, 0, props.height) - rect.value = { + const nextRect = { x: Math.min(left, right), y: Math.min(top, bottom), w: Math.abs(right - left), h: Math.abs(bottom - top), } + if (rect.value) { + preserveAnnotationScreenPositions(annotations.value, rect.value, nextRect) + } + rect.value = nextRect } function selectTool(tool: Tool) { diff --git a/frontend/tests/annotationGeometry.test.ts b/frontend/tests/annotationGeometry.test.ts new file mode 100644 index 0000000..c1ae7f8 --- /dev/null +++ b/frontend/tests/annotationGeometry.test.ts @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { preserveAnnotationScreenPositions } from '../src/utils/annotationGeometry.ts' + +test('keeps annotation points at the same screen position when the origin moves', () => { + const annotations = [ + { points: [{ x: 30, y: 40 }, { x: 80, y: 90 }] }, + { points: [{ x: 12, y: 18 }] }, + ] + + preserveAnnotationScreenPositions( + annotations, + { x: 100, y: 200 }, + { x: 125, y: 230 } + ) + + assert.deepEqual(annotations, [ + { points: [{ x: 5, y: 10 }, { x: 55, y: 60 }] }, + { points: [{ x: -13, y: -12 }] }, + ]) + assert.deepEqual( + annotations[0].points.map((point) => ({ + x: 125 + point.x, + y: 230 + point.y, + })), + [{ x: 130, y: 240 }, { x: 180, y: 290 }] + ) +}) + +test('does not move annotations when only the right or bottom edge changes', () => { + const annotations = [{ points: [{ x: 30, y: 40 }] }] + + preserveAnnotationScreenPositions( + annotations, + { x: 100, y: 200 }, + { x: 100, y: 200 } + ) + + assert.deepEqual(annotations, [{ points: [{ x: 30, y: 40 }] }]) +}) + +test('preserves screen positions across repeated drags and an edge crossover', () => { + const annotations = [{ points: [{ x: 50, y: 60 }] }] + + preserveAnnotationScreenPositions( + annotations, + { x: 100, y: 100 }, + { x: 140, y: 130 } + ) + preserveAnnotationScreenPositions( + annotations, + { x: 140, y: 130 }, + { x: 300, y: 150 } + ) + + assert.deepEqual(annotations, [{ points: [{ x: -150, y: 10 }] }]) + assert.deepEqual( + { + x: 300 + annotations[0].points[0].x, + y: 150 + annotations[0].points[0].y, + }, + { x: 150, y: 160 } + ) +}) diff --git a/native_overlay_darwin.go b/native_overlay_darwin.go index 30d8d0c..eeb7e49 100644 --- a/native_overlay_darwin.go +++ b/native_overlay_darwin.go @@ -838,6 +838,24 @@ static id nativeOverlayKeyMonitor = nil; MAX(0, MIN(p.y - _selection.origin.y, _selection.size.height))); } +// Annotations use selection-local coordinates. When a top or left resize +// moves the selection origin, offset every local point in the opposite +// direction so the annotation remains attached to the same screen pixels. +- (void)preserveAnnotationScreenPositionsFromOrigin:(NSPoint)previousOrigin toOrigin:(NSPoint)nextOrigin { + CGFloat dx = previousOrigin.x - nextOrigin.x; + CGFloat dy = previousOrigin.y - nextOrigin.y; + if (fabs(dx) <= 0.001 && fabs(dy) <= 0.001) { + return; + } + for (NSDictionary *annotation in _annotations) { + NSMutableArray *points = (NSMutableArray *)annotation[@"points"]; + for (NSUInteger i = 0; i < points.count; i++) { + NSPoint point = [points[i] pointValue]; + points[i] = [NSValue valueWithPoint:NSMakePoint(point.x + dx, point.y + dy)]; + } + } +} + // Translate a selection rectangle expressed in view-local coordinates into // the global top-left screen coordinate system used by // `/usr/sbin/screencapture -R`. @@ -992,7 +1010,9 @@ static id nativeOverlayKeyMonitor = nil; 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)); + NSRect nextSelection = NSMakeRect(MIN(left, right), MIN(top, bottom), fabs(right - left), fabs(bottom - top)); + [self preserveAnnotationScreenPositionsFromOrigin:_selection.origin toOrigin:nextSelection.origin]; + _selection = nextSelection; } else if (_draggingTextAnnotation && _selectedTextAnnotationIndex >= 0 && _selectedTextAnnotationIndex < (NSInteger)_annotations.count) { NSMutableDictionary *item = (NSMutableDictionary *)_annotations[(NSUInteger)_selectedTextAnnotationIndex]; NSMutableArray *points = (NSMutableArray *)item[@"points"];