@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,462 @@
1
+ import {
2
+ deleteTargetFromProfile,
3
+ hitTestDocument,
4
+ isDirectManipulationLayer,
5
+ isSameTransform,
6
+ layerNaturalSize,
7
+ layerGestureConfigFromProfile,
8
+ NO_SNAP,
9
+ useEditor,
10
+ useEditorStore,
11
+ type DeleteTarget,
12
+ type LayerSnapState,
13
+ type Transform,
14
+ } from '@pictor-sdk/core'
15
+ import { preferMeasuredSize, useCanvasMetrics, useRenderer } from '@pictor-sdk/skia'
16
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
17
+ import { StyleSheet } from 'react-native'
18
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler'
19
+ import Animated, { useDerivedValue, useSharedValue } from 'react-native-reanimated'
20
+ import { runOnJS } from 'react-native-worklets'
21
+ import {
22
+ composeLayerGestureUI,
23
+ isOverDeleteTargetUI,
24
+ hitTestLayerCandidatesUI,
25
+ layerPreviewDeltaUI,
26
+ } from './layerGestureWorklet'
27
+ import { useInteractionState } from './interactionState'
28
+ import { type GestureSnapEvent } from './controller'
29
+
30
+ /** Overrides for overlay-layer gestures, defaulting to the profile's settings. */
31
+ export interface LayerGestureSurfaceProps {
32
+ /** Fired once per snap engagement — hook up haptics here if you want them. */
33
+ onSnap?: (event: GestureSnapEvent) => void
34
+ }
35
+
36
+ /**
37
+ * Direct manipulation of overlay layers: grab the top-most layer under the
38
+ * finger and pan, focal-pinch and rotate it on the UI thread.
39
+ *
40
+ * Three things make this feel right, and each is covered by tests:
41
+ *
42
+ * - **Focal composition.** Scale and rotation happen about the gesture
43
+ * centroid, so the document point under the fingers stays under them.
44
+ * - **Pointer-count re-basing.** Gesture Handler restarts its cumulative
45
+ * translation/scale/rotation when a finger lands or lifts. Every recognizer
46
+ * value is read relative to an origin captured at the last re-base, and the
47
+ * frame re-bases from the live transform whenever the pointer count changes
48
+ * — so two fingers going to one does not jump.
49
+ * - **One history entry.** The document is written exactly once, when the
50
+ * gesture settles, and not at all if nothing actually moved.
51
+ */
52
+ export function LayerGestureSurface({ onSnap }: LayerGestureSurfaceProps) {
53
+ const { document, dispatch, profile } = useEditor()
54
+ const store = useEditorStore()
55
+ const metrics = useCanvasMetrics()
56
+ // Destructure the setters: the context value's identity changes on every
57
+ // interaction state change (including ones this component causes), so
58
+ // depending on the object would tear down effects mid-gesture. The setters
59
+ // themselves are stable.
60
+ const { setPreview, setLayerDragActive, setOverDeleteTarget, setSnap } = useInteractionState()
61
+
62
+ const renderer = useRenderer()
63
+
64
+ const config = useMemo(
65
+ () => layerGestureConfigFromProfile(profile.interaction?.layers),
66
+ [profile],
67
+ )
68
+ const deleteTarget = useMemo(
69
+ () => deleteTargetFromProfile(profile.interaction?.layers),
70
+ [profile],
71
+ )
72
+ const aspect = document.size.height / document.size.width
73
+ // Rebuild hit geometry when the renderer publishes a new text measurement,
74
+ // so a layer's touch box tracks the box it actually draws.
75
+ const [measureVersion, setMeasureVersion] = useState(0)
76
+ useEffect(
77
+ () => renderer.textMeasures.subscribe(() => setMeasureVersion((v) => v + 1)),
78
+ [renderer],
79
+ )
80
+
81
+ const hitCandidates = useMemo(
82
+ () =>
83
+ document.layers
84
+ .map((layer, index) => ({ layer, index }))
85
+ .filter(
86
+ ({ layer, index }) =>
87
+ !layer.hidden &&
88
+ !layer.locked &&
89
+ isDirectManipulationLayer(layer, index, profile.interaction?.layers),
90
+ )
91
+ .reverse()
92
+ .map(({ layer }) => {
93
+ // The renderer's measurement when it has one, core's estimate
94
+ // otherwise — core cannot lay out text, so its estimate is a
95
+ // character-count approximation that does not match the drawn box.
96
+ const natural = preferMeasuredSize(
97
+ renderer.textMeasures.get(layer.id),
98
+ layerNaturalSize(layer, store.registry, document),
99
+ )
100
+ const anchor = layer.transform.anchor ?? { x: 0.5, y: 0.5 }
101
+ return {
102
+ id: layer.id,
103
+ x: layer.transform.x,
104
+ y: layer.transform.y,
105
+ scale: layer.transform.scale,
106
+ rotation: layer.transform.rotation,
107
+ width: natural.width,
108
+ height: natural.height,
109
+ anchorX: anchor.x,
110
+ anchorY: anchor.y,
111
+ flipX: layer.transform.flipX ?? false,
112
+ flipY: layer.transform.flipY ?? false,
113
+ }
114
+ }),
115
+ // `measureVersion` is deliberate: the measurements live outside React, so
116
+ // this is how a new one re-derives the hit geometry.
117
+ // eslint-disable-next-line react-hooks/exhaustive-deps
118
+ [document, profile, store.registry, renderer, measureVersion],
119
+ )
120
+
121
+ // ── live transform (UI thread) ──
122
+ const liveX = useSharedValue(0.5)
123
+ const liveY = useSharedValue(0.5)
124
+ const liveScale = useSharedValue(1)
125
+ const liveRotation = useSharedValue(0)
126
+ // Immutable for the lifetime of a gesture. The renderer preview is always a
127
+ // delta from the still-committed document transform, never from a frame base.
128
+ const committedX = useSharedValue(0.5)
129
+ const committedY = useSharedValue(0.5)
130
+ const committedScale = useSharedValue(1)
131
+ const committedRotation = useSharedValue(0)
132
+ // ── frame base, re-captured on every pointer-count change ──
133
+ const baseX = useSharedValue(0.5)
134
+ const baseY = useSharedValue(0.5)
135
+ const baseScale = useSharedValue(1)
136
+ const baseRotation = useSharedValue(0)
137
+ // ── recognizer origins, subtracted so each frame starts from zero ──
138
+ const originPanX = useSharedValue(0)
139
+ const originPanY = useSharedValue(0)
140
+ const originPinch = useSharedValue(1)
141
+ const originRotate = useSharedValue(0)
142
+ const rawPanX = useSharedValue(0)
143
+ const rawPanY = useSharedValue(0)
144
+ const rawPinch = useSharedValue(1)
145
+ const rawRotate = useSharedValue(0)
146
+ const focalX = useSharedValue(0.5)
147
+ const focalY = useSharedValue(0.5)
148
+ const pointers = useSharedValue(0)
149
+ const active = useSharedValue(false)
150
+ const snapCX = useSharedValue(false)
151
+ const snapCY = useSharedValue(false)
152
+ const snapR = useSharedValue(false)
153
+ const overDelete = useSharedValue(false)
154
+
155
+ // ── preview transform: a delta from the committed transform, UI-thread only ──
156
+ const previewTransform = useDerivedValue(() => {
157
+ return layerPreviewDeltaUI(
158
+ {
159
+ x: committedX.value,
160
+ y: committedY.value,
161
+ scale: committedScale.value,
162
+ rotation: committedRotation.value,
163
+ },
164
+ { x: liveX.value, y: liveY.value, scale: liveScale.value, rotation: liveRotation.value },
165
+ document.size.width,
166
+ document.size.height,
167
+ )
168
+ }, [document.size.width, document.size.height])
169
+
170
+ /**
171
+ * Fresh render state for the JS callbacks below.
172
+ *
173
+ * The callbacks themselves must be identity-stable, because a worklet has to
174
+ * capture them *directly*: Reanimated serialises captured objects, so a
175
+ * function reached through `someRef.current` arrives on the UI thread as a
176
+ * plain value and calling it throws "Object is not a function". Stable
177
+ * callbacks + a mutable snapshot gives both fresh data and a gesture object
178
+ * that never needs rebuilding.
179
+ */
180
+ const latest = useRef({ document, metrics, store, dispatch, onSnap, profile })
181
+ latest.current = { document, metrics, store, dispatch, onSnap, profile }
182
+
183
+ const grabbedId = useRef<string | null>(null)
184
+ const grabbedFrom = useRef<Transform | null>(null)
185
+ const snapState = useRef<LayerSnapState>(NO_SNAP)
186
+
187
+ const onGrabLayer = useCallback((id: string) => {
188
+ const { document: doc, store: s } = latest.current
189
+ const hit = doc.layers.find((layer) => layer.id === id)
190
+ if (!hit) return
191
+ const t = hit.transform
192
+ grabbedId.current = hit.id
193
+ grabbedFrom.current = { ...t }
194
+ s.select([hit.id])
195
+ setPreview({ layerId: hit.id, transform: previewTransform })
196
+ setLayerDragActive(true)
197
+ }, [setPreview, setLayerDragActive, previewTransform])
198
+
199
+ const onSelectAt = useCallback((xDp: number, yDp: number) => {
200
+ const { document: doc, metrics: m, store: s } = latest.current
201
+ if (!m) return
202
+ const layerInteraction = latest.current.profile.interaction?.layers
203
+ const hit = hitTestDocument(
204
+ doc,
205
+ { x: xDp / m.width, y: yDp / m.height },
206
+ s.registry,
207
+ (layer, index) => isDirectManipulationLayer(layer, index, layerInteraction),
208
+ )
209
+ s.select(hit && !hit.locked ? [hit.id] : [])
210
+ }, [])
211
+
212
+ const onSnapChanged = useCallback(
213
+ (centerX: boolean, centerY: boolean, rotation: boolean) => {
214
+ const previous = snapState.current
215
+ const next = { centerX, centerY, rotation }
216
+ snapState.current = next
217
+ const notify = latest.current.onSnap
218
+ if (centerX && !previous.centerX) notify?.({ kind: 'centerX' })
219
+ if (centerY && !previous.centerY) notify?.({ kind: 'centerY' })
220
+ if (rotation && !previous.rotation) notify?.({ kind: 'rotation' })
221
+ setSnap(next)
222
+ },
223
+ [setSnap],
224
+ )
225
+
226
+ const onHoverChanged = useCallback(
227
+ (over: boolean) => setOverDeleteTarget(over),
228
+ [setOverDeleteTarget],
229
+ )
230
+
231
+ const onGestureFinished = useCallback(
232
+ (t: Transform, deleted: boolean) => {
233
+ const { dispatch: d, store: s } = latest.current
234
+ const id = grabbedId.current
235
+ const from = grabbedFrom.current
236
+ grabbedId.current = null
237
+ grabbedFrom.current = null
238
+ snapState.current = NO_SNAP
239
+ setLayerDragActive(false)
240
+ setOverDeleteTarget(false)
241
+ setSnap(NO_SNAP)
242
+ setPreview(null)
243
+ if (!id) return
244
+ if (deleted) {
245
+ d({ type: 'layer/remove', id })
246
+ s.select([])
247
+ return
248
+ }
249
+ // a tap, or a drag that ended where it started, must not create an undo entry
250
+ if (from && isSameTransform(from, t)) return
251
+ d({ type: 'layer/transform', id, patch: t })
252
+ },
253
+ [setLayerDragActive, setOverDeleteTarget, setSnap, setPreview],
254
+ )
255
+
256
+ useEffect(() => () => setPreview(null), [setPreview])
257
+
258
+ const gesture = useMemo(() => {
259
+ const metricsWidth = metrics?.width ?? 0
260
+ const metricsHeight = metrics?.height ?? 0
261
+ const target: DeleteTarget | null = deleteTarget
262
+ const candidates = hitCandidates
263
+
264
+ /** Re-capture the frame base and zero every recognizer origin. */
265
+ const rebase = () => {
266
+ 'worklet'
267
+ baseX.value = liveX.value
268
+ baseY.value = liveY.value
269
+ baseScale.value = liveScale.value
270
+ baseRotation.value = liveRotation.value
271
+ originPanX.value = rawPanX.value
272
+ originPanY.value = rawPanY.value
273
+ originPinch.value = rawPinch.value
274
+ originRotate.value = rawRotate.value
275
+ }
276
+
277
+ const recompute = (settle: boolean) => {
278
+ 'worklet'
279
+ if (!active.value || metricsWidth === 0 || metricsHeight === 0) return
280
+ const out = composeLayerGestureUI(
281
+ {
282
+ x: baseX.value,
283
+ y: baseY.value,
284
+ scale: baseScale.value,
285
+ rotation: baseRotation.value,
286
+ },
287
+ {
288
+ panX: (rawPanX.value - originPanX.value) / metricsWidth,
289
+ panY: (rawPanY.value - originPanY.value) / metricsHeight,
290
+ pinch: rawPinch.value / Math.max(originPinch.value, 1e-9),
291
+ rotate: rawRotate.value - originRotate.value,
292
+ focalX: focalX.value,
293
+ focalY: focalY.value,
294
+ },
295
+ aspect,
296
+ config,
297
+ { centerX: snapCX.value, centerY: snapCY.value, rotation: snapR.value },
298
+ settle,
299
+ )
300
+ liveX.value = out.x
301
+ liveY.value = out.y
302
+ liveScale.value = out.scale
303
+ liveRotation.value = out.rotation
304
+
305
+ if (
306
+ out.snapCenterX !== snapCX.value ||
307
+ out.snapCenterY !== snapCY.value ||
308
+ out.snapRotation !== snapR.value
309
+ ) {
310
+ snapCX.value = out.snapCenterX
311
+ snapCY.value = out.snapCenterY
312
+ snapR.value = out.snapRotation
313
+ runOnJS(onSnapChanged)(out.snapCenterX, out.snapCenterY, out.snapRotation)
314
+ }
315
+
316
+ if (target) {
317
+ const over = isOverDeleteTargetUI(out.x, out.y, target, aspect)
318
+ if (over !== overDelete.value) {
319
+ overDelete.value = over
320
+ runOnJS(onHoverChanged)(over)
321
+ }
322
+ }
323
+ }
324
+
325
+ /** Track pointer count and re-base the frame whenever it changes. */
326
+ const syncPointers = (count: number) => {
327
+ 'worklet'
328
+ if (active.value && count !== pointers.value) {
329
+ pointers.value = count
330
+ rebase()
331
+ }
332
+ }
333
+
334
+ const endIfIdle = () => {
335
+ 'worklet'
336
+ if (!active.value) {
337
+ // an inert gesture (nothing was grabbed) still has to clear UI state
338
+ runOnJS(onGestureFinished)({ x: 0, y: 0, scale: 1, rotation: 0 }, false)
339
+ return
340
+ }
341
+ recompute(true)
342
+ active.value = false
343
+ pointers.value = 0
344
+ const deleted = overDelete.value
345
+ overDelete.value = false
346
+ snapCX.value = false
347
+ snapCY.value = false
348
+ snapR.value = false
349
+ runOnJS(onGestureFinished)(
350
+ {
351
+ x: liveX.value,
352
+ y: liveY.value,
353
+ scale: liveScale.value,
354
+ rotation: liveRotation.value,
355
+ },
356
+ deleted,
357
+ )
358
+ }
359
+
360
+ const pan = Gesture.Pan()
361
+ .minPointers(1)
362
+ .maxPointers(2)
363
+ .onBegin((e) => {
364
+ 'worklet'
365
+ if (active.value) return
366
+ rawPanX.value = 0
367
+ rawPanY.value = 0
368
+ rawPinch.value = 1
369
+ rawRotate.value = 0
370
+ originPanX.value = 0
371
+ originPanY.value = 0
372
+ originPinch.value = 1
373
+ originRotate.value = 0
374
+ pointers.value = e.numberOfPointers
375
+ const hit = hitTestLayerCandidatesUI(
376
+ e.x / Math.max(metricsWidth, 1e-9),
377
+ e.y / Math.max(metricsHeight, 1e-9),
378
+ candidates,
379
+ document.size.width,
380
+ document.size.height,
381
+ )
382
+ if (!hit) {
383
+ active.value = false
384
+ return
385
+ }
386
+ liveX.value = hit.x
387
+ liveY.value = hit.y
388
+ liveScale.value = hit.scale
389
+ liveRotation.value = hit.rotation
390
+ committedX.value = hit.x
391
+ committedY.value = hit.y
392
+ committedScale.value = hit.scale
393
+ committedRotation.value = hit.rotation
394
+ baseX.value = hit.x
395
+ baseY.value = hit.y
396
+ baseScale.value = hit.scale
397
+ baseRotation.value = hit.rotation
398
+ focalX.value = hit.x
399
+ focalY.value = hit.y
400
+ active.value = true
401
+ runOnJS(onGrabLayer)(hit.id)
402
+ })
403
+ .onUpdate((e) => {
404
+ 'worklet'
405
+ rawPanX.value = e.translationX
406
+ rawPanY.value = e.translationY
407
+ syncPointers(e.numberOfPointers)
408
+ recompute(false)
409
+ })
410
+ .onFinalize(() => {
411
+ 'worklet'
412
+ endIfIdle()
413
+ })
414
+
415
+ const pinch = Gesture.Pinch().onUpdate((e) => {
416
+ 'worklet'
417
+ rawPinch.value = e.scale
418
+ focalX.value = e.focalX / metricsWidth
419
+ focalY.value = e.focalY / metricsHeight
420
+ syncPointers(e.numberOfPointers)
421
+ recompute(false)
422
+ })
423
+
424
+ const rotate = Gesture.Rotation()
425
+ .enabled(config.rotationEnabled)
426
+ .onUpdate((e) => {
427
+ 'worklet'
428
+ rawRotate.value = e.rotation
429
+ focalX.value = e.anchorX / metricsWidth
430
+ focalY.value = e.anchorY / metricsHeight
431
+ syncPointers(e.numberOfPointers)
432
+ recompute(false)
433
+ })
434
+
435
+ const tap = Gesture.Tap().onEnd((e) => {
436
+ 'worklet'
437
+ runOnJS(onSelectAt)(e.x, e.y)
438
+ })
439
+
440
+ return Gesture.Simultaneous(Gesture.Race(pan, tap), pinch, rotate)
441
+ // eslint-disable-next-line react-hooks/exhaustive-deps
442
+ }, [
443
+ metrics,
444
+ config,
445
+ deleteTarget,
446
+ aspect,
447
+ onGrabLayer,
448
+ onSelectAt,
449
+ onSnapChanged,
450
+ onHoverChanged,
451
+ onGestureFinished,
452
+ hitCandidates,
453
+ document.size.width,
454
+ document.size.height,
455
+ ])
456
+
457
+ return (
458
+ <GestureDetector gesture={gesture}>
459
+ <Animated.View style={StyleSheet.absoluteFill} />
460
+ </GestureDetector>
461
+ )
462
+ }