@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,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard layout maths for the text composer.
|
|
3
|
+
*
|
|
4
|
+
* Split from the component so it can be tested without mounting a keyboard,
|
|
5
|
+
* and kept free of any React Native import so it runs in plain Node.
|
|
6
|
+
*
|
|
7
|
+
* The rule this encodes: **the canvas never moves.** Milestone 2 established
|
|
8
|
+
* that changing canvas metrics mid-interaction moves the ground under the
|
|
9
|
+
* gesture; the same is true of a caret. So the keyboard's height becomes an
|
|
10
|
+
* inset for the composer's *controls*, and nothing else in the editor reacts to
|
|
11
|
+
* it.
|
|
12
|
+
*
|
|
13
|
+
* @experimental
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface KeyboardFrame {
|
|
17
|
+
/** Height of the keyboard in dp; 0 when hidden or when a hardware keyboard is attached. */
|
|
18
|
+
height: number
|
|
19
|
+
/** Milliseconds the platform will take to move it. Android often reports 0. */
|
|
20
|
+
duration?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ComposerInsets {
|
|
24
|
+
/** Distance from the bottom of the screen to place the control bar. */
|
|
25
|
+
bottom: number
|
|
26
|
+
/** True while the keyboard occupies screen space. */
|
|
27
|
+
keyboardVisible: boolean
|
|
28
|
+
/** Animation duration to match the platform's own, clamped to something sane. */
|
|
29
|
+
duration: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const HIDDEN_KEYBOARD: KeyboardFrame = { height: 0 }
|
|
33
|
+
|
|
34
|
+
/** Longest keyboard animation we will follow; beyond this the UI feels stuck. */
|
|
35
|
+
const MAX_DURATION = 400
|
|
36
|
+
/** Android frequently reports 0; a short move still reads better than a jump. */
|
|
37
|
+
const FALLBACK_DURATION = 180
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Where the composer's controls sit, given the keyboard and the safe area.
|
|
41
|
+
*
|
|
42
|
+
* When the keyboard is up it already covers the home indicator, so the bottom
|
|
43
|
+
* safe-area inset must *not* be added on top of it — doing so leaves a visible
|
|
44
|
+
* gap between the controls and the keyboard on iOS.
|
|
45
|
+
*/
|
|
46
|
+
export function composerInsets(
|
|
47
|
+
frame: KeyboardFrame,
|
|
48
|
+
safeAreaBottom = 0,
|
|
49
|
+
): ComposerInsets {
|
|
50
|
+
const height = Number.isFinite(frame.height) ? Math.max(0, frame.height) : 0
|
|
51
|
+
const visible = height > 0
|
|
52
|
+
const raw = frame.duration
|
|
53
|
+
const duration =
|
|
54
|
+
raw === undefined || !Number.isFinite(raw) || raw <= 0
|
|
55
|
+
? FALLBACK_DURATION
|
|
56
|
+
: Math.min(MAX_DURATION, raw)
|
|
57
|
+
return {
|
|
58
|
+
bottom: visible ? height : Math.max(0, safeAreaBottom),
|
|
59
|
+
keyboardVisible: visible,
|
|
60
|
+
duration,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether a keyboard event belongs to the session that is listening.
|
|
66
|
+
*
|
|
67
|
+
* Keyboard events arrive asynchronously and can land after a session has
|
|
68
|
+
* closed — or, worse, after the *next* one has opened. Sessions carry a
|
|
69
|
+
* monotonic token so a late event from an earlier session is ignored rather
|
|
70
|
+
* than resizing the wrong composer.
|
|
71
|
+
*/
|
|
72
|
+
export function isCurrentSession(eventToken: number, currentToken: number): boolean {
|
|
73
|
+
return eventToken === currentToken
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Space available for the text itself, between the top chrome and the controls.
|
|
78
|
+
*
|
|
79
|
+
* Returns 0 rather than a negative number when the keyboard leaves no room —
|
|
80
|
+
* a caller sizing a view with this must not be handed a negative height.
|
|
81
|
+
*/
|
|
82
|
+
export function composerContentHeight(
|
|
83
|
+
screenHeight: number,
|
|
84
|
+
insets: ComposerInsets,
|
|
85
|
+
topChrome: number,
|
|
86
|
+
controlsHeight: number,
|
|
87
|
+
): number {
|
|
88
|
+
return Math.max(0, screenHeight - topChrome - controlsHeight - insets.bottom)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface Box {
|
|
92
|
+
width: number
|
|
93
|
+
height: number
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface CanvasRect {
|
|
97
|
+
/** Size of the fitted canvas inside the container. */
|
|
98
|
+
width: number
|
|
99
|
+
height: number
|
|
100
|
+
/** Offset of the canvas inside the container, which centres it. */
|
|
101
|
+
left: number
|
|
102
|
+
top: number
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Where the canvas sits inside a container, fitted to the document's aspect.
|
|
107
|
+
*
|
|
108
|
+
* The composer needs this to lay its input over the text it is editing, and it
|
|
109
|
+
* cannot ask the canvas: `useCanvasMetrics` is provided by `EditorCanvas` to
|
|
110
|
+
* its own children, and the composer is a sibling of the canvas, not a child.
|
|
111
|
+
* Reading it there returns `null` — which, when used as a width, silently
|
|
112
|
+
* produced a zero-width input that swallowed every keystroke.
|
|
113
|
+
*
|
|
114
|
+
* So the fit is recomputed here from the same rule `EditorCanvas` uses:
|
|
115
|
+
* contain, then centre.
|
|
116
|
+
*/
|
|
117
|
+
export function fitCanvasRect(container: Box, document: Box): CanvasRect {
|
|
118
|
+
if (container.width <= 0 || container.height <= 0 || document.width <= 0 || document.height <= 0) {
|
|
119
|
+
return { width: 0, height: 0, left: 0, top: 0 }
|
|
120
|
+
}
|
|
121
|
+
const scale = Math.min(container.width / document.width, container.height / document.height)
|
|
122
|
+
const width = document.width * scale
|
|
123
|
+
const height = document.height * scale
|
|
124
|
+
return {
|
|
125
|
+
width,
|
|
126
|
+
height,
|
|
127
|
+
left: (container.width - width) / 2,
|
|
128
|
+
top: (container.height - height) / 2,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clampTextSize,
|
|
3
|
+
collapseTextSpans,
|
|
4
|
+
createTextDraft,
|
|
5
|
+
needsSpanCollapse,
|
|
6
|
+
isBlankText,
|
|
7
|
+
patchTextSpan,
|
|
8
|
+
resolveTextSession,
|
|
9
|
+
textSpanOf,
|
|
10
|
+
textValueOf,
|
|
11
|
+
useEditor,
|
|
12
|
+
type Layer,
|
|
13
|
+
type TextLayerData,
|
|
14
|
+
type TextSpan,
|
|
15
|
+
} from '@pictor-sdk/core'
|
|
16
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The state and commands behind a text-editing session.
|
|
20
|
+
*
|
|
21
|
+
* This is the whole contract a composer needs, so the default composer and any
|
|
22
|
+
* replacement are built on exactly the same surface — which is what makes the
|
|
23
|
+
* default one genuinely replaceable rather than merely swappable-in-principle.
|
|
24
|
+
*
|
|
25
|
+
* The session owns its history batch. The text tool has no bottom-sheet panel —
|
|
26
|
+
* the composer *is* its UI — so `ToolPanelHost` never opens one for it, and the
|
|
27
|
+
* session must, or every keystroke would be its own undo entry. Opening it here
|
|
28
|
+
* also fixes the ordering: the batch is open before the draft is created,
|
|
29
|
+
* which it would not be if a parent opened it in its own mount effect (effects
|
|
30
|
+
* run child-first).
|
|
31
|
+
*
|
|
32
|
+
* @experimental
|
|
33
|
+
*/
|
|
34
|
+
export interface TextSessionController {
|
|
35
|
+
/** The layer being edited — a fresh draft, or the one that was selected. */
|
|
36
|
+
layer: Layer | undefined
|
|
37
|
+
/** Whether this session created the layer, which decides blank-Done. */
|
|
38
|
+
kind: 'draft' | 'existing'
|
|
39
|
+
/** Current text. Mirrors the document; drives the input's value. */
|
|
40
|
+
value: string
|
|
41
|
+
/** Replace the text. */
|
|
42
|
+
setValue(next: string): void
|
|
43
|
+
/** The span the composer styles as a whole. */
|
|
44
|
+
span: TextSpan | undefined
|
|
45
|
+
/** Full layer data, for alignment/fill/stroke/shadow controls. */
|
|
46
|
+
data: TextLayerData | undefined
|
|
47
|
+
/** Patch the styled span — font, size, colour, weight, spacing. */
|
|
48
|
+
styleSpan(patch: Partial<TextSpan>): void
|
|
49
|
+
/** Patch layer-level text data — alignment, fill, stroke, shadow, maxWidth. */
|
|
50
|
+
styleText(patch: Partial<TextLayerData>): void
|
|
51
|
+
/** Set a normalized font size, held inside the supported range. */
|
|
52
|
+
setSize(size: number): void
|
|
53
|
+
/**
|
|
54
|
+
* Finish. A draft that ended blank is discarded and records nothing.
|
|
55
|
+
*
|
|
56
|
+
* Returns the committed layer id, or `null` when nothing was kept.
|
|
57
|
+
*/
|
|
58
|
+
commit(): string | null
|
|
59
|
+
/** Abandon. Restores the document to exactly its state when the tool opened. */
|
|
60
|
+
cancel(): void
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Hooks into the composing session — chiefly what to do when it ends. */
|
|
64
|
+
export interface UseTextSessionOptions {
|
|
65
|
+
/**
|
|
66
|
+
* Edit this layer instead of resolving from the selection. Lets a custom UI
|
|
67
|
+
* request editing for a specific layer — the re-entry API.
|
|
68
|
+
*/
|
|
69
|
+
layerId?: string
|
|
70
|
+
/** Called once the session has committed or been discarded. */
|
|
71
|
+
onFinished?: (layerId: string | null) => void
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Open a text-editing session for the current selection (or `layerId`).
|
|
76
|
+
*
|
|
77
|
+
* Creates its draft on mount so the canvas shows the layer immediately and the
|
|
78
|
+
* caret has something to sit on.
|
|
79
|
+
*/
|
|
80
|
+
export function useTextSession(options: UseTextSessionOptions = {}): TextSessionController {
|
|
81
|
+
const { document, dispatch, profile, selection, select } = useEditor()
|
|
82
|
+
const { layerId: requestedId, onFinished } = options
|
|
83
|
+
|
|
84
|
+
// Resolved once: what the session is editing must not change under the user
|
|
85
|
+
// because the selection moved.
|
|
86
|
+
const [session] = useState(() =>
|
|
87
|
+
resolveTextSession(document.layers, requestedId ? [requestedId] : selection),
|
|
88
|
+
)
|
|
89
|
+
const [draftId, setDraftId] = useState<string | null>(null)
|
|
90
|
+
const finished = useRef(false)
|
|
91
|
+
/**
|
|
92
|
+
* The selection as it was before the session opened.
|
|
93
|
+
*
|
|
94
|
+
* Rolling the *document* back is not enough: a draft replaces the selection
|
|
95
|
+
* with its own id, so a rollback that clears the selection leaves the editor
|
|
96
|
+
* in a state the user never chose. Captured once, restored on every rollback
|
|
97
|
+
* path — Cancel, blank-draft Done, and unmount.
|
|
98
|
+
*/
|
|
99
|
+
const priorSelection = useRef<string[]>([])
|
|
100
|
+
const capturedPrior = useRef(false)
|
|
101
|
+
if (!capturedPrior.current) {
|
|
102
|
+
capturedPrior.current = true
|
|
103
|
+
priorSelection.current = [...selection]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const latest = useRef({ document, dispatch, select, selection, profile })
|
|
107
|
+
latest.current = { document, dispatch, select, selection, profile }
|
|
108
|
+
|
|
109
|
+
// Open the transaction, then create the draft inside it. Both exactly once:
|
|
110
|
+
// a second draft would be a second layer, and a second batch would leak depth.
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
latest.current.dispatch({ type: 'history/beginBatch', label: 'text' })
|
|
113
|
+
if (session.kind === 'draft') {
|
|
114
|
+
const draft = createTextDraft(latest.current.profile)
|
|
115
|
+
setDraftId(draft.id)
|
|
116
|
+
latest.current.dispatch({ type: 'layer/add', layer: draft })
|
|
117
|
+
latest.current.select([draft.id])
|
|
118
|
+
} else if (session.layerId) {
|
|
119
|
+
// Whole-layer editing cannot address more than one span, so a multi-span
|
|
120
|
+
// layer is collapsed up front — inside the batch, so Cancel reverses it
|
|
121
|
+
// exactly. See `collapseTextSpans` for why.
|
|
122
|
+
const layer = latest.current.document.layers.find((l) => l.id === session.layerId)
|
|
123
|
+
const layerData = layer?.data as TextLayerData | undefined
|
|
124
|
+
if (layerData && needsSpanCollapse(layerData)) {
|
|
125
|
+
latest.current.dispatch({
|
|
126
|
+
type: 'layer/patchData',
|
|
127
|
+
id: session.layerId,
|
|
128
|
+
patch: { spans: collapseTextSpans(layerData).spans },
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return () => {
|
|
133
|
+
// Unmounting without Done or Cancel — a tool switch, a navigation, an
|
|
134
|
+
// unmount mid-typing — must not leave the batch open or the edit applied.
|
|
135
|
+
if (finished.current) return
|
|
136
|
+
finished.current = true
|
|
137
|
+
latest.current.dispatch({ type: 'history/cancelBatch' })
|
|
138
|
+
latest.current.select(priorSelection.current)
|
|
139
|
+
}
|
|
140
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
141
|
+
}, [])
|
|
142
|
+
|
|
143
|
+
const layerId = session.kind === 'existing' ? session.layerId! : draftId
|
|
144
|
+
const layer = layerId ? document.layers.find((l) => l.id === layerId) : undefined
|
|
145
|
+
const data = layer ? (layer.data as TextLayerData) : undefined
|
|
146
|
+
const span = data ? textSpanOf(data) : undefined
|
|
147
|
+
const value = data ? textValueOf(data) : ''
|
|
148
|
+
|
|
149
|
+
// Read through refs so the commands keep a stable identity: a composer that
|
|
150
|
+
// re-created its callbacks each keystroke would remount its controls.
|
|
151
|
+
const idRef = useRef<string | null>(null)
|
|
152
|
+
idRef.current = layerId ?? null
|
|
153
|
+
const dataRef = useRef<TextLayerData | undefined>(undefined)
|
|
154
|
+
dataRef.current = data
|
|
155
|
+
|
|
156
|
+
const patchData = useCallback((patch: Partial<TextLayerData>) => {
|
|
157
|
+
const id = idRef.current
|
|
158
|
+
if (!id) return
|
|
159
|
+
latest.current.dispatch({ type: 'layer/patchData', id, patch: patch as Record<string, unknown> })
|
|
160
|
+
}, [])
|
|
161
|
+
|
|
162
|
+
const setValue = useCallback(
|
|
163
|
+
(next: string) => {
|
|
164
|
+
const current = dataRef.current
|
|
165
|
+
if (!current) return
|
|
166
|
+
patchData({ spans: patchTextSpan(current, { text: next }).spans })
|
|
167
|
+
},
|
|
168
|
+
[patchData],
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
const styleSpan = useCallback(
|
|
172
|
+
(patch: Partial<TextSpan>) => {
|
|
173
|
+
const current = dataRef.current
|
|
174
|
+
if (!current) return
|
|
175
|
+
patchData({ spans: patchTextSpan(current, patch).spans })
|
|
176
|
+
},
|
|
177
|
+
[patchData],
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
const setSize = useCallback(
|
|
181
|
+
(size: number) => styleSpan({ fontSize: clampTextSize(size) }),
|
|
182
|
+
[styleSpan],
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
const commit = useCallback((): string | null => {
|
|
186
|
+
if (finished.current) return null
|
|
187
|
+
finished.current = true
|
|
188
|
+
const id = idRef.current
|
|
189
|
+
const blank = isBlankText(dataRef.current ? textValueOf(dataRef.current) : '')
|
|
190
|
+
// A blank draft is discarded by cancelling, not by removing and committing:
|
|
191
|
+
// `endBatch` compares opening and closing documents by identity, so
|
|
192
|
+
// add-then-remove still pushes an undo entry that appears to do nothing.
|
|
193
|
+
if (session.kind === 'draft' && blank) {
|
|
194
|
+
latest.current.dispatch({ type: 'history/cancelBatch' })
|
|
195
|
+
latest.current.select(priorSelection.current)
|
|
196
|
+
onFinished?.(null)
|
|
197
|
+
return null
|
|
198
|
+
}
|
|
199
|
+
latest.current.dispatch({ type: 'history/endBatch' })
|
|
200
|
+
onFinished?.(id ?? null)
|
|
201
|
+
return id ?? null
|
|
202
|
+
}, [session.kind, onFinished])
|
|
203
|
+
|
|
204
|
+
const cancel = useCallback(() => {
|
|
205
|
+
if (finished.current) return
|
|
206
|
+
finished.current = true
|
|
207
|
+
latest.current.dispatch({ type: 'history/cancelBatch' })
|
|
208
|
+
// Restore the whole editor state, not just the document: the session may
|
|
209
|
+
// have moved the selection to its draft.
|
|
210
|
+
latest.current.select(priorSelection.current)
|
|
211
|
+
onFinished?.(null)
|
|
212
|
+
}, [onFinished])
|
|
213
|
+
|
|
214
|
+
return useMemo(
|
|
215
|
+
() => ({
|
|
216
|
+
layer,
|
|
217
|
+
kind: session.kind,
|
|
218
|
+
value,
|
|
219
|
+
setValue,
|
|
220
|
+
span,
|
|
221
|
+
data,
|
|
222
|
+
styleSpan,
|
|
223
|
+
styleText: patchData,
|
|
224
|
+
setSize,
|
|
225
|
+
commit,
|
|
226
|
+
cancel,
|
|
227
|
+
}),
|
|
228
|
+
[layer, session.kind, value, setValue, span, data, styleSpan, patchData, setSize, commit, cancel],
|
|
229
|
+
)
|
|
230
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clampCropTransform,
|
|
3
|
+
cropFitScale,
|
|
4
|
+
cropPanBounds,
|
|
5
|
+
focalZoomTransform,
|
|
6
|
+
type ImageLayerData,
|
|
7
|
+
type Transform,
|
|
8
|
+
} from '@pictor-sdk/core'
|
|
9
|
+
import { useCanvasMetrics } from '@pictor-sdk/skia'
|
|
10
|
+
import React, { useEffect, useMemo, useRef } from 'react'
|
|
11
|
+
import { StyleSheet } from 'react-native'
|
|
12
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
|
13
|
+
import Animated, { useDerivedValue, useSharedValue, withTiming } from 'react-native-reanimated'
|
|
14
|
+
import { runOnJS } from 'react-native-worklets'
|
|
15
|
+
import { useTargetImageLayer } from '../panels/useTargetImageLayer'
|
|
16
|
+
import { useEditor } from '@pictor-sdk/core'
|
|
17
|
+
import { useInteractionState } from './interactionState'
|
|
18
|
+
|
|
19
|
+
/** Overrides for crop gestures, defaulting to the profile's interaction settings. */
|
|
20
|
+
export interface CropGestureSurfaceProps {
|
|
21
|
+
/** Override profile crop fit for this surface. */
|
|
22
|
+
fit?: 'contain' | 'cover'
|
|
23
|
+
maxScale?: number
|
|
24
|
+
/** Enable free two-finger rotation; disabled by the default Post profile. */
|
|
25
|
+
rotationEnabled?: boolean
|
|
26
|
+
/** Optional host haptic/motion callback. */
|
|
27
|
+
onInteractionChange?: (active: boolean) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Fixed-viewport photo crop gestures. Pan and focal-point pinch run as shared
|
|
32
|
+
* values on the UI thread; one normalized transform is committed on release.
|
|
33
|
+
*/
|
|
34
|
+
export function CropGestureSurface({ fit, maxScale, rotationEnabled, onInteractionChange }: CropGestureSurfaceProps) {
|
|
35
|
+
const { document, dispatch, profile } = useEditor()
|
|
36
|
+
const layer = useTargetImageLayer()
|
|
37
|
+
const metrics = useCanvasMetrics()
|
|
38
|
+
const interaction = useInteractionState()
|
|
39
|
+
const config = profile.interaction?.crop
|
|
40
|
+
const source = (layer?.data as ImageLayerData | undefined)?.intrinsic
|
|
41
|
+
const resolvedFit = fit ?? config?.fit ?? 'contain'
|
|
42
|
+
const resolvedMax = maxScale ?? config?.maxScale ?? 8
|
|
43
|
+
const resolvedRotation = rotationEnabled ?? config?.rotationEnabled ?? false
|
|
44
|
+
|
|
45
|
+
const liveX = useSharedValue(layer?.transform.x ?? 0.5)
|
|
46
|
+
const liveY = useSharedValue(layer?.transform.y ?? 0.5)
|
|
47
|
+
const liveScale = useSharedValue(layer?.transform.scale ?? 1)
|
|
48
|
+
// Seed from React state directly; reading shared values during render trips
|
|
49
|
+
// Reanimated strict mode on real devices.
|
|
50
|
+
const baseX = useSharedValue(layer?.transform.x ?? 0.5)
|
|
51
|
+
const baseY = useSharedValue(layer?.transform.y ?? 0.5)
|
|
52
|
+
const baseScale = useSharedValue(layer?.transform.scale ?? 1)
|
|
53
|
+
const liveRotation = useSharedValue(layer?.transform.rotation ?? 0)
|
|
54
|
+
const baseRotation = useSharedValue(layer?.transform.rotation ?? 0)
|
|
55
|
+
const panX = useSharedValue(0)
|
|
56
|
+
const panY = useSharedValue(0)
|
|
57
|
+
const pinchScale = useSharedValue(1)
|
|
58
|
+
const rotateDelta = useSharedValue(0)
|
|
59
|
+
const focalX = useSharedValue(0.5)
|
|
60
|
+
const focalY = useSharedValue(0.5)
|
|
61
|
+
const activeRecognizers = useSharedValue(0)
|
|
62
|
+
const commitTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
63
|
+
|
|
64
|
+
const minScale = source
|
|
65
|
+
? cropFitScale(source, document.size, resolvedFit, layer?.transform.rotation ?? 0)
|
|
66
|
+
: 1
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!layer) return
|
|
70
|
+
liveX.value = layer.transform.x
|
|
71
|
+
liveY.value = layer.transform.y
|
|
72
|
+
liveScale.value = layer.transform.scale
|
|
73
|
+
liveRotation.value = layer.transform.rotation
|
|
74
|
+
// Shared-value identities are stable; only committed scalar values resync them.
|
|
75
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
76
|
+
}, [layer?.transform.x, layer?.transform.y, layer?.transform.scale, layer?.transform.rotation])
|
|
77
|
+
|
|
78
|
+
const previewTransform = useDerivedValue(() => {
|
|
79
|
+
const ratio = liveScale.value / Math.max(baseScale.value, 0.000001)
|
|
80
|
+
return [
|
|
81
|
+
{ translateX: liveX.value * document.size.width },
|
|
82
|
+
{ translateY: liveY.value * document.size.height },
|
|
83
|
+
{ rotate: liveRotation.value - baseRotation.value },
|
|
84
|
+
{ scale: ratio },
|
|
85
|
+
{ translateX: -baseX.value * document.size.width },
|
|
86
|
+
{ translateY: -baseY.value * document.size.height },
|
|
87
|
+
]
|
|
88
|
+
}, [document.size.width, document.size.height])
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!layer) return
|
|
92
|
+
interaction.setPreview({ layerId: layer.id, transform: previewTransform })
|
|
93
|
+
return () => interaction.setPreview(null)
|
|
94
|
+
// Register by layer identity; crop-active context changes must not replace it.
|
|
95
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
96
|
+
}, [layer?.id, previewTransform, interaction.setPreview])
|
|
97
|
+
|
|
98
|
+
const setActive = (active: boolean) => {
|
|
99
|
+
interaction.setCropActive(active)
|
|
100
|
+
onInteractionChange?.(active)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const commit = (candidate: Transform) => {
|
|
104
|
+
if (!layer || !source) return
|
|
105
|
+
if (commitTimer.current) clearTimeout(commitTimer.current)
|
|
106
|
+
const settledMinScale = cropFitScale(source, document.size, resolvedFit, candidate.rotation)
|
|
107
|
+
const bounds = cropPanBounds(source, document.size, candidate.scale, settledMinScale, resolvedMax, candidate.rotation)
|
|
108
|
+
const settled = clampCropTransform(candidate, bounds)
|
|
109
|
+
liveX.value = withTiming(settled.x, { duration: 160 })
|
|
110
|
+
liveY.value = withTiming(settled.y, { duration: 160 })
|
|
111
|
+
liveScale.value = withTiming(settled.scale, { duration: 160 })
|
|
112
|
+
liveRotation.value = withTiming(settled.rotation, { duration: 160 })
|
|
113
|
+
commitTimer.current = setTimeout(() => {
|
|
114
|
+
dispatch({
|
|
115
|
+
type: 'layer/transform',
|
|
116
|
+
id: layer.id,
|
|
117
|
+
patch: {
|
|
118
|
+
x: settled.x,
|
|
119
|
+
y: settled.y,
|
|
120
|
+
scale: settled.scale,
|
|
121
|
+
...(resolvedRotation ? { rotation: settled.rotation } : {}),
|
|
122
|
+
},
|
|
123
|
+
})
|
|
124
|
+
baseX.value = settled.x
|
|
125
|
+
baseY.value = settled.y
|
|
126
|
+
baseScale.value = settled.scale
|
|
127
|
+
baseRotation.value = settled.rotation
|
|
128
|
+
liveX.value = settled.x
|
|
129
|
+
liveY.value = settled.y
|
|
130
|
+
liveScale.value = settled.scale
|
|
131
|
+
liveRotation.value = settled.rotation
|
|
132
|
+
setActive(false)
|
|
133
|
+
}, 165)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const toggleZoom = (xDp: number, yDp: number) => {
|
|
137
|
+
if (!layer || !source || !metrics) return
|
|
138
|
+
baseX.value = layer.transform.x
|
|
139
|
+
baseY.value = layer.transform.y
|
|
140
|
+
baseScale.value = layer.transform.scale
|
|
141
|
+
baseRotation.value = layer.transform.rotation
|
|
142
|
+
liveX.value = layer.transform.x
|
|
143
|
+
liveY.value = layer.transform.y
|
|
144
|
+
liveScale.value = layer.transform.scale
|
|
145
|
+
liveRotation.value = layer.transform.rotation
|
|
146
|
+
setActive(true)
|
|
147
|
+
const atMinimum = Math.abs(layer.transform.scale - minScale) < 0.01
|
|
148
|
+
const targetScale = atMinimum
|
|
149
|
+
? Math.min(resolvedMax, minScale * (config?.doubleTapScale ?? 2))
|
|
150
|
+
: minScale
|
|
151
|
+
commit(focalZoomTransform(layer.transform, targetScale, {
|
|
152
|
+
x: xDp / metrics.width,
|
|
153
|
+
y: yDp / metrics.height,
|
|
154
|
+
}))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const gesture = useMemo(() => {
|
|
158
|
+
if (!metrics || !source || !layer) return Gesture.Tap()
|
|
159
|
+
const begin = () => {
|
|
160
|
+
'worklet'
|
|
161
|
+
if (activeRecognizers.value === 0) {
|
|
162
|
+
baseX.value = liveX.value
|
|
163
|
+
baseY.value = liveY.value
|
|
164
|
+
baseScale.value = liveScale.value
|
|
165
|
+
baseRotation.value = liveRotation.value
|
|
166
|
+
panX.value = 0
|
|
167
|
+
panY.value = 0
|
|
168
|
+
pinchScale.value = 1
|
|
169
|
+
rotateDelta.value = 0
|
|
170
|
+
runOnJS(setActive)(true)
|
|
171
|
+
}
|
|
172
|
+
activeRecognizers.value += 1
|
|
173
|
+
}
|
|
174
|
+
const updateLive = () => {
|
|
175
|
+
'worklet'
|
|
176
|
+
const rotation = baseRotation.value + rotateDelta.value
|
|
177
|
+
const naturalWidth = document.size.width
|
|
178
|
+
const naturalHeight = document.size.width * (source.height / source.width)
|
|
179
|
+
const cos = Math.abs(Math.cos(rotation))
|
|
180
|
+
const sin = Math.abs(Math.sin(rotation))
|
|
181
|
+
const rotatedWidth = naturalWidth * cos + naturalHeight * sin
|
|
182
|
+
const rotatedHeight = naturalWidth * sin + naturalHeight * cos
|
|
183
|
+
const widthScale = document.size.width / rotatedWidth
|
|
184
|
+
const heightScale = document.size.height / rotatedHeight
|
|
185
|
+
const dynamicMin = resolvedFit === 'contain' ? Math.min(widthScale, heightScale) : Math.max(widthScale, heightScale)
|
|
186
|
+
const rawScale = baseScale.value * pinchScale.value
|
|
187
|
+
const resistance = config?.resistance ?? 0.35
|
|
188
|
+
const scale = rawScale < dynamicMin
|
|
189
|
+
? dynamicMin - (dynamicMin - rawScale) * resistance
|
|
190
|
+
: rawScale > resolvedMax
|
|
191
|
+
? resolvedMax + (rawScale - resolvedMax) * resistance
|
|
192
|
+
: rawScale
|
|
193
|
+
const ratio = scale / Math.max(baseScale.value, 0.000001)
|
|
194
|
+
const zoomX = focalX.value - (focalX.value - baseX.value) * ratio
|
|
195
|
+
const zoomY = focalY.value - (focalY.value - baseY.value) * ratio
|
|
196
|
+
liveRotation.value = rotation
|
|
197
|
+
const dx = Math.max(0, (rotatedWidth * scale - document.size.width) / (2 * document.size.width))
|
|
198
|
+
const dy = Math.max(0, (rotatedHeight * scale - document.size.height) / (2 * document.size.height))
|
|
199
|
+
liveScale.value = scale
|
|
200
|
+
const rawX = zoomX + panX.value
|
|
201
|
+
const rawY = zoomY + panY.value
|
|
202
|
+
const minX = 0.5 - dx
|
|
203
|
+
const maxX = 0.5 + dx
|
|
204
|
+
const minY = 0.5 - dy
|
|
205
|
+
const maxY = 0.5 + dy
|
|
206
|
+
liveX.value = rawX < minX ? minX - (minX - rawX) * resistance : rawX > maxX ? maxX + (rawX - maxX) * resistance : rawX
|
|
207
|
+
liveY.value = rawY < minY ? minY - (minY - rawY) * resistance : rawY > maxY ? maxY + (rawY - maxY) * resistance : rawY
|
|
208
|
+
}
|
|
209
|
+
const finish = () => {
|
|
210
|
+
'worklet'
|
|
211
|
+
activeRecognizers.value = Math.max(0, activeRecognizers.value - 1)
|
|
212
|
+
if (activeRecognizers.value === 0) {
|
|
213
|
+
runOnJS(commit)({
|
|
214
|
+
x: liveX.value,
|
|
215
|
+
y: liveY.value,
|
|
216
|
+
scale: liveScale.value,
|
|
217
|
+
rotation: liveRotation.value,
|
|
218
|
+
...(layer.transform.anchor ? { anchor: layer.transform.anchor } : {}),
|
|
219
|
+
...(layer.transform.flipX !== undefined ? { flipX: layer.transform.flipX } : {}),
|
|
220
|
+
...(layer.transform.flipY !== undefined ? { flipY: layer.transform.flipY } : {}),
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const pan = Gesture.Pan()
|
|
225
|
+
.minPointers(1)
|
|
226
|
+
.onStart(begin)
|
|
227
|
+
.onUpdate((event) => {
|
|
228
|
+
'worklet'
|
|
229
|
+
panX.value = event.translationX / metrics.width
|
|
230
|
+
panY.value = event.translationY / metrics.height
|
|
231
|
+
updateLive()
|
|
232
|
+
})
|
|
233
|
+
.onFinalize(finish)
|
|
234
|
+
const pinch = Gesture.Pinch()
|
|
235
|
+
.onStart(begin)
|
|
236
|
+
.onUpdate((event) => {
|
|
237
|
+
'worklet'
|
|
238
|
+
pinchScale.value = event.scale
|
|
239
|
+
focalX.value = event.focalX / metrics.width
|
|
240
|
+
focalY.value = event.focalY / metrics.height
|
|
241
|
+
updateLive()
|
|
242
|
+
})
|
|
243
|
+
.onFinalize(finish)
|
|
244
|
+
const rotate = Gesture.Rotation()
|
|
245
|
+
.enabled(resolvedRotation)
|
|
246
|
+
.onStart(begin)
|
|
247
|
+
.onUpdate((event) => {
|
|
248
|
+
'worklet'
|
|
249
|
+
rotateDelta.value = event.rotation
|
|
250
|
+
updateLive()
|
|
251
|
+
})
|
|
252
|
+
.onFinalize(finish)
|
|
253
|
+
const doubleTap = Gesture.Tap()
|
|
254
|
+
.numberOfTaps(2)
|
|
255
|
+
.onEnd((event) => {
|
|
256
|
+
'worklet'
|
|
257
|
+
runOnJS(toggleZoom)(event.x, event.y)
|
|
258
|
+
})
|
|
259
|
+
return Gesture.Exclusive(doubleTap, Gesture.Simultaneous(pan, pinch, rotate))
|
|
260
|
+
// Gesture worklets read stable shared values; recreate only for geometry/config changes.
|
|
261
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
262
|
+
}, [metrics, source, layer, resolvedFit, resolvedMax, resolvedRotation, minScale, document.size.width, document.size.height, config?.resistance, config?.doubleTapScale])
|
|
263
|
+
|
|
264
|
+
if (!layer) return null
|
|
265
|
+
return (
|
|
266
|
+
<GestureDetector gesture={gesture}>
|
|
267
|
+
<Animated.View style={StyleSheet.absoluteFill} />
|
|
268
|
+
</GestureDetector>
|
|
269
|
+
)
|
|
270
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { StyleSheet, View } from 'react-native'
|
|
3
|
+
import { useInteractionState } from './interactionState'
|
|
4
|
+
|
|
5
|
+
/** Rule-of-thirds overlay shown only while crop interaction is active. */
|
|
6
|
+
export function CropGrid({ always = false }: { always?: boolean }) {
|
|
7
|
+
const { cropActive } = useInteractionState()
|
|
8
|
+
if (!always && !cropActive) return null
|
|
9
|
+
return (
|
|
10
|
+
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
|
|
11
|
+
{[1 / 3, 2 / 3].map((fraction) => (
|
|
12
|
+
<React.Fragment key={fraction}>
|
|
13
|
+
<View style={[styles.vertical, { left: `${fraction * 100}%` }]} />
|
|
14
|
+
<View style={[styles.horizontal, { top: `${fraction * 100}%` }]} />
|
|
15
|
+
</React.Fragment>
|
|
16
|
+
))}
|
|
17
|
+
</View>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const styles = StyleSheet.create({
|
|
22
|
+
vertical: { position: 'absolute', top: 0, bottom: 0, width: 1, backgroundColor: '#ffffff88' },
|
|
23
|
+
horizontal: { position: 'absolute', left: 0, right: 0, height: 1, backgroundColor: '#ffffff88' },
|
|
24
|
+
})
|