@deepnoid/canvas 0.1.60 → 0.1.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,9 @@ export declare class RectangleController {
10
10
  private startMousePoint;
11
11
  private rectangleAnchor;
12
12
  private needsRedraw;
13
+ private isDrawingNewRectangle;
14
+ private hasMoved;
15
+ private targetIndexOnBegin;
13
16
  constructor(engine: AnnotationEngine);
14
17
  getRenderState(): {
15
18
  mousePoint: PointState;
@@ -1,10 +1,11 @@
1
1
  import { cloneDeep } from '../../utils/cloneDeep';
2
2
  import { isMouseClickAction, isMouseDragAction, MouseAction } from '../../utils/mouseActions';
3
- import { isInsideImage, clampBoundingBoxToImage, ACTIVE_POINT_SIZE } from './rectangleMath';
3
+ import { isInsideImage, clampBoundingBoxToImage } from './rectangleMath';
4
4
  import { getCanvasMousePoint } from '../../utils/mousePoint';
5
5
  import { selectRectangleAtPoint, updateActiveRectangleAnchor } from './rectangleHitTest';
6
6
  import { applyRectangleMove, applyRectangleResize } from './rectangleTransform';
7
7
  import { DrawMode } from '../annotationTypes';
8
+ const DRAG_THRESHOLD = 3;
8
9
  export class RectangleController {
9
10
  constructor(engine) {
10
11
  this.engine = engine;
@@ -14,6 +15,9 @@ export class RectangleController {
14
15
  this.startMousePoint = null;
15
16
  this.rectangleAnchor = null;
16
17
  this.needsRedraw = false;
18
+ this.isDrawingNewRectangle = false;
19
+ this.hasMoved = false;
20
+ this.targetIndexOnBegin = -1;
17
21
  }
18
22
  getRenderState() {
19
23
  return {
@@ -37,64 +41,101 @@ export class RectangleController {
37
41
  this.mouseAction = MouseAction.LEFT;
38
42
  this.mousePoint = mousePoint;
39
43
  this.startMousePoint = mousePoint;
40
- const selectedAnnotation = selectRectangleAtPoint(zoom, this.mousePoint, this.engine.getAnnotations());
41
- this.engine.setSelectedAnnotation(selectedAnnotation);
42
- this.rectangleAnchor = updateActiveRectangleAnchor(zoom, this.mousePoint, selectedAnnotation);
44
+ this.isDrawingNewRectangle = false;
45
+ this.hasMoved = false;
46
+ const annotations = this.engine.getAnnotations();
47
+ const currentSelected = this.engine.getSelectedAnnotation();
48
+ const currentSelectedIndex = currentSelected ? annotations.findIndex((a) => a === currentSelected) : -1;
49
+ const tempAnnotations = annotations.map((a) => ({ ...a }));
50
+ const targetAnnotation = selectRectangleAtPoint(zoom, mousePoint, tempAnnotations);
51
+ this.targetIndexOnBegin = targetAnnotation
52
+ ? annotations.findIndex((a) => a.x === targetAnnotation.x &&
53
+ a.y === targetAnnotation.y &&
54
+ a.width === targetAnnotation.width &&
55
+ a.height === targetAnnotation.height)
56
+ : -1;
57
+ this.rectangleAnchor = updateActiveRectangleAnchor(zoom, mousePoint, currentSelected);
58
+ if (currentSelectedIndex >= 0 && this.targetIndexOnBegin !== currentSelectedIndex) {
59
+ this.engine.setSelectedAnnotation(null);
60
+ annotations.forEach((a) => (a.selected = false));
61
+ this.engine.setAnnotations(annotations);
62
+ }
43
63
  return true;
44
64
  }
45
65
  move(event) {
46
66
  this.needsRedraw = false;
47
- if (!isMouseDragAction(event.buttons, MouseAction.LEFT))
67
+ if (!isMouseDragAction(event.buttons, MouseAction.LEFT) || !this.startMousePoint)
48
68
  return;
49
69
  const imageCanvasState = this.engine.getImageCanvasState();
50
70
  const { dw, dh } = imageCanvasState;
51
71
  this.prevMousePoint = cloneDeep(this.mousePoint);
52
72
  this.mousePoint = getCanvasMousePoint(event, this.engine.getImageCanvas(), imageCanvasState);
53
- const selectedAnnotation = this.engine.getSelectedAnnotation();
54
- if (this.mouseAction === MouseAction.LEFT && selectedAnnotation && this.prevMousePoint && this.mousePoint) {
55
- const params = {
56
- annotations: this.engine.getAnnotations(),
57
- dw,
58
- dh,
59
- mousePoint: this.mousePoint,
60
- prevMousePoint: this.prevMousePoint,
61
- rectangleAnchor: this.rectangleAnchor,
62
- };
63
- if (this.rectangleAnchor) {
64
- applyRectangleResize(params);
65
- }
66
- else {
67
- applyRectangleMove(params);
73
+ const movedDistance = Math.sqrt(Math.pow(this.mousePoint.x - this.startMousePoint.x, 2) + Math.pow(this.mousePoint.y - this.startMousePoint.y, 2));
74
+ if (movedDistance > DRAG_THRESHOLD) {
75
+ this.hasMoved = true;
76
+ if (!this.isDrawingNewRectangle) {
77
+ const currentSelected = this.engine.getSelectedAnnotation();
78
+ const currentSelectedIndex = currentSelected
79
+ ? this.engine.getAnnotations().findIndex((a) => a === currentSelected)
80
+ : -1;
81
+ const isResizeMode = this.rectangleAnchor && currentSelected;
82
+ const isMoveMode = currentSelectedIndex >= 0 && currentSelectedIndex === this.targetIndexOnBegin;
83
+ if (!isResizeMode && !isMoveMode) {
84
+ this.isDrawingNewRectangle = true;
85
+ }
68
86
  }
87
+ }
88
+ if (this.isDrawingNewRectangle) {
69
89
  this.needsRedraw = true;
70
90
  }
91
+ else {
92
+ const selectedAnnotation = this.engine.getSelectedAnnotation();
93
+ if (selectedAnnotation && this.prevMousePoint && this.hasMoved) {
94
+ const params = {
95
+ annotations: this.engine.getAnnotations(),
96
+ dw,
97
+ dh,
98
+ mousePoint: this.mousePoint,
99
+ prevMousePoint: this.prevMousePoint,
100
+ rectangleAnchor: this.rectangleAnchor,
101
+ };
102
+ if (this.rectangleAnchor) {
103
+ applyRectangleResize(params);
104
+ }
105
+ else {
106
+ applyRectangleMove(params);
107
+ }
108
+ this.needsRedraw = true;
109
+ }
110
+ }
71
111
  return true;
72
112
  }
73
113
  end(event) {
74
114
  if (!isMouseClickAction(event.button, MouseAction.LEFT))
75
115
  return;
76
116
  const imageCanvasState = this.engine.getImageCanvasState();
117
+ const { zoom, dw, dh } = imageCanvasState;
77
118
  const mousePoint = getCanvasMousePoint(event, this.engine.getImageCanvas(), imageCanvasState);
78
119
  this.mousePoint = mousePoint;
79
120
  if (this.startMousePoint) {
80
121
  const movedWidth = Math.abs(this.startMousePoint.x - mousePoint.x);
81
122
  const movedHeight = Math.abs(this.startMousePoint.y - mousePoint.y);
82
- const { zoom, dw, dh } = imageCanvasState;
83
- if (movedWidth > ACTIVE_POINT_SIZE && movedHeight > ACTIVE_POINT_SIZE) {
84
- const { x, y, width, height } = clampBoundingBoxToImage(Math.min(mousePoint.x, this.startMousePoint.x), Math.min(mousePoint.y, this.startMousePoint.y), movedWidth, movedHeight, dw, dh);
85
- if (this.engine.getSelectedAnnotation()) {
86
- this.engine.commitHistory(true);
87
- }
88
- else {
123
+ if (this.hasMoved && movedWidth > DRAG_THRESHOLD && movedHeight > DRAG_THRESHOLD) {
124
+ if (this.isDrawingNewRectangle) {
125
+ const { x, y, width, height } = clampBoundingBoxToImage(Math.min(mousePoint.x, this.startMousePoint.x), Math.min(mousePoint.y, this.startMousePoint.y), movedWidth, movedHeight, dw, dh);
89
126
  const label = this.engine.getDrawing().label;
90
127
  this.engine.appendAnnotation({ label, type: DrawMode.RECTANGLE, x, y, width, height, selected: false });
91
128
  this.engine.commitHistory(true);
92
129
  this.engine.setSelectedAnnotation(null);
93
130
  }
131
+ else if (this.engine.getSelectedAnnotation()) {
132
+ this.engine.commitHistory(true);
133
+ }
94
134
  }
95
- else {
135
+ else if (!this.hasMoved) {
96
136
  const annotations = this.engine.getAnnotations();
97
- this.engine.setSelectedAnnotation(selectRectangleAtPoint(zoom, this.mousePoint, annotations));
137
+ const selected = selectRectangleAtPoint(zoom, mousePoint, annotations);
138
+ this.engine.setSelectedAnnotation(selected);
98
139
  this.engine.setAnnotations(annotations);
99
140
  }
100
141
  }
@@ -111,5 +152,8 @@ export class RectangleController {
111
152
  this.startMousePoint = null;
112
153
  this.prevMousePoint = null;
113
154
  this.rectangleAnchor = null;
155
+ this.isDrawingNewRectangle = false;
156
+ this.hasMoved = false;
157
+ this.targetIndexOnBegin = -1;
114
158
  }
115
159
  }
@@ -73,7 +73,7 @@ export class AnnotationEngine {
73
73
  return this.showSelectedOnly;
74
74
  }
75
75
  destroy() {
76
- this.annotations = [];
76
+ this.resetCanvas();
77
77
  this.history = null;
78
78
  this.panZoom.destroy();
79
79
  }
@@ -9,58 +9,70 @@ const AnnotationCanvas = ({ image, annotations = [], setAnnotations, options, dr
9
9
  const { panZoomEnabled, zoom, ZoomButton, resetOnImageChange = false } = options || {};
10
10
  const { onImageLoadSuccess, onImageLoadError } = events || {};
11
11
  const imageCanvasRef = useRef(null);
12
- const imageRef = useRef(new Image());
13
- const engineRef = useRef(null);
14
12
  const annotationsCanvasRef = useRef(null);
15
- const [_, forceRender] = useState(0);
13
+ const engineRef = useRef(null);
14
+ const pendingAnnotationsRef = useRef(null);
15
+ const [, forceRender] = useState(0);
16
+ /* ---------- Resize ---------- */
16
17
  useResizeObserver({
17
18
  ref: imageCanvasRef,
18
19
  onResize: useDebounce((size) => {
19
20
  const engine = engineRef.current;
20
- const imageCanvas = engine?.getImageCanvas();
21
- if (!engine || !imageCanvas || !imageRef.current.src)
21
+ const canvas = engine?.getImageCanvas();
22
+ if (!engine || !canvas)
22
23
  return;
23
- const needsResize = size.width !== imageCanvas.width || size.height !== imageCanvas.height;
24
- if (needsResize)
24
+ if (canvas.width !== size.width || canvas.height !== size.height) {
25
25
  engine.initImageCanvas(true);
26
+ }
26
27
  }, 150),
27
28
  });
29
+ /* ---------- Hotkeys ---------- */
28
30
  useHotkeys({
29
31
  onDelete: () => engineRef.current?.deleteSelected(),
30
32
  toggleSelectionOnly: () => engineRef.current?.toggleShowSelectedOnly(),
31
33
  onUndoRedo: (isRedo) => engineRef.current?.undoRedo(isRedo),
32
34
  enabled: enableHotkeys,
33
35
  });
34
- const createEmptyImage = () => {
35
- const img = new Image();
36
- img.width = img.height = 0;
37
- return img;
38
- };
39
- const resetCanvas = (errorMsg) => {
40
- engineRef.current?.resetCanvas();
41
- imageRef.current = createEmptyImage();
42
- if (errorMsg)
43
- onImageLoadError?.(new Error(errorMsg));
44
- };
36
+ /* ---------- Image / Engine lifecycle ---------- */
45
37
  useEffect(() => {
46
- if (!image?.trim())
47
- return void resetCanvas();
38
+ const prevEngine = engineRef.current;
39
+ const prevImageCanvasState = prevEngine?.getImageCanvasState();
40
+ if (prevEngine) {
41
+ prevEngine.destroy();
42
+ engineRef.current = null;
43
+ }
44
+ if (!image?.trim()) {
45
+ return;
46
+ }
48
47
  let cancelled = false;
49
48
  const img = new Image();
50
49
  img.onload = () => {
51
- if (cancelled || !imageCanvasRef.current || !annotationsCanvasRef.current)
50
+ if (cancelled || !imageCanvasRef.current || !annotationsCanvasRef.current) {
52
51
  return;
53
- const imageCanvasState = engineRef.current
54
- ? engineRef.current.getImageCanvasState()
55
- : { moveX: 0, moveY: 0, zoomX: 0, zoomY: 0, zoom: 1, dx: 0, dy: 0, dw: img.width, dh: img.height, initZoom: 1 };
56
- engineRef.current?.destroy?.();
52
+ }
53
+ const imageCanvasState = !resetOnImageChange && prevImageCanvasState
54
+ ? prevImageCanvasState
55
+ : {
56
+ moveX: 0,
57
+ moveY: 0,
58
+ zoomX: 0,
59
+ zoomY: 0,
60
+ zoom: 1,
61
+ dx: 0,
62
+ dy: 0,
63
+ dw: img.width,
64
+ dh: img.height,
65
+ initZoom: 1,
66
+ };
67
+ const initialAnnotations = pendingAnnotationsRef.current ?? annotations ?? [];
68
+ pendingAnnotationsRef.current = null;
57
69
  engineRef.current = new AnnotationEngine({
58
70
  imageCanvas: imageCanvasRef.current,
59
71
  image: img,
60
72
  imageCanvasState,
61
73
  annotationsCanvas: annotationsCanvasRef.current,
62
- annotations,
63
- setAnnotations: (annotations) => setAnnotations?.(annotations),
74
+ annotations: initialAnnotations,
75
+ setAnnotations: (next) => setAnnotations?.(next),
64
76
  drawing,
65
77
  editable,
66
78
  panZoomEnabled,
@@ -69,42 +81,50 @@ const AnnotationCanvas = ({ image, annotations = [], setAnnotations, options, dr
69
81
  onChange: () => forceRender((v) => v + 1),
70
82
  });
71
83
  engineRef.current.initImageCanvas(resetOnImageChange);
84
+ onImageLoadSuccess?.();
85
+ };
86
+ img.onerror = () => {
87
+ if (cancelled)
88
+ return;
89
+ pendingAnnotationsRef.current = null;
90
+ onImageLoadError?.(new Error(`Failed to load image: ${image}`));
72
91
  };
73
- img.onerror = () => !cancelled && resetCanvas(`Failed to load image: ${image}`);
74
92
  img.src = image;
75
- imageRef.current = img;
76
- onImageLoadSuccess?.();
77
93
  return () => {
78
94
  cancelled = true;
79
95
  img.onload = null;
80
96
  img.onerror = null;
81
- engineRef.current?.destroy();
82
97
  };
83
- }, [image]);
98
+ }, [image, resetOnImageChange]);
99
+ /* ---------- External annotations ---------- */
84
100
  useEffect(() => {
85
- if (!engineRef.current)
101
+ if (!annotations)
86
102
  return;
87
- engineRef.current.setPanZoomEnabled(!!panZoomEnabled);
88
- }, [panZoomEnabled]);
89
- useEffect(() => {
90
- if (!engineRef.current)
91
- return;
92
- engineRef.current.setEditable(editable);
93
- }, [editable]);
94
- useEffect(() => {
95
- if (!engineRef.current)
103
+ if (!engineRef.current) {
104
+ pendingAnnotationsRef.current = annotations;
96
105
  return;
106
+ }
97
107
  const before = engineRef.current.getAnnotations();
98
108
  if (JSON.stringify(before) !== JSON.stringify(annotations)) {
99
109
  engineRef.current.setAnnotations(annotations);
100
110
  engineRef.current.commitHistory();
101
111
  }
102
- }, [engineRef.current, annotations]);
112
+ }, [annotations]);
113
+ /* ---------- Options sync ---------- */
103
114
  useEffect(() => {
104
- if (!engineRef.current)
105
- return;
106
- engineRef.current.setDrawing(drawing);
107
- }, [engineRef.current, drawing]);
108
- return (_jsx("div", { style: { width: '100%', height: '100%', display: 'flex', flex: 1 }, children: _jsxs("div", { onWheel: (e) => engineRef.current?.onWheel?.(e.nativeEvent), onMouseDown: (e) => engineRef.current?.onMouseDown?.(e.nativeEvent), onMouseMove: (e) => engineRef.current?.onMouseMove?.(e.nativeEvent), onMouseUp: (e) => engineRef.current?.onMouseUp?.(e.nativeEvent), onMouseLeave: (e) => engineRef.current?.onMouseLeave?.(e.nativeEvent), onContextMenu: (e) => e.preventDefault(), style: { flex: 1, position: 'relative', cursor: !panZoomEnabled || editable ? 'default' : 'grab' }, children: [_jsx("canvas", { ref: imageCanvasRef, style: { position: 'absolute', width: '100%', height: '100%' } }), _jsx("canvas", { ref: annotationsCanvasRef, style: { position: 'absolute', width: '100%', height: '100%' } }), ZoomButton && (_jsx(ZoomButton, { onClick: () => engineRef.current?.initImageCanvas(true), children: engineRef.current?.getZoomRatioLabel() }))] }) }));
115
+ engineRef.current?.setPanZoomEnabled(!!panZoomEnabled);
116
+ }, [panZoomEnabled]);
117
+ useEffect(() => {
118
+ engineRef.current?.setEditable(editable);
119
+ }, [editable]);
120
+ useEffect(() => {
121
+ engineRef.current?.setDrawing(drawing);
122
+ }, [drawing]);
123
+ /* ---------- Render ---------- */
124
+ return (_jsx("div", { style: { width: '100%', height: '100%', display: 'flex', flex: 1 }, children: _jsxs("div", { onWheel: (e) => engineRef.current?.onWheel?.(e.nativeEvent), onMouseDown: (e) => engineRef.current?.onMouseDown?.(e.nativeEvent), onMouseMove: (e) => engineRef.current?.onMouseMove?.(e.nativeEvent), onMouseUp: (e) => engineRef.current?.onMouseUp?.(e.nativeEvent), onMouseLeave: (e) => engineRef.current?.onMouseLeave?.(e.nativeEvent), onContextMenu: (e) => e.preventDefault(), style: {
125
+ flex: 1,
126
+ position: 'relative',
127
+ cursor: !panZoomEnabled || editable ? 'default' : 'grab',
128
+ }, children: [_jsx("canvas", { ref: imageCanvasRef, style: { position: 'absolute', width: '100%', height: '100%' } }), _jsx("canvas", { ref: annotationsCanvasRef, style: { position: 'absolute', width: '100%', height: '100%' } }), ZoomButton && (_jsx(ZoomButton, { onClick: () => engineRef.current?.initImageCanvas(true), children: engineRef.current?.getZoomRatioLabel() }))] }) }));
109
129
  };
110
130
  export default AnnotationCanvas;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepnoid/canvas",
3
- "version": "0.1.60",
3
+ "version": "0.1.62",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "main": "./dist/index.cjs",