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