@deepnoid/canvas 0.1.3 → 0.1.5

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.
@@ -17,6 +17,8 @@ type Props = {
17
17
  children: ReactNode;
18
18
  }>;
19
19
  };
20
+ onImageLoadError?: (error: Error) => void;
21
+ onImageLoadSuccess?: () => void;
20
22
  };
21
- declare const XrayViewer: ({ image, hasZoom, coordinates }: Props) => import("react/jsx-runtime").JSX.Element;
23
+ declare const XrayViewer: ({ image, hasZoom, coordinates, onImageLoadError, onImageLoadSuccess }: Props) => import("react/jsx-runtime").JSX.Element;
22
24
  export default XrayViewer;
@@ -1,15 +1,17 @@
1
+ 'use client';
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  import { useCallback, useEffect, useRef, useState } from 'react';
3
4
  import useResizeObserver from '../hooks/useResizeObserver';
4
5
  import { canvasCenterPoint, drawCanvas, getMousePointTransform, resolutionCanvas } from '../utils/graphic';
5
6
  import Canvas from './Canvas';
6
- const XrayViewer = ({ image, hasZoom, coordinates }) => {
7
+ const XrayViewer = ({ image, hasZoom, coordinates, onImageLoadError, onImageLoadSuccess }) => {
7
8
  const wrapper = useRef(null);
8
9
  const viewer = useRef(null);
9
10
  const { width, height } = useResizeObserver({ ref: viewer });
10
11
  const ZOOM_UNIT = 0.9;
11
12
  const MAX_ZOOM = 4;
12
13
  const MIN_ZOOM = 0.5;
14
+ const IMAGE_LOAD_TIMEOUT = 10000;
13
15
  const [initZoom, setInitZoom] = useState(0);
14
16
  const [zoom, setZoom] = useState(0);
15
17
  const [zoomX, setZoomX] = useState(0);
@@ -22,9 +24,36 @@ const XrayViewer = ({ image, hasZoom, coordinates }) => {
22
24
  const [moveY, setMoveY] = useState(0);
23
25
  const [status, setStatus] = useState('');
24
26
  const [imageOnloadCount, setImageOnloadCount] = useState(0);
25
- const [_imageInfo, _setImageInfo] = useState();
27
+ const [isImageLoading, setIsImageLoading] = useState(false);
28
+ const [imageError, setImageError] = useState(null);
26
29
  const imageRef = useRef(new Image());
27
30
  const prevImageInfo = useRef(undefined);
31
+ const loadTimeoutRef = useRef(0);
32
+ const [startMousePoint, setStartMousePoint] = useState();
33
+ const loadImage = useCallback((src) => {
34
+ return new Promise((resolve, reject) => {
35
+ const img = new Image();
36
+ img.crossOrigin = 'anonymous';
37
+ const timeout = setTimeout(() => {
38
+ reject(new Error(`Image load timeout: ${src}`));
39
+ }, IMAGE_LOAD_TIMEOUT);
40
+ img.onload = () => {
41
+ clearTimeout(timeout);
42
+ if (img.naturalWidth === 0 || img.naturalHeight === 0) {
43
+ reject(new Error(`Invalid image dimensions: ${src}`));
44
+ return;
45
+ }
46
+ resolve(img);
47
+ };
48
+ img.onerror = () => {
49
+ clearTimeout(timeout);
50
+ reject(new Error(`Failed to load image: ${src}`));
51
+ };
52
+ setTimeout(() => {
53
+ img.src = src;
54
+ }, 0);
55
+ });
56
+ }, []);
28
57
  const calculatorZoomPoint = useCallback((canvas_el, movementX, movementY, dx, dy) => {
29
58
  let x = 0;
30
59
  let y = 0;
@@ -46,67 +75,121 @@ const XrayViewer = ({ image, hasZoom, coordinates }) => {
46
75
  : canvas_el.clientWidth / image.width;
47
76
  }
48
77
  }, []);
49
- const init = (canvas_el, image) => {
50
- const point = canvasCenterPoint(canvas_el, image);
51
- setDx(point.x);
52
- setDy(point.y);
53
- setDw(image.width);
54
- setDh(image.height);
55
- setMoveX(0);
56
- setMoveY(0);
57
- const init_zoom = calculateInitZoom(image, canvas_el);
58
- setZoom(init_zoom);
59
- setInitZoom(init_zoom);
60
- const zoomPoint = calculatorZoomPoint(canvas_el, 0, 0, point.x, point.y);
61
- setZoomX(zoomPoint.x);
62
- setZoomY(zoomPoint.y);
63
- };
64
- const initCanvas = () => {
65
- if (image) {
78
+ const init = useCallback((canvas_el, image) => {
79
+ try {
80
+ const point = canvasCenterPoint(canvas_el, image);
81
+ setDx(point.x);
82
+ setDy(point.y);
83
+ setDw(image.width);
84
+ setDh(image.height);
85
+ setMoveX(0);
86
+ setMoveY(0);
87
+ const init_zoom = calculateInitZoom(image, canvas_el);
88
+ setZoom(init_zoom);
89
+ setInitZoom(init_zoom);
90
+ const zoomPoint = calculatorZoomPoint(canvas_el, 0, 0, point.x, point.y);
91
+ setZoomX(zoomPoint.x);
92
+ setZoomY(zoomPoint.y);
93
+ setImageError(null);
94
+ }
95
+ catch (error) {
96
+ const errorMessage = error instanceof Error ? error.message : 'Canvas initialization failed';
97
+ setImageError(errorMessage);
98
+ onImageLoadError?.(new Error(errorMessage));
99
+ }
100
+ }, [calculateInitZoom, calculatorZoomPoint, onImageLoadError]);
101
+ const initCanvas = useCallback(async () => {
102
+ if (!image)
103
+ return;
104
+ try {
105
+ setIsImageLoading(true);
106
+ setImageError(null);
107
+ if (loadTimeoutRef.current) {
108
+ clearTimeout(loadTimeoutRef.current);
109
+ }
110
+ const loadedImage = await loadImage(image.imagePath);
111
+ imageRef.current = loadedImage;
66
112
  const canvas_el = resolutionCanvas(viewer.current);
67
- if (canvas_el)
68
- init(canvas_el, imageRef.current);
69
- setImageOnloadCount(prev => prev + 1);
113
+ if (canvas_el) {
114
+ init(canvas_el, loadedImage);
115
+ setImageOnloadCount((prev) => prev + 1);
116
+ onImageLoadSuccess?.();
117
+ }
70
118
  }
71
- };
119
+ catch (error) {
120
+ const errorMessage = error instanceof Error ? error.message : 'Unknown image load error';
121
+ setImageError(errorMessage);
122
+ onImageLoadError?.(new Error(errorMessage));
123
+ console.error('Image load failed:', error);
124
+ }
125
+ finally {
126
+ setIsImageLoading(false);
127
+ }
128
+ }, [image, loadImage, init, onImageLoadError, onImageLoadSuccess]);
72
129
  useEffect(() => {
73
- if (image) {
74
- imageRef.current.src = image.imagePath;
75
- const imageInfo = `${image.itemId}-${image.type}`;
76
- imageRef.current.onload = () => {
77
- if (prevImageInfo.current !== imageInfo || ['REALITY'].includes(image.type)) {
130
+ if (!image)
131
+ return;
132
+ const imageInfo = `${image.itemId}-${image.type}`;
133
+ const loadImageAsync = async () => {
134
+ try {
135
+ setIsImageLoading(true);
136
+ setImageError(null);
137
+ const loadedImage = await loadImage(image.imagePath);
138
+ imageRef.current = loadedImage;
139
+ if (prevImageInfo.current !== imageInfo || image.type === 'REALITY') {
78
140
  prevImageInfo.current = imageInfo;
79
141
  const canvas_el = resolutionCanvas(viewer.current);
80
- if (canvas_el)
81
- init(canvas_el, imageRef.current);
142
+ if (canvas_el) {
143
+ init(canvas_el, loadedImage);
144
+ }
82
145
  }
83
- setImageOnloadCount(prev => prev + 1);
84
- };
85
- }
86
- }, [image]);
87
- const [startMousePoint, setStartMousePoint] = useState();
146
+ setImageOnloadCount((prev) => prev + 1);
147
+ onImageLoadSuccess?.();
148
+ }
149
+ catch (error) {
150
+ const errorMessage = error instanceof Error ? error.message : 'Image load failed';
151
+ setImageError(errorMessage);
152
+ onImageLoadError?.(new Error(errorMessage));
153
+ console.error('Image load failed:', error);
154
+ }
155
+ finally {
156
+ setIsImageLoading(false);
157
+ }
158
+ };
159
+ loadImageAsync();
160
+ return () => {
161
+ if (loadTimeoutRef.current) {
162
+ clearTimeout(loadTimeoutRef.current);
163
+ }
164
+ };
165
+ }, [image, loadImage, init, onImageLoadError, onImageLoadSuccess]);
88
166
  useEffect(() => {
89
167
  const _redraw = () => {
90
- if (viewer.current) {
91
- drawCanvas(moveX, moveY, zoomX, zoomY, zoom, dx, dy, resolutionCanvas(viewer.current), imageRef.current, true);
168
+ if (viewer.current && imageRef.current && !isImageLoading && !imageError) {
169
+ try {
170
+ drawCanvas(moveX, moveY, zoomX, zoomY, zoom, dx, dy, resolutionCanvas(viewer.current), imageRef.current, true);
171
+ }
172
+ catch {
173
+ setImageError('Canvas drawing failed');
174
+ }
92
175
  }
93
176
  };
94
177
  _redraw();
95
- }, [moveX, moveY, zoomX, zoomY, zoom, dx, dy, dw, dh, imageOnloadCount]);
178
+ }, [moveX, moveY, zoomX, zoomY, zoom, dx, dy, dw, dh, imageOnloadCount, isImageLoading, imageError]);
96
179
  useEffect(() => {
97
- if (image) {
98
- imageRef.current.src = image.imagePath;
180
+ if (image && imageRef.current && !isImageLoading) {
99
181
  const imageInfo = `${image.itemId}-${image.type}`;
100
182
  if (prevImageInfo.current === imageInfo) {
101
183
  const canvas_el = resolutionCanvas(viewer.current);
102
- if (canvas_el)
184
+ if (canvas_el) {
103
185
  init(canvas_el, imageRef.current);
104
- setImageOnloadCount(prev => prev + 1);
186
+ setImageOnloadCount((prev) => prev + 1);
187
+ }
105
188
  }
106
189
  }
107
- }, [width, height]);
190
+ }, [width, height, image, init, isImageLoading]);
108
191
  const handleWheel = (event) => {
109
- if (!hasZoom)
192
+ if (!hasZoom || isImageLoading || imageError)
110
193
  return;
111
194
  if (viewer.current) {
112
195
  let calc_zoom = event.deltaY < 0 ? (zoom || 1) * (1 / ZOOM_UNIT) : (zoom || 1) * ZOOM_UNIT;
@@ -122,7 +205,7 @@ const XrayViewer = ({ image, hasZoom, coordinates }) => {
122
205
  }
123
206
  };
124
207
  const handleMouseMove = (event) => {
125
- if (!hasZoom)
208
+ if (!hasZoom || isImageLoading || imageError)
126
209
  return;
127
210
  if (status === 'MOVE') {
128
211
  if (viewer.current) {
@@ -147,6 +230,8 @@ const XrayViewer = ({ image, hasZoom, coordinates }) => {
147
230
  event.preventDefault();
148
231
  };
149
232
  const handleMouseDown = (event) => {
233
+ if (isImageLoading || imageError)
234
+ return;
150
235
  setStatus('MOVE');
151
236
  if (viewer.current) {
152
237
  const canvas_el = viewer.current;
@@ -154,12 +239,7 @@ const XrayViewer = ({ image, hasZoom, coordinates }) => {
154
239
  const mouseX = event.clientX - rect.left;
155
240
  const mouseY = event.clientY - rect.top;
156
241
  const mouse = getMousePointTransform({ x: mouseX, y: mouseY }, { x: moveX, y: moveY }, { x: zoomX, y: zoomY }, { x: dx, y: dy }, zoom || 1, canvas_el);
157
- const x = mouse.x;
158
- const y = mouse.y;
159
- setStartMousePoint({
160
- x: x,
161
- y: y,
162
- });
242
+ setStartMousePoint({ x: mouse.x, y: mouse.y });
163
243
  }
164
244
  event.preventDefault();
165
245
  };
@@ -171,22 +251,14 @@ const XrayViewer = ({ image, hasZoom, coordinates }) => {
171
251
  setStatus('STOP');
172
252
  event.preventDefault();
173
253
  };
174
- return (_jsx("div", { style: {
175
- width: '100%',
176
- height: '100%',
177
- display: 'flex',
178
- flex: 1,
179
- backgroundColor: '#f5f5f5'
180
- }, children: _jsxs("div", { ref: wrapper, onWheel: handleWheel, onMouseMove: handleMouseMove, onMouseDown: handleMouseDown, onMouseUp: handleMouseUp, onMouseLeave: handleMouseLeave, style: {
181
- flex: 1,
182
- position: 'relative'
183
- }, children: [_jsx("canvas", { ref: viewer, style: {
254
+ return (_jsx("div", { style: { width: '100%', height: '100%', display: 'flex', flex: 1, backgroundColor: '#f5f5f5' }, children: _jsxs("div", { ref: wrapper, onWheel: handleWheel, onMouseMove: handleMouseMove, onMouseDown: handleMouseDown, onMouseUp: handleMouseUp, onMouseLeave: handleMouseLeave, style: { flex: 1, position: 'relative' }, children: [_jsx("canvas", { ref: viewer, style: {
184
255
  position: 'absolute',
185
256
  width: '100%',
186
257
  height: '100%',
187
258
  left: 0,
188
259
  top: 0,
189
- transition: 'all 500ms'
190
- } }), _jsx(Canvas, { moveX: moveX, moveY: moveY, zoomX: zoomX, zoomY: zoomY, zoom: zoom, dx: dx, dy: dy, dw: dw, dh: dh, coordinates: coordinates }), hasZoom && initZoom !== 0 && (_jsx(hasZoom.ZoomButton, { onClick: initCanvas, children: `${Math.round((zoom / initZoom) * 100)}%` }))] }) }));
260
+ transition: 'all 500ms',
261
+ opacity: isImageLoading || imageError ? 0.5 : 1,
262
+ } }), !isImageLoading && !imageError && (_jsx(Canvas, { moveX: moveX, moveY: moveY, zoomX: zoomX, zoomY: zoomY, zoom: zoom, dx: dx, dy: dy, dw: dw, dh: dh, coordinates: coordinates })), hasZoom && initZoom !== 0 && !isImageLoading && !imageError && (_jsx(hasZoom.ZoomButton, { onClick: initCanvas, children: `${Math.round((zoom / initZoom) * 100)}%` }))] }) }));
191
263
  };
192
264
  export default XrayViewer;
@@ -5,11 +5,11 @@ type ObservedSize = {
5
5
  };
6
6
  type ResizeHandler = (size: ObservedSize) => void;
7
7
  type HookResponse<T extends Element> = {
8
- ref: RefCallback<T>;
8
+ ref: RefCallback<T> & RefObject<T>;
9
9
  } & ObservedSize;
10
10
  type RoundingFunction = (n: number) => number;
11
11
  type ResizeObserverBoxOptions = 'border-box' | 'content-box' | 'device-pixel-content-box';
12
- declare const useResizeObserver: <T extends Element>(opts: {
12
+ declare const useResizeObserver: <T extends Element>(opts?: {
13
13
  ref?: RefObject<T | null> | T | null | undefined;
14
14
  onResize?: ResizeHandler;
15
15
  box?: ResizeObserverBoxOptions;
@@ -1,18 +1,12 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
- const useResizeObserver = (opts) => {
2
+ const useResizeObserver = (opts = {}) => {
3
3
  const onResize = opts.onResize;
4
4
  const onResizeRef = useRef(undefined);
5
5
  onResizeRef.current = onResize;
6
6
  const round = opts.round || Math.round;
7
7
  const resizeObserverRef = useRef({});
8
- const [size, setSize] = useState({
9
- width: undefined,
10
- height: undefined,
11
- });
12
- const previous = useRef({
13
- width: undefined,
14
- height: undefined,
15
- });
8
+ const [size, setSize] = useState({ width: undefined, height: undefined });
9
+ const previous = useRef({ width: undefined, height: undefined });
16
10
  const didUnmount = useRef(false);
17
11
  useEffect(() => {
18
12
  didUnmount.current = false;
@@ -62,10 +56,10 @@ const useResizeObserver = (opts) => {
62
56
  };
63
57
  }, [opts.box, round]), opts.ref);
64
58
  return useMemo(() => ({
65
- ref: refCallback,
59
+ ref: Object.assign(refCallback, { current: opts.ref?.current ?? null }),
66
60
  width: size.width,
67
61
  height: size.height,
68
- }), [refCallback, size.width, size.height]);
62
+ }), [refCallback, size.width, size.height, opts.ref]);
69
63
  };
70
64
  export default useResizeObserver;
71
65
  function useResolvedElement(subscriber, refOrElement) {
@@ -114,9 +108,7 @@ function useResolvedElement(subscriber, refOrElement) {
114
108
  }, [evaluateSubscription]);
115
109
  }
116
110
  function extractSize(entry, boxProp, sizeType) {
117
- const box = Array.isArray(entry[boxProp])
118
- ? entry[boxProp][0]
119
- : undefined;
111
+ const box = Array.isArray(entry[boxProp]) ? entry[boxProp][0] : undefined;
120
112
  if (!box) {
121
113
  return entry.contentRect[sizeType === 'inlineSize' ? 'width' : 'height'];
122
114
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepnoid/canvas",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "main": "./dist/index.cjs",
@@ -33,6 +33,7 @@
33
33
  "eslint-plugin-react": "^7.33.2",
34
34
  "eslint-plugin-react-hooks": "^4.6.0",
35
35
  "eslint-plugin-react-refresh": "^0.4.5",
36
+ "prettier": "^3.6.2",
36
37
  "react": "^19.1.1",
37
38
  "react-dom": "^19.1.1",
38
39
  "typescript": "^5.9.2",