@umituz/react-native-photo-editor 2.0.21 → 2.0.23

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 (30) hide show
  1. package/ARCHITECTURE.md +104 -0
  2. package/MIGRATION.md +100 -0
  3. package/package.json +1 -5
  4. package/src/PhotoEditor.tsx +56 -44
  5. package/src/application/hooks/useEditor.ts +67 -0
  6. package/src/application/hooks/useEditorUI.ts +145 -0
  7. package/src/application/stores/EditorStore.ts +137 -0
  8. package/src/constants.ts +5 -52
  9. package/src/domain/entities/Filters.ts +72 -0
  10. package/src/domain/entities/Layer.ts +126 -0
  11. package/src/domain/entities/Transform.ts +55 -0
  12. package/src/domain/services/HistoryService.ts +60 -0
  13. package/src/domain/services/LayerService.ts +105 -0
  14. package/src/index.ts +25 -5
  15. package/src/infrastructure/gesture/types.ts +27 -0
  16. package/src/infrastructure/gesture/useTransformGesture.ts +136 -0
  17. package/src/infrastructure/history/HistoryManager.ts +38 -0
  18. package/src/presentation/components/DraggableLayer.tsx +114 -0
  19. package/src/presentation/components/EditorCanvas.tsx +90 -0
  20. package/src/presentation/components/EditorToolbar.tsx +192 -0
  21. package/src/presentation/components/FontControls.tsx +99 -0
  22. package/src/presentation/components/sheets/AIMagicSheet.tsx +99 -0
  23. package/src/presentation/components/sheets/AdjustmentsSheet.tsx +113 -0
  24. package/src/presentation/components/sheets/FilterSheet.tsx +128 -0
  25. package/src/presentation/components/sheets/LayerManager.tsx +151 -0
  26. package/src/presentation/components/sheets/StickerPicker.tsx +67 -0
  27. package/src/presentation/components/sheets/TextEditorSheet.tsx +159 -0
  28. package/src/presentation/components/ui/ColorPicker.tsx +78 -0
  29. package/src/presentation/components/ui/Slider.tsx +116 -0
  30. package/src/types.ts +13 -58
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Transform Gesture Hook
3
+ * Reusable gesture logic for draggable components
4
+ * Eliminates ~180 lines of duplicate code
5
+ */
6
+
7
+ import { useState, useRef, useCallback, useEffect } from "react";
8
+ import { Gesture } from "react-native-gesture-handler";
9
+ import type { Transform } from "../../domain/entities/Transform";
10
+ import type { TransformGestureConfig, TransformGestureState } from "./types";
11
+
12
+ const DEFAULT_STATE: TransformGestureState = {
13
+ position: { x: 50, y: 50 },
14
+ scale: 1,
15
+ rotation: 0,
16
+ };
17
+
18
+ export function useTransformGesture(
19
+ initialTransform: Partial<Transform>,
20
+ config: TransformGestureConfig
21
+ ) {
22
+ const {
23
+ minScale = 0.2,
24
+ maxScale = 6,
25
+ onTransformEnd,
26
+ onPress,
27
+ } = config;
28
+
29
+ // State
30
+ const [state, setState] = useState<TransformGestureState>(() => ({
31
+ position: { x: initialTransform.x ?? 50, y: initialTransform.y ?? 50 },
32
+ scale: initialTransform.scale ?? 1,
33
+ rotation: initialTransform.rotation ?? 0,
34
+ }));
35
+
36
+ // Sync state when props change (undo/redo)
37
+ useEffect(() => {
38
+ setState(prev => ({
39
+ ...prev,
40
+ position: { x: initialTransform.x ?? prev.position.x, y: initialTransform.y ?? prev.position.y },
41
+ scale: initialTransform.scale ?? prev.scale,
42
+ rotation: initialTransform.rotation ?? prev.rotation,
43
+ }));
44
+ }, [initialTransform.x, initialTransform.y, initialTransform.scale, initialTransform.rotation]);
45
+
46
+ // Refs for gesture callbacks
47
+ const stateRef = useRef(state);
48
+ stateRef.current = state;
49
+ const onTransformEndRef = useRef(onTransformEnd);
50
+ onTransformEndRef.current = onTransformEnd;
51
+ const onPressRef = useRef(onPress);
52
+ onPressRef.current = onPress;
53
+
54
+ // Start values for gestures
55
+ const offsetRef = useRef(state.position);
56
+ const scaleStartRef = useRef(state.scale);
57
+ const rotationStartRef = useRef(state.rotation);
58
+
59
+ // Emit transform
60
+ const emitTransform = useCallback(() => {
61
+ onTransformEndRef.current({
62
+ x: stateRef.current.position.x,
63
+ y: stateRef.current.position.y,
64
+ scale: stateRef.current.scale,
65
+ rotation: stateRef.current.rotation,
66
+ });
67
+ }, []);
68
+
69
+ // Pan gesture
70
+ const panGesture = Gesture.Pan()
71
+ .runOnJS(true)
72
+ .averageTouches(true)
73
+ .onStart(() => {
74
+ offsetRef.current = stateRef.current.position;
75
+ })
76
+ .onUpdate((e) => {
77
+ setState(prev => ({
78
+ ...prev,
79
+ position: {
80
+ x: offsetRef.current.x + e.translationX,
81
+ y: offsetRef.current.y + e.translationY,
82
+ },
83
+ }));
84
+ })
85
+ .onEnd(emitTransform);
86
+
87
+ // Pinch gesture
88
+ const pinchGesture = Gesture.Pinch()
89
+ .runOnJS(true)
90
+ .onStart(() => {
91
+ scaleStartRef.current = stateRef.current.scale;
92
+ })
93
+ .onUpdate((e) => {
94
+ setState(prev => ({
95
+ ...prev,
96
+ scale: Math.max(minScale, Math.min(maxScale, scaleStartRef.current * e.scale)),
97
+ }));
98
+ })
99
+ .onEnd(emitTransform);
100
+
101
+ // Rotation gesture
102
+ const rotationGesture = Gesture.Rotation()
103
+ .runOnJS(true)
104
+ .onStart(() => {
105
+ rotationStartRef.current = stateRef.current.rotation;
106
+ })
107
+ .onUpdate((e) => {
108
+ setState(prev => ({
109
+ ...prev,
110
+ rotation: rotationStartRef.current + (e.rotation * 180) / Math.PI,
111
+ }));
112
+ })
113
+ .onEnd(emitTransform);
114
+
115
+ // Tap gesture
116
+ const tapGesture = Gesture.Tap()
117
+ .runOnJS(true)
118
+ .onEnd(() => onPressRef.current?.());
119
+
120
+ // Composed gesture
121
+ const composed = Gesture.Exclusive(
122
+ Gesture.Simultaneous(panGesture, pinchGesture, rotationGesture),
123
+ tapGesture,
124
+ );
125
+
126
+ return {
127
+ state,
128
+ gestures: {
129
+ pan: panGesture,
130
+ pinch: pinchGesture,
131
+ rotation: rotationGesture,
132
+ tap: tapGesture,
133
+ composed,
134
+ },
135
+ };
136
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * History Manager Implementation
3
+ * Legacy compatibility wrapper for HistoryService
4
+ */
5
+
6
+ import { HistoryService } from "../../domain/services/HistoryService";
7
+
8
+ const historyService = new HistoryService(20);
9
+
10
+ export type { HistoryState } from "../../domain/services/HistoryService";
11
+
12
+ export class HistoryManager<T> {
13
+ private readonly maxHistory = 20;
14
+
15
+ createInitialState(initialValue: T) {
16
+ return historyService.createInitialState(initialValue);
17
+ }
18
+
19
+ push(history: any, newValue: T) {
20
+ return historyService.push(history, newValue);
21
+ }
22
+
23
+ undo(history: any) {
24
+ return historyService.undo(history);
25
+ }
26
+
27
+ redo(history: any) {
28
+ return historyService.redo(history);
29
+ }
30
+
31
+ canUndo(history: any) {
32
+ return historyService.canUndo(history);
33
+ }
34
+
35
+ canRedo(history: any) {
36
+ return historyService.canRedo(history);
37
+ }
38
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Draggable Layer Component
3
+ * Unified draggable component for both text and stickers
4
+ * Replaces DraggableText + DraggableSticker (~180 lines of duplicate code)
5
+ */
6
+
7
+ import React, { memo } from "react";
8
+ import { View, StyleSheet, TouchableOpacity } from "react-native";
9
+ import { GestureDetector } from "react-native-gesture-handler";
10
+ import { Image } from "expo-image";
11
+ import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system/atoms";
12
+ import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
13
+ import { useTransformGesture } from "../../infrastructure/gesture/useTransformGesture";
14
+ import { Layer, isTextLayer, isStickerLayer } from "../../domain/entities/Layer";
15
+
16
+ interface DraggableLayerProps {
17
+ layer: Layer;
18
+ isSelected: boolean;
19
+ onPress: () => void;
20
+ onTransformEnd: (transform: { x: number; y: number; scale: number; rotation: number }) => void;
21
+ }
22
+
23
+ const isEmojiString = (str: string) =>
24
+ str.length <= 4 && !/^https?:\/\//i.test(str) && !str.startsWith("/");
25
+
26
+ export const DraggableLayer = memo<DraggableLayerProps>(({
27
+ layer,
28
+ isSelected,
29
+ onPress,
30
+ onTransformEnd,
31
+ }) => {
32
+ const tokens = useAppDesignTokens();
33
+ const { state, gestures } = useTransformGesture(layer, {
34
+ onTransformEnd,
35
+ onPress,
36
+ });
37
+
38
+ return (
39
+ <GestureDetector gesture={gestures.composed}>
40
+ <View
41
+ accessibilityLabel={isTextLayer(layer) ? layer.text || "Text layer" : "Sticker layer"}
42
+ accessibilityRole="button"
43
+ style={[
44
+ styles.container,
45
+ {
46
+ transform: [
47
+ { translateX: state.position.x },
48
+ { translateY: state.position.y },
49
+ { rotate: `${state.rotation}deg` },
50
+ { scale: state.scale },
51
+ ],
52
+ opacity: layer.opacity,
53
+ zIndex: isSelected ? 100 : layer.zIndex,
54
+ },
55
+ ]}
56
+ >
57
+ <View
58
+ style={{
59
+ padding: tokens.spacing.xs,
60
+ borderRadius: tokens.borders.radius.sm,
61
+ borderWidth: isSelected ? 2 : 0,
62
+ borderColor: tokens.colors.primary,
63
+ borderStyle: "dashed",
64
+ backgroundColor: isSelected
65
+ ? tokens.colors.primary + "10"
66
+ : isTextLayer(layer)
67
+ ? layer.backgroundColor
68
+ : "transparent",
69
+ }}
70
+ >
71
+ {isTextLayer(layer) ? (
72
+ <AtomicText
73
+ style={{
74
+ fontSize: layer.fontSize,
75
+ fontFamily: layer.fontFamily === "System" ? undefined : layer.fontFamily,
76
+ color: layer.color,
77
+ textAlign: layer.textAlign,
78
+ fontWeight: layer.isBold ? "900" : "normal",
79
+ fontStyle: layer.isItalic ? "italic" : "normal",
80
+ }}
81
+ >
82
+ {layer.text || "TAP TO EDIT"}
83
+ </AtomicText>
84
+ ) : isStickerLayer(layer) ? (
85
+ renderStickerContent(layer.uri, tokens)
86
+ ) : null}
87
+ </View>
88
+ </View>
89
+ </GestureDetector>
90
+ );
91
+ });
92
+
93
+ DraggableLayer.displayName = "DraggableLayer";
94
+
95
+ function renderStickerContent(uri: string, tokens: any) {
96
+ const isEmoji = isEmojiString(uri);
97
+
98
+ if (isEmoji) {
99
+ return <AtomicText style={{ fontSize: 48 }}>{uri}</AtomicText>;
100
+ }
101
+
102
+ return (
103
+ <Image
104
+ source={{ uri }}
105
+ style={{ width: 80, height: 80 }}
106
+ contentFit="contain"
107
+ accessibilityIgnoresInvertColors
108
+ />
109
+ );
110
+ }
111
+
112
+ const styles = StyleSheet.create({
113
+ container: { position: "absolute" },
114
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Editor Canvas Component
3
+ * Renders the image and all layers
4
+ */
5
+
6
+ import React, { memo } from "react";
7
+ import { View, StyleSheet } from "react-native";
8
+ import { Image } from "expo-image";
9
+ import { DraggableLayer } from "./DraggableLayer";
10
+ import { Layer } from "../../domain/entities/Layer";
11
+ import type { FilterValues } from "../../domain/entities/Filters";
12
+
13
+ interface EditorCanvasProps {
14
+ imageUrl: string;
15
+ layers: Layer[];
16
+ activeLayerId: string | null;
17
+ filters: FilterValues;
18
+ onLayerTap: (layerId: string) => void;
19
+ onLayerTransform: (layerId: string, transform: { x: number; y: number; scale: number; rotation: number }) => void;
20
+ style?: any;
21
+ }
22
+
23
+ export const EditorCanvas = memo<EditorCanvasProps>(({
24
+ imageUrl,
25
+ layers,
26
+ activeLayerId,
27
+ filters,
28
+ onLayerTap,
29
+ onLayerTransform,
30
+ style,
31
+ }) => {
32
+ const brightnessOverlay = createBrightnessOverlay(filters.brightness);
33
+
34
+ return (
35
+ <View style={[styles.canvas, style]}>
36
+ <Image
37
+ source={{ uri: imageUrl }}
38
+ style={styles.canvasImage}
39
+ contentFit="cover"
40
+ />
41
+
42
+ {brightnessOverlay && (
43
+ <View
44
+ style={[
45
+ StyleSheet.absoluteFill,
46
+ {
47
+ backgroundColor: brightnessOverlay.color,
48
+ opacity: brightnessOverlay.opacity,
49
+ },
50
+ ]}
51
+ pointerEvents="none"
52
+ />
53
+ )}
54
+
55
+ {layers.map((layer) => (
56
+ <DraggableLayer
57
+ key={layer.id}
58
+ layer={layer}
59
+ isSelected={activeLayerId === layer.id}
60
+ onPress={() => onLayerTap(layer.id)}
61
+ onTransformEnd={(transform) => onLayerTransform(layer.id, transform)}
62
+ />
63
+ ))}
64
+ </View>
65
+ );
66
+ });
67
+
68
+ EditorCanvas.displayName = "EditorCanvas";
69
+
70
+ function createBrightnessOverlay(brightness: number = 1) {
71
+ if (brightness < 1) {
72
+ return { color: "black", opacity: Math.min(0.6, 1 - brightness) };
73
+ }
74
+ if (brightness > 1) {
75
+ return { color: "white", opacity: Math.min(0.4, brightness - 1) };
76
+ }
77
+ return null;
78
+ }
79
+
80
+ const styles = StyleSheet.create({
81
+ canvas: {
82
+ position: "relative",
83
+ width: "100%",
84
+ aspectRatio: 1,
85
+ },
86
+ canvasImage: {
87
+ width: "100%",
88
+ height: "100%",
89
+ },
90
+ });
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Editor Toolbar Component
3
+ * Main toolbar with action buttons
4
+ */
5
+
6
+ import React, { memo } from "react";
7
+ import { View, TouchableOpacity, ScrollView } from "react-native";
8
+ import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system/atoms";
9
+ import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
10
+
11
+ interface ToolButtonProps {
12
+ icon: string;
13
+ label: string;
14
+ onPress: () => void;
15
+ isActive?: boolean;
16
+ disabled?: boolean;
17
+ isPrimary?: boolean;
18
+ }
19
+
20
+ const ToolButton = memo<ToolButtonProps>(({ icon, label, onPress, isActive, disabled, isPrimary }) => {
21
+ const tokens = useAppDesignTokens();
22
+
23
+ if (isPrimary) {
24
+ return (
25
+ <TouchableOpacity
26
+ style={{
27
+ width: 56,
28
+ height: 56,
29
+ borderRadius: 28,
30
+ backgroundColor: tokens.colors.primary,
31
+ alignItems: "center",
32
+ justifyContent: "center",
33
+ elevation: 4,
34
+ shadowColor: "#000",
35
+ shadowOffset: { width: 0, height: 2 },
36
+ shadowOpacity: 0.25,
37
+ shadowRadius: 4,
38
+ }}
39
+ onPress={onPress}
40
+ accessibilityLabel={label}
41
+ accessibilityRole="button"
42
+ >
43
+ <AtomicIcon name={icon as "sparkles"} size="lg" customColor={tokens.colors.onPrimary} />
44
+ </TouchableOpacity>
45
+ );
46
+ }
47
+
48
+ return (
49
+ <TouchableOpacity
50
+ style={{
51
+ alignItems: "center",
52
+ gap: tokens.spacing.xs,
53
+ opacity: disabled ? 0.5 : 1,
54
+ }}
55
+ onPress={onPress}
56
+ disabled={disabled}
57
+ accessibilityLabel={label}
58
+ accessibilityRole="button"
59
+ accessibilityState={{ selected: isActive, disabled }}
60
+ >
61
+ <AtomicIcon
62
+ name={icon as "edit"}
63
+ size="md"
64
+ color={disabled ? "textSecondary" : isActive ? "primary" : "textSecondary"}
65
+ />
66
+ <AtomicText
67
+ type="labelSmall"
68
+ color={disabled ? "textSecondary" : isActive ? "primary" : "textSecondary"}
69
+ >
70
+ {label}
71
+ </AtomicText>
72
+ </TouchableOpacity>
73
+ );
74
+ });
75
+
76
+ ToolButton.displayName = "ToolButton";
77
+
78
+ export interface EditorToolbarProps {
79
+ onAddText: () => void;
80
+ onAddSticker?: () => void;
81
+ onOpenFilters?: () => void;
82
+ onOpenAdjustments?: () => void;
83
+ onOpenLayers: () => void;
84
+ onAIMagic?: () => void;
85
+ onUndo?: () => void;
86
+ onRedo?: () => void;
87
+ canUndo?: boolean;
88
+ canRedo?: boolean;
89
+ t: (key: string) => string;
90
+ }
91
+
92
+ export const EditorToolbar = memo<EditorToolbarProps>(({
93
+ onAddText,
94
+ onAddSticker,
95
+ onOpenFilters,
96
+ onOpenAdjustments,
97
+ onOpenLayers,
98
+ onAIMagic,
99
+ onUndo,
100
+ onRedo,
101
+ canUndo = false,
102
+ canRedo = false,
103
+ t,
104
+ }) => {
105
+ const tokens = useAppDesignTokens();
106
+
107
+ return (
108
+ <View
109
+ style={{
110
+ flexDirection: "row",
111
+ alignItems: "center",
112
+ paddingVertical: tokens.spacing.md,
113
+ paddingHorizontal: tokens.spacing.sm,
114
+ backgroundColor: tokens.colors.surface,
115
+ borderTopWidth: 1,
116
+ borderTopColor: tokens.colors.border,
117
+ gap: tokens.spacing.sm,
118
+ }}
119
+ >
120
+ <ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ flex: 1 }}>
121
+ <View style={{ flexDirection: "row", alignItems: "center", gap: tokens.spacing.md }}>
122
+ {onUndo && (
123
+ <ToolButton
124
+ icon="arrow-back"
125
+ label={t("photo_editor.undo") || "Undo"}
126
+ onPress={onUndo}
127
+ disabled={!canUndo}
128
+ />
129
+ )}
130
+
131
+ <ToolButton
132
+ icon="edit"
133
+ label={t("photo_editor.text") || "Text"}
134
+ onPress={onAddText}
135
+ />
136
+
137
+ {onAddSticker && (
138
+ <ToolButton
139
+ icon="sparkles"
140
+ label={t("photo_editor.sticker") || "Sticker"}
141
+ onPress={onAddSticker}
142
+ />
143
+ )}
144
+
145
+ {onOpenAdjustments && (
146
+ <ToolButton
147
+ icon="flash"
148
+ label={t("photo_editor.adjust") || "Adjust"}
149
+ onPress={onOpenAdjustments}
150
+ />
151
+ )}
152
+
153
+ {onOpenFilters && (
154
+ <ToolButton
155
+ icon="brush"
156
+ label={t("photo_editor.filters") || "Filters"}
157
+ onPress={onOpenFilters}
158
+ />
159
+ )}
160
+
161
+ <ToolButton
162
+ icon="copy"
163
+ label={t("photo_editor.layers") || "Layers"}
164
+ onPress={onOpenLayers}
165
+ />
166
+
167
+ {onRedo && (
168
+ <ToolButton
169
+ icon="chevron-forward"
170
+ label={t("photo_editor.redo") || "Redo"}
171
+ onPress={onRedo}
172
+ disabled={!canRedo}
173
+ />
174
+ )}
175
+ </View>
176
+ </ScrollView>
177
+
178
+ {onAIMagic && (
179
+ <View style={{ marginLeft: tokens.spacing.sm }}>
180
+ <ToolButton
181
+ icon="sparkles"
182
+ label="AI"
183
+ onPress={onAIMagic}
184
+ isPrimary
185
+ />
186
+ </View>
187
+ )}
188
+ </View>
189
+ );
190
+ });
191
+
192
+ EditorToolbar.displayName = "EditorToolbar";
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Font Controls Component
3
+ * Font family and size controls
4
+ */
5
+
6
+ import React, { memo } from "react";
7
+ import { View, ScrollView, TouchableOpacity, StyleSheet } from "react-native";
8
+ import { AtomicText } from "@umituz/react-native-design-system/atoms";
9
+ import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
10
+ import { Slider } from "./ui/Slider";
11
+
12
+ const DEFAULT_FONTS = [
13
+ "System",
14
+ "Impact",
15
+ "Comic",
16
+ "Serif",
17
+ "Retro",
18
+ ];
19
+
20
+ interface FontControlsProps {
21
+ fontSize: number;
22
+ selectedFont: string;
23
+ fonts?: readonly string[];
24
+ onFontSizeChange: (size: number) => void;
25
+ onFontSelect: (font: string) => void;
26
+ style?: any;
27
+ }
28
+
29
+ export const FontControls = memo<FontControlsProps>(({
30
+ fontSize,
31
+ selectedFont,
32
+ fonts = DEFAULT_FONTS,
33
+ onFontSizeChange,
34
+ onFontSelect,
35
+ style,
36
+ }) => {
37
+ const tokens = useAppDesignTokens();
38
+
39
+ return (
40
+ <View style={[styles.container, style]}>
41
+ <Slider
42
+ label="Font Size"
43
+ value={fontSize}
44
+ min={12}
45
+ max={120}
46
+ step={1}
47
+ onValueChange={onFontSizeChange}
48
+ formatValue={(v) => `${Math.round(v)}px`}
49
+ />
50
+
51
+ <ScrollView
52
+ horizontal
53
+ showsHorizontalScrollIndicator={false}
54
+ style={{ marginTop: tokens.spacing.sm }}
55
+ contentContainerStyle={{ gap: tokens.spacing.sm, paddingHorizontal: tokens.spacing.sm }}
56
+ >
57
+ {fonts.map((font) => (
58
+ <TouchableOpacity
59
+ key={font}
60
+ style={[
61
+ styles.fontButton,
62
+ {
63
+ backgroundColor: tokens.colors.surfaceVariant,
64
+ borderWidth: selectedFont === font ? 2 : 1,
65
+ borderColor: selectedFont === font ? tokens.colors.primary : tokens.colors.border,
66
+ },
67
+ ]}
68
+ onPress={() => onFontSelect(font)}
69
+ accessibilityLabel={`Font ${font}`}
70
+ accessibilityRole="button"
71
+ accessibilityState={{ selected: selectedFont === font }}
72
+ >
73
+ <AtomicText
74
+ style={{
75
+ fontFamily: font === "System" ? undefined : font,
76
+ color: selectedFont === font ? tokens.colors.primary : tokens.colors.textPrimary,
77
+ }}
78
+ >
79
+ {font}
80
+ </AtomicText>
81
+ </TouchableOpacity>
82
+ ))}
83
+ </ScrollView>
84
+ </View>
85
+ );
86
+ });
87
+
88
+ FontControls.displayName = "FontControls";
89
+
90
+ const styles = StyleSheet.create({
91
+ container: { gap: 8 },
92
+ fontButton: {
93
+ paddingHorizontal: 16,
94
+ paddingVertical: 8,
95
+ borderRadius: 8,
96
+ minWidth: 80,
97
+ alignItems: "center",
98
+ },
99
+ });