@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,77 @@
|
|
|
1
|
+
import { useEditor, useEditorStore } from '@pictor-sdk/core'
|
|
2
|
+
import { samplePixelColor, useCanvasMetrics, useRenderer } from '@pictor-sdk/skia'
|
|
3
|
+
import React, { useMemo, useRef } from 'react'
|
|
4
|
+
import { StyleSheet } from 'react-native'
|
|
5
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
|
6
|
+
import Animated from 'react-native-reanimated'
|
|
7
|
+
import { runOnJS } from 'react-native-worklets'
|
|
8
|
+
import { useUiState } from '../uiState'
|
|
9
|
+
import { createDrawController, type BrushConfig } from './drawController'
|
|
10
|
+
|
|
11
|
+
/** The brush a drawing session starts with. */
|
|
12
|
+
export const DEFAULT_BRUSH: BrushConfig = { brush: 'pen', color: '#ffffff', width: 0.012 }
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Freehand drawing capture, active while the draw tool is open. In
|
|
16
|
+
* eyedropper mode a tap samples the rendered pixel color into the brush.
|
|
17
|
+
*/
|
|
18
|
+
export function DrawingSurface() {
|
|
19
|
+
const store = useEditorStore()
|
|
20
|
+
const { document } = useEditor()
|
|
21
|
+
const renderer = useRenderer()
|
|
22
|
+
const metrics = useCanvasMetrics()
|
|
23
|
+
const metricsRef = useRef(metrics)
|
|
24
|
+
metricsRef.current = metrics
|
|
25
|
+
const [brush, setBrush] = useUiState<BrushConfig>('draw.brush', DEFAULT_BRUSH)
|
|
26
|
+
const [eyedropper, setEyedropper] = useUiState('draw.eyedropper', false)
|
|
27
|
+
const brushRef = useRef(brush)
|
|
28
|
+
brushRef.current = brush
|
|
29
|
+
const stateRef = useRef({ eyedropper, document, setBrush, setEyedropper })
|
|
30
|
+
stateRef.current = { eyedropper, document, setBrush, setEyedropper }
|
|
31
|
+
|
|
32
|
+
const controller = useMemo(
|
|
33
|
+
() => createDrawController(store, () => metricsRef.current, () => brushRef.current),
|
|
34
|
+
[store],
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const gesture = useMemo(() => {
|
|
38
|
+
const sample = async (x: number, y: number) => {
|
|
39
|
+
const m = metricsRef.current
|
|
40
|
+
const s = stateRef.current
|
|
41
|
+
if (!m || !s.eyedropper) return
|
|
42
|
+
const color = await samplePixelColor(renderer, s.document, {
|
|
43
|
+
x: x / m.width,
|
|
44
|
+
y: y / m.height,
|
|
45
|
+
})
|
|
46
|
+
if (color) s.setBrush({ ...brushRef.current, color })
|
|
47
|
+
s.setEyedropper(false)
|
|
48
|
+
}
|
|
49
|
+
const beginOrSample = (x: number, y: number) => {
|
|
50
|
+
if (stateRef.current.eyedropper) {
|
|
51
|
+
void sample(x, y)
|
|
52
|
+
} else {
|
|
53
|
+
controller.begin(x, y)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return Gesture.Pan()
|
|
57
|
+
.minDistance(0)
|
|
58
|
+
.onStart((e) => {
|
|
59
|
+
'worklet'
|
|
60
|
+
runOnJS(beginOrSample)(e.x, e.y)
|
|
61
|
+
})
|
|
62
|
+
.onUpdate((e) => {
|
|
63
|
+
'worklet'
|
|
64
|
+
runOnJS(controller.move)(e.x, e.y)
|
|
65
|
+
})
|
|
66
|
+
.onFinalize(() => {
|
|
67
|
+
'worklet'
|
|
68
|
+
runOnJS(controller.end)()
|
|
69
|
+
})
|
|
70
|
+
}, [controller, renderer])
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<GestureDetector gesture={gesture}>
|
|
74
|
+
<Animated.View style={StyleSheet.absoluteFill} />
|
|
75
|
+
</GestureDetector>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useEditorStore } from '@pictor-sdk/core'
|
|
2
|
+
import { useCanvasMetrics } from '@pictor-sdk/skia'
|
|
3
|
+
import React, { useMemo, useRef } from 'react'
|
|
4
|
+
import { StyleSheet } from 'react-native'
|
|
5
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
|
6
|
+
import Animated from 'react-native-reanimated'
|
|
7
|
+
import { runOnJS } from 'react-native-worklets'
|
|
8
|
+
import {
|
|
9
|
+
createGestureController,
|
|
10
|
+
type GestureSnapEvent,
|
|
11
|
+
} from './controller'
|
|
12
|
+
|
|
13
|
+
/** Overrides forwarded to whichever gesture surface the active tool mounts. */
|
|
14
|
+
export interface EditorGesturesProps {
|
|
15
|
+
/** Optional haptic hook — fired once per snap engagement. */
|
|
16
|
+
onSnap?: (event: GestureSnapEvent) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Invisible gesture surface for <EditorCanvas children={…}/>: tap to select,
|
|
21
|
+
* pan/pinch/rotate the selected layer. Recognizers run simultaneously on the
|
|
22
|
+
* UI thread; commits are batched into a single undo step (see controller).
|
|
23
|
+
*/
|
|
24
|
+
export function EditorGestures({ onSnap }: EditorGesturesProps) {
|
|
25
|
+
const store = useEditorStore()
|
|
26
|
+
const metrics = useCanvasMetrics()
|
|
27
|
+
const metricsRef = useRef(metrics)
|
|
28
|
+
metricsRef.current = metrics
|
|
29
|
+
const onSnapRef = useRef(onSnap)
|
|
30
|
+
onSnapRef.current = onSnap
|
|
31
|
+
|
|
32
|
+
const controller = useMemo(
|
|
33
|
+
() =>
|
|
34
|
+
createGestureController(store, () => metricsRef.current, {
|
|
35
|
+
onSnap: (e) => onSnapRef.current?.(e),
|
|
36
|
+
}),
|
|
37
|
+
// metricsRef/onSnap are read through refs — store identity is the only dep
|
|
38
|
+
[store],
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const gesture = useMemo(() => {
|
|
42
|
+
const tap = Gesture.Tap().onEnd((e) => {
|
|
43
|
+
'worklet'
|
|
44
|
+
runOnJS(controller.tap)(e.x, e.y)
|
|
45
|
+
})
|
|
46
|
+
const pan = Gesture.Pan()
|
|
47
|
+
.minPointers(1)
|
|
48
|
+
.onStart(() => {
|
|
49
|
+
'worklet'
|
|
50
|
+
runOnJS(controller.begin)()
|
|
51
|
+
})
|
|
52
|
+
.onUpdate((e) => {
|
|
53
|
+
'worklet'
|
|
54
|
+
runOnJS(controller.setPan)(e.translationX, e.translationY)
|
|
55
|
+
})
|
|
56
|
+
.onFinalize(() => {
|
|
57
|
+
'worklet'
|
|
58
|
+
runOnJS(controller.end)()
|
|
59
|
+
})
|
|
60
|
+
const pinch = Gesture.Pinch()
|
|
61
|
+
.onStart(() => {
|
|
62
|
+
'worklet'
|
|
63
|
+
runOnJS(controller.begin)()
|
|
64
|
+
})
|
|
65
|
+
.onUpdate((e) => {
|
|
66
|
+
'worklet'
|
|
67
|
+
runOnJS(controller.setPinch)(e.scale)
|
|
68
|
+
})
|
|
69
|
+
.onFinalize(() => {
|
|
70
|
+
'worklet'
|
|
71
|
+
runOnJS(controller.end)()
|
|
72
|
+
})
|
|
73
|
+
const rotate = Gesture.Rotation()
|
|
74
|
+
.onStart(() => {
|
|
75
|
+
'worklet'
|
|
76
|
+
runOnJS(controller.begin)()
|
|
77
|
+
})
|
|
78
|
+
.onUpdate((e) => {
|
|
79
|
+
'worklet'
|
|
80
|
+
runOnJS(controller.setRotate)(e.rotation)
|
|
81
|
+
})
|
|
82
|
+
.onFinalize(() => {
|
|
83
|
+
'worklet'
|
|
84
|
+
runOnJS(controller.end)()
|
|
85
|
+
})
|
|
86
|
+
return Gesture.Race(
|
|
87
|
+
Gesture.Simultaneous(pan, pinch, rotate),
|
|
88
|
+
tap,
|
|
89
|
+
)
|
|
90
|
+
}, [controller])
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<GestureDetector gesture={gesture}>
|
|
94
|
+
<Animated.View style={StyleSheet.absoluteFill} />
|
|
95
|
+
</GestureDetector>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { deleteTargetFromProfile, useEditor } from '@pictor-sdk/core'
|
|
2
|
+
import { useCanvasMetrics } from '@pictor-sdk/skia'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import { StyleSheet, Text, View } from 'react-native'
|
|
5
|
+
import { useTheme } from '../theme'
|
|
6
|
+
import { useInteractionState } from './interactionState'
|
|
7
|
+
|
|
8
|
+
/** The centre and rotation guides shown while a layer snaps. */
|
|
9
|
+
export interface InteractionGuidesProps {
|
|
10
|
+
/** Label shown above the delete target. */
|
|
11
|
+
deleteLabel?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Alignment guides and the drag-to-delete target.
|
|
16
|
+
*
|
|
17
|
+
* DECISION: these are plain React Native views layered over `<EditorCanvas>`,
|
|
18
|
+
* not Skia nodes inside `renderDocument`. Transient interaction chrome then
|
|
19
|
+
* has no way of reaching an export — stronger than an `isExport` check — and
|
|
20
|
+
* the single render path (invariant 1) stays untouched.
|
|
21
|
+
*
|
|
22
|
+
* Render as a child of `<EditorCanvas>`, which sizes its children to the
|
|
23
|
+
* fitted document box, so percentage offsets here are document-relative.
|
|
24
|
+
*/
|
|
25
|
+
export function InteractionGuides({ deleteLabel = 'Drag to delete' }: InteractionGuidesProps) {
|
|
26
|
+
const { profile } = useEditor()
|
|
27
|
+
const metrics = useCanvasMetrics()
|
|
28
|
+
const theme = useTheme()
|
|
29
|
+
const { snap, layerDragActive, overDeleteTarget } = useInteractionState()
|
|
30
|
+
const target = deleteTargetFromProfile(profile.interaction?.layers)
|
|
31
|
+
|
|
32
|
+
if (!metrics) return null
|
|
33
|
+
|
|
34
|
+
// radius is in document-WIDTH units, so it maps through the canvas width
|
|
35
|
+
const radius = target ? target.radius * metrics.width : 0
|
|
36
|
+
const size = radius * 2
|
|
37
|
+
const active = overDeleteTarget
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
|
|
41
|
+
{snap.centerX ? (
|
|
42
|
+
<View
|
|
43
|
+
style={{
|
|
44
|
+
position: 'absolute',
|
|
45
|
+
left: metrics.width / 2 - 1,
|
|
46
|
+
top: 0,
|
|
47
|
+
width: 2,
|
|
48
|
+
height: metrics.height,
|
|
49
|
+
backgroundColor: theme.colors.accent,
|
|
50
|
+
}}
|
|
51
|
+
/>
|
|
52
|
+
) : null}
|
|
53
|
+
{snap.centerY ? (
|
|
54
|
+
<View
|
|
55
|
+
style={{
|
|
56
|
+
position: 'absolute',
|
|
57
|
+
top: metrics.height / 2 - 1,
|
|
58
|
+
left: 0,
|
|
59
|
+
height: 2,
|
|
60
|
+
width: metrics.width,
|
|
61
|
+
backgroundColor: theme.colors.accent,
|
|
62
|
+
}}
|
|
63
|
+
/>
|
|
64
|
+
) : null}
|
|
65
|
+
|
|
66
|
+
{snap.rotation ? (
|
|
67
|
+
<View
|
|
68
|
+
style={{
|
|
69
|
+
position: 'absolute',
|
|
70
|
+
top: theme.spacing.md,
|
|
71
|
+
alignSelf: 'center',
|
|
72
|
+
backgroundColor: 'rgba(0,0,0,0.6)',
|
|
73
|
+
borderRadius: theme.radii.lg,
|
|
74
|
+
paddingHorizontal: theme.spacing.md,
|
|
75
|
+
paddingVertical: theme.spacing.xs,
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
<Text
|
|
79
|
+
style={{
|
|
80
|
+
color: theme.colors.text,
|
|
81
|
+
fontSize: theme.font.sizeSm,
|
|
82
|
+
fontWeight: theme.font.weight,
|
|
83
|
+
}}
|
|
84
|
+
>
|
|
85
|
+
Right-angle snap
|
|
86
|
+
</Text>
|
|
87
|
+
</View>
|
|
88
|
+
) : null}
|
|
89
|
+
|
|
90
|
+
{target && layerDragActive ? (
|
|
91
|
+
<View
|
|
92
|
+
style={{
|
|
93
|
+
position: 'absolute',
|
|
94
|
+
left: target.x * metrics.width - radius,
|
|
95
|
+
top: target.y * metrics.height - radius,
|
|
96
|
+
width: size,
|
|
97
|
+
height: size,
|
|
98
|
+
alignItems: 'center',
|
|
99
|
+
justifyContent: 'center',
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
<View
|
|
103
|
+
style={{
|
|
104
|
+
width: size,
|
|
105
|
+
height: size,
|
|
106
|
+
borderRadius: radius,
|
|
107
|
+
borderWidth: 2,
|
|
108
|
+
borderColor: theme.colors.text,
|
|
109
|
+
// a scrim keeps the target legible over light photos, which a
|
|
110
|
+
// plain white ring is not
|
|
111
|
+
backgroundColor: active ? theme.colors.danger : 'rgba(0,0,0,0.45)',
|
|
112
|
+
alignItems: 'center',
|
|
113
|
+
justifyContent: 'center',
|
|
114
|
+
transform: [{ scale: active ? 1.15 : 1 }],
|
|
115
|
+
}}
|
|
116
|
+
>
|
|
117
|
+
<Text
|
|
118
|
+
style={{
|
|
119
|
+
color: theme.colors.text,
|
|
120
|
+
fontSize: radius * 0.7,
|
|
121
|
+
fontWeight: theme.font.weight,
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
×
|
|
125
|
+
</Text>
|
|
126
|
+
</View>
|
|
127
|
+
</View>
|
|
128
|
+
) : null}
|
|
129
|
+
|
|
130
|
+
{/* the label sits in the full-canvas box, not the target-sized one, so a
|
|
131
|
+
longer or translated string is never clipped by the target diameter */}
|
|
132
|
+
{target && layerDragActive ? (
|
|
133
|
+
<View
|
|
134
|
+
style={{
|
|
135
|
+
position: 'absolute',
|
|
136
|
+
left: 0,
|
|
137
|
+
right: 0,
|
|
138
|
+
top: target.y * metrics.height - radius - theme.spacing.lg * 2,
|
|
139
|
+
alignItems: 'center',
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
<Text
|
|
143
|
+
style={{
|
|
144
|
+
color: theme.colors.text,
|
|
145
|
+
fontSize: theme.font.sizeSm,
|
|
146
|
+
fontWeight: theme.font.weight,
|
|
147
|
+
backgroundColor: 'rgba(0,0,0,0.45)',
|
|
148
|
+
paddingHorizontal: theme.spacing.sm,
|
|
149
|
+
paddingVertical: theme.spacing.xs,
|
|
150
|
+
borderRadius: theme.radii.sm,
|
|
151
|
+
overflow: 'hidden',
|
|
152
|
+
}}
|
|
153
|
+
numberOfLines={1}
|
|
154
|
+
>
|
|
155
|
+
{deleteLabel}
|
|
156
|
+
</Text>
|
|
157
|
+
</View>
|
|
158
|
+
) : null}
|
|
159
|
+
</View>
|
|
160
|
+
)
|
|
161
|
+
}
|