@lotics/ui 3.3.0 → 3.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -19,6 +19,8 @@
19
19
  "./file_preview_types": "./src/file_preview_types.ts",
20
20
  "./file_gallery_modal": "./src/file_gallery_modal.tsx",
21
21
  "./image_gallery": "./src/image_gallery.tsx",
22
+ "./rotatable_image": "./src/rotatable_image.tsx",
23
+ "./use_image_rotation": "./src/use_image_rotation.ts",
22
24
  "./file_thumbnail_grid": "./src/file_thumbnail_grid.tsx",
23
25
  "./pagination": "./src/pagination.tsx",
24
26
  "./bar_chart": "./src/bar_chart.tsx",
package/src/alert.tsx CHANGED
@@ -135,14 +135,20 @@ class Alert {
135
135
  }, []);
136
136
 
137
137
  useEffect(() => {
138
+ // Close on KEYUP + capture + stopImmediatePropagation — the same event
139
+ // RN-web Modal closes on, run first and stopped — so an alert opened over
140
+ // a drawer/dialog closes only the alert, not the modal behind it. Closing
141
+ // on keydown would unmount the alert before the keyup and let RN's keyup
142
+ // close the modal underneath too. (Matches the Popover's ESC handling.)
138
143
  const handleEscape = (e: KeyboardEvent) => {
139
144
  if (e.key === "Escape" && alertOptions.cancelable !== false) {
145
+ e.stopImmediatePropagation();
140
146
  handleDismiss();
141
147
  }
142
148
  };
143
149
 
144
- document.addEventListener("keydown", handleEscape);
145
- return () => document.removeEventListener("keydown", handleEscape);
150
+ document.addEventListener("keyup", handleEscape, true);
151
+ return () => document.removeEventListener("keyup", handleEscape, true);
146
152
  }, [handleDismiss]);
147
153
 
148
154
  if (!visible) {
@@ -1,21 +1,36 @@
1
+ import type { ReactNode } from "react";
1
2
  import { View, StyleSheet } from "react-native";
2
3
  import { Text } from "./text";
4
+ import { Icon, type IconName } from "./icon";
5
+ import { colors } from "./colors";
3
6
 
4
7
  interface EmptyStateProps {
5
8
  /** What's empty, in plain language ("Không có nhân viên nào khớp"). */
6
9
  message: string;
7
10
  /** What the user can do about it ("Thử từ khóa khác"). */
8
11
  hint?: string;
12
+ /** Optional Lucide glyph centered above the message — a visual anchor so the
13
+ * empty region reads as a deliberate state, not two lone lines of muted text. */
14
+ icon?: IconName;
15
+ /** Optional call-to-action below the text (e.g. a Button) for an actionable
16
+ * empty state ("no records yet → create one"). */
17
+ action?: ReactNode;
9
18
  }
10
19
 
11
20
  /**
12
21
  * Centered placeholder for an empty list/filter result. Generous vertical
13
22
  * space on purpose — an empty region that collapses to nothing reads as a
14
- * rendering bug, not a state.
23
+ * rendering bug, not a state. Pass `icon` for a visual anchor and `action`
24
+ * for a CTA when the empty state is actionable.
15
25
  */
16
26
  export function EmptyState(props: EmptyStateProps) {
17
27
  return (
18
28
  <View style={styles.container}>
29
+ {props.icon ? (
30
+ <View style={styles.icon}>
31
+ <Icon name={props.icon} size={28} color={colors.zinc[400]} />
32
+ </View>
33
+ ) : null}
19
34
  <Text size="sm" color="muted">
20
35
  {props.message}
21
36
  </Text>
@@ -24,6 +39,7 @@ export function EmptyState(props: EmptyStateProps) {
24
39
  {props.hint}
25
40
  </Text>
26
41
  ) : null}
42
+ {props.action ? <View style={styles.action}>{props.action}</View> : null}
27
43
  </View>
28
44
  );
29
45
  }
@@ -34,4 +50,10 @@ const styles = StyleSheet.create({
34
50
  alignItems: "center",
35
51
  gap: 4,
36
52
  },
53
+ icon: {
54
+ marginBottom: 4,
55
+ },
56
+ action: {
57
+ marginTop: 8,
58
+ },
37
59
  });
@@ -16,6 +16,8 @@ import { Icon } from "./icon";
16
16
  import { colors } from "./colors";
17
17
  import type { DisplayFile } from "./file_thumbnail";
18
18
  import { FilePreview } from "./file_preview";
19
+ import { isImageMimeType } from "./mime";
20
+ import { useImageRotation, type ImageRotation } from "./use_image_rotation";
19
21
  import type { PreviewLabels } from "./file_preview_types";
20
22
 
21
23
  export interface FileGalleryModalProps {
@@ -32,6 +34,14 @@ export interface FileGalleryModalProps {
32
34
  labels?: Partial<PreviewLabels>;
33
35
  /** Reported when a file fails to render (host wires to its logger). */
34
36
  onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
37
+ /**
38
+ * Optional shared rotation state (from `useImageRotation`) — pass it to keep an
39
+ * inline gallery and this modal in sync. Omitted ⇒ the modal keeps its own
40
+ * in-memory rotation. View-only; image files only.
41
+ */
42
+ rotation?: ImageRotation;
43
+ /** Show 90° rotate controls for image files. Default true. */
44
+ rotatable?: boolean;
35
45
  }
36
46
 
37
47
  /**
@@ -39,9 +49,12 @@ export interface FileGalleryModalProps {
39
49
  * via FilePreview; unknown types show a download placeholder.
40
50
  */
41
51
  export function FileGalleryModal(props: FileGalleryModalProps) {
42
- const { files, activeIndex, onIndexChange, captionHint, labels, onError } = props;
52
+ const { files, activeIndex, onIndexChange, captionHint, labels, onError, rotation, rotatable = true } = props;
43
53
  const visible = activeIndex !== null;
44
54
  const overlayRef = useRef<View | null>(null);
55
+ // Own the rotation when the host doesn't share one (standalone consumers).
56
+ const internalRotation = useImageRotation();
57
+ const rot = rotation ?? internalRotation;
45
58
 
46
59
  const close = useCallback(() => onIndexChange(null), [onIndexChange]);
47
60
  const next = useCallback(() => {
@@ -53,22 +66,35 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
53
66
  onIndexChange(Math.max(activeIndex - 1, 0));
54
67
  }, [activeIndex, onIndexChange]);
55
68
 
56
- // Keyboard navigation (web only — RN native handles via Modal accessibility).
69
+ // Arrow nav (web only — RN native handles via Modal accessibility). Capture +
70
+ // stopImmediatePropagation so ←/→ drive THIS gallery and never also reach a
71
+ // record-nav arrow handler on a drawer behind it.
72
+ //
73
+ // Escape is deliberately NOT handled here — it closes via the Modal's
74
+ // `onRequestClose`, which RN-web fires on KEYUP and scopes to the topmost
75
+ // modal (its `activeModalStack`). Closing here on KEYDOWN would unmount this
76
+ // modal first, so by the keyup RN's active modal is the drawer underneath and
77
+ // it closes that too — the "ESC closes the preview AND the record" bug.
57
78
  useEffect(() => {
58
79
  if (!visible || typeof window === "undefined") return;
59
80
  function onKey(e: KeyboardEvent) {
60
- if (e.key === "Escape") close();
61
- else if (e.key === "ArrowRight") next();
62
- else if (e.key === "ArrowLeft") prev();
81
+ if (e.key === "ArrowRight") {
82
+ e.stopImmediatePropagation();
83
+ next();
84
+ } else if (e.key === "ArrowLeft") {
85
+ e.stopImmediatePropagation();
86
+ prev();
87
+ }
63
88
  }
64
- window.addEventListener("keydown", onKey);
65
- return () => window.removeEventListener("keydown", onKey);
66
- }, [visible, close, next, prev]);
89
+ window.addEventListener("keydown", onKey, true);
90
+ return () => window.removeEventListener("keydown", onKey, true);
91
+ }, [visible, next, prev]);
67
92
 
68
93
  if (!visible || activeIndex === null) return null;
69
94
  const file = files[activeIndex];
70
95
  if (!file) return null;
71
96
 
97
+ const isImage = isImageMimeType(file.mimeType);
72
98
  const total = files.length;
73
99
  const caption = captionHint
74
100
  ? `${file.filename} · ${activeIndex + 1} / ${total} · ${captionHint}`
@@ -85,9 +111,30 @@ export function FileGalleryModal(props: FileGalleryModalProps) {
85
111
  />
86
112
 
87
113
  <View style={styles.previewPanel}>
88
- <FilePreview file={file} labels={labels} onError={onError} />
114
+ <FilePreview file={file} labels={labels} onError={onError} rotation={rot.rotationFor(file.id)} />
89
115
  </View>
90
116
 
117
+ {isImage && rotatable ? (
118
+ <View style={styles.controls}>
119
+ <Pressable
120
+ onPress={() => rot.rotate(file.id, -1)}
121
+ accessibilityRole="button"
122
+ accessibilityLabel="Rotate left"
123
+ style={styles.controlButton}
124
+ >
125
+ <Icon name="rotate-ccw" size={20} color={colors.white} />
126
+ </Pressable>
127
+ <Pressable
128
+ onPress={() => rot.rotate(file.id, 1)}
129
+ accessibilityRole="button"
130
+ accessibilityLabel="Rotate right"
131
+ style={styles.controlButton}
132
+ >
133
+ <Icon name="rotate-cw" size={20} color={colors.white} />
134
+ </Pressable>
135
+ </View>
136
+ ) : null}
137
+
91
138
  {activeIndex > 0 ? (
92
139
  <Pressable
93
140
  onPress={prev}
@@ -151,6 +198,21 @@ const styles = StyleSheet.create({
151
198
  navRight: {
152
199
  right: 16,
153
200
  },
201
+ controls: {
202
+ position: "absolute",
203
+ top: 16,
204
+ right: 16,
205
+ flexDirection: "row",
206
+ gap: 8,
207
+ },
208
+ controlButton: {
209
+ width: 44,
210
+ height: 44,
211
+ borderRadius: 22,
212
+ backgroundColor: "rgba(0, 0, 0, 0.4)",
213
+ justifyContent: "center",
214
+ alignItems: "center",
215
+ },
154
216
  captionWrap: {
155
217
  position: "absolute",
156
218
  bottom: 24,
@@ -1,7 +1,8 @@
1
- import { View, Image, StyleSheet } from "react-native";
1
+ import { View, StyleSheet } from "react-native";
2
2
  import { Text } from "./text";
3
3
  import { DocumentCard } from "./file_thumbnail";
4
4
  import { isImageMimeType } from "./mime";
5
+ import { RotatableImage } from "./rotatable_image";
5
6
  import { type FilePreviewProps, resolveLabels } from "./file_preview_types";
6
7
 
7
8
  /**
@@ -9,17 +10,10 @@ import { type FilePreviewProps, resolveLabels } from "./file_preview_types";
9
10
  * document placeholder. Rich document rendering (docx/xlsx/pdf) is web-only —
10
11
  * the `.web.tsx` variant carries it; native consumers download instead.
11
12
  */
12
- export function FilePreview({ file, labels }: FilePreviewProps) {
13
+ export function FilePreview({ file, labels, rotation }: FilePreviewProps) {
13
14
  const l = resolveLabels(labels);
14
15
  if (isImageMimeType(file.mimeType)) {
15
- return (
16
- <Image
17
- source={{ uri: file.url }}
18
- resizeMode="contain"
19
- style={styles.image}
20
- accessibilityIgnoresInvertColors
21
- />
22
- );
16
+ return <RotatableImage uri={file.url} alt={file.filename} rotation={rotation} />;
23
17
  }
24
18
  return (
25
19
  <View style={styles.placeholder}>
@@ -32,7 +26,6 @@ export function FilePreview({ file, labels }: FilePreviewProps) {
32
26
  }
33
27
 
34
28
  const styles = StyleSheet.create({
35
- image: { flex: 1, width: "100%" },
36
29
  placeholder: { flex: 1, justifyContent: "center", alignItems: "center", gap: 16 },
37
30
  placeholderText: { marginTop: 8 },
38
31
  });
@@ -13,6 +13,7 @@ import {
13
13
  } from "./mime";
14
14
  import { type FilePreviewProps, resolveLabels, type PreviewLabels } from "./file_preview_types";
15
15
  import { SpreadsheetView } from "./spreadsheet_view";
16
+ import { RotatableImage } from "./rotatable_image";
16
17
  import { downloadFileFromUrl } from "./download";
17
18
 
18
19
  /**
@@ -24,11 +25,18 @@ import { downloadFileFromUrl } from "./download";
24
25
  * Pure: i18n via `labels` props, errors via `onError` — no lingui/analytics
25
26
  * (purity test). Lifted from the frontend gallery so both share one renderer.
26
27
  */
27
- export function FilePreview({ file, labels, onError }: FilePreviewProps) {
28
+ export function FilePreview({ file, labels, onError, rotation }: FilePreviewProps) {
28
29
  const l = resolveLabels(labels);
29
30
 
30
31
  if (isImageMimeType(file.mimeType)) {
31
- return <img alt={file.filename} src={file.url} style={imageStyle} />;
32
+ return (
33
+ <RotatableImage
34
+ uri={file.url}
35
+ alt={file.filename}
36
+ rotation={rotation}
37
+ backgroundColor="rgba(10, 10, 10, 0.8)"
38
+ />
39
+ );
32
40
  }
33
41
  if (isPdfMimeType(file.mimeType)) {
34
42
  return <iframe src={file.url} title={file.filename} style={fullFrameStyle} />;
@@ -156,12 +164,6 @@ const styles = StyleSheet.create({
156
164
  placeholderText: { marginTop: 8 },
157
165
  });
158
166
 
159
- const imageStyle: React.CSSProperties = {
160
- width: "100%",
161
- height: "100%",
162
- objectFit: "contain",
163
- backgroundColor: "rgba(10, 10, 10, 0.8)",
164
- };
165
167
  const fullFrameStyle: React.CSSProperties = { width: "100%", height: "100%", border: "none" };
166
168
  const mediaElementStyle: React.CSSProperties = {
167
169
  width: "100%",
@@ -28,6 +28,8 @@ export interface FilePreviewProps {
28
28
  * package stays analytics-free), so the host wires this to its logger.
29
29
  */
30
30
  onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
31
+ /** View rotation in degrees (0/90/180/270) — applied to image previews only. */
32
+ rotation?: number;
31
33
  }
32
34
 
33
35
  export function resolveLabels(labels: Partial<PreviewLabels> | undefined): PreviewLabels {
package/src/icon.tsx CHANGED
@@ -153,6 +153,7 @@ import RectangleEllipsis from "lucide-react-native/dist/esm/icons/rectangle-elli
153
153
  import Redo from "lucide-react-native/dist/esm/icons/redo";
154
154
  import RefreshCw from "lucide-react-native/dist/esm/icons/refresh-cw";
155
155
  import Repeat from "lucide-react-native/dist/esm/icons/repeat";
156
+ import RotateCcw from "lucide-react-native/dist/esm/icons/rotate-ccw";
156
157
  import RotateCw from "lucide-react-native/dist/esm/icons/rotate-cw";
157
158
  import Scan from "lucide-react-native/dist/esm/icons/scan";
158
159
  import Search from "lucide-react-native/dist/esm/icons/search";
@@ -344,6 +345,7 @@ const iconComponents = {
344
345
  redo: Redo,
345
346
  "refresh-cw": RefreshCw,
346
347
  repeat: Repeat,
348
+ "rotate-ccw": RotateCcw,
347
349
  "rotate-cw": RotateCw,
348
350
  scan: Scan,
349
351
  search: Search,
@@ -8,15 +8,27 @@
8
8
  // relying on percentage/flex-basis resolution (which collapses inside an
9
9
  // auto-width parent).
10
10
  import { useState } from "react";
11
- import { View, Image, Pressable } from "react-native";
11
+ import { View, Pressable, type ViewStyle } from "react-native";
12
12
  import { Text } from "./text";
13
+ import { Icon } from "./icon";
13
14
  import { colors } from "./colors";
14
15
  import { ActivityIndicator } from "./activity_indicator";
15
16
  import { FileThumbnail, type DisplayFile } from "./file_thumbnail";
16
17
  import { FileGalleryModal } from "./file_gallery_modal";
18
+ import { RotatableImage } from "./rotatable_image";
19
+ import { useImageRotation } from "./use_image_rotation";
17
20
 
18
21
  const GAP = 12;
19
22
 
23
+ const rotateButtonStyle: ViewStyle = {
24
+ width: 32,
25
+ height: 32,
26
+ borderRadius: 16,
27
+ backgroundColor: "rgba(0, 0, 0, 0.45)",
28
+ alignItems: "center",
29
+ justifyContent: "center",
30
+ };
31
+
20
32
  export interface ImageGalleryProps {
21
33
  images: DisplayFile[];
22
34
  loading?: boolean;
@@ -34,6 +46,8 @@ export interface ImageGalleryProps {
34
46
  mainAspectRatio?: number;
35
47
  /** Main image's fraction of the width in "right" mode (0–1). Default 0.6. */
36
48
  mainFraction?: number;
49
+ /** Show 90° rotate controls on images — inline and in the zoom modal. Default true. */
50
+ rotatable?: boolean;
37
51
  }
38
52
 
39
53
  export function ImageGallery({
@@ -45,10 +59,13 @@ export function ImageGallery({
45
59
  thumbnailPosition = "bottom",
46
60
  mainAspectRatio = 4 / 3,
47
61
  mainFraction = 0.6,
62
+ rotatable = true,
48
63
  }: ImageGalleryProps) {
49
64
  const [selected, setSelected] = useState(0);
50
65
  const [zoomIdx, setZoomIdx] = useState<number | null>(null);
51
66
  const [containerWidth, setContainerWidth] = useState(0);
67
+ // Per-image rotation, shared with the zoom modal so a turn survives opening it.
68
+ const rotation = useImageRotation();
52
69
 
53
70
  if (loading) return <ActivityIndicator />;
54
71
  if (images.length === 0) return <Text size="sm" color="muted">{emptyText}</Text>;
@@ -59,23 +76,47 @@ export function ImageGallery({
59
76
  const width = maxWidth ? Math.min(containerWidth, maxWidth) : containerWidth;
60
77
 
61
78
  const mainImage = (
62
- <Pressable
63
- onPress={() => setZoomIdx(idx)}
64
- accessibilityRole="button"
65
- accessibilityLabel="Zoom image"
66
- style={{
67
- width: "100%",
68
- aspectRatio: mainAspectRatio,
69
- borderRadius: 8,
70
- borderWidth: 1,
71
- borderColor: colors.border,
72
- backgroundColor: colors.zinc["100"],
73
- overflow: "hidden",
74
- cursor: "pointer",
75
- }}
76
- >
77
- <Image source={{ uri: main.url }} resizeMode="contain" style={{ width: "100%", height: "100%" }} />
78
- </Pressable>
79
+ // The rotate controls sit OUTSIDE the zoom Pressable (an absolute sibling on
80
+ // top) so pressing them turns the image instead of opening the modal.
81
+ <View style={{ width: "100%" }}>
82
+ <Pressable
83
+ onPress={() => setZoomIdx(idx)}
84
+ accessibilityRole="button"
85
+ accessibilityLabel="Zoom image"
86
+ style={{
87
+ width: "100%",
88
+ aspectRatio: mainAspectRatio,
89
+ borderRadius: 8,
90
+ borderWidth: 1,
91
+ borderColor: colors.border,
92
+ backgroundColor: colors.zinc["100"],
93
+ overflow: "hidden",
94
+ cursor: "pointer",
95
+ }}
96
+ >
97
+ <RotatableImage uri={main.url} alt={main.filename} rotation={rotation.rotationFor(main.id)} />
98
+ </Pressable>
99
+ {rotatable ? (
100
+ <View style={{ position: "absolute", top: 8, right: 8, flexDirection: "row", gap: 6 }}>
101
+ <Pressable
102
+ onPress={() => rotation.rotate(main.id, -1)}
103
+ accessibilityRole="button"
104
+ accessibilityLabel="Rotate left"
105
+ style={rotateButtonStyle}
106
+ >
107
+ <Icon name="rotate-ccw" size={16} color={colors.white} />
108
+ </Pressable>
109
+ <Pressable
110
+ onPress={() => rotation.rotate(main.id, 1)}
111
+ accessibilityRole="button"
112
+ accessibilityLabel="Rotate right"
113
+ style={rotateButtonStyle}
114
+ >
115
+ <Icon name="rotate-cw" size={16} color={colors.white} />
116
+ </Pressable>
117
+ </View>
118
+ ) : null}
119
+ </View>
79
120
  );
80
121
 
81
122
  const thumbnails = images.length > 1 ? (
@@ -117,6 +158,8 @@ export function ImageGallery({
117
158
  activeIndex={zoomIdx}
118
159
  onIndexChange={setZoomIdx}
119
160
  captionHint={captionHint}
161
+ rotation={rotation}
162
+ rotatable={rotatable}
120
163
  />
121
164
  </View>
122
165
  );
@@ -0,0 +1,56 @@
1
+ import { useState } from "react";
2
+ import { View, Image, StyleSheet, type StyleProp, type ViewStyle } from "react-native";
3
+
4
+ export interface RotatableImageProps {
5
+ uri: string;
6
+ /** Accessible name for the image. */
7
+ alt?: string;
8
+ /** Degrees clockwise — normalized to 0/90/180/270. */
9
+ rotation?: number;
10
+ /** Fill behind the contained image. */
11
+ backgroundColor?: string;
12
+ style?: StyleProp<ViewStyle>;
13
+ }
14
+
15
+ /**
16
+ * An image that rotates in 90° steps and REFITS: at a quarter turn the image box
17
+ * is laid out in the container's SWAPPED dimensions, then rotated into place — so
18
+ * a rotated landscape photo fills the frame instead of shrinking into a corner.
19
+ * `resizeMode="contain"` preserves aspect ratio; the container measures itself
20
+ * via `onLayout`. Works on web (react-native-web) and native.
21
+ */
22
+ export function RotatableImage({ uri, alt, rotation = 0, backgroundColor, style }: RotatableImageProps) {
23
+ const [size, setSize] = useState({ width: 0, height: 0 });
24
+ const deg = (((rotation % 360) + 360) % 360);
25
+ const quarter = deg === 90 || deg === 270;
26
+ const boxWidth = quarter ? size.height : size.width;
27
+ const boxHeight = quarter ? size.width : size.height;
28
+
29
+ return (
30
+ <View
31
+ onLayout={(e) =>
32
+ setSize({ width: e.nativeEvent.layout.width, height: e.nativeEvent.layout.height })
33
+ }
34
+ style={[styles.container, backgroundColor ? { backgroundColor } : null, style]}
35
+ >
36
+ {size.width > 0 && size.height > 0 ? (
37
+ <Image
38
+ source={{ uri }}
39
+ resizeMode="contain"
40
+ accessibilityLabel={alt}
41
+ style={{ width: boxWidth, height: boxHeight, transform: [{ rotate: `${deg}deg` }] }}
42
+ />
43
+ ) : null}
44
+ </View>
45
+ );
46
+ }
47
+
48
+ const styles = StyleSheet.create({
49
+ container: {
50
+ width: "100%",
51
+ height: "100%",
52
+ alignItems: "center",
53
+ justifyContent: "center",
54
+ overflow: "hidden",
55
+ },
56
+ });
@@ -0,0 +1,30 @@
1
+ import { useCallback, useMemo, useState } from "react";
2
+
3
+ /**
4
+ * Per-file view rotation for image viewers — degrees (0/90/180/270) keyed by
5
+ * file id, held in memory. Shared between an inline gallery and its fullscreen
6
+ * modal so a rotation survives prev/next navigation and open/close while the
7
+ * gallery is mounted. View-only: it is a render transform, never a mutation of
8
+ * the stored file.
9
+ */
10
+ export interface ImageRotation {
11
+ rotations: Record<string, number>;
12
+ rotationFor: (id: string) => number;
13
+ /** Advance a quarter-turn; `step` 1 = clockwise, -1 = counter-clockwise. */
14
+ rotate: (id: string, step?: 1 | -1) => void;
15
+ }
16
+
17
+ export function useImageRotation(): ImageRotation {
18
+ const [rotations, setRotations] = useState<Record<string, number>>({});
19
+
20
+ const rotate = useCallback((id: string, step: 1 | -1 = 1) => {
21
+ setRotations((prev) => {
22
+ const next = ((((prev[id] ?? 0) + step * 90) % 360) + 360) % 360;
23
+ return { ...prev, [id]: next };
24
+ });
25
+ }, []);
26
+
27
+ const rotationFor = useCallback((id: string) => rotations[id] ?? 0, [rotations]);
28
+
29
+ return useMemo(() => ({ rotations, rotationFor, rotate }), [rotations, rotationFor, rotate]);
30
+ }