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