react-native-image-editor-skia 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +202 -0
- package/lib/commonjs/ImageEditor.js +141 -0
- package/lib/commonjs/ImageEditor.js.map +1 -0
- package/lib/commonjs/annotations/AnnotationView.js +42 -0
- package/lib/commonjs/annotations/AnnotationView.js.map +1 -0
- package/lib/commonjs/annotations/ArrowAnnotation.js +29 -0
- package/lib/commonjs/annotations/ArrowAnnotation.js.map +1 -0
- package/lib/commonjs/annotations/CircleAnnotation.js +31 -0
- package/lib/commonjs/annotations/CircleAnnotation.js.map +1 -0
- package/lib/commonjs/annotations/FreehandAnnotation.js +29 -0
- package/lib/commonjs/annotations/FreehandAnnotation.js.map +1 -0
- package/lib/commonjs/annotations/MarkerAnnotation.js +27 -0
- package/lib/commonjs/annotations/MarkerAnnotation.js.map +1 -0
- package/lib/commonjs/annotations/RotatedGroup.js +34 -0
- package/lib/commonjs/annotations/RotatedGroup.js.map +1 -0
- package/lib/commonjs/annotations/TextAnnotation.js +40 -0
- package/lib/commonjs/annotations/TextAnnotation.js.map +1 -0
- package/lib/commonjs/annotations/geometry.js +73 -0
- package/lib/commonjs/annotations/geometry.js.map +1 -0
- package/lib/commonjs/annotations/geometryPure.js +104 -0
- package/lib/commonjs/annotations/geometryPure.js.map +1 -0
- package/lib/commonjs/canvas/AnnotationLayer.js +58 -0
- package/lib/commonjs/canvas/AnnotationLayer.js.map +1 -0
- package/lib/commonjs/canvas/BaseImageLayer.js +27 -0
- package/lib/commonjs/canvas/BaseImageLayer.js.map +1 -0
- package/lib/commonjs/canvas/CropOverlay.js +135 -0
- package/lib/commonjs/canvas/CropOverlay.js.map +1 -0
- package/lib/commonjs/canvas/EditorCanvas.js +91 -0
- package/lib/commonjs/canvas/EditorCanvas.js.map +1 -0
- package/lib/commonjs/canvas/InFlightLayer.js +152 -0
- package/lib/commonjs/canvas/InFlightLayer.js.map +1 -0
- package/lib/commonjs/canvas/SelectionOverlay.js +90 -0
- package/lib/commonjs/canvas/SelectionOverlay.js.map +1 -0
- package/lib/commonjs/constants.js +56 -0
- package/lib/commonjs/constants.js.map +1 -0
- package/lib/commonjs/context/EditorContext.js +132 -0
- package/lib/commonjs/context/EditorContext.js.map +1 -0
- package/lib/commonjs/export/drawScene.js +97 -0
- package/lib/commonjs/export/drawScene.js.map +1 -0
- package/lib/commonjs/export/exportImage.js +92 -0
- package/lib/commonjs/export/exportImage.js.map +1 -0
- package/lib/commonjs/gestures/applyTransform.js +79 -0
- package/lib/commonjs/gestures/applyTransform.js.map +1 -0
- package/lib/commonjs/gestures/createAnnotation.js +73 -0
- package/lib/commonjs/gestures/createAnnotation.js.map +1 -0
- package/lib/commonjs/gestures/handles.js +53 -0
- package/lib/commonjs/gestures/handles.js.map +1 -0
- package/lib/commonjs/gestures/hitTest.js +72 -0
- package/lib/commonjs/gestures/hitTest.js.map +1 -0
- package/lib/commonjs/gestures/useCropGesture.js +149 -0
- package/lib/commonjs/gestures/useCropGesture.js.map +1 -0
- package/lib/commonjs/gestures/useEditorGestures.js +289 -0
- package/lib/commonjs/gestures/useEditorGestures.js.map +1 -0
- package/lib/commonjs/image/disposeRegistry.js +63 -0
- package/lib/commonjs/image/disposeRegistry.js.map +1 -0
- package/lib/commonjs/image/useLoadedImage.js +121 -0
- package/lib/commonjs/image/useLoadedImage.js.map +1 -0
- package/lib/commonjs/index.js +52 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/state/history.js +85 -0
- package/lib/commonjs/state/history.js.map +1 -0
- package/lib/commonjs/state/selectors.js +19 -0
- package/lib/commonjs/state/selectors.js.map +1 -0
- package/lib/commonjs/state/useEditorReducer.js +83 -0
- package/lib/commonjs/state/useEditorReducer.js.map +1 -0
- package/lib/commonjs/toolbar/ColorPicker.js +84 -0
- package/lib/commonjs/toolbar/ColorPicker.js.map +1 -0
- package/lib/commonjs/toolbar/CropControls.js +65 -0
- package/lib/commonjs/toolbar/CropControls.js.map +1 -0
- package/lib/commonjs/toolbar/RotationSlider.js +73 -0
- package/lib/commonjs/toolbar/RotationSlider.js.map +1 -0
- package/lib/commonjs/toolbar/TextInputOverlay.js +108 -0
- package/lib/commonjs/toolbar/TextInputOverlay.js.map +1 -0
- package/lib/commonjs/toolbar/ToolButton.js +56 -0
- package/lib/commonjs/toolbar/ToolButton.js.map +1 -0
- package/lib/commonjs/toolbar/Toolbar.js +137 -0
- package/lib/commonjs/toolbar/Toolbar.js.map +1 -0
- package/lib/commonjs/types.js +47 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/commonjs/utils/color.js +37 -0
- package/lib/commonjs/utils/color.js.map +1 -0
- package/lib/commonjs/utils/id.js +14 -0
- package/lib/commonjs/utils/id.js.map +1 -0
- package/lib/commonjs/utils/math.js +277 -0
- package/lib/commonjs/utils/math.js.map +1 -0
- package/lib/module/ImageEditor.js +138 -0
- package/lib/module/ImageEditor.js.map +1 -0
- package/lib/module/annotations/AnnotationView.js +39 -0
- package/lib/module/annotations/AnnotationView.js.map +1 -0
- package/lib/module/annotations/ArrowAnnotation.js +26 -0
- package/lib/module/annotations/ArrowAnnotation.js.map +1 -0
- package/lib/module/annotations/CircleAnnotation.js +27 -0
- package/lib/module/annotations/CircleAnnotation.js.map +1 -0
- package/lib/module/annotations/FreehandAnnotation.js +25 -0
- package/lib/module/annotations/FreehandAnnotation.js.map +1 -0
- package/lib/module/annotations/MarkerAnnotation.js +23 -0
- package/lib/module/annotations/MarkerAnnotation.js.map +1 -0
- package/lib/module/annotations/RotatedGroup.js +29 -0
- package/lib/module/annotations/RotatedGroup.js.map +1 -0
- package/lib/module/annotations/TextAnnotation.js +37 -0
- package/lib/module/annotations/TextAnnotation.js.map +1 -0
- package/lib/module/annotations/geometry.js +56 -0
- package/lib/module/annotations/geometry.js.map +1 -0
- package/lib/module/annotations/geometryPure.js +100 -0
- package/lib/module/annotations/geometryPure.js.map +1 -0
- package/lib/module/canvas/AnnotationLayer.js +55 -0
- package/lib/module/canvas/AnnotationLayer.js.map +1 -0
- package/lib/module/canvas/BaseImageLayer.js +23 -0
- package/lib/module/canvas/BaseImageLayer.js.map +1 -0
- package/lib/module/canvas/CropOverlay.js +131 -0
- package/lib/module/canvas/CropOverlay.js.map +1 -0
- package/lib/module/canvas/EditorCanvas.js +88 -0
- package/lib/module/canvas/EditorCanvas.js.map +1 -0
- package/lib/module/canvas/InFlightLayer.js +149 -0
- package/lib/module/canvas/InFlightLayer.js.map +1 -0
- package/lib/module/canvas/SelectionOverlay.js +85 -0
- package/lib/module/canvas/SelectionOverlay.js.map +1 -0
- package/lib/module/constants.js +52 -0
- package/lib/module/constants.js.map +1 -0
- package/lib/module/context/EditorContext.js +126 -0
- package/lib/module/context/EditorContext.js.map +1 -0
- package/lib/module/export/drawScene.js +93 -0
- package/lib/module/export/drawScene.js.map +1 -0
- package/lib/module/export/exportImage.js +88 -0
- package/lib/module/export/exportImage.js.map +1 -0
- package/lib/module/gestures/applyTransform.js +75 -0
- package/lib/module/gestures/applyTransform.js.map +1 -0
- package/lib/module/gestures/createAnnotation.js +65 -0
- package/lib/module/gestures/createAnnotation.js.map +1 -0
- package/lib/module/gestures/handles.js +49 -0
- package/lib/module/gestures/handles.js.map +1 -0
- package/lib/module/gestures/hitTest.js +69 -0
- package/lib/module/gestures/hitTest.js.map +1 -0
- package/lib/module/gestures/useCropGesture.js +145 -0
- package/lib/module/gestures/useCropGesture.js.map +1 -0
- package/lib/module/gestures/useEditorGestures.js +285 -0
- package/lib/module/gestures/useEditorGestures.js.map +1 -0
- package/lib/module/image/disposeRegistry.js +57 -0
- package/lib/module/image/disposeRegistry.js.map +1 -0
- package/lib/module/image/useLoadedImage.js +117 -0
- package/lib/module/image/useLoadedImage.js.map +1 -0
- package/lib/module/index.js +8 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/state/history.js +76 -0
- package/lib/module/state/history.js.map +1 -0
- package/lib/module/state/selectors.js +14 -0
- package/lib/module/state/selectors.js.map +1 -0
- package/lib/module/state/useEditorReducer.js +79 -0
- package/lib/module/state/useEditorReducer.js.map +1 -0
- package/lib/module/toolbar/ColorPicker.js +80 -0
- package/lib/module/toolbar/ColorPicker.js.map +1 -0
- package/lib/module/toolbar/CropControls.js +62 -0
- package/lib/module/toolbar/CropControls.js.map +1 -0
- package/lib/module/toolbar/RotationSlider.js +69 -0
- package/lib/module/toolbar/RotationSlider.js.map +1 -0
- package/lib/module/toolbar/TextInputOverlay.js +105 -0
- package/lib/module/toolbar/TextInputOverlay.js.map +1 -0
- package/lib/module/toolbar/ToolButton.js +52 -0
- package/lib/module/toolbar/ToolButton.js.map +1 -0
- package/lib/module/toolbar/Toolbar.js +133 -0
- package/lib/module/toolbar/Toolbar.js.map +1 -0
- package/lib/module/types.js +43 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/utils/color.js +33 -0
- package/lib/module/utils/color.js.map +1 -0
- package/lib/module/utils/id.js +10 -0
- package/lib/module/utils/id.js.map +1 -0
- package/lib/module/utils/math.js +258 -0
- package/lib/module/utils/math.js.map +1 -0
- package/lib/typescript/src/ImageEditor.d.ts +9 -0
- package/lib/typescript/src/ImageEditor.d.ts.map +1 -0
- package/lib/typescript/src/annotations/AnnotationView.d.ts +6 -0
- package/lib/typescript/src/annotations/AnnotationView.d.ts.map +1 -0
- package/lib/typescript/src/annotations/ArrowAnnotation.d.ts +5 -0
- package/lib/typescript/src/annotations/ArrowAnnotation.d.ts.map +1 -0
- package/lib/typescript/src/annotations/CircleAnnotation.d.ts +5 -0
- package/lib/typescript/src/annotations/CircleAnnotation.d.ts.map +1 -0
- package/lib/typescript/src/annotations/FreehandAnnotation.d.ts +5 -0
- package/lib/typescript/src/annotations/FreehandAnnotation.d.ts.map +1 -0
- package/lib/typescript/src/annotations/MarkerAnnotation.d.ts +5 -0
- package/lib/typescript/src/annotations/MarkerAnnotation.d.ts.map +1 -0
- package/lib/typescript/src/annotations/RotatedGroup.d.ts +13 -0
- package/lib/typescript/src/annotations/RotatedGroup.d.ts.map +1 -0
- package/lib/typescript/src/annotations/TextAnnotation.d.ts +10 -0
- package/lib/typescript/src/annotations/TextAnnotation.d.ts.map +1 -0
- package/lib/typescript/src/annotations/geometry.d.ts +11 -0
- package/lib/typescript/src/annotations/geometry.d.ts.map +1 -0
- package/lib/typescript/src/annotations/geometryPure.d.ts +14 -0
- package/lib/typescript/src/annotations/geometryPure.d.ts.map +1 -0
- package/lib/typescript/src/canvas/AnnotationLayer.d.ts +11 -0
- package/lib/typescript/src/canvas/AnnotationLayer.d.ts.map +1 -0
- package/lib/typescript/src/canvas/BaseImageLayer.d.ts +12 -0
- package/lib/typescript/src/canvas/BaseImageLayer.d.ts.map +1 -0
- package/lib/typescript/src/canvas/CropOverlay.d.ts +10 -0
- package/lib/typescript/src/canvas/CropOverlay.d.ts.map +1 -0
- package/lib/typescript/src/canvas/EditorCanvas.d.ts +18 -0
- package/lib/typescript/src/canvas/EditorCanvas.d.ts.map +1 -0
- package/lib/typescript/src/canvas/InFlightLayer.d.ts +12 -0
- package/lib/typescript/src/canvas/InFlightLayer.d.ts.map +1 -0
- package/lib/typescript/src/canvas/SelectionOverlay.d.ts +11 -0
- package/lib/typescript/src/canvas/SelectionOverlay.d.ts.map +1 -0
- package/lib/typescript/src/constants.d.ts +25 -0
- package/lib/typescript/src/constants.d.ts.map +1 -0
- package/lib/typescript/src/context/EditorContext.d.ts +66 -0
- package/lib/typescript/src/context/EditorContext.d.ts.map +1 -0
- package/lib/typescript/src/export/drawScene.d.ts +10 -0
- package/lib/typescript/src/export/drawScene.d.ts.map +1 -0
- package/lib/typescript/src/export/exportImage.d.ts +23 -0
- package/lib/typescript/src/export/exportImage.d.ts.map +1 -0
- package/lib/typescript/src/gestures/applyTransform.d.ts +17 -0
- package/lib/typescript/src/gestures/applyTransform.d.ts.map +1 -0
- package/lib/typescript/src/gestures/createAnnotation.d.ts +11 -0
- package/lib/typescript/src/gestures/createAnnotation.d.ts.map +1 -0
- package/lib/typescript/src/gestures/handles.d.ts +17 -0
- package/lib/typescript/src/gestures/handles.d.ts.map +1 -0
- package/lib/typescript/src/gestures/hitTest.d.ts +9 -0
- package/lib/typescript/src/gestures/hitTest.d.ts.map +1 -0
- package/lib/typescript/src/gestures/useCropGesture.d.ts +7 -0
- package/lib/typescript/src/gestures/useCropGesture.d.ts.map +1 -0
- package/lib/typescript/src/gestures/useEditorGestures.d.ts +8 -0
- package/lib/typescript/src/gestures/useEditorGestures.d.ts.map +1 -0
- package/lib/typescript/src/image/disposeRegistry.d.ts +25 -0
- package/lib/typescript/src/image/disposeRegistry.d.ts.map +1 -0
- package/lib/typescript/src/image/useLoadedImage.d.ts +23 -0
- package/lib/typescript/src/image/useLoadedImage.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +6 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/state/history.d.ts +23 -0
- package/lib/typescript/src/state/history.d.ts.map +1 -0
- package/lib/typescript/src/state/selectors.d.ts +5 -0
- package/lib/typescript/src/state/selectors.d.ts.map +1 -0
- package/lib/typescript/src/state/useEditorReducer.d.ts +32 -0
- package/lib/typescript/src/state/useEditorReducer.d.ts.map +1 -0
- package/lib/typescript/src/toolbar/ColorPicker.d.ts +7 -0
- package/lib/typescript/src/toolbar/ColorPicker.d.ts.map +1 -0
- package/lib/typescript/src/toolbar/CropControls.d.ts +3 -0
- package/lib/typescript/src/toolbar/CropControls.d.ts.map +1 -0
- package/lib/typescript/src/toolbar/RotationSlider.d.ts +8 -0
- package/lib/typescript/src/toolbar/RotationSlider.d.ts.map +1 -0
- package/lib/typescript/src/toolbar/TextInputOverlay.d.ts +9 -0
- package/lib/typescript/src/toolbar/TextInputOverlay.d.ts.map +1 -0
- package/lib/typescript/src/toolbar/ToolButton.d.ts +7 -0
- package/lib/typescript/src/toolbar/ToolButton.d.ts.map +1 -0
- package/lib/typescript/src/toolbar/Toolbar.d.ts +4 -0
- package/lib/typescript/src/toolbar/Toolbar.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +170 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/lib/typescript/src/utils/color.d.ts +8 -0
- package/lib/typescript/src/utils/color.d.ts.map +1 -0
- package/lib/typescript/src/utils/id.d.ts +3 -0
- package/lib/typescript/src/utils/id.d.ts.map +1 -0
- package/lib/typescript/src/utils/math.d.ts +68 -0
- package/lib/typescript/src/utils/math.d.ts.map +1 -0
- package/package.json +90 -0
- package/src/ImageEditor.tsx +133 -0
- package/src/annotations/AnnotationView.tsx +24 -0
- package/src/annotations/ArrowAnnotation.tsx +26 -0
- package/src/annotations/CircleAnnotation.tsx +22 -0
- package/src/annotations/FreehandAnnotation.tsx +22 -0
- package/src/annotations/MarkerAnnotation.tsx +20 -0
- package/src/annotations/RotatedGroup.tsx +28 -0
- package/src/annotations/TextAnnotation.tsx +42 -0
- package/src/annotations/geometry.ts +62 -0
- package/src/annotations/geometryPure.ts +73 -0
- package/src/canvas/AnnotationLayer.tsx +43 -0
- package/src/canvas/BaseImageLayer.tsx +28 -0
- package/src/canvas/CropOverlay.tsx +92 -0
- package/src/canvas/EditorCanvas.tsx +70 -0
- package/src/canvas/InFlightLayer.tsx +140 -0
- package/src/canvas/SelectionOverlay.tsx +92 -0
- package/src/constants.ts +46 -0
- package/src/context/EditorContext.tsx +229 -0
- package/src/export/drawScene.ts +120 -0
- package/src/export/exportImage.ts +111 -0
- package/src/gestures/applyTransform.ts +76 -0
- package/src/gestures/createAnnotation.ts +92 -0
- package/src/gestures/handles.ts +50 -0
- package/src/gestures/hitTest.ts +79 -0
- package/src/gestures/useCropGesture.ts +123 -0
- package/src/gestures/useEditorGestures.ts +308 -0
- package/src/image/disposeRegistry.ts +59 -0
- package/src/image/useLoadedImage.ts +131 -0
- package/src/index.ts +32 -0
- package/src/state/history.ts +71 -0
- package/src/state/selectors.ts +16 -0
- package/src/state/useEditorReducer.ts +93 -0
- package/src/toolbar/ColorPicker.tsx +72 -0
- package/src/toolbar/CropControls.tsx +46 -0
- package/src/toolbar/RotationSlider.tsx +56 -0
- package/src/toolbar/TextInputOverlay.tsx +104 -0
- package/src/toolbar/ToolButton.tsx +46 -0
- package/src/toolbar/Toolbar.tsx +110 -0
- package/src/types.ts +203 -0
- package/src/utils/color.ts +34 -0
- package/src/utils/id.ts +7 -0
- package/src/utils/math.ts +222 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Annotation, Vec2 } from '../types';
|
|
2
|
+
import { annotationBounds, annotationCenter } from '../annotations/geometryPure';
|
|
3
|
+
import { distanceToSegment, rotatePoint } from '../utils/math';
|
|
4
|
+
|
|
5
|
+
// NOTE: `hitOne` is declared BEFORE `hitTest`. The worklets Babel plugin rewrites
|
|
6
|
+
// `'worklet'` function declarations into `const`s (no hoisting), so a worklet that
|
|
7
|
+
// calls another must be defined after its callee.
|
|
8
|
+
function hitOne(a: Annotation, p: Vec2, slop: number): boolean {
|
|
9
|
+
'worklet';
|
|
10
|
+
switch (a.type) {
|
|
11
|
+
case 'circle': {
|
|
12
|
+
const d = Math.hypot(p.x - a.center.x, p.y - a.center.y);
|
|
13
|
+
if (a.fill !== undefined) {
|
|
14
|
+
return d <= a.radius + slop;
|
|
15
|
+
}
|
|
16
|
+
// Outline: hit near the ring.
|
|
17
|
+
return Math.abs(d - a.radius) <= slop + a.strokeWidth / 2;
|
|
18
|
+
}
|
|
19
|
+
case 'arrow':
|
|
20
|
+
return distanceToSegment(p, a.start, a.end) <= slop + a.strokeWidth / 2;
|
|
21
|
+
case 'marker': {
|
|
22
|
+
const r = a.rect;
|
|
23
|
+
return (
|
|
24
|
+
p.x >= r.x - slop &&
|
|
25
|
+
p.x <= r.x + r.width + slop &&
|
|
26
|
+
p.y >= r.y - slop &&
|
|
27
|
+
p.y <= r.y + r.height + slop
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
case 'freehand': {
|
|
31
|
+
const pts = a.points;
|
|
32
|
+
const tol = slop + a.strokeWidth / 2;
|
|
33
|
+
for (let i = 1; i < pts.length; i++) {
|
|
34
|
+
if (distanceToSegment(p, pts[i - 1]!, pts[i]!) <= tol) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return pts.length === 1
|
|
39
|
+
? Math.hypot(p.x - pts[0]!.x, p.y - pts[0]!.y) <= tol
|
|
40
|
+
: false;
|
|
41
|
+
}
|
|
42
|
+
case 'text': {
|
|
43
|
+
const b = annotationBounds(a);
|
|
44
|
+
return (
|
|
45
|
+
p.x >= b.x - slop &&
|
|
46
|
+
p.x <= b.x + b.width + slop &&
|
|
47
|
+
p.y >= b.y - slop &&
|
|
48
|
+
p.y <= b.y + b.height + slop
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Return the id of the top-most annotation hit by `point` (image space), or null.
|
|
56
|
+
* Worklet-safe so selection resolves on the UI thread during a tap.
|
|
57
|
+
*
|
|
58
|
+
* `slop` is an extra hit margin, in image pixels (screen slop / current scale).
|
|
59
|
+
*/
|
|
60
|
+
export function hitTest(
|
|
61
|
+
annotations: Annotation[],
|
|
62
|
+
point: Vec2,
|
|
63
|
+
slop: number
|
|
64
|
+
): string | null {
|
|
65
|
+
'worklet';
|
|
66
|
+
// Iterate from top (highest z) to bottom.
|
|
67
|
+
const ordered = [...annotations].sort((a, b) => b.z - a.z);
|
|
68
|
+
for (let i = 0; i < ordered.length; i++) {
|
|
69
|
+
const a = ordered[i]!;
|
|
70
|
+
// Transform the touch into the annotation's local (unrotated) frame.
|
|
71
|
+
const local = a.rotation
|
|
72
|
+
? rotatePoint(point, annotationCenter(a), -a.rotation)
|
|
73
|
+
: point;
|
|
74
|
+
if (hitOne(a, local, slop)) {
|
|
75
|
+
return a.id;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { Gesture } from 'react-native-gesture-handler';
|
|
3
|
+
import { useSharedValue } from 'react-native-reanimated';
|
|
4
|
+
|
|
5
|
+
import type { Rect, Vec2 } from '../types';
|
|
6
|
+
import { useEditor } from '../context/EditorContext';
|
|
7
|
+
import { HANDLE_SIZE } from '../constants';
|
|
8
|
+
import { applyToPoint, distance, invert } from '../utils/math';
|
|
9
|
+
|
|
10
|
+
const MIN_CROP = 24; // minimum crop size, in image pixels
|
|
11
|
+
|
|
12
|
+
type CropMode = 'none' | 'move' | 'resize';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Pan gesture that edits `cropRectSV` (image space) while the crop tool is
|
|
16
|
+
* active. Grab a corner to resize, or the interior to move. All values stay
|
|
17
|
+
* clamped inside the image bounds.
|
|
18
|
+
*/
|
|
19
|
+
export function useCropGesture() {
|
|
20
|
+
const { tool, matrix, cropRectSV, imageSize } = useEditor();
|
|
21
|
+
const invMatrix = useMemo(() => invert(matrix), [matrix]);
|
|
22
|
+
|
|
23
|
+
const cropMode = useSharedValue<CropMode>('none');
|
|
24
|
+
const corner = useSharedValue(-1);
|
|
25
|
+
const startRect = useSharedValue<Rect>({ x: 0, y: 0, width: 0, height: 0 });
|
|
26
|
+
const startImg = useSharedValue<Vec2>({ x: 0, y: 0 });
|
|
27
|
+
|
|
28
|
+
return useMemo(() => {
|
|
29
|
+
return Gesture.Pan()
|
|
30
|
+
.enabled(tool === 'crop')
|
|
31
|
+
.maxPointers(1)
|
|
32
|
+
.onBegin((e) => {
|
|
33
|
+
'worklet';
|
|
34
|
+
const screen = { x: e.x, y: e.y };
|
|
35
|
+
const img = applyToPoint(invMatrix, screen);
|
|
36
|
+
const r = cropRectSV.value;
|
|
37
|
+
startRect.value = r;
|
|
38
|
+
startImg.value = img;
|
|
39
|
+
|
|
40
|
+
const corners: Vec2[] = [
|
|
41
|
+
{ x: r.x, y: r.y },
|
|
42
|
+
{ x: r.x + r.width, y: r.y },
|
|
43
|
+
{ x: r.x + r.width, y: r.y + r.height },
|
|
44
|
+
{ x: r.x, y: r.y + r.height },
|
|
45
|
+
];
|
|
46
|
+
for (let i = 0; i < corners.length; i++) {
|
|
47
|
+
const cs = applyToPoint(matrix, corners[i]!);
|
|
48
|
+
if (distance(screen, cs) <= HANDLE_SIZE * 1.5) {
|
|
49
|
+
cropMode.value = 'resize';
|
|
50
|
+
corner.value = i;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (
|
|
55
|
+
img.x >= r.x &&
|
|
56
|
+
img.x <= r.x + r.width &&
|
|
57
|
+
img.y >= r.y &&
|
|
58
|
+
img.y <= r.y + r.height
|
|
59
|
+
) {
|
|
60
|
+
cropMode.value = 'move';
|
|
61
|
+
} else {
|
|
62
|
+
cropMode.value = 'none';
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
.onChange((e) => {
|
|
66
|
+
'worklet';
|
|
67
|
+
const img = applyToPoint(invMatrix, { x: e.x, y: e.y });
|
|
68
|
+
const dx = img.x - startImg.value.x;
|
|
69
|
+
const dy = img.y - startImg.value.y;
|
|
70
|
+
const s = startRect.value;
|
|
71
|
+
|
|
72
|
+
if (cropMode.value === 'move') {
|
|
73
|
+
let nx = s.x + dx;
|
|
74
|
+
let ny = s.y + dy;
|
|
75
|
+
nx = Math.max(0, Math.min(nx, imageSize.width - s.width));
|
|
76
|
+
ny = Math.max(0, Math.min(ny, imageSize.height - s.height));
|
|
77
|
+
cropRectSV.value = { x: nx, y: ny, width: s.width, height: s.height };
|
|
78
|
+
} else if (cropMode.value === 'resize') {
|
|
79
|
+
// Fixed corner = the one opposite the grabbed corner.
|
|
80
|
+
const left = s.x;
|
|
81
|
+
const top = s.y;
|
|
82
|
+
const right = s.x + s.width;
|
|
83
|
+
const bottom = s.y + s.height;
|
|
84
|
+
let x0 = left;
|
|
85
|
+
let y0 = top;
|
|
86
|
+
let x1 = right;
|
|
87
|
+
let y1 = bottom;
|
|
88
|
+
switch (corner.value) {
|
|
89
|
+
case 0: // TL moves
|
|
90
|
+
x0 = Math.min(right - MIN_CROP, Math.max(0, left + dx));
|
|
91
|
+
y0 = Math.min(bottom - MIN_CROP, Math.max(0, top + dy));
|
|
92
|
+
break;
|
|
93
|
+
case 1: // TR moves
|
|
94
|
+
x1 = Math.max(left + MIN_CROP, Math.min(imageSize.width, right + dx));
|
|
95
|
+
y0 = Math.min(bottom - MIN_CROP, Math.max(0, top + dy));
|
|
96
|
+
break;
|
|
97
|
+
case 2: // BR moves
|
|
98
|
+
x1 = Math.max(left + MIN_CROP, Math.min(imageSize.width, right + dx));
|
|
99
|
+
y1 = Math.max(top + MIN_CROP, Math.min(imageSize.height, bottom + dy));
|
|
100
|
+
break;
|
|
101
|
+
case 3: // BL moves
|
|
102
|
+
x0 = Math.min(right - MIN_CROP, Math.max(0, left + dx));
|
|
103
|
+
y1 = Math.max(top + MIN_CROP, Math.min(imageSize.height, bottom + dy));
|
|
104
|
+
break;
|
|
105
|
+
default:
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
cropRectSV.value = {
|
|
109
|
+
x: x0,
|
|
110
|
+
y: y0,
|
|
111
|
+
width: x1 - x0,
|
|
112
|
+
height: y1 - y0,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
.onEnd(() => {
|
|
117
|
+
'worklet';
|
|
118
|
+
cropMode.value = 'none';
|
|
119
|
+
corner.value = -1;
|
|
120
|
+
});
|
|
121
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
122
|
+
}, [tool, matrix, imageSize]);
|
|
123
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { Gesture } from 'react-native-gesture-handler';
|
|
3
|
+
import { runOnJS, useSharedValue } from 'react-native-reanimated';
|
|
4
|
+
|
|
5
|
+
import type { Annotation, Rect, ToolType, Vec2 } from '../types';
|
|
6
|
+
import { useEditor } from '../context/EditorContext';
|
|
7
|
+
import {
|
|
8
|
+
FREEHAND_MIN_DISTANCE,
|
|
9
|
+
HANDLE_SIZE,
|
|
10
|
+
HIT_SLOP,
|
|
11
|
+
} from '../constants';
|
|
12
|
+
import { applyToPoint, distance, invert } from '../utils/math';
|
|
13
|
+
import { annotationCenter } from '../annotations/geometry';
|
|
14
|
+
import { hitTest } from './hitTest';
|
|
15
|
+
import { selectionHandles } from './handles';
|
|
16
|
+
import { applyTransformToAnnotation } from './applyTransform';
|
|
17
|
+
import type { LiveValues } from './applyTransform';
|
|
18
|
+
import {
|
|
19
|
+
makeArrow,
|
|
20
|
+
makeCircle,
|
|
21
|
+
makeFreehand,
|
|
22
|
+
makeMarker,
|
|
23
|
+
makeText,
|
|
24
|
+
} from './createAnnotation';
|
|
25
|
+
|
|
26
|
+
type Mode = 'none' | 'draw' | 'text' | 'move' | 'resize' | 'rotate';
|
|
27
|
+
|
|
28
|
+
function isDrawTool(tool: ToolType): boolean {
|
|
29
|
+
'worklet';
|
|
30
|
+
return (
|
|
31
|
+
tool === 'circle' ||
|
|
32
|
+
tool === 'arrow' ||
|
|
33
|
+
tool === 'marker' ||
|
|
34
|
+
tool === 'freehand'
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A single composed Pan gesture over the canvas. It branches on the active tool
|
|
40
|
+
* and, in select mode, on where the touch began (rotate handle → rotate, corner
|
|
41
|
+
* handle → resize, body → move, empty → (de)select). All geometry math runs on
|
|
42
|
+
* the UI thread; only final commits hop to JS via runOnJS.
|
|
43
|
+
*/
|
|
44
|
+
export function useEditorGestures() {
|
|
45
|
+
const editor = useEditor();
|
|
46
|
+
const {
|
|
47
|
+
tool,
|
|
48
|
+
selectedId,
|
|
49
|
+
annotations,
|
|
50
|
+
matrix,
|
|
51
|
+
draw,
|
|
52
|
+
live,
|
|
53
|
+
strokeColor,
|
|
54
|
+
strokeWidth,
|
|
55
|
+
dispatch,
|
|
56
|
+
setSelectedId,
|
|
57
|
+
setEditingTextId,
|
|
58
|
+
} = editor;
|
|
59
|
+
|
|
60
|
+
const invMatrix = useMemo(() => invert(matrix), [matrix]);
|
|
61
|
+
const scaleFactor = useMemo(
|
|
62
|
+
() => Math.hypot(matrix.a, matrix.b) || 1,
|
|
63
|
+
[matrix]
|
|
64
|
+
);
|
|
65
|
+
const slopImage = HIT_SLOP / scaleFactor;
|
|
66
|
+
|
|
67
|
+
// Per-gesture UI-thread scratch state.
|
|
68
|
+
const mode = useSharedValue<Mode>('none');
|
|
69
|
+
const startImg = useSharedValue<Vec2>({ x: 0, y: 0 });
|
|
70
|
+
const startDist = useSharedValue(0);
|
|
71
|
+
const startAngle = useSharedValue(0);
|
|
72
|
+
const activeId = useSharedValue<string | null>(null);
|
|
73
|
+
|
|
74
|
+
// ---- JS-thread commit callbacks --------------------------------------
|
|
75
|
+
const selectId = (id: string | null) => setSelectedId(id);
|
|
76
|
+
|
|
77
|
+
const commitCircle = (center: Vec2, radius: number) =>
|
|
78
|
+
dispatch({
|
|
79
|
+
type: 'ADD_ANNOTATION',
|
|
80
|
+
annotation: makeCircle(center, radius, { strokeColor, strokeWidth }),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const commitArrow = (start: Vec2, end: Vec2) =>
|
|
84
|
+
dispatch({
|
|
85
|
+
type: 'ADD_ANNOTATION',
|
|
86
|
+
annotation: makeArrow(start, end, { strokeColor, strokeWidth }),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const commitMarker = (rect: Rect) =>
|
|
90
|
+
dispatch({
|
|
91
|
+
type: 'ADD_ANNOTATION',
|
|
92
|
+
annotation: makeMarker(rect, strokeColor),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const commitFreehand = (points: Vec2[]) =>
|
|
96
|
+
dispatch({
|
|
97
|
+
type: 'ADD_ANNOTATION',
|
|
98
|
+
annotation: makeFreehand(points, { strokeColor, strokeWidth }),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const placeText = (origin: Vec2) => {
|
|
102
|
+
const annotation = makeText(origin, editor.textColor);
|
|
103
|
+
dispatch({ type: 'ADD_ANNOTATION', annotation });
|
|
104
|
+
setSelectedId(annotation.id);
|
|
105
|
+
setEditingTextId(annotation.id);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const commitTransform = (id: string | null, vals: LiveValues) => {
|
|
109
|
+
if (!id) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
// Skip no-op transforms (a plain tap) so they don't pollute history.
|
|
113
|
+
const identity =
|
|
114
|
+
vals.tx === 0 && vals.ty === 0 && vals.rotate === 0 && vals.scale === 1;
|
|
115
|
+
if (identity) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const target = annotations.find((a) => a.id === id);
|
|
119
|
+
if (!target) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const updated = applyTransformToAnnotation(target, vals);
|
|
123
|
+
dispatch({ type: 'UPDATE_ANNOTATION', id, changes: updated });
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const resetLive = () => {
|
|
127
|
+
live.active.value = false;
|
|
128
|
+
live.tx.value = 0;
|
|
129
|
+
live.ty.value = 0;
|
|
130
|
+
live.rotate.value = 0;
|
|
131
|
+
live.scale.value = 1;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// ---- The gesture ------------------------------------------------------
|
|
135
|
+
const pan = useMemo(() => {
|
|
136
|
+
return Gesture.Pan()
|
|
137
|
+
.maxPointers(1)
|
|
138
|
+
.onBegin((e) => {
|
|
139
|
+
'worklet';
|
|
140
|
+
if (tool === 'crop') {
|
|
141
|
+
mode.value = 'none';
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const screen = { x: e.x, y: e.y };
|
|
145
|
+
const img = applyToPoint(invMatrix, screen);
|
|
146
|
+
startImg.value = img;
|
|
147
|
+
activeId.value = selectedId;
|
|
148
|
+
|
|
149
|
+
if (isDrawTool(tool)) {
|
|
150
|
+
mode.value = 'draw';
|
|
151
|
+
draw.active.value = true;
|
|
152
|
+
draw.start.value = img;
|
|
153
|
+
draw.current.value = img;
|
|
154
|
+
if (tool === 'freehand') {
|
|
155
|
+
draw.points.value = [img];
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (tool === 'text') {
|
|
161
|
+
mode.value = 'text';
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// select tool: figure out what was grabbed.
|
|
166
|
+
const sel: Annotation | undefined = selectedId
|
|
167
|
+
? annotations.find((a) => a.id === selectedId)
|
|
168
|
+
: undefined;
|
|
169
|
+
if (sel) {
|
|
170
|
+
const h = selectionHandles(sel, matrix);
|
|
171
|
+
const center = annotationCenter(sel);
|
|
172
|
+
if (distance(screen, h.rotate) <= HANDLE_SIZE * 1.5) {
|
|
173
|
+
mode.value = 'rotate';
|
|
174
|
+
live.origin.value = center;
|
|
175
|
+
live.active.value = true;
|
|
176
|
+
startAngle.value = Math.atan2(img.y - center.y, img.x - center.x);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
for (let i = 0; i < h.corners.length; i++) {
|
|
180
|
+
if (distance(screen, h.corners[i]!) <= HANDLE_SIZE * 1.5) {
|
|
181
|
+
mode.value = 'resize';
|
|
182
|
+
live.origin.value = center;
|
|
183
|
+
live.active.value = true;
|
|
184
|
+
startDist.value = distance(center, img);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Body of the selected annotation → move it.
|
|
189
|
+
if (hitTest([sel], img, slopImage) === sel.id) {
|
|
190
|
+
mode.value = 'move';
|
|
191
|
+
live.origin.value = center;
|
|
192
|
+
live.active.value = true;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Otherwise (de)select whatever is under the touch.
|
|
198
|
+
const hitId = hitTest(annotations, img, slopImage);
|
|
199
|
+
runOnJS(selectId)(hitId);
|
|
200
|
+
activeId.value = hitId;
|
|
201
|
+
if (hitId) {
|
|
202
|
+
const target = annotations.find((a) => a.id === hitId)!;
|
|
203
|
+
mode.value = 'move';
|
|
204
|
+
live.origin.value = annotationCenter(target);
|
|
205
|
+
live.active.value = true;
|
|
206
|
+
} else {
|
|
207
|
+
mode.value = 'none';
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
.onChange((e) => {
|
|
211
|
+
'worklet';
|
|
212
|
+
const screen = { x: e.x, y: e.y };
|
|
213
|
+
const img = applyToPoint(invMatrix, screen);
|
|
214
|
+
switch (mode.value) {
|
|
215
|
+
case 'draw':
|
|
216
|
+
if (tool === 'freehand') {
|
|
217
|
+
const pts = draw.points.value;
|
|
218
|
+
const last = pts[pts.length - 1];
|
|
219
|
+
if (!last || distance(last, img) >= FREEHAND_MIN_DISTANCE) {
|
|
220
|
+
draw.points.value = [...pts, img];
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
draw.current.value = img;
|
|
224
|
+
}
|
|
225
|
+
break;
|
|
226
|
+
case 'move':
|
|
227
|
+
live.tx.value = img.x - startImg.value.x;
|
|
228
|
+
live.ty.value = img.y - startImg.value.y;
|
|
229
|
+
break;
|
|
230
|
+
case 'resize':
|
|
231
|
+
live.scale.value =
|
|
232
|
+
startDist.value > 0 ? distance(live.origin.value, img) / startDist.value : 1;
|
|
233
|
+
break;
|
|
234
|
+
case 'rotate': {
|
|
235
|
+
const ang = Math.atan2(
|
|
236
|
+
img.y - live.origin.value.y,
|
|
237
|
+
img.x - live.origin.value.x
|
|
238
|
+
);
|
|
239
|
+
live.rotate.value = ang - startAngle.value;
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
default:
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
.onEnd(() => {
|
|
247
|
+
'worklet';
|
|
248
|
+
if (mode.value === 'draw') {
|
|
249
|
+
draw.active.value = false;
|
|
250
|
+
const s = draw.start.value;
|
|
251
|
+
const c = draw.current.value;
|
|
252
|
+
if (tool === 'circle') {
|
|
253
|
+
const r = distance(s, c);
|
|
254
|
+
if (r > 2) runOnJS(commitCircle)(s, r);
|
|
255
|
+
} else if (tool === 'arrow') {
|
|
256
|
+
if (distance(s, c) > 2) runOnJS(commitArrow)(s, c);
|
|
257
|
+
} else if (tool === 'marker') {
|
|
258
|
+
const rect: Rect = {
|
|
259
|
+
x: Math.min(s.x, c.x),
|
|
260
|
+
y: Math.min(s.y, c.y),
|
|
261
|
+
width: Math.abs(c.x - s.x),
|
|
262
|
+
height: Math.abs(c.y - s.y),
|
|
263
|
+
};
|
|
264
|
+
if (rect.width > 2 && rect.height > 2) runOnJS(commitMarker)(rect);
|
|
265
|
+
} else if (tool === 'freehand') {
|
|
266
|
+
const pts = draw.points.value;
|
|
267
|
+
if (pts.length > 1) runOnJS(commitFreehand)(pts);
|
|
268
|
+
draw.points.value = [];
|
|
269
|
+
}
|
|
270
|
+
mode.value = 'none';
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (mode.value === 'text') {
|
|
275
|
+
runOnJS(placeText)(startImg.value);
|
|
276
|
+
mode.value = 'none';
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (
|
|
281
|
+
mode.value === 'move' ||
|
|
282
|
+
mode.value === 'resize' ||
|
|
283
|
+
mode.value === 'rotate'
|
|
284
|
+
) {
|
|
285
|
+
const vals: LiveValues = {
|
|
286
|
+
tx: live.tx.value,
|
|
287
|
+
ty: live.ty.value,
|
|
288
|
+
rotate: live.rotate.value,
|
|
289
|
+
scale: live.scale.value,
|
|
290
|
+
origin: live.origin.value,
|
|
291
|
+
};
|
|
292
|
+
runOnJS(commitTransform)(activeId.value, vals);
|
|
293
|
+
runOnJS(resetLive)();
|
|
294
|
+
mode.value = 'none';
|
|
295
|
+
}
|
|
296
|
+
})
|
|
297
|
+
.onFinalize(() => {
|
|
298
|
+
'worklet';
|
|
299
|
+
// Safety: ensure draw preview is cleared if the gesture is cancelled.
|
|
300
|
+
if (draw.active.value && mode.value !== 'draw') {
|
|
301
|
+
draw.active.value = false;
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
305
|
+
}, [tool, selectedId, annotations, matrix, strokeColor, strokeWidth]);
|
|
306
|
+
|
|
307
|
+
return pan;
|
|
308
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracks Skia native objects (SkImage / SkData / SkSurface / SkParagraph …) so
|
|
3
|
+
* they can be released deterministically. Skia objects are JSI HostObjects; the
|
|
4
|
+
* Hermes GC under-counts their native footprint and may never collect them, so
|
|
5
|
+
* we must call `.dispose()` explicitly. This registry is a safety net: register
|
|
6
|
+
* anything created imperatively, and `flush()` on unmount to guarantee cleanup.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface Disposable {
|
|
10
|
+
dispose: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class DisposeRegistry {
|
|
14
|
+
private items = new Set<Disposable>();
|
|
15
|
+
|
|
16
|
+
/** Track an object and return it for convenient chaining. */
|
|
17
|
+
add<T extends Disposable>(item: T): T {
|
|
18
|
+
this.items.add(item);
|
|
19
|
+
return item;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Stop tracking without disposing (e.g. ownership handed elsewhere). */
|
|
23
|
+
forget(item: Disposable): void {
|
|
24
|
+
this.items.delete(item);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Dispose a single tracked object now. */
|
|
28
|
+
release(item: Disposable | null | undefined): void {
|
|
29
|
+
if (!item) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
this.items.delete(item);
|
|
33
|
+
safeDispose(item);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Dispose everything still tracked. Call on unmount. */
|
|
37
|
+
flush(): void {
|
|
38
|
+
for (const item of this.items) {
|
|
39
|
+
safeDispose(item);
|
|
40
|
+
}
|
|
41
|
+
this.items.clear();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get size(): number {
|
|
45
|
+
return this.items.size;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Dispose without throwing if the object was already released. */
|
|
50
|
+
export function safeDispose(item: Disposable | null | undefined): void {
|
|
51
|
+
if (!item) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
item.dispose();
|
|
56
|
+
} catch {
|
|
57
|
+
// Already disposed or not a real disposable — ignore.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Skia } from '@shopify/react-native-skia';
|
|
3
|
+
import type { SkImage } from '@shopify/react-native-skia';
|
|
4
|
+
|
|
5
|
+
import type { ImageSource } from '../types';
|
|
6
|
+
import { safeDispose } from './disposeRegistry';
|
|
7
|
+
|
|
8
|
+
export interface LoadedImage {
|
|
9
|
+
image: SkImage | null;
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
loading: boolean;
|
|
13
|
+
error: Error | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function stripDataUri(base64: string): string {
|
|
17
|
+
const comma = base64.indexOf(',');
|
|
18
|
+
if (base64.startsWith('data:') && comma !== -1) {
|
|
19
|
+
return base64.slice(comma + 1);
|
|
20
|
+
}
|
|
21
|
+
return base64;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Decode an {@link ImageSource} into an `SkImage`, managing native memory.
|
|
26
|
+
*
|
|
27
|
+
* Memory rules enforced here:
|
|
28
|
+
* - The encoded `SkData` is disposed immediately after `MakeImageFromEncoded`;
|
|
29
|
+
* the decoded `SkImage` owns its pixels, so retaining the encoded bytes would
|
|
30
|
+
* roughly double memory usage.
|
|
31
|
+
* - When `source` changes (or the component unmounts) the previous `SkImage` is
|
|
32
|
+
* disposed before the next one is created.
|
|
33
|
+
* - The input base64 string is consumed inside the effect and never copied into
|
|
34
|
+
* state, so a large payload is not retained by this hook.
|
|
35
|
+
*/
|
|
36
|
+
export function useLoadedImage(source: ImageSource): LoadedImage {
|
|
37
|
+
const [state, setState] = useState<LoadedImage>({
|
|
38
|
+
image: null,
|
|
39
|
+
width: 0,
|
|
40
|
+
height: 0,
|
|
41
|
+
loading: true,
|
|
42
|
+
error: null,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Serialize the source so the effect re-runs only on a real change, without
|
|
46
|
+
// holding the (possibly huge) base64 string in a memoized ref.
|
|
47
|
+
const sourceKey =
|
|
48
|
+
'base64' in source ? `b64:${source.base64.length}:${source.base64.slice(-64)}` : `uri:${source.uri}`;
|
|
49
|
+
|
|
50
|
+
const currentImage = useRef<SkImage | null>(null);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
let cancelled = false;
|
|
54
|
+
|
|
55
|
+
const commit = (image: SkImage) => {
|
|
56
|
+
if (cancelled) {
|
|
57
|
+
// A newer load superseded us — drop this result.
|
|
58
|
+
safeDispose(image);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// Dispose the previous image before swapping in the new one.
|
|
62
|
+
if (currentImage.current && currentImage.current !== image) {
|
|
63
|
+
safeDispose(currentImage.current);
|
|
64
|
+
}
|
|
65
|
+
currentImage.current = image;
|
|
66
|
+
setState({
|
|
67
|
+
image,
|
|
68
|
+
width: image.width(),
|
|
69
|
+
height: image.height(),
|
|
70
|
+
loading: false,
|
|
71
|
+
error: null,
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const fail = (error: Error) => {
|
|
76
|
+
if (cancelled) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
setState((prev) => ({ ...prev, loading: false, error }));
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
setState((prev) => ({ ...prev, loading: true, error: null }));
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
if ('base64' in source) {
|
|
86
|
+
const data = Skia.Data.fromBase64(stripDataUri(source.base64));
|
|
87
|
+
const image = Skia.Image.MakeImageFromEncoded(data);
|
|
88
|
+
safeDispose(data); // decoded pixels are owned by `image` now
|
|
89
|
+
if (!image) {
|
|
90
|
+
throw new Error('Failed to decode image from base64.');
|
|
91
|
+
}
|
|
92
|
+
commit(image);
|
|
93
|
+
} else {
|
|
94
|
+
// fromURI is async (may fetch a remote/local file).
|
|
95
|
+
Skia.Data.fromURI(source.uri)
|
|
96
|
+
.then((data) => {
|
|
97
|
+
if (cancelled) {
|
|
98
|
+
safeDispose(data);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const image = Skia.Image.MakeImageFromEncoded(data);
|
|
102
|
+
safeDispose(data);
|
|
103
|
+
if (!image) {
|
|
104
|
+
throw new Error(`Failed to decode image from URI: ${source.uri}`);
|
|
105
|
+
}
|
|
106
|
+
commit(image);
|
|
107
|
+
})
|
|
108
|
+
.catch((e: unknown) =>
|
|
109
|
+
fail(e instanceof Error ? e : new Error(String(e)))
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
} catch (e) {
|
|
113
|
+
fail(e instanceof Error ? e : new Error(String(e)));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return () => {
|
|
117
|
+
cancelled = true;
|
|
118
|
+
};
|
|
119
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
120
|
+
}, [sourceKey]);
|
|
121
|
+
|
|
122
|
+
// Final safety net: dispose the last image when the hook unmounts.
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
return () => {
|
|
125
|
+
safeDispose(currentImage.current);
|
|
126
|
+
currentImage.current = null;
|
|
127
|
+
};
|
|
128
|
+
}, []);
|
|
129
|
+
|
|
130
|
+
return state;
|
|
131
|
+
}
|