@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,152 @@
1
+ import {
2
+ hitTestDocument,
3
+ snapCenter,
4
+ snapRotation,
5
+ type EditorStore,
6
+ type Transform,
7
+ } from '@pictor-sdk/core'
8
+
9
+ /** The fitted canvas's size in dp, for turning touches into document units. */
10
+ export interface GestureMetrics {
11
+ /** canvas size in dp (the fitted preview) */
12
+ width: number
13
+ height: number
14
+ }
15
+
16
+ /** Emitted the moment a gesture snaps — the hook for haptics. */
17
+ export interface GestureSnapEvent {
18
+ kind: 'rotation' | 'centerX' | 'centerY'
19
+ }
20
+
21
+ /**
22
+ * Host callbacks a gesture controller may fire.
23
+ *
24
+ * Callbacks rather than behaviour: haptics would mean a native dependency, so
25
+ * the library reports the moment and the host decides what it means.
26
+ */
27
+ export interface GestureControllerOptions {
28
+ /** e.g. trigger haptics — optional callback, no haptics dependency here. */
29
+ onSnap?: (event: GestureSnapEvent) => void
30
+ }
31
+
32
+ /**
33
+ * Pure gesture → command translation, shared by the RN gesture wiring and
34
+ * unit tests. Pan/pinch/rotate report cumulative deltas; the controller
35
+ * recomputes the transform from the base captured at gesture start and
36
+ * commits through a history batch so the whole gesture is ONE undo step.
37
+ *
38
+ * DECISION: shared values give 60fps feedback on the UI thread; document
39
+ * commits hop to JS per frame (cheap — one reducer pass at preview scale)
40
+ * inside the batch. True UI-thread document mutation would need a worklet
41
+ * store and is out of scope for v0.1.
42
+ */
43
+ export function createGestureController(
44
+ store: Pick<EditorStore, 'getState' | 'dispatch' | 'select' | 'registry'>,
45
+ getMetrics: () => GestureMetrics | null,
46
+ options: GestureControllerOptions = {},
47
+ ) {
48
+ let active = 0
49
+ let base: Transform | null = null
50
+ let layerId: string | null = null
51
+ let pan = { x: 0, y: 0 }
52
+ let pinch = 1
53
+ let rotate = 0
54
+ const snapState = { rotation: false, centerX: false, centerY: false }
55
+
56
+ function targetLayerId(): string | null {
57
+ return store.getState().selection[0] ?? null
58
+ }
59
+
60
+ function begin() {
61
+ if (active === 0) {
62
+ layerId = targetLayerId()
63
+ if (!layerId) return
64
+ const layer = store.getState().document.layers.find((l) => l.id === layerId)
65
+ if (!layer || layer.locked) {
66
+ layerId = null
67
+ return
68
+ }
69
+ base = { ...layer.transform }
70
+ pan = { x: 0, y: 0 }
71
+ pinch = 1
72
+ rotate = 0
73
+ snapState.rotation = snapState.centerX = snapState.centerY = false
74
+ store.dispatch({ type: 'history/beginBatch', label: 'gesture' })
75
+ }
76
+ active++
77
+ }
78
+
79
+ function fireSnap(kind: GestureSnapEvent['kind'], snapped: boolean) {
80
+ if (snapped && !snapState[kind]) options.onSnap?.({ kind })
81
+ snapState[kind] = snapped
82
+ }
83
+
84
+ function commit() {
85
+ if (!base || !layerId) return
86
+ const metrics = getMetrics()
87
+ if (!metrics || metrics.width === 0 || metrics.height === 0) return
88
+
89
+ const rawX = base.x + pan.x / metrics.width
90
+ const rawY = base.y + pan.y / metrics.height
91
+ const rawRotation = base.rotation + rotate
92
+
93
+ const sx = snapCenter(rawX)
94
+ const sy = snapCenter(rawY)
95
+ const sr = snapRotation(rawRotation)
96
+ fireSnap('centerX', sx.snapped)
97
+ fireSnap('centerY', sy.snapped)
98
+ fireSnap('rotation', sr.snapped)
99
+
100
+ store.dispatch({
101
+ type: 'layer/transform',
102
+ id: layerId,
103
+ patch: {
104
+ x: sx.value,
105
+ y: sy.value,
106
+ rotation: sr.value,
107
+ scale: Math.max(0.01, base.scale * pinch),
108
+ },
109
+ })
110
+ }
111
+
112
+ function end() {
113
+ if (active === 0) return
114
+ active--
115
+ if (active === 0) {
116
+ if (layerId) store.dispatch({ type: 'history/endBatch' })
117
+ base = null
118
+ layerId = null
119
+ }
120
+ }
121
+
122
+ return {
123
+ /** Pan/pinch/rotate lifecycles — call begin/end once per recognizer. */
124
+ begin,
125
+ end,
126
+ setPan(xDp: number, yDp: number) {
127
+ pan = { x: xDp, y: yDp }
128
+ commit()
129
+ },
130
+ setPinch(scale: number) {
131
+ pinch = scale
132
+ commit()
133
+ },
134
+ setRotate(rad: number) {
135
+ rotate = rad
136
+ commit()
137
+ },
138
+ /** Tap in canvas dp → hit-test → select (empty tap clears selection). */
139
+ tap(xDp: number, yDp: number) {
140
+ const metrics = getMetrics()
141
+ if (!metrics) return
142
+ const point = { x: xDp / metrics.width, y: yDp / metrics.height }
143
+ const hit = hitTestDocument(store.getState().document, point, store.registry)
144
+ store.select(hit ? [hit.id] : [])
145
+ },
146
+ /** true while a gesture is in flight (used to mute other UI). */
147
+ isActive: () => active > 0,
148
+ }
149
+ }
150
+
151
+ /** Turns raw gesture input into layer transforms, with snapping. */
152
+ export type GestureController = ReturnType<typeof createGestureController>
@@ -0,0 +1,104 @@
1
+ import { createId, type EditorStore, type Stroke } from '@pictor-sdk/core'
2
+ import { type GestureMetrics } from './controller'
3
+
4
+ /** The active brush: kind, colour and width. Width is a fraction of document width. */
5
+ export interface BrushConfig {
6
+ brush: Stroke['brush']
7
+ color: string
8
+ /** 0..1 of document width */
9
+ width: number
10
+ }
11
+
12
+ /**
13
+ * Pure drawing controller: converts a pan gesture into a stroke on the
14
+ * top-most draw layer (created on first use), committed as ONE undo step.
15
+ * Live feedback comes from patching the stroke into the document on every
16
+ * move inside the history batch.
17
+ */
18
+ export function createDrawController(
19
+ store: Pick<EditorStore, 'getState' | 'dispatch'>,
20
+ getMetrics: () => GestureMetrics | null,
21
+ getBrush: () => BrushConfig,
22
+ ) {
23
+ let drawing = false
24
+ let layerId: string | null = null
25
+ let points: Stroke['points'] = []
26
+ let baseStrokes: Stroke[] = []
27
+
28
+ function toDocPoint(xDp: number, yDp: number) {
29
+ const metrics = getMetrics()
30
+ if (!metrics || metrics.width === 0 || metrics.height === 0) return null
31
+ return { x: xDp / metrics.width, y: yDp / metrics.height }
32
+ }
33
+
34
+ function ensureDrawLayer(): string {
35
+ const doc = store.getState().document
36
+ for (let i = doc.layers.length - 1; i >= 0; i--) {
37
+ const layer = doc.layers[i]!
38
+ if (layer.type === 'draw' && !layer.locked && !layer.hidden) return layer.id
39
+ }
40
+ const id = createId('draw')
41
+ store.dispatch({
42
+ type: 'layer/add',
43
+ layer: {
44
+ id,
45
+ type: 'draw',
46
+ transform: { x: 0.5, y: 0.5, scale: 1, rotation: 0 },
47
+ opacity: 1,
48
+ blendMode: 'normal',
49
+ data: { strokes: [] },
50
+ },
51
+ })
52
+ return id
53
+ }
54
+
55
+ function currentStroke(): Stroke {
56
+ const brush = getBrush()
57
+ return { points, color: brush.color, width: brush.width, brush: brush.brush }
58
+ }
59
+
60
+ function patch() {
61
+ if (!layerId) return
62
+ store.dispatch({
63
+ type: 'layer/patchData',
64
+ id: layerId,
65
+ patch: { strokes: [...baseStrokes, currentStroke()] },
66
+ })
67
+ }
68
+
69
+ return {
70
+ begin(xDp: number, yDp: number) {
71
+ const point = toDocPoint(xDp, yDp)
72
+ if (!point || drawing) return
73
+ drawing = true
74
+ store.dispatch({ type: 'history/beginBatch', label: 'draw' })
75
+ layerId = ensureDrawLayer()
76
+ const layer = store.getState().document.layers.find((l) => l.id === layerId)
77
+ baseStrokes = (layer?.data as { strokes?: Stroke[] } | undefined)?.strokes ?? []
78
+ points = [point]
79
+ patch()
80
+ },
81
+ move(xDp: number, yDp: number) {
82
+ if (!drawing) return
83
+ const point = toDocPoint(xDp, yDp)
84
+ if (!point) return
85
+ const last = points[points.length - 1]
86
+ // drop sub-half-percent movements to keep strokes light
87
+ if (last && Math.hypot(point.x - last.x, point.y - last.y) < 0.003) return
88
+ points = [...points, point]
89
+ patch()
90
+ },
91
+ end() {
92
+ if (!drawing) return
93
+ drawing = false
94
+ store.dispatch({ type: 'history/endBatch' })
95
+ layerId = null
96
+ points = []
97
+ baseStrokes = []
98
+ },
99
+ isDrawing: () => drawing,
100
+ }
101
+ }
102
+
103
+ /** Accumulates touch points into strokes and commits them to the document. */
104
+ export type DrawController = ReturnType<typeof createDrawController>
@@ -0,0 +1,81 @@
1
+ import { NO_SNAP, type LayerSnapState } from '@pictor-sdk/core'
2
+ import { type PreviewLayerTransform } from '@pictor-sdk/skia'
3
+ import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'
4
+
5
+ /**
6
+ * Transient interaction state shared between the gesture surfaces and the
7
+ * chrome. None of it is part of the document: it never reaches history,
8
+ * serialization or export.
9
+ *
10
+ * Per-frame transforms stay on the UI thread as shared values (`previews`).
11
+ * Only the discrete flags below cross to the JS thread, and only when they
12
+ * actually change — a drag starting, a guide engaging, the delete target being
13
+ * entered — so the render path is untouched while fingers move.
14
+ */
15
+ export interface InteractionState {
16
+ previews: readonly PreviewLayerTransform[]
17
+ setPreview(preview: PreviewLayerTransform | null): void
18
+ cropActive: boolean
19
+ setCropActive(active: boolean): void
20
+ /** True while a layer is being dragged/scaled/rotated. */
21
+ layerDragActive: boolean
22
+ setLayerDragActive(active: boolean): void
23
+ /** True while the dragged layer is over the delete target. */
24
+ overDeleteTarget: boolean
25
+ setOverDeleteTarget(over: boolean): void
26
+ /** Which alignment guides are currently engaged. */
27
+ snap: LayerSnapState
28
+ setSnap(snap: LayerSnapState): void
29
+ }
30
+
31
+ const Context = createContext<InteractionState | null>(null)
32
+
33
+ /**
34
+ * Shares the in-flight interaction between the gesture surfaces and the canvas.
35
+ *
36
+ * How a live preview transform reaches the renderer without going through the
37
+ * document — which would mean dispatching every frame.
38
+ */
39
+ export function InteractionProvider({ children }: { children?: React.ReactNode }) {
40
+ const [preview, setPreviewValue] = useState<PreviewLayerTransform | null>(null)
41
+ const [cropActive, setCropActive] = useState(false)
42
+ const [layerDragActive, setLayerDragActive] = useState(false)
43
+ const [overDeleteTarget, setOverDeleteTarget] = useState(false)
44
+ const [snap, setSnapValue] = useState<LayerSnapState>(NO_SNAP)
45
+
46
+ const setPreview = useCallback((value: PreviewLayerTransform | null) => setPreviewValue(value), [])
47
+ // identity-stable so worklets can call it through runOnJS without re-creating
48
+ const setSnap = useCallback((value: LayerSnapState) => {
49
+ setSnapValue((current) =>
50
+ current.centerX === value.centerX &&
51
+ current.centerY === value.centerY &&
52
+ current.rotation === value.rotation
53
+ ? current
54
+ : value,
55
+ )
56
+ }, [])
57
+
58
+ const value = useMemo(
59
+ () => ({
60
+ previews: preview ? [preview] : [],
61
+ setPreview,
62
+ cropActive,
63
+ setCropActive,
64
+ layerDragActive,
65
+ setLayerDragActive,
66
+ overDeleteTarget,
67
+ setOverDeleteTarget,
68
+ snap,
69
+ setSnap,
70
+ }),
71
+ [preview, setPreview, cropActive, layerDragActive, overDeleteTarget, snap, setSnap],
72
+ )
73
+ return <Context.Provider value={value}>{children}</Context.Provider>
74
+ }
75
+
76
+ /** The current interaction. Throws outside an `<Editor>`, where there is none. */
77
+ export function useInteractionState(): InteractionState {
78
+ const value = useContext(Context)
79
+ if (!value) throw new Error('interaction components must be used inside <Editor>')
80
+ return value
81
+ }
@@ -0,0 +1,198 @@
1
+ import { type ResolvedLayerGestureConfig } from '@pictor-sdk/core'
2
+
3
+ /**
4
+ * UI-thread twin of `composeLayerGesture` from @pictor-sdk/core.
5
+ *
6
+ * DECISION: the gesture math exists twice on purpose. @pictor-sdk/core must stay
7
+ * free of Reanimated (invariant 5) and must run in plain Node, so it cannot
8
+ * carry a `'worklet'` directive; but the preview has to recompute the
9
+ * transform on the UI thread every frame, which requires a worklet. Rather
10
+ * than rely on the consumer's Babel config workletizing another package's
11
+ * source, the worklet lives here — and `worklet-parity.test.ts` asserts the
12
+ * two implementations agree across a grid of inputs, so they cannot drift.
13
+ *
14
+ * Keep this function free of imports and closures over anything but plain
15
+ * numbers and frozen config objects.
16
+ */
17
+ export interface WorkletTransform {
18
+ x: number
19
+ y: number
20
+ scale: number
21
+ rotation: number
22
+ snapCenterX: boolean
23
+ snapCenterY: boolean
24
+ snapRotation: boolean
25
+ }
26
+
27
+ export interface WorkletBase {
28
+ x: number
29
+ y: number
30
+ scale: number
31
+ rotation: number
32
+ }
33
+
34
+ export interface WorkletInput {
35
+ panX: number
36
+ panY: number
37
+ pinch: number
38
+ rotate: number
39
+ focalX: number
40
+ focalY: number
41
+ }
42
+
43
+ export interface WorkletPrevSnap {
44
+ centerX: boolean
45
+ centerY: boolean
46
+ rotation: boolean
47
+ }
48
+
49
+ export function composeLayerGestureUI(
50
+ base: WorkletBase,
51
+ input: WorkletInput,
52
+ aspect: number,
53
+ config: ResolvedLayerGestureConfig,
54
+ previous: WorkletPrevSnap,
55
+ settle: boolean,
56
+ ): WorkletTransform {
57
+ 'worklet'
58
+ const requested = base.scale * input.pinch
59
+ let scale: number
60
+ if (settle) {
61
+ scale = Math.min(config.maxScale, Math.max(config.minScale, requested))
62
+ } else if (requested < config.minScale) {
63
+ scale = config.minScale - (config.minScale - requested) * config.scaleResistance
64
+ } else if (requested > config.maxScale) {
65
+ scale = config.maxScale + (requested - config.maxScale) * config.scaleResistance
66
+ } else {
67
+ scale = requested
68
+ }
69
+
70
+ const ratio = scale / Math.max(base.scale, 1e-9)
71
+ const rotationDelta = config.rotationEnabled ? input.rotate : 0
72
+
73
+ const dx = base.x - input.focalX
74
+ const dy = (base.y - input.focalY) * aspect
75
+ const cos = Math.cos(rotationDelta)
76
+ const sin = Math.sin(rotationDelta)
77
+ const rx = (dx * cos - dy * sin) * ratio
78
+ const ry = (dx * sin + dy * cos) * ratio
79
+
80
+ let x = input.focalX + rx + input.panX
81
+ let y = input.focalY + ry / aspect + input.panY
82
+ let rotation = base.rotation + rotationDelta
83
+
84
+ let snapCenterX = false
85
+ let snapCenterY = false
86
+ let snapRotation = false
87
+
88
+ if (config.centerSnap !== false) {
89
+ const engage = config.centerSnap
90
+ const release = Math.max(engage, config.centerSnapRelease)
91
+ const tx = previous.centerX ? release : engage
92
+ if (Math.abs(x - 0.5) <= tx + 1e-9) {
93
+ x = 0.5
94
+ snapCenterX = true
95
+ }
96
+ const ty = previous.centerY ? release : engage
97
+ if (Math.abs(y - 0.5) <= ty + 1e-9) {
98
+ y = 0.5
99
+ snapCenterY = true
100
+ }
101
+ }
102
+
103
+ if (config.rotationSnap !== false && config.rotationEnabled) {
104
+ const engage = config.rotationSnap
105
+ const release = Math.max(engage, config.rotationSnapRelease)
106
+ const step = Math.PI / 2
107
+ const nearest = Math.round(rotation / step) * step
108
+ const tr = previous.rotation ? release : engage
109
+ if (Math.abs(rotation - nearest) <= tr + 1e-9) {
110
+ rotation = nearest
111
+ snapRotation = true
112
+ }
113
+ }
114
+
115
+ return { x, y, scale, rotation, snapCenterX, snapCenterY, snapRotation }
116
+ }
117
+
118
+ /**
119
+ * UI-thread twin of `isOverDeleteTarget`.
120
+ *
121
+ * Same reason as above: a worklet may only call other worklets, and
122
+ * @pictor-sdk/core cannot carry the directive. Held to core by the parity test.
123
+ */
124
+ export function isOverDeleteTargetUI(
125
+ x: number,
126
+ y: number,
127
+ target: { x: number; y: number; radius: number },
128
+ aspect: number,
129
+ ): boolean {
130
+ 'worklet'
131
+ const dx = x - target.x
132
+ const dy = (y - target.y) * aspect
133
+ return Math.sqrt(dx * dx + dy * dy) <= target.radius
134
+ }
135
+
136
+ /**
137
+ * Renderer delta from the immutable committed transform to the live transform.
138
+ * Pointer-frame rebasing must never change these reference values.
139
+ */
140
+ export function layerPreviewDeltaUI(
141
+ committed: { x: number; y: number; scale: number; rotation: number },
142
+ live: { x: number; y: number; scale: number; rotation: number },
143
+ width: number,
144
+ height: number,
145
+ ) {
146
+ 'worklet'
147
+ return [
148
+ { translateX: live.x * width },
149
+ { translateY: live.y * height },
150
+ { rotate: live.rotation - committed.rotation },
151
+ { scale: live.scale / Math.max(committed.scale, 1e-9) },
152
+ { translateX: -committed.x * width },
153
+ { translateY: -committed.y * height },
154
+ ]
155
+ }
156
+
157
+ export interface LayerHitCandidateUI {
158
+ id: string
159
+ x: number
160
+ y: number
161
+ scale: number
162
+ rotation: number
163
+ width: number
164
+ height: number
165
+ anchorX: number
166
+ anchorY: number
167
+ flipX: boolean
168
+ flipY: boolean
169
+ }
170
+
171
+ /** Top-most bounding-box hit test used to activate built-in overlays instantly. */
172
+ export function hitTestLayerCandidatesUI(
173
+ x: number,
174
+ y: number,
175
+ candidates: readonly LayerHitCandidateUI[],
176
+ documentWidth: number,
177
+ documentHeight: number,
178
+ ): LayerHitCandidateUI | null {
179
+ 'worklet'
180
+ for (let i = 0; i < candidates.length; i++) {
181
+ const candidate = candidates[i]!
182
+ let dx = x * documentWidth - candidate.x * documentWidth
183
+ let dy = y * documentHeight - candidate.y * documentHeight
184
+ const cos = Math.cos(-candidate.rotation)
185
+ const sin = Math.sin(-candidate.rotation)
186
+ const rx = dx * cos - dy * sin
187
+ const ry = dx * sin + dy * cos
188
+ dx = candidate.flipX ? -rx : rx
189
+ dy = candidate.flipY ? -ry : ry
190
+ const width = candidate.scale * candidate.width * documentWidth
191
+ const height = candidate.scale * candidate.height * documentWidth
192
+ if (width <= 0 || height <= 0) continue
193
+ const localX = dx / width + candidate.anchorX
194
+ const localY = dy / height + candidate.anchorY
195
+ if (localX >= 0 && localX <= 1 && localY >= 0 && localY <= 1) return candidate
196
+ }
197
+ return null
198
+ }
@@ -0,0 +1,159 @@
1
+ import { type ResolvedMediaConfig } from '@pictor-sdk/core'
2
+
3
+ /**
4
+ * UI-thread twin of `composeMediaGesture` from @pictor-sdk/core.
5
+ *
6
+ * DECISION: the same deliberate duplication as `layerGestureWorklet`. Core must
7
+ * stay free of Reanimated and must run in plain Node, so it cannot carry a
8
+ * `'worklet'` directive; but the preview recomputes the transform every frame
9
+ * on the UI thread, which requires one. `media-worklet-parity.test.ts` holds
10
+ * the two implementations together across a grid of inputs, so they cannot
11
+ * drift.
12
+ *
13
+ * Keep this file free of imports beyond types, and of closures over anything
14
+ * but plain numbers and frozen config objects.
15
+ */
16
+ export interface MediaWorkletTransform {
17
+ x: number
18
+ y: number
19
+ scale: number
20
+ rotation: number
21
+ }
22
+
23
+ export interface MediaWorkletInput {
24
+ panX: number
25
+ panY: number
26
+ pinch: number
27
+ rotate: number
28
+ focalX: number
29
+ focalY: number
30
+ }
31
+
32
+ export interface MediaWorkletSize {
33
+ width: number
34
+ height: number
35
+ }
36
+
37
+ /** Mirror of core's `mediaFitScale`. */
38
+ export function mediaFitScaleUI(
39
+ intrinsic: MediaWorkletSize,
40
+ canvas: MediaWorkletSize,
41
+ fit: 'width' | 'cover' | 'contain',
42
+ rotation: number,
43
+ ): number {
44
+ 'worklet'
45
+ if (intrinsic.width <= 0 || intrinsic.height <= 0) return 1
46
+ if (fit === 'width') return 1
47
+ const w = canvas.width
48
+ const h = canvas.width * (intrinsic.height / intrinsic.width)
49
+ const cos = Math.abs(Math.cos(rotation))
50
+ const sin = Math.abs(Math.sin(rotation))
51
+ const byWidth = canvas.width / (w * cos + h * sin)
52
+ const byHeight = canvas.height / (w * sin + h * cos)
53
+ return fit === 'cover' ? Math.max(byWidth, byHeight) : Math.min(byWidth, byHeight)
54
+ }
55
+
56
+ /** Mirror of core's `mediaPanBounds`. */
57
+ export function mediaPanBoundsUI(
58
+ _intrinsic: MediaWorkletSize,
59
+ _canvas: MediaWorkletSize,
60
+ _scale: number,
61
+ _rotation: number,
62
+ ): { minX: number; maxX: number; minY: number; maxY: number } {
63
+ 'worklet'
64
+ // Mirror of core's `mediaPanBounds`: the media is freely positionable, held
65
+ // only so its anchor stays on the canvas and it can always be dragged back.
66
+ return { minX: 0, maxX: 1, minY: 0, maxY: 1 }
67
+ }
68
+
69
+ /** Mirror of core's `composeMediaGesture`. */
70
+ export function composeMediaGestureUI(
71
+ base: MediaWorkletTransform,
72
+ input: MediaWorkletInput,
73
+ canvas: MediaWorkletSize,
74
+ intrinsic: MediaWorkletSize,
75
+ config: ResolvedMediaConfig,
76
+ settle: boolean,
77
+ ): MediaWorkletTransform {
78
+ 'worklet'
79
+ const rotationDelta = config.rotationEnabled ? input.rotate : 0
80
+ const rotation = base.rotation + rotationDelta
81
+
82
+ const fit = mediaFitScaleUI(intrinsic, canvas, config.fit, rotation)
83
+ const minScale = fit * config.minScaleFactor
84
+ const maxScale = Math.max(minScale, config.maxScale)
85
+
86
+ const requested = base.scale * input.pinch
87
+ let scale: number
88
+ if (settle) {
89
+ scale = Math.min(maxScale, Math.max(minScale, requested))
90
+ } else if (requested < minScale) {
91
+ scale = minScale - (minScale - requested) * config.resistance
92
+ } else if (requested > maxScale) {
93
+ scale = maxScale + (requested - maxScale) * config.resistance
94
+ } else {
95
+ scale = requested
96
+ }
97
+
98
+ const aspect = canvas.height / canvas.width
99
+ const ratio = scale / Math.max(base.scale, 1e-9)
100
+ const dx = base.x - input.focalX
101
+ const dy = (base.y - input.focalY) * aspect
102
+ const cos = Math.cos(rotationDelta)
103
+ const sin = Math.sin(rotationDelta)
104
+ const rx = (dx * cos - dy * sin) * ratio
105
+ const ry = (dx * sin + dy * cos) * ratio
106
+
107
+ const rawX = input.focalX + rx + input.panX
108
+ const rawY = input.focalY + ry / aspect + input.panY
109
+
110
+ const bounds = mediaPanBoundsUI(intrinsic, canvas, scale, rotation)
111
+ let x: number
112
+ let y: number
113
+ if (settle) {
114
+ x = Math.min(bounds.maxX, Math.max(bounds.minX, rawX))
115
+ y = Math.min(bounds.maxY, Math.max(bounds.minY, rawY))
116
+ } else {
117
+ x =
118
+ rawX < bounds.minX
119
+ ? bounds.minX - (bounds.minX - rawX) * config.resistance
120
+ : rawX > bounds.maxX
121
+ ? bounds.maxX + (rawX - bounds.maxX) * config.resistance
122
+ : rawX
123
+ y =
124
+ rawY < bounds.minY
125
+ ? bounds.minY - (bounds.minY - rawY) * config.resistance
126
+ : rawY > bounds.maxY
127
+ ? bounds.maxY + (rawY - bounds.maxY) * config.resistance
128
+ : rawY
129
+ }
130
+
131
+ return { x, y, scale, rotation }
132
+ }
133
+
134
+ // ─────────────────────────────────────────────────────────────
135
+ // Recognizer lifecycle
136
+ // ─────────────────────────────────────────────────────────────
137
+
138
+ /**
139
+ * Pan, pinch and rotation run simultaneously, and each finalises on its own.
140
+ * Lifting one finger of a two-finger gesture finalises pinch and rotation while
141
+ * the pan recognizer keeps running with the remaining finger — so an
142
+ * interaction must only be considered over once *every* recognizer has
143
+ * finalised. Counting them is what makes two-fingers-to-one continuous instead
144
+ * of ending the gesture early.
145
+ *
146
+ * Pure and worklet-safe so the surface and its tests share one definition.
147
+ */
148
+ export function onRecognizerBegin(count: number): { count: number; isFirst: boolean } {
149
+ 'worklet'
150
+ const next = Math.max(0, count) + 1
151
+ return { count: next, isFirst: next === 1 }
152
+ }
153
+
154
+ /** Returns `isLast` only when this was the final live recognizer. */
155
+ export function onRecognizerEnd(count: number): { count: number; isLast: boolean } {
156
+ 'worklet'
157
+ const next = Math.max(0, count - 1)
158
+ return { count: next, isLast: next === 0 && count > 0 }
159
+ }