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