@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 [
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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,
|
|
69
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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,
|
|
142
|
+
if (canvas_el) {
|
|
143
|
+
init(canvas_el, loadedImage);
|
|
144
|
+
}
|
|
82
145
|
}
|
|
83
|
-
setImageOnloadCount(prev => prev + 1);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
"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",
|