@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,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isDirectManipulationLayer,
|
|
3
|
+
useEditor,
|
|
4
|
+
type EditorDocument,
|
|
5
|
+
type ProfileLayerInteraction,
|
|
6
|
+
} from '@pictor-sdk/core'
|
|
7
|
+
import React from 'react'
|
|
8
|
+
import { Pressable, ScrollView, Text, View } from 'react-native'
|
|
9
|
+
import { useTheme } from '../theme'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Z-order UI: top-most first, tap to select, reorder and delete.
|
|
13
|
+
* (Drag-to-delete on canvas maps to the same layer/remove command.)
|
|
14
|
+
*/
|
|
15
|
+
export function LayersPanel() {
|
|
16
|
+
const { document, selection, select, dispatch, profile } = useEditor()
|
|
17
|
+
const theme = useTheme()
|
|
18
|
+
const layers = layersForPanel(document, profile.interaction?.layers)
|
|
19
|
+
const minimumIndex = minimumLayerIndexForPanel(document, profile.interaction?.layers)
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<ScrollView style={{ maxHeight: 220, backgroundColor: theme.colors.background }}>
|
|
23
|
+
{layers.map(({ layer, index }) => {
|
|
24
|
+
const selected = selection.includes(layer.id)
|
|
25
|
+
return (
|
|
26
|
+
<View
|
|
27
|
+
key={layer.id}
|
|
28
|
+
style={{
|
|
29
|
+
flexDirection: 'row',
|
|
30
|
+
alignItems: 'center',
|
|
31
|
+
paddingHorizontal: theme.spacing.md,
|
|
32
|
+
paddingVertical: theme.spacing.xs,
|
|
33
|
+
backgroundColor: selected ? theme.colors.surface : 'transparent',
|
|
34
|
+
}}
|
|
35
|
+
>
|
|
36
|
+
<Pressable style={{ flex: 1 }} onPress={() => select([layer.id])}>
|
|
37
|
+
<Text style={{ color: selected ? theme.colors.accent : theme.colors.text, fontSize: theme.font.sizeSm }}>
|
|
38
|
+
{layer.name ?? `${layer.type} · ${layer.id.slice(0, 6)}`}
|
|
39
|
+
</Text>
|
|
40
|
+
</Pressable>
|
|
41
|
+
<Pressable
|
|
42
|
+
disabled={index === document.layers.length - 1}
|
|
43
|
+
onPress={() => dispatch({ type: 'layer/reorder', id: layer.id, to: index + 1 })}
|
|
44
|
+
style={{ padding: theme.spacing.xs }}
|
|
45
|
+
>
|
|
46
|
+
<Text style={{ color: theme.colors.textMuted }}>▲</Text>
|
|
47
|
+
</Pressable>
|
|
48
|
+
<Pressable
|
|
49
|
+
disabled={index === minimumIndex}
|
|
50
|
+
onPress={() => dispatch({ type: 'layer/reorder', id: layer.id, to: index - 1 })}
|
|
51
|
+
style={{ padding: theme.spacing.xs }}
|
|
52
|
+
>
|
|
53
|
+
<Text style={{ color: theme.colors.textMuted }}>▼</Text>
|
|
54
|
+
</Pressable>
|
|
55
|
+
<Pressable
|
|
56
|
+
onPress={() => dispatch({ type: 'layer/remove', id: layer.id })}
|
|
57
|
+
style={{ padding: theme.spacing.xs }}
|
|
58
|
+
>
|
|
59
|
+
<Text style={{ color: theme.colors.danger }}>✕</Text>
|
|
60
|
+
</Pressable>
|
|
61
|
+
</View>
|
|
62
|
+
)
|
|
63
|
+
})}
|
|
64
|
+
</ScrollView>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Lowest z-index to which the panel may move an overlay. */
|
|
69
|
+
export function minimumLayerIndexForPanel(
|
|
70
|
+
document: EditorDocument,
|
|
71
|
+
interaction?: ProfileLayerInteraction,
|
|
72
|
+
): number {
|
|
73
|
+
return interaction?.excludeBaseImage && document.layers[0]?.type === 'image' ? 1 : 0
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Overlay layers shown by the built-in manager, top-most first. */
|
|
77
|
+
export function layersForPanel(
|
|
78
|
+
document: EditorDocument,
|
|
79
|
+
interaction?: ProfileLayerInteraction,
|
|
80
|
+
): Array<{ layer: EditorDocument['layers'][number]; index: number }> {
|
|
81
|
+
return document.layers
|
|
82
|
+
.map((layer, index) => ({ layer, index }))
|
|
83
|
+
.filter(({ layer, index }) => isDirectManipulationLayer(layer, index, interaction))
|
|
84
|
+
.reverse()
|
|
85
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useEditor } from '@pictor-sdk/core'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { Pressable, ScrollView, Text } from 'react-native'
|
|
4
|
+
import { useTheme } from '../theme'
|
|
5
|
+
import { cellToTransformPatch, layoutCells, type LayoutPreset } from './layoutMath'
|
|
6
|
+
|
|
7
|
+
const PRESETS: Array<{ id: LayoutPreset; label: string }> = [
|
|
8
|
+
{ id: '1', label: 'Single' },
|
|
9
|
+
{ id: '2v', label: '2 rows' },
|
|
10
|
+
{ id: '2h', label: '2 cols' },
|
|
11
|
+
{ id: '3v', label: '3 rows' },
|
|
12
|
+
{ id: '4grid', label: 'Grid' },
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
/** Layout grids: snap the image layers into the chosen arrangement. */
|
|
16
|
+
export function LayoutPanel() {
|
|
17
|
+
const { document, dispatch } = useEditor()
|
|
18
|
+
const theme = useTheme()
|
|
19
|
+
|
|
20
|
+
const apply = (preset: LayoutPreset) => {
|
|
21
|
+
const cells = layoutCells(preset, document.size)
|
|
22
|
+
const images = document.layers.filter((l) => l.type === 'image')
|
|
23
|
+
dispatch({ type: 'history/beginBatch', label: 'layout' })
|
|
24
|
+
images.slice(0, cells.length).forEach((layer, i) => {
|
|
25
|
+
dispatch({ type: 'layer/transform', id: layer.id, patch: cellToTransformPatch(cells[i]!) })
|
|
26
|
+
})
|
|
27
|
+
dispatch({ type: 'history/endBatch' })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<ScrollView
|
|
32
|
+
horizontal
|
|
33
|
+
showsHorizontalScrollIndicator={false}
|
|
34
|
+
contentContainerStyle={{ gap: theme.spacing.sm, padding: theme.spacing.md }}
|
|
35
|
+
style={{ flexGrow: 0, backgroundColor: theme.colors.background }}
|
|
36
|
+
>
|
|
37
|
+
{PRESETS.map((preset) => (
|
|
38
|
+
<Pressable
|
|
39
|
+
key={preset.id}
|
|
40
|
+
onPress={() => apply(preset.id)}
|
|
41
|
+
style={{
|
|
42
|
+
paddingHorizontal: theme.spacing.md,
|
|
43
|
+
paddingVertical: theme.spacing.sm,
|
|
44
|
+
borderRadius: theme.radii.md,
|
|
45
|
+
backgroundColor: theme.colors.surface,
|
|
46
|
+
}}
|
|
47
|
+
>
|
|
48
|
+
<Text style={{ color: theme.colors.text, fontSize: theme.font.sizeSm, fontWeight: theme.font.weight }}>
|
|
49
|
+
{preset.label}
|
|
50
|
+
</Text>
|
|
51
|
+
</Pressable>
|
|
52
|
+
))}
|
|
53
|
+
</ScrollView>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import {
|
|
2
|
+
luxControlState,
|
|
3
|
+
luxIntensityPatch,
|
|
4
|
+
useEditor,
|
|
5
|
+
type ImageLayerData,
|
|
6
|
+
type LuxState,
|
|
7
|
+
} from '@pictor-sdk/core'
|
|
8
|
+
import { useLuxAnalysis } from '@pictor-sdk/skia'
|
|
9
|
+
import React from 'react'
|
|
10
|
+
import { Pressable, Text, View } from 'react-native'
|
|
11
|
+
import { Slider } from '../components/Slider'
|
|
12
|
+
import { useTheme } from '../theme'
|
|
13
|
+
import { useTargetImageLayer } from './useTargetImageLayer'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Lux: one intensity slider over an analysis of the photograph.
|
|
17
|
+
*
|
|
18
|
+
* Two things distinguish it from the 13 adjustments, both taken from
|
|
19
|
+
* measurement (`docs/research/photo-editor-ux.md`):
|
|
20
|
+
*
|
|
21
|
+
* * the slider is **one-directional and rests at zero**, not centred — Lux is
|
|
22
|
+
* an amount of correction, and there is no meaningful negative;
|
|
23
|
+
* * the correction itself is **image-dependent**. The same slider position
|
|
24
|
+
* lifts a flat photograph's highlights and pulls a well-exposed one's down.
|
|
25
|
+
*
|
|
26
|
+
* The analysis is written into the document the moment the user engages the
|
|
27
|
+
* slider, and everything afterwards — preview, undo, save, export — reads it
|
|
28
|
+
* from there. Recomputing at render time would let a different decode, a
|
|
29
|
+
* resized preview or a future analyzer quietly restyle an existing document.
|
|
30
|
+
*/
|
|
31
|
+
export function LuxPanel() {
|
|
32
|
+
const { dispatch } = useEditor()
|
|
33
|
+
const theme = useTheme()
|
|
34
|
+
const layer = useTargetImageLayer()
|
|
35
|
+
const data = layer?.data as ImageLayerData | undefined
|
|
36
|
+
const proposal = useLuxAnalysis(data?.source)
|
|
37
|
+
|
|
38
|
+
if (!layer || !data) return null
|
|
39
|
+
const stored: LuxState | undefined = data.color.lux
|
|
40
|
+
const intensity = stored?.intensity ?? 0
|
|
41
|
+
// Once stored, the document's analysis wins — that is the whole point. Until
|
|
42
|
+
// one exists, the control is inert rather than falling back to a neutral
|
|
43
|
+
// proposal: a neutral analysis stored during a slow decode would render as
|
|
44
|
+
// nothing *and* suppress the real proposal permanently.
|
|
45
|
+
const state = luxControlState(stored, proposal)
|
|
46
|
+
|
|
47
|
+
const setIntensity = (value: number) => {
|
|
48
|
+
const lux = luxIntensityPatch(state, value)
|
|
49
|
+
if (!lux) return
|
|
50
|
+
dispatch({ type: 'layer/patchData', id: layer.id, patch: { color: { lux } } })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Clear Lux entirely — `undefined` deletes the key, so the document returns
|
|
55
|
+
* to the shape it had before Lux was ever touched and the render path skips
|
|
56
|
+
* the contribution instead of adding zeroes.
|
|
57
|
+
*
|
|
58
|
+
* Inside the tool session's batch, like every other reset: Cancel rolls it
|
|
59
|
+
* back with the rest and Done commits it as one entry.
|
|
60
|
+
*/
|
|
61
|
+
const reset = () =>
|
|
62
|
+
dispatch({
|
|
63
|
+
type: 'layer/patchData',
|
|
64
|
+
id: layer.id,
|
|
65
|
+
patch: { color: { lux: undefined } } as unknown as Record<string, unknown>,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<View style={{ backgroundColor: theme.colors.background, paddingBottom: theme.spacing.md }}>
|
|
70
|
+
<View style={{ paddingHorizontal: theme.spacing.lg }}>
|
|
71
|
+
<View
|
|
72
|
+
style={{
|
|
73
|
+
flexDirection: 'row',
|
|
74
|
+
alignItems: 'center',
|
|
75
|
+
justifyContent: 'center',
|
|
76
|
+
gap: theme.spacing.sm,
|
|
77
|
+
minHeight: 28,
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
<Text
|
|
81
|
+
style={{
|
|
82
|
+
color: theme.colors.textMuted,
|
|
83
|
+
textAlign: 'center',
|
|
84
|
+
fontSize: theme.font.sizeSm,
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
{state.pending ? 'Analyzing…' : `Lux ${intensity === 0 ? '' : Math.round(intensity * 100)}`}
|
|
88
|
+
</Text>
|
|
89
|
+
{intensity > 0 ? (
|
|
90
|
+
<Pressable
|
|
91
|
+
accessibilityRole="button"
|
|
92
|
+
accessibilityLabel="Reset Lux"
|
|
93
|
+
onPress={reset}
|
|
94
|
+
hitSlop={8}
|
|
95
|
+
style={{
|
|
96
|
+
paddingHorizontal: theme.spacing.sm,
|
|
97
|
+
paddingVertical: 2,
|
|
98
|
+
borderRadius: theme.radii.sm,
|
|
99
|
+
borderWidth: 1,
|
|
100
|
+
borderColor: theme.colors.track,
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
<Text style={{ color: theme.colors.text, fontSize: theme.font.sizeSm }}>Reset</Text>
|
|
104
|
+
</Pressable>
|
|
105
|
+
) : null}
|
|
106
|
+
</View>
|
|
107
|
+
<Slider
|
|
108
|
+
value={intensity}
|
|
109
|
+
min={0}
|
|
110
|
+
max={1}
|
|
111
|
+
centered={false}
|
|
112
|
+
disabled={!state.interactive}
|
|
113
|
+
testID="lux-slider"
|
|
114
|
+
valueLabel={`${Math.round(intensity * 100)}`}
|
|
115
|
+
onStart={() => dispatch({ type: 'history/beginBatch', label: 'lux' })}
|
|
116
|
+
onChange={setIntensity}
|
|
117
|
+
onEnd={() => dispatch({ type: 'history/endBatch' })}
|
|
118
|
+
/>
|
|
119
|
+
</View>
|
|
120
|
+
</View>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {
|
|
2
|
+
initialMediaTransform,
|
|
3
|
+
mediaConfigFromProfile,
|
|
4
|
+
useEditor,
|
|
5
|
+
type ImageLayerData,
|
|
6
|
+
} from '@pictor-sdk/core'
|
|
7
|
+
import { preferResolvedSize, useResolvedImageSize } from '@pictor-sdk/skia'
|
|
8
|
+
import React from 'react'
|
|
9
|
+
import { Pressable, Text, View } from 'react-native'
|
|
10
|
+
import { useBaseMediaLayer } from '../gestures/StoryMediaGestureSurface'
|
|
11
|
+
import { useTheme } from '../theme'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The base-photo tool panel.
|
|
15
|
+
*
|
|
16
|
+
* It is deliberately thin: the gestures live on the canvas surface, and the
|
|
17
|
+
* Cancel/Done transaction comes from `ToolPanelHost`, which opens a history
|
|
18
|
+
* batch for any tool with a panel. The panel only offers the affordances that
|
|
19
|
+
* are not gestures — a reset to the initial fit — and explains the interaction.
|
|
20
|
+
*/
|
|
21
|
+
export function MediaPanel() {
|
|
22
|
+
const { document, dispatch, profile } = useEditor()
|
|
23
|
+
const layer = useBaseMediaLayer()
|
|
24
|
+
const theme = useTheme()
|
|
25
|
+
// same size the renderer and the gesture surface use, so Reset agrees with them
|
|
26
|
+
const data = layer?.data as ImageLayerData | undefined
|
|
27
|
+
const resolved = useResolvedImageSize(data?.source)
|
|
28
|
+
|
|
29
|
+
if (!layer) {
|
|
30
|
+
return (
|
|
31
|
+
<View style={{ padding: theme.spacing.lg }}>
|
|
32
|
+
<Text style={{ color: theme.colors.textMuted, fontSize: theme.font.sizeSm }}>
|
|
33
|
+
This document has no base photo.
|
|
34
|
+
</Text>
|
|
35
|
+
</View>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const reset = () => {
|
|
40
|
+
const config = mediaConfigFromProfile(profile.interaction?.media)
|
|
41
|
+
dispatch({
|
|
42
|
+
type: 'layer/transform',
|
|
43
|
+
id: layer.id,
|
|
44
|
+
patch: initialMediaTransform(
|
|
45
|
+
preferResolvedSize(resolved, data?.intrinsic),
|
|
46
|
+
document.size,
|
|
47
|
+
config,
|
|
48
|
+
),
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<View style={{ padding: theme.spacing.lg, gap: theme.spacing.md }}>
|
|
54
|
+
<Text style={{ color: theme.colors.textMuted, fontSize: theme.font.sizeSm }}>
|
|
55
|
+
Drag to reposition · pinch to zoom · twist to rotate · double-tap to zoom
|
|
56
|
+
</Text>
|
|
57
|
+
<Pressable
|
|
58
|
+
accessibilityRole="button"
|
|
59
|
+
accessibilityLabel="Reset photo placement"
|
|
60
|
+
onPress={reset}
|
|
61
|
+
style={{
|
|
62
|
+
alignSelf: 'flex-start',
|
|
63
|
+
paddingHorizontal: theme.spacing.md,
|
|
64
|
+
paddingVertical: theme.spacing.sm,
|
|
65
|
+
borderRadius: theme.radii.md,
|
|
66
|
+
backgroundColor: theme.colors.surface,
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
<Text style={{ color: theme.colors.text, fontSize: theme.font.size }}>Reset</Text>
|
|
70
|
+
</Pressable>
|
|
71
|
+
</View>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import {
|
|
2
|
+
needsPerspective,
|
|
3
|
+
perspectivePatch,
|
|
4
|
+
useEditor,
|
|
5
|
+
MAX_PERSPECTIVE_DEGREES,
|
|
6
|
+
type ImageLayerData,
|
|
7
|
+
type Perspective,
|
|
8
|
+
} from '@pictor-sdk/core'
|
|
9
|
+
import React, { useState } from 'react'
|
|
10
|
+
import { Pressable, Text, View } from 'react-native'
|
|
11
|
+
import { Slider } from '../components/Slider'
|
|
12
|
+
import { useTheme } from '../theme'
|
|
13
|
+
import { useTargetImageLayer } from './useTargetImageLayer'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Geometry: vertical keystone, straighten, horizontal keystone.
|
|
17
|
+
*
|
|
18
|
+
* One control shared by all three, because they are one family — the research
|
|
19
|
+
* pass found the reference driving exactly these three from a single dial, and
|
|
20
|
+
* that observation is what justified a single unified cover calculation rather
|
|
21
|
+
* than multiplying separate zooms.
|
|
22
|
+
*
|
|
23
|
+
* Values read in **degrees**, as measured, and each mode carries an inline
|
|
24
|
+
* reset that clears only itself.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Straighten shares the perspective range here, so one slider serves all three. */
|
|
28
|
+
const MAX_STRAIGHTEN_DEGREES = MAX_PERSPECTIVE_DEGREES
|
|
29
|
+
|
|
30
|
+
const MODES = [
|
|
31
|
+
{ key: 'vertical', label: 'Vertical' },
|
|
32
|
+
{ key: 'straighten', label: 'Straighten' },
|
|
33
|
+
{ key: 'horizontal', label: 'Horizontal' },
|
|
34
|
+
] as const
|
|
35
|
+
|
|
36
|
+
type ModeKey = (typeof MODES)[number]['key']
|
|
37
|
+
|
|
38
|
+
const toRadians = (degrees: number) => (degrees * Math.PI) / 180
|
|
39
|
+
const toDegrees = (radians: number) => (radians * 180) / Math.PI
|
|
40
|
+
|
|
41
|
+
export function PerspectivePanel() {
|
|
42
|
+
const { dispatch } = useEditor()
|
|
43
|
+
const theme = useTheme()
|
|
44
|
+
const layer = useTargetImageLayer()
|
|
45
|
+
const [mode, setMode] = useState<ModeKey>('vertical')
|
|
46
|
+
|
|
47
|
+
if (!layer) return null
|
|
48
|
+
const data = layer.data as ImageLayerData
|
|
49
|
+
const perspective: Perspective = data.perspective ?? { vertical: 0, horizontal: 0 }
|
|
50
|
+
const straightenDegrees = toDegrees(data.straighten ?? 0)
|
|
51
|
+
|
|
52
|
+
/** Every mode expressed as degrees, so one slider can drive all three. */
|
|
53
|
+
const degreesOf = (key: ModeKey): number =>
|
|
54
|
+
key === 'straighten'
|
|
55
|
+
? straightenDegrees
|
|
56
|
+
: perspective[key] * MAX_PERSPECTIVE_DEGREES
|
|
57
|
+
|
|
58
|
+
const setDegrees = (key: ModeKey, degrees: number) => {
|
|
59
|
+
if (key === 'straighten') {
|
|
60
|
+
return dispatch({
|
|
61
|
+
type: 'layer/patchData',
|
|
62
|
+
id: layer.id,
|
|
63
|
+
patch: { straighten: degrees === 0 ? undefined : toRadians(degrees) } as unknown as Record<
|
|
64
|
+
string,
|
|
65
|
+
unknown
|
|
66
|
+
>,
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
const next: Perspective = { ...perspective, [key]: degrees / MAX_PERSPECTIVE_DEGREES }
|
|
70
|
+
dispatch({
|
|
71
|
+
type: 'layer/patchData',
|
|
72
|
+
id: layer.id,
|
|
73
|
+
// `perspectivePatch` deletes the key when both axes reach zero, so
|
|
74
|
+
// straightening a correction back out leaves nothing serialized.
|
|
75
|
+
patch: perspectivePatch(needsPerspective(next) ? next : undefined) as unknown as Record<
|
|
76
|
+
string,
|
|
77
|
+
unknown
|
|
78
|
+
>,
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const value = degreesOf(mode)
|
|
83
|
+
const max = mode === 'straighten' ? MAX_STRAIGHTEN_DEGREES : MAX_PERSPECTIVE_DEGREES
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<View style={{ backgroundColor: theme.colors.background, paddingBottom: theme.spacing.md }}>
|
|
87
|
+
<View
|
|
88
|
+
style={{
|
|
89
|
+
flexDirection: 'row',
|
|
90
|
+
justifyContent: 'center',
|
|
91
|
+
gap: theme.spacing.sm,
|
|
92
|
+
paddingVertical: theme.spacing.sm,
|
|
93
|
+
}}
|
|
94
|
+
>
|
|
95
|
+
{MODES.map(({ key, label }) => {
|
|
96
|
+
const selected = key === mode
|
|
97
|
+
const degrees = degreesOf(key)
|
|
98
|
+
const set = Math.abs(degrees) > 0.05
|
|
99
|
+
return (
|
|
100
|
+
<Pressable
|
|
101
|
+
key={key}
|
|
102
|
+
accessibilityRole="button"
|
|
103
|
+
accessibilityLabel={`${label} correction`}
|
|
104
|
+
onPress={() => setMode(key)}
|
|
105
|
+
style={{
|
|
106
|
+
flexDirection: 'row',
|
|
107
|
+
alignItems: 'center',
|
|
108
|
+
gap: 6,
|
|
109
|
+
paddingHorizontal: theme.spacing.sm,
|
|
110
|
+
paddingVertical: 6,
|
|
111
|
+
borderRadius: theme.radii.sm,
|
|
112
|
+
borderWidth: 1,
|
|
113
|
+
borderColor: selected ? theme.colors.accent : theme.colors.track,
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
<Text
|
|
117
|
+
style={{
|
|
118
|
+
color: selected ? theme.colors.accent : theme.colors.textMuted,
|
|
119
|
+
fontSize: theme.font.sizeSm,
|
|
120
|
+
fontWeight: theme.font.weight,
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
{/* A pill showing the value with its unit once set — the
|
|
124
|
+
affordance the research pass measured. */}
|
|
125
|
+
{set ? `${label} ${degrees.toFixed(1)}°` : label}
|
|
126
|
+
</Text>
|
|
127
|
+
{set ? (
|
|
128
|
+
<Pressable
|
|
129
|
+
accessibilityRole="button"
|
|
130
|
+
accessibilityLabel={`Reset ${label.toLowerCase()}`}
|
|
131
|
+
onPress={() => setDegrees(key, 0)}
|
|
132
|
+
hitSlop={8}
|
|
133
|
+
>
|
|
134
|
+
<Text style={{ color: theme.colors.text, fontSize: theme.font.sizeSm }}>✕</Text>
|
|
135
|
+
</Pressable>
|
|
136
|
+
) : null}
|
|
137
|
+
</Pressable>
|
|
138
|
+
)
|
|
139
|
+
})}
|
|
140
|
+
</View>
|
|
141
|
+
|
|
142
|
+
<View style={{ paddingHorizontal: theme.spacing.lg }}>
|
|
143
|
+
<Slider
|
|
144
|
+
value={value}
|
|
145
|
+
min={-max}
|
|
146
|
+
max={max}
|
|
147
|
+
centered
|
|
148
|
+
testID={`perspective-${mode}`}
|
|
149
|
+
valueLabel={`${value > 0 ? '+' : ''}${value.toFixed(1)}°`}
|
|
150
|
+
onStart={() => dispatch({ type: 'history/beginBatch', label: `perspective-${mode}` })}
|
|
151
|
+
onChange={(v) => setDegrees(mode, v)}
|
|
152
|
+
onEnd={() => dispatch({ type: 'history/endBatch' })}
|
|
153
|
+
/>
|
|
154
|
+
</View>
|
|
155
|
+
</View>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createId, useEditor, type Size } from '@pictor-sdk/core'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { Image, Pressable, ScrollView, Text, View } from 'react-native'
|
|
4
|
+
import { useTheme } from '../theme'
|
|
5
|
+
import { useUiState } from '../uiState'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One sticker offered by the panel.
|
|
9
|
+
*
|
|
10
|
+
* Supplied by the host: the library ships no sticker artwork, so what appears
|
|
11
|
+
* here is entirely the app's own.
|
|
12
|
+
*/
|
|
13
|
+
export interface StickerAsset {
|
|
14
|
+
id: string
|
|
15
|
+
/** image source (require() result, {uri}, or URL string) */
|
|
16
|
+
source: unknown
|
|
17
|
+
intrinsic?: Size
|
|
18
|
+
label?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Sticker tab. Sticker catalogs are host-supplied — pass them via
|
|
23
|
+
* <Editor stickers={…}> (or replace this panel through a plugin tool Panel).
|
|
24
|
+
* DECISION: no sticker assets ship with the library (same reasoning as LUTs).
|
|
25
|
+
*/
|
|
26
|
+
export function StickerPanel() {
|
|
27
|
+
const { dispatch, select } = useEditor()
|
|
28
|
+
const theme = useTheme()
|
|
29
|
+
const [stickers] = useUiState<StickerAsset[]>('stickers', [])
|
|
30
|
+
|
|
31
|
+
const add = (sticker: StickerAsset) => {
|
|
32
|
+
const id = createId('sticker')
|
|
33
|
+
dispatch({
|
|
34
|
+
type: 'layer/add',
|
|
35
|
+
layer: {
|
|
36
|
+
id,
|
|
37
|
+
type: 'sticker',
|
|
38
|
+
transform: { x: 0.5, y: 0.5, scale: 1, rotation: 0 },
|
|
39
|
+
opacity: 1,
|
|
40
|
+
blendMode: 'normal',
|
|
41
|
+
data: {
|
|
42
|
+
source: sticker.source,
|
|
43
|
+
intrinsic: sticker.intrinsic ?? { width: 0, height: 0 },
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
select([id])
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (stickers.length === 0) {
|
|
51
|
+
return (
|
|
52
|
+
<View style={{ backgroundColor: theme.colors.background, padding: theme.spacing.md }}>
|
|
53
|
+
<Text style={{ color: theme.colors.textMuted, textAlign: 'center', fontSize: theme.font.sizeSm }}>
|
|
54
|
+
No stickers registered — pass `stickers` to {'<Editor>'}.
|
|
55
|
+
</Text>
|
|
56
|
+
</View>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<ScrollView
|
|
62
|
+
horizontal
|
|
63
|
+
showsHorizontalScrollIndicator={false}
|
|
64
|
+
contentContainerStyle={{ gap: theme.spacing.sm, padding: theme.spacing.md }}
|
|
65
|
+
style={{ flexGrow: 0, backgroundColor: theme.colors.background }}
|
|
66
|
+
>
|
|
67
|
+
{stickers.map((sticker) => (
|
|
68
|
+
<Pressable key={sticker.id} onPress={() => add(sticker)}>
|
|
69
|
+
<Image
|
|
70
|
+
source={sticker.source as never}
|
|
71
|
+
style={{ width: 64, height: 64, borderRadius: theme.radii.sm, backgroundColor: theme.colors.surface }}
|
|
72
|
+
resizeMode="contain"
|
|
73
|
+
/>
|
|
74
|
+
</Pressable>
|
|
75
|
+
))}
|
|
76
|
+
</ScrollView>
|
|
77
|
+
)
|
|
78
|
+
}
|