@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,130 @@
1
+ /**
2
+ * Keyboard layout maths for the text composer.
3
+ *
4
+ * Split from the component so it can be tested without mounting a keyboard,
5
+ * and kept free of any React Native import so it runs in plain Node.
6
+ *
7
+ * The rule this encodes: **the canvas never moves.** Milestone 2 established
8
+ * that changing canvas metrics mid-interaction moves the ground under the
9
+ * gesture; the same is true of a caret. So the keyboard's height becomes an
10
+ * inset for the composer's *controls*, and nothing else in the editor reacts to
11
+ * it.
12
+ *
13
+ * @experimental
14
+ */
15
+
16
+ export interface KeyboardFrame {
17
+ /** Height of the keyboard in dp; 0 when hidden or when a hardware keyboard is attached. */
18
+ height: number
19
+ /** Milliseconds the platform will take to move it. Android often reports 0. */
20
+ duration?: number
21
+ }
22
+
23
+ export interface ComposerInsets {
24
+ /** Distance from the bottom of the screen to place the control bar. */
25
+ bottom: number
26
+ /** True while the keyboard occupies screen space. */
27
+ keyboardVisible: boolean
28
+ /** Animation duration to match the platform's own, clamped to something sane. */
29
+ duration: number
30
+ }
31
+
32
+ export const HIDDEN_KEYBOARD: KeyboardFrame = { height: 0 }
33
+
34
+ /** Longest keyboard animation we will follow; beyond this the UI feels stuck. */
35
+ const MAX_DURATION = 400
36
+ /** Android frequently reports 0; a short move still reads better than a jump. */
37
+ const FALLBACK_DURATION = 180
38
+
39
+ /**
40
+ * Where the composer's controls sit, given the keyboard and the safe area.
41
+ *
42
+ * When the keyboard is up it already covers the home indicator, so the bottom
43
+ * safe-area inset must *not* be added on top of it — doing so leaves a visible
44
+ * gap between the controls and the keyboard on iOS.
45
+ */
46
+ export function composerInsets(
47
+ frame: KeyboardFrame,
48
+ safeAreaBottom = 0,
49
+ ): ComposerInsets {
50
+ const height = Number.isFinite(frame.height) ? Math.max(0, frame.height) : 0
51
+ const visible = height > 0
52
+ const raw = frame.duration
53
+ const duration =
54
+ raw === undefined || !Number.isFinite(raw) || raw <= 0
55
+ ? FALLBACK_DURATION
56
+ : Math.min(MAX_DURATION, raw)
57
+ return {
58
+ bottom: visible ? height : Math.max(0, safeAreaBottom),
59
+ keyboardVisible: visible,
60
+ duration,
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Whether a keyboard event belongs to the session that is listening.
66
+ *
67
+ * Keyboard events arrive asynchronously and can land after a session has
68
+ * closed — or, worse, after the *next* one has opened. Sessions carry a
69
+ * monotonic token so a late event from an earlier session is ignored rather
70
+ * than resizing the wrong composer.
71
+ */
72
+ export function isCurrentSession(eventToken: number, currentToken: number): boolean {
73
+ return eventToken === currentToken
74
+ }
75
+
76
+ /**
77
+ * Space available for the text itself, between the top chrome and the controls.
78
+ *
79
+ * Returns 0 rather than a negative number when the keyboard leaves no room —
80
+ * a caller sizing a view with this must not be handed a negative height.
81
+ */
82
+ export function composerContentHeight(
83
+ screenHeight: number,
84
+ insets: ComposerInsets,
85
+ topChrome: number,
86
+ controlsHeight: number,
87
+ ): number {
88
+ return Math.max(0, screenHeight - topChrome - controlsHeight - insets.bottom)
89
+ }
90
+
91
+ export interface Box {
92
+ width: number
93
+ height: number
94
+ }
95
+
96
+ export interface CanvasRect {
97
+ /** Size of the fitted canvas inside the container. */
98
+ width: number
99
+ height: number
100
+ /** Offset of the canvas inside the container, which centres it. */
101
+ left: number
102
+ top: number
103
+ }
104
+
105
+ /**
106
+ * Where the canvas sits inside a container, fitted to the document's aspect.
107
+ *
108
+ * The composer needs this to lay its input over the text it is editing, and it
109
+ * cannot ask the canvas: `useCanvasMetrics` is provided by `EditorCanvas` to
110
+ * its own children, and the composer is a sibling of the canvas, not a child.
111
+ * Reading it there returns `null` — which, when used as a width, silently
112
+ * produced a zero-width input that swallowed every keystroke.
113
+ *
114
+ * So the fit is recomputed here from the same rule `EditorCanvas` uses:
115
+ * contain, then centre.
116
+ */
117
+ export function fitCanvasRect(container: Box, document: Box): CanvasRect {
118
+ if (container.width <= 0 || container.height <= 0 || document.width <= 0 || document.height <= 0) {
119
+ return { width: 0, height: 0, left: 0, top: 0 }
120
+ }
121
+ const scale = Math.min(container.width / document.width, container.height / document.height)
122
+ const width = document.width * scale
123
+ const height = document.height * scale
124
+ return {
125
+ width,
126
+ height,
127
+ left: (container.width - width) / 2,
128
+ top: (container.height - height) / 2,
129
+ }
130
+ }
@@ -0,0 +1,230 @@
1
+ import {
2
+ clampTextSize,
3
+ collapseTextSpans,
4
+ createTextDraft,
5
+ needsSpanCollapse,
6
+ isBlankText,
7
+ patchTextSpan,
8
+ resolveTextSession,
9
+ textSpanOf,
10
+ textValueOf,
11
+ useEditor,
12
+ type Layer,
13
+ type TextLayerData,
14
+ type TextSpan,
15
+ } from '@pictor-sdk/core'
16
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
17
+
18
+ /**
19
+ * The state and commands behind a text-editing session.
20
+ *
21
+ * This is the whole contract a composer needs, so the default composer and any
22
+ * replacement are built on exactly the same surface — which is what makes the
23
+ * default one genuinely replaceable rather than merely swappable-in-principle.
24
+ *
25
+ * The session owns its history batch. The text tool has no bottom-sheet panel —
26
+ * the composer *is* its UI — so `ToolPanelHost` never opens one for it, and the
27
+ * session must, or every keystroke would be its own undo entry. Opening it here
28
+ * also fixes the ordering: the batch is open before the draft is created,
29
+ * which it would not be if a parent opened it in its own mount effect (effects
30
+ * run child-first).
31
+ *
32
+ * @experimental
33
+ */
34
+ export interface TextSessionController {
35
+ /** The layer being edited — a fresh draft, or the one that was selected. */
36
+ layer: Layer | undefined
37
+ /** Whether this session created the layer, which decides blank-Done. */
38
+ kind: 'draft' | 'existing'
39
+ /** Current text. Mirrors the document; drives the input's value. */
40
+ value: string
41
+ /** Replace the text. */
42
+ setValue(next: string): void
43
+ /** The span the composer styles as a whole. */
44
+ span: TextSpan | undefined
45
+ /** Full layer data, for alignment/fill/stroke/shadow controls. */
46
+ data: TextLayerData | undefined
47
+ /** Patch the styled span — font, size, colour, weight, spacing. */
48
+ styleSpan(patch: Partial<TextSpan>): void
49
+ /** Patch layer-level text data — alignment, fill, stroke, shadow, maxWidth. */
50
+ styleText(patch: Partial<TextLayerData>): void
51
+ /** Set a normalized font size, held inside the supported range. */
52
+ setSize(size: number): void
53
+ /**
54
+ * Finish. A draft that ended blank is discarded and records nothing.
55
+ *
56
+ * Returns the committed layer id, or `null` when nothing was kept.
57
+ */
58
+ commit(): string | null
59
+ /** Abandon. Restores the document to exactly its state when the tool opened. */
60
+ cancel(): void
61
+ }
62
+
63
+ /** Hooks into the composing session — chiefly what to do when it ends. */
64
+ export interface UseTextSessionOptions {
65
+ /**
66
+ * Edit this layer instead of resolving from the selection. Lets a custom UI
67
+ * request editing for a specific layer — the re-entry API.
68
+ */
69
+ layerId?: string
70
+ /** Called once the session has committed or been discarded. */
71
+ onFinished?: (layerId: string | null) => void
72
+ }
73
+
74
+ /**
75
+ * Open a text-editing session for the current selection (or `layerId`).
76
+ *
77
+ * Creates its draft on mount so the canvas shows the layer immediately and the
78
+ * caret has something to sit on.
79
+ */
80
+ export function useTextSession(options: UseTextSessionOptions = {}): TextSessionController {
81
+ const { document, dispatch, profile, selection, select } = useEditor()
82
+ const { layerId: requestedId, onFinished } = options
83
+
84
+ // Resolved once: what the session is editing must not change under the user
85
+ // because the selection moved.
86
+ const [session] = useState(() =>
87
+ resolveTextSession(document.layers, requestedId ? [requestedId] : selection),
88
+ )
89
+ const [draftId, setDraftId] = useState<string | null>(null)
90
+ const finished = useRef(false)
91
+ /**
92
+ * The selection as it was before the session opened.
93
+ *
94
+ * Rolling the *document* back is not enough: a draft replaces the selection
95
+ * with its own id, so a rollback that clears the selection leaves the editor
96
+ * in a state the user never chose. Captured once, restored on every rollback
97
+ * path — Cancel, blank-draft Done, and unmount.
98
+ */
99
+ const priorSelection = useRef<string[]>([])
100
+ const capturedPrior = useRef(false)
101
+ if (!capturedPrior.current) {
102
+ capturedPrior.current = true
103
+ priorSelection.current = [...selection]
104
+ }
105
+
106
+ const latest = useRef({ document, dispatch, select, selection, profile })
107
+ latest.current = { document, dispatch, select, selection, profile }
108
+
109
+ // Open the transaction, then create the draft inside it. Both exactly once:
110
+ // a second draft would be a second layer, and a second batch would leak depth.
111
+ useEffect(() => {
112
+ latest.current.dispatch({ type: 'history/beginBatch', label: 'text' })
113
+ if (session.kind === 'draft') {
114
+ const draft = createTextDraft(latest.current.profile)
115
+ setDraftId(draft.id)
116
+ latest.current.dispatch({ type: 'layer/add', layer: draft })
117
+ latest.current.select([draft.id])
118
+ } else if (session.layerId) {
119
+ // Whole-layer editing cannot address more than one span, so a multi-span
120
+ // layer is collapsed up front — inside the batch, so Cancel reverses it
121
+ // exactly. See `collapseTextSpans` for why.
122
+ const layer = latest.current.document.layers.find((l) => l.id === session.layerId)
123
+ const layerData = layer?.data as TextLayerData | undefined
124
+ if (layerData && needsSpanCollapse(layerData)) {
125
+ latest.current.dispatch({
126
+ type: 'layer/patchData',
127
+ id: session.layerId,
128
+ patch: { spans: collapseTextSpans(layerData).spans },
129
+ })
130
+ }
131
+ }
132
+ return () => {
133
+ // Unmounting without Done or Cancel — a tool switch, a navigation, an
134
+ // unmount mid-typing — must not leave the batch open or the edit applied.
135
+ if (finished.current) return
136
+ finished.current = true
137
+ latest.current.dispatch({ type: 'history/cancelBatch' })
138
+ latest.current.select(priorSelection.current)
139
+ }
140
+ // eslint-disable-next-line react-hooks/exhaustive-deps
141
+ }, [])
142
+
143
+ const layerId = session.kind === 'existing' ? session.layerId! : draftId
144
+ const layer = layerId ? document.layers.find((l) => l.id === layerId) : undefined
145
+ const data = layer ? (layer.data as TextLayerData) : undefined
146
+ const span = data ? textSpanOf(data) : undefined
147
+ const value = data ? textValueOf(data) : ''
148
+
149
+ // Read through refs so the commands keep a stable identity: a composer that
150
+ // re-created its callbacks each keystroke would remount its controls.
151
+ const idRef = useRef<string | null>(null)
152
+ idRef.current = layerId ?? null
153
+ const dataRef = useRef<TextLayerData | undefined>(undefined)
154
+ dataRef.current = data
155
+
156
+ const patchData = useCallback((patch: Partial<TextLayerData>) => {
157
+ const id = idRef.current
158
+ if (!id) return
159
+ latest.current.dispatch({ type: 'layer/patchData', id, patch: patch as Record<string, unknown> })
160
+ }, [])
161
+
162
+ const setValue = useCallback(
163
+ (next: string) => {
164
+ const current = dataRef.current
165
+ if (!current) return
166
+ patchData({ spans: patchTextSpan(current, { text: next }).spans })
167
+ },
168
+ [patchData],
169
+ )
170
+
171
+ const styleSpan = useCallback(
172
+ (patch: Partial<TextSpan>) => {
173
+ const current = dataRef.current
174
+ if (!current) return
175
+ patchData({ spans: patchTextSpan(current, patch).spans })
176
+ },
177
+ [patchData],
178
+ )
179
+
180
+ const setSize = useCallback(
181
+ (size: number) => styleSpan({ fontSize: clampTextSize(size) }),
182
+ [styleSpan],
183
+ )
184
+
185
+ const commit = useCallback((): string | null => {
186
+ if (finished.current) return null
187
+ finished.current = true
188
+ const id = idRef.current
189
+ const blank = isBlankText(dataRef.current ? textValueOf(dataRef.current) : '')
190
+ // A blank draft is discarded by cancelling, not by removing and committing:
191
+ // `endBatch` compares opening and closing documents by identity, so
192
+ // add-then-remove still pushes an undo entry that appears to do nothing.
193
+ if (session.kind === 'draft' && blank) {
194
+ latest.current.dispatch({ type: 'history/cancelBatch' })
195
+ latest.current.select(priorSelection.current)
196
+ onFinished?.(null)
197
+ return null
198
+ }
199
+ latest.current.dispatch({ type: 'history/endBatch' })
200
+ onFinished?.(id ?? null)
201
+ return id ?? null
202
+ }, [session.kind, onFinished])
203
+
204
+ const cancel = useCallback(() => {
205
+ if (finished.current) return
206
+ finished.current = true
207
+ latest.current.dispatch({ type: 'history/cancelBatch' })
208
+ // Restore the whole editor state, not just the document: the session may
209
+ // have moved the selection to its draft.
210
+ latest.current.select(priorSelection.current)
211
+ onFinished?.(null)
212
+ }, [onFinished])
213
+
214
+ return useMemo(
215
+ () => ({
216
+ layer,
217
+ kind: session.kind,
218
+ value,
219
+ setValue,
220
+ span,
221
+ data,
222
+ styleSpan,
223
+ styleText: patchData,
224
+ setSize,
225
+ commit,
226
+ cancel,
227
+ }),
228
+ [layer, session.kind, value, setValue, span, data, styleSpan, patchData, setSize, commit, cancel],
229
+ )
230
+ }
@@ -0,0 +1,270 @@
1
+ import {
2
+ clampCropTransform,
3
+ cropFitScale,
4
+ cropPanBounds,
5
+ focalZoomTransform,
6
+ type ImageLayerData,
7
+ type Transform,
8
+ } from '@pictor-sdk/core'
9
+ import { useCanvasMetrics } from '@pictor-sdk/skia'
10
+ import React, { useEffect, useMemo, useRef } from 'react'
11
+ import { StyleSheet } from 'react-native'
12
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler'
13
+ import Animated, { useDerivedValue, useSharedValue, withTiming } from 'react-native-reanimated'
14
+ import { runOnJS } from 'react-native-worklets'
15
+ import { useTargetImageLayer } from '../panels/useTargetImageLayer'
16
+ import { useEditor } from '@pictor-sdk/core'
17
+ import { useInteractionState } from './interactionState'
18
+
19
+ /** Overrides for crop gestures, defaulting to the profile's interaction settings. */
20
+ export interface CropGestureSurfaceProps {
21
+ /** Override profile crop fit for this surface. */
22
+ fit?: 'contain' | 'cover'
23
+ maxScale?: number
24
+ /** Enable free two-finger rotation; disabled by the default Post profile. */
25
+ rotationEnabled?: boolean
26
+ /** Optional host haptic/motion callback. */
27
+ onInteractionChange?: (active: boolean) => void
28
+ }
29
+
30
+ /**
31
+ * Fixed-viewport photo crop gestures. Pan and focal-point pinch run as shared
32
+ * values on the UI thread; one normalized transform is committed on release.
33
+ */
34
+ export function CropGestureSurface({ fit, maxScale, rotationEnabled, onInteractionChange }: CropGestureSurfaceProps) {
35
+ const { document, dispatch, profile } = useEditor()
36
+ const layer = useTargetImageLayer()
37
+ const metrics = useCanvasMetrics()
38
+ const interaction = useInteractionState()
39
+ const config = profile.interaction?.crop
40
+ const source = (layer?.data as ImageLayerData | undefined)?.intrinsic
41
+ const resolvedFit = fit ?? config?.fit ?? 'contain'
42
+ const resolvedMax = maxScale ?? config?.maxScale ?? 8
43
+ const resolvedRotation = rotationEnabled ?? config?.rotationEnabled ?? false
44
+
45
+ const liveX = useSharedValue(layer?.transform.x ?? 0.5)
46
+ const liveY = useSharedValue(layer?.transform.y ?? 0.5)
47
+ const liveScale = useSharedValue(layer?.transform.scale ?? 1)
48
+ // Seed from React state directly; reading shared values during render trips
49
+ // Reanimated strict mode on real devices.
50
+ const baseX = useSharedValue(layer?.transform.x ?? 0.5)
51
+ const baseY = useSharedValue(layer?.transform.y ?? 0.5)
52
+ const baseScale = useSharedValue(layer?.transform.scale ?? 1)
53
+ const liveRotation = useSharedValue(layer?.transform.rotation ?? 0)
54
+ const baseRotation = useSharedValue(layer?.transform.rotation ?? 0)
55
+ const panX = useSharedValue(0)
56
+ const panY = useSharedValue(0)
57
+ const pinchScale = useSharedValue(1)
58
+ const rotateDelta = useSharedValue(0)
59
+ const focalX = useSharedValue(0.5)
60
+ const focalY = useSharedValue(0.5)
61
+ const activeRecognizers = useSharedValue(0)
62
+ const commitTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
63
+
64
+ const minScale = source
65
+ ? cropFitScale(source, document.size, resolvedFit, layer?.transform.rotation ?? 0)
66
+ : 1
67
+
68
+ useEffect(() => {
69
+ if (!layer) return
70
+ liveX.value = layer.transform.x
71
+ liveY.value = layer.transform.y
72
+ liveScale.value = layer.transform.scale
73
+ liveRotation.value = layer.transform.rotation
74
+ // Shared-value identities are stable; only committed scalar values resync them.
75
+ // eslint-disable-next-line react-hooks/exhaustive-deps
76
+ }, [layer?.transform.x, layer?.transform.y, layer?.transform.scale, layer?.transform.rotation])
77
+
78
+ const previewTransform = useDerivedValue(() => {
79
+ const ratio = liveScale.value / Math.max(baseScale.value, 0.000001)
80
+ return [
81
+ { translateX: liveX.value * document.size.width },
82
+ { translateY: liveY.value * document.size.height },
83
+ { rotate: liveRotation.value - baseRotation.value },
84
+ { scale: ratio },
85
+ { translateX: -baseX.value * document.size.width },
86
+ { translateY: -baseY.value * document.size.height },
87
+ ]
88
+ }, [document.size.width, document.size.height])
89
+
90
+ useEffect(() => {
91
+ if (!layer) return
92
+ interaction.setPreview({ layerId: layer.id, transform: previewTransform })
93
+ return () => interaction.setPreview(null)
94
+ // Register by layer identity; crop-active context changes must not replace it.
95
+ // eslint-disable-next-line react-hooks/exhaustive-deps
96
+ }, [layer?.id, previewTransform, interaction.setPreview])
97
+
98
+ const setActive = (active: boolean) => {
99
+ interaction.setCropActive(active)
100
+ onInteractionChange?.(active)
101
+ }
102
+
103
+ const commit = (candidate: Transform) => {
104
+ if (!layer || !source) return
105
+ if (commitTimer.current) clearTimeout(commitTimer.current)
106
+ const settledMinScale = cropFitScale(source, document.size, resolvedFit, candidate.rotation)
107
+ const bounds = cropPanBounds(source, document.size, candidate.scale, settledMinScale, resolvedMax, candidate.rotation)
108
+ const settled = clampCropTransform(candidate, bounds)
109
+ liveX.value = withTiming(settled.x, { duration: 160 })
110
+ liveY.value = withTiming(settled.y, { duration: 160 })
111
+ liveScale.value = withTiming(settled.scale, { duration: 160 })
112
+ liveRotation.value = withTiming(settled.rotation, { duration: 160 })
113
+ commitTimer.current = setTimeout(() => {
114
+ dispatch({
115
+ type: 'layer/transform',
116
+ id: layer.id,
117
+ patch: {
118
+ x: settled.x,
119
+ y: settled.y,
120
+ scale: settled.scale,
121
+ ...(resolvedRotation ? { rotation: settled.rotation } : {}),
122
+ },
123
+ })
124
+ baseX.value = settled.x
125
+ baseY.value = settled.y
126
+ baseScale.value = settled.scale
127
+ baseRotation.value = settled.rotation
128
+ liveX.value = settled.x
129
+ liveY.value = settled.y
130
+ liveScale.value = settled.scale
131
+ liveRotation.value = settled.rotation
132
+ setActive(false)
133
+ }, 165)
134
+ }
135
+
136
+ const toggleZoom = (xDp: number, yDp: number) => {
137
+ if (!layer || !source || !metrics) return
138
+ baseX.value = layer.transform.x
139
+ baseY.value = layer.transform.y
140
+ baseScale.value = layer.transform.scale
141
+ baseRotation.value = layer.transform.rotation
142
+ liveX.value = layer.transform.x
143
+ liveY.value = layer.transform.y
144
+ liveScale.value = layer.transform.scale
145
+ liveRotation.value = layer.transform.rotation
146
+ setActive(true)
147
+ const atMinimum = Math.abs(layer.transform.scale - minScale) < 0.01
148
+ const targetScale = atMinimum
149
+ ? Math.min(resolvedMax, minScale * (config?.doubleTapScale ?? 2))
150
+ : minScale
151
+ commit(focalZoomTransform(layer.transform, targetScale, {
152
+ x: xDp / metrics.width,
153
+ y: yDp / metrics.height,
154
+ }))
155
+ }
156
+
157
+ const gesture = useMemo(() => {
158
+ if (!metrics || !source || !layer) return Gesture.Tap()
159
+ const begin = () => {
160
+ 'worklet'
161
+ if (activeRecognizers.value === 0) {
162
+ baseX.value = liveX.value
163
+ baseY.value = liveY.value
164
+ baseScale.value = liveScale.value
165
+ baseRotation.value = liveRotation.value
166
+ panX.value = 0
167
+ panY.value = 0
168
+ pinchScale.value = 1
169
+ rotateDelta.value = 0
170
+ runOnJS(setActive)(true)
171
+ }
172
+ activeRecognizers.value += 1
173
+ }
174
+ const updateLive = () => {
175
+ 'worklet'
176
+ const rotation = baseRotation.value + rotateDelta.value
177
+ const naturalWidth = document.size.width
178
+ const naturalHeight = document.size.width * (source.height / source.width)
179
+ const cos = Math.abs(Math.cos(rotation))
180
+ const sin = Math.abs(Math.sin(rotation))
181
+ const rotatedWidth = naturalWidth * cos + naturalHeight * sin
182
+ const rotatedHeight = naturalWidth * sin + naturalHeight * cos
183
+ const widthScale = document.size.width / rotatedWidth
184
+ const heightScale = document.size.height / rotatedHeight
185
+ const dynamicMin = resolvedFit === 'contain' ? Math.min(widthScale, heightScale) : Math.max(widthScale, heightScale)
186
+ const rawScale = baseScale.value * pinchScale.value
187
+ const resistance = config?.resistance ?? 0.35
188
+ const scale = rawScale < dynamicMin
189
+ ? dynamicMin - (dynamicMin - rawScale) * resistance
190
+ : rawScale > resolvedMax
191
+ ? resolvedMax + (rawScale - resolvedMax) * resistance
192
+ : rawScale
193
+ const ratio = scale / Math.max(baseScale.value, 0.000001)
194
+ const zoomX = focalX.value - (focalX.value - baseX.value) * ratio
195
+ const zoomY = focalY.value - (focalY.value - baseY.value) * ratio
196
+ liveRotation.value = rotation
197
+ const dx = Math.max(0, (rotatedWidth * scale - document.size.width) / (2 * document.size.width))
198
+ const dy = Math.max(0, (rotatedHeight * scale - document.size.height) / (2 * document.size.height))
199
+ liveScale.value = scale
200
+ const rawX = zoomX + panX.value
201
+ const rawY = zoomY + panY.value
202
+ const minX = 0.5 - dx
203
+ const maxX = 0.5 + dx
204
+ const minY = 0.5 - dy
205
+ const maxY = 0.5 + dy
206
+ liveX.value = rawX < minX ? minX - (minX - rawX) * resistance : rawX > maxX ? maxX + (rawX - maxX) * resistance : rawX
207
+ liveY.value = rawY < minY ? minY - (minY - rawY) * resistance : rawY > maxY ? maxY + (rawY - maxY) * resistance : rawY
208
+ }
209
+ const finish = () => {
210
+ 'worklet'
211
+ activeRecognizers.value = Math.max(0, activeRecognizers.value - 1)
212
+ if (activeRecognizers.value === 0) {
213
+ runOnJS(commit)({
214
+ x: liveX.value,
215
+ y: liveY.value,
216
+ scale: liveScale.value,
217
+ rotation: liveRotation.value,
218
+ ...(layer.transform.anchor ? { anchor: layer.transform.anchor } : {}),
219
+ ...(layer.transform.flipX !== undefined ? { flipX: layer.transform.flipX } : {}),
220
+ ...(layer.transform.flipY !== undefined ? { flipY: layer.transform.flipY } : {}),
221
+ })
222
+ }
223
+ }
224
+ const pan = Gesture.Pan()
225
+ .minPointers(1)
226
+ .onStart(begin)
227
+ .onUpdate((event) => {
228
+ 'worklet'
229
+ panX.value = event.translationX / metrics.width
230
+ panY.value = event.translationY / metrics.height
231
+ updateLive()
232
+ })
233
+ .onFinalize(finish)
234
+ const pinch = Gesture.Pinch()
235
+ .onStart(begin)
236
+ .onUpdate((event) => {
237
+ 'worklet'
238
+ pinchScale.value = event.scale
239
+ focalX.value = event.focalX / metrics.width
240
+ focalY.value = event.focalY / metrics.height
241
+ updateLive()
242
+ })
243
+ .onFinalize(finish)
244
+ const rotate = Gesture.Rotation()
245
+ .enabled(resolvedRotation)
246
+ .onStart(begin)
247
+ .onUpdate((event) => {
248
+ 'worklet'
249
+ rotateDelta.value = event.rotation
250
+ updateLive()
251
+ })
252
+ .onFinalize(finish)
253
+ const doubleTap = Gesture.Tap()
254
+ .numberOfTaps(2)
255
+ .onEnd((event) => {
256
+ 'worklet'
257
+ runOnJS(toggleZoom)(event.x, event.y)
258
+ })
259
+ return Gesture.Exclusive(doubleTap, Gesture.Simultaneous(pan, pinch, rotate))
260
+ // Gesture worklets read stable shared values; recreate only for geometry/config changes.
261
+ // eslint-disable-next-line react-hooks/exhaustive-deps
262
+ }, [metrics, source, layer, resolvedFit, resolvedMax, resolvedRotation, minScale, document.size.width, document.size.height, config?.resistance, config?.doubleTapScale])
263
+
264
+ if (!layer) return null
265
+ return (
266
+ <GestureDetector gesture={gesture}>
267
+ <Animated.View style={StyleSheet.absoluteFill} />
268
+ </GestureDetector>
269
+ )
270
+ }
@@ -0,0 +1,24 @@
1
+ import React from 'react'
2
+ import { StyleSheet, View } from 'react-native'
3
+ import { useInteractionState } from './interactionState'
4
+
5
+ /** Rule-of-thirds overlay shown only while crop interaction is active. */
6
+ export function CropGrid({ always = false }: { always?: boolean }) {
7
+ const { cropActive } = useInteractionState()
8
+ if (!always && !cropActive) return null
9
+ return (
10
+ <View pointerEvents="none" style={StyleSheet.absoluteFill}>
11
+ {[1 / 3, 2 / 3].map((fraction) => (
12
+ <React.Fragment key={fraction}>
13
+ <View style={[styles.vertical, { left: `${fraction * 100}%` }]} />
14
+ <View style={[styles.horizontal, { top: `${fraction * 100}%` }]} />
15
+ </React.Fragment>
16
+ ))}
17
+ </View>
18
+ )
19
+ }
20
+
21
+ const styles = StyleSheet.create({
22
+ vertical: { position: 'absolute', top: 0, bottom: 0, width: 1, backgroundColor: '#ffffff88' },
23
+ horizontal: { position: 'absolute', left: 0, right: 0, height: 1, backgroundColor: '#ffffff88' },
24
+ })