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