@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,177 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createId,
|
|
3
|
+
useEditor,
|
|
4
|
+
useEditorStore,
|
|
5
|
+
type TextLayerData,
|
|
6
|
+
type TextSpan,
|
|
7
|
+
} from '@pictor-sdk/core'
|
|
8
|
+
import React from 'react'
|
|
9
|
+
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native'
|
|
10
|
+
import { useTheme } from '../theme'
|
|
11
|
+
import { Slider } from '../components/Slider'
|
|
12
|
+
|
|
13
|
+
const SWATCHES = ['#ffffff', '#000000', '#ed4956', '#ffe25c', '#78de85', '#4dc4ff', '#a15fed']
|
|
14
|
+
const ALIGNS = ['left', 'center', 'right'] as const
|
|
15
|
+
const FILL_MODES = [undefined, 'solid', 'perLine'] as const
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Text tab: add a text layer, then edit the selected one — content, font,
|
|
19
|
+
* size, color, alignment, and the fill treatment (solid / per-line
|
|
20
|
+
* @experimental). Editing happens in the panel for v0.2; DECISION:
|
|
21
|
+
* on-canvas edit-in-place needs a native text-input overlay pass, deferred.
|
|
22
|
+
*/
|
|
23
|
+
export function TextPanel() {
|
|
24
|
+
const { document, selection, dispatch, select } = useEditor()
|
|
25
|
+
const store = useEditorStore()
|
|
26
|
+
const theme = useTheme()
|
|
27
|
+
|
|
28
|
+
const selected = document.layers.find(
|
|
29
|
+
(l) => l.type === 'text' && selection.includes(l.id),
|
|
30
|
+
)
|
|
31
|
+
const data = selected ? (selected.data as TextLayerData) : null
|
|
32
|
+
const span: TextSpan | null = data?.spans[0] ?? null
|
|
33
|
+
|
|
34
|
+
const addText = () => {
|
|
35
|
+
const id = createId('text')
|
|
36
|
+
dispatch({
|
|
37
|
+
type: 'layer/add',
|
|
38
|
+
layer: {
|
|
39
|
+
id,
|
|
40
|
+
type: 'text',
|
|
41
|
+
transform: { x: 0.5, y: 0.4, scale: 1, rotation: 0 },
|
|
42
|
+
opacity: 1,
|
|
43
|
+
blendMode: 'normal',
|
|
44
|
+
data: {
|
|
45
|
+
spans: [{ text: 'Tap to edit', fontFamily: 'System', fontSize: 0.07, color: '#ffffff' }],
|
|
46
|
+
align: 'center',
|
|
47
|
+
maxWidth: 0.9,
|
|
48
|
+
} satisfies TextLayerData,
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
select([id])
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const patchSpan = (patch: Partial<TextSpan>) => {
|
|
55
|
+
if (!selected || !span) return
|
|
56
|
+
dispatch({
|
|
57
|
+
type: 'layer/patchData',
|
|
58
|
+
id: selected.id,
|
|
59
|
+
patch: { spans: [{ ...span, ...patch }, ...(data!.spans.slice(1) ?? [])] },
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const fonts = ['System', ...store.registry.fonts.map((f) => f.family)]
|
|
64
|
+
|
|
65
|
+
if (!selected || !span || !data) {
|
|
66
|
+
return (
|
|
67
|
+
<View style={{ backgroundColor: theme.colors.background, padding: theme.spacing.md, alignItems: 'center' }}>
|
|
68
|
+
<Pressable
|
|
69
|
+
onPress={addText}
|
|
70
|
+
style={{
|
|
71
|
+
paddingHorizontal: theme.spacing.lg,
|
|
72
|
+
paddingVertical: theme.spacing.sm,
|
|
73
|
+
borderRadius: theme.radii.md,
|
|
74
|
+
backgroundColor: theme.colors.surface,
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
<Text style={{ color: theme.colors.accent, fontSize: theme.font.size, fontWeight: theme.font.weight }}>
|
|
78
|
+
+ Add text
|
|
79
|
+
</Text>
|
|
80
|
+
</Pressable>
|
|
81
|
+
</View>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const cycleAlign = () => {
|
|
86
|
+
const next = ALIGNS[(ALIGNS.indexOf(data.align) + 1) % ALIGNS.length]!
|
|
87
|
+
dispatch({ type: 'layer/patchData', id: selected.id, patch: { align: next } })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const cycleFill = () => {
|
|
91
|
+
const current = data.fill?.mode
|
|
92
|
+
const index = FILL_MODES.indexOf(current as (typeof FILL_MODES)[number])
|
|
93
|
+
const next = FILL_MODES[(index + 1) % FILL_MODES.length]
|
|
94
|
+
dispatch({
|
|
95
|
+
type: 'layer/patchData',
|
|
96
|
+
id: selected.id,
|
|
97
|
+
patch: {
|
|
98
|
+
fill: next
|
|
99
|
+
? { color: span.color === '#000000' ? '#ffffff' : '#000000', radius: 0.012, padding: 0.012, mode: next }
|
|
100
|
+
: undefined,
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<View style={{ backgroundColor: theme.colors.background }}>
|
|
107
|
+
<View style={{ paddingHorizontal: theme.spacing.md }}>
|
|
108
|
+
<TextInput
|
|
109
|
+
value={span.text}
|
|
110
|
+
onChangeText={(text) => patchSpan({ text })}
|
|
111
|
+
multiline
|
|
112
|
+
placeholder="Your text"
|
|
113
|
+
placeholderTextColor={theme.colors.textMuted}
|
|
114
|
+
style={{
|
|
115
|
+
color: theme.colors.text,
|
|
116
|
+
backgroundColor: theme.colors.surface,
|
|
117
|
+
borderRadius: theme.radii.sm,
|
|
118
|
+
padding: theme.spacing.sm,
|
|
119
|
+
fontSize: theme.font.size,
|
|
120
|
+
}}
|
|
121
|
+
/>
|
|
122
|
+
<Slider
|
|
123
|
+
value={span.fontSize}
|
|
124
|
+
min={0.02}
|
|
125
|
+
max={0.2}
|
|
126
|
+
centered={false}
|
|
127
|
+
onStart={() => dispatch({ type: 'history/beginBatch', label: 'font size' })}
|
|
128
|
+
onChange={(fontSize) => patchSpan({ fontSize })}
|
|
129
|
+
onEnd={() => dispatch({ type: 'history/endBatch' })}
|
|
130
|
+
/>
|
|
131
|
+
</View>
|
|
132
|
+
<ScrollView
|
|
133
|
+
horizontal
|
|
134
|
+
showsHorizontalScrollIndicator={false}
|
|
135
|
+
contentContainerStyle={{ gap: theme.spacing.sm, paddingHorizontal: theme.spacing.md, alignItems: 'center', paddingBottom: theme.spacing.sm }}
|
|
136
|
+
style={{ flexGrow: 0 }}
|
|
137
|
+
>
|
|
138
|
+
<Pressable onPress={cycleAlign} style={{ padding: theme.spacing.sm }}>
|
|
139
|
+
<Text style={{ color: theme.colors.text, fontWeight: theme.font.weight }}>
|
|
140
|
+
{data.align === 'left' ? '⇤' : data.align === 'center' ? '☰' : '⇥'}
|
|
141
|
+
</Text>
|
|
142
|
+
</Pressable>
|
|
143
|
+
<Pressable onPress={cycleFill} style={{ padding: theme.spacing.sm }}>
|
|
144
|
+
<Text style={{ color: data.fill ? theme.colors.accent : theme.colors.text, fontWeight: theme.font.weight }}>
|
|
145
|
+
{data.fill?.mode === 'perLine' ? 'A⃞⃞' : 'A⃞'}
|
|
146
|
+
</Text>
|
|
147
|
+
</Pressable>
|
|
148
|
+
{fonts.map((family) => (
|
|
149
|
+
<Pressable key={family} onPress={() => patchSpan({ fontFamily: family })} style={{ padding: theme.spacing.sm }}>
|
|
150
|
+
<Text
|
|
151
|
+
style={{
|
|
152
|
+
color: span.fontFamily === family ? theme.colors.accent : theme.colors.textMuted,
|
|
153
|
+
fontSize: theme.font.sizeSm,
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
{family}
|
|
157
|
+
</Text>
|
|
158
|
+
</Pressable>
|
|
159
|
+
))}
|
|
160
|
+
{SWATCHES.map((color) => (
|
|
161
|
+
<Pressable
|
|
162
|
+
key={color}
|
|
163
|
+
onPress={() => patchSpan({ color })}
|
|
164
|
+
style={{
|
|
165
|
+
width: 24,
|
|
166
|
+
height: 24,
|
|
167
|
+
borderRadius: 12,
|
|
168
|
+
backgroundColor: color,
|
|
169
|
+
borderWidth: span.color === color ? 3 : 1,
|
|
170
|
+
borderColor: span.color === color ? theme.colors.accent : theme.colors.track,
|
|
171
|
+
}}
|
|
172
|
+
/>
|
|
173
|
+
))}
|
|
174
|
+
</ScrollView>
|
|
175
|
+
</View>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createTiltShift,
|
|
3
|
+
needsTiltShift,
|
|
4
|
+
tiltShiftPatch,
|
|
5
|
+
useEditor,
|
|
6
|
+
MAX_TILT_BLUR,
|
|
7
|
+
MAX_TILT_FEATHER,
|
|
8
|
+
MAX_TILT_RADIUS,
|
|
9
|
+
type ImageLayerData,
|
|
10
|
+
type TiltShift,
|
|
11
|
+
} from '@pictor-sdk/core'
|
|
12
|
+
import React from 'react'
|
|
13
|
+
import { Pressable, Text, View } from 'react-native'
|
|
14
|
+
import { Slider } from '../components/Slider'
|
|
15
|
+
import { useTheme } from '../theme'
|
|
16
|
+
import { useTargetImageLayer } from './useTargetImageLayer'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Selective focus: Off · Radial · Linear, then the shape of the region.
|
|
20
|
+
*
|
|
21
|
+
* The mode row is explicit rather than implied by a non-zero blur, because the
|
|
22
|
+
* research pass measured it that way and because "off" needs to be reachable
|
|
23
|
+
* without dragging a slider back to exactly zero.
|
|
24
|
+
*
|
|
25
|
+
* Position and size are set by dragging on the canvas, not here — see
|
|
26
|
+
* `TiltShiftGestureSurface`. The sliders cover what a gesture cannot express.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const MODES = [
|
|
30
|
+
{ key: 'off', label: 'Off' },
|
|
31
|
+
{ key: 'radial', label: 'Radial' },
|
|
32
|
+
{ key: 'linear', label: 'Linear' },
|
|
33
|
+
] as const
|
|
34
|
+
|
|
35
|
+
export function TiltShiftPanel() {
|
|
36
|
+
const { dispatch } = useEditor()
|
|
37
|
+
const theme = useTheme()
|
|
38
|
+
const layer = useTargetImageLayer()
|
|
39
|
+
|
|
40
|
+
if (!layer) return null
|
|
41
|
+
const data = layer.data as ImageLayerData
|
|
42
|
+
const tilt = needsTiltShift(data.tiltShift) ? data.tiltShift! : undefined
|
|
43
|
+
const mode = tilt?.mode ?? 'off'
|
|
44
|
+
|
|
45
|
+
const patch = (next: TiltShift | undefined) =>
|
|
46
|
+
dispatch({
|
|
47
|
+
type: 'layer/patchData',
|
|
48
|
+
id: layer.id,
|
|
49
|
+
patch: tiltShiftPatch(next) as unknown as Record<string, unknown>,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Switching mode keeps the region where the user put it.
|
|
54
|
+
*
|
|
55
|
+
* Only the shape changes; re-centring on every mode tap would throw away
|
|
56
|
+
* positioning work for no reason.
|
|
57
|
+
*/
|
|
58
|
+
const setMode = (key: (typeof MODES)[number]['key']) => {
|
|
59
|
+
if (key === 'off') return patch(undefined)
|
|
60
|
+
patch(tilt ? { ...tilt, mode: key } : createTiltShift(key))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const set = (over: Partial<TiltShift>) => tilt && patch({ ...tilt, ...over })
|
|
64
|
+
|
|
65
|
+
const sliders: Array<{ key: keyof TiltShift; label: string; max: number }> = [
|
|
66
|
+
{ key: 'blur', label: 'Blur', max: MAX_TILT_BLUR },
|
|
67
|
+
{ key: 'radius', label: 'Size', max: MAX_TILT_RADIUS },
|
|
68
|
+
{ key: 'feather', label: 'Feather', max: MAX_TILT_FEATHER },
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<View style={{ backgroundColor: theme.colors.background, paddingBottom: theme.spacing.md }}>
|
|
73
|
+
<View
|
|
74
|
+
style={{
|
|
75
|
+
flexDirection: 'row',
|
|
76
|
+
justifyContent: 'center',
|
|
77
|
+
gap: theme.spacing.md,
|
|
78
|
+
paddingVertical: theme.spacing.sm,
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
{MODES.map(({ key, label }) => {
|
|
82
|
+
const selected = key === mode
|
|
83
|
+
return (
|
|
84
|
+
<Pressable
|
|
85
|
+
key={key}
|
|
86
|
+
accessibilityRole="button"
|
|
87
|
+
accessibilityLabel={`Tilt shift ${label.toLowerCase()}`}
|
|
88
|
+
onPress={() => setMode(key)}
|
|
89
|
+
style={{
|
|
90
|
+
paddingHorizontal: theme.spacing.md,
|
|
91
|
+
paddingVertical: 6,
|
|
92
|
+
borderRadius: theme.radii.sm,
|
|
93
|
+
borderWidth: 1,
|
|
94
|
+
borderColor: selected ? theme.colors.accent : theme.colors.track,
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
<Text
|
|
98
|
+
style={{
|
|
99
|
+
color: selected ? theme.colors.accent : theme.colors.textMuted,
|
|
100
|
+
fontSize: theme.font.sizeSm,
|
|
101
|
+
fontWeight: theme.font.weight,
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
{label}
|
|
105
|
+
</Text>
|
|
106
|
+
</Pressable>
|
|
107
|
+
)
|
|
108
|
+
})}
|
|
109
|
+
</View>
|
|
110
|
+
|
|
111
|
+
<View style={{ paddingHorizontal: theme.spacing.lg }}>
|
|
112
|
+
<Text
|
|
113
|
+
style={{
|
|
114
|
+
color: theme.colors.textMuted,
|
|
115
|
+
textAlign: 'center',
|
|
116
|
+
fontSize: theme.font.sizeSm,
|
|
117
|
+
minHeight: 20,
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
{tilt ? 'Drag on the photo to place the focus' : 'Off'}
|
|
121
|
+
</Text>
|
|
122
|
+
{sliders.map(({ key, label, max }) => {
|
|
123
|
+
const value = (tilt?.[key] as number | undefined) ?? 0
|
|
124
|
+
return (
|
|
125
|
+
<View key={key}>
|
|
126
|
+
<Text style={{ color: theme.colors.textMuted, fontSize: theme.font.sizeSm }}>
|
|
127
|
+
{label} {tilt ? Math.round((value / max) * 100) : ''}
|
|
128
|
+
</Text>
|
|
129
|
+
<Slider
|
|
130
|
+
value={value}
|
|
131
|
+
min={0}
|
|
132
|
+
max={max}
|
|
133
|
+
centered={false}
|
|
134
|
+
testID={`tilt-${key}`}
|
|
135
|
+
disabled={!tilt}
|
|
136
|
+
onStart={() => dispatch({ type: 'history/beginBatch', label: `tilt-${key}` })}
|
|
137
|
+
onChange={(v) => set({ [key]: v } as Partial<TiltShift>)}
|
|
138
|
+
onEnd={() => dispatch({ type: 'history/endBatch' })}
|
|
139
|
+
/>
|
|
140
|
+
</View>
|
|
141
|
+
)
|
|
142
|
+
})}
|
|
143
|
+
</View>
|
|
144
|
+
</View>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createImageLayers,
|
|
3
|
+
insertImageCommands,
|
|
4
|
+
type Dispatch,
|
|
5
|
+
type InsertableImage,
|
|
6
|
+
type InsertImageOptions,
|
|
7
|
+
type InsertImageTransaction,
|
|
8
|
+
} from '@pictor-sdk/core'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Acquiring photos from the host, then inserting them.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately **not** a React component. Acquisition is asynchronous and has
|
|
14
|
+
* to survive a re-render, refuse a second request while one is in flight, and
|
|
15
|
+
* do nothing at all if the panel is gone by the time the host answers — none of
|
|
16
|
+
* which is expressible as render logic, and all of which is worth testing
|
|
17
|
+
* without mounting anything.
|
|
18
|
+
*
|
|
19
|
+
* Acquisition is also kept separate from insertion so a host that wants neither
|
|
20
|
+
* this controller nor the default panel can call `createImageLayers` and
|
|
21
|
+
* `insertImageCommands` directly.
|
|
22
|
+
*
|
|
23
|
+
* @experimental
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export interface AddPhotoControllerOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Supplied by the host. Resolving `null` or `[]` means the user cancelled,
|
|
29
|
+
* which must add nothing — no command, no selection change, no history entry.
|
|
30
|
+
*/
|
|
31
|
+
requestImages?: () => Promise<readonly InsertableImage[] | null>
|
|
32
|
+
dispatch: Dispatch
|
|
33
|
+
select: (ids: string[]) => void
|
|
34
|
+
/** Anything the host's picker throws, or a result that is not usable. */
|
|
35
|
+
onError?: (error: unknown) => void
|
|
36
|
+
/** Presentation and transaction options forwarded to the core helpers. */
|
|
37
|
+
insert?: InsertImageOptions & InsertImageTransaction
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A live add-photo session.
|
|
42
|
+
*
|
|
43
|
+
* @experimental
|
|
44
|
+
*/
|
|
45
|
+
export interface AddPhotoController {
|
|
46
|
+
/** Ask the host for photos and insert whatever comes back. */
|
|
47
|
+
request: () => Promise<void>
|
|
48
|
+
/** True while a request is in flight; a second press is ignored. */
|
|
49
|
+
isPending: () => boolean
|
|
50
|
+
/**
|
|
51
|
+
* Whether the host supplied a way to ask at all.
|
|
52
|
+
*
|
|
53
|
+
* **Session-scoped.** Captured when the controller is created, which for the
|
|
54
|
+
* bundled panel means when it mounts. A host that adds `requestImages` while
|
|
55
|
+
* the panel is open sees it unavailable until the tool is reopened — the
|
|
56
|
+
* mount is the session boundary everywhere else in this panel too, and a
|
|
57
|
+
* control that becomes live mid-session is a stranger surprise than one that
|
|
58
|
+
* waits for the next open.
|
|
59
|
+
*/
|
|
60
|
+
isAvailable: () => boolean
|
|
61
|
+
/** Called when the panel unmounts: a late result is then discarded. */
|
|
62
|
+
dispose: () => void
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** An entry the library can actually insert. */
|
|
66
|
+
function isInsertable(value: unknown): value is InsertableImage {
|
|
67
|
+
if (typeof value !== 'object' || value === null) return false
|
|
68
|
+
const source = (value as { source?: unknown }).source
|
|
69
|
+
// `source` is deliberately `unknown` in the document model — a URI, a
|
|
70
|
+
// `{ uri }`, a Metro asset id — so the only thing worth asserting is that the
|
|
71
|
+
// host gave us *something* to resolve.
|
|
72
|
+
return source !== undefined && source !== null && source !== ''
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A dense list of insertable images, or `null` if the result is unusable.
|
|
77
|
+
*
|
|
78
|
+
* Every index is checked with `in` rather than by iterating, because a sparse
|
|
79
|
+
* array **skips its holes**: `new Array(3).every(fn)` is `true`, so a host
|
|
80
|
+
* returning one would pass validation and go on to produce sparse layers and a
|
|
81
|
+
* command list with `undefined` in it — dispatching `history/beginBatch` and
|
|
82
|
+
* then failing part-way, leaving a batch open.
|
|
83
|
+
*
|
|
84
|
+
* Copied into a dense array before anything is constructed, so nothing
|
|
85
|
+
* downstream has to think about holes again.
|
|
86
|
+
*/
|
|
87
|
+
function toDenseImages(value: unknown): InsertableImage[] | null {
|
|
88
|
+
if (!Array.isArray(value)) return null
|
|
89
|
+
const dense: InsertableImage[] = []
|
|
90
|
+
for (let index = 0; index < value.length; index++) {
|
|
91
|
+
if (!(index in value)) return null
|
|
92
|
+
const entry = value[index]
|
|
93
|
+
if (!isInsertable(entry)) return null
|
|
94
|
+
dense.push(entry)
|
|
95
|
+
}
|
|
96
|
+
return dense
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A command the reducer could actually accept. */
|
|
100
|
+
function isDispatchable(command: unknown): boolean {
|
|
101
|
+
return (
|
|
102
|
+
typeof command === 'object' &&
|
|
103
|
+
command !== null &&
|
|
104
|
+
typeof (command as { type?: unknown }).type === 'string'
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The host resolved with something that is not a list of insertable images.
|
|
110
|
+
*
|
|
111
|
+
* Reported rather than coerced: guessing at a malformed result would insert
|
|
112
|
+
* layers the host did not mean to add.
|
|
113
|
+
*
|
|
114
|
+
* @experimental
|
|
115
|
+
*/
|
|
116
|
+
export class InvalidImageResultError extends Error {
|
|
117
|
+
constructor() {
|
|
118
|
+
super('requestImages resolved with something that is not a list of images')
|
|
119
|
+
this.name = 'InvalidImageResultError'
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Wire a host's picker to the core insertion helpers.
|
|
125
|
+
*
|
|
126
|
+
* Acquisition stays separate from insertion, so a host that wants neither this
|
|
127
|
+
* controller nor the default panel can call `createImageLayers` and
|
|
128
|
+
* `insertImageCommands` itself.
|
|
129
|
+
*
|
|
130
|
+
* @experimental
|
|
131
|
+
*/
|
|
132
|
+
export function createAddPhotoController({
|
|
133
|
+
requestImages,
|
|
134
|
+
dispatch,
|
|
135
|
+
select,
|
|
136
|
+
onError,
|
|
137
|
+
insert,
|
|
138
|
+
}: AddPhotoControllerOptions): AddPhotoController {
|
|
139
|
+
let pending = false
|
|
140
|
+
let disposed = false
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
isPending: () => pending,
|
|
144
|
+
isAvailable: () => typeof requestImages === 'function',
|
|
145
|
+
dispose: () => {
|
|
146
|
+
disposed = true
|
|
147
|
+
},
|
|
148
|
+
async request() {
|
|
149
|
+
// A second press while the picker is open must not open a second picker,
|
|
150
|
+
// and must not ask the host twice for one intent.
|
|
151
|
+
if (!requestImages || pending || disposed) return
|
|
152
|
+
pending = true
|
|
153
|
+
try {
|
|
154
|
+
const result = await requestImages()
|
|
155
|
+
// Disposal is checked *first*, before the result is even looked at.
|
|
156
|
+
// Once the panel is gone the user has moved on, so nothing that comes
|
|
157
|
+
// back matters — not a success, not a malformed result, and not an
|
|
158
|
+
// error worth interrupting them about.
|
|
159
|
+
if (disposed) return
|
|
160
|
+
// Cancellation. Not an error, and nothing to do.
|
|
161
|
+
if (result === null || result === undefined) return
|
|
162
|
+
|
|
163
|
+
const images = toDenseImages(result)
|
|
164
|
+
if (!images) {
|
|
165
|
+
onError?.(new InvalidImageResultError())
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
if (images.length === 0) return
|
|
169
|
+
|
|
170
|
+
// Everything is built and checked before the *first* dispatch, so a
|
|
171
|
+
// problem discovered late cannot leave a `beginBatch` open with no
|
|
172
|
+
// matching end.
|
|
173
|
+
const layers = createImageLayers(images, insert)
|
|
174
|
+
const commands = insertImageCommands(layers, insert)
|
|
175
|
+
if (!commands.every(isDispatchable)) {
|
|
176
|
+
onError?.(new InvalidImageResultError())
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const command of commands) dispatch(command)
|
|
181
|
+
// Only the last — the topmost photo, and the one the user ends up
|
|
182
|
+
// looking at. Selecting all of them would invite a bulk edit that is
|
|
183
|
+
// not designed.
|
|
184
|
+
const last = layers[layers.length - 1]
|
|
185
|
+
if (last) select([last.id])
|
|
186
|
+
} catch (error) {
|
|
187
|
+
// Same rule as a late success: after disposal, silence.
|
|
188
|
+
if (!disposed) onError?.(error)
|
|
189
|
+
} finally {
|
|
190
|
+
// Cleared even after a failure, so reopening — or simply pressing
|
|
191
|
+
// again — can try once more.
|
|
192
|
+
pending = false
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type Rect, type Size } from '@pictor-sdk/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure crop math for the Crop tool (unit-tested in Node).
|
|
5
|
+
*
|
|
6
|
+
* Aspect selection: the document canvas takes the chosen aspect (constant
|
|
7
|
+
* width) and the image layer gets a maximal centred crop matching it.
|
|
8
|
+
*/
|
|
9
|
+
export function canvasSizeForAspect(current: Size, aspect: number | 'free'): Size {
|
|
10
|
+
if (aspect === 'free') return current
|
|
11
|
+
return { width: current.width, height: Math.round(current.width / aspect) }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Maximal centred crop (normalized within the source) with the given aspect. */
|
|
15
|
+
export function centeredCropForAspect(intrinsic: Size, aspect: number): Rect {
|
|
16
|
+
const sourceAspect = intrinsic.width / intrinsic.height
|
|
17
|
+
if (sourceAspect > aspect) {
|
|
18
|
+
// source is wider → crop the sides
|
|
19
|
+
const width = aspect / sourceAspect
|
|
20
|
+
return { x: (1 - width) / 2, y: 0, width, height: 1 }
|
|
21
|
+
}
|
|
22
|
+
// source is taller → crop top/bottom
|
|
23
|
+
const height = sourceAspect / aspect
|
|
24
|
+
return { x: 0, y: (1 - height) / 2, width: 1, height }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The straighten dial's range: ±45°, in radians. */
|
|
28
|
+
export const STRAIGHTEN_LIMIT = (45 * Math.PI) / 180
|
|
29
|
+
|
|
30
|
+
/** Straighten dial value (-1..1) ↔ radians (±45°). */
|
|
31
|
+
export function straightenToRadians(value: number): number {
|
|
32
|
+
return Math.max(-1, Math.min(1, value)) * STRAIGHTEN_LIMIT
|
|
33
|
+
}
|
|
34
|
+
/** Radians to dial value (-1..1), clamped to the limit. */
|
|
35
|
+
export function radiansToStraighten(rad: number): number {
|
|
36
|
+
return Math.max(-1, Math.min(1, rad / STRAIGHTEN_LIMIT))
|
|
37
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type Size, type Transform } from '@pictor-sdk/core'
|
|
2
|
+
|
|
3
|
+
/** The bundled multi-photo arrangements, by cell count and split direction. */
|
|
4
|
+
export type LayoutPreset = '1' | '2v' | '2h' | '3v' | '4grid'
|
|
5
|
+
|
|
6
|
+
/** One slot in a layout: where its photo sits, in normalized document units. */
|
|
7
|
+
export interface LayoutCell {
|
|
8
|
+
/** centre + scale in normalized document units */
|
|
9
|
+
x: number
|
|
10
|
+
y: number
|
|
11
|
+
/** width of the cell as a fraction of document width */
|
|
12
|
+
scale: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Story layout grids: cell centres and scales for placing the first N image
|
|
17
|
+
* layers. Pure — unit tested. Cells are defined as document fractions so any
|
|
18
|
+
* canvas size works.
|
|
19
|
+
*/
|
|
20
|
+
export function layoutCells(preset: LayoutPreset, _doc: Size): LayoutCell[] {
|
|
21
|
+
switch (preset) {
|
|
22
|
+
case '1':
|
|
23
|
+
return [{ x: 0.5, y: 0.5, scale: 1 }]
|
|
24
|
+
case '2v': // two rows
|
|
25
|
+
return [
|
|
26
|
+
{ x: 0.5, y: 0.25, scale: 1 },
|
|
27
|
+
{ x: 0.5, y: 0.75, scale: 1 },
|
|
28
|
+
]
|
|
29
|
+
case '2h': // two columns
|
|
30
|
+
return [
|
|
31
|
+
{ x: 0.25, y: 0.5, scale: 0.5 },
|
|
32
|
+
{ x: 0.75, y: 0.5, scale: 0.5 },
|
|
33
|
+
]
|
|
34
|
+
case '3v':
|
|
35
|
+
return [
|
|
36
|
+
{ x: 0.5, y: 1 / 6, scale: 1 },
|
|
37
|
+
{ x: 0.5, y: 0.5, scale: 1 },
|
|
38
|
+
{ x: 0.5, y: 5 / 6, scale: 1 },
|
|
39
|
+
]
|
|
40
|
+
case '4grid':
|
|
41
|
+
return [
|
|
42
|
+
{ x: 0.25, y: 0.25, scale: 0.5 },
|
|
43
|
+
{ x: 0.75, y: 0.25, scale: 0.5 },
|
|
44
|
+
{ x: 0.25, y: 0.75, scale: 0.5 },
|
|
45
|
+
{ x: 0.75, y: 0.75, scale: 0.5 },
|
|
46
|
+
]
|
|
47
|
+
default: {
|
|
48
|
+
const _exhaustive: never = preset
|
|
49
|
+
void _exhaustive
|
|
50
|
+
return [{ x: 0.5, y: 0.5, scale: 1 }]
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The transform patch that moves a layer into a cell, leaving its other fields alone. */
|
|
56
|
+
export function cellToTransformPatch(cell: LayoutCell): Partial<Transform> {
|
|
57
|
+
return { x: cell.x, y: cell.y, scale: cell.scale, rotation: 0 }
|
|
58
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {
|
|
2
|
+
pinnedTargetImageLayer,
|
|
3
|
+
resolveTargetImageLayer,
|
|
4
|
+
useEditor,
|
|
5
|
+
type Layer,
|
|
6
|
+
} from '@pictor-sdk/core'
|
|
7
|
+
import { useRef } from 'react'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The image an open panel edits, **pinned for the whole session**.
|
|
11
|
+
*
|
|
12
|
+
* Resolved once, from the selection at the moment the panel mounts, and then
|
|
13
|
+
* held by id. `ToolPanelHost` mounts a fresh panel per tool activation, so a
|
|
14
|
+
* mount *is* a session, and closing and reopening resolves again.
|
|
15
|
+
*
|
|
16
|
+
* The pinning is the point. Re-resolving each render would let a selection
|
|
17
|
+
* change mid-drag redirect the next slider movement to a different photo — an
|
|
18
|
+
* edit landing silently on the wrong image, which is worse than an edit that
|
|
19
|
+
* does nothing. So when the target is deleted, locked or hidden mid-session
|
|
20
|
+
* this returns `null` and the panel disables its controls; it never quietly
|
|
21
|
+
* falls back to the base.
|
|
22
|
+
*
|
|
23
|
+
* `useBaseImageLayer` is unchanged and still means *the base photo*. Use it
|
|
24
|
+
* where a capability is genuinely base-only — Story media layout, the derived
|
|
25
|
+
* background — rather than by inertia.
|
|
26
|
+
*
|
|
27
|
+
* @experimental
|
|
28
|
+
*/
|
|
29
|
+
export function useTargetImageLayer(): Layer | null {
|
|
30
|
+
const { document, selection } = useEditor()
|
|
31
|
+
const pinned = useRef<string | null>(null)
|
|
32
|
+
|
|
33
|
+
// Resolved on the first render of this mount and never again. A ref rather
|
|
34
|
+
// than an effect: an effect would leave the first render targeting nothing,
|
|
35
|
+
// and a panel that renders once with no target flashes disabled controls.
|
|
36
|
+
if (pinned.current === null) {
|
|
37
|
+
pinned.current = resolveTargetImageLayer(document, selection)?.id ?? null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return pinnedTargetImageLayer(document, pinned.current) ?? null
|
|
41
|
+
}
|