@pictor-sdk/ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +29 -0
  3. package/dist/index.d.mts +833 -0
  4. package/dist/index.d.ts +833 -0
  5. package/dist/index.js +4620 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/index.mjs +4643 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/package.json +68 -0
  10. package/src/Editor.tsx +276 -0
  11. package/src/ToolPanelHost.tsx +98 -0
  12. package/src/components/Slider.tsx +172 -0
  13. package/src/components/StraightenRuler.tsx +57 -0
  14. package/src/components/ToolRail.tsx +58 -0
  15. package/src/components/TopBar.tsx +53 -0
  16. package/src/composer/TextComposer.tsx +540 -0
  17. package/src/composer/keyboardLayout.ts +130 -0
  18. package/src/composer/useTextSession.ts +230 -0
  19. package/src/gestures/CropGestureSurface.tsx +270 -0
  20. package/src/gestures/CropGrid.tsx +24 -0
  21. package/src/gestures/DrawingSurface.tsx +77 -0
  22. package/src/gestures/EditorGestures.tsx +97 -0
  23. package/src/gestures/InteractionGuides.tsx +161 -0
  24. package/src/gestures/LayerGestureSurface.tsx +462 -0
  25. package/src/gestures/StoryMediaGestureSurface.tsx +517 -0
  26. package/src/gestures/TiltShiftGestureSurface.tsx +189 -0
  27. package/src/gestures/controller.ts +152 -0
  28. package/src/gestures/drawController.ts +104 -0
  29. package/src/gestures/interactionState.tsx +81 -0
  30. package/src/gestures/layerGestureWorklet.ts +198 -0
  31. package/src/gestures/mediaGestureWorklet.ts +159 -0
  32. package/src/gestures/routing.ts +42 -0
  33. package/src/gestures/settleCoordinator.ts +61 -0
  34. package/src/gestures/transformOwnership.ts +91 -0
  35. package/src/hostCallbacks.tsx +62 -0
  36. package/src/index.ts +72 -0
  37. package/src/panels/AddPhotoPanel.tsx +101 -0
  38. package/src/panels/AdjustPanel.tsx +158 -0
  39. package/src/panels/ColorPanel.tsx +193 -0
  40. package/src/panels/CropPanel.tsx +185 -0
  41. package/src/panels/DrawPanel.tsx +106 -0
  42. package/src/panels/FilterStrip.tsx +153 -0
  43. package/src/panels/LayersPanel.tsx +85 -0
  44. package/src/panels/LayoutPanel.tsx +55 -0
  45. package/src/panels/LuxPanel.tsx +122 -0
  46. package/src/panels/MediaPanel.tsx +73 -0
  47. package/src/panels/PerspectivePanel.tsx +157 -0
  48. package/src/panels/StickerPanel.tsx +78 -0
  49. package/src/panels/TextPanel.tsx +177 -0
  50. package/src/panels/TiltShiftPanel.tsx +146 -0
  51. package/src/panels/addPhotoController.ts +196 -0
  52. package/src/panels/cropMath.ts +37 -0
  53. package/src/panels/layoutMath.ts +58 -0
  54. package/src/panels/useTargetImageLayer.ts +41 -0
  55. package/src/theme.ts +61 -0
  56. package/src/uiState.tsx +57 -0
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
+ }