@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,517 @@
|
|
|
1
|
+
import {
|
|
2
|
+
doubleTapMediaTransform,
|
|
3
|
+
isBaseImageLayer,
|
|
4
|
+
isSameTransform,
|
|
5
|
+
mediaConfigFromProfile,
|
|
6
|
+
useEditor,
|
|
7
|
+
type ImageLayerData,
|
|
8
|
+
type Layer,
|
|
9
|
+
type Transform,
|
|
10
|
+
} from '@pictor-sdk/core'
|
|
11
|
+
import { preferResolvedSize, useCanvasMetrics, useResolvedImageSize } from '@pictor-sdk/skia'
|
|
12
|
+
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
|
|
13
|
+
import { StyleSheet } from 'react-native'
|
|
14
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
|
15
|
+
import Animated, {
|
|
16
|
+
cancelAnimation,
|
|
17
|
+
useDerivedValue,
|
|
18
|
+
useSharedValue,
|
|
19
|
+
withTiming,
|
|
20
|
+
} from 'react-native-reanimated'
|
|
21
|
+
import { runOnJS } from 'react-native-worklets'
|
|
22
|
+
import {
|
|
23
|
+
composeMediaGestureUI,
|
|
24
|
+
onRecognizerBegin,
|
|
25
|
+
onRecognizerEnd,
|
|
26
|
+
} from './mediaGestureWorklet'
|
|
27
|
+
import { createSettleCoordinator, type SettleCoordinator } from './settleCoordinator'
|
|
28
|
+
import {
|
|
29
|
+
beginOwnership,
|
|
30
|
+
commitOwnership,
|
|
31
|
+
documentChanged,
|
|
32
|
+
IDLE_OWNERSHIP,
|
|
33
|
+
type OwnershipState,
|
|
34
|
+
} from './transformOwnership'
|
|
35
|
+
import { useInteractionState } from './interactionState'
|
|
36
|
+
|
|
37
|
+
/** Overrides for base-media gestures, defaulting to the profile's settings. */
|
|
38
|
+
export interface StoryMediaGestureSurfaceProps {
|
|
39
|
+
/** Fired when a media gesture starts and ends — for host haptics/chrome. */
|
|
40
|
+
onInteractionChange?: (active: boolean) => void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Find the document's base media — the bottom-most layer, if it is an image. */
|
|
44
|
+
export function useBaseMediaLayer(): Layer | undefined {
|
|
45
|
+
const { document } = useEditor()
|
|
46
|
+
const base = document.layers[0]
|
|
47
|
+
return base && isBaseImageLayer(document.layers, base.id) ? base : undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Direct manipulation of the Story base photo: one-finger reposition, focal
|
|
52
|
+
* pinch and rotation, held inside rotation-aware pan bounds with rubber-band
|
|
53
|
+
* resistance and a bounded settle.
|
|
54
|
+
*
|
|
55
|
+
* Mounted only while the `media` tool is active, so it cannot compete with
|
|
56
|
+
* overlay or drawing gestures. The transform is written to the document once
|
|
57
|
+
* per settled gesture; because the tool session already opened a history batch
|
|
58
|
+
* (see `ToolPanelHost`), a whole session of adjustments collapses into one undo
|
|
59
|
+
* entry, and a session that changed nothing records none.
|
|
60
|
+
*/
|
|
61
|
+
export function StoryMediaGestureSurface({
|
|
62
|
+
onInteractionChange,
|
|
63
|
+
}: StoryMediaGestureSurfaceProps) {
|
|
64
|
+
const { document, dispatch, profile } = useEditor()
|
|
65
|
+
const metrics = useCanvasMetrics()
|
|
66
|
+
const { setPreview } = useInteractionState()
|
|
67
|
+
const layer = useBaseMediaLayer()
|
|
68
|
+
|
|
69
|
+
const config = useMemo(
|
|
70
|
+
() => mediaConfigFromProfile(profile.interaction?.media),
|
|
71
|
+
[profile],
|
|
72
|
+
)
|
|
73
|
+
// The renderer sizes the layer from the DECODED image, so geometry must use
|
|
74
|
+
// the same number. A declared intrinsic of 0x0 is normal for a remote URL.
|
|
75
|
+
const declared = (layer?.data as ImageLayerData | undefined)?.intrinsic
|
|
76
|
+
const resolved = useResolvedImageSize((layer?.data as ImageLayerData | undefined)?.source)
|
|
77
|
+
const intrinsic = useMemo(
|
|
78
|
+
() => preferResolvedSize(resolved, declared),
|
|
79
|
+
[resolved, declared],
|
|
80
|
+
)
|
|
81
|
+
const canvas = document.size
|
|
82
|
+
|
|
83
|
+
// ── live transform (UI thread) ──
|
|
84
|
+
const liveX = useSharedValue(0.5)
|
|
85
|
+
const liveY = useSharedValue(0.5)
|
|
86
|
+
const liveScale = useSharedValue(1)
|
|
87
|
+
const liveRotation = useSharedValue(0)
|
|
88
|
+
// ── frame base, re-captured on every pointer-count change ──
|
|
89
|
+
const baseX = useSharedValue(0.5)
|
|
90
|
+
const baseY = useSharedValue(0.5)
|
|
91
|
+
const baseScale = useSharedValue(1)
|
|
92
|
+
const baseRotation = useSharedValue(0)
|
|
93
|
+
// ── recognizer origins, subtracted so each frame starts from zero ──
|
|
94
|
+
const originPanX = useSharedValue(0)
|
|
95
|
+
const originPanY = useSharedValue(0)
|
|
96
|
+
const originPinch = useSharedValue(1)
|
|
97
|
+
const originRotate = useSharedValue(0)
|
|
98
|
+
const rawPanX = useSharedValue(0)
|
|
99
|
+
const rawPanY = useSharedValue(0)
|
|
100
|
+
const rawPinch = useSharedValue(1)
|
|
101
|
+
const rawRotate = useSharedValue(0)
|
|
102
|
+
const focalX = useSharedValue(0.5)
|
|
103
|
+
const focalY = useSharedValue(0.5)
|
|
104
|
+
const active = useSharedValue(false)
|
|
105
|
+
/** How many of pan/pinch/rotation are still live. */
|
|
106
|
+
const recognizers = useSharedValue(0)
|
|
107
|
+
/**
|
|
108
|
+
* Whether each recognizer actually activated.
|
|
109
|
+
*
|
|
110
|
+
* `onBegin` fires on touch-down for every recognizer, including the taps of a
|
|
111
|
+
* double-tap that `Gesture.Exclusive` will go on to win. Ownership has to key
|
|
112
|
+
* off activation instead, or a plain tap opens and closes a whole interaction
|
|
113
|
+
* -- registering a preview, settling and committing -- underneath the
|
|
114
|
+
* double-tap animation it is racing.
|
|
115
|
+
*/
|
|
116
|
+
const startedPan = useSharedValue(false)
|
|
117
|
+
const startedPinch = useSharedValue(false)
|
|
118
|
+
const startedRotate = useSharedValue(false)
|
|
119
|
+
/**
|
|
120
|
+
* Drives the settle's timing, and nothing else.
|
|
121
|
+
*
|
|
122
|
+
* The commit has to happen when the settle *finishes*, which means some
|
|
123
|
+
* animation has to report completion. Using one of the four transform
|
|
124
|
+
* animations for that is a trap: a gesture clamped in position but not scale
|
|
125
|
+
* ends with `liveScale` already at its target, and `withTiming` from a value
|
|
126
|
+
* to itself completes in a single frame. That committed 6ms into a 180ms
|
|
127
|
+
* settle and cut the x/y animations off before they had moved. This value
|
|
128
|
+
* always travels 0 -> 1, so it always takes the full duration.
|
|
129
|
+
*/
|
|
130
|
+
const settleProgress = useSharedValue(0)
|
|
131
|
+
|
|
132
|
+
const committed = layer?.transform
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Ownership of the transform, as a plain ref.
|
|
136
|
+
*
|
|
137
|
+
* Deliberately not a shared value: this is read during render on the JS
|
|
138
|
+
* thread, and a shared value written on the UI thread reaches the JS side
|
|
139
|
+
* only asynchronously, so the guard could read stale and skip the reseed.
|
|
140
|
+
* Every writer here runs on the JS thread.
|
|
141
|
+
*/
|
|
142
|
+
const ownership = useRef<OwnershipState>(IDLE_OWNERSHIP)
|
|
143
|
+
|
|
144
|
+
// Re-seed from the document whenever the committed transform changes from
|
|
145
|
+
// outside the gesture (undo, Cancel, a fresh photo) and whenever a gesture
|
|
146
|
+
// commits — the preview is a delta from these, so they must track the
|
|
147
|
+
// document exactly.
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
const next = documentChanged(ownership.current, committed)
|
|
150
|
+
ownership.current = next.state
|
|
151
|
+
if (!next.reseed || !committed) return
|
|
152
|
+
liveX.value = committed.x
|
|
153
|
+
liveY.value = committed.y
|
|
154
|
+
liveScale.value = committed.scale
|
|
155
|
+
liveRotation.value = committed.rotation
|
|
156
|
+
// shared values are stable refs; only committed scalars resync them
|
|
157
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
158
|
+
}, [committed?.x, committed?.y, committed?.scale, committed?.rotation])
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The transform the canvas already drew the layer at, captured when the
|
|
162
|
+
* interaction begins.
|
|
163
|
+
*
|
|
164
|
+
* The preview is a *delta* from it, so the two must never disagree. Deriving
|
|
165
|
+
* it from the document each render sounds safer but is worse: the preview is
|
|
166
|
+
* registered from an effect, so on the render that commits, the canvas holds
|
|
167
|
+
* the new document transform and still the previous preview — one frame of
|
|
168
|
+
* the delta applied to the wrong baseline. Capturing it at grab sidesteps the
|
|
169
|
+
* question entirely, because the document cannot change during an
|
|
170
|
+
* interaction, and the preview is cleared in the same call that dispatches.
|
|
171
|
+
*/
|
|
172
|
+
const baseCommittedX = useSharedValue(0.5)
|
|
173
|
+
const baseCommittedY = useSharedValue(0.5)
|
|
174
|
+
const baseCommittedScale = useSharedValue(1)
|
|
175
|
+
const baseCommittedRotation = useSharedValue(0)
|
|
176
|
+
|
|
177
|
+
const previewTransform = useDerivedValue(() => {
|
|
178
|
+
const ratio = liveScale.value / Math.max(baseCommittedScale.value, 1e-9)
|
|
179
|
+
return [
|
|
180
|
+
{ translateX: liveX.value * canvas.width },
|
|
181
|
+
{ translateY: liveY.value * canvas.height },
|
|
182
|
+
{ rotate: liveRotation.value - baseCommittedRotation.value },
|
|
183
|
+
{ scale: ratio },
|
|
184
|
+
{ translateX: -baseCommittedX.value * canvas.width },
|
|
185
|
+
{ translateY: -baseCommittedY.value * canvas.height },
|
|
186
|
+
]
|
|
187
|
+
}, [canvas.width, canvas.height])
|
|
188
|
+
|
|
189
|
+
useEffect(() => () => setPreview(null), [setPreview])
|
|
190
|
+
|
|
191
|
+
const latest = useRef({ layer, dispatch, onInteractionChange, previewTransform })
|
|
192
|
+
latest.current = { layer, dispatch, onInteractionChange, previewTransform }
|
|
193
|
+
const settleCoordinatorRef = useRef<SettleCoordinator<Transform> | null>(null)
|
|
194
|
+
|
|
195
|
+
/** Hand the transform to the gesture: stop any settle, start previewing. */
|
|
196
|
+
const beginInteraction = useCallback(() => {
|
|
197
|
+
// A touch that lands during a settle takes ownership of the transform, so
|
|
198
|
+
// the settle in flight must never commit its stale target. This runs in the
|
|
199
|
+
// same `runOnJS` hop `begin()` already makes, and the interrupted
|
|
200
|
+
// animation's own completion callback is queued ahead of it, so there is no
|
|
201
|
+
// window in which the old settle can still write.
|
|
202
|
+
settleCoordinatorRef.current?.cancel()
|
|
203
|
+
ownership.current = beginOwnership()
|
|
204
|
+
const current = latest.current.layer
|
|
205
|
+
if (current) {
|
|
206
|
+
// snapshot the baseline the canvas is drawing at, then start previewing
|
|
207
|
+
baseCommittedX.value = current.transform.x
|
|
208
|
+
baseCommittedY.value = current.transform.y
|
|
209
|
+
baseCommittedScale.value = current.transform.scale
|
|
210
|
+
baseCommittedRotation.value = current.transform.rotation
|
|
211
|
+
setPreview({ layerId: current.id, transform: latest.current.previewTransform })
|
|
212
|
+
}
|
|
213
|
+
latest.current.onInteractionChange?.(true)
|
|
214
|
+
// shared values are stable refs
|
|
215
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
216
|
+
}, [setPreview])
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Write the settled transform. One document write per gesture — and none at
|
|
220
|
+
* all when the gesture settled exactly where it started, so a tap or a
|
|
221
|
+
* there-and-back drag leaves the session clean.
|
|
222
|
+
*/
|
|
223
|
+
const commitNow = useCallback((next: Transform) => {
|
|
224
|
+
const current = latest.current.layer
|
|
225
|
+
// Cleared in the same call that dispatches, so React batches them into one
|
|
226
|
+
// render: the delta goes away exactly as the document takes the transform.
|
|
227
|
+
setPreview(null)
|
|
228
|
+
latest.current.onInteractionChange?.(false)
|
|
229
|
+
// A no-op commit means no document update is coming, so ownership ends
|
|
230
|
+
// here. Otherwise it lasts until the document reports `next` back — until
|
|
231
|
+
// then `live` already holds it, and reseeding would flash the old value.
|
|
232
|
+
const dispatched = current !== undefined && !isSameTransform(current.transform, next)
|
|
233
|
+
ownership.current = commitOwnership(next, dispatched, current?.transform)
|
|
234
|
+
if (!dispatched || !current) return
|
|
235
|
+
latest.current.dispatch({
|
|
236
|
+
type: 'layer/transform',
|
|
237
|
+
id: current.id,
|
|
238
|
+
patch: { x: next.x, y: next.y, scale: next.scale, rotation: next.rotation },
|
|
239
|
+
})
|
|
240
|
+
}, [setPreview])
|
|
241
|
+
|
|
242
|
+
const settleCoordinator = useMemo(
|
|
243
|
+
() => createSettleCoordinator<Transform>((target) => commitNow(target)),
|
|
244
|
+
// commitNow reads through `latest`, so it is stable
|
|
245
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
246
|
+
[],
|
|
247
|
+
)
|
|
248
|
+
// `beginInteraction` is created before the coordinator exists, so it reaches it here.
|
|
249
|
+
settleCoordinatorRef.current = settleCoordinator
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Animate to `target`, then write it. The write happens in the animation's
|
|
253
|
+
* completion callback — writing first would change `committed` and cancel the
|
|
254
|
+
* very animation heading there.
|
|
255
|
+
*/
|
|
256
|
+
const settleTo = useCallback(
|
|
257
|
+
(target: Transform, duration: number) => {
|
|
258
|
+
settleCoordinator.settle(target, (done) => {
|
|
259
|
+
liveX.value = withTiming(target.x, { duration })
|
|
260
|
+
liveY.value = withTiming(target.y, { duration })
|
|
261
|
+
liveRotation.value = withTiming(target.rotation, { duration })
|
|
262
|
+
liveScale.value = withTiming(target.scale, { duration })
|
|
263
|
+
// the timekeeper: never degenerate, so it always runs the full duration
|
|
264
|
+
settleProgress.value = 0
|
|
265
|
+
settleProgress.value = withTiming(1, { duration }, (finished) => {
|
|
266
|
+
'worklet'
|
|
267
|
+
runOnJS(done)(finished === true)
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
},
|
|
271
|
+
// shared values are stable refs, so they are deliberately not dependencies
|
|
272
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
273
|
+
[settleCoordinator],
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
// Abandon a settle in flight if the tool closes or the surface unmounts, so
|
|
277
|
+
// no stale transform is written after the session has gone.
|
|
278
|
+
useEffect(
|
|
279
|
+
() => () => {
|
|
280
|
+
settleCoordinator.cancel()
|
|
281
|
+
ownership.current = IDLE_OWNERSHIP
|
|
282
|
+
cancelAnimation(liveX)
|
|
283
|
+
cancelAnimation(liveY)
|
|
284
|
+
cancelAnimation(liveScale)
|
|
285
|
+
cancelAnimation(liveRotation)
|
|
286
|
+
cancelAnimation(settleProgress)
|
|
287
|
+
},
|
|
288
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
289
|
+
[settleCoordinator],
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
const runDoubleTap = useCallback(
|
|
293
|
+
(xDp: number, yDp: number) => {
|
|
294
|
+
const current = latest.current.layer
|
|
295
|
+
if (!current || !metrics) return
|
|
296
|
+
const target = doubleTapMediaTransform(current.transform, canvas, intrinsic, config, {
|
|
297
|
+
x: xDp / metrics.width,
|
|
298
|
+
y: yDp / metrics.height,
|
|
299
|
+
})
|
|
300
|
+
// the animation drives the live values, so it needs the preview too
|
|
301
|
+
beginInteraction()
|
|
302
|
+
settleTo(target, config.settleMs)
|
|
303
|
+
},
|
|
304
|
+
[metrics, canvas, intrinsic, config, settleTo, beginInteraction],
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
const gesture = useMemo(() => {
|
|
308
|
+
const width = metrics?.width ?? 0
|
|
309
|
+
const height = metrics?.height ?? 0
|
|
310
|
+
const settleMs = config.settleMs
|
|
311
|
+
|
|
312
|
+
const recompute = () => {
|
|
313
|
+
'worklet'
|
|
314
|
+
if (!active.value || width === 0 || height === 0) return
|
|
315
|
+
const out = composeMediaGestureUI(
|
|
316
|
+
{
|
|
317
|
+
x: baseX.value,
|
|
318
|
+
y: baseY.value,
|
|
319
|
+
scale: baseScale.value,
|
|
320
|
+
rotation: baseRotation.value,
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
panX: (rawPanX.value - originPanX.value) / width,
|
|
324
|
+
panY: (rawPanY.value - originPanY.value) / height,
|
|
325
|
+
pinch: rawPinch.value / Math.max(originPinch.value, 1e-9),
|
|
326
|
+
rotate: rawRotate.value - originRotate.value,
|
|
327
|
+
focalX: focalX.value,
|
|
328
|
+
focalY: focalY.value,
|
|
329
|
+
},
|
|
330
|
+
canvas,
|
|
331
|
+
intrinsic,
|
|
332
|
+
config,
|
|
333
|
+
false,
|
|
334
|
+
)
|
|
335
|
+
liveX.value = out.x
|
|
336
|
+
liveY.value = out.y
|
|
337
|
+
liveScale.value = out.scale
|
|
338
|
+
liveRotation.value = out.rotation
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const begin = () => {
|
|
342
|
+
'worklet'
|
|
343
|
+
const started = onRecognizerBegin(recognizers.value)
|
|
344
|
+
recognizers.value = started.count
|
|
345
|
+
// A recognizer joining an interaction already in flight seeds its own
|
|
346
|
+
// raw/origin pair in its onStart and needs nothing else: each one's
|
|
347
|
+
// contribution is measured from its own activation, so they compose
|
|
348
|
+
// without re-anchoring the frame.
|
|
349
|
+
if (!started.isFirst) return
|
|
350
|
+
// Take the transform away from any settle in flight. The animations are
|
|
351
|
+
// stopped first so `live*` holds what is actually on screen, and that is
|
|
352
|
+
// what the new gesture bases itself on — grabbing a photo mid-settle
|
|
353
|
+
// continues from where the finger sees it, with no jump.
|
|
354
|
+
cancelAnimation(liveX)
|
|
355
|
+
cancelAnimation(liveY)
|
|
356
|
+
cancelAnimation(liveScale)
|
|
357
|
+
cancelAnimation(liveRotation)
|
|
358
|
+
cancelAnimation(settleProgress)
|
|
359
|
+
active.value = true
|
|
360
|
+
// each recognizer seeds its own raw/origin pair in its onStart; anything
|
|
361
|
+
// that has not activated contributes a neutral value
|
|
362
|
+
rawPanX.value = 0
|
|
363
|
+
rawPanY.value = 0
|
|
364
|
+
rawPinch.value = 1
|
|
365
|
+
rawRotate.value = 0
|
|
366
|
+
originPanX.value = 0
|
|
367
|
+
originPanY.value = 0
|
|
368
|
+
originPinch.value = 1
|
|
369
|
+
originRotate.value = 0
|
|
370
|
+
baseX.value = liveX.value
|
|
371
|
+
baseY.value = liveY.value
|
|
372
|
+
baseScale.value = liveScale.value
|
|
373
|
+
baseRotation.value = liveRotation.value
|
|
374
|
+
focalX.value = liveX.value
|
|
375
|
+
focalY.value = liveY.value
|
|
376
|
+
runOnJS(beginInteraction)()
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const finish = () => {
|
|
380
|
+
'worklet'
|
|
381
|
+
const ended = onRecognizerEnd(recognizers.value)
|
|
382
|
+
recognizers.value = ended.count
|
|
383
|
+
// Pinch and rotation finalise as soon as a finger lifts while the pan
|
|
384
|
+
// recognizer keeps running with the remaining one. Ending here would cut
|
|
385
|
+
// the interaction short, so wait for the last recognizer.
|
|
386
|
+
if (!ended.isLast || !active.value) return
|
|
387
|
+
active.value = false
|
|
388
|
+
// settle: hard-clamp back inside the bounds, then animate there
|
|
389
|
+
const settled = composeMediaGestureUI(
|
|
390
|
+
{
|
|
391
|
+
x: baseX.value,
|
|
392
|
+
y: baseY.value,
|
|
393
|
+
scale: baseScale.value,
|
|
394
|
+
rotation: baseRotation.value,
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
panX: (rawPanX.value - originPanX.value) / width,
|
|
398
|
+
panY: (rawPanY.value - originPanY.value) / height,
|
|
399
|
+
pinch: rawPinch.value / Math.max(originPinch.value, 1e-9),
|
|
400
|
+
rotate: rawRotate.value - originRotate.value,
|
|
401
|
+
focalX: focalX.value,
|
|
402
|
+
focalY: focalY.value,
|
|
403
|
+
},
|
|
404
|
+
canvas,
|
|
405
|
+
intrinsic,
|
|
406
|
+
config,
|
|
407
|
+
true,
|
|
408
|
+
)
|
|
409
|
+
runOnJS(settleTo)(settled, settleMs)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const pan = Gesture.Pan()
|
|
413
|
+
.minPointers(1)
|
|
414
|
+
.maxPointers(2)
|
|
415
|
+
// A recognizer only activates once the finger has travelled past the
|
|
416
|
+
// touch slop, and by then its cumulative value already includes that
|
|
417
|
+
// travel. Taking the origin at activation is what keeps the photo still
|
|
418
|
+
// under the finger; taking it at touch-down makes it leap by the slop.
|
|
419
|
+
.onStart((e) => {
|
|
420
|
+
'worklet'
|
|
421
|
+
startedPan.value = true
|
|
422
|
+
begin()
|
|
423
|
+
rawPanX.value = e.translationX
|
|
424
|
+
rawPanY.value = e.translationY
|
|
425
|
+
originPanX.value = e.translationX
|
|
426
|
+
originPanY.value = e.translationY
|
|
427
|
+
})
|
|
428
|
+
.onUpdate((e) => {
|
|
429
|
+
'worklet'
|
|
430
|
+
rawPanX.value = e.translationX
|
|
431
|
+
rawPanY.value = e.translationY
|
|
432
|
+
recompute()
|
|
433
|
+
})
|
|
434
|
+
.onFinalize(() => {
|
|
435
|
+
'worklet'
|
|
436
|
+
if (!startedPan.value) return
|
|
437
|
+
startedPan.value = false
|
|
438
|
+
finish()
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
const pinch = Gesture.Pinch()
|
|
442
|
+
.onStart((e) => {
|
|
443
|
+
'worklet'
|
|
444
|
+
startedPinch.value = true
|
|
445
|
+
begin()
|
|
446
|
+
rawPinch.value = e.scale
|
|
447
|
+
originPinch.value = e.scale
|
|
448
|
+
focalX.value = e.focalX / width
|
|
449
|
+
focalY.value = e.focalY / height
|
|
450
|
+
})
|
|
451
|
+
.onUpdate((e) => {
|
|
452
|
+
'worklet'
|
|
453
|
+
rawPinch.value = e.scale
|
|
454
|
+
focalX.value = e.focalX / width
|
|
455
|
+
focalY.value = e.focalY / height
|
|
456
|
+
recompute()
|
|
457
|
+
})
|
|
458
|
+
.onFinalize(() => {
|
|
459
|
+
'worklet'
|
|
460
|
+
if (!startedPinch.value) return
|
|
461
|
+
startedPinch.value = false
|
|
462
|
+
finish()
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
const rotate = Gesture.Rotation()
|
|
466
|
+
.enabled(config.rotationEnabled)
|
|
467
|
+
.onStart((e) => {
|
|
468
|
+
'worklet'
|
|
469
|
+
startedRotate.value = true
|
|
470
|
+
begin()
|
|
471
|
+
rawRotate.value = e.rotation
|
|
472
|
+
originRotate.value = e.rotation
|
|
473
|
+
})
|
|
474
|
+
.onUpdate((e) => {
|
|
475
|
+
'worklet'
|
|
476
|
+
// Only the pinch owns the focal point. Gesture Handler's rotation
|
|
477
|
+
// anchor and pinch focal are computed separately and disagree by a
|
|
478
|
+
// pixel or two, so letting both write it made the focal — and with it
|
|
479
|
+
// the whole composed transform — oscillate every frame.
|
|
480
|
+
rawRotate.value = e.rotation
|
|
481
|
+
recompute()
|
|
482
|
+
})
|
|
483
|
+
.onFinalize(() => {
|
|
484
|
+
'worklet'
|
|
485
|
+
if (!startedRotate.value) return
|
|
486
|
+
startedRotate.value = false
|
|
487
|
+
finish()
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
const doubleTap = Gesture.Tap()
|
|
491
|
+
.numberOfTaps(2)
|
|
492
|
+
.onEnd((e) => {
|
|
493
|
+
'worklet'
|
|
494
|
+
runOnJS(runDoubleTap)(e.x, e.y)
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
// `Exclusive(doubleTap, ...)` would make the pan wait for the double-tap to
|
|
498
|
+
// *fail* before it may activate — and a two-tap gesture only fails once the
|
|
499
|
+
// finger has moved past its delta threshold or its timing windows expire,
|
|
500
|
+
// up to half a second. That gated the start of every drag, which is felt as
|
|
501
|
+
// lag however fast the rest of the pipeline is.
|
|
502
|
+
//
|
|
503
|
+
// Racing them instead lets the pan activate the moment it passes touch
|
|
504
|
+
// slop, while a stationary double-tap still wins because a tap never moves
|
|
505
|
+
// far enough to activate the pan. This is the composition the overlay
|
|
506
|
+
// surface uses, which is the one that feels immediate.
|
|
507
|
+
return Gesture.Simultaneous(Gesture.Race(pan, doubleTap), pinch, rotate)
|
|
508
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
509
|
+
}, [metrics, canvas, intrinsic, config, settleTo, beginInteraction, runDoubleTap])
|
|
510
|
+
|
|
511
|
+
if (!layer) return null
|
|
512
|
+
return (
|
|
513
|
+
<GestureDetector gesture={gesture}>
|
|
514
|
+
<Animated.View style={StyleSheet.absoluteFill} />
|
|
515
|
+
</GestureDetector>
|
|
516
|
+
)
|
|
517
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import {
|
|
2
|
+
moveTiltCenter,
|
|
3
|
+
needsTiltShift,
|
|
4
|
+
rotateTiltBand,
|
|
5
|
+
scaleTiltRadius,
|
|
6
|
+
tiltShiftPatch,
|
|
7
|
+
useEditor,
|
|
8
|
+
type ImageLayerData,
|
|
9
|
+
type TiltShift,
|
|
10
|
+
} from '@pictor-sdk/core'
|
|
11
|
+
import { useCanvasMetrics } from '@pictor-sdk/skia'
|
|
12
|
+
import React, { useMemo, useRef } from 'react'
|
|
13
|
+
import { StyleSheet } from 'react-native'
|
|
14
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
|
15
|
+
import Animated, { useAnimatedStyle, useSharedValue } from 'react-native-reanimated'
|
|
16
|
+
import { runOnJS } from 'react-native-worklets'
|
|
17
|
+
import { useTargetImageLayer } from '../panels/useTargetImageLayer'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Placing the focus region: drag to move it, pinch to size it, twist to turn a
|
|
21
|
+
* band.
|
|
22
|
+
*
|
|
23
|
+
* **Why the blur does not follow the finger.** The focus geometry lives in the
|
|
24
|
+
* document and the shader reads it from there, so updating it per frame would
|
|
25
|
+
* mean dispatching per frame — exactly the React-state-per-frame rule this
|
|
26
|
+
* codebase forbids. Instead the gesture drives a **transient indicator**
|
|
27
|
+
* computed entirely on the UI thread, and commits one normalized value on
|
|
28
|
+
* release inside the tool's batch. That is also what the research pass measured
|
|
29
|
+
* on the reference implementation: no focus ring is drawn persistently, and the
|
|
30
|
+
* indicator appears only during interaction.
|
|
31
|
+
*
|
|
32
|
+
* Composition follows the media surface's rules: `Simultaneous(pan, pinch,
|
|
33
|
+
* rotate)`, never `Exclusive` against a tap — an `Exclusive` gate makes every
|
|
34
|
+
* drag wait for a tap to fail first, which is what made the story surface feel
|
|
35
|
+
* laggy. The origin is captured once when the first recogniser activates, so a
|
|
36
|
+
* second finger landing mid-gesture cannot re-base the interaction on a value
|
|
37
|
+
* it is itself changing.
|
|
38
|
+
*/
|
|
39
|
+
export function TiltShiftGestureSurface() {
|
|
40
|
+
const { dispatch } = useEditor()
|
|
41
|
+
const layer = useTargetImageLayer()
|
|
42
|
+
const metrics = useCanvasMetrics()
|
|
43
|
+
const tilt = (layer?.data as ImageLayerData | undefined)?.tiltShift
|
|
44
|
+
const active = needsTiltShift(tilt)
|
|
45
|
+
|
|
46
|
+
// The value the whole interaction is measured from. A ref, not state: it is
|
|
47
|
+
// read by the commit on the JS thread and never rendered.
|
|
48
|
+
const origin = useRef<TiltShift | null>(null)
|
|
49
|
+
const live = useRef<TiltShift | null>(null)
|
|
50
|
+
live.current = active ? tilt! : null
|
|
51
|
+
|
|
52
|
+
const width = metrics?.width ?? 0
|
|
53
|
+
const height = metrics?.height ?? 0
|
|
54
|
+
|
|
55
|
+
// Origin geometry in canvas dp, mirrored so the indicator needs no JS.
|
|
56
|
+
const oX = useSharedValue(0)
|
|
57
|
+
const oY = useSharedValue(0)
|
|
58
|
+
const oR = useSharedValue(0)
|
|
59
|
+
const oRot = useSharedValue(0)
|
|
60
|
+
const oLinear = useSharedValue(0)
|
|
61
|
+
const visible = useSharedValue(0)
|
|
62
|
+
// Live totals from each recogniser. Every one reports its own running total
|
|
63
|
+
// against the origin, so they combine without accumulating.
|
|
64
|
+
const dx = useSharedValue(0)
|
|
65
|
+
const dy = useSharedValue(0)
|
|
66
|
+
const pinch = useSharedValue(1)
|
|
67
|
+
const twist = useSharedValue(0)
|
|
68
|
+
const activeCount = useSharedValue(0)
|
|
69
|
+
|
|
70
|
+
const capture = useMemo(
|
|
71
|
+
() => () => {
|
|
72
|
+
const from = live.current
|
|
73
|
+
if (!from || origin.current) return
|
|
74
|
+
origin.current = from
|
|
75
|
+
oX.value = from.x * width
|
|
76
|
+
oY.value = from.y * height
|
|
77
|
+
oR.value = from.radius * width
|
|
78
|
+
oRot.value = from.rotation
|
|
79
|
+
oLinear.value = from.mode === 'linear' ? 1 : 0
|
|
80
|
+
},
|
|
81
|
+
[width, height, oX, oY, oR, oRot, oLinear],
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
const commit = useMemo(
|
|
85
|
+
() => (tx: number, ty: number, scale: number, rotation: number) => {
|
|
86
|
+
const from = origin.current
|
|
87
|
+
origin.current = null
|
|
88
|
+
if (!from || !layer || width <= 0 || height <= 0) return
|
|
89
|
+
// Each transform applied once, from the captured origin.
|
|
90
|
+
let next = moveTiltCenter(from, tx / width, ty / height)
|
|
91
|
+
next = scaleTiltRadius({ ...next, radius: from.radius, feather: from.feather }, scale)
|
|
92
|
+
next = rotateTiltBand({ ...next, rotation: from.rotation }, rotation)
|
|
93
|
+
dispatch({
|
|
94
|
+
type: 'layer/patchData',
|
|
95
|
+
id: layer.id,
|
|
96
|
+
patch: tiltShiftPatch(next) as unknown as Record<string, unknown>,
|
|
97
|
+
})
|
|
98
|
+
},
|
|
99
|
+
[dispatch, layer, width, height],
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
const gesture = useMemo(() => {
|
|
103
|
+
const begin = () => {
|
|
104
|
+
'worklet'
|
|
105
|
+
activeCount.value += 1
|
|
106
|
+
visible.value = 1
|
|
107
|
+
runOnJS(capture)()
|
|
108
|
+
}
|
|
109
|
+
/** Commit once, when the last recogniser lets go. */
|
|
110
|
+
const end = () => {
|
|
111
|
+
'worklet'
|
|
112
|
+
activeCount.value = Math.max(0, activeCount.value - 1)
|
|
113
|
+
if (activeCount.value > 0) return
|
|
114
|
+
visible.value = 0
|
|
115
|
+
runOnJS(commit)(dx.value, dy.value, pinch.value, twist.value)
|
|
116
|
+
dx.value = 0
|
|
117
|
+
dy.value = 0
|
|
118
|
+
pinch.value = 1
|
|
119
|
+
twist.value = 0
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const pan = Gesture.Pan()
|
|
123
|
+
.enabled(active)
|
|
124
|
+
.onStart(begin)
|
|
125
|
+
.onUpdate((e) => {
|
|
126
|
+
'worklet'
|
|
127
|
+
dx.value = e.translationX
|
|
128
|
+
dy.value = e.translationY
|
|
129
|
+
})
|
|
130
|
+
.onFinalize(end)
|
|
131
|
+
|
|
132
|
+
const pinchGesture = Gesture.Pinch()
|
|
133
|
+
.enabled(active)
|
|
134
|
+
.onStart(begin)
|
|
135
|
+
.onUpdate((e) => {
|
|
136
|
+
'worklet'
|
|
137
|
+
pinch.value = e.scale
|
|
138
|
+
})
|
|
139
|
+
.onFinalize(end)
|
|
140
|
+
|
|
141
|
+
const rotation = Gesture.Rotation()
|
|
142
|
+
.enabled(active)
|
|
143
|
+
.onStart(begin)
|
|
144
|
+
.onUpdate((e) => {
|
|
145
|
+
'worklet'
|
|
146
|
+
twist.value = e.rotation
|
|
147
|
+
})
|
|
148
|
+
.onFinalize(end)
|
|
149
|
+
|
|
150
|
+
return Gesture.Simultaneous(pan, pinchGesture, rotation)
|
|
151
|
+
}, [active, capture, commit, activeCount, visible, dx, dy, pinch, twist])
|
|
152
|
+
|
|
153
|
+
// The indicator, entirely on the UI thread — no dispatch, no re-render, and
|
|
154
|
+
// no JS hop per frame.
|
|
155
|
+
const ringStyle = useAnimatedStyle(() => {
|
|
156
|
+
const r = Math.max(1, oR.value * pinch.value)
|
|
157
|
+
const cx = oX.value + dx.value
|
|
158
|
+
const cy = oY.value + dy.value
|
|
159
|
+
const band = oLinear.value > 0.5
|
|
160
|
+
return {
|
|
161
|
+
opacity: visible.value,
|
|
162
|
+
left: band ? -width : cx - r,
|
|
163
|
+
top: cy - r,
|
|
164
|
+
width: band ? width * 3 : r * 2,
|
|
165
|
+
height: r * 2,
|
|
166
|
+
borderRadius: band ? 0 : r,
|
|
167
|
+
transform: [{ rotate: `${oRot.value + twist.value}rad` }],
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
if (!active) return null
|
|
172
|
+
return (
|
|
173
|
+
<GestureDetector gesture={gesture}>
|
|
174
|
+
<Animated.View style={StyleSheet.absoluteFill}>
|
|
175
|
+
{/* Drawn here, in React Native, never inside renderDocument — so no
|
|
176
|
+
editing chrome can reach an export. */}
|
|
177
|
+
<Animated.View pointerEvents="none" style={[styles.ring, ringStyle]} />
|
|
178
|
+
</Animated.View>
|
|
179
|
+
</GestureDetector>
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const styles = StyleSheet.create({
|
|
184
|
+
ring: {
|
|
185
|
+
position: 'absolute',
|
|
186
|
+
borderWidth: 1.5,
|
|
187
|
+
borderColor: 'rgba(255,255,255,0.9)',
|
|
188
|
+
},
|
|
189
|
+
})
|