@umituz/react-native-video-editor 1.1.63 → 1.1.65
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 +1 -1
- package/src/application/services/EditorService.ts +151 -0
- package/src/application/usecases/LayerUseCases.ts +192 -0
- package/src/infrastructure/repositories/LayerRepositoryImpl.ts +158 -0
- package/src/infrastructure/services/image-layer-operations.service.ts +1 -1
- package/src/infrastructure/services/shape-layer-operations.service.ts +1 -1
- package/src/infrastructure/services/text-layer-operations.service.ts +1 -1
- package/src/infrastructure/utils/debounce.utils.ts +69 -0
- package/src/infrastructure/utils/image-processing.utils.ts +73 -0
- package/src/infrastructure/utils/position-calculations.utils.ts +45 -161
- package/src/presentation/components/EditorTimeline.tsx +29 -11
- package/src/presentation/components/EditorToolPanel.tsx +71 -172
- package/src/presentation/components/LayerActionsMenu.tsx +97 -159
- package/src/presentation/components/SceneActionsMenu.tsx +34 -44
- package/src/presentation/components/SubtitleListPanel.tsx +55 -28
- package/src/presentation/components/SubtitleStylePicker.tsx +36 -156
- package/src/presentation/components/collage/CollageCanvas.tsx +2 -2
- package/src/presentation/components/collage/CollageLayoutSelector.tsx +0 -4
- package/src/presentation/components/draggable-layer/LayerContent.tsx +7 -2
- package/src/presentation/components/generic/ActionMenu.tsx +110 -0
- package/src/presentation/components/generic/Editor.tsx +65 -0
- package/src/presentation/components/generic/Selector.tsx +96 -0
- package/src/presentation/components/generic/Toolbar.tsx +77 -0
- package/src/presentation/components/image-layer/ImagePreview.tsx +7 -1
- package/src/presentation/components/shape-layer/ColorPickerHorizontal.tsx +20 -55
- package/src/presentation/components/subtitle/SubtitleListItem.tsx +4 -5
- package/src/presentation/components/subtitle/SubtitleModal.tsx +2 -2
- package/src/presentation/components/subtitle/useSubtitleForm.ts +1 -1
- package/src/presentation/components/text-layer/ColorPicker.tsx +21 -55
- package/src/presentation/components/text-layer/FontSizeSelector.tsx +19 -50
- package/src/presentation/components/text-layer/OptionSelector.tsx +18 -55
- package/src/presentation/components/text-layer/TextAlignSelector.tsx +24 -51
- package/src/presentation/hooks/generic/use-layer-form.hook.ts +19 -16
- package/src/presentation/hooks/generic/useForm.ts +99 -0
- package/src/presentation/hooks/generic/useList.ts +117 -0
- package/src/presentation/hooks/useDraggableLayerGestures.ts +28 -25
- package/src/presentation/hooks/useEditorPlayback.ts +19 -2
- package/src/presentation/hooks/useImageLayerForm.ts +9 -6
- package/src/presentation/hooks/useMenuActions.tsx +19 -4
- package/src/presentation/hooks/useTextLayerForm.ts +9 -6
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Subtitle Style Picker Component
|
|
3
|
+
* REFACTORED: Uses generic Selector component (110 lines)
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import React, { useMemo } from "react";
|
|
7
|
-
import { View,
|
|
7
|
+
import { View, ScrollView } from "react-native";
|
|
8
8
|
import { AtomicText } from "@umituz/react-native-design-system/atoms";
|
|
9
9
|
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
SUBTITLE_FONT_COLORS,
|
|
13
|
-
SUBTITLE_BG_COLORS,
|
|
14
|
-
} from "../../infrastructure/constants/subtitle.constants";
|
|
10
|
+
import { Selector, type SelectorItem } from "./generic/Selector";
|
|
11
|
+
import { FONT_SIZE_MAP, SUBTITLE_FONT_COLORS, SUBTITLE_BG_COLORS } from "../../infrastructure/constants/subtitle.constants";
|
|
15
12
|
import type { SubtitleStyle } from "../../domain/entities/video-project.types";
|
|
16
13
|
|
|
17
14
|
interface SubtitleStylePickerProps {
|
|
@@ -24,166 +21,49 @@ interface SubtitleStylePickerProps {
|
|
|
24
21
|
const FONT_SIZES: SubtitleStyle["fontSize"][] = ["small", "medium", "large", "extraLarge"];
|
|
25
22
|
const POSITIONS: SubtitleStyle["position"][] = ["top", "center", "bottom"];
|
|
26
23
|
|
|
27
|
-
|
|
28
|
-
style,
|
|
29
|
-
previewText,
|
|
30
|
-
onChange,
|
|
31
|
-
t,
|
|
32
|
-
}) => {
|
|
24
|
+
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => {
|
|
33
25
|
const tokens = useAppDesignTokens();
|
|
26
|
+
return (
|
|
27
|
+
<View style={{ marginTop: tokens.spacing.md }}>
|
|
28
|
+
<AtomicText type="labelSmall" color="textSecondary" style={{ marginBottom: tokens.spacing.sm }}>{title}</AtomicText>
|
|
29
|
+
{children}
|
|
30
|
+
</View>
|
|
31
|
+
);
|
|
32
|
+
};
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
sectionLabel: { marginBottom: tokens.spacing.sm },
|
|
38
|
-
row: { flexDirection: "row", gap: tokens.spacing.sm, flexWrap: "wrap" },
|
|
39
|
-
optionBtn: {
|
|
40
|
-
paddingHorizontal: tokens.spacing.md,
|
|
41
|
-
paddingVertical: tokens.spacing.sm,
|
|
42
|
-
borderRadius: tokens.borders.radius.md,
|
|
43
|
-
backgroundColor: tokens.colors.surface,
|
|
44
|
-
borderWidth: 1,
|
|
45
|
-
borderColor: tokens.colors.border,
|
|
46
|
-
},
|
|
47
|
-
optionBtnActive: {
|
|
48
|
-
backgroundColor: tokens.colors.primaryContainer,
|
|
49
|
-
borderColor: tokens.colors.primary,
|
|
50
|
-
},
|
|
51
|
-
colorRow: { flexDirection: "row", gap: tokens.spacing.sm },
|
|
52
|
-
colorBtn: {
|
|
53
|
-
width: 36,
|
|
54
|
-
height: 36,
|
|
55
|
-
borderRadius: 18,
|
|
56
|
-
borderWidth: 2,
|
|
57
|
-
borderColor: "transparent",
|
|
58
|
-
alignItems: "center",
|
|
59
|
-
justifyContent: "center",
|
|
60
|
-
},
|
|
61
|
-
colorBtnActive: {
|
|
62
|
-
borderColor: tokens.colors.primary,
|
|
63
|
-
},
|
|
64
|
-
previewBox: {
|
|
65
|
-
height: 72,
|
|
66
|
-
backgroundColor: tokens.colors.surfaceVariant,
|
|
67
|
-
borderRadius: tokens.borders.radius.md,
|
|
68
|
-
marginTop: tokens.spacing.md,
|
|
69
|
-
justifyContent: "center",
|
|
70
|
-
alignItems: "center",
|
|
71
|
-
borderWidth: 1,
|
|
72
|
-
borderColor: tokens.colors.border,
|
|
73
|
-
overflow: "hidden",
|
|
74
|
-
},
|
|
75
|
-
previewBubble: {
|
|
76
|
-
paddingHorizontal: tokens.spacing.md,
|
|
77
|
-
paddingVertical: tokens.spacing.xs,
|
|
78
|
-
borderRadius: tokens.borders.radius.sm,
|
|
79
|
-
},
|
|
80
|
-
}), [tokens]);
|
|
34
|
+
export const SubtitleStylePicker: React.FC<SubtitleStylePickerProps> = ({ style, previewText, onChange, t }) => {
|
|
35
|
+
const tokens = useAppDesignTokens();
|
|
81
36
|
|
|
82
|
-
const
|
|
37
|
+
const fontSizeItems = useMemo<SelectorItem<SubtitleStyle["fontSize"]>[]>(() => FONT_SIZES.map((size) => ({ value: size, label: size })), []);
|
|
38
|
+
const fontColorItems = useMemo<SelectorItem[]>(() => SUBTITLE_FONT_COLORS.map((color) => ({ value: color, label: "", color })), []);
|
|
39
|
+
const bgColorItems = useMemo<SelectorItem[]>(() => SUBTITLE_BG_COLORS.map((bg) => ({ value: bg.value, label: bg.label, color: bg.value })), []);
|
|
40
|
+
const positionItems = useMemo<SelectorItem<SubtitleStyle["position"]>[]>(() => POSITIONS.map((pos) => ({ value: pos, label: pos })), []);
|
|
83
41
|
|
|
84
42
|
return (
|
|
85
|
-
<
|
|
86
|
-
{
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
{t("subtitle.style.fontSize") || "Size"}
|
|
90
|
-
</AtomicText>
|
|
91
|
-
<View style={styles.row}>
|
|
92
|
-
{FONT_SIZES.map((size) => (
|
|
93
|
-
<TouchableOpacity
|
|
94
|
-
key={size}
|
|
95
|
-
style={[styles.optionBtn, style.fontSize === size && styles.optionBtnActive]}
|
|
96
|
-
onPress={() => update({ fontSize: size })}
|
|
97
|
-
accessibilityRole="button"
|
|
98
|
-
>
|
|
99
|
-
<AtomicText
|
|
100
|
-
type="labelSmall"
|
|
101
|
-
color={style.fontSize === size ? "primary" : "textSecondary"}
|
|
102
|
-
>
|
|
103
|
-
{size}
|
|
104
|
-
</AtomicText>
|
|
105
|
-
</TouchableOpacity>
|
|
106
|
-
))}
|
|
107
|
-
</View>
|
|
108
|
-
</View>
|
|
43
|
+
<ScrollView>
|
|
44
|
+
<Section title={t("subtitle.style.fontSize") || "Size"}>
|
|
45
|
+
<Selector items={fontSizeItems} selectedValue={style.fontSize} onSelect={(value) => onChange({ ...style, fontSize: value })} orientation="horizontal" testID="font-size-selector" />
|
|
46
|
+
</Section>
|
|
109
47
|
|
|
110
|
-
{
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
{t("subtitle.style.fontColor") || "Color"}
|
|
114
|
-
</AtomicText>
|
|
115
|
-
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
116
|
-
<View style={styles.colorRow}>
|
|
117
|
-
{SUBTITLE_FONT_COLORS.map((color) => (
|
|
118
|
-
<TouchableOpacity
|
|
119
|
-
key={color}
|
|
120
|
-
style={[styles.colorBtn, { backgroundColor: color }, style.fontColor === color && styles.colorBtnActive]}
|
|
121
|
-
onPress={() => update({ fontColor: color })}
|
|
122
|
-
accessibilityRole="button"
|
|
123
|
-
/>
|
|
124
|
-
))}
|
|
125
|
-
</View>
|
|
126
|
-
</ScrollView>
|
|
127
|
-
</View>
|
|
48
|
+
<Section title={t("subtitle.style.fontColor") || "Color"}>
|
|
49
|
+
<Selector items={fontColorItems} selectedValue={style.fontColor} onSelect={(value) => onChange({ ...style, fontColor: value })} orientation="horizontal" colorPreview testID="font-color-selector" />
|
|
50
|
+
</Section>
|
|
128
51
|
|
|
129
|
-
{
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
{t("subtitle.style.background") || "Background"}
|
|
133
|
-
</AtomicText>
|
|
134
|
-
<View style={styles.row}>
|
|
135
|
-
{SUBTITLE_BG_COLORS.map((bg) => (
|
|
136
|
-
<TouchableOpacity
|
|
137
|
-
key={bg.value}
|
|
138
|
-
style={[styles.optionBtn, style.backgroundColor === bg.value && styles.optionBtnActive]}
|
|
139
|
-
onPress={() => update({ backgroundColor: bg.value })}
|
|
140
|
-
accessibilityRole="button"
|
|
141
|
-
>
|
|
142
|
-
<AtomicText
|
|
143
|
-
type="labelSmall"
|
|
144
|
-
color={style.backgroundColor === bg.value ? "primary" : "textSecondary"}
|
|
145
|
-
>
|
|
146
|
-
{bg.label}
|
|
147
|
-
</AtomicText>
|
|
148
|
-
</TouchableOpacity>
|
|
149
|
-
))}
|
|
150
|
-
</View>
|
|
151
|
-
</View>
|
|
52
|
+
<Section title={t("subtitle.style.background") || "Background"}>
|
|
53
|
+
<Selector items={bgColorItems} selectedValue={style.backgroundColor} onSelect={(value) => onChange({ ...style, backgroundColor: value })} orientation="horizontal" testID="bg-color-selector" />
|
|
54
|
+
</Section>
|
|
152
55
|
|
|
153
|
-
{
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
{t("subtitle.style.position") || "Position"}
|
|
157
|
-
</AtomicText>
|
|
158
|
-
<View style={styles.row}>
|
|
159
|
-
{POSITIONS.map((pos) => (
|
|
160
|
-
<TouchableOpacity
|
|
161
|
-
key={pos}
|
|
162
|
-
style={[styles.optionBtn, style.position === pos && styles.optionBtnActive]}
|
|
163
|
-
onPress={() => update({ position: pos })}
|
|
164
|
-
accessibilityRole="button"
|
|
165
|
-
>
|
|
166
|
-
<AtomicText
|
|
167
|
-
type="labelSmall"
|
|
168
|
-
color={style.position === pos ? "primary" : "textSecondary"}
|
|
169
|
-
>
|
|
170
|
-
{pos}
|
|
171
|
-
</AtomicText>
|
|
172
|
-
</TouchableOpacity>
|
|
173
|
-
))}
|
|
174
|
-
</View>
|
|
175
|
-
</View>
|
|
56
|
+
<Section title={t("subtitle.style.position") || "Position"}>
|
|
57
|
+
<Selector items={positionItems} selectedValue={style.position} onSelect={(value) => onChange({ ...style, position: value })} testID="position-selector" />
|
|
58
|
+
</Section>
|
|
176
59
|
|
|
177
|
-
{
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
<AtomicText
|
|
181
|
-
style={{ color: style.fontColor, fontSize: FONT_SIZE_MAP[style.fontSize] * 0.75, textAlign: "center" }}
|
|
182
|
-
>
|
|
60
|
+
<View style={{ height: 72, backgroundColor: tokens.colors.surfaceVariant, borderRadius: tokens.borders.radius.md, marginTop: tokens.spacing.md, justifyContent: "center", alignItems: "center", borderWidth: 1, borderColor: tokens.colors.border, overflow: "hidden" }}>
|
|
61
|
+
<View style={{ paddingHorizontal: tokens.spacing.md, paddingVertical: tokens.spacing.xs, borderRadius: tokens.borders.radius.sm, backgroundColor: style.backgroundColor }}>
|
|
62
|
+
<AtomicText style={{ color: style.fontColor, fontSize: FONT_SIZE_MAP[style.fontSize] * 0.75, textAlign: "center" }}>
|
|
183
63
|
{previewText || t("subtitle.preview.placeholder") || "Preview text"}
|
|
184
64
|
</AtomicText>
|
|
185
65
|
</View>
|
|
186
66
|
</View>
|
|
187
|
-
</
|
|
67
|
+
</ScrollView>
|
|
188
68
|
);
|
|
189
69
|
};
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
|
|
6
6
|
import React from "react";
|
|
7
7
|
import { View, ScrollView, TouchableOpacity } from "react-native";
|
|
8
|
-
import { AtomicText } from "@umituz/react-native-design-system/atoms";
|
|
9
8
|
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
10
9
|
import { COLLAGE_LAYOUTS } from "../../../infrastructure/constants/collage.constants";
|
|
11
10
|
import type { CollageLayout } from "../../../infrastructure/constants/collage.constants";
|
|
@@ -93,9 +92,6 @@ export const CollageLayoutSelector: React.FC<CollageLayoutSelectorProps> = ({
|
|
|
93
92
|
);
|
|
94
93
|
})}
|
|
95
94
|
</View>
|
|
96
|
-
<AtomicText type="caption" color="textSecondary">
|
|
97
|
-
{layout.name}
|
|
98
|
-
</AtomicText>
|
|
99
95
|
</TouchableOpacity>
|
|
100
96
|
))}
|
|
101
97
|
</ScrollView>
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* LayerContent Component
|
|
3
3
|
* Renders different layer types (text, image, shape)
|
|
4
|
+
* PERFORMANCE: Uses expo-image for better caching and memory management
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import React from "react";
|
|
7
|
-
import { View,
|
|
8
|
+
import { View, Text as RNText, StyleSheet } from "react-native";
|
|
9
|
+
import { Image } from "expo-image";
|
|
8
10
|
import { AtomicIcon } from "@umituz/react-native-design-system/atoms";
|
|
9
11
|
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
10
12
|
import type { Layer, TextLayer, ImageLayer, ShapeLayer } from "../../../domain/entities/video-project.types";
|
|
@@ -46,7 +48,10 @@ export const LayerContent: React.FC<LayerContentProps> = ({ layer }) => {
|
|
|
46
48
|
<Image
|
|
47
49
|
source={{ uri: imageLayer.uri }}
|
|
48
50
|
style={styles.layerImage}
|
|
49
|
-
|
|
51
|
+
contentFit="cover"
|
|
52
|
+
// PERFORMANCE: Cache strategy for better performance
|
|
53
|
+
cachePolicy="memory-disk"
|
|
54
|
+
transition={200} // Smooth fade-in
|
|
50
55
|
/>
|
|
51
56
|
) : (
|
|
52
57
|
<View style={[styles.imagePlaceholder, { backgroundColor: tokens.colors.surface }]}>
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic Action Menu Component
|
|
3
|
+
* Single Responsibility: Display action menu with selectable items
|
|
4
|
+
* Replaces: LayerActionsMenu, SceneActionsMenu, SubtitleListPanel actions
|
|
5
|
+
*
|
|
6
|
+
* Features:
|
|
7
|
+
* - Vertical list of actions
|
|
8
|
+
* - Icons + labels
|
|
9
|
+
* - Disabled states
|
|
10
|
+
* - Destructive action styling
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import React, { useMemo } from "react";
|
|
14
|
+
import { View, TouchableOpacity, StyleSheet } from "react-native";
|
|
15
|
+
import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system/atoms";
|
|
16
|
+
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
17
|
+
|
|
18
|
+
export interface ActionMenuItem {
|
|
19
|
+
id: string;
|
|
20
|
+
label: string;
|
|
21
|
+
icon?: string;
|
|
22
|
+
destructive?: boolean;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
testID?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ActionMenuProps {
|
|
28
|
+
actions: ActionMenuItem[];
|
|
29
|
+
onSelect: (actionId: string) => void;
|
|
30
|
+
testID?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Generic action menu that can render any set of actions
|
|
35
|
+
* Used for layer actions, scene actions, etc.
|
|
36
|
+
*/
|
|
37
|
+
export function ActionMenu({
|
|
38
|
+
actions,
|
|
39
|
+
onSelect,
|
|
40
|
+
testID = "action-menu",
|
|
41
|
+
}: ActionMenuProps) {
|
|
42
|
+
const tokens = useAppDesignTokens();
|
|
43
|
+
|
|
44
|
+
const styles = useMemo(() => StyleSheet.create({
|
|
45
|
+
container: {
|
|
46
|
+
backgroundColor: tokens.colors.surface,
|
|
47
|
+
borderRadius: tokens.borders.radius.lg,
|
|
48
|
+
padding: tokens.spacing.xs,
|
|
49
|
+
},
|
|
50
|
+
actionItem: {
|
|
51
|
+
flexDirection: "row" as const,
|
|
52
|
+
alignItems: "center" as const,
|
|
53
|
+
padding: tokens.spacing.md,
|
|
54
|
+
borderRadius: tokens.borders.radius.md,
|
|
55
|
+
gap: tokens.spacing.md,
|
|
56
|
+
},
|
|
57
|
+
actionItemDestructive: {
|
|
58
|
+
backgroundColor: `${tokens.colors.error}10`,
|
|
59
|
+
},
|
|
60
|
+
actionItemDisabled: {
|
|
61
|
+
opacity: 0.4,
|
|
62
|
+
},
|
|
63
|
+
icon: {
|
|
64
|
+
width: 24,
|
|
65
|
+
height: 24,
|
|
66
|
+
alignItems: "center" as const,
|
|
67
|
+
justifyContent: "center" as const,
|
|
68
|
+
},
|
|
69
|
+
label: {
|
|
70
|
+
flex: 1,
|
|
71
|
+
},
|
|
72
|
+
}), [tokens]);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<View style={styles.container} testID={testID}>
|
|
76
|
+
{actions.map((action) => (
|
|
77
|
+
<TouchableOpacity
|
|
78
|
+
key={action.id}
|
|
79
|
+
style={[
|
|
80
|
+
styles.actionItem,
|
|
81
|
+
action.destructive && styles.actionItemDestructive,
|
|
82
|
+
action.disabled && styles.actionItemDisabled,
|
|
83
|
+
]}
|
|
84
|
+
onPress={() => onSelect(action.id)}
|
|
85
|
+
disabled={action.disabled}
|
|
86
|
+
testID={action.testID}
|
|
87
|
+
accessibilityRole="button"
|
|
88
|
+
accessibilityLabel={action.label}
|
|
89
|
+
>
|
|
90
|
+
{action.icon && (
|
|
91
|
+
<View style={styles.icon}>
|
|
92
|
+
<AtomicIcon
|
|
93
|
+
name={action.icon}
|
|
94
|
+
size="sm"
|
|
95
|
+
color={action.destructive ? "error" : "textPrimary"}
|
|
96
|
+
/>
|
|
97
|
+
</View>
|
|
98
|
+
)}
|
|
99
|
+
<AtomicText
|
|
100
|
+
style={styles.label}
|
|
101
|
+
color={action.destructive ? "error" : "textPrimary"}
|
|
102
|
+
fontWeight="medium"
|
|
103
|
+
>
|
|
104
|
+
{action.label}
|
|
105
|
+
</AtomicText>
|
|
106
|
+
</TouchableOpacity>
|
|
107
|
+
))}
|
|
108
|
+
</View>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Universal Editor Component
|
|
3
|
+
* Replaces: ImageLayerEditor, TextLayerEditor, ShapeLayerEditor, AudioEditor, AnimationEditor
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React 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
|
+
|
|
11
|
+
export interface EditorSection {
|
|
12
|
+
id: string;
|
|
13
|
+
title?: string;
|
|
14
|
+
component: React.ComponentType<any>;
|
|
15
|
+
props?: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface EditorConfig<T = unknown> {
|
|
19
|
+
sections: EditorSection[];
|
|
20
|
+
preview?: React.ComponentType<{ data: T }>;
|
|
21
|
+
actions?: { saveLabel?: string; cancelLabel?: string; onSave: () => void | Promise<void>; onCancel: () => void };
|
|
22
|
+
isValid?: boolean;
|
|
23
|
+
testID?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function Editor<T = unknown>({ sections, preview: Preview, actions, isValid = true, testID = "editor" }: EditorConfig<T>) {
|
|
27
|
+
const tokens = useAppDesignTokens();
|
|
28
|
+
const styles = StyleSheet.create({
|
|
29
|
+
container: { flex: 1, backgroundColor: tokens.colors.surface },
|
|
30
|
+
scrollContent: { padding: tokens.spacing.md, paddingBottom: tokens.spacing.xl },
|
|
31
|
+
section: { marginBottom: tokens.spacing.lg },
|
|
32
|
+
previewContainer: { margin: tokens.spacing.md, borderRadius: tokens.borders.radius.lg, overflow: "hidden", backgroundColor: tokens.colors.surfaceVariant },
|
|
33
|
+
actionsContainer: { padding: tokens.spacing.md, borderTopWidth: 1, borderTopColor: tokens.colors.border },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<View style={styles.container} testID={testID}>
|
|
38
|
+
<ScrollView style={styles.container} contentContainerStyle={styles.scrollContent}>
|
|
39
|
+
{Preview && <View style={styles.previewContainer}><Preview data={undefined as T} /></View>}
|
|
40
|
+
{sections.map((section) => (
|
|
41
|
+
<View key={section.id} style={styles.section}>
|
|
42
|
+
{section.title && <AtomicText type="labelMedium" color="textSecondary" style={{ marginBottom: tokens.spacing.sm }}>{section.title}</AtomicText>}
|
|
43
|
+
<section.component {...section.props} />
|
|
44
|
+
</View>
|
|
45
|
+
))}
|
|
46
|
+
</ScrollView>
|
|
47
|
+
{actions && (
|
|
48
|
+
<View style={styles.actionsContainer}>
|
|
49
|
+
<View style={{ flexDirection: "row", gap: tokens.spacing.md }}>
|
|
50
|
+
<TouchableOpacity style={{ flex: 1, paddingVertical: tokens.spacing.md, borderRadius: tokens.borders.radius.md, alignItems: "center", backgroundColor: tokens.colors.surfaceVariant }} onPress={actions.onCancel}>
|
|
51
|
+
<AtomicText fontWeight="medium" color="textPrimary">{actions.cancelLabel || "Cancel"}</AtomicText>
|
|
52
|
+
</TouchableOpacity>
|
|
53
|
+
<TouchableOpacity
|
|
54
|
+
style={{ flex: 1, paddingVertical: tokens.spacing.md, borderRadius: tokens.borders.radius.md, alignItems: "center", backgroundColor: tokens.colors.primary, opacity: isValid ? 1 : 0.4 }}
|
|
55
|
+
onPress={actions.onSave}
|
|
56
|
+
disabled={!isValid}
|
|
57
|
+
>
|
|
58
|
+
<AtomicText fontWeight="semibold" color={isValid ? "onPrimary" : "textSecondary"}>{actions.saveLabel || "Save"}</AtomicText>
|
|
59
|
+
</TouchableOpacity>
|
|
60
|
+
</View>
|
|
61
|
+
</View>
|
|
62
|
+
)}
|
|
63
|
+
</View>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Universal Selector Component
|
|
3
|
+
* Replaces: OptionSelector, FontSizeSelector, TextAlignSelector, ShapeTypeSelector, etc.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { useMemo } from "react";
|
|
7
|
+
import { View, ScrollView, TouchableOpacity, StyleSheet } 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
|
+
export interface SelectorItem<T = string> {
|
|
12
|
+
value: T;
|
|
13
|
+
label: string;
|
|
14
|
+
icon?: string;
|
|
15
|
+
color?: string;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SelectorProps<T = string> {
|
|
20
|
+
items: SelectorItem<T>[];
|
|
21
|
+
selectedValue: T;
|
|
22
|
+
onSelect: (value: T) => void;
|
|
23
|
+
orientation?: "horizontal" | "vertical" | "grid";
|
|
24
|
+
itemWidth?: number;
|
|
25
|
+
itemHeight?: number;
|
|
26
|
+
icon?: boolean;
|
|
27
|
+
colorPreview?: boolean;
|
|
28
|
+
testID?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SelectorItem = React.memo<{ item: SelectorItem; isSelected: boolean; onSelect: () => void; styles: any; icon: boolean; colorPreview: boolean }>(
|
|
32
|
+
({ item, isSelected, onSelect, styles, icon, colorPreview }) => (
|
|
33
|
+
<TouchableOpacity
|
|
34
|
+
style={[styles.item, isSelected && styles.itemSelected, item.disabled && styles.itemDisabled]}
|
|
35
|
+
onPress={onSelect}
|
|
36
|
+
disabled={item.disabled}
|
|
37
|
+
accessibilityRole="button"
|
|
38
|
+
accessibilityState={{ selected: isSelected }}
|
|
39
|
+
accessibilityLabel={item.label}
|
|
40
|
+
>
|
|
41
|
+
{colorPreview && item.color && <View style={[styles.colorPreview, { backgroundColor: item.color }]} />}
|
|
42
|
+
{icon && item.icon && <AtomicIcon name={item.icon} size="sm" color="textPrimary" />}
|
|
43
|
+
<AtomicText type="labelSmall" style={styles.label} color={isSelected ? "primary" : "textPrimary"}>{item.label}</AtomicText>
|
|
44
|
+
</TouchableOpacity>
|
|
45
|
+
)
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
SelectorItem.displayName = "SelectorItem";
|
|
49
|
+
|
|
50
|
+
export function Selector<T = string>({
|
|
51
|
+
items,
|
|
52
|
+
selectedValue,
|
|
53
|
+
onSelect,
|
|
54
|
+
orientation = "horizontal",
|
|
55
|
+
itemWidth = 80,
|
|
56
|
+
itemHeight = 40,
|
|
57
|
+
icon = false,
|
|
58
|
+
colorPreview = false,
|
|
59
|
+
testID = "selector",
|
|
60
|
+
}: SelectorProps<T>) {
|
|
61
|
+
const tokens = useAppDesignTokens();
|
|
62
|
+
const styles = useMemo(() => StyleSheet.create({
|
|
63
|
+
container: { gap: tokens.spacing.sm },
|
|
64
|
+
scrollContent: { gap: tokens.spacing.sm, paddingHorizontal: orientation === "horizontal" ? tokens.spacing.md : 0 },
|
|
65
|
+
gridContainer: { flexDirection: "row", flexWrap: "wrap", gap: tokens.spacing.sm },
|
|
66
|
+
item: { width: orientation === "grid" ? undefined : itemWidth, height: itemHeight, borderRadius: tokens.borders.radius.md, borderWidth: 1, borderColor: tokens.colors.border, backgroundColor: tokens.colors.surface, alignItems: "center", justifyContent: "center", paddingHorizontal: tokens.spacing.md },
|
|
67
|
+
itemSelected: { borderColor: tokens.colors.primary, backgroundColor: `${tokens.colors.primary}20` },
|
|
68
|
+
itemDisabled: { opacity: 0.4 },
|
|
69
|
+
label: { color: tokens.colors.textPrimary },
|
|
70
|
+
colorPreview: { width: 24, height: 24, borderRadius: 12 },
|
|
71
|
+
}), [tokens, orientation, itemWidth, itemHeight]);
|
|
72
|
+
|
|
73
|
+
const content = items.map((item) => (
|
|
74
|
+
<SelectorItem
|
|
75
|
+
key={String(item.value)}
|
|
76
|
+
item={item}
|
|
77
|
+
isSelected={item.value === selectedValue}
|
|
78
|
+
onSelect={() => onSelect(item.value)}
|
|
79
|
+
styles={styles}
|
|
80
|
+
icon={icon}
|
|
81
|
+
colorPreview={colorPreview}
|
|
82
|
+
/>
|
|
83
|
+
));
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<View style={styles.container} testID={testID}>
|
|
87
|
+
{orientation === "horizontal" ? (
|
|
88
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>
|
|
89
|
+
{content}
|
|
90
|
+
</ScrollView>
|
|
91
|
+
) : (
|
|
92
|
+
<View style={styles.gridContainer}>{content}</View>
|
|
93
|
+
)}
|
|
94
|
+
</View>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic Toolbar Component
|
|
3
|
+
* Replaces: EditorToolPanel and similar toolbar patterns
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { useMemo } from "react";
|
|
7
|
+
import { View, ScrollView, TouchableOpacity, StyleSheet } 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
|
+
export interface ToolbarButton {
|
|
12
|
+
id: string;
|
|
13
|
+
icon: string;
|
|
14
|
+
label: string;
|
|
15
|
+
onPress: () => void;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
destructive?: boolean;
|
|
18
|
+
showBadge?: boolean;
|
|
19
|
+
badgeColor?: string;
|
|
20
|
+
testID?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ToolbarSection {
|
|
24
|
+
id: string;
|
|
25
|
+
buttons: ToolbarButton[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ToolbarProps {
|
|
29
|
+
sections: ToolbarSection[];
|
|
30
|
+
orientation?: "horizontal" | "vertical";
|
|
31
|
+
scrollable?: boolean;
|
|
32
|
+
testID?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ToolbarButtonComp = React.memo<{ button: ToolbarButton; styles: any }>(({ button, styles }) => (
|
|
36
|
+
<TouchableOpacity
|
|
37
|
+
style={[styles.button, button.destructive && styles.buttonDestructive, button.disabled && styles.buttonDisabled]}
|
|
38
|
+
onPress={button.onPress}
|
|
39
|
+
disabled={button.disabled}
|
|
40
|
+
testID={button.testID}
|
|
41
|
+
accessibilityRole="button"
|
|
42
|
+
accessibilityLabel={button.label}
|
|
43
|
+
>
|
|
44
|
+
<AtomicIcon name={button.icon as any} size="sm" color={button.destructive ? "error" : "primary"} />
|
|
45
|
+
<AtomicText type="labelSmall" style={styles.label}>{button.label}</AtomicText>
|
|
46
|
+
{button.showBadge && <View style={[styles.badge, { backgroundColor: button.badgeColor }]}><AtomicText style={styles.badgeText}>!</AtomicText></View>}
|
|
47
|
+
</TouchableOpacity>
|
|
48
|
+
));
|
|
49
|
+
|
|
50
|
+
ToolbarButtonComp.displayName = "ToolbarButton";
|
|
51
|
+
|
|
52
|
+
export function Toolbar({ sections, orientation = "horizontal", scrollable = false, testID = "toolbar" }: ToolbarProps) {
|
|
53
|
+
const tokens = useAppDesignTokens();
|
|
54
|
+
const styles = useMemo(() => StyleSheet.create({
|
|
55
|
+
container: { backgroundColor: tokens.colors.surface, borderRadius: tokens.borders.radius.lg, padding: tokens.spacing.sm },
|
|
56
|
+
scrollContent: { gap: tokens.spacing.sm, paddingHorizontal: tokens.spacing.sm },
|
|
57
|
+
sectionContainer: { gap: tokens.spacing.sm },
|
|
58
|
+
button: { alignItems: "center", padding: tokens.spacing.sm, borderRadius: tokens.borders.radius.md, gap: tokens.spacing.xs, backgroundColor: tokens.colors.surfaceVariant, borderWidth: 1, borderColor: tokens.colors.border, minWidth: orientation === "horizontal" ? 60 : 50 },
|
|
59
|
+
buttonDestructive: { backgroundColor: `${tokens.colors.error}10`, borderColor: tokens.colors.error },
|
|
60
|
+
buttonDisabled: { opacity: 0.4 },
|
|
61
|
+
label: { color: tokens.colors.textPrimary },
|
|
62
|
+
badge: { position: "absolute", top: -4, right: -4, minWidth: 16, height: 16, borderRadius: 8, borderWidth: 2, borderColor: tokens.colors.surface, alignItems: "center", justifyContent: "center" },
|
|
63
|
+
badgeText: { color: tokens.colors.onPrimary, fontSize: 10, fontWeight: "bold" },
|
|
64
|
+
}), [tokens, orientation]);
|
|
65
|
+
|
|
66
|
+
const content = sections.map((section) => (
|
|
67
|
+
<View key={section.id} style={styles.sectionContainer}>
|
|
68
|
+
{section.buttons.map((button) => <ToolbarButtonComp key={button.id} button={button} styles={styles} />)}
|
|
69
|
+
</View>
|
|
70
|
+
));
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<View style={styles.container} testID={testID}>
|
|
74
|
+
{scrollable ? <ScrollView horizontal={orientation === "horizontal"} showsHorizontalScrollIndicator={false} contentContainerStyle={styles.scrollContent}>{content}</ScrollView> : content}
|
|
75
|
+
</View>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ImagePreview Component
|
|
3
3
|
* Image preview or placeholder for image layer editor
|
|
4
|
+
* PERFORMANCE: Uses expo-image with caching for better performance
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import React from "react";
|
|
7
|
-
import { View,
|
|
8
|
+
import { View, StyleSheet } from "react-native";
|
|
9
|
+
import { Image } from "expo-image";
|
|
8
10
|
import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system/atoms";
|
|
9
11
|
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
10
12
|
|
|
@@ -24,6 +26,10 @@ export const ImagePreview: React.FC<ImagePreviewProps> = ({
|
|
|
24
26
|
<Image
|
|
25
27
|
source={{ uri: imageUri }}
|
|
26
28
|
style={[styles.imagePreview, { opacity }]}
|
|
29
|
+
// PERFORMANCE: Cache strategy for better performance
|
|
30
|
+
cachePolicy="memory-disk"
|
|
31
|
+
transition={200} // Smooth fade-in
|
|
32
|
+
placeholder="#F0F0F0" // Placeholder color while loading
|
|
27
33
|
/>
|
|
28
34
|
);
|
|
29
35
|
}
|