@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.
- package/LICENSE +21 -0
- package/README.md +29 -0
- package/dist/index.d.mts +833 -0
- package/dist/index.d.ts +833 -0
- package/dist/index.js +4620 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +4643 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +68 -0
- package/src/Editor.tsx +276 -0
- package/src/ToolPanelHost.tsx +98 -0
- package/src/components/Slider.tsx +172 -0
- package/src/components/StraightenRuler.tsx +57 -0
- package/src/components/ToolRail.tsx +58 -0
- package/src/components/TopBar.tsx +53 -0
- package/src/composer/TextComposer.tsx +540 -0
- package/src/composer/keyboardLayout.ts +130 -0
- package/src/composer/useTextSession.ts +230 -0
- package/src/gestures/CropGestureSurface.tsx +270 -0
- package/src/gestures/CropGrid.tsx +24 -0
- package/src/gestures/DrawingSurface.tsx +77 -0
- package/src/gestures/EditorGestures.tsx +97 -0
- package/src/gestures/InteractionGuides.tsx +161 -0
- package/src/gestures/LayerGestureSurface.tsx +462 -0
- package/src/gestures/StoryMediaGestureSurface.tsx +517 -0
- package/src/gestures/TiltShiftGestureSurface.tsx +189 -0
- package/src/gestures/controller.ts +152 -0
- package/src/gestures/drawController.ts +104 -0
- package/src/gestures/interactionState.tsx +81 -0
- package/src/gestures/layerGestureWorklet.ts +198 -0
- package/src/gestures/mediaGestureWorklet.ts +159 -0
- package/src/gestures/routing.ts +42 -0
- package/src/gestures/settleCoordinator.ts +61 -0
- package/src/gestures/transformOwnership.ts +91 -0
- package/src/hostCallbacks.tsx +62 -0
- package/src/index.ts +72 -0
- package/src/panels/AddPhotoPanel.tsx +101 -0
- package/src/panels/AdjustPanel.tsx +158 -0
- package/src/panels/ColorPanel.tsx +193 -0
- package/src/panels/CropPanel.tsx +185 -0
- package/src/panels/DrawPanel.tsx +106 -0
- package/src/panels/FilterStrip.tsx +153 -0
- package/src/panels/LayersPanel.tsx +85 -0
- package/src/panels/LayoutPanel.tsx +55 -0
- package/src/panels/LuxPanel.tsx +122 -0
- package/src/panels/MediaPanel.tsx +73 -0
- package/src/panels/PerspectivePanel.tsx +157 -0
- package/src/panels/StickerPanel.tsx +78 -0
- package/src/panels/TextPanel.tsx +177 -0
- package/src/panels/TiltShiftPanel.tsx +146 -0
- package/src/panels/addPhotoController.ts +196 -0
- package/src/panels/cropMath.ts +37 -0
- package/src/panels/layoutMath.ts +58 -0
- package/src/panels/useTargetImageLayer.ts +41 -0
- package/src/theme.ts +61 -0
- 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
|
+
}
|