@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.
Files changed (41) hide show
  1. package/dist/components/Form/ControlledSelect.js +7 -1
  2. package/dist/components/Form/ControlledSelect.js.map +1 -1
  3. package/dist/components/Form/ControlledTabs.js +1 -1
  4. package/dist/components/Form/ControlledViewToggle.js +1 -1
  5. package/dist/components/Navigation/UiNavigationContext.js +1 -1
  6. package/dist/components/UI/ImageAnnotator/AnnotationEditor.d.ts +2 -1
  7. package/dist/components/UI/ImageAnnotator/AnnotationEditor.js +138 -43
  8. package/dist/components/UI/ImageAnnotator/AnnotationEditor.js.map +1 -1
  9. package/dist/components/UI/ImageAnnotator/ImageAnnotationDialog.d.ts +3 -1
  10. package/dist/components/UI/ImageAnnotator/ImageAnnotationDialog.js +3 -0
  11. package/dist/components/UI/ImageAnnotator/ImageAnnotationDialog.js.map +1 -1
  12. package/dist/components/UI/ImageAnnotator/canvas/AnnotationCropFrame.d.ts +16 -0
  13. package/dist/components/UI/ImageAnnotator/canvas/AnnotationCropFrame.js +156 -0
  14. package/dist/components/UI/ImageAnnotator/canvas/AnnotationCropFrame.js.map +1 -0
  15. package/dist/components/UI/ImageAnnotator/canvas/AnnotationStage.d.ts +8 -1
  16. package/dist/components/UI/ImageAnnotator/canvas/AnnotationStage.js +30 -5
  17. package/dist/components/UI/ImageAnnotator/canvas/AnnotationStage.js.map +1 -1
  18. package/dist/components/UI/ImageAnnotator/components/AnnotationCropBar.d.ts +12 -0
  19. package/dist/components/UI/ImageAnnotator/components/AnnotationCropBar.js +89 -0
  20. package/dist/components/UI/ImageAnnotator/components/AnnotationCropBar.js.map +1 -0
  21. package/dist/components/UI/ImageAnnotator/components/AnnotationTextOverlay.d.ts +3 -1
  22. package/dist/components/UI/ImageAnnotator/components/AnnotationTextOverlay.js +3 -2
  23. package/dist/components/UI/ImageAnnotator/components/AnnotationTextOverlay.js.map +1 -1
  24. package/dist/components/UI/ImageAnnotator/components/AnnotationToolbar.js +11 -0
  25. package/dist/components/UI/ImageAnnotator/components/AnnotationToolbar.js.map +1 -1
  26. package/dist/components/UI/ImageAnnotator/helpers/annotationCrop.d.ts +12 -0
  27. package/dist/components/UI/ImageAnnotator/helpers/annotationCrop.js +64 -0
  28. package/dist/components/UI/ImageAnnotator/helpers/annotationCrop.js.map +1 -0
  29. package/dist/components/UI/ImageAnnotator/hooks/useAnnotationDocument.d.ts +4 -2
  30. package/dist/components/UI/ImageAnnotator/hooks/useAnnotationDocument.js +23 -12
  31. package/dist/components/UI/ImageAnnotator/hooks/useAnnotationDocument.js.map +1 -1
  32. package/dist/components/UI/ImageAnnotator/hooks/useAnnotationShortcuts.js +2 -1
  33. package/dist/components/UI/ImageAnnotator/hooks/useAnnotationShortcuts.js.map +1 -1
  34. package/dist/components/UI/ImageAnnotator/interfaces/annotationDocument.d.ts +10 -1
  35. package/dist/components/UI/ImageAnnotator/interfaces/annotationDocument.js.map +1 -1
  36. package/dist/locales/en/ui.json.d.ts +17 -2
  37. package/dist/locales/en/ui.json.js +1 -1
  38. package/dist/locales/index.d.ts +30 -0
  39. package/dist/locales/pt/ui.json.d.ts +17 -2
  40. package/dist/locales/pt/ui.json.js +1 -1
  41. 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
- return clampPointToImage({ x: pointer.x / fit.scale, y: pointer.y / fit.scale }, imageSize);
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":";;;;;AAoBA,MAAM,wBAA+D,CAAC;AAAA,EACpE;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,MAAM,QAAQ,IAAI;AAAA,QAClB,KAAK,QAAQ,IAAI;AAAA,QACjB,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;"}
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,QAC7C;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;"}
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) return state;
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.elements,
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.elements, future: [] };
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((next) => dispatch({ type: "commit", elements: next }), []);
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
- dispatch({ type: "commit", elements: [...elements, element] });
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,