@pictor-sdk/ui 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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +29 -0
  3. package/dist/index.d.mts +833 -0
  4. package/dist/index.d.ts +833 -0
  5. package/dist/index.js +4620 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/index.mjs +4643 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/package.json +68 -0
  10. package/src/Editor.tsx +276 -0
  11. package/src/ToolPanelHost.tsx +98 -0
  12. package/src/components/Slider.tsx +172 -0
  13. package/src/components/StraightenRuler.tsx +57 -0
  14. package/src/components/ToolRail.tsx +58 -0
  15. package/src/components/TopBar.tsx +53 -0
  16. package/src/composer/TextComposer.tsx +540 -0
  17. package/src/composer/keyboardLayout.ts +130 -0
  18. package/src/composer/useTextSession.ts +230 -0
  19. package/src/gestures/CropGestureSurface.tsx +270 -0
  20. package/src/gestures/CropGrid.tsx +24 -0
  21. package/src/gestures/DrawingSurface.tsx +77 -0
  22. package/src/gestures/EditorGestures.tsx +97 -0
  23. package/src/gestures/InteractionGuides.tsx +161 -0
  24. package/src/gestures/LayerGestureSurface.tsx +462 -0
  25. package/src/gestures/StoryMediaGestureSurface.tsx +517 -0
  26. package/src/gestures/TiltShiftGestureSurface.tsx +189 -0
  27. package/src/gestures/controller.ts +152 -0
  28. package/src/gestures/drawController.ts +104 -0
  29. package/src/gestures/interactionState.tsx +81 -0
  30. package/src/gestures/layerGestureWorklet.ts +198 -0
  31. package/src/gestures/mediaGestureWorklet.ts +159 -0
  32. package/src/gestures/routing.ts +42 -0
  33. package/src/gestures/settleCoordinator.ts +61 -0
  34. package/src/gestures/transformOwnership.ts +91 -0
  35. package/src/hostCallbacks.tsx +62 -0
  36. package/src/index.ts +72 -0
  37. package/src/panels/AddPhotoPanel.tsx +101 -0
  38. package/src/panels/AdjustPanel.tsx +158 -0
  39. package/src/panels/ColorPanel.tsx +193 -0
  40. package/src/panels/CropPanel.tsx +185 -0
  41. package/src/panels/DrawPanel.tsx +106 -0
  42. package/src/panels/FilterStrip.tsx +153 -0
  43. package/src/panels/LayersPanel.tsx +85 -0
  44. package/src/panels/LayoutPanel.tsx +55 -0
  45. package/src/panels/LuxPanel.tsx +122 -0
  46. package/src/panels/MediaPanel.tsx +73 -0
  47. package/src/panels/PerspectivePanel.tsx +157 -0
  48. package/src/panels/StickerPanel.tsx +78 -0
  49. package/src/panels/TextPanel.tsx +177 -0
  50. package/src/panels/TiltShiftPanel.tsx +146 -0
  51. package/src/panels/addPhotoController.ts +196 -0
  52. package/src/panels/cropMath.ts +37 -0
  53. package/src/panels/layoutMath.ts +58 -0
  54. package/src/panels/useTargetImageLayer.ts +41 -0
  55. package/src/theme.ts +61 -0
  56. package/src/uiState.tsx +57 -0
@@ -0,0 +1,193 @@
1
+ import {
2
+ isInertTint,
3
+ tintPatch,
4
+ useEditor,
5
+ type ColorTint,
6
+ type ImageLayerData,
7
+ } from '@pictor-sdk/core'
8
+ import React, { useState } from 'react'
9
+ import { Pressable, ScrollView, Text, View } from 'react-native'
10
+ import { Slider } from '../components/Slider'
11
+ import { useTheme } from '../theme'
12
+ import { useTargetImageLayer } from './useTargetImageLayer'
13
+
14
+ /**
15
+ * Split toning: a colour cast on the shadows, another on the highlights.
16
+ *
17
+ * Two ends, each with a palette and a strength. They are edited one at a time
18
+ * because they are genuinely independent — the render masks confine each to its
19
+ * own end of the tonal range — so showing both at once would suggest an
20
+ * interaction that does not exist.
21
+ *
22
+ * The strength slider is one-directional and rests at zero, like Lux: a cast
23
+ * has an amount, not a direction.
24
+ */
25
+
26
+ /**
27
+ * Our own palette — eight hues spread around the wheel at a muted saturation.
28
+ *
29
+ * Deliberately not anyone else's swatch set. Muted rather than pure because a
30
+ * fully saturated cast at any usable strength reads as a colour error; these
31
+ * still shift the grade decisively at strength 1.
32
+ */
33
+ export const TINT_SWATCHES: readonly string[] = [
34
+ '#1f8a8a',
35
+ '#2f6fd0',
36
+ '#7a5cd0',
37
+ '#c04a9e',
38
+ '#c8443c',
39
+ '#d99038',
40
+ '#8a9a3f',
41
+ '#3f9a6a',
42
+ ]
43
+
44
+ const ENDS = [
45
+ { key: 'shadows', label: 'Shadows' },
46
+ { key: 'highlights', label: 'Highlights' },
47
+ ] as const
48
+
49
+ /** Strength given to a colour picked while the end has none yet. */
50
+ const INITIAL_STRENGTH = 0.5
51
+
52
+ export function ColorPanel() {
53
+ const { dispatch } = useEditor()
54
+ const theme = useTheme()
55
+ const layer = useTargetImageLayer()
56
+ const [end, setEnd] = useState<'shadows' | 'highlights'>('shadows')
57
+
58
+ if (!layer) return null
59
+ const data = layer.data as ImageLayerData
60
+ const tint: ColorTint | undefined = data.color.tinting?.[end]
61
+ const active = isInertTint(tint) ? undefined : tint
62
+
63
+ const patch = (next: ColorTint | undefined) =>
64
+ dispatch({
65
+ type: 'layer/patchData',
66
+ id: layer.id,
67
+ patch: { color: tintPatch(end, next) } as unknown as Record<string, unknown>,
68
+ })
69
+
70
+ /** Picking the colour already applied clears this end — tap again to undo it. */
71
+ const pick = (color: string) =>
72
+ patch(
73
+ active?.color === color
74
+ ? undefined
75
+ : { color, strength: active?.strength ?? INITIAL_STRENGTH },
76
+ )
77
+
78
+ return (
79
+ <View style={{ backgroundColor: theme.colors.background, paddingBottom: theme.spacing.md }}>
80
+ <View
81
+ style={{
82
+ flexDirection: 'row',
83
+ justifyContent: 'center',
84
+ gap: theme.spacing.md,
85
+ paddingTop: theme.spacing.sm,
86
+ }}
87
+ >
88
+ {ENDS.map(({ key, label }) => {
89
+ const selected = key === end
90
+ const set = !isInertTint(data.color.tinting?.[key])
91
+ return (
92
+ <Pressable
93
+ key={key}
94
+ accessibilityRole="button"
95
+ accessibilityLabel={`Tint ${label.toLowerCase()}`}
96
+ onPress={() => setEnd(key)}
97
+ style={{
98
+ paddingHorizontal: theme.spacing.md,
99
+ paddingVertical: 6,
100
+ borderRadius: theme.radii.sm,
101
+ borderWidth: 1,
102
+ borderColor: selected ? theme.colors.accent : 'transparent',
103
+ }}
104
+ >
105
+ <Text
106
+ style={{
107
+ color: selected ? theme.colors.accent : theme.colors.textMuted,
108
+ fontSize: theme.font.sizeSm,
109
+ fontWeight: theme.font.weight,
110
+ }}
111
+ >
112
+ {label}
113
+ {set ? ' •' : ''}
114
+ </Text>
115
+ </Pressable>
116
+ )
117
+ })}
118
+ </View>
119
+
120
+ <ScrollView
121
+ horizontal
122
+ showsHorizontalScrollIndicator={false}
123
+ contentContainerStyle={{ gap: theme.spacing.sm, paddingHorizontal: theme.spacing.lg, paddingVertical: theme.spacing.md }}
124
+ style={{ flexGrow: 0 }}
125
+ >
126
+ {TINT_SWATCHES.map((color) => (
127
+ <Pressable
128
+ key={color}
129
+ accessibilityRole="button"
130
+ accessibilityLabel={`Tint ${end} ${color}`}
131
+ onPress={() => pick(color)}
132
+ style={{
133
+ width: 40,
134
+ height: 40,
135
+ borderRadius: 20,
136
+ backgroundColor: color,
137
+ borderWidth: active?.color === color ? 3 : 1,
138
+ borderColor: active?.color === color ? theme.colors.text : theme.colors.track,
139
+ }}
140
+ />
141
+ ))}
142
+ </ScrollView>
143
+
144
+ <View style={{ paddingHorizontal: theme.spacing.lg }}>
145
+ <View
146
+ style={{
147
+ flexDirection: 'row',
148
+ alignItems: 'center',
149
+ justifyContent: 'center',
150
+ gap: theme.spacing.sm,
151
+ minHeight: 28,
152
+ }}
153
+ >
154
+ <Text style={{ color: theme.colors.textMuted, fontSize: theme.font.sizeSm }}>
155
+ {active ? `Strength ${Math.round(active.strength * 100)}` : 'Pick a colour'}
156
+ </Text>
157
+ {active ? (
158
+ <Pressable
159
+ accessibilityRole="button"
160
+ accessibilityLabel={`Reset ${end} tint`}
161
+ onPress={() => patch(undefined)}
162
+ hitSlop={8}
163
+ style={{
164
+ paddingHorizontal: theme.spacing.sm,
165
+ paddingVertical: 2,
166
+ borderRadius: theme.radii.sm,
167
+ borderWidth: 1,
168
+ borderColor: theme.colors.track,
169
+ }}
170
+ >
171
+ <Text style={{ color: theme.colors.text, fontSize: theme.font.sizeSm }}>Reset</Text>
172
+ </Pressable>
173
+ ) : null}
174
+ </View>
175
+ <Slider
176
+ value={active?.strength ?? 0}
177
+ min={0}
178
+ max={1}
179
+ centered={false}
180
+ testID="tint-strength"
181
+ disabled={!active}
182
+ valueLabel={`${Math.round((active?.strength ?? 0) * 100)}`}
183
+ onStart={() => dispatch({ type: 'history/beginBatch', label: `tint-${end}` })}
184
+ onChange={(strength) => {
185
+ if (!active) return
186
+ patch({ color: active.color, strength })
187
+ }}
188
+ onEnd={() => dispatch({ type: 'history/endBatch' })}
189
+ />
190
+ </View>
191
+ </View>
192
+ )
193
+ }
@@ -0,0 +1,185 @@
1
+ import {
2
+ cropFitScale,
3
+ flipPatch,
4
+ isFlipped,
5
+ useEditor,
6
+ useEditorStore,
7
+ type FlipAxis,
8
+ type ImageLayerData,
9
+ } from '@pictor-sdk/core'
10
+ import React from 'react'
11
+ import { Pressable, ScrollView, Text, View } from 'react-native'
12
+ import { useTheme } from '../theme'
13
+ import { StraightenRuler } from '../components/StraightenRuler'
14
+ import { useTargetImageLayer } from './useTargetImageLayer'
15
+ import {
16
+ canvasSizeForAspect,
17
+ } from './cropMath'
18
+
19
+ function aspectLabel(aspect: number | 'free'): string {
20
+ if (aspect === 'free') return 'Free'
21
+ const known: Array<[number, string]> = [
22
+ [4 / 5, '4:5'],
23
+ [1, '1:1'],
24
+ [16 / 9, '16:9'],
25
+ [9 / 16, '9:16'],
26
+ [3 / 4, '3:4'],
27
+ [4 / 3, '4:3'],
28
+ ]
29
+ for (const [value, label] of known) {
30
+ if (Math.abs(aspect - value) < 1e-6) return label
31
+ }
32
+ return aspect.toFixed(2)
33
+ }
34
+
35
+ /** Crop tool: aspect presets from the profile, 90° rotate, straighten dial. */
36
+ export function CropPanel() {
37
+ const { document, dispatch, profile } = useEditor()
38
+ useEditorStore() // subscribe
39
+ const theme = useTheme()
40
+ const layer = useTargetImageLayer()
41
+ if (!layer) return null
42
+ const data = layer.data as ImageLayerData
43
+ const straighten = data.straighten ?? 0
44
+
45
+ const applyAspect = (aspect: number | 'free') => {
46
+ const nextSize = canvasSizeForAspect(document.size, aspect)
47
+ dispatch({ type: 'history/beginBatch', label: 'aspect' })
48
+ dispatch({ type: 'doc/patch', patch: { size: nextSize } })
49
+ dispatch({ type: 'layer/patchData', id: layer.id, patch: { crop: undefined } })
50
+ dispatch({
51
+ type: 'layer/transform',
52
+ id: layer.id,
53
+ patch: {
54
+ x: 0.5,
55
+ y: 0.5,
56
+ scale: cropFitScale(data.intrinsic, nextSize, profile.interaction?.crop?.fit ?? 'contain'),
57
+ },
58
+ })
59
+ dispatch({ type: 'history/endBatch' })
60
+ }
61
+
62
+ const rotate90 = () => {
63
+ const current = layer.transform.rotation
64
+ const rotation = current + Math.PI / 2
65
+ dispatch({
66
+ type: 'layer/transform',
67
+ id: layer.id,
68
+ patch: {
69
+ rotation,
70
+ x: 0.5,
71
+ y: 0.5,
72
+ scale: cropFitScale(data.intrinsic, document.size, profile.interaction?.crop?.fit ?? 'contain', rotation),
73
+ },
74
+ })
75
+ }
76
+
77
+ /**
78
+ * Mirror the photo on one axis.
79
+ *
80
+ * No batch of its own: the crop tool session already opened one, so a flip
81
+ * joins that session's single undo entry and Cancel rolls it back with the
82
+ * rest. Only the flip flag changes — the crop window, straighten, position
83
+ * and scale are all left alone, and hit-testing follows because it reads the
84
+ * same flag the renderer does.
85
+ */
86
+ const flip = (axis: FlipAxis) =>
87
+ dispatch({
88
+ type: 'layer/transform',
89
+ id: layer.id,
90
+ patch: flipPatch(layer.transform, axis),
91
+ })
92
+
93
+ return (
94
+ <View style={{ backgroundColor: theme.colors.background }}>
95
+ <View style={{ paddingHorizontal: theme.spacing.lg }}>
96
+ <StraightenRuler
97
+ value={(straighten * 180) / Math.PI}
98
+ min={-45}
99
+ max={45}
100
+ onStart={() => dispatch({ type: 'history/beginBatch', label: 'straighten' })}
101
+ onChange={(v) =>
102
+ dispatch({
103
+ type: 'layer/patchData',
104
+ id: layer.id,
105
+ patch: { straighten: (v * Math.PI) / 180 },
106
+ })
107
+ }
108
+ onEnd={() => dispatch({ type: 'history/endBatch' })}
109
+ />
110
+ </View>
111
+ <ScrollView
112
+ horizontal
113
+ showsHorizontalScrollIndicator={false}
114
+ contentContainerStyle={{ gap: theme.spacing.sm, padding: theme.spacing.md }}
115
+ style={{ flexGrow: 0 }}
116
+ >
117
+ <Pressable
118
+ accessibilityRole="button"
119
+ accessibilityLabel="Rotate 90 degrees"
120
+ onPress={rotate90}
121
+ style={{
122
+ paddingHorizontal: theme.spacing.md,
123
+ paddingVertical: theme.spacing.sm,
124
+ borderRadius: theme.radii.md,
125
+ backgroundColor: theme.colors.surface,
126
+ }}
127
+ >
128
+ <Text style={{ color: theme.colors.text, fontSize: theme.font.sizeSm, fontWeight: theme.font.weight }}>
129
+ ⟳ 90°
130
+ </Text>
131
+ </Pressable>
132
+ {(['horizontal', 'vertical'] as const).map((axis) => {
133
+ const on = isFlipped(layer.transform, axis)
134
+ return (
135
+ <Pressable
136
+ key={axis}
137
+ accessibilityRole="button"
138
+ accessibilityLabel={`Flip ${axis}`}
139
+ accessibilityState={{ selected: on }}
140
+ onPress={() => flip(axis)}
141
+ style={{
142
+ paddingHorizontal: theme.spacing.md,
143
+ paddingVertical: theme.spacing.sm,
144
+ borderRadius: theme.radii.md,
145
+ backgroundColor: theme.colors.surface,
146
+ borderWidth: 1,
147
+ borderColor: on ? theme.colors.accent : 'transparent',
148
+ }}
149
+ >
150
+ <Text
151
+ style={{
152
+ color: on ? theme.colors.accent : theme.colors.text,
153
+ fontSize: theme.font.sizeSm,
154
+ fontWeight: theme.font.weight,
155
+ }}
156
+ >
157
+ {axis === 'horizontal' ? '⇋' : '⇅'}
158
+ </Text>
159
+ </Pressable>
160
+ )
161
+ })}
162
+ {profile.aspects.map((aspect, i) => (
163
+ <Pressable
164
+ key={i}
165
+ onPress={() => applyAspect(aspect)}
166
+ style={{
167
+ width: 58,
168
+ height: 48,
169
+ borderRadius: theme.radii.md,
170
+ borderWidth: 1,
171
+ borderColor: theme.colors.textMuted,
172
+ backgroundColor: 'transparent',
173
+ alignItems: 'center',
174
+ justifyContent: 'center',
175
+ }}
176
+ >
177
+ <Text style={{ color: theme.colors.text, fontSize: theme.font.sizeSm, fontWeight: theme.font.weight }}>
178
+ {aspectLabel(aspect)}
179
+ </Text>
180
+ </Pressable>
181
+ ))}
182
+ </ScrollView>
183
+ </View>
184
+ )
185
+ }
@@ -0,0 +1,106 @@
1
+ import { type Stroke } from '@pictor-sdk/core'
2
+ import React from 'react'
3
+ import { Pressable, ScrollView, Text, View } from 'react-native'
4
+ import { DEFAULT_BRUSH } from '../gestures/DrawingSurface'
5
+ import { type BrushConfig } from '../gestures/drawController'
6
+ import { useTheme } from '../theme'
7
+ import { useUiState } from '../uiState'
8
+ import { Slider } from '../components/Slider'
9
+
10
+ const BRUSHES: Array<{ id: Stroke['brush']; label: string }> = [
11
+ { id: 'pen', label: 'Pen' },
12
+ { id: 'marker', label: 'Marker' },
13
+ { id: 'neon', label: 'Neon' },
14
+ { id: 'highlighter', label: 'Highlight' },
15
+ { id: 'eraser', label: 'Eraser' },
16
+ ]
17
+
18
+ const SWATCHES = [
19
+ '#ffffff', '#000000', '#ed4956', '#ffb43b', '#ffe25c',
20
+ '#78de85', '#4dc4ff', '#3897f0', '#a15fed', '#d977b1',
21
+ ]
22
+
23
+ /** Draw tab: brush picker, color swatches + eyedropper, width slider. */
24
+ export function DrawPanel() {
25
+ const theme = useTheme()
26
+ const [brush, setBrush] = useUiState<BrushConfig>('draw.brush', DEFAULT_BRUSH)
27
+ const [eyedropper, setEyedropper] = useUiState('draw.eyedropper', false)
28
+
29
+ return (
30
+ <View style={{ backgroundColor: theme.colors.background }}>
31
+ <View style={{ paddingHorizontal: theme.spacing.lg }}>
32
+ <Slider
33
+ value={brush.width}
34
+ min={0.002}
35
+ max={0.06}
36
+ centered={false}
37
+ onChange={(width) => setBrush({ ...brush, width })}
38
+ />
39
+ </View>
40
+ <ScrollView
41
+ horizontal
42
+ showsHorizontalScrollIndicator={false}
43
+ contentContainerStyle={{ gap: theme.spacing.sm, paddingHorizontal: theme.spacing.md, alignItems: 'center' }}
44
+ style={{ flexGrow: 0 }}
45
+ >
46
+ <Pressable
47
+ onPress={() => setEyedropper(!eyedropper)}
48
+ style={{
49
+ width: 28,
50
+ height: 28,
51
+ borderRadius: 14,
52
+ borderWidth: 2,
53
+ borderColor: eyedropper ? theme.colors.accent : theme.colors.textMuted,
54
+ alignItems: 'center',
55
+ justifyContent: 'center',
56
+ }}
57
+ >
58
+ <Text style={{ color: theme.colors.text, fontSize: 13 }}>💧</Text>
59
+ </Pressable>
60
+ {SWATCHES.map((color) => (
61
+ <Pressable
62
+ key={color}
63
+ onPress={() => setBrush({ ...brush, color })}
64
+ style={{
65
+ width: 26,
66
+ height: 26,
67
+ borderRadius: 13,
68
+ backgroundColor: color,
69
+ borderWidth: brush.color === color ? 3 : 1,
70
+ borderColor: brush.color === color ? theme.colors.accent : theme.colors.track,
71
+ }}
72
+ />
73
+ ))}
74
+ </ScrollView>
75
+ <ScrollView
76
+ horizontal
77
+ showsHorizontalScrollIndicator={false}
78
+ contentContainerStyle={{ gap: theme.spacing.sm, padding: theme.spacing.md }}
79
+ style={{ flexGrow: 0 }}
80
+ >
81
+ {BRUSHES.map((b) => (
82
+ <Pressable
83
+ key={b.id}
84
+ onPress={() => setBrush({ ...brush, brush: b.id })}
85
+ style={{
86
+ paddingHorizontal: theme.spacing.md,
87
+ paddingVertical: theme.spacing.sm,
88
+ borderRadius: theme.radii.md,
89
+ backgroundColor: brush.brush === b.id ? theme.colors.surface : 'transparent',
90
+ }}
91
+ >
92
+ <Text
93
+ style={{
94
+ color: brush.brush === b.id ? theme.colors.accent : theme.colors.text,
95
+ fontSize: theme.font.sizeSm,
96
+ fontWeight: theme.font.weight,
97
+ }}
98
+ >
99
+ {b.label}
100
+ </Text>
101
+ </Pressable>
102
+ ))}
103
+ </ScrollView>
104
+ </View>
105
+ )
106
+ }
@@ -0,0 +1,153 @@
1
+ import { baseImageLayer, useEditor, useLuts, type ImageLayerData, type LutRef } from '@pictor-sdk/core'
2
+ import { useRenderer } from '@pictor-sdk/skia'
3
+ import { Canvas, Image as SkiaImage, type SkImage } from '@shopify/react-native-skia'
4
+ import React, { useEffect, useState } from 'react'
5
+ import { Pressable, ScrollView, Text, View } from 'react-native'
6
+ import { useTheme } from '../theme'
7
+ import { Slider } from '../components/Slider'
8
+ import { useTargetImageLayer } from './useTargetImageLayer'
9
+
10
+ const THUMB = 84
11
+
12
+ /**
13
+ * The base photo — the bottom-most image layer.
14
+ *
15
+ * Unchanged, and deliberately still available: some capabilities are genuinely
16
+ * about *the base photo* rather than about whichever photo is selected — Story
17
+ * media placement and the derived background are computed from it. Panels that
18
+ * edit "the current image" use {@link useTargetImageLayer} instead.
19
+ */
20
+ export function useBaseImageLayer() {
21
+ const { document } = useEditor()
22
+ return baseImageLayer(document)
23
+ }
24
+
25
+ function Thumb({ lut, label, active }: { lut: LutRef | undefined; label: string; active: boolean }) {
26
+ const { document } = useEditor()
27
+ const renderer = useRenderer()
28
+ const theme = useTheme()
29
+ const [image, setImage] = useState<SkImage | null>(null)
30
+ const lutId = lut?.id
31
+ const lutIntensity = lut?.intensity
32
+
33
+ // cached by (source, crop, lut) inside the renderer — free after first render
34
+ useEffect(() => {
35
+ let alive = true
36
+ const requestedLut = lutId === undefined ? undefined : { id: lutId, intensity: lutIntensity ?? 1 }
37
+ renderer
38
+ .renderThumbnailImage(document, requestedLut, THUMB)
39
+ .then((img) => alive && setImage(img))
40
+ .catch(() => {})
41
+ return () => {
42
+ alive = false
43
+ }
44
+ }, [renderer, document, lutId, lutIntensity])
45
+
46
+ return (
47
+ <View style={{ alignItems: 'center', gap: 4 }}>
48
+ <Text
49
+ style={{
50
+ color: active ? theme.colors.accent : theme.colors.textMuted,
51
+ fontSize: theme.font.sizeSm,
52
+ }}
53
+ >
54
+ {label}
55
+ </Text>
56
+ <View
57
+ style={{
58
+ width: THUMB,
59
+ height: THUMB,
60
+ borderRadius: theme.radii.sm,
61
+ overflow: 'hidden',
62
+ borderWidth: active ? 2 : 0,
63
+ borderColor: theme.colors.accent,
64
+ backgroundColor: theme.colors.surface,
65
+ }}
66
+ >
67
+ {image ? (
68
+ <Canvas style={{ width: THUMB, height: THUMB }}>
69
+ <SkiaImage image={image} x={0} y={0} width={THUMB} height={THUMB} fit="cover" />
70
+ </Canvas>
71
+ ) : null}
72
+ </View>
73
+ </View>
74
+ )
75
+ }
76
+
77
+ /**
78
+ * The LUT filter strip: live thumbnails, tap to apply, second tap on the
79
+ * active filter reveals the intensity slider.
80
+ */
81
+ export function FilterStrip() {
82
+ const { dispatch } = useEditor()
83
+ const luts = useLuts()
84
+ const theme = useTheme()
85
+ const layer = useTargetImageLayer()
86
+ const [intensityFor, setIntensityFor] = useState<string | null>(null)
87
+
88
+ if (!layer) return null
89
+ const color = (layer.data as ImageLayerData).color
90
+ const activeLut = color.lut
91
+
92
+ const applyLut = (lutId: string | null) => {
93
+ if (lutId === null) {
94
+ dispatch({ type: 'layer/patchData', id: layer.id, patch: { color: { lut: undefined } } })
95
+ setIntensityFor(null)
96
+ return
97
+ }
98
+ if (activeLut?.id === lutId) {
99
+ // second tap → intensity slider
100
+ setIntensityFor((cur) => (cur === lutId ? null : lutId))
101
+ return
102
+ }
103
+ dispatch({
104
+ type: 'layer/patchData',
105
+ id: layer.id,
106
+ patch: { color: { lut: { id: lutId, intensity: 1 } } },
107
+ })
108
+ }
109
+
110
+ return (
111
+ <View style={{ backgroundColor: theme.colors.background }}>
112
+ {intensityFor && activeLut?.id === intensityFor ? (
113
+ <View style={{ paddingHorizontal: theme.spacing.lg }}>
114
+ <Slider
115
+ value={activeLut.intensity}
116
+ min={0}
117
+ max={1}
118
+ centered={false}
119
+ valueLabel={`${Math.round(activeLut.intensity * 100)}`}
120
+ onStart={() => dispatch({ type: 'history/beginBatch', label: 'intensity' })}
121
+ onChange={(intensity) =>
122
+ dispatch({
123
+ type: 'layer/patchData',
124
+ id: layer.id,
125
+ patch: { color: { lut: { id: intensityFor, intensity } } },
126
+ })
127
+ }
128
+ onEnd={() => dispatch({ type: 'history/endBatch' })}
129
+ />
130
+ </View>
131
+ ) : null}
132
+ <ScrollView
133
+ horizontal
134
+ showsHorizontalScrollIndicator={false}
135
+ contentContainerStyle={{ gap: theme.spacing.sm, padding: theme.spacing.md }}
136
+ style={{ flexGrow: 0 }}
137
+ >
138
+ <Pressable onPress={() => applyLut(null)}>
139
+ <Thumb lut={undefined} label="Original" active={!activeLut} />
140
+ </Pressable>
141
+ {luts.map((lut) => (
142
+ <Pressable key={lut.id} onPress={() => applyLut(lut.id)}>
143
+ <Thumb
144
+ lut={{ id: lut.id, intensity: 1 }}
145
+ label={lut.label}
146
+ active={activeLut?.id === lut.id}
147
+ />
148
+ </Pressable>
149
+ ))}
150
+ </ScrollView>
151
+ </View>
152
+ )
153
+ }