@umodeapporg/ui 0.24.3 → 0.25.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/dist/components/Form/ControlledSelect.js +7 -1
- package/dist/components/Form/ControlledSelect.js.map +1 -1
- package/dist/components/Form/ControlledTabs.js +1 -1
- package/dist/components/Form/ControlledViewToggle.js +1 -1
- package/dist/components/Navigation/UiNavigationContext.js +1 -1
- package/dist/components/UI/ImageAnnotator/AnnotationEditor.d.ts +2 -1
- package/dist/components/UI/ImageAnnotator/AnnotationEditor.js +138 -43
- package/dist/components/UI/ImageAnnotator/AnnotationEditor.js.map +1 -1
- package/dist/components/UI/ImageAnnotator/ImageAnnotationDialog.d.ts +3 -1
- package/dist/components/UI/ImageAnnotator/ImageAnnotationDialog.js +3 -0
- package/dist/components/UI/ImageAnnotator/ImageAnnotationDialog.js.map +1 -1
- package/dist/components/UI/ImageAnnotator/canvas/AnnotationCropFrame.d.ts +16 -0
- package/dist/components/UI/ImageAnnotator/canvas/AnnotationCropFrame.js +156 -0
- package/dist/components/UI/ImageAnnotator/canvas/AnnotationCropFrame.js.map +1 -0
- package/dist/components/UI/ImageAnnotator/canvas/AnnotationStage.d.ts +8 -1
- package/dist/components/UI/ImageAnnotator/canvas/AnnotationStage.js +30 -5
- package/dist/components/UI/ImageAnnotator/canvas/AnnotationStage.js.map +1 -1
- package/dist/components/UI/ImageAnnotator/components/AnnotationCropBar.d.ts +12 -0
- package/dist/components/UI/ImageAnnotator/components/AnnotationCropBar.js +89 -0
- package/dist/components/UI/ImageAnnotator/components/AnnotationCropBar.js.map +1 -0
- package/dist/components/UI/ImageAnnotator/components/AnnotationTextOverlay.d.ts +3 -1
- package/dist/components/UI/ImageAnnotator/components/AnnotationTextOverlay.js +3 -2
- package/dist/components/UI/ImageAnnotator/components/AnnotationTextOverlay.js.map +1 -1
- package/dist/components/UI/ImageAnnotator/components/AnnotationToolbar.js +11 -0
- package/dist/components/UI/ImageAnnotator/components/AnnotationToolbar.js.map +1 -1
- package/dist/components/UI/ImageAnnotator/helpers/annotationCrop.d.ts +12 -0
- package/dist/components/UI/ImageAnnotator/helpers/annotationCrop.js +64 -0
- package/dist/components/UI/ImageAnnotator/helpers/annotationCrop.js.map +1 -0
- package/dist/components/UI/ImageAnnotator/hooks/useAnnotationDocument.d.ts +4 -2
- package/dist/components/UI/ImageAnnotator/hooks/useAnnotationDocument.js +23 -12
- package/dist/components/UI/ImageAnnotator/hooks/useAnnotationDocument.js.map +1 -1
- package/dist/components/UI/ImageAnnotator/hooks/useAnnotationShortcuts.js +2 -1
- package/dist/components/UI/ImageAnnotator/hooks/useAnnotationShortcuts.js.map +1 -1
- package/dist/components/UI/ImageAnnotator/interfaces/annotationDocument.d.ts +10 -1
- package/dist/components/UI/ImageAnnotator/interfaces/annotationDocument.js.map +1 -1
- package/dist/locales/en/ui.json.d.ts +17 -2
- package/dist/locales/en/ui.json.js +1 -1
- package/dist/locales/index.d.ts +30 -0
- package/dist/locales/pt/ui.json.d.ts +17 -2
- package/dist/locales/pt/ui.json.js +1 -1
- package/package.json +1 -1
|
@@ -2,11 +2,18 @@ import { default as Konva } from 'konva';
|
|
|
2
2
|
import { IAnnotationStyle } from '../annotationStyle';
|
|
3
3
|
import { IStageFit } from '../helpers/annotationCanvas';
|
|
4
4
|
import { IAnnotationDocumentController } from '../hooks/useAnnotationDocument';
|
|
5
|
-
import { IAnnotationPoint, IAnnotationSize, TAnnotationTool } from '../interfaces/annotationDocument';
|
|
5
|
+
import { IAnnotationCropRect, IAnnotationPoint, IAnnotationSize, TAnnotationTool } from '../interfaces/annotationDocument';
|
|
6
6
|
interface IAnnotationStageProps {
|
|
7
7
|
baseImage: HTMLImageElement;
|
|
8
8
|
imageSize: IAnnotationSize;
|
|
9
9
|
fit: IStageFit;
|
|
10
|
+
/** Region of the image the stage shows: the applied crop, or the whole image while cropping. */
|
|
11
|
+
viewRect: IAnnotationCropRect;
|
|
12
|
+
cropDraft: IAnnotationCropRect | null;
|
|
13
|
+
cropAspectRatio: number | null;
|
|
14
|
+
/** Screen-pixel gutter around the image, so crop handles sitting on the image edge stay grabbable. */
|
|
15
|
+
gutter: number;
|
|
16
|
+
onCropDraftChange: (crop: IAnnotationCropRect) => void;
|
|
10
17
|
tool: TAnnotationTool;
|
|
11
18
|
style: IAnnotationStyle;
|
|
12
19
|
controller: IAnnotationDocumentController;
|
|
@@ -4,6 +4,7 @@ import { Stage, Layer, Image } from "react-konva";
|
|
|
4
4
|
import { isDegenerateDrag, shouldAppendFreehandPoint, clampPointToImage } from "../helpers/annotationCanvas.js";
|
|
5
5
|
import { createFreehandElement, createSegmentElement, createShapeElement } from "../helpers/annotationElements.js";
|
|
6
6
|
import { ensureKonvaFullBuild } from "../helpers/konvaFullBuild.js";
|
|
7
|
+
import AnnotationCropFrame from "./AnnotationCropFrame.js";
|
|
7
8
|
import AnnotationElementNode from "./AnnotationElementNode.js";
|
|
8
9
|
import AnnotationSelection from "./AnnotationSelection.js";
|
|
9
10
|
ensureKonvaFullBuild();
|
|
@@ -14,12 +15,18 @@ const cursorForTool = (tool) => {
|
|
|
14
15
|
if (tool === "select") return "default";
|
|
15
16
|
if (tool === "text") return "text";
|
|
16
17
|
if (tool === "eraser") return "cell";
|
|
18
|
+
if (tool === "crop") return "default";
|
|
17
19
|
return "crosshair";
|
|
18
20
|
};
|
|
19
21
|
const AnnotationStage = ({
|
|
20
22
|
baseImage,
|
|
21
23
|
imageSize,
|
|
22
24
|
fit,
|
|
25
|
+
viewRect,
|
|
26
|
+
cropDraft,
|
|
27
|
+
cropAspectRatio,
|
|
28
|
+
gutter,
|
|
29
|
+
onCropDraftChange,
|
|
23
30
|
tool,
|
|
24
31
|
style,
|
|
25
32
|
controller,
|
|
@@ -35,6 +42,8 @@ const AnnotationStage = ({
|
|
|
35
42
|
const [draft, setDraft] = useState(null);
|
|
36
43
|
const [gestureActive, setGestureActive] = useState(false);
|
|
37
44
|
const { select, addElement, updateElement, removeElements } = controller;
|
|
45
|
+
const layerX = gutter / fit.scale - viewRect.x;
|
|
46
|
+
const layerY = gutter / fit.scale - viewRect.y;
|
|
38
47
|
const registerNode = useCallback((id, node) => {
|
|
39
48
|
if (node) nodesRef.current.set(id, node);
|
|
40
49
|
else nodesRef.current.delete(id);
|
|
@@ -43,7 +52,11 @@ const AnnotationStage = ({
|
|
|
43
52
|
const pointFromStage = (stage) => {
|
|
44
53
|
const pointer = stage.getPointerPosition();
|
|
45
54
|
if (!pointer) return null;
|
|
46
|
-
|
|
55
|
+
const point = clampPointToImage(
|
|
56
|
+
{ x: (pointer.x - gutter) / fit.scale, y: (pointer.y - gutter) / fit.scale },
|
|
57
|
+
{ width: viewRect.width, height: viewRect.height }
|
|
58
|
+
);
|
|
59
|
+
return { x: point.x + viewRect.x, y: point.y + viewRect.y };
|
|
47
60
|
};
|
|
48
61
|
const eraseAtPointer = (stage) => {
|
|
49
62
|
const pointer = stage.getPointerPosition();
|
|
@@ -59,6 +72,7 @@ const AnnotationStage = ({
|
|
|
59
72
|
if (event.target === stage) select(null);
|
|
60
73
|
return;
|
|
61
74
|
}
|
|
75
|
+
if (tool === "crop") return;
|
|
62
76
|
if (tool === "eraser") {
|
|
63
77
|
dragOriginRef.current = { x: 0, y: 0 };
|
|
64
78
|
erasedIdsRef.current = /* @__PURE__ */ new Set();
|
|
@@ -153,8 +167,8 @@ const AnnotationStage = ({
|
|
|
153
167
|
Stage,
|
|
154
168
|
{
|
|
155
169
|
ref: stageRef,
|
|
156
|
-
width: fit.width,
|
|
157
|
-
height: fit.height,
|
|
170
|
+
width: fit.width + gutter * 2,
|
|
171
|
+
height: fit.height + gutter * 2,
|
|
158
172
|
scaleX: fit.scale,
|
|
159
173
|
scaleY: fit.scale,
|
|
160
174
|
style: { cursor: cursorForTool(tool) },
|
|
@@ -163,8 +177,8 @@ const AnnotationStage = ({
|
|
|
163
177
|
onPointerUp: handlePointerUp,
|
|
164
178
|
onPointerCancel: handlePointerUp,
|
|
165
179
|
children: [
|
|
166
|
-
/* @__PURE__ */ jsx(Layer, { listening: false, children: /* @__PURE__ */ jsx(Image, { image: baseImage, width: imageSize.width, height: imageSize.height, perfectDrawEnabled: false }) }),
|
|
167
|
-
/* @__PURE__ */ jsxs(Layer, { children: [
|
|
180
|
+
/* @__PURE__ */ jsx(Layer, { listening: false, x: layerX, y: layerY, children: /* @__PURE__ */ jsx(Image, { image: baseImage, width: imageSize.width, height: imageSize.height, perfectDrawEnabled: false }) }),
|
|
181
|
+
/* @__PURE__ */ jsxs(Layer, { x: layerX, y: layerY, children: [
|
|
168
182
|
controller.elements.map((element) => /* @__PURE__ */ jsx(
|
|
169
183
|
AnnotationElementNode,
|
|
170
184
|
{
|
|
@@ -196,6 +210,17 @@ const AnnotationStage = ({
|
|
|
196
210
|
registerNode: () => void 0
|
|
197
211
|
}
|
|
198
212
|
),
|
|
213
|
+
tool === "crop" && cropDraft && /* @__PURE__ */ jsx(
|
|
214
|
+
AnnotationCropFrame,
|
|
215
|
+
{
|
|
216
|
+
crop: cropDraft,
|
|
217
|
+
imageSize,
|
|
218
|
+
viewScale: fit.scale,
|
|
219
|
+
gutter,
|
|
220
|
+
aspectRatio: cropAspectRatio,
|
|
221
|
+
onChange: onCropDraftChange
|
|
222
|
+
}
|
|
223
|
+
),
|
|
199
224
|
tool === "select" && !editingTextId && /* @__PURE__ */ jsx(
|
|
200
225
|
AnnotationSelection,
|
|
201
226
|
{
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AnnotationStage.js","sources":["../../../../../src/components/UI/ImageAnnotator/canvas/AnnotationStage.tsx"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react';\nimport Konva from 'konva';\nimport { Image as KonvaImage, Layer, Stage } from 'react-konva';\n\nimport { IAnnotationStyle } from '../annotationStyle';\nimport { clampPointToImage, isDegenerateDrag, IStageFit, shouldAppendFreehandPoint } from '../helpers/annotationCanvas';\nimport { createFreehandElement, createSegmentElement, createShapeElement } from '../helpers/annotationElements';\nimport { ensureKonvaFullBuild } from '../helpers/konvaFullBuild';\nimport { IAnnotationDocumentController } from '../hooks/useAnnotationDocument';\nimport {\n IAnnotationPoint,\n IAnnotationSize,\n TAnnotationElement,\n TAnnotationTool,\n} from '../interfaces/annotationDocument';\n\nimport AnnotationElementNode from './AnnotationElementNode';\nimport AnnotationSelection from './AnnotationSelection';\n\nensureKonvaFullBuild();\n\nconst DEGENERATE_DRAG_PX = 4;\nconst FREEHAND_MIN_DISTANCE_PX = 2;\n\nconst DRAWING_TOOLS: Array<TAnnotationTool> = ['freehand', 'line', 'arrow', 'rectangle', 'ellipse'];\n\ninterface IAnnotationStageProps {\n baseImage: HTMLImageElement;\n imageSize: IAnnotationSize;\n fit: IStageFit;\n tool: TAnnotationTool;\n style: IAnnotationStyle;\n controller: IAnnotationDocumentController;\n editingTextId: string | null;\n stageRef: React.MutableRefObject<Konva.Stage | null>;\n onRequestTextEdit: (id: string) => void;\n onTextPlaced: (point: IAnnotationPoint) => void;\n}\n\nconst cursorForTool = (tool: TAnnotationTool): string => {\n if (tool === 'select') return 'default';\n if (tool === 'text') return 'text';\n if (tool === 'eraser') return 'cell';\n return 'crosshair';\n};\n\nconst AnnotationStage: React.FC<IAnnotationStageProps> = ({\n baseImage,\n imageSize,\n fit,\n tool,\n style,\n controller,\n editingTextId,\n stageRef,\n onRequestTextEdit,\n onTextPlaced,\n}) => {\n const nodesRef = useRef<Map<string, Konva.Node>>(new Map());\n const dragOriginRef = useRef<IAnnotationPoint | null>(null);\n const erasedIdsRef = useRef<Set<string>>(new Set());\n const finishGestureRef = useRef<() => void>(() => undefined);\n const [draft, setDraft] = useState<TAnnotationElement | null>(null);\n const [gestureActive, setGestureActive] = useState(false);\n\n const { select, addElement, updateElement, removeElements } = controller;\n\n const registerNode = useCallback((id: string, node: Konva.Node | null) => {\n if (node) nodesRef.current.set(id, node);\n else nodesRef.current.delete(id);\n }, []);\n\n const getNode = useCallback((id: string) => nodesRef.current.get(id) ?? null, []);\n\n const pointFromStage = (stage: Konva.Stage): IAnnotationPoint | null => {\n const pointer = stage.getPointerPosition();\n if (!pointer) return null;\n return clampPointToImage({ x: pointer.x / fit.scale, y: pointer.y / fit.scale }, imageSize);\n };\n\n const eraseAtPointer = (stage: Konva.Stage) => {\n const pointer = stage.getPointerPosition();\n if (!pointer) return;\n\n let node: Konva.Node | null = stage.getIntersection(pointer);\n while (node && !node.id()) node = node.getParent();\n if (node?.id()) erasedIdsRef.current.add(node.id());\n };\n\n const handlePointerDown = (event: Konva.KonvaEventObject<PointerEvent>) => {\n const stage = event.target.getStage();\n if (!stage) return;\n\n if (tool === 'select') {\n if (event.target === stage) select(null);\n return;\n }\n\n if (tool === 'eraser') {\n dragOriginRef.current = { x: 0, y: 0 };\n erasedIdsRef.current = new Set();\n setGestureActive(true);\n eraseAtPointer(stage);\n return;\n }\n\n const point = pointFromStage(stage);\n if (!point) return;\n\n if (tool === 'text') {\n onTextPlaced(point);\n return;\n }\n\n if (!DRAWING_TOOLS.includes(tool)) return;\n\n dragOriginRef.current = point;\n setGestureActive(true);\n if (tool === 'freehand') setDraft(createFreehandElement(point, style, imageSize));\n else if (tool === 'line' || tool === 'arrow') setDraft(createSegmentElement(tool, point, point, style, imageSize));\n else if (tool === 'rectangle' || tool === 'ellipse')\n setDraft(createShapeElement(tool, point, point, style, imageSize));\n };\n\n const handlePointerMove = (event: Konva.KonvaEventObject<PointerEvent>) => {\n const stage = event.target.getStage();\n if (!stage) return;\n\n if (tool === 'eraser') {\n if (dragOriginRef.current) eraseAtPointer(stage);\n return;\n }\n\n const origin = dragOriginRef.current;\n if (!origin || !draft) return;\n\n const point = pointFromStage(stage);\n if (!point) return;\n\n setDraft((current) => {\n if (!current) return current;\n\n if (current.type === 'freehand') {\n const next = { x: point.x - current.x, y: point.y - current.y };\n if (!shouldAppendFreehandPoint(current.points, next, FREEHAND_MIN_DISTANCE_PX / fit.scale)) return current;\n return { ...current, points: [...current.points, next.x, next.y] };\n }\n\n if (current.type === 'line' || current.type === 'arrow') return { ...current, end: point };\n\n if (current.type === 'rectangle' || current.type === 'ellipse') {\n return {\n ...current,\n x: Math.min(origin.x, point.x),\n y: Math.min(origin.y, point.y),\n width: Math.abs(point.x - origin.x),\n height: Math.abs(point.y - origin.y),\n };\n }\n\n return current;\n });\n };\n\n const handlePointerUp = () => {\n const origin = dragOriginRef.current;\n dragOriginRef.current = null;\n setGestureActive(false);\n\n if (tool === 'eraser') {\n removeElements([...erasedIdsRef.current]);\n erasedIdsRef.current = new Set();\n return;\n }\n\n const pending = draft;\n setDraft(null);\n if (!pending || !origin) return;\n\n if (pending.type === 'freehand') {\n if (pending.points.length < 4) return;\n addElement(pending, { select: false });\n return;\n }\n\n if (pending.type === 'line' || pending.type === 'arrow') {\n if (isDegenerateDrag(pending.start, pending.end, DEGENERATE_DRAG_PX / fit.scale)) return;\n addElement(pending);\n return;\n }\n\n if (pending.type === 'rectangle' || pending.type === 'ellipse') {\n if (pending.width < DEGENERATE_DRAG_PX / fit.scale || pending.height < DEGENERATE_DRAG_PX / fit.scale) return;\n addElement(pending);\n }\n };\n\n finishGestureRef.current = handlePointerUp;\n\n /** Releasing the pointer outside the canvas must still commit the stroke, not orphan it. */\n useEffect(() => {\n if (!gestureActive) return;\n\n const finish = () => finishGestureRef.current();\n window.addEventListener('pointerup', finish);\n window.addEventListener('pointercancel', finish);\n\n return () => {\n window.removeEventListener('pointerup', finish);\n window.removeEventListener('pointercancel', finish);\n };\n }, [gestureActive]);\n\n return (\n <Stage\n ref={stageRef}\n width={fit.width}\n height={fit.height}\n scaleX={fit.scale}\n scaleY={fit.scale}\n style={{ cursor: cursorForTool(tool) }}\n onPointerDown={handlePointerDown}\n onPointerMove={handlePointerMove}\n onPointerUp={handlePointerUp}\n onPointerCancel={handlePointerUp}\n >\n <Layer listening={false}>\n <KonvaImage image={baseImage} width={imageSize.width} height={imageSize.height} perfectDrawEnabled={false} />\n </Layer>\n\n <Layer>\n {controller.elements.map((element) => (\n <AnnotationElementNode\n key={element.id}\n element={element}\n draggable={tool === 'select'}\n listening={tool === 'select' || tool === 'eraser'}\n viewScale={fit.scale}\n idleCursor={cursorForTool(tool)}\n hidden={element.id === editingTextId}\n onSelect={select}\n onChange={updateElement}\n onRequestTextEdit={onRequestTextEdit}\n registerNode={registerNode}\n />\n ))}\n\n {draft && (\n <AnnotationElementNode\n element={draft}\n draggable={false}\n listening={false}\n viewScale={fit.scale}\n idleCursor={cursorForTool(tool)}\n hidden={false}\n onSelect={select}\n onChange={updateElement}\n onRequestTextEdit={onRequestTextEdit}\n registerNode={() => undefined}\n />\n )}\n\n {tool === 'select' && !editingTextId && (\n <AnnotationSelection\n selectedElement={controller.selectedElement}\n viewScale={fit.scale}\n getNode={getNode}\n onChange={updateElement}\n />\n )}\n </Layer>\n </Stage>\n );\n};\n\nexport default AnnotationStage;\n"],"names":["KonvaImage"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"AnnotationStage.js","sources":["../../../../../src/components/UI/ImageAnnotator/canvas/AnnotationStage.tsx"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react';\nimport Konva from 'konva';\nimport { Image as KonvaImage, Layer, Stage } from 'react-konva';\n\nimport { IAnnotationStyle } from '../annotationStyle';\nimport { clampPointToImage, isDegenerateDrag, IStageFit, shouldAppendFreehandPoint } from '../helpers/annotationCanvas';\nimport { createFreehandElement, createSegmentElement, createShapeElement } from '../helpers/annotationElements';\nimport { ensureKonvaFullBuild } from '../helpers/konvaFullBuild';\nimport { IAnnotationDocumentController } from '../hooks/useAnnotationDocument';\nimport {\n IAnnotationCropRect,\n IAnnotationPoint,\n IAnnotationSize,\n TAnnotationElement,\n TAnnotationTool,\n} from '../interfaces/annotationDocument';\n\nimport AnnotationCropFrame from './AnnotationCropFrame';\nimport AnnotationElementNode from './AnnotationElementNode';\nimport AnnotationSelection from './AnnotationSelection';\n\nensureKonvaFullBuild();\n\nconst DEGENERATE_DRAG_PX = 4;\nconst FREEHAND_MIN_DISTANCE_PX = 2;\n\nconst DRAWING_TOOLS: Array<TAnnotationTool> = ['freehand', 'line', 'arrow', 'rectangle', 'ellipse'];\n\ninterface IAnnotationStageProps {\n baseImage: HTMLImageElement;\n imageSize: IAnnotationSize;\n fit: IStageFit;\n /** Region of the image the stage shows: the applied crop, or the whole image while cropping. */\n viewRect: IAnnotationCropRect;\n cropDraft: IAnnotationCropRect | null;\n cropAspectRatio: number | null;\n /** Screen-pixel gutter around the image, so crop handles sitting on the image edge stay grabbable. */\n gutter: number;\n onCropDraftChange: (crop: IAnnotationCropRect) => void;\n tool: TAnnotationTool;\n style: IAnnotationStyle;\n controller: IAnnotationDocumentController;\n editingTextId: string | null;\n stageRef: React.MutableRefObject<Konva.Stage | null>;\n onRequestTextEdit: (id: string) => void;\n onTextPlaced: (point: IAnnotationPoint) => void;\n}\n\nconst cursorForTool = (tool: TAnnotationTool): string => {\n if (tool === 'select') return 'default';\n if (tool === 'text') return 'text';\n if (tool === 'eraser') return 'cell';\n if (tool === 'crop') return 'default';\n return 'crosshair';\n};\n\nconst AnnotationStage: React.FC<IAnnotationStageProps> = ({\n baseImage,\n imageSize,\n fit,\n viewRect,\n cropDraft,\n cropAspectRatio,\n gutter,\n onCropDraftChange,\n tool,\n style,\n controller,\n editingTextId,\n stageRef,\n onRequestTextEdit,\n onTextPlaced,\n}) => {\n const nodesRef = useRef<Map<string, Konva.Node>>(new Map());\n const dragOriginRef = useRef<IAnnotationPoint | null>(null);\n const erasedIdsRef = useRef<Set<string>>(new Set());\n const finishGestureRef = useRef<() => void>(() => undefined);\n const [draft, setDraft] = useState<TAnnotationElement | null>(null);\n const [gestureActive, setGestureActive] = useState(false);\n\n const { select, addElement, updateElement, removeElements } = controller;\n\n const layerX = gutter / fit.scale - viewRect.x;\n const layerY = gutter / fit.scale - viewRect.y;\n\n const registerNode = useCallback((id: string, node: Konva.Node | null) => {\n if (node) nodesRef.current.set(id, node);\n else nodesRef.current.delete(id);\n }, []);\n\n const getNode = useCallback((id: string) => nodesRef.current.get(id) ?? null, []);\n\n const pointFromStage = (stage: Konva.Stage): IAnnotationPoint | null => {\n const pointer = stage.getPointerPosition();\n if (!pointer) return null;\n const point = clampPointToImage(\n { x: (pointer.x - gutter) / fit.scale, y: (pointer.y - gutter) / fit.scale },\n { width: viewRect.width, height: viewRect.height },\n );\n return { x: point.x + viewRect.x, y: point.y + viewRect.y };\n };\n\n const eraseAtPointer = (stage: Konva.Stage) => {\n const pointer = stage.getPointerPosition();\n if (!pointer) return;\n\n let node: Konva.Node | null = stage.getIntersection(pointer);\n while (node && !node.id()) node = node.getParent();\n if (node?.id()) erasedIdsRef.current.add(node.id());\n };\n\n const handlePointerDown = (event: Konva.KonvaEventObject<PointerEvent>) => {\n const stage = event.target.getStage();\n if (!stage) return;\n\n if (tool === 'select') {\n if (event.target === stage) select(null);\n return;\n }\n\n if (tool === 'crop') return;\n\n if (tool === 'eraser') {\n dragOriginRef.current = { x: 0, y: 0 };\n erasedIdsRef.current = new Set();\n setGestureActive(true);\n eraseAtPointer(stage);\n return;\n }\n\n const point = pointFromStage(stage);\n if (!point) return;\n\n if (tool === 'text') {\n onTextPlaced(point);\n return;\n }\n\n if (!DRAWING_TOOLS.includes(tool)) return;\n\n dragOriginRef.current = point;\n setGestureActive(true);\n if (tool === 'freehand') setDraft(createFreehandElement(point, style, imageSize));\n else if (tool === 'line' || tool === 'arrow') setDraft(createSegmentElement(tool, point, point, style, imageSize));\n else if (tool === 'rectangle' || tool === 'ellipse')\n setDraft(createShapeElement(tool, point, point, style, imageSize));\n };\n\n const handlePointerMove = (event: Konva.KonvaEventObject<PointerEvent>) => {\n const stage = event.target.getStage();\n if (!stage) return;\n\n if (tool === 'eraser') {\n if (dragOriginRef.current) eraseAtPointer(stage);\n return;\n }\n\n const origin = dragOriginRef.current;\n if (!origin || !draft) return;\n\n const point = pointFromStage(stage);\n if (!point) return;\n\n setDraft((current) => {\n if (!current) return current;\n\n if (current.type === 'freehand') {\n const next = { x: point.x - current.x, y: point.y - current.y };\n if (!shouldAppendFreehandPoint(current.points, next, FREEHAND_MIN_DISTANCE_PX / fit.scale)) return current;\n return { ...current, points: [...current.points, next.x, next.y] };\n }\n\n if (current.type === 'line' || current.type === 'arrow') return { ...current, end: point };\n\n if (current.type === 'rectangle' || current.type === 'ellipse') {\n return {\n ...current,\n x: Math.min(origin.x, point.x),\n y: Math.min(origin.y, point.y),\n width: Math.abs(point.x - origin.x),\n height: Math.abs(point.y - origin.y),\n };\n }\n\n return current;\n });\n };\n\n const handlePointerUp = () => {\n const origin = dragOriginRef.current;\n dragOriginRef.current = null;\n setGestureActive(false);\n\n if (tool === 'eraser') {\n removeElements([...erasedIdsRef.current]);\n erasedIdsRef.current = new Set();\n return;\n }\n\n const pending = draft;\n setDraft(null);\n if (!pending || !origin) return;\n\n if (pending.type === 'freehand') {\n if (pending.points.length < 4) return;\n addElement(pending, { select: false });\n return;\n }\n\n if (pending.type === 'line' || pending.type === 'arrow') {\n if (isDegenerateDrag(pending.start, pending.end, DEGENERATE_DRAG_PX / fit.scale)) return;\n addElement(pending);\n return;\n }\n\n if (pending.type === 'rectangle' || pending.type === 'ellipse') {\n if (pending.width < DEGENERATE_DRAG_PX / fit.scale || pending.height < DEGENERATE_DRAG_PX / fit.scale) return;\n addElement(pending);\n }\n };\n\n finishGestureRef.current = handlePointerUp;\n\n /** Releasing the pointer outside the canvas must still commit the stroke, not orphan it. */\n useEffect(() => {\n if (!gestureActive) return;\n\n const finish = () => finishGestureRef.current();\n window.addEventListener('pointerup', finish);\n window.addEventListener('pointercancel', finish);\n\n return () => {\n window.removeEventListener('pointerup', finish);\n window.removeEventListener('pointercancel', finish);\n };\n }, [gestureActive]);\n\n return (\n <Stage\n ref={stageRef}\n width={fit.width + gutter * 2}\n height={fit.height + gutter * 2}\n scaleX={fit.scale}\n scaleY={fit.scale}\n style={{ cursor: cursorForTool(tool) }}\n onPointerDown={handlePointerDown}\n onPointerMove={handlePointerMove}\n onPointerUp={handlePointerUp}\n onPointerCancel={handlePointerUp}\n >\n <Layer listening={false} x={layerX} y={layerY}>\n <KonvaImage image={baseImage} width={imageSize.width} height={imageSize.height} perfectDrawEnabled={false} />\n </Layer>\n\n <Layer x={layerX} y={layerY}>\n {controller.elements.map((element) => (\n <AnnotationElementNode\n key={element.id}\n element={element}\n draggable={tool === 'select'}\n listening={tool === 'select' || tool === 'eraser'}\n viewScale={fit.scale}\n idleCursor={cursorForTool(tool)}\n hidden={element.id === editingTextId}\n onSelect={select}\n onChange={updateElement}\n onRequestTextEdit={onRequestTextEdit}\n registerNode={registerNode}\n />\n ))}\n\n {draft && (\n <AnnotationElementNode\n element={draft}\n draggable={false}\n listening={false}\n viewScale={fit.scale}\n idleCursor={cursorForTool(tool)}\n hidden={false}\n onSelect={select}\n onChange={updateElement}\n onRequestTextEdit={onRequestTextEdit}\n registerNode={() => undefined}\n />\n )}\n\n {tool === 'crop' && cropDraft && (\n <AnnotationCropFrame\n crop={cropDraft}\n imageSize={imageSize}\n viewScale={fit.scale}\n gutter={gutter}\n aspectRatio={cropAspectRatio}\n onChange={onCropDraftChange}\n />\n )}\n\n {tool === 'select' && !editingTextId && (\n <AnnotationSelection\n selectedElement={controller.selectedElement}\n viewScale={fit.scale}\n getNode={getNode}\n onChange={updateElement}\n />\n )}\n </Layer>\n </Stage>\n );\n};\n\nexport default AnnotationStage;\n"],"names":["KonvaImage"],"mappings":"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"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { TAnnotationCropAspect } from '../helpers/annotationCrop';
|
|
2
|
+
interface IAnnotationCropBarProps {
|
|
3
|
+
aspect: TAnnotationCropAspect;
|
|
4
|
+
touch: boolean;
|
|
5
|
+
canReset: boolean;
|
|
6
|
+
onAspectChange: (aspect: TAnnotationCropAspect) => void;
|
|
7
|
+
onReset: () => void;
|
|
8
|
+
onCancel: () => void;
|
|
9
|
+
onApply: () => void;
|
|
10
|
+
}
|
|
11
|
+
declare const AnnotationCropBar: React.FC<IAnnotationCropBarProps>;
|
|
12
|
+
export default AnnotationCropBar;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { jsxs, jsx } from "react/jsx-runtime";
|
|
2
|
+
import classNames from "classnames";
|
|
3
|
+
import { useTranslation } from "react-i18next";
|
|
4
|
+
import ControlledButton from "../../../Form/ControlledButton.js";
|
|
5
|
+
import ControlledViewToggle from "../../../Form/ControlledViewToggle.js";
|
|
6
|
+
import { MaterialSymbol } from "../../MaterialSymbol/index.js";
|
|
7
|
+
import AnnotationToolButton from "./AnnotationToolButton.js";
|
|
8
|
+
const AnnotationCropBar = ({
|
|
9
|
+
aspect,
|
|
10
|
+
touch,
|
|
11
|
+
canReset,
|
|
12
|
+
onAspectChange,
|
|
13
|
+
onReset,
|
|
14
|
+
onCancel,
|
|
15
|
+
onApply
|
|
16
|
+
}) => {
|
|
17
|
+
const { t } = useTranslation("ui");
|
|
18
|
+
const aspectOptions = [
|
|
19
|
+
{ value: "free", label: t("imageAnnotator.crop.aspect.free") },
|
|
20
|
+
{ value: "original", label: t("imageAnnotator.crop.aspect.original") },
|
|
21
|
+
{ value: "square", label: t("imageAnnotator.crop.aspect.square") },
|
|
22
|
+
{ value: "portrait", label: t("imageAnnotator.crop.aspect.portrait") },
|
|
23
|
+
{ value: "classic", label: t("imageAnnotator.crop.aspect.classic") },
|
|
24
|
+
{ value: "wide", label: t("imageAnnotator.crop.aspect.wide") }
|
|
25
|
+
];
|
|
26
|
+
return /* @__PURE__ */ jsxs(
|
|
27
|
+
"div",
|
|
28
|
+
{
|
|
29
|
+
className: classNames(
|
|
30
|
+
"flex shrink-0 items-center gap-x-2 rounded-xl bg-muted-hover px-2 py-1.5",
|
|
31
|
+
touch ? "overflow-x-auto" : "flex-wrap gap-y-2"
|
|
32
|
+
),
|
|
33
|
+
children: [
|
|
34
|
+
/* @__PURE__ */ jsx("span", { className: "shrink-0 px-1 text-xs font-medium text-foreground-muted", children: t("imageAnnotator.crop.aspectLabel") }),
|
|
35
|
+
/* @__PURE__ */ jsx(
|
|
36
|
+
ControlledViewToggle,
|
|
37
|
+
{
|
|
38
|
+
size: "sm",
|
|
39
|
+
syncWithUrl: false,
|
|
40
|
+
options: aspectOptions,
|
|
41
|
+
value: aspect,
|
|
42
|
+
onChange: onAspectChange,
|
|
43
|
+
className: "shrink-0"
|
|
44
|
+
}
|
|
45
|
+
),
|
|
46
|
+
/* @__PURE__ */ jsx(
|
|
47
|
+
AnnotationToolButton,
|
|
48
|
+
{
|
|
49
|
+
icon: "restart_alt",
|
|
50
|
+
label: t("imageAnnotator.crop.reset"),
|
|
51
|
+
touch,
|
|
52
|
+
disabled: !canReset,
|
|
53
|
+
className: touch ? void 0 : "size-8",
|
|
54
|
+
onClick: onReset
|
|
55
|
+
}
|
|
56
|
+
),
|
|
57
|
+
/* @__PURE__ */ jsxs("div", { className: "ml-auto flex shrink-0 items-center gap-x-2", children: [
|
|
58
|
+
/* @__PURE__ */ jsx(
|
|
59
|
+
ControlledButton,
|
|
60
|
+
{
|
|
61
|
+
size: "sm",
|
|
62
|
+
variant: "text",
|
|
63
|
+
tone: "neutral",
|
|
64
|
+
onClick: onCancel,
|
|
65
|
+
className: classNames("shrink-0 normal-case", touch && "h-11"),
|
|
66
|
+
children: t("imageAnnotator.crop.cancel")
|
|
67
|
+
}
|
|
68
|
+
),
|
|
69
|
+
/* @__PURE__ */ jsxs(
|
|
70
|
+
ControlledButton,
|
|
71
|
+
{
|
|
72
|
+
size: "sm",
|
|
73
|
+
onClick: onApply,
|
|
74
|
+
className: classNames("flex shrink-0 items-center gap-x-1.5 normal-case", touch && "h-11"),
|
|
75
|
+
children: [
|
|
76
|
+
/* @__PURE__ */ jsx(MaterialSymbol, { icon: "crop", size: 14, color: "white" }),
|
|
77
|
+
t("imageAnnotator.crop.apply")
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
] })
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
);
|
|
85
|
+
};
|
|
86
|
+
export {
|
|
87
|
+
AnnotationCropBar as default
|
|
88
|
+
};
|
|
89
|
+
//# sourceMappingURL=AnnotationCropBar.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AnnotationCropBar.js","sources":["../../../../../src/components/UI/ImageAnnotator/components/AnnotationCropBar.tsx"],"sourcesContent":["import classNames from 'classnames';\nimport { useTranslation } from 'react-i18next';\n\nimport ControlledButton from '@/components/Form/ControlledButton';\nimport ControlledViewToggle, { IControlledViewToggleOption } from '@/components/Form/ControlledViewToggle';\nimport { MaterialSymbol } from '@/components/UI/MaterialSymbol';\n\nimport { TAnnotationCropAspect } from '../helpers/annotationCrop';\n\nimport AnnotationToolButton from './AnnotationToolButton';\n\ninterface IAnnotationCropBarProps {\n aspect: TAnnotationCropAspect;\n touch: boolean;\n canReset: boolean;\n onAspectChange: (aspect: TAnnotationCropAspect) => void;\n onReset: () => void;\n onCancel: () => void;\n onApply: () => void;\n}\n\nconst AnnotationCropBar: React.FC<IAnnotationCropBarProps> = ({\n aspect,\n touch,\n canReset,\n onAspectChange,\n onReset,\n onCancel,\n onApply,\n}) => {\n const { t } = useTranslation('ui');\n\n const aspectOptions: Array<IControlledViewToggleOption<TAnnotationCropAspect>> = [\n { value: 'free', label: t('imageAnnotator.crop.aspect.free') },\n { value: 'original', label: t('imageAnnotator.crop.aspect.original') },\n { value: 'square', label: t('imageAnnotator.crop.aspect.square') },\n { value: 'portrait', label: t('imageAnnotator.crop.aspect.portrait') },\n { value: 'classic', label: t('imageAnnotator.crop.aspect.classic') },\n { value: 'wide', label: t('imageAnnotator.crop.aspect.wide') },\n ];\n\n return (\n <div\n className={classNames(\n 'flex shrink-0 items-center gap-x-2 rounded-xl bg-muted-hover px-2 py-1.5',\n touch ? 'overflow-x-auto' : 'flex-wrap gap-y-2',\n )}\n >\n <span className=\"shrink-0 px-1 text-xs font-medium text-foreground-muted\">\n {t('imageAnnotator.crop.aspectLabel')}\n </span>\n\n <ControlledViewToggle<TAnnotationCropAspect>\n size=\"sm\"\n syncWithUrl={false}\n options={aspectOptions}\n value={aspect}\n onChange={onAspectChange}\n className=\"shrink-0\"\n />\n\n <AnnotationToolButton\n icon=\"restart_alt\"\n label={t('imageAnnotator.crop.reset')}\n touch={touch}\n disabled={!canReset}\n className={touch ? undefined : 'size-8'}\n onClick={onReset}\n />\n\n <div className=\"ml-auto flex shrink-0 items-center gap-x-2\">\n <ControlledButton\n size=\"sm\"\n variant=\"text\"\n tone=\"neutral\"\n onClick={onCancel}\n className={classNames('shrink-0 normal-case', touch && 'h-11')}\n >\n {t('imageAnnotator.crop.cancel')}\n </ControlledButton>\n <ControlledButton\n size=\"sm\"\n onClick={onApply}\n className={classNames('flex shrink-0 items-center gap-x-1.5 normal-case', touch && 'h-11')}\n >\n <MaterialSymbol icon=\"crop\" size={14} color=\"white\" />\n {t('imageAnnotator.crop.apply')}\n </ControlledButton>\n </div>\n </div>\n );\n};\n\nexport default AnnotationCropBar;\n"],"names":[],"mappings":";;;;;;;AAqBA,MAAM,oBAAuD,CAAC;AAAA,EAC5D;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,MAAM;AACJ,QAAM,EAAE,EAAA,IAAM,eAAe,IAAI;AAEjC,QAAM,gBAA2E;AAAA,IAC/E,EAAE,OAAO,QAAQ,OAAO,EAAE,iCAAiC,EAAA;AAAA,IAC3D,EAAE,OAAO,YAAY,OAAO,EAAE,qCAAqC,EAAA;AAAA,IACnE,EAAE,OAAO,UAAU,OAAO,EAAE,mCAAmC,EAAA;AAAA,IAC/D,EAAE,OAAO,YAAY,OAAO,EAAE,qCAAqC,EAAA;AAAA,IACnE,EAAE,OAAO,WAAW,OAAO,EAAE,oCAAoC,EAAA;AAAA,IACjE,EAAE,OAAO,QAAQ,OAAO,EAAE,iCAAiC,EAAA;AAAA,EAAE;AAG/D,SACE;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,WAAW;AAAA,QACT;AAAA,QACA,QAAQ,oBAAoB;AAAA,MAAA;AAAA,MAG9B,UAAA;AAAA,QAAA,oBAAC,QAAA,EAAK,WAAU,2DACb,UAAA,EAAE,iCAAiC,GACtC;AAAA,QAEA;AAAA,UAAC;AAAA,UAAA;AAAA,YACC,MAAK;AAAA,YACL,aAAa;AAAA,YACb,SAAS;AAAA,YACT,OAAO;AAAA,YACP,UAAU;AAAA,YACV,WAAU;AAAA,UAAA;AAAA,QAAA;AAAA,QAGZ;AAAA,UAAC;AAAA,UAAA;AAAA,YACC,MAAK;AAAA,YACL,OAAO,EAAE,2BAA2B;AAAA,YACpC;AAAA,YACA,UAAU,CAAC;AAAA,YACX,WAAW,QAAQ,SAAY;AAAA,YAC/B,SAAS;AAAA,UAAA;AAAA,QAAA;AAAA,QAGX,qBAAC,OAAA,EAAI,WAAU,8CACb,UAAA;AAAA,UAAA;AAAA,YAAC;AAAA,YAAA;AAAA,cACC,MAAK;AAAA,cACL,SAAQ;AAAA,cACR,MAAK;AAAA,cACL,SAAS;AAAA,cACT,WAAW,WAAW,wBAAwB,SAAS,MAAM;AAAA,cAE5D,YAAE,4BAA4B;AAAA,YAAA;AAAA,UAAA;AAAA,UAEjC;AAAA,YAAC;AAAA,YAAA;AAAA,cACC,MAAK;AAAA,cACL,SAAS;AAAA,cACT,WAAW,WAAW,oDAAoD,SAAS,MAAM;AAAA,cAEzF,UAAA;AAAA,gBAAA,oBAAC,kBAAe,MAAK,QAAO,MAAM,IAAI,OAAM,SAAQ;AAAA,gBACnD,EAAE,2BAA2B;AAAA,cAAA;AAAA,YAAA;AAAA,UAAA;AAAA,QAChC,EAAA,CACF;AAAA,MAAA;AAAA,IAAA;AAAA,EAAA;AAGN;"}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { IAnnotationTextElement } from '../interfaces/annotationDocument';
|
|
1
|
+
import { IAnnotationPoint, IAnnotationTextElement } from '../interfaces/annotationDocument';
|
|
2
2
|
interface IAnnotationTextOverlayProps {
|
|
3
3
|
element: IAnnotationTextElement;
|
|
4
4
|
viewScale: number;
|
|
5
|
+
/** Top-left of the visible image region, so a cropped view still places the editor over the text. */
|
|
6
|
+
viewOrigin: IAnnotationPoint;
|
|
5
7
|
value: string;
|
|
6
8
|
onChange: (value: string) => void;
|
|
7
9
|
onCommit: () => void;
|
|
@@ -6,6 +6,7 @@ import { hexToRgba } from "../helpers/annotationCanvas.js";
|
|
|
6
6
|
const AnnotationTextOverlay = ({
|
|
7
7
|
element,
|
|
8
8
|
viewScale,
|
|
9
|
+
viewOrigin,
|
|
9
10
|
value,
|
|
10
11
|
onChange,
|
|
11
12
|
onCommit,
|
|
@@ -53,8 +54,8 @@ const AnnotationTextOverlay = ({
|
|
|
53
54
|
},
|
|
54
55
|
className: "absolute z-10 resize-none overflow-hidden rounded-lg outline-none ring-2 ring-ring",
|
|
55
56
|
style: {
|
|
56
|
-
left: element.x * viewScale,
|
|
57
|
-
top: element.y * viewScale,
|
|
57
|
+
left: (element.x - viewOrigin.x) * viewScale,
|
|
58
|
+
top: (element.y - viewOrigin.y) * viewScale,
|
|
58
59
|
width: element.width * viewScale,
|
|
59
60
|
transform: `rotate(${element.rotation}deg)`,
|
|
60
61
|
transformOrigin: "top left",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AnnotationTextOverlay.js","sources":["../../../../../src/components/UI/ImageAnnotator/components/AnnotationTextOverlay.tsx"],"sourcesContent":["import { useEffect, useRef } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { ANNOTATION_TEXT_FONT_FAMILY } from '../annotationStyle';\nimport { hexToRgba } from '../helpers/annotationCanvas';\nimport { IAnnotationTextElement } from '../interfaces/annotationDocument';\n\ninterface IAnnotationTextOverlayProps {\n element: IAnnotationTextElement;\n viewScale: number;\n value: string;\n onChange: (value: string) => void;\n onCommit: () => void;\n onCancel: () => void;\n}\n\n/**\n * The editor sits exactly where the element renders and mirrors its typography, so typing and\n * the committed result never disagree.\n */\nconst AnnotationTextOverlay: React.FC<IAnnotationTextOverlayProps> = ({\n element,\n viewScale,\n value,\n onChange,\n onCommit,\n onCancel,\n}) => {\n const { t } = useTranslation('ui');\n const textareaRef = useRef<HTMLTextAreaElement | null>(null);\n\n useEffect(() => {\n const frame = window.requestAnimationFrame(() => {\n const textarea = textareaRef.current;\n if (!textarea) return;\n textarea.focus();\n textarea.setSelectionRange(textarea.value.length, textarea.value.length);\n });\n\n return () => window.cancelAnimationFrame(frame);\n }, [element.id]);\n\n useEffect(() => {\n const textarea = textareaRef.current;\n if (!textarea) return;\n textarea.style.height = 'auto';\n textarea.style.height = `${textarea.scrollHeight}px`;\n }, [value, viewScale, element.fontSize]);\n\n const fontSize = element.fontSize * viewScale;\n const padding = element.padding * viewScale;\n\n return (\n <textarea\n ref={textareaRef}\n value={value}\n aria-label={t('imageAnnotator.tools.text')}\n placeholder={t('imageAnnotator.textPlaceholder')}\n spellCheck={false}\n onChange={(event) => onChange(event.target.value)}\n onBlur={onCommit}\n onKeyDown={(event) => {\n event.stopPropagation();\n if (event.key === 'Escape') {\n event.preventDefault();\n onCancel();\n }\n if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {\n event.preventDefault();\n onCommit();\n }\n }}\n className=\"absolute z-10 resize-none overflow-hidden rounded-lg outline-none ring-2 ring-ring\"\n style={{\n left: element.x * viewScale,\n top: element.y * viewScale,\n width: element.width * viewScale,\n transform: `rotate(${element.rotation}deg)`,\n transformOrigin: 'top left',\n fontFamily: ANNOTATION_TEXT_FONT_FAMILY,\n fontSize,\n lineHeight: 1.25,\n padding,\n textAlign: element.align,\n fontWeight: element.bold ? 700 : 400,\n fontStyle: element.italic ? 'italic' : 'normal',\n color: element.color,\n backgroundColor: element.background ? hexToRgba(element.background, element.backgroundOpacity) : 'transparent',\n borderRadius: element.cornerRadius * viewScale,\n }}\n />\n );\n};\n\nexport default AnnotationTextOverlay;\n"],"names":[],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"AnnotationTextOverlay.js","sources":["../../../../../src/components/UI/ImageAnnotator/components/AnnotationTextOverlay.tsx"],"sourcesContent":["import { useEffect, useRef } from 'react';\nimport { useTranslation } from 'react-i18next';\n\nimport { ANNOTATION_TEXT_FONT_FAMILY } from '../annotationStyle';\nimport { hexToRgba } from '../helpers/annotationCanvas';\nimport { IAnnotationPoint, IAnnotationTextElement } from '../interfaces/annotationDocument';\n\ninterface IAnnotationTextOverlayProps {\n element: IAnnotationTextElement;\n viewScale: number;\n /** Top-left of the visible image region, so a cropped view still places the editor over the text. */\n viewOrigin: IAnnotationPoint;\n value: string;\n onChange: (value: string) => void;\n onCommit: () => void;\n onCancel: () => void;\n}\n\n/**\n * The editor sits exactly where the element renders and mirrors its typography, so typing and\n * the committed result never disagree.\n */\nconst AnnotationTextOverlay: React.FC<IAnnotationTextOverlayProps> = ({\n element,\n viewScale,\n viewOrigin,\n value,\n onChange,\n onCommit,\n onCancel,\n}) => {\n const { t } = useTranslation('ui');\n const textareaRef = useRef<HTMLTextAreaElement | null>(null);\n\n useEffect(() => {\n const frame = window.requestAnimationFrame(() => {\n const textarea = textareaRef.current;\n if (!textarea) return;\n textarea.focus();\n textarea.setSelectionRange(textarea.value.length, textarea.value.length);\n });\n\n return () => window.cancelAnimationFrame(frame);\n }, [element.id]);\n\n useEffect(() => {\n const textarea = textareaRef.current;\n if (!textarea) return;\n textarea.style.height = 'auto';\n textarea.style.height = `${textarea.scrollHeight}px`;\n }, [value, viewScale, element.fontSize]);\n\n const fontSize = element.fontSize * viewScale;\n const padding = element.padding * viewScale;\n\n return (\n <textarea\n ref={textareaRef}\n value={value}\n aria-label={t('imageAnnotator.tools.text')}\n placeholder={t('imageAnnotator.textPlaceholder')}\n spellCheck={false}\n onChange={(event) => onChange(event.target.value)}\n onBlur={onCommit}\n onKeyDown={(event) => {\n event.stopPropagation();\n if (event.key === 'Escape') {\n event.preventDefault();\n onCancel();\n }\n if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {\n event.preventDefault();\n onCommit();\n }\n }}\n className=\"absolute z-10 resize-none overflow-hidden rounded-lg outline-none ring-2 ring-ring\"\n style={{\n left: (element.x - viewOrigin.x) * viewScale,\n top: (element.y - viewOrigin.y) * viewScale,\n width: element.width * viewScale,\n transform: `rotate(${element.rotation}deg)`,\n transformOrigin: 'top left',\n fontFamily: ANNOTATION_TEXT_FONT_FAMILY,\n fontSize,\n lineHeight: 1.25,\n padding,\n textAlign: element.align,\n fontWeight: element.bold ? 700 : 400,\n fontStyle: element.italic ? 'italic' : 'normal',\n color: element.color,\n backgroundColor: element.background ? hexToRgba(element.background, element.backgroundOpacity) : 'transparent',\n borderRadius: element.cornerRadius * viewScale,\n }}\n />\n );\n};\n\nexport default AnnotationTextOverlay;\n"],"names":[],"mappings":";;;;;AAsBA,MAAM,wBAA+D,CAAC;AAAA,EACpE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,MAAM;AACJ,QAAM,EAAE,EAAA,IAAM,eAAe,IAAI;AACjC,QAAM,cAAc,OAAmC,IAAI;AAE3D,YAAU,MAAM;AACd,UAAM,QAAQ,OAAO,sBAAsB,MAAM;AAC/C,YAAM,WAAW,YAAY;AAC7B,UAAI,CAAC,SAAU;AACf,eAAS,MAAA;AACT,eAAS,kBAAkB,SAAS,MAAM,QAAQ,SAAS,MAAM,MAAM;AAAA,IACzE,CAAC;AAED,WAAO,MAAM,OAAO,qBAAqB,KAAK;AAAA,EAChD,GAAG,CAAC,QAAQ,EAAE,CAAC;AAEf,YAAU,MAAM;AACd,UAAM,WAAW,YAAY;AAC7B,QAAI,CAAC,SAAU;AACf,aAAS,MAAM,SAAS;AACxB,aAAS,MAAM,SAAS,GAAG,SAAS,YAAY;AAAA,EAClD,GAAG,CAAC,OAAO,WAAW,QAAQ,QAAQ,CAAC;AAEvC,QAAM,WAAW,QAAQ,WAAW;AACpC,QAAM,UAAU,QAAQ,UAAU;AAElC,SACE;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,KAAK;AAAA,MACL;AAAA,MACA,cAAY,EAAE,2BAA2B;AAAA,MACzC,aAAa,EAAE,gCAAgC;AAAA,MAC/C,YAAY;AAAA,MACZ,UAAU,CAAC,UAAU,SAAS,MAAM,OAAO,KAAK;AAAA,MAChD,QAAQ;AAAA,MACR,WAAW,CAAC,UAAU;AACpB,cAAM,gBAAA;AACN,YAAI,MAAM,QAAQ,UAAU;AAC1B,gBAAM,eAAA;AACN,mBAAA;AAAA,QACF;AACA,YAAI,MAAM,QAAQ,YAAY,MAAM,WAAW,MAAM,UAAU;AAC7D,gBAAM,eAAA;AACN,mBAAA;AAAA,QACF;AAAA,MACF;AAAA,MACA,WAAU;AAAA,MACV,OAAO;AAAA,QACL,OAAO,QAAQ,IAAI,WAAW,KAAK;AAAA,QACnC,MAAM,QAAQ,IAAI,WAAW,KAAK;AAAA,QAClC,OAAO,QAAQ,QAAQ;AAAA,QACvB,WAAW,UAAU,QAAQ,QAAQ;AAAA,QACrC,iBAAiB;AAAA,QACjB,YAAY;AAAA,QACZ;AAAA,QACA,YAAY;AAAA,QACZ;AAAA,QACA,WAAW,QAAQ;AAAA,QACnB,YAAY,QAAQ,OAAO,MAAM;AAAA,QACjC,WAAW,QAAQ,SAAS,WAAW;AAAA,QACvC,OAAO,QAAQ;AAAA,QACf,iBAAiB,QAAQ,aAAa,UAAU,QAAQ,YAAY,QAAQ,iBAAiB,IAAI;AAAA,QACjG,cAAc,QAAQ,eAAe;AAAA,MAAA;AAAA,IACvC;AAAA,EAAA;AAGN;"}
|
|
@@ -53,6 +53,17 @@ const AnnotationToolbar = ({
|
|
|
53
53
|
showLabel: touch,
|
|
54
54
|
onClick: () => fileInputRef.current?.click()
|
|
55
55
|
}
|
|
56
|
+
),
|
|
57
|
+
/* @__PURE__ */ jsx(
|
|
58
|
+
AnnotationToolButton,
|
|
59
|
+
{
|
|
60
|
+
icon: "crop",
|
|
61
|
+
label: t("imageAnnotator.tools.crop"),
|
|
62
|
+
active: tool === "crop",
|
|
63
|
+
touch,
|
|
64
|
+
showLabel: touch,
|
|
65
|
+
onClick: () => onToolChange("crop")
|
|
66
|
+
}
|
|
56
67
|
)
|
|
57
68
|
]
|
|
58
69
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AnnotationToolbar.js","sources":["../../../../../src/components/UI/ImageAnnotator/components/AnnotationToolbar.tsx"],"sourcesContent":["import { useRef } from 'react';\nimport classNames from 'classnames';\nimport { useTranslation } from 'react-i18next';\n\nimport { SymbolCodepoints } from '@/components/UI/MaterialSymbol';\n\nimport { TAnnotationTool } from '../interfaces/annotationDocument';\n\nimport AnnotationToolButton from './AnnotationToolButton';\n\nconst TOOL_OPTIONS: Array<{ tool: TAnnotationTool; icon: SymbolCodepoints; labelKey: string }> = [\n { tool: 'select', icon: 'arrow_selector_tool', labelKey: 'imageAnnotator.tools.select' },\n { tool: 'freehand', icon: 'draw', labelKey: 'imageAnnotator.tools.pen' },\n { tool: 'eraser', icon: 'ink_eraser', labelKey: 'imageAnnotator.tools.eraser' },\n { tool: 'arrow', icon: 'north_east', labelKey: 'imageAnnotator.tools.arrow' },\n { tool: 'line', icon: 'horizontal_rule', labelKey: 'imageAnnotator.tools.line' },\n { tool: 'rectangle', icon: 'rectangle', labelKey: 'imageAnnotator.tools.rectangle' },\n { tool: 'ellipse', icon: 'circle', labelKey: 'imageAnnotator.tools.ellipse' },\n { tool: 'text', icon: 'title', labelKey: 'imageAnnotator.tools.text' },\n];\n\nconst ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp,image/gif';\n\ninterface IAnnotationToolbarProps {\n tool: TAnnotationTool;\n touch: boolean;\n actions?: React.ReactNode;\n onToolChange: (tool: TAnnotationTool) => void;\n onInsertImage: (file: File) => void;\n}\n\nconst AnnotationToolbar: React.FC<IAnnotationToolbarProps> = ({\n tool,\n touch,\n actions,\n onToolChange,\n onInsertImage,\n}) => {\n const { t } = useTranslation('ui');\n const fileInputRef = useRef<HTMLInputElement | null>(null);\n\n return (\n <div className={classNames('flex shrink-0 items-center gap-x-2', !touch && 'flex-wrap gap-y-2')}>\n <div\n className={classNames(\n 'flex items-center gap-x-0.5 rounded-xl bg-muted-hover p-1',\n touch && 'min-w-0 flex-1 overflow-x-auto',\n )}\n >\n {TOOL_OPTIONS.map(({ tool: option, icon, labelKey }) => (\n <AnnotationToolButton\n key={option}\n icon={icon}\n label={t(labelKey)}\n active={tool === option}\n touch={touch}\n showLabel={touch}\n onClick={() => onToolChange(option)}\n />\n ))}\n\n <AnnotationToolButton\n icon=\"add_photo_alternate\"\n label={t('imageAnnotator.tools.image')}\n touch={touch}\n showLabel={touch}\n onClick={() => fileInputRef.current?.click()}\n />\n </div>\n\n <input\n ref={fileInputRef}\n type=\"file\"\n accept={ACCEPTED_IMAGE_TYPES}\n className=\"hidden\"\n onChange={(event) => {\n const file = event.target.files?.[0];\n event.target.value = '';\n if (file) onInsertImage(file);\n }}\n />\n\n {actions && <div className=\"ml-auto flex items-center\">{actions}</div>}\n </div>\n );\n};\n\nexport default AnnotationToolbar;\n"],"names":[],"mappings":";;;;;AAUA,MAAM,eAA2F;AAAA,EAC/F,EAAE,MAAM,UAAU,MAAM,uBAAuB,UAAU,8BAAA;AAAA,EACzD,EAAE,MAAM,YAAY,MAAM,QAAQ,UAAU,2BAAA;AAAA,EAC5C,EAAE,MAAM,UAAU,MAAM,cAAc,UAAU,8BAAA;AAAA,EAChD,EAAE,MAAM,SAAS,MAAM,cAAc,UAAU,6BAAA;AAAA,EAC/C,EAAE,MAAM,QAAQ,MAAM,mBAAmB,UAAU,4BAAA;AAAA,EACnD,EAAE,MAAM,aAAa,MAAM,aAAa,UAAU,iCAAA;AAAA,EAClD,EAAE,MAAM,WAAW,MAAM,UAAU,UAAU,+BAAA;AAAA,EAC7C,EAAE,MAAM,QAAQ,MAAM,SAAS,UAAU,4BAAA;AAC3C;AAEA,MAAM,uBAAuB;AAU7B,MAAM,oBAAuD,CAAC;AAAA,EAC5D;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,MAAM;AACJ,QAAM,EAAE,EAAA,IAAM,eAAe,IAAI;AACjC,QAAM,eAAe,OAAgC,IAAI;AAEzD,SACE,qBAAC,SAAI,WAAW,WAAW,sCAAsC,CAAC,SAAS,mBAAmB,GAC5F,UAAA;AAAA,IAAA;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,WAAW;AAAA,UACT;AAAA,UACA,SAAS;AAAA,QAAA;AAAA,QAGV,UAAA;AAAA,UAAA,aAAa,IAAI,CAAC,EAAE,MAAM,QAAQ,MAAM,eACvC;AAAA,YAAC;AAAA,YAAA;AAAA,cAEC;AAAA,cACA,OAAO,EAAE,QAAQ;AAAA,cACjB,QAAQ,SAAS;AAAA,cACjB;AAAA,cACA,WAAW;AAAA,cACX,SAAS,MAAM,aAAa,MAAM;AAAA,YAAA;AAAA,YAN7B;AAAA,UAAA,CAQR;AAAA,UAED;AAAA,YAAC;AAAA,YAAA;AAAA,cACC,MAAK;AAAA,cACL,OAAO,EAAE,4BAA4B;AAAA,cACrC;AAAA,cACA,WAAW;AAAA,cACX,SAAS,MAAM,aAAa,SAAS,MAAA;AAAA,YAAM;AAAA,UAAA;AAAA,
|
|
1
|
+
{"version":3,"file":"AnnotationToolbar.js","sources":["../../../../../src/components/UI/ImageAnnotator/components/AnnotationToolbar.tsx"],"sourcesContent":["import { useRef } from 'react';\nimport classNames from 'classnames';\nimport { useTranslation } from 'react-i18next';\n\nimport { SymbolCodepoints } from '@/components/UI/MaterialSymbol';\n\nimport { TAnnotationTool } from '../interfaces/annotationDocument';\n\nimport AnnotationToolButton from './AnnotationToolButton';\n\nconst TOOL_OPTIONS: Array<{ tool: TAnnotationTool; icon: SymbolCodepoints; labelKey: string }> = [\n { tool: 'select', icon: 'arrow_selector_tool', labelKey: 'imageAnnotator.tools.select' },\n { tool: 'freehand', icon: 'draw', labelKey: 'imageAnnotator.tools.pen' },\n { tool: 'eraser', icon: 'ink_eraser', labelKey: 'imageAnnotator.tools.eraser' },\n { tool: 'arrow', icon: 'north_east', labelKey: 'imageAnnotator.tools.arrow' },\n { tool: 'line', icon: 'horizontal_rule', labelKey: 'imageAnnotator.tools.line' },\n { tool: 'rectangle', icon: 'rectangle', labelKey: 'imageAnnotator.tools.rectangle' },\n { tool: 'ellipse', icon: 'circle', labelKey: 'imageAnnotator.tools.ellipse' },\n { tool: 'text', icon: 'title', labelKey: 'imageAnnotator.tools.text' },\n];\n\nconst ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp,image/gif';\n\ninterface IAnnotationToolbarProps {\n tool: TAnnotationTool;\n touch: boolean;\n actions?: React.ReactNode;\n onToolChange: (tool: TAnnotationTool) => void;\n onInsertImage: (file: File) => void;\n}\n\nconst AnnotationToolbar: React.FC<IAnnotationToolbarProps> = ({\n tool,\n touch,\n actions,\n onToolChange,\n onInsertImage,\n}) => {\n const { t } = useTranslation('ui');\n const fileInputRef = useRef<HTMLInputElement | null>(null);\n\n return (\n <div className={classNames('flex shrink-0 items-center gap-x-2', !touch && 'flex-wrap gap-y-2')}>\n <div\n className={classNames(\n 'flex items-center gap-x-0.5 rounded-xl bg-muted-hover p-1',\n touch && 'min-w-0 flex-1 overflow-x-auto',\n )}\n >\n {TOOL_OPTIONS.map(({ tool: option, icon, labelKey }) => (\n <AnnotationToolButton\n key={option}\n icon={icon}\n label={t(labelKey)}\n active={tool === option}\n touch={touch}\n showLabel={touch}\n onClick={() => onToolChange(option)}\n />\n ))}\n\n <AnnotationToolButton\n icon=\"add_photo_alternate\"\n label={t('imageAnnotator.tools.image')}\n touch={touch}\n showLabel={touch}\n onClick={() => fileInputRef.current?.click()}\n />\n\n <AnnotationToolButton\n icon=\"crop\"\n label={t('imageAnnotator.tools.crop')}\n active={tool === 'crop'}\n touch={touch}\n showLabel={touch}\n onClick={() => onToolChange('crop')}\n />\n </div>\n\n <input\n ref={fileInputRef}\n type=\"file\"\n accept={ACCEPTED_IMAGE_TYPES}\n className=\"hidden\"\n onChange={(event) => {\n const file = event.target.files?.[0];\n event.target.value = '';\n if (file) onInsertImage(file);\n }}\n />\n\n {actions && <div className=\"ml-auto flex items-center\">{actions}</div>}\n </div>\n );\n};\n\nexport default AnnotationToolbar;\n"],"names":[],"mappings":";;;;;AAUA,MAAM,eAA2F;AAAA,EAC/F,EAAE,MAAM,UAAU,MAAM,uBAAuB,UAAU,8BAAA;AAAA,EACzD,EAAE,MAAM,YAAY,MAAM,QAAQ,UAAU,2BAAA;AAAA,EAC5C,EAAE,MAAM,UAAU,MAAM,cAAc,UAAU,8BAAA;AAAA,EAChD,EAAE,MAAM,SAAS,MAAM,cAAc,UAAU,6BAAA;AAAA,EAC/C,EAAE,MAAM,QAAQ,MAAM,mBAAmB,UAAU,4BAAA;AAAA,EACnD,EAAE,MAAM,aAAa,MAAM,aAAa,UAAU,iCAAA;AAAA,EAClD,EAAE,MAAM,WAAW,MAAM,UAAU,UAAU,+BAAA;AAAA,EAC7C,EAAE,MAAM,QAAQ,MAAM,SAAS,UAAU,4BAAA;AAC3C;AAEA,MAAM,uBAAuB;AAU7B,MAAM,oBAAuD,CAAC;AAAA,EAC5D;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,MAAM;AACJ,QAAM,EAAE,EAAA,IAAM,eAAe,IAAI;AACjC,QAAM,eAAe,OAAgC,IAAI;AAEzD,SACE,qBAAC,SAAI,WAAW,WAAW,sCAAsC,CAAC,SAAS,mBAAmB,GAC5F,UAAA;AAAA,IAAA;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,WAAW;AAAA,UACT;AAAA,UACA,SAAS;AAAA,QAAA;AAAA,QAGV,UAAA;AAAA,UAAA,aAAa,IAAI,CAAC,EAAE,MAAM,QAAQ,MAAM,eACvC;AAAA,YAAC;AAAA,YAAA;AAAA,cAEC;AAAA,cACA,OAAO,EAAE,QAAQ;AAAA,cACjB,QAAQ,SAAS;AAAA,cACjB;AAAA,cACA,WAAW;AAAA,cACX,SAAS,MAAM,aAAa,MAAM;AAAA,YAAA;AAAA,YAN7B;AAAA,UAAA,CAQR;AAAA,UAED;AAAA,YAAC;AAAA,YAAA;AAAA,cACC,MAAK;AAAA,cACL,OAAO,EAAE,4BAA4B;AAAA,cACrC;AAAA,cACA,WAAW;AAAA,cACX,SAAS,MAAM,aAAa,SAAS,MAAA;AAAA,YAAM;AAAA,UAAA;AAAA,UAG7C;AAAA,YAAC;AAAA,YAAA;AAAA,cACC,MAAK;AAAA,cACL,OAAO,EAAE,2BAA2B;AAAA,cACpC,QAAQ,SAAS;AAAA,cACjB;AAAA,cACA,WAAW;AAAA,cACX,SAAS,MAAM,aAAa,MAAM;AAAA,YAAA;AAAA,UAAA;AAAA,QACpC;AAAA,MAAA;AAAA,IAAA;AAAA,IAGF;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,KAAK;AAAA,QACL,MAAK;AAAA,QACL,QAAQ;AAAA,QACR,WAAU;AAAA,QACV,UAAU,CAAC,UAAU;AACnB,gBAAM,OAAO,MAAM,OAAO,QAAQ,CAAC;AACnC,gBAAM,OAAO,QAAQ;AACrB,cAAI,oBAAoB,IAAI;AAAA,QAC9B;AAAA,MAAA;AAAA,IAAA;AAAA,IAGD,WAAW,oBAAC,OAAA,EAAI,WAAU,6BAA6B,UAAA,QAAA,CAAQ;AAAA,EAAA,GAClE;AAEJ;"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { IAnnotationCropRect, IAnnotationSize } from '../interfaces/annotationDocument';
|
|
2
|
+
export type TAnnotationCropAspect = 'free' | 'original' | 'square' | 'portrait' | 'classic' | 'wide';
|
|
3
|
+
export declare const ANNOTATION_CROP_ASPECTS: Array<TAnnotationCropAspect>;
|
|
4
|
+
export declare const fullImageCrop: (size: IAnnotationSize) => IAnnotationCropRect;
|
|
5
|
+
export declare const minCropDimension: (size: IAnnotationSize) => number;
|
|
6
|
+
export declare const aspectRatioFor: (aspect: TAnnotationCropAspect, size: IAnnotationSize) => number | null;
|
|
7
|
+
export declare const clampCropToImage: (crop: IAnnotationCropRect, size: IAnnotationSize) => IAnnotationCropRect;
|
|
8
|
+
/** Largest rect with the requested ratio, kept on the current crop's center so the framing does not jump. */
|
|
9
|
+
export declare const fitCropToAspect: (crop: IAnnotationCropRect, ratio: number, size: IAnnotationSize) => IAnnotationCropRect;
|
|
10
|
+
export declare const isFullImageCrop: (crop: IAnnotationCropRect | null, size: IAnnotationSize) => boolean;
|
|
11
|
+
/** A saved document is untrusted input: a crop that does not fit the image is dropped, never applied. */
|
|
12
|
+
export declare const sanitizeInitialCrop: (crop: IAnnotationCropRect | null | undefined, size: IAnnotationSize) => IAnnotationCropRect | null;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const ANNOTATION_CROP_ASPECTS = [
|
|
2
|
+
"free",
|
|
3
|
+
"original",
|
|
4
|
+
"square",
|
|
5
|
+
"portrait",
|
|
6
|
+
"classic",
|
|
7
|
+
"wide"
|
|
8
|
+
];
|
|
9
|
+
const FIXED_ASPECT_RATIOS = {
|
|
10
|
+
square: 1,
|
|
11
|
+
portrait: 4 / 5,
|
|
12
|
+
classic: 3 / 4,
|
|
13
|
+
wide: 16 / 9
|
|
14
|
+
};
|
|
15
|
+
const MIN_CROP_FRACTION = 0.05;
|
|
16
|
+
const fullImageCrop = (size) => ({
|
|
17
|
+
x: 0,
|
|
18
|
+
y: 0,
|
|
19
|
+
width: size.width,
|
|
20
|
+
height: size.height
|
|
21
|
+
});
|
|
22
|
+
const minCropDimension = (size) => Math.max(16, Math.round(Math.min(size.width, size.height) * MIN_CROP_FRACTION));
|
|
23
|
+
const aspectRatioFor = (aspect, size) => {
|
|
24
|
+
if (aspect === "free") return null;
|
|
25
|
+
if (aspect === "original") return size.width / size.height;
|
|
26
|
+
return FIXED_ASPECT_RATIOS[aspect];
|
|
27
|
+
};
|
|
28
|
+
const clampCropToImage = (crop, size) => {
|
|
29
|
+
const minimum = minCropDimension(size);
|
|
30
|
+
const width = Math.min(Math.max(crop.width, minimum), size.width);
|
|
31
|
+
const height = Math.min(Math.max(crop.height, minimum), size.height);
|
|
32
|
+
return {
|
|
33
|
+
x: Math.round(Math.min(Math.max(crop.x, 0), size.width - width)),
|
|
34
|
+
y: Math.round(Math.min(Math.max(crop.y, 0), size.height - height)),
|
|
35
|
+
width: Math.round(width),
|
|
36
|
+
height: Math.round(height)
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
const fitCropToAspect = (crop, ratio, size) => {
|
|
40
|
+
const width = Math.min(size.width, size.height * ratio);
|
|
41
|
+
const height = width / ratio;
|
|
42
|
+
const centerX = crop.x + crop.width / 2;
|
|
43
|
+
const centerY = crop.y + crop.height / 2;
|
|
44
|
+
return clampCropToImage({ x: centerX - width / 2, y: centerY - height / 2, width, height }, size);
|
|
45
|
+
};
|
|
46
|
+
const isFullImageCrop = (crop, size) => !crop || crop.x <= 0 && crop.y <= 0 && crop.width >= size.width && crop.height >= size.height;
|
|
47
|
+
const sanitizeInitialCrop = (crop, size) => {
|
|
48
|
+
if (!crop) return null;
|
|
49
|
+
const values = [crop.x, crop.y, crop.width, crop.height];
|
|
50
|
+
if (values.some((value) => typeof value !== "number" || !Number.isFinite(value))) return null;
|
|
51
|
+
const clamped = clampCropToImage(crop, size);
|
|
52
|
+
return isFullImageCrop(clamped, size) ? null : clamped;
|
|
53
|
+
};
|
|
54
|
+
export {
|
|
55
|
+
ANNOTATION_CROP_ASPECTS,
|
|
56
|
+
aspectRatioFor,
|
|
57
|
+
clampCropToImage,
|
|
58
|
+
fitCropToAspect,
|
|
59
|
+
fullImageCrop,
|
|
60
|
+
isFullImageCrop,
|
|
61
|
+
minCropDimension,
|
|
62
|
+
sanitizeInitialCrop
|
|
63
|
+
};
|
|
64
|
+
//# sourceMappingURL=annotationCrop.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"annotationCrop.js","sources":["../../../../../src/components/UI/ImageAnnotator/helpers/annotationCrop.ts"],"sourcesContent":["import { IAnnotationCropRect, IAnnotationSize } from '../interfaces/annotationDocument';\n\nexport type TAnnotationCropAspect = 'free' | 'original' | 'square' | 'portrait' | 'classic' | 'wide';\n\nexport const ANNOTATION_CROP_ASPECTS: Array<TAnnotationCropAspect> = [\n 'free',\n 'original',\n 'square',\n 'portrait',\n 'classic',\n 'wide',\n];\n\nconst FIXED_ASPECT_RATIOS: Record<Exclude<TAnnotationCropAspect, 'free' | 'original'>, number> = {\n square: 1,\n portrait: 4 / 5,\n classic: 3 / 4,\n wide: 16 / 9,\n};\n\n/** Below this the crop handles overlap and the result is a smudge nobody meant to keep. */\nconst MIN_CROP_FRACTION = 0.05;\n\nexport const fullImageCrop = (size: IAnnotationSize): IAnnotationCropRect => ({\n x: 0,\n y: 0,\n width: size.width,\n height: size.height,\n});\n\nexport const minCropDimension = (size: IAnnotationSize): number =>\n Math.max(16, Math.round(Math.min(size.width, size.height) * MIN_CROP_FRACTION));\n\nexport const aspectRatioFor = (aspect: TAnnotationCropAspect, size: IAnnotationSize): number | null => {\n if (aspect === 'free') return null;\n if (aspect === 'original') return size.width / size.height;\n return FIXED_ASPECT_RATIOS[aspect];\n};\n\nexport const clampCropToImage = (crop: IAnnotationCropRect, size: IAnnotationSize): IAnnotationCropRect => {\n const minimum = minCropDimension(size);\n const width = Math.min(Math.max(crop.width, minimum), size.width);\n const height = Math.min(Math.max(crop.height, minimum), size.height);\n\n return {\n x: Math.round(Math.min(Math.max(crop.x, 0), size.width - width)),\n y: Math.round(Math.min(Math.max(crop.y, 0), size.height - height)),\n width: Math.round(width),\n height: Math.round(height),\n };\n};\n\n/** Largest rect with the requested ratio, kept on the current crop's center so the framing does not jump. */\nexport const fitCropToAspect = (\n crop: IAnnotationCropRect,\n ratio: number,\n size: IAnnotationSize,\n): IAnnotationCropRect => {\n const width = Math.min(size.width, size.height * ratio);\n const height = width / ratio;\n const centerX = crop.x + crop.width / 2;\n const centerY = crop.y + crop.height / 2;\n\n return clampCropToImage({ x: centerX - width / 2, y: centerY - height / 2, width, height }, size);\n};\n\nexport const isFullImageCrop = (crop: IAnnotationCropRect | null, size: IAnnotationSize): boolean =>\n !crop || (crop.x <= 0 && crop.y <= 0 && crop.width >= size.width && crop.height >= size.height);\n\n/** A saved document is untrusted input: a crop that does not fit the image is dropped, never applied. */\nexport const sanitizeInitialCrop = (\n crop: IAnnotationCropRect | null | undefined,\n size: IAnnotationSize,\n): IAnnotationCropRect | null => {\n if (!crop) return null;\n const values = [crop.x, crop.y, crop.width, crop.height];\n if (values.some((value) => typeof value !== 'number' || !Number.isFinite(value))) return null;\n\n const clamped = clampCropToImage(crop, size);\n return isFullImageCrop(clamped, size) ? null : clamped;\n};\n"],"names":[],"mappings":"AAIO,MAAM,0BAAwD;AAAA,EACnE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAEA,MAAM,sBAA2F;AAAA,EAC/F,QAAQ;AAAA,EACR,UAAU,IAAI;AAAA,EACd,SAAS,IAAI;AAAA,EACb,MAAM,KAAK;AACb;AAGA,MAAM,oBAAoB;AAEnB,MAAM,gBAAgB,CAAC,UAAgD;AAAA,EAC5E,GAAG;AAAA,EACH,GAAG;AAAA,EACH,OAAO,KAAK;AAAA,EACZ,QAAQ,KAAK;AACf;AAEO,MAAM,mBAAmB,CAAC,SAC/B,KAAK,IAAI,IAAI,KAAK,MAAM,KAAK,IAAI,KAAK,OAAO,KAAK,MAAM,IAAI,iBAAiB,CAAC;AAEzE,MAAM,iBAAiB,CAAC,QAA+B,SAAyC;AACrG,MAAI,WAAW,OAAQ,QAAO;AAC9B,MAAI,WAAW,WAAY,QAAO,KAAK,QAAQ,KAAK;AACpD,SAAO,oBAAoB,MAAM;AACnC;AAEO,MAAM,mBAAmB,CAAC,MAA2B,SAA+C;AACzG,QAAM,UAAU,iBAAiB,IAAI;AACrC,QAAM,QAAQ,KAAK,IAAI,KAAK,IAAI,KAAK,OAAO,OAAO,GAAG,KAAK,KAAK;AAChE,QAAM,SAAS,KAAK,IAAI,KAAK,IAAI,KAAK,QAAQ,OAAO,GAAG,KAAK,MAAM;AAEnE,SAAO;AAAA,IACL,GAAG,KAAK,MAAM,KAAK,IAAI,KAAK,IAAI,KAAK,GAAG,CAAC,GAAG,KAAK,QAAQ,KAAK,CAAC;AAAA,IAC/D,GAAG,KAAK,MAAM,KAAK,IAAI,KAAK,IAAI,KAAK,GAAG,CAAC,GAAG,KAAK,SAAS,MAAM,CAAC;AAAA,IACjE,OAAO,KAAK,MAAM,KAAK;AAAA,IACvB,QAAQ,KAAK,MAAM,MAAM;AAAA,EAAA;AAE7B;AAGO,MAAM,kBAAkB,CAC7B,MACA,OACA,SACwB;AACxB,QAAM,QAAQ,KAAK,IAAI,KAAK,OAAO,KAAK,SAAS,KAAK;AACtD,QAAM,SAAS,QAAQ;AACvB,QAAM,UAAU,KAAK,IAAI,KAAK,QAAQ;AACtC,QAAM,UAAU,KAAK,IAAI,KAAK,SAAS;AAEvC,SAAO,iBAAiB,EAAE,GAAG,UAAU,QAAQ,GAAG,GAAG,UAAU,SAAS,GAAG,OAAO,OAAA,GAAU,IAAI;AAClG;AAEO,MAAM,kBAAkB,CAAC,MAAkC,SAChE,CAAC,QAAS,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,SAAS,KAAK,SAAS,KAAK,UAAU,KAAK;AAGnF,MAAM,sBAAsB,CACjC,MACA,SAC+B;AAC/B,MAAI,CAAC,KAAM,QAAO;AAClB,QAAM,SAAS,CAAC,KAAK,GAAG,KAAK,GAAG,KAAK,OAAO,KAAK,MAAM;AACvD,MAAI,OAAO,KAAK,CAAC,UAAU,OAAO,UAAU,YAAY,CAAC,OAAO,SAAS,KAAK,CAAC,EAAG,QAAO;AAEzF,QAAM,UAAU,iBAAiB,MAAM,IAAI;AAC3C,SAAO,gBAAgB,SAAS,IAAI,IAAI,OAAO;AACjD;"}
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import { IAnnotationDocument, IAnnotationSize, TAnnotationElement } from '../interfaces/annotationDocument';
|
|
1
|
+
import { IAnnotationCropRect, IAnnotationDocument, IAnnotationSize, TAnnotationElement } from '../interfaces/annotationDocument';
|
|
2
2
|
type TElementList = Array<TAnnotationElement>;
|
|
3
3
|
export interface IAnnotationDocumentController {
|
|
4
4
|
elements: TElementList;
|
|
5
|
+
crop: IAnnotationCropRect | null;
|
|
5
6
|
document: IAnnotationDocument;
|
|
6
7
|
selectedId: string | null;
|
|
7
8
|
selectedElement: TAnnotationElement | null;
|
|
8
9
|
canUndo: boolean;
|
|
9
10
|
canRedo: boolean;
|
|
10
11
|
isEmpty: boolean;
|
|
12
|
+
setCrop: (crop: IAnnotationCropRect | null) => void;
|
|
11
13
|
select: (id: string | null) => void;
|
|
12
14
|
addElement: (element: TAnnotationElement, options?: {
|
|
13
15
|
select?: boolean;
|
|
@@ -23,5 +25,5 @@ export interface IAnnotationDocumentController {
|
|
|
23
25
|
redo: () => void;
|
|
24
26
|
clear: () => void;
|
|
25
27
|
}
|
|
26
|
-
export declare const useAnnotationDocument: (size: IAnnotationSize, initialElements?: TElementList) => IAnnotationDocumentController;
|
|
28
|
+
export declare const useAnnotationDocument: (size: IAnnotationSize, initialElements?: TElementList, initialCrop?: IAnnotationCropRect | null) => IAnnotationDocumentController;
|
|
27
29
|
export {};
|
|
@@ -5,10 +5,12 @@ const HISTORY_LIMIT = 50;
|
|
|
5
5
|
const historyReducer = (state, action) => {
|
|
6
6
|
switch (action.type) {
|
|
7
7
|
case "commit":
|
|
8
|
-
if (action.elements === state.present
|
|
8
|
+
if (action.snapshot.elements === state.present.elements && action.snapshot.crop === state.present.crop) {
|
|
9
|
+
return state;
|
|
10
|
+
}
|
|
9
11
|
return {
|
|
10
12
|
past: [...state.past, state.present].slice(-HISTORY_LIMIT),
|
|
11
|
-
present: action.
|
|
13
|
+
present: action.snapshot,
|
|
12
14
|
future: []
|
|
13
15
|
};
|
|
14
16
|
case "undo": {
|
|
@@ -30,26 +32,33 @@ const historyReducer = (state, action) => {
|
|
|
30
32
|
};
|
|
31
33
|
}
|
|
32
34
|
case "reset":
|
|
33
|
-
return { past: [], present: action.
|
|
35
|
+
return { past: [], present: action.snapshot, future: [] };
|
|
34
36
|
default:
|
|
35
37
|
return state;
|
|
36
38
|
}
|
|
37
39
|
};
|
|
38
|
-
const useAnnotationDocument = (size, initialElements = []) => {
|
|
40
|
+
const useAnnotationDocument = (size, initialElements = [], initialCrop = null) => {
|
|
39
41
|
const [history, dispatch] = useReducer(historyReducer, {
|
|
40
42
|
past: [],
|
|
41
|
-
present: initialElements,
|
|
43
|
+
present: { elements: initialElements, crop: initialCrop },
|
|
42
44
|
future: []
|
|
43
45
|
});
|
|
44
46
|
const [selectedId, setSelectedId] = useState(null);
|
|
45
|
-
const elements = history.present;
|
|
46
|
-
const commit = useCallback(
|
|
47
|
+
const { elements, crop } = history.present;
|
|
48
|
+
const commit = useCallback(
|
|
49
|
+
(next) => dispatch({ type: "commit", snapshot: { elements: next, crop } }),
|
|
50
|
+
[crop]
|
|
51
|
+
);
|
|
52
|
+
const setCrop = useCallback(
|
|
53
|
+
(next) => dispatch({ type: "commit", snapshot: { elements, crop: next } }),
|
|
54
|
+
[elements]
|
|
55
|
+
);
|
|
47
56
|
const addElement = useCallback(
|
|
48
57
|
(element, options) => {
|
|
49
|
-
|
|
58
|
+
commit([...elements, element]);
|
|
50
59
|
if (options?.select !== false) setSelectedId(element.id);
|
|
51
60
|
},
|
|
52
|
-
[elements]
|
|
61
|
+
[elements, commit]
|
|
53
62
|
);
|
|
54
63
|
const updateElement = useCallback(
|
|
55
64
|
(id, updater) => {
|
|
@@ -118,17 +127,19 @@ const useAnnotationDocument = (size, initialElements = []) => {
|
|
|
118
127
|
[selectedId, updateElement]
|
|
119
128
|
);
|
|
120
129
|
const document = useMemo(
|
|
121
|
-
() => ({ version: ANNOTATION_DOCUMENT_VERSION, width: size.width, height: size.height, elements }),
|
|
122
|
-
[size.width, size.height, elements]
|
|
130
|
+
() => ({ version: ANNOTATION_DOCUMENT_VERSION, width: size.width, height: size.height, elements, crop }),
|
|
131
|
+
[size.width, size.height, elements, crop]
|
|
123
132
|
);
|
|
124
133
|
return {
|
|
125
134
|
elements,
|
|
135
|
+
crop,
|
|
126
136
|
document,
|
|
127
137
|
selectedId,
|
|
128
138
|
selectedElement,
|
|
129
139
|
canUndo: history.past.length > 0,
|
|
130
140
|
canRedo: history.future.length > 0,
|
|
131
|
-
isEmpty: elements.length === 0,
|
|
141
|
+
isEmpty: elements.length === 0 && !crop,
|
|
142
|
+
setCrop,
|
|
132
143
|
select: setSelectedId,
|
|
133
144
|
addElement,
|
|
134
145
|
updateElement,
|