@pictor-sdk/ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +29 -0
  3. package/dist/index.d.mts +833 -0
  4. package/dist/index.d.ts +833 -0
  5. package/dist/index.js +4620 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/index.mjs +4643 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/package.json +68 -0
  10. package/src/Editor.tsx +276 -0
  11. package/src/ToolPanelHost.tsx +98 -0
  12. package/src/components/Slider.tsx +172 -0
  13. package/src/components/StraightenRuler.tsx +57 -0
  14. package/src/components/ToolRail.tsx +58 -0
  15. package/src/components/TopBar.tsx +53 -0
  16. package/src/composer/TextComposer.tsx +540 -0
  17. package/src/composer/keyboardLayout.ts +130 -0
  18. package/src/composer/useTextSession.ts +230 -0
  19. package/src/gestures/CropGestureSurface.tsx +270 -0
  20. package/src/gestures/CropGrid.tsx +24 -0
  21. package/src/gestures/DrawingSurface.tsx +77 -0
  22. package/src/gestures/EditorGestures.tsx +97 -0
  23. package/src/gestures/InteractionGuides.tsx +161 -0
  24. package/src/gestures/LayerGestureSurface.tsx +462 -0
  25. package/src/gestures/StoryMediaGestureSurface.tsx +517 -0
  26. package/src/gestures/TiltShiftGestureSurface.tsx +189 -0
  27. package/src/gestures/controller.ts +152 -0
  28. package/src/gestures/drawController.ts +104 -0
  29. package/src/gestures/interactionState.tsx +81 -0
  30. package/src/gestures/layerGestureWorklet.ts +198 -0
  31. package/src/gestures/mediaGestureWorklet.ts +159 -0
  32. package/src/gestures/routing.ts +42 -0
  33. package/src/gestures/settleCoordinator.ts +61 -0
  34. package/src/gestures/transformOwnership.ts +91 -0
  35. package/src/hostCallbacks.tsx +62 -0
  36. package/src/index.ts +72 -0
  37. package/src/panels/AddPhotoPanel.tsx +101 -0
  38. package/src/panels/AdjustPanel.tsx +158 -0
  39. package/src/panels/ColorPanel.tsx +193 -0
  40. package/src/panels/CropPanel.tsx +185 -0
  41. package/src/panels/DrawPanel.tsx +106 -0
  42. package/src/panels/FilterStrip.tsx +153 -0
  43. package/src/panels/LayersPanel.tsx +85 -0
  44. package/src/panels/LayoutPanel.tsx +55 -0
  45. package/src/panels/LuxPanel.tsx +122 -0
  46. package/src/panels/MediaPanel.tsx +73 -0
  47. package/src/panels/PerspectivePanel.tsx +157 -0
  48. package/src/panels/StickerPanel.tsx +78 -0
  49. package/src/panels/TextPanel.tsx +177 -0
  50. package/src/panels/TiltShiftPanel.tsx +146 -0
  51. package/src/panels/addPhotoController.ts +196 -0
  52. package/src/panels/cropMath.ts +37 -0
  53. package/src/panels/layoutMath.ts +58 -0
  54. package/src/panels/useTargetImageLayer.ts +41 -0
  55. package/src/theme.ts +61 -0
  56. package/src/uiState.tsx +57 -0
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Which gesture surface owns the canvas.
3
+ *
4
+ * Precedence is decided by the active tool and nothing else, so it is a pure
5
+ * function that can be tested without mounting anything — and so the rule is
6
+ * stated in one place rather than implied by JSX nesting.
7
+ */
8
+ export type GestureSurfaceKind = 'media' | 'draw' | 'crop' | 'tilt' | 'text' | 'overlay'
9
+
10
+ /**
11
+ * Resolve the surface for the active tool.
12
+ *
13
+ * - `text` — the full-screen composer owns the screen, so **no** canvas surface
14
+ * is mounted at all. Not mounting is the strongest form of "disabled": there
15
+ * is no recogniser to arbitrate with, so a tap meant for the caret cannot
16
+ * start a canvas gesture, and a drag begins immediately once composing ends.
17
+ * - `media` — the base photo tool owns the surface, so the photo moves and
18
+ * overlays are inert.
19
+ * - `draw` — drawing owns the surface, so strokes are never interpreted as
20
+ * layer drags.
21
+ * - `crop` — the Post crop surface.
22
+ * - `tilt` — placing the selective-focus region, which needs the whole canvas;
23
+ * overlay hit-testing would steal the drag to whatever sits under the finger.
24
+ * - anything else (including no active tool) — overlay hit-testing, which
25
+ * excludes the base photo, so dragging text or a sticker cannot move it.
26
+ */
27
+ export function resolveGestureSurface(activeTool: string | null | undefined): GestureSurfaceKind {
28
+ switch (activeTool) {
29
+ case 'text':
30
+ return 'text'
31
+ case 'media':
32
+ return 'media'
33
+ case 'draw':
34
+ return 'draw'
35
+ case 'crop':
36
+ return 'crop'
37
+ case 'tilt':
38
+ return 'tilt'
39
+ default:
40
+ return 'overlay'
41
+ }
42
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Coordinates the settle animation and the single document write that follows
3
+ * it.
4
+ *
5
+ * Committing *while* the animation runs is the trap: the commit changes the
6
+ * document, which re-seeds the live shared values from the committed
7
+ * transform, which cancels the animation that was still running. So the commit
8
+ * has to happen when the animation reports completion, not when it is started.
9
+ *
10
+ * Kept pure — the animator and the commit are injected — so the ordering rules
11
+ * below are unit-testable without Reanimated:
12
+ *
13
+ * - a settle commits exactly once, when its animation finishes;
14
+ * - an animation that is interrupted commits nothing;
15
+ * - a newer settle supersedes an older one, so a late completion from the
16
+ * superseded animation cannot commit stale values;
17
+ * - `cancel()` (tool dismissed, unmount) prevents any pending commit.
18
+ */
19
+ export interface SettleAnimation {
20
+ /** Run the animation; call `done(true)` on completion, `done(false)` if interrupted. */
21
+ (done: (finished: boolean) => void): void
22
+ }
23
+
24
+ export interface SettleCoordinator<T> {
25
+ /** Animate toward `target`, committing it when the animation completes. */
26
+ settle(target: T, animate: SettleAnimation): void
27
+ /** Abandon any settle in flight without committing. */
28
+ cancel(): void
29
+ /** True while an animation is running and its commit is still pending. */
30
+ isSettling(): boolean
31
+ }
32
+
33
+ export function createSettleCoordinator<T>(commit: (target: T) => void): SettleCoordinator<T> {
34
+ // Monotonic token: only the newest settle may commit. Cancelling bumps it too,
35
+ // so a completion callback that arrives afterwards is ignored.
36
+ let generation = 0
37
+ let settling = false
38
+
39
+ return {
40
+ settle(target, animate) {
41
+ generation += 1
42
+ const mine = generation
43
+ settling = true
44
+ animate((finished) => {
45
+ if (mine !== generation) return // superseded, cancelled, or already resolved
46
+ generation += 1 // one completion per settle, whatever the animator does
47
+ settling = false
48
+ if (finished) commit(target)
49
+ })
50
+ },
51
+
52
+ cancel() {
53
+ generation += 1
54
+ settling = false
55
+ },
56
+
57
+ isSettling() {
58
+ return settling
59
+ },
60
+ }
61
+ }
@@ -0,0 +1,91 @@
1
+ import { isSameTransform, type Transform } from '@pictor-sdk/core'
2
+
3
+ /**
4
+ * Who owns a layer's live transform: the gesture surface, or the document.
5
+ *
6
+ * The surface drives `live*` shared values while a gesture or its settle runs,
7
+ * and the canvas draws them as a delta from the transform the document holds.
8
+ * The handover back to the document is the delicate part, and getting it wrong
9
+ * is visible as a single-frame flash:
10
+ *
11
+ * - release ownership when `dispatch` is *called* and the resync can fire while
12
+ * the document still holds the old transform, snapping `live` back to it for
13
+ * one frame;
14
+ * - never release it and an undo or a Cancel cannot reseed `live` at all.
15
+ *
16
+ * So ownership ends when the document actually reports the transform that was
17
+ * committed — not before. Until then `live` is already correct and must be left
18
+ * alone. Pure and synchronous, so the ordering is unit-testable.
19
+ */
20
+ export interface OwnershipState {
21
+ /** True while the surface, not the document, decides the transform. */
22
+ owning: boolean
23
+ /** A transform dispatched but not yet observed on the document. */
24
+ pending: Transform | null
25
+ /**
26
+ * What the document held when `pending` was dispatched.
27
+ *
28
+ * Needed to tell "our commit has not landed yet" from "something else
29
+ * changed the document": both merely differ from `pending`, and only the
30
+ * second should reseed.
31
+ */
32
+ previous: Transform | null
33
+ }
34
+
35
+ export const IDLE_OWNERSHIP: OwnershipState = { owning: false, pending: null, previous: null }
36
+
37
+ /** A gesture or animation takes the transform. */
38
+ export function beginOwnership(): OwnershipState {
39
+ return { owning: true, pending: null, previous: null }
40
+ }
41
+
42
+ /**
43
+ * The surface committed `target`.
44
+ *
45
+ * `dispatched` is false when the commit was a no-op — the transform never
46
+ * changed, so no document update is coming and ownership ends immediately.
47
+ */
48
+ export function commitOwnership(
49
+ target: Transform,
50
+ dispatched: boolean,
51
+ previous?: Transform,
52
+ ): OwnershipState {
53
+ return dispatched
54
+ ? { owning: true, pending: target, previous: previous ?? null }
55
+ : IDLE_OWNERSHIP
56
+ }
57
+
58
+ export interface DocumentChange {
59
+ state: OwnershipState
60
+ /** Whether `live*` should be reseeded from the document. */
61
+ reseed: boolean
62
+ }
63
+
64
+ /**
65
+ * The document's transform for this layer changed, or the effect re-ran.
66
+ *
67
+ * Reseeding is for changes the surface did not make — undo, Cancel, a new
68
+ * photo. A change the surface itself dispatched needs no reseed: `live` is
69
+ * already sitting on exactly that value, and rewriting it is what introduces
70
+ * the flash.
71
+ */
72
+ export function documentChanged(
73
+ state: OwnershipState,
74
+ committed: Transform | undefined,
75
+ ): DocumentChange {
76
+ if (!committed) return { state, reseed: false }
77
+ if (state.pending) {
78
+ // our commit landed: `live` is already on it, so hand back without touching
79
+ if (isSameTransform(state.pending, committed)) return { state: IDLE_OWNERSHIP, reseed: false }
80
+ // the document still holds what it did when we dispatched — the update has
81
+ // simply not rendered yet. Reseeding here is the one-frame flash.
82
+ if (state.previous && isSameTransform(state.previous, committed)) {
83
+ return { state, reseed: false }
84
+ }
85
+ // anything else means something outside changed the document first; that
86
+ // wins, and our pending commit is stale
87
+ return { state: IDLE_OWNERSHIP, reseed: true }
88
+ }
89
+ if (state.owning) return { state, reseed: false }
90
+ return { state, reseed: true }
91
+ }
@@ -0,0 +1,62 @@
1
+ import { type InsertableImage } from '@pictor-sdk/core'
2
+ import React, { createContext, useContext, useRef } from 'react'
3
+
4
+ /**
5
+ * Callbacks the host supplies to `<Editor>` that panels deep in the tree need.
6
+ *
7
+ * Held in a **ref** rather than state. A callback prop is usually an inline
8
+ * arrow recreated every render, so storing it in state would notify on every
9
+ * render and re-render forever; consumers read `.current` at call time instead
10
+ * and never re-render because the host re-created its closure.
11
+ *
12
+ * @experimental
13
+ */
14
+ export interface HostCallbacks {
15
+ /**
16
+ * Asked to supply photos when the user presses the add-photo control.
17
+ *
18
+ * The library never touches the gallery itself: permissions, the picker and
19
+ * any HEIC/HEIF transcoding are the host's, which is what keeps the library
20
+ * free of Expo and of native code. Resolve `null` or `[]` for cancellation.
21
+ */
22
+ requestImages?: () => Promise<readonly InsertableImage[] | null>
23
+ /** Where a failed request is reported. */
24
+ onError?: (error: unknown) => void
25
+ }
26
+
27
+ const Context = createContext<{ current: HostCallbacks } | null>(null)
28
+
29
+ /**
30
+ * Makes the host's callbacks reachable from panels deep in the tree.
31
+ *
32
+ * Mounted by `<Editor>`; a host composing its own editor from slots mounts it
33
+ * itself, or omits it and the controls render unavailable.
34
+ *
35
+ * @experimental
36
+ */
37
+ export function HostCallbacksProvider({
38
+ value,
39
+ children,
40
+ }: {
41
+ value: HostCallbacks
42
+ children?: React.ReactNode
43
+ }) {
44
+ const ref = useRef<HostCallbacks>(value)
45
+ // Updated during render on purpose: a panel that calls one of these in the
46
+ // same commit must see the latest, and an effect would be a frame late.
47
+ ref.current = value
48
+ return <Context.Provider value={ref}>{children}</Context.Provider>
49
+ }
50
+
51
+ /**
52
+ * The host's callbacks, as a stable ref.
53
+ *
54
+ * Returns an empty set outside an `<Editor>` rather than throwing — a panel
55
+ * rendered standalone should degrade to "unavailable", not crash.
56
+ *
57
+ * @experimental
58
+ */
59
+ export function useHostCallbacks(): { current: HostCallbacks } {
60
+ const fallback = useRef<HostCallbacks>({})
61
+ return useContext(Context) ?? fallback
62
+ }
package/src/index.ts ADDED
@@ -0,0 +1,72 @@
1
+ export { Editor, CanvasStage, mergeSlots, type CanvasStageProps, type EditorProps, type EditorSlots } from './Editor'
2
+ export { TopBar, type TopBarProps } from './components/TopBar'
3
+ export { ToolRail } from './components/ToolRail'
4
+ export { ToolPanelHost, resolvePanel } from './ToolPanelHost'
5
+ export { FilterStrip, useBaseImageLayer } from './panels/FilterStrip'
6
+ export { useTargetImageLayer } from './panels/useTargetImageLayer'
7
+ export { AddPhotoPanel } from './panels/AddPhotoPanel'
8
+ export {
9
+ createAddPhotoController,
10
+ InvalidImageResultError,
11
+ type AddPhotoController,
12
+ type AddPhotoControllerOptions,
13
+ } from './panels/addPhotoController'
14
+ export { HostCallbacksProvider, useHostCallbacks, type HostCallbacks } from './hostCallbacks'
15
+ export { AdjustPanel } from './panels/AdjustPanel'
16
+ export { CropPanel } from './panels/CropPanel'
17
+ export {
18
+ canvasSizeForAspect,
19
+ centeredCropForAspect,
20
+ radiansToStraighten,
21
+ straightenToRadians,
22
+ STRAIGHTEN_LIMIT,
23
+ } from './panels/cropMath'
24
+ export { Slider, type SliderProps } from './components/Slider'
25
+ export { StraightenRuler, type StraightenRulerProps } from './components/StraightenRuler'
26
+ export { defaultTheme, mergeTheme, useTheme, ThemeContext, type EditorTheme, type PartialTheme } from './theme'
27
+ export { EditorGestures, type EditorGesturesProps } from './gestures/EditorGestures'
28
+ export { LayerGestureSurface, type LayerGestureSurfaceProps } from './gestures/LayerGestureSurface'
29
+ export { InteractionGuides, type InteractionGuidesProps } from './gestures/InteractionGuides'
30
+ export {
31
+ StoryMediaGestureSurface,
32
+ useBaseMediaLayer,
33
+ type StoryMediaGestureSurfaceProps,
34
+ } from './gestures/StoryMediaGestureSurface'
35
+ export { resolveGestureSurface, type GestureSurfaceKind } from './gestures/routing'
36
+ export { MediaPanel } from './panels/MediaPanel'
37
+ // The worklet twins, the slider's detent maths, the built-in panel registry and
38
+ // the composer's keyboard arithmetic stay internal. Unlike the panels and
39
+ // surfaces above — which exist to be picked apart, so their broad surface is
40
+ // deliberate — these describe how one component is wired, and a consumer
41
+ // replacing that component replaces the wiring too. Their tests import by path.
42
+ export { CropGestureSurface, type CropGestureSurfaceProps } from './gestures/CropGestureSurface'
43
+ export { CropGrid } from './gestures/CropGrid'
44
+ export { InteractionProvider, useInteractionState } from './gestures/interactionState'
45
+ export {
46
+ createGestureController,
47
+ type GestureController,
48
+ type GestureControllerOptions,
49
+ type GestureMetrics,
50
+ type GestureSnapEvent,
51
+ } from './gestures/controller'
52
+ // Convenience re-exports so consumers of the default UI need one import
53
+ export { POST_PROFILE, STORY_PROFILE } from '@pictor-sdk/core'
54
+ export {
55
+ TextComposer,
56
+ TextComposerHost,
57
+ type TextComposerProps,
58
+ } from './composer/TextComposer'
59
+ export {
60
+ useTextSession,
61
+ type TextSessionController,
62
+ type UseTextSessionOptions,
63
+ } from './composer/useTextSession'
64
+ export { TextPanel } from './panels/TextPanel'
65
+ export { DrawPanel } from './panels/DrawPanel'
66
+ export { StickerPanel, type StickerAsset } from './panels/StickerPanel'
67
+ export { LayersPanel } from './panels/LayersPanel'
68
+ export { LayoutPanel } from './panels/LayoutPanel'
69
+ export { layoutCells, cellToTransformPatch, type LayoutCell, type LayoutPreset } from './panels/layoutMath'
70
+ export { DrawingSurface, DEFAULT_BRUSH } from './gestures/DrawingSurface'
71
+ export { createDrawController, type BrushConfig, type DrawController } from './gestures/drawController'
72
+ export { UiStateProvider, useUiState, createUiStateStore, type UiStateStore } from './uiState'
@@ -0,0 +1,101 @@
1
+ import { useEditor } from '@pictor-sdk/core'
2
+ import React, { useEffect, useMemo, useState } from 'react'
3
+ import { Pressable, Text, View } from 'react-native'
4
+ import { useHostCallbacks } from '../hostCallbacks'
5
+ import { useTheme } from '../theme'
6
+ import { createAddPhotoController } from './addPhotoController'
7
+
8
+ /**
9
+ * The add-photo control: one press, one picker, one undo entry.
10
+ *
11
+ * Nothing happens on mount. Opening a picker because a panel appeared would
12
+ * take the choice away from the user and make a stray tool tap launch the
13
+ * gallery, so acquisition waits for an explicit press.
14
+ *
15
+ * All the asynchronous behaviour lives in `createAddPhotoController`, which is
16
+ * testable without mounting anything. This is the shell.
17
+ *
18
+ * @experimental
19
+ */
20
+ export function AddPhotoPanel() {
21
+ const { dispatch, select } = useEditor()
22
+ const host = useHostCallbacks()
23
+ const theme = useTheme()
24
+ const [pending, setPending] = useState(false)
25
+
26
+ const controller = useMemo(
27
+ () =>
28
+ createAddPhotoController({
29
+ // Read through the ref at call time, so a host re-creating its closure
30
+ // does not rebuild the controller mid-request.
31
+ requestImages: host.current.requestImages
32
+ ? () => host.current.requestImages!()
33
+ : undefined,
34
+ dispatch,
35
+ select,
36
+ onError: (error) => host.current.onError?.(error),
37
+ }),
38
+ [dispatch, select, host],
39
+ )
40
+
41
+ // Disposal is what makes a late result harmless: the picker can resolve long
42
+ // after the user has closed the tool.
43
+ useEffect(() => () => controller.dispose(), [controller])
44
+
45
+ // Session-scoped, like the panel's image target: whether a picker exists is
46
+ // decided at mount. A host that wires `requestImages` while this is open sees
47
+ // it on the next open.
48
+ const available = controller.isAvailable()
49
+
50
+ const press = async () => {
51
+ if (!available || pending) return
52
+ setPending(true)
53
+ try {
54
+ await controller.request()
55
+ } finally {
56
+ setPending(false)
57
+ }
58
+ }
59
+
60
+ return (
61
+ <View style={{ backgroundColor: theme.colors.background, padding: theme.spacing.lg }}>
62
+ <Pressable
63
+ accessibilityRole="button"
64
+ accessibilityLabel="Add photos"
65
+ accessibilityState={{ disabled: !available || pending }}
66
+ disabled={!available || pending}
67
+ onPress={press}
68
+ style={{
69
+ paddingVertical: theme.spacing.md,
70
+ borderRadius: theme.radii.sm,
71
+ borderWidth: 1,
72
+ borderColor: available ? theme.colors.accent : theme.colors.track,
73
+ alignItems: 'center',
74
+ opacity: available && !pending ? 1 : 0.45,
75
+ }}
76
+ >
77
+ <Text
78
+ style={{
79
+ color: available ? theme.colors.accent : theme.colors.textMuted,
80
+ fontSize: theme.font.size,
81
+ fontWeight: theme.font.weight,
82
+ }}
83
+ >
84
+ {pending ? 'Choosing…' : 'Add photos'}
85
+ </Text>
86
+ </Pressable>
87
+ <Text
88
+ style={{
89
+ color: theme.colors.textMuted,
90
+ fontSize: theme.font.sizeSm,
91
+ textAlign: 'center',
92
+ marginTop: theme.spacing.sm,
93
+ }}
94
+ >
95
+ {available
96
+ ? 'Pick one or more photos to place on the story'
97
+ : `Unavailable — pass \`requestImages\` to <Editor>`}
98
+ </Text>
99
+ </View>
100
+ )
101
+ }
@@ -0,0 +1,158 @@
1
+ import {
2
+ adjustmentResetPatch,
3
+ hasAdjustment,
4
+ useEditor,
5
+ useEditorStore,
6
+ type Adjustments,
7
+ type ImageLayerData,
8
+ } from '@pictor-sdk/core'
9
+ import React, { useState } from 'react'
10
+ import { Pressable, ScrollView, Text, View } from 'react-native'
11
+ import { useTheme } from '../theme'
12
+ import { Slider } from '../components/Slider'
13
+ import { useTargetImageLayer } from './useTargetImageLayer'
14
+
15
+ const LABELS: Record<string, string> = {
16
+ brightness: 'Brightness',
17
+ contrast: 'Contrast',
18
+ saturation: 'Saturation',
19
+ warmth: 'Warmth',
20
+ tint: 'Tint',
21
+ exposure: 'Exposure',
22
+ highlights: 'Highlights',
23
+ shadows: 'Shadows',
24
+ fade: 'Fade',
25
+ structure: 'Structure',
26
+ sharpen: 'Sharpen',
27
+ grain: 'Grain',
28
+ vignette: 'Vignette',
29
+ }
30
+
31
+ /**
32
+ * The Adjust tab: the 13 tools from profile/registry order, one centred
33
+ * slider with a detent at 0 (a 0 adjustment is a provable render no-op).
34
+ */
35
+ export function AdjustPanel() {
36
+ const { dispatch } = useEditor()
37
+ const store = useEditorStore()
38
+ const theme = useTheme()
39
+ const layer = useTargetImageLayer()
40
+ const keys = store.registry.adjustments
41
+ const [activeKey, setActiveKey] = useState(keys[0] ?? 'brightness')
42
+
43
+ if (!layer) return null
44
+ const adjustments: Adjustments = (layer.data as ImageLayerData).color.adjustments ?? {}
45
+ const value = adjustments[activeKey] ?? 0
46
+ const touchedActive = hasAdjustment(adjustments, activeKey)
47
+
48
+ /**
49
+ * Clear the active adjustment, and only it.
50
+ *
51
+ * No batch of its own: the adjust tool session already opened one, so this
52
+ * belongs to that session's single undo entry — Cancel rolls it back with
53
+ * everything else, and Done commits it as part of the whole.
54
+ */
55
+ const resetActive = () =>
56
+ dispatch({
57
+ type: 'layer/patchData',
58
+ id: layer.id,
59
+ patch: adjustmentResetPatch(activeKey) as Record<string, unknown>,
60
+ })
61
+
62
+ return (
63
+ <View style={{ backgroundColor: theme.colors.background }}>
64
+ <View style={{ paddingHorizontal: theme.spacing.lg }}>
65
+ {/* Value readout, with an inline reset once the adjustment is set —
66
+ the affordance Instagram uses, and the one the milestone asks for:
67
+ it clears this adjustment only. */}
68
+ <View
69
+ style={{
70
+ flexDirection: 'row',
71
+ alignItems: 'center',
72
+ justifyContent: 'center',
73
+ gap: theme.spacing.sm,
74
+ minHeight: 28,
75
+ }}
76
+ >
77
+ <Text
78
+ style={{
79
+ color: theme.colors.textMuted,
80
+ textAlign: 'center',
81
+ fontSize: theme.font.sizeSm,
82
+ }}
83
+ >
84
+ {LABELS[activeKey] ?? activeKey}{' '}
85
+ {value === 0 ? '' : (value > 0 ? '+' : '') + Math.round(value * 100)}
86
+ </Text>
87
+ {touchedActive ? (
88
+ <Pressable
89
+ accessibilityRole="button"
90
+ accessibilityLabel={`Reset ${LABELS[activeKey] ?? activeKey}`}
91
+ onPress={resetActive}
92
+ hitSlop={8}
93
+ style={{
94
+ paddingHorizontal: theme.spacing.sm,
95
+ paddingVertical: 2,
96
+ borderRadius: theme.radii.sm,
97
+ borderWidth: 1,
98
+ borderColor: theme.colors.track,
99
+ }}
100
+ >
101
+ <Text style={{ color: theme.colors.text, fontSize: theme.font.sizeSm }}>Reset</Text>
102
+ </Pressable>
103
+ ) : null}
104
+ </View>
105
+ <Slider
106
+ value={value}
107
+ min={-1}
108
+ max={1}
109
+ centered
110
+ valueLabel={`${value > 0 ? '+' : ''}${Math.round(value * 100)}`}
111
+ onStart={() => dispatch({ type: 'history/beginBatch', label: activeKey })}
112
+ onChange={(v) =>
113
+ dispatch({
114
+ type: 'layer/patchData',
115
+ id: layer.id,
116
+ patch: { color: { adjustments: { [activeKey]: v } } },
117
+ })
118
+ }
119
+ onEnd={() => dispatch({ type: 'history/endBatch' })}
120
+ />
121
+ </View>
122
+ <ScrollView
123
+ horizontal
124
+ showsHorizontalScrollIndicator={false}
125
+ contentContainerStyle={{ gap: theme.spacing.sm, padding: theme.spacing.md }}
126
+ style={{ flexGrow: 0 }}
127
+ >
128
+ {keys.map((key) => {
129
+ const touched = (adjustments[key] ?? 0) !== 0
130
+ const active = key === activeKey
131
+ return (
132
+ <Pressable
133
+ key={key}
134
+ onPress={() => setActiveKey(key)}
135
+ style={{ alignItems: 'center', gap: 6, width: 76 }}
136
+ >
137
+ <View style={{ width: 48, height: 48, borderRadius: 24, borderWidth: 1, borderColor: active ? theme.colors.accent : theme.colors.textMuted, backgroundColor: active ? theme.colors.surface : 'transparent', alignItems: 'center', justifyContent: 'center' }}>
138
+ <Text style={{ color: active ? theme.colors.accent : theme.colors.text, fontSize: 18 }}>
139
+ {(LABELS[key] ?? key).slice(0, 1)}
140
+ </Text>
141
+ </View>
142
+ <Text
143
+ style={{
144
+ color: active ? theme.colors.accent : touched ? theme.colors.text : theme.colors.textMuted,
145
+ fontSize: theme.font.sizeSm,
146
+ fontWeight: theme.font.weight,
147
+ }}
148
+ >
149
+ {LABELS[key] ?? key}
150
+ {touched ? ' •' : ''}
151
+ </Text>
152
+ </Pressable>
153
+ )
154
+ })}
155
+ </ScrollView>
156
+ </View>
157
+ )
158
+ }