@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
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pictor-sdk/ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Default slot-based editor UI for Pictor, built entirely on the public headless API.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"module": "./dist/index.mjs",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"react-native": "./src/index.ts",
|
|
11
|
+
"source": "./src/index.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"react-native": "./src/index.ts",
|
|
16
|
+
"import": "./dist/index.mjs",
|
|
17
|
+
"require": "./dist/index.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"src",
|
|
23
|
+
"LICENSE",
|
|
24
|
+
"README.md",
|
|
25
|
+
"!src/**/*.test.*",
|
|
26
|
+
"!src/**/__tests__/**",
|
|
27
|
+
"!dist/**/*.test.*"
|
|
28
|
+
],
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@shopify/react-native-skia": ">=1.5.0",
|
|
31
|
+
"react": ">=18.0.0",
|
|
32
|
+
"react-native": ">=0.76.0",
|
|
33
|
+
"react-native-gesture-handler": ">=2.20.0",
|
|
34
|
+
"react-native-reanimated": ">=3.16.0",
|
|
35
|
+
"react-native-worklets": ">=0.5.1",
|
|
36
|
+
"@pictor-sdk/core": "^0.1.0",
|
|
37
|
+
"@pictor-sdk/skia": "^0.1.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@shopify/react-native-skia": "^2.0.0",
|
|
41
|
+
"@types/react": "^19.0.0",
|
|
42
|
+
"react": "^19.1.0",
|
|
43
|
+
"react-native": "^0.81.0",
|
|
44
|
+
"react-native-gesture-handler": "^2.28.0",
|
|
45
|
+
"react-native-reanimated": "^4.1.0",
|
|
46
|
+
"react-native-worklets": "0.5.1",
|
|
47
|
+
"@pictor-sdk/core": "0.1.0",
|
|
48
|
+
"@pictor-sdk/skia": "0.1.0"
|
|
49
|
+
},
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public",
|
|
52
|
+
"registry": "https://registry.npmjs.org/"
|
|
53
|
+
},
|
|
54
|
+
"repository": {
|
|
55
|
+
"type": "git",
|
|
56
|
+
"url": "git+https://github.com/zahidhasanaunto/pictor.git",
|
|
57
|
+
"directory": "packages/ui"
|
|
58
|
+
},
|
|
59
|
+
"homepage": "https://github.com/zahidhasanaunto/pictor#readme",
|
|
60
|
+
"bugs": {
|
|
61
|
+
"url": "https://github.com/zahidhasanaunto/pictor/issues"
|
|
62
|
+
},
|
|
63
|
+
"scripts": {
|
|
64
|
+
"build": "tsup",
|
|
65
|
+
"test": "vitest run",
|
|
66
|
+
"typecheck": "tsc --noEmit"
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/Editor.tsx
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EditorProvider,
|
|
3
|
+
selectionChromeEnabled,
|
|
4
|
+
useEditor,
|
|
5
|
+
type EditorPlugin,
|
|
6
|
+
type EditorProfile,
|
|
7
|
+
type EditorDocument,
|
|
8
|
+
type ExportSpec,
|
|
9
|
+
type InsertableImage,
|
|
10
|
+
type Size,
|
|
11
|
+
} from '@pictor-sdk/core'
|
|
12
|
+
import { EditorCanvas } from '@pictor-sdk/skia'
|
|
13
|
+
import React, { useState, type ComponentType, type ReactNode } from 'react'
|
|
14
|
+
import { View } from 'react-native'
|
|
15
|
+
import { GestureHandlerRootView } from 'react-native-gesture-handler'
|
|
16
|
+
import { HostCallbacksProvider } from './hostCallbacks'
|
|
17
|
+
import { DrawingSurface } from './gestures/DrawingSurface'
|
|
18
|
+
import { CropGestureSurface } from './gestures/CropGestureSurface'
|
|
19
|
+
import { TiltShiftGestureSurface } from './gestures/TiltShiftGestureSurface'
|
|
20
|
+
import { CropGrid } from './gestures/CropGrid'
|
|
21
|
+
import { InteractionProvider, useInteractionState } from './gestures/interactionState'
|
|
22
|
+
import { LayerGestureSurface } from './gestures/LayerGestureSurface'
|
|
23
|
+
import { StoryMediaGestureSurface } from './gestures/StoryMediaGestureSurface'
|
|
24
|
+
import { resolveGestureSurface } from './gestures/routing'
|
|
25
|
+
import { InteractionGuides } from './gestures/InteractionGuides'
|
|
26
|
+
import { UiStateProvider, useUiState } from './uiState'
|
|
27
|
+
import { type StickerAsset } from './panels/StickerPanel'
|
|
28
|
+
import { ThemeContext, mergeTheme, useTheme, type PartialTheme } from './theme'
|
|
29
|
+
import { ToolPanelHost } from './ToolPanelHost'
|
|
30
|
+
import { TextComposer, TextComposerHost, type TextComposerProps } from './composer/TextComposer'
|
|
31
|
+
import { TopBar, type TopBarProps } from './components/TopBar'
|
|
32
|
+
import { ToolRail } from './components/ToolRail'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Every region of the default editor is a slot: pass your own component to
|
|
36
|
+
* replace it, or null to remove it. All slots are built exclusively on the
|
|
37
|
+
* public headless hooks — replacing one can never break the others.
|
|
38
|
+
*/
|
|
39
|
+
export interface EditorSlots {
|
|
40
|
+
TopBar: ComponentType<TopBarProps> | null
|
|
41
|
+
CanvasStage: ComponentType<CanvasStageProps> | null
|
|
42
|
+
ToolRail: ComponentType | null
|
|
43
|
+
ToolPanelHost: ComponentType | null
|
|
44
|
+
/**
|
|
45
|
+
* The full-screen text composer, shown while the `text` tool is active.
|
|
46
|
+
*
|
|
47
|
+
* Replace it to ship your own composition UI without replacing the canvas or
|
|
48
|
+
* the editor; it receives everything the default one gets. `null` removes it,
|
|
49
|
+
* leaving text editing to a host-built surface.
|
|
50
|
+
*
|
|
51
|
+
* @experimental
|
|
52
|
+
*/
|
|
53
|
+
TextComposer: ComponentType<TextComposerProps> | null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What the canvas slot receives. Children draw over the canvas, in its coordinates. */
|
|
57
|
+
export interface CanvasStageProps {
|
|
58
|
+
children?: ReactNode
|
|
59
|
+
/** Reported when a layer's image source fails to load. */
|
|
60
|
+
onError?: (error: unknown) => void
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The canvas plus whichever gesture surface the active tool calls for.
|
|
65
|
+
*
|
|
66
|
+
* Exactly one surface is mounted at a time — precedence lives in
|
|
67
|
+
* `resolveGestureSurface`, not in this JSX — so two recognisers can never
|
|
68
|
+
* arbitrate over the same touch.
|
|
69
|
+
*/
|
|
70
|
+
export function CanvasStage({ children, onError }: CanvasStageProps) {
|
|
71
|
+
const { activeTool, profile } = useEditor()
|
|
72
|
+
const { previews } = useInteractionState()
|
|
73
|
+
const surface = resolveGestureSurface(activeTool)
|
|
74
|
+
return (
|
|
75
|
+
<EditorCanvas
|
|
76
|
+
style={{ flex: 1 }}
|
|
77
|
+
onError={onError}
|
|
78
|
+
previewTransforms={previews}
|
|
79
|
+
showSelectionChrome={selectionChromeEnabled(profile)}
|
|
80
|
+
>
|
|
81
|
+
{/* exactly one surface owns the canvas; precedence is resolveGestureSurface */}
|
|
82
|
+
{surface === 'draw' ? (
|
|
83
|
+
<DrawingSurface />
|
|
84
|
+
) : surface === 'crop' ? (
|
|
85
|
+
<>
|
|
86
|
+
<CropGestureSurface />
|
|
87
|
+
<CropGrid />
|
|
88
|
+
</>
|
|
89
|
+
) : surface === 'tilt' ? (
|
|
90
|
+
<TiltShiftGestureSurface />
|
|
91
|
+
) : surface === 'media' ? (
|
|
92
|
+
<StoryMediaGestureSurface />
|
|
93
|
+
) : (
|
|
94
|
+
<>
|
|
95
|
+
<LayerGestureSurface />
|
|
96
|
+
<InteractionGuides />
|
|
97
|
+
</>
|
|
98
|
+
)}
|
|
99
|
+
{children}
|
|
100
|
+
</EditorCanvas>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* What `<Editor>` accepts: a profile, its plugins, a document or a source, and
|
|
106
|
+
* any slots or theme you want to replace.
|
|
107
|
+
*/
|
|
108
|
+
export interface EditorProps {
|
|
109
|
+
profile: EditorProfile
|
|
110
|
+
plugins?: EditorPlugin[]
|
|
111
|
+
/** Image source for a fresh document (or pass initialDocument). */
|
|
112
|
+
source?: unknown
|
|
113
|
+
intrinsic?: Size
|
|
114
|
+
initialDocument?: EditorDocument
|
|
115
|
+
onExport?: (bytes: Uint8Array) => void | Promise<void>
|
|
116
|
+
onCancel?: () => void
|
|
117
|
+
onError?: (error: unknown) => void
|
|
118
|
+
exportSpec?: Partial<ExportSpec>
|
|
119
|
+
slots?: Partial<EditorSlots>
|
|
120
|
+
theme?: PartialTheme
|
|
121
|
+
/** Host-supplied sticker catalog for the built-in sticker panel. */
|
|
122
|
+
stickers?: StickerAsset[]
|
|
123
|
+
/**
|
|
124
|
+
* Asked to supply photos when the user presses the add-photo control.
|
|
125
|
+
*
|
|
126
|
+
* The library never touches the gallery: permissions, the picker and any
|
|
127
|
+
* HEIC/HEIF transcoding stay with the host, which is what keeps it free of
|
|
128
|
+
* Expo and of native code. Resolve `null` or `[]` for cancellation, which
|
|
129
|
+
* adds nothing. Omit it and the control renders unavailable rather than
|
|
130
|
+
* throwing.
|
|
131
|
+
*
|
|
132
|
+
* @experimental
|
|
133
|
+
*/
|
|
134
|
+
requestImages?: () => Promise<readonly InsertableImage[] | null>
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Merge slot overrides over the defaults. Pure — unit tested. */
|
|
138
|
+
export function mergeSlots(overrides?: Partial<EditorSlots>): EditorSlots {
|
|
139
|
+
return {
|
|
140
|
+
TopBar,
|
|
141
|
+
CanvasStage,
|
|
142
|
+
ToolRail,
|
|
143
|
+
ToolPanelHost,
|
|
144
|
+
TextComposer,
|
|
145
|
+
...overrides,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function EditorBody({
|
|
150
|
+
slots,
|
|
151
|
+
onExport,
|
|
152
|
+
onCancel,
|
|
153
|
+
onError,
|
|
154
|
+
exportSpec,
|
|
155
|
+
stickers,
|
|
156
|
+
}: Pick<EditorProps, 'onExport' | 'onCancel' | 'onError' | 'exportSpec' | 'stickers'> & {
|
|
157
|
+
slots: EditorSlots
|
|
158
|
+
}) {
|
|
159
|
+
const { export: exportDoc, activeTool } = useEditor()
|
|
160
|
+
const { layerDragActive } = useInteractionState()
|
|
161
|
+
const [, setStickers] = useUiState<StickerAsset[]>('stickers', [])
|
|
162
|
+
React.useEffect(() => {
|
|
163
|
+
if (stickers) setStickers(stickers)
|
|
164
|
+
}, [stickers, setStickers])
|
|
165
|
+
const theme = useTheme()
|
|
166
|
+
const [busy, setBusy] = useState(false)
|
|
167
|
+
|
|
168
|
+
const handleNext = onExport
|
|
169
|
+
? async () => {
|
|
170
|
+
setBusy(true)
|
|
171
|
+
try {
|
|
172
|
+
const bytes = await exportDoc(exportSpec)
|
|
173
|
+
await onExport(bytes)
|
|
174
|
+
} catch (error) {
|
|
175
|
+
onError?.(error)
|
|
176
|
+
} finally {
|
|
177
|
+
setBusy(false)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
: undefined
|
|
181
|
+
|
|
182
|
+
const {
|
|
183
|
+
TopBar: Top,
|
|
184
|
+
CanvasStage: Stage,
|
|
185
|
+
ToolRail: Rail,
|
|
186
|
+
ToolPanelHost: PanelHost,
|
|
187
|
+
TextComposer: Composer,
|
|
188
|
+
} = slots
|
|
189
|
+
// The composer is full-screen and owns the whole interaction, so the ordinary
|
|
190
|
+
// chrome and the tool panel stand down while it is up rather than competing
|
|
191
|
+
// with it for the keyboard and for taps.
|
|
192
|
+
const composing = activeTool === 'text' && Composer !== null
|
|
193
|
+
// While a layer is being dragged the editing chrome gets out of the way, so
|
|
194
|
+
// the canvas and the delete target are all that is on screen.
|
|
195
|
+
//
|
|
196
|
+
// It is hidden with opacity rather than unmounted on purpose: unmounting
|
|
197
|
+
// would hand the freed space to the canvas, which resizes it mid-gesture and
|
|
198
|
+
// moves the ground under the interaction. Keeping the boxes in the layout
|
|
199
|
+
// holds the canvas metrics still for the whole drag.
|
|
200
|
+
const chrome = !layerDragActive
|
|
201
|
+
const hidden = { opacity: chrome ? 1 : 0 }
|
|
202
|
+
const hits = chrome ? 'auto' : ('none' as const)
|
|
203
|
+
return (
|
|
204
|
+
// GestureDetector requires a GestureHandlerRootView ancestor; providing it
|
|
205
|
+
// here means consumers never have to know (nesting under an app-level one
|
|
206
|
+
// is harmless).
|
|
207
|
+
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
208
|
+
<View style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
|
209
|
+
{Top && !composing ? (
|
|
210
|
+
<View style={hidden} pointerEvents={hits}>
|
|
211
|
+
<Top onCancel={onCancel} onNext={handleNext} busy={busy} />
|
|
212
|
+
</View>
|
|
213
|
+
) : null}
|
|
214
|
+
{Stage ? <Stage onError={onError} /> : null}
|
|
215
|
+
{PanelHost && !composing ? (
|
|
216
|
+
<View style={hidden} pointerEvents={hits}>
|
|
217
|
+
<PanelHost />
|
|
218
|
+
</View>
|
|
219
|
+
) : null}
|
|
220
|
+
{Rail && !activeTool ? (
|
|
221
|
+
<View style={hidden} pointerEvents={hits}>
|
|
222
|
+
<Rail />
|
|
223
|
+
</View>
|
|
224
|
+
) : null}
|
|
225
|
+
{composing && Composer ? <TextComposerHost Composer={Composer} /> : null}
|
|
226
|
+
</View>
|
|
227
|
+
</GestureHandlerRootView>
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The batteries-included editor. `<Editor profile={POST_PROFILE} … />` gives
|
|
233
|
+
* the full IG-style flow; every part is replaceable via `slots`.
|
|
234
|
+
*/
|
|
235
|
+
export function Editor({
|
|
236
|
+
profile,
|
|
237
|
+
plugins,
|
|
238
|
+
source,
|
|
239
|
+
intrinsic,
|
|
240
|
+
initialDocument,
|
|
241
|
+
onExport,
|
|
242
|
+
onCancel,
|
|
243
|
+
onError,
|
|
244
|
+
exportSpec,
|
|
245
|
+
slots,
|
|
246
|
+
theme,
|
|
247
|
+
stickers,
|
|
248
|
+
requestImages,
|
|
249
|
+
}: EditorProps) {
|
|
250
|
+
return (
|
|
251
|
+
<EditorProvider
|
|
252
|
+
profile={profile}
|
|
253
|
+
plugins={plugins}
|
|
254
|
+
source={source}
|
|
255
|
+
intrinsic={intrinsic}
|
|
256
|
+
initialDocument={initialDocument}
|
|
257
|
+
>
|
|
258
|
+
<ThemeContext.Provider value={mergeTheme(theme)}>
|
|
259
|
+
<UiStateProvider>
|
|
260
|
+
<HostCallbacksProvider value={{ requestImages, onError }}>
|
|
261
|
+
<InteractionProvider>
|
|
262
|
+
<EditorBody
|
|
263
|
+
slots={mergeSlots(slots)}
|
|
264
|
+
onExport={onExport}
|
|
265
|
+
onCancel={onCancel}
|
|
266
|
+
onError={onError}
|
|
267
|
+
exportSpec={exportSpec}
|
|
268
|
+
stickers={stickers}
|
|
269
|
+
/>
|
|
270
|
+
</InteractionProvider>
|
|
271
|
+
</HostCallbacksProvider>
|
|
272
|
+
</UiStateProvider>
|
|
273
|
+
</ThemeContext.Provider>
|
|
274
|
+
</EditorProvider>
|
|
275
|
+
)
|
|
276
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { useEditor, useTools, type ToolPanelProps } from '@pictor-sdk/core'
|
|
2
|
+
import React, { useEffect, useRef, type ComponentType } from 'react'
|
|
3
|
+
import { Pressable, Text, View } from 'react-native'
|
|
4
|
+
import { useTheme } from './theme'
|
|
5
|
+
import { AdjustPanel } from './panels/AdjustPanel'
|
|
6
|
+
import { AddPhotoPanel } from './panels/AddPhotoPanel'
|
|
7
|
+
import { ColorPanel } from './panels/ColorPanel'
|
|
8
|
+
import { CropPanel } from './panels/CropPanel'
|
|
9
|
+
import { DrawPanel } from './panels/DrawPanel'
|
|
10
|
+
import { FilterStrip } from './panels/FilterStrip'
|
|
11
|
+
import { LayoutPanel } from './panels/LayoutPanel'
|
|
12
|
+
import { LuxPanel } from './panels/LuxPanel'
|
|
13
|
+
import { MediaPanel } from './panels/MediaPanel'
|
|
14
|
+
import { LayersPanel } from './panels/LayersPanel'
|
|
15
|
+
import { PerspectivePanel } from './panels/PerspectivePanel'
|
|
16
|
+
import { StickerPanel } from './panels/StickerPanel'
|
|
17
|
+
import { TiltShiftPanel } from './panels/TiltShiftPanel'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Built-in panels for the built-in tool ids. Pure — unit tested.
|
|
21
|
+
*
|
|
22
|
+
* `text` deliberately has no panel: the text tool is served by the full-screen
|
|
23
|
+
* composer slot, which owns its own controls. Leaving a panel here would open a
|
|
24
|
+
* second history batch alongside the composer's and render a bottom sheet
|
|
25
|
+
* underneath it.
|
|
26
|
+
*/
|
|
27
|
+
export function resolvePanel(
|
|
28
|
+
toolId: string | null,
|
|
29
|
+
pluginPanel: unknown,
|
|
30
|
+
builtins: Record<string, ComponentType> = BUILTIN_PANELS,
|
|
31
|
+
): ComponentType<Partial<ToolPanelProps>> | null {
|
|
32
|
+
if (!toolId) return null
|
|
33
|
+
if (pluginPanel) return pluginPanel as ComponentType<Partial<ToolPanelProps>>
|
|
34
|
+
return (builtins[toolId] as ComponentType<Partial<ToolPanelProps>>) ?? null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const BUILTIN_PANELS: Record<string, ComponentType> = {
|
|
38
|
+
media: MediaPanel,
|
|
39
|
+
addPhoto: AddPhotoPanel,
|
|
40
|
+
filter: FilterStrip,
|
|
41
|
+
adjust: AdjustPanel,
|
|
42
|
+
lux: LuxPanel,
|
|
43
|
+
color: ColorPanel,
|
|
44
|
+
tilt: TiltShiftPanel,
|
|
45
|
+
perspective: PerspectivePanel,
|
|
46
|
+
crop: CropPanel,
|
|
47
|
+
draw: DrawPanel,
|
|
48
|
+
sticker: StickerPanel,
|
|
49
|
+
layout: LayoutPanel,
|
|
50
|
+
layers: LayersPanel,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Renders the active tool's panel — plugin-provided Panel wins over built-ins. */
|
|
54
|
+
export function ToolPanelHost() {
|
|
55
|
+
const { activeTool, document, selection, dispatch, setActiveTool } = useEditor()
|
|
56
|
+
const tools = useTools()
|
|
57
|
+
const tool = tools.find((t) => t.id === activeTool)
|
|
58
|
+
const Panel = resolvePanel(activeTool, tool?.Panel)
|
|
59
|
+
const theme = useTheme()
|
|
60
|
+
const finalized = useRef(false)
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!activeTool || !Panel) return
|
|
64
|
+
finalized.current = false
|
|
65
|
+
dispatch({ type: 'history/beginBatch', label: activeTool })
|
|
66
|
+
return () => {
|
|
67
|
+
if (!finalized.current) dispatch({ type: 'history/cancelBatch' })
|
|
68
|
+
}
|
|
69
|
+
}, [activeTool, Panel, dispatch])
|
|
70
|
+
|
|
71
|
+
if (!Panel) return null
|
|
72
|
+
const done = () => {
|
|
73
|
+
finalized.current = true
|
|
74
|
+
dispatch({ type: 'history/endBatch' })
|
|
75
|
+
setActiveTool(null)
|
|
76
|
+
}
|
|
77
|
+
const cancel = () => {
|
|
78
|
+
finalized.current = true
|
|
79
|
+
dispatch({ type: 'history/cancelBatch' })
|
|
80
|
+
setActiveTool(null)
|
|
81
|
+
}
|
|
82
|
+
return (
|
|
83
|
+
<View style={{ backgroundColor: theme.colors.background, borderTopLeftRadius: theme.radii.lg, borderTopRightRadius: theme.radii.lg, overflow: 'hidden' }}>
|
|
84
|
+
<Panel document={document} selection={selection} dispatch={dispatch} close={done} />
|
|
85
|
+
<View style={{ height: 52, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', borderTopWidth: 1, borderTopColor: theme.colors.track, paddingHorizontal: theme.spacing.lg }}>
|
|
86
|
+
<Pressable accessibilityRole="button" accessibilityLabel="Cancel edits" onPress={cancel}>
|
|
87
|
+
<Text style={{ color: theme.colors.text, fontSize: theme.font.size }}>Cancel</Text>
|
|
88
|
+
</Pressable>
|
|
89
|
+
<Text style={{ color: theme.colors.text, fontSize: theme.font.size, fontWeight: theme.font.weight }}>
|
|
90
|
+
{tool?.label ?? activeTool}
|
|
91
|
+
</Text>
|
|
92
|
+
<Pressable accessibilityRole="button" accessibilityLabel="Apply edits" onPress={done}>
|
|
93
|
+
<Text style={{ color: theme.colors.accent, fontSize: theme.font.size, fontWeight: theme.font.weight }}>Done</Text>
|
|
94
|
+
</Pressable>
|
|
95
|
+
</View>
|
|
96
|
+
</View>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import React, { useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { Text, View, type LayoutChangeEvent } from 'react-native'
|
|
3
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
|
4
|
+
import Animated from 'react-native-reanimated'
|
|
5
|
+
import { runOnJS } from 'react-native-worklets'
|
|
6
|
+
import { useTheme } from '../theme'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A slider's value and range.
|
|
10
|
+
*
|
|
11
|
+
* `centered` is the adjustment shape — `-1..1` resting in the middle with a
|
|
12
|
+
* detent at zero. A quantity with no meaningful negative (Lux, tint strength,
|
|
13
|
+
* blur) uses `min: 0` and `centered: false` instead, which is the control shape
|
|
14
|
+
* measured on the reference editor.
|
|
15
|
+
*/
|
|
16
|
+
export interface SliderProps {
|
|
17
|
+
/** -1..1 when centered, 0..1 otherwise */
|
|
18
|
+
value: number
|
|
19
|
+
min?: number
|
|
20
|
+
max?: number
|
|
21
|
+
/** show a centre detent and snap to 0 within ±0.05 (adjustment sliders) */
|
|
22
|
+
centered?: boolean
|
|
23
|
+
onStart?: () => void
|
|
24
|
+
onChange: (value: number) => void
|
|
25
|
+
onEnd?: () => void
|
|
26
|
+
testID?: string
|
|
27
|
+
/** Floating value label shown above the thumb. */
|
|
28
|
+
valueLabel?: string
|
|
29
|
+
/**
|
|
30
|
+
* Inert and dimmed. For a control whose value has nothing meaningful to
|
|
31
|
+
* write yet — dragging it would record something untrue.
|
|
32
|
+
*/
|
|
33
|
+
disabled?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Snap a centered slider value into its detent. Pure — unit tested. */
|
|
37
|
+
export function applyDetent(value: number, centered: boolean, width = 0.05): number {
|
|
38
|
+
if (centered && Math.abs(value) < width) return 0
|
|
39
|
+
return value
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Dependency-free horizontal slider with an optional centre detent. */
|
|
43
|
+
export function Slider({
|
|
44
|
+
value,
|
|
45
|
+
min = -1,
|
|
46
|
+
max = 1,
|
|
47
|
+
centered = true,
|
|
48
|
+
onStart,
|
|
49
|
+
onChange,
|
|
50
|
+
onEnd,
|
|
51
|
+
testID,
|
|
52
|
+
valueLabel,
|
|
53
|
+
disabled = false,
|
|
54
|
+
}: SliderProps) {
|
|
55
|
+
const theme = useTheme()
|
|
56
|
+
const [width, setWidth] = useState(0)
|
|
57
|
+
const widthRef = useRef(0)
|
|
58
|
+
const startValue = useRef(value)
|
|
59
|
+
const valueRef = useRef(value)
|
|
60
|
+
valueRef.current = value
|
|
61
|
+
|
|
62
|
+
const onLayout = (e: LayoutChangeEvent) => {
|
|
63
|
+
setWidth(e.nativeEvent.layout.width)
|
|
64
|
+
widthRef.current = e.nativeEvent.layout.width
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const handlers = useMemo(() => {
|
|
68
|
+
const begin = () => {
|
|
69
|
+
startValue.current = valueRef.current
|
|
70
|
+
onStart?.()
|
|
71
|
+
}
|
|
72
|
+
const update = (tx: number) => {
|
|
73
|
+
const w = widthRef.current
|
|
74
|
+
if (w === 0) return
|
|
75
|
+
const range = max - min
|
|
76
|
+
const raw = startValue.current + (tx / w) * range
|
|
77
|
+
const clamped = Math.min(max, Math.max(min, raw))
|
|
78
|
+
onChange(applyDetent(clamped, centered && min < 0))
|
|
79
|
+
}
|
|
80
|
+
const finish = () => onEnd?.()
|
|
81
|
+
return { begin, update, finish }
|
|
82
|
+
}, [min, max, centered, onChange, onStart, onEnd])
|
|
83
|
+
|
|
84
|
+
const gesture = useMemo(
|
|
85
|
+
() =>
|
|
86
|
+
Gesture.Pan()
|
|
87
|
+
.enabled(!disabled)
|
|
88
|
+
.onStart(() => {
|
|
89
|
+
'worklet'
|
|
90
|
+
runOnJS(handlers.begin)()
|
|
91
|
+
})
|
|
92
|
+
.onUpdate((e) => {
|
|
93
|
+
'worklet'
|
|
94
|
+
runOnJS(handlers.update)(e.translationX)
|
|
95
|
+
})
|
|
96
|
+
.onFinalize(() => {
|
|
97
|
+
'worklet'
|
|
98
|
+
runOnJS(handlers.finish)()
|
|
99
|
+
}),
|
|
100
|
+
[handlers, disabled],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const t = (value - min) / (max - min)
|
|
104
|
+
const thumbX = width * t
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<GestureDetector gesture={gesture}>
|
|
108
|
+
<Animated.View
|
|
109
|
+
testID={testID}
|
|
110
|
+
onLayout={onLayout}
|
|
111
|
+
style={{
|
|
112
|
+
height: valueLabel ? 58 : 36,
|
|
113
|
+
justifyContent: 'flex-end',
|
|
114
|
+
paddingBottom: 9,
|
|
115
|
+
opacity: disabled ? 0.4 : 1,
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
{valueLabel ? (
|
|
119
|
+
<View
|
|
120
|
+
pointerEvents="none"
|
|
121
|
+
style={{
|
|
122
|
+
position: 'absolute',
|
|
123
|
+
top: 0,
|
|
124
|
+
left: Math.max(0, Math.min(width - 44, thumbX - 22)),
|
|
125
|
+
width: 44,
|
|
126
|
+
alignItems: 'center',
|
|
127
|
+
paddingVertical: 3,
|
|
128
|
+
borderRadius: 10,
|
|
129
|
+
backgroundColor: theme.colors.surface,
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
<Text style={{ color: theme.colors.text, fontSize: theme.font.sizeSm }}>{valueLabel}</Text>
|
|
133
|
+
</View>
|
|
134
|
+
) : null}
|
|
135
|
+
<View style={{ height: 2, borderRadius: 1, backgroundColor: theme.colors.track }} />
|
|
136
|
+
<View
|
|
137
|
+
style={{
|
|
138
|
+
position: 'absolute',
|
|
139
|
+
bottom: 9,
|
|
140
|
+
left: centered && min < 0 ? Math.min(width / 2, thumbX) : 0,
|
|
141
|
+
width: centered && min < 0 ? Math.abs(thumbX - width / 2) : thumbX,
|
|
142
|
+
height: 2,
|
|
143
|
+
borderRadius: 1,
|
|
144
|
+
backgroundColor: theme.colors.accent,
|
|
145
|
+
}}
|
|
146
|
+
/>
|
|
147
|
+
{centered && min < 0 ? (
|
|
148
|
+
<View
|
|
149
|
+
style={{
|
|
150
|
+
position: 'absolute',
|
|
151
|
+
left: width / 2 - 1,
|
|
152
|
+
width: 2,
|
|
153
|
+
height: 10,
|
|
154
|
+
backgroundColor: theme.colors.textMuted,
|
|
155
|
+
}}
|
|
156
|
+
/>
|
|
157
|
+
) : null}
|
|
158
|
+
<View
|
|
159
|
+
style={{
|
|
160
|
+
position: 'absolute',
|
|
161
|
+
bottom: 1,
|
|
162
|
+
left: Math.max(0, Math.min(width - 18, thumbX - 9)),
|
|
163
|
+
width: 18,
|
|
164
|
+
height: 18,
|
|
165
|
+
borderRadius: 9,
|
|
166
|
+
backgroundColor: theme.colors.thumb,
|
|
167
|
+
}}
|
|
168
|
+
/>
|
|
169
|
+
</Animated.View>
|
|
170
|
+
</GestureDetector>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import React, { useMemo, useRef } from 'react'
|
|
2
|
+
import { Text, View } from 'react-native'
|
|
3
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
|
4
|
+
import Animated from 'react-native-reanimated'
|
|
5
|
+
import { runOnJS } from 'react-native-worklets'
|
|
6
|
+
import { useTheme } from '../theme'
|
|
7
|
+
|
|
8
|
+
/** The straighten dial: a value in -1..1 and the callbacks bracketing a drag. */
|
|
9
|
+
export interface StraightenRulerProps {
|
|
10
|
+
value: number
|
|
11
|
+
min?: number
|
|
12
|
+
max?: number
|
|
13
|
+
onStart?: () => void
|
|
14
|
+
onChange(value: number): void
|
|
15
|
+
onEnd?: () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const PX_PER_DEGREE = 60
|
|
19
|
+
|
|
20
|
+
/** Fine, zero-snapping ruler for angle correction. Values are degrees. */
|
|
21
|
+
export function StraightenRuler({ value, min = -45, max = 45, onStart, onChange, onEnd }: StraightenRulerProps) {
|
|
22
|
+
const theme = useTheme()
|
|
23
|
+
const start = useRef(value)
|
|
24
|
+
const current = useRef(value)
|
|
25
|
+
current.current = value
|
|
26
|
+
const handlers = useMemo(() => ({
|
|
27
|
+
begin: () => { start.current = current.current; onStart?.() },
|
|
28
|
+
update: (translationX: number) => {
|
|
29
|
+
const raw = Math.min(max, Math.max(min, start.current - translationX / PX_PER_DEGREE))
|
|
30
|
+
onChange(Math.abs(raw) < 0.15 ? 0 : raw)
|
|
31
|
+
},
|
|
32
|
+
finish: () => onEnd?.(),
|
|
33
|
+
}), [min, max, onStart, onChange, onEnd])
|
|
34
|
+
const gesture = useMemo(() => Gesture.Pan()
|
|
35
|
+
.onStart(() => { 'worklet'; runOnJS(handlers.begin)() })
|
|
36
|
+
.onUpdate((event) => { 'worklet'; runOnJS(handlers.update)(event.translationX) })
|
|
37
|
+
.onFinalize(() => { 'worklet'; runOnJS(handlers.finish)() }), [handlers])
|
|
38
|
+
|
|
39
|
+
const rounded = Math.round(value * 10) / 10
|
|
40
|
+
return (
|
|
41
|
+
<GestureDetector gesture={gesture}>
|
|
42
|
+
<Animated.View style={{ height: 76, overflow: 'hidden', justifyContent: 'flex-end' }}>
|
|
43
|
+
<View style={{ position: 'absolute', top: 0, alignSelf: 'center', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 12, backgroundColor: theme.colors.surface }}>
|
|
44
|
+
<Text style={{ color: theme.colors.text, fontSize: theme.font.sizeSm }}>{rounded.toFixed(1)}°</Text>
|
|
45
|
+
</View>
|
|
46
|
+
<View style={{ height: 34, flexDirection: 'row', justifyContent: 'center', alignItems: 'flex-end', gap: 8 }}>
|
|
47
|
+
{Array.from({ length: 31 }, (_, index) => {
|
|
48
|
+
const offset = index - 15
|
|
49
|
+
const major = offset % 5 === 0
|
|
50
|
+
return <View key={offset} style={{ width: 1, height: major ? 22 : 11, backgroundColor: offset === 0 ? theme.colors.accent : theme.colors.textMuted }} />
|
|
51
|
+
})}
|
|
52
|
+
</View>
|
|
53
|
+
<View style={{ position: 'absolute', bottom: 0, left: '50%', width: 2, height: 30, backgroundColor: theme.colors.accent }} />
|
|
54
|
+
</Animated.View>
|
|
55
|
+
</GestureDetector>
|
|
56
|
+
)
|
|
57
|
+
}
|