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