@umituz/react-native-video-editor 1.1.64 → 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/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 +54 -27
- package/src/presentation/components/SubtitleStylePicker.tsx +36 -156
- 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/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/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/useMenuActions.tsx +19 -4
|
@@ -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
|
};
|
|
@@ -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
|
}
|