@igorpache/expo-video-editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.prettierrc +8 -0
  2. package/LICENSE +21 -0
  3. package/README.md +117 -0
  4. package/android/build.gradle +27 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/expo/modules/videoeditor/ColorMatrixEffect.kt +25 -0
  7. package/android/src/main/java/expo/modules/videoeditor/ExpoVideoEditorModule.kt +61 -0
  8. package/android/src/main/java/expo/modules/videoeditor/ExpoVideoEditorView.kt +109 -0
  9. package/android/src/main/java/expo/modules/videoeditor/ExportRunner.kt +302 -0
  10. package/android/src/main/java/expo/modules/videoeditor/GifOverlay.kt +27 -0
  11. package/android/src/main/java/expo/modules/videoeditor/Records.kt +44 -0
  12. package/eslint.config.cjs +16 -0
  13. package/expo-module.config.json +9 -0
  14. package/ios/AudioMixBuilder.swift +66 -0
  15. package/ios/ColorMatrixFilter.swift +24 -0
  16. package/ios/ExpoVideoEditor.podspec +23 -0
  17. package/ios/ExpoVideoEditorModule.swift +55 -0
  18. package/ios/ExpoVideoEditorView.swift +144 -0
  19. package/ios/ExportRunner.swift +135 -0
  20. package/ios/FrameComposer.swift +94 -0
  21. package/ios/ImageVideoWriter.swift +144 -0
  22. package/ios/Records.swift +41 -0
  23. package/package.json +36 -0
  24. package/src/components/EditorRoot.tsx +180 -0
  25. package/src/components/ExportOverlay.tsx +44 -0
  26. package/src/components/PreviewSurface.tsx +49 -0
  27. package/src/components/VideoEditor.tsx +51 -0
  28. package/src/components/audio/AudioPanel.tsx +69 -0
  29. package/src/components/audio/AudioScrubber.tsx +177 -0
  30. package/src/components/audio/Slider.tsx +60 -0
  31. package/src/components/filters/FilterCarousel.tsx +44 -0
  32. package/src/components/giphy/GiphyGrid.tsx +48 -0
  33. package/src/components/giphy/GiphyPicker.tsx +112 -0
  34. package/src/components/overlays/GifOverlayView.tsx +53 -0
  35. package/src/components/overlays/OverlayLayer.tsx +24 -0
  36. package/src/components/overlays/TextEditorSheet.tsx +135 -0
  37. package/src/components/overlays/TextOverlayView.tsx +61 -0
  38. package/src/components/timeline/ThumbnailStrip.tsx +63 -0
  39. package/src/components/timeline/TrimTimeline.tsx +152 -0
  40. package/src/errors.ts +33 -0
  41. package/src/filters/presets.ts +84 -0
  42. package/src/hooks/useAudioPreview.ts +94 -0
  43. package/src/hooks/useExport.ts +41 -0
  44. package/src/hooks/useOverlayGestures.ts +90 -0
  45. package/src/index.ts +29 -0
  46. package/src/native/EditorVideoView.tsx +27 -0
  47. package/src/native/VideoEditorModule.ts +51 -0
  48. package/src/overlays/captureRegistry.ts +12 -0
  49. package/src/services/demoCatalog.ts +30 -0
  50. package/src/services/exportService.ts +199 -0
  51. package/src/services/feedfmCatalog.ts +44 -0
  52. package/src/services/giphyClient.ts +69 -0
  53. package/src/services/mediaCache.ts +18 -0
  54. package/src/services/musicCatalog.ts +23 -0
  55. package/src/state/store.ts +200 -0
  56. package/src/theme.ts +26 -0
  57. package/src/types.ts +104 -0
  58. package/tsconfig.json +28 -0
@@ -0,0 +1,177 @@
1
+ import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { Pressable, StyleSheet, Text, View } from 'react-native';
4
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
5
+ import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from 'react-native-reanimated';
6
+
7
+ import { useEditorStore } from '../../state/store';
8
+ import { useTheme } from '../../theme';
9
+
10
+ const HANDLE_W = 20;
11
+ const TRACK_H = 56;
12
+ const MIN_GAP_S = 0.5;
13
+
14
+ export function AudioScrubber() {
15
+ const audio = useEditorStore((s) => s.audio);
16
+ const updateAudio = useEditorStore((s) => s.updateAudio);
17
+ const theme = useTheme();
18
+
19
+ const player = useAudioPlayer(audio ? { uri: audio.uri } : null);
20
+ const status = useAudioPlayerStatus(player);
21
+ const [playing, setPlaying] = useState(false);
22
+ const [w, setW] = useState(0);
23
+
24
+ const duration = audio?.duration && audio.duration > 0 ? audio.duration : status.duration || 0;
25
+
26
+ useEffect(() => {
27
+ if (audio && (!audio.duration || audio.duration <= 0) && status.duration > 0) {
28
+ updateAudio({
29
+ duration: status.duration,
30
+ trimEnd: audio.trimEnd > 0 ? audio.trimEnd : status.duration,
31
+ });
32
+ }
33
+ }, [status.duration, audio, updateAudio]);
34
+
35
+ const startX = useSharedValue(0);
36
+ const endX = useSharedValue(0);
37
+ const baseStart = useSharedValue(0);
38
+ const baseEnd = useSharedValue(0);
39
+ const draggingRef = useRef(false);
40
+
41
+ const pxPerSec = duration > 0 && w > 0 ? w / duration : 0;
42
+ const minGapPx = MIN_GAP_S * pxPerSec;
43
+
44
+ useEffect(() => {
45
+ if (draggingRef.current || pxPerSec === 0 || !audio) return;
46
+ startX.value = audio.trimStart * pxPerSec;
47
+ endX.value = audio.trimEnd * pxPerSec;
48
+ }, [audio, pxPerSec, startX, endX]);
49
+
50
+ const setDragging = (v: boolean) => {
51
+ draggingRef.current = v;
52
+ };
53
+ const commit = () => {
54
+ if (pxPerSec > 0)
55
+ updateAudio({ trimStart: startX.value / pxPerSec, trimEnd: endX.value / pxPerSec });
56
+ };
57
+ const seekSolo = (px: number) => {
58
+ if (pxPerSec > 0) {
59
+ try {
60
+ player.seekTo(px / pxPerSec);
61
+ } catch {}
62
+ }
63
+ };
64
+
65
+ const startPan = Gesture.Pan()
66
+ .onStart(() => {
67
+ baseStart.value = startX.value;
68
+ runOnJS(setDragging)(true);
69
+ })
70
+ .onUpdate((e) => {
71
+ let v = baseStart.value + e.translationX;
72
+ v = Math.max(0, Math.min(v, endX.value - minGapPx));
73
+ startX.value = v;
74
+ runOnJS(seekSolo)(v);
75
+ })
76
+ .onEnd(() => {
77
+ runOnJS(commit)();
78
+ runOnJS(setDragging)(false);
79
+ });
80
+
81
+ const endPan = Gesture.Pan()
82
+ .onStart(() => {
83
+ baseEnd.value = endX.value;
84
+ runOnJS(setDragging)(true);
85
+ })
86
+ .onUpdate((e) => {
87
+ let v = baseEnd.value + e.translationX;
88
+ v = Math.min(w, Math.max(v, startX.value + minGapPx));
89
+ endX.value = v;
90
+ })
91
+ .onEnd(() => {
92
+ runOnJS(commit)();
93
+ runOnJS(setDragging)(false);
94
+ });
95
+
96
+ useEffect(() => {
97
+ if (!playing || !audio) return;
98
+ const id = setInterval(() => {
99
+ try {
100
+ if (player.currentTime >= audio.trimEnd || player.currentTime < audio.trimStart - 0.1) {
101
+ player.seekTo(audio.trimStart);
102
+ }
103
+ } catch {}
104
+ }, 150);
105
+ return () => clearInterval(id);
106
+ }, [playing, audio, player]);
107
+
108
+ useEffect(
109
+ () => () => {
110
+ try {
111
+ player.pause();
112
+ } catch {}
113
+ },
114
+ [player]
115
+ );
116
+
117
+ const togglePlay = () => {
118
+ try {
119
+ if (playing) {
120
+ player.pause();
121
+ setPlaying(false);
122
+ } else {
123
+ player.seekTo(audio?.trimStart ?? 0);
124
+ player.play();
125
+ setPlaying(true);
126
+ }
127
+ } catch {}
128
+ };
129
+
130
+ const selStyle = useAnimatedStyle(() => ({
131
+ left: startX.value,
132
+ width: Math.max(0, endX.value - startX.value),
133
+ }));
134
+ const leftHandle = useAnimatedStyle(() => ({ left: startX.value - HANDLE_W }));
135
+ const rightHandle = useAnimatedStyle(() => ({ left: endX.value }));
136
+
137
+ return (
138
+ <View style={styles.row}>
139
+ <Pressable onPress={togglePlay} style={[styles.playBtn, { backgroundColor: theme.accent }]}>
140
+ <Text style={styles.playIcon}>{playing ? '❚❚' : '▶'}</Text>
141
+ </Pressable>
142
+ <View
143
+ style={[styles.track, { backgroundColor: theme.surface }]}
144
+ onLayout={(e) => setW(e.nativeEvent.layout.width)}>
145
+ <Animated.View
146
+ style={[
147
+ styles.selection,
148
+ { borderColor: theme.accent, backgroundColor: 'rgba(124,92,255,0.18)' },
149
+ selStyle,
150
+ ]}
151
+ pointerEvents="none"
152
+ />
153
+ <GestureDetector gesture={startPan}>
154
+ <Animated.View style={[styles.handle, { backgroundColor: theme.accent }, leftHandle]} />
155
+ </GestureDetector>
156
+ <GestureDetector gesture={endPan}>
157
+ <Animated.View style={[styles.handle, { backgroundColor: theme.accent }, rightHandle]} />
158
+ </GestureDetector>
159
+ </View>
160
+ </View>
161
+ );
162
+ }
163
+
164
+ const styles = StyleSheet.create({
165
+ row: { flexDirection: 'row', alignItems: 'center', gap: 12 },
166
+ playBtn: {
167
+ width: 44,
168
+ height: 44,
169
+ borderRadius: 22,
170
+ alignItems: 'center',
171
+ justifyContent: 'center',
172
+ },
173
+ playIcon: { color: '#fff', fontSize: 16 },
174
+ track: { flex: 1, height: TRACK_H, borderRadius: 8, justifyContent: 'center' },
175
+ selection: { position: 'absolute', top: 0, bottom: 0, borderWidth: 2, borderRadius: 8 },
176
+ handle: { position: 'absolute', top: 0, bottom: 0, width: HANDLE_W, borderRadius: 6 },
177
+ });
@@ -0,0 +1,60 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { StyleSheet, View } from 'react-native';
3
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
4
+ import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from 'react-native-reanimated';
5
+
6
+ import { useTheme } from '../../theme';
7
+
8
+ const THUMB = 18;
9
+
10
+ export function Slider({ value, onChange }: { value: number; onChange: (v: number) => void }) {
11
+ const theme = useTheme();
12
+ const [w, setW] = useState(0);
13
+ const x = useSharedValue(0);
14
+ const start = useSharedValue(0);
15
+
16
+ useEffect(() => {
17
+ if (w > 0) x.value = Math.max(0, Math.min(1, value)) * w;
18
+ }, [value, w, x]);
19
+
20
+ const commit = (px: number) => {
21
+ if (w > 0) onChange(Math.max(0, Math.min(1, px / w)));
22
+ };
23
+
24
+ const pan = Gesture.Pan()
25
+ .onStart(() => {
26
+ start.value = x.value;
27
+ })
28
+ .onUpdate((e) => {
29
+ x.value = Math.max(0, Math.min(w, start.value + e.translationX));
30
+ runOnJS(commit)(x.value);
31
+ })
32
+ .onEnd(() => runOnJS(commit)(x.value));
33
+
34
+ const fill = useAnimatedStyle(() => ({ width: x.value }));
35
+ const thumb = useAnimatedStyle(() => ({ transform: [{ translateX: x.value - THUMB / 2 }] }));
36
+
37
+ return (
38
+ <GestureDetector gesture={pan}>
39
+ <View style={styles.track} onLayout={(e) => setW(e.nativeEvent.layout.width)}>
40
+ <View style={styles.bg} />
41
+ <Animated.View style={[styles.fill, { backgroundColor: theme.accent }, fill]} />
42
+ <Animated.View style={[styles.thumb, { backgroundColor: theme.accent }, thumb]} />
43
+ </View>
44
+ </GestureDetector>
45
+ );
46
+ }
47
+
48
+ const styles = StyleSheet.create({
49
+ track: { height: THUMB + 12, justifyContent: 'center' },
50
+ bg: {
51
+ position: 'absolute',
52
+ left: 0,
53
+ right: 0,
54
+ height: 4,
55
+ borderRadius: 2,
56
+ backgroundColor: 'rgba(255,255,255,0.2)',
57
+ },
58
+ fill: { position: 'absolute', left: 0, height: 4, borderRadius: 2 },
59
+ thumb: { width: THUMB, height: THUMB, borderRadius: THUMB / 2 },
60
+ });
@@ -0,0 +1,44 @@
1
+ import { Pressable, ScrollView, StyleSheet, Text } from 'react-native';
2
+
3
+ import { FILTER_PRESETS } from '../../filters/presets';
4
+ import { useEditorStore } from '../../state/store';
5
+ import { useTheme } from '../../theme';
6
+
7
+ export function FilterCarousel() {
8
+ const filter = useEditorStore((s) => s.filter);
9
+ const setFilter = useEditorStore((s) => s.setFilter);
10
+ const theme = useTheme();
11
+
12
+ return (
13
+ <ScrollView
14
+ horizontal
15
+ showsHorizontalScrollIndicator={false}
16
+ contentContainerStyle={styles.row}>
17
+ {FILTER_PRESETS.map((p) => {
18
+ const active = p.key === filter;
19
+ return (
20
+ <Pressable
21
+ key={p.key}
22
+ onPress={() => setFilter(p.key)}
23
+ style={[
24
+ styles.chip,
25
+ {
26
+ backgroundColor: theme.surface,
27
+ borderColor: active ? theme.accent : 'transparent',
28
+ },
29
+ ]}>
30
+ <Text style={[styles.label, { color: active ? theme.accent : theme.textMuted }]}>
31
+ {p.label}
32
+ </Text>
33
+ </Pressable>
34
+ );
35
+ })}
36
+ </ScrollView>
37
+ );
38
+ }
39
+
40
+ const styles = StyleSheet.create({
41
+ row: { gap: 8, paddingHorizontal: 4, paddingVertical: 2 },
42
+ chip: { paddingHorizontal: 16, paddingVertical: 10, borderRadius: 16, borderWidth: 2 },
43
+ label: { fontSize: 13, fontWeight: '600' },
44
+ });
@@ -0,0 +1,48 @@
1
+ import { Image } from 'expo-image';
2
+ import { FlatList, Pressable, StyleSheet } from 'react-native';
3
+
4
+ import type { GiphyItem } from '../../services/giphyClient';
5
+
6
+ const COLUMNS = 3;
7
+ const GAP = 6;
8
+
9
+ export function GiphyGrid({
10
+ items,
11
+ onPick,
12
+ onEndReached,
13
+ }: {
14
+ items: GiphyItem[];
15
+ onPick: (item: GiphyItem) => void;
16
+ onEndReached: () => void;
17
+ }) {
18
+ return (
19
+ <FlatList
20
+ data={items}
21
+ keyExtractor={(it, i) => `${it.id}-${i}`}
22
+ numColumns={COLUMNS}
23
+ contentContainerStyle={styles.content}
24
+ columnWrapperStyle={styles.row}
25
+ onEndReached={onEndReached}
26
+ onEndReachedThreshold={0.6}
27
+ keyboardShouldPersistTaps="handled"
28
+ renderItem={({ item }) => (
29
+ <Pressable style={styles.cell} onPress={() => onPick(item)}>
30
+ <Image source={{ uri: item.previewUrl }} style={styles.img} contentFit="cover" />
31
+ </Pressable>
32
+ )}
33
+ />
34
+ );
35
+ }
36
+
37
+ const styles = StyleSheet.create({
38
+ content: { padding: GAP, gap: GAP },
39
+ row: { gap: GAP },
40
+ cell: {
41
+ flex: 1 / COLUMNS,
42
+ aspectRatio: 1,
43
+ borderRadius: 8,
44
+ overflow: 'hidden',
45
+ backgroundColor: '#222',
46
+ },
47
+ img: { width: '100%', height: '100%' },
48
+ });
@@ -0,0 +1,112 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import {
3
+ ActivityIndicator,
4
+ Modal,
5
+ Pressable,
6
+ StyleSheet,
7
+ Text,
8
+ TextInput,
9
+ View,
10
+ } from 'react-native';
11
+
12
+ import { GiphyGrid } from './GiphyGrid';
13
+ import { searchGifs, trendingGifs, type GiphyItem } from '../../services/giphyClient';
14
+ import { useEditorStore } from '../../state/store';
15
+ import { useTheme } from '../../theme';
16
+
17
+ const PAGE = 24;
18
+
19
+ export function GiphyPicker() {
20
+ const activePanel = useEditorStore((s) => s.activePanel);
21
+ const setActivePanel = useEditorStore((s) => s.setActivePanel);
22
+ const apiKey = useEditorStore((s) => s.giphyApiKey);
23
+ const addGifOverlay = useEditorStore((s) => s.addGifOverlay);
24
+ const theme = useTheme();
25
+
26
+ const [query, setQuery] = useState('');
27
+ const [items, setItems] = useState<GiphyItem[]>([]);
28
+ const [loading, setLoading] = useState(false);
29
+ const offsetRef = useRef(0);
30
+
31
+ const visible = activePanel === 'giphy' && !!apiKey;
32
+
33
+ const load = useCallback(
34
+ async (reset: boolean, q: string) => {
35
+ if (!apiKey) return;
36
+ setLoading(true);
37
+ try {
38
+ const offset = reset ? 0 : offsetRef.current;
39
+ const page = q
40
+ ? await searchGifs(apiKey, q, { offset, limit: PAGE })
41
+ : await trendingGifs(apiKey, { offset, limit: PAGE });
42
+ offsetRef.current = offset + PAGE;
43
+ setItems((prev) => (reset ? page : [...prev, ...page]));
44
+ } catch {
45
+ if (reset) setItems([]);
46
+ } finally {
47
+ setLoading(false);
48
+ }
49
+ },
50
+ [apiKey]
51
+ );
52
+
53
+ useEffect(() => {
54
+ if (!visible) return;
55
+ const t = setTimeout(() => load(true, query.trim()), query ? 350 : 0);
56
+ return () => clearTimeout(t);
57
+ }, [visible, query, load]);
58
+
59
+ if (!visible) return null;
60
+ const close = () => setActivePanel('none');
61
+
62
+ return (
63
+ <Modal transparent visible animationType="slide" onRequestClose={close}>
64
+ <View style={[styles.sheet, { backgroundColor: theme.background }]}>
65
+ <View style={styles.header}>
66
+ <TextInput
67
+ value={query}
68
+ onChangeText={setQuery}
69
+ placeholder="Search GIPHY"
70
+ placeholderTextColor={theme.textMuted}
71
+ autoCorrect={false}
72
+ style={[styles.input, { backgroundColor: theme.surface, color: theme.text }]}
73
+ />
74
+ <Pressable onPress={close} hitSlop={12}>
75
+ <Text style={[styles.close, { color: theme.accent }]}>Close</Text>
76
+ </Pressable>
77
+ </View>
78
+
79
+ <GiphyGrid
80
+ items={items}
81
+ onPick={(item) => {
82
+ addGifOverlay({
83
+ previewUri: item.previewUrl,
84
+ exportUri: item.originalUrl,
85
+ aspectRatio: item.height > 0 ? item.width / item.height : 1,
86
+ });
87
+ close();
88
+ }}
89
+ onEndReached={() => !loading && load(false, query.trim())}
90
+ />
91
+
92
+ {loading ? <ActivityIndicator style={styles.loader} color={theme.accent} /> : null}
93
+ <Text style={[styles.attribution, { color: theme.textMuted }]}>Powered by GIPHY</Text>
94
+ </View>
95
+ </Modal>
96
+ );
97
+ }
98
+
99
+ const styles = StyleSheet.create({
100
+ sheet: { flex: 1, paddingTop: 56 },
101
+ header: {
102
+ flexDirection: 'row',
103
+ alignItems: 'center',
104
+ gap: 12,
105
+ paddingHorizontal: 16,
106
+ paddingBottom: 10,
107
+ },
108
+ input: { flex: 1, height: 40, borderRadius: 20, paddingHorizontal: 16, fontSize: 15 },
109
+ close: { fontSize: 15, fontWeight: '700' },
110
+ loader: { position: 'absolute', top: '50%', alignSelf: 'center' },
111
+ attribution: { textAlign: 'center', fontSize: 11, paddingVertical: 8, letterSpacing: 1 },
112
+ });
@@ -0,0 +1,53 @@
1
+ import { Image } from 'expo-image';
2
+ import { StyleSheet, View } from 'react-native';
3
+ import { GestureDetector } from 'react-native-gesture-handler';
4
+ import Animated from 'react-native-reanimated';
5
+
6
+ import { useOverlayGestures } from '../../hooks/useOverlayGestures';
7
+ import { useEditorStore } from '../../state/store';
8
+ import { useTheme } from '../../theme';
9
+ import type { StickerOverlay } from '../../types';
10
+
11
+ const BASE_WIDTH = 120;
12
+
13
+ export function GifOverlayView({ overlay }: { overlay: StickerOverlay }) {
14
+ const { gesture, animatedStyle } = useOverlayGestures(overlay);
15
+ const updateOverlay = useEditorStore((s) => s.updateOverlay);
16
+ const selectOverlay = useEditorStore((s) => s.selectOverlay);
17
+ const selected = useEditorStore((s) => s.selectedOverlayId === overlay.id);
18
+ const theme = useTheme();
19
+
20
+ const width = BASE_WIDTH;
21
+ const height = BASE_WIDTH / (overlay.aspectRatio || 1);
22
+
23
+ return (
24
+ <GestureDetector gesture={gesture}>
25
+ <Animated.View style={[styles.wrapper, animatedStyle]}>
26
+ <View
27
+ collapsable={false}
28
+ onLayout={(e) => {
29
+ const { width: w, height: h } = e.nativeEvent.layout;
30
+ if (
31
+ Math.abs((overlay.baseWidth ?? 0) - w) > 0.5 ||
32
+ Math.abs((overlay.baseHeight ?? 0) - h) > 0.5
33
+ ) {
34
+ updateOverlay(overlay.id, { baseWidth: w, baseHeight: h });
35
+ }
36
+ }}
37
+ onTouchEnd={() => selectOverlay(overlay.id)}
38
+ style={[styles.content, selected && { borderColor: theme.accent }]}>
39
+ <Image
40
+ source={{ uri: overlay.previewUri }}
41
+ style={{ width, height }}
42
+ contentFit="contain"
43
+ />
44
+ </View>
45
+ </Animated.View>
46
+ </GestureDetector>
47
+ );
48
+ }
49
+
50
+ const styles = StyleSheet.create({
51
+ wrapper: { position: 'absolute', top: 0, left: 0 },
52
+ content: { borderWidth: 1, borderColor: 'transparent', borderRadius: 6 },
53
+ });
@@ -0,0 +1,24 @@
1
+ import { StyleSheet, View } from 'react-native';
2
+
3
+ import { GifOverlayView } from './GifOverlayView';
4
+ import { TextOverlayView } from './TextOverlayView';
5
+ import { useEditorStore } from '../../state/store';
6
+
7
+ export function OverlayLayer() {
8
+ const overlays = useEditorStore((s) => s.overlays);
9
+ const activePanel = useEditorStore((s) => s.activePanel);
10
+ const selectedOverlayId = useEditorStore((s) => s.selectedOverlayId);
11
+
12
+ return (
13
+ <View style={StyleSheet.absoluteFill} pointerEvents="box-none">
14
+ {overlays.map((o) => {
15
+ if (o.type === 'text' && activePanel === 'text' && o.id === selectedOverlayId) return null;
16
+ return o.type === 'text' ? (
17
+ <TextOverlayView key={o.id} overlay={o} />
18
+ ) : (
19
+ <GifOverlayView key={o.id} overlay={o} />
20
+ );
21
+ })}
22
+ </View>
23
+ );
24
+ }
@@ -0,0 +1,135 @@
1
+ import { Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
2
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
3
+
4
+ import { useEditorStore } from '../../state/store';
5
+ import { useTheme } from '../../theme';
6
+
7
+ const COLORS = [
8
+ '#ffffff',
9
+ '#000000',
10
+ '#ff5c7c',
11
+ '#7c5cff',
12
+ '#4cd964',
13
+ '#ffd60a',
14
+ '#5ac8fa',
15
+ '#ff9f0a',
16
+ ];
17
+ const ALIGNS = ['left', 'center', 'right'] as const;
18
+ const ALIGN_LABELS: Record<(typeof ALIGNS)[number], string> = {
19
+ left: 'Esquerda',
20
+ center: 'Centro',
21
+ right: 'Direita',
22
+ };
23
+
24
+ export function TextEditorSheet() {
25
+ const activePanel = useEditorStore((s) => s.activePanel);
26
+ const setActivePanel = useEditorStore((s) => s.setActivePanel);
27
+ const overlay = useEditorStore((s) => s.overlays.find((o) => o.id === s.selectedOverlayId));
28
+ const updateOverlay = useEditorStore((s) => s.updateOverlay);
29
+ const removeOverlay = useEditorStore((s) => s.removeOverlay);
30
+ const theme = useTheme();
31
+ const insets = useSafeAreaInsets();
32
+
33
+ if (activePanel !== 'text' || !overlay || overlay.type !== 'text') return null;
34
+ const close = () => setActivePanel('none');
35
+ const cycleAlign = () => {
36
+ const next = ALIGNS[(ALIGNS.indexOf(overlay.align) + 1) % ALIGNS.length];
37
+ updateOverlay(overlay.id, { align: next });
38
+ };
39
+ const toggleBg = () =>
40
+ updateOverlay(overlay.id, {
41
+ backgroundColor: overlay.backgroundColor ? undefined : 'rgba(0,0,0,0.5)',
42
+ });
43
+
44
+ return (
45
+ <Modal transparent visible animationType="fade" onRequestClose={close}>
46
+ <View style={styles.backdrop}>
47
+ <View style={styles.topbar}>
48
+ <Pressable
49
+ hitSlop={12}
50
+ onPress={() => {
51
+ removeOverlay(overlay.id);
52
+ close();
53
+ }}>
54
+ <Text style={[styles.action, { color: theme.danger }]}>Excluir</Text>
55
+ </Pressable>
56
+ <Pressable hitSlop={12} onPress={close}>
57
+ <Text style={[styles.action, { color: theme.accent }]}>Concluir</Text>
58
+ </Pressable>
59
+ </View>
60
+
61
+ <View style={styles.center}>
62
+ <TextInput
63
+ autoFocus
64
+ multiline
65
+ value={overlay.text}
66
+ onChangeText={(t) => updateOverlay(overlay.id, { text: t })}
67
+ placeholder="Digite…"
68
+ placeholderTextColor="rgba(255,255,255,0.4)"
69
+ style={[styles.input, { color: overlay.color, textAlign: overlay.align }]}
70
+ />
71
+ </View>
72
+
73
+ <View style={[styles.tools, { paddingBottom: insets.bottom + 40 }]}>
74
+ <Pressable style={styles.toolChip} onPress={cycleAlign}>
75
+ <Text style={styles.toolChipText}>{ALIGN_LABELS[overlay.align]}</Text>
76
+ </Pressable>
77
+ <Pressable
78
+ style={[
79
+ styles.toolChip,
80
+ overlay.backgroundColor ? { backgroundColor: theme.accent } : null,
81
+ ]}
82
+ onPress={toggleBg}>
83
+ <Text style={styles.toolChipText}>Fundo</Text>
84
+ </Pressable>
85
+ <ScrollView
86
+ horizontal
87
+ showsHorizontalScrollIndicator={false}
88
+ contentContainerStyle={styles.swatches}>
89
+ {COLORS.map((c) => (
90
+ <Pressable
91
+ key={c}
92
+ onPress={() => updateOverlay(overlay.id, { color: c })}
93
+ style={[
94
+ styles.swatch,
95
+ {
96
+ backgroundColor: c,
97
+ borderColor: overlay.color === c ? theme.accent : 'rgba(255,255,255,0.3)',
98
+ },
99
+ ]}
100
+ />
101
+ ))}
102
+ </ScrollView>
103
+ </View>
104
+ </View>
105
+ </Modal>
106
+ );
107
+ }
108
+
109
+ const styles = StyleSheet.create({
110
+ backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.85)' },
111
+ topbar: {
112
+ flexDirection: 'row',
113
+ justifyContent: 'space-between',
114
+ paddingHorizontal: 20,
115
+ paddingTop: 60,
116
+ },
117
+ action: { fontSize: 16, fontWeight: '700' },
118
+ center: { flex: 1, justifyContent: 'center', paddingHorizontal: 24 },
119
+ input: { fontSize: 32, fontWeight: '700', minHeight: 60 },
120
+ tools: {
121
+ paddingHorizontal: 16,
122
+ gap: 12,
123
+ flexDirection: 'row',
124
+ alignItems: 'center',
125
+ },
126
+ toolChip: {
127
+ paddingHorizontal: 14,
128
+ paddingVertical: 8,
129
+ borderRadius: 14,
130
+ backgroundColor: 'rgba(255,255,255,0.15)',
131
+ },
132
+ toolChipText: { color: '#fff', fontWeight: '600', fontSize: 13, textTransform: 'capitalize' },
133
+ swatches: { gap: 10, alignItems: 'center', paddingRight: 12 },
134
+ swatch: { width: 28, height: 28, borderRadius: 14, borderWidth: 2 },
135
+ });