@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,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
+ }