@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,58 @@
1
+ import { useEditor, useTools } from '@pictor-sdk/core'
2
+ import React from 'react'
3
+ import { Pressable, ScrollView, Text, View } from 'react-native'
4
+ import { useTheme } from '../theme'
5
+
6
+ /** The toolbar IS profile.tools — rendered from the registry, never hardcoded. */
7
+ export function ToolRail() {
8
+ const { activeTool, setActiveTool, document, selection, dispatch } = useEditor()
9
+ const tools = useTools()
10
+ const theme = useTheme()
11
+ return (
12
+ <ScrollView
13
+ horizontal
14
+ showsHorizontalScrollIndicator={false}
15
+ contentContainerStyle={{
16
+ gap: theme.spacing.md,
17
+ paddingHorizontal: theme.spacing.md,
18
+ paddingVertical: theme.spacing.md,
19
+ }}
20
+ style={{ flexGrow: 0, backgroundColor: theme.colors.background }}
21
+ >
22
+ {tools.map((tool) => {
23
+ const enabled = tool.isEnabled ? tool.isEnabled({ document, selection }) : true
24
+ const active = activeTool === tool.id
25
+ return (
26
+ <Pressable
27
+ key={tool.id}
28
+ disabled={!enabled}
29
+ onPress={() => {
30
+ if (tool.onActivate) {
31
+ tool.onActivate({ document, dispatch })
32
+ } else {
33
+ setActiveTool(active ? null : tool.id)
34
+ }
35
+ }}
36
+ style={{ width: 72, alignItems: 'center', gap: 6, opacity: enabled ? 1 : 0.4 }}
37
+ >
38
+ <View style={{ width: 46, height: 46, borderRadius: 23, borderWidth: 1, borderColor: active ? theme.colors.accent : theme.colors.textMuted, backgroundColor: active ? theme.colors.surface : 'transparent', alignItems: 'center', justifyContent: 'center' }}>
39
+ <Text style={{ color: active ? theme.colors.accent : theme.colors.text, fontSize: 18 }}>
40
+ {tool.label.slice(0, 1)}
41
+ </Text>
42
+ </View>
43
+ <Text
44
+ style={{
45
+ color: active ? theme.colors.accent : theme.colors.text,
46
+ fontSize: theme.font.sizeSm,
47
+ fontWeight: theme.font.weight,
48
+ }}
49
+ numberOfLines={1}
50
+ >
51
+ {tool.label}
52
+ </Text>
53
+ </Pressable>
54
+ )
55
+ })}
56
+ </ScrollView>
57
+ )
58
+ }
@@ -0,0 +1,53 @@
1
+ import { useEditor } from '@pictor-sdk/core'
2
+ import React from 'react'
3
+ import { ActivityIndicator, Pressable, Text, View } from 'react-native'
4
+ import { useTheme } from '../theme'
5
+
6
+ /** What the top bar slot receives: the transaction actions and undo state. */
7
+ export interface TopBarProps {
8
+ onCancel?: () => void
9
+ onNext?: () => void
10
+ /** true while an export is running (Next shows a spinner). */
11
+ busy?: boolean
12
+ }
13
+
14
+ /** The default top bar: cancel, undo/redo, and the action that ends the session. */
15
+ export function TopBar({ onCancel, onNext, busy = false }: TopBarProps) {
16
+ const { canUndo, canRedo, dispatch } = useEditor()
17
+ const theme = useTheme()
18
+ const button = (label: string, enabled: boolean, onPress?: () => void, color?: string) => (
19
+ <Pressable disabled={!enabled} onPress={onPress} style={{ padding: theme.spacing.sm }}>
20
+ <Text
21
+ style={{
22
+ color: enabled ? (color ?? theme.colors.text) : theme.colors.textMuted,
23
+ fontSize: theme.font.size,
24
+ fontWeight: theme.font.weight,
25
+ }}
26
+ >
27
+ {label}
28
+ </Text>
29
+ </Pressable>
30
+ )
31
+ return (
32
+ <View
33
+ style={{
34
+ flexDirection: 'row',
35
+ alignItems: 'center',
36
+ justifyContent: 'space-between',
37
+ paddingHorizontal: theme.spacing.sm,
38
+ backgroundColor: theme.colors.background,
39
+ }}
40
+ >
41
+ {button('Cancel', !!onCancel, onCancel)}
42
+ <View style={{ flexDirection: 'row' }}>
43
+ {button('↺', canUndo, () => dispatch({ type: 'history/undo' }))}
44
+ {button('↻', canRedo, () => dispatch({ type: 'history/redo' }))}
45
+ </View>
46
+ {busy ? (
47
+ <ActivityIndicator color={theme.colors.accent} style={{ padding: theme.spacing.sm }} />
48
+ ) : (
49
+ button('Next', !!onNext, onNext, theme.colors.accent)
50
+ )}
51
+ </View>
52
+ )
53
+ }
@@ -0,0 +1,540 @@
1
+ import {
2
+ MAX_TEXT_SIZE,
3
+ MIN_TEXT_SIZE,
4
+ fontChoices,
5
+ useEditor,
6
+ useEditorStore,
7
+ type TextLayerData,
8
+ } from '@pictor-sdk/core'
9
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
10
+ import {
11
+ BackHandler,
12
+ Keyboard,
13
+ Platform,
14
+ Pressable,
15
+ ScrollView,
16
+ Text,
17
+ TextInput,
18
+ View,
19
+ type TextInputSelectionChangeEventData,
20
+ type NativeSyntheticEvent,
21
+ } from 'react-native'
22
+ import { Slider } from '../components/Slider'
23
+ import { useTheme } from '../theme'
24
+ import {
25
+ composerInsets,
26
+ fitCanvasRect,
27
+ HIDDEN_KEYBOARD,
28
+ type ComposerInsets,
29
+ } from './keyboardLayout'
30
+ import { useTextSession, type TextSessionController } from './useTextSession'
31
+
32
+ const SWATCHES = ['#ffffff', '#000000', '#ed4956', '#ffe25c', '#78de85', '#4dc4ff', '#a15fed']
33
+ const ALIGNS = ['left', 'center', 'right'] as const
34
+ const ALIGN_GLYPH = { left: '⇤', center: '☰', right: '⇥' } as const
35
+ const FILL_MODES = [undefined, 'solid', 'perLine'] as const
36
+ /** Normalized stroke widths: off, hairline, bold. All fractions of doc width. */
37
+ const STROKE_WIDTHS = [undefined, 0.002, 0.005] as const
38
+ /** Normalized shadow presets: off, soft, hard. */
39
+ const SHADOWS = [
40
+ undefined,
41
+ { color: '#000000', blur: 0.012, dx: 0.002, dy: 0.004 },
42
+ { color: '#000000', blur: 0.002, dx: 0.006, dy: 0.008 },
43
+ ] as const
44
+
45
+ /**
46
+ * Props a text composer receives.
47
+ *
48
+ * The default composer is built on exactly this, so a replacement has
49
+ * everything the built-in one had — no privileged access to reach for.
50
+ *
51
+ * @experimental
52
+ */
53
+ export interface TextComposerProps {
54
+ /** The live session: layer, value, style commands, Done and Cancel. */
55
+ session: TextSessionController
56
+ /** Where the controls should sit, given the keyboard. */
57
+ insets: ComposerInsets
58
+ /** Finish and keep the text. */
59
+ onDone(): void
60
+ /** Abandon, restoring the document. */
61
+ onCancel(): void
62
+ }
63
+
64
+ /**
65
+ * The full-screen Story text composer.
66
+ *
67
+ * An absolute overlay above the canvas. The canvas keeps rendering the layer
68
+ * being typed — there is no second text renderer and no editing chrome inside
69
+ * `renderDocument` — so what is on screen while composing is what exports.
70
+ *
71
+ * The `TextInput` itself is invisible and stretched over the canvas: it owns
72
+ * the caret, the platform selection handles and the IME, while the *visible*
73
+ * text is the real Skia layer underneath. That is what keeps preview and export
74
+ * identical without giving up native text editing.
75
+ *
76
+ * @experimental
77
+ */
78
+ export function TextComposer({ session, insets, onDone, onCancel }: TextComposerProps) {
79
+ const theme = useTheme()
80
+ const store = useEditorStore()
81
+ const { document } = useEditor()
82
+ // Measured rather than taken from `useCanvasMetrics`: that context belongs to
83
+ // EditorCanvas's children, and the composer is its sibling.
84
+ const [container, setContainer] = useState({ width: 0, height: 0 })
85
+ const input = useRef<TextInput>(null)
86
+ const [selectionRange, setSelectionRange] = useState<{ start: number; end: number } | undefined>(
87
+ undefined,
88
+ )
89
+
90
+ const data = session.data
91
+ const span = session.span
92
+
93
+ /**
94
+ * Lay the input out over the text it is editing.
95
+ *
96
+ * The visible glyphs are the Skia layer; this input is transparent and only
97
+ * carries the caret, the selection handles and the IME. For the caret to land
98
+ * where the user sees their text, the input's own layout has to match the
99
+ * layer's: same font size in dp, same alignment, and a box centred on the
100
+ * layer's normalized y. It will never be glyph-exact — Skia and the platform
101
+ * measure differently — but it tracks closely enough to edit by.
102
+ */
103
+ const layout = useMemo(() => {
104
+ const canvas = fitCanvasRect(container, document.size)
105
+ const fontSize = (span?.fontSize ?? 0.08) * canvas.width
106
+ const centreY = canvas.top + (session.layer?.transform.y ?? 0.42) * canvas.height
107
+ const height = canvas.height
108
+ return {
109
+ fontSize: Math.max(1, fontSize),
110
+ lineHeight: Math.max(1, fontSize * (span?.lineHeight ?? 1.2)),
111
+ left: canvas.left,
112
+ width: Math.max(1, canvas.width),
113
+ top: Math.max(0, centreY - height / 2),
114
+ height: Math.max(1, height),
115
+ }
116
+ }, [container, document.size, span?.fontSize, span?.lineHeight, session.layer?.transform.y])
117
+ const fonts = useMemo(() => fontChoices(store.registry.fonts), [store.registry.fonts])
118
+
119
+ // Focus once the overlay is up. A frame's delay lets the mount settle so the
120
+ // keyboard animates in rather than appearing half-drawn.
121
+ useEffect(() => {
122
+ const handle = setTimeout(() => input.current?.focus(), 50)
123
+ return () => clearTimeout(handle)
124
+ }, [])
125
+
126
+ const onSelectionChange = useCallback(
127
+ (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
128
+ setSelectionRange(event.nativeEvent.selection)
129
+ },
130
+ [],
131
+ )
132
+
133
+ /**
134
+ * Apply a style without disturbing the caret.
135
+ *
136
+ * Style controls are outside the input, so tapping one would ordinarily blur
137
+ * it and collapse the selection. Re-focusing and restoring the range keeps
138
+ * the user where they were — the difference between styling a selection and
139
+ * losing it.
140
+ */
141
+ const keepFocus = useCallback(
142
+ (apply: () => void) => {
143
+ apply()
144
+ if (!input.current?.isFocused()) input.current?.focus()
145
+ if (selectionRange) {
146
+ const range = selectionRange
147
+ requestAnimationFrame(() => input.current?.setNativeProps({ selection: range }))
148
+ }
149
+ },
150
+ [selectionRange],
151
+ )
152
+
153
+ const cycleAlign = () => {
154
+ if (!data) return
155
+ const next = ALIGNS[(ALIGNS.indexOf(data.align) + 1) % ALIGNS.length]!
156
+ keepFocus(() => session.styleText({ align: next }))
157
+ }
158
+
159
+ const cycleFill = () => {
160
+ if (!data || !span) return
161
+ const index = FILL_MODES.indexOf(data.fill?.mode as (typeof FILL_MODES)[number])
162
+ const next = FILL_MODES[(index + 1) % FILL_MODES.length]
163
+ keepFocus(() =>
164
+ session.styleText({
165
+ fill: next
166
+ ? {
167
+ color: span.color === '#000000' ? '#ffffff' : '#000000',
168
+ radius: 0.012,
169
+ padding: 0.012,
170
+ mode: next,
171
+ }
172
+ : undefined,
173
+ }),
174
+ )
175
+ }
176
+
177
+ /**
178
+ * Cycle the outline. Widths are normalized fractions of document width, like
179
+ * every other length in the document, so they scale with the export.
180
+ */
181
+ const cycleStroke = () => {
182
+ if (!data || !span) return
183
+ const current = data.stroke?.width
184
+ const index = STROKE_WIDTHS.findIndex((w) => w === current)
185
+ const next = STROKE_WIDTHS[(index + 1) % STROKE_WIDTHS.length]
186
+ keepFocus(() =>
187
+ session.styleText({
188
+ stroke: next
189
+ ? { color: span.color === '#000000' ? '#ffffff' : '#000000', width: next }
190
+ : undefined,
191
+ }),
192
+ )
193
+ }
194
+
195
+ /** Cycle the drop shadow: off, soft, hard. */
196
+ const cycleShadow = () => {
197
+ if (!data) return
198
+ const current = data.shadow
199
+ const index = current
200
+ ? SHADOWS.findIndex((s) => s && current && s.blur === current.blur && s.dx === current.dx)
201
+ : 0
202
+ const next = SHADOWS[(index + 1) % SHADOWS.length]
203
+ keepFocus(() => session.styleText({ shadow: next ? { ...next } : undefined }))
204
+ }
205
+
206
+ // Hardware back dismisses the keyboard first, and only closes the session on
207
+ // a second press — so a stray back does not discard what was typed.
208
+ useEffect(() => {
209
+ const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
210
+ if (Keyboard.isVisible?.() ?? insets.keyboardVisible) {
211
+ Keyboard.dismiss()
212
+ return true
213
+ }
214
+ onCancel()
215
+ return true
216
+ })
217
+ return () => subscription.remove()
218
+ }, [onCancel, insets.keyboardVisible])
219
+
220
+ const controlRow = {
221
+ flexDirection: 'row' as const,
222
+ alignItems: 'center' as const,
223
+ gap: theme.spacing.sm,
224
+ paddingHorizontal: theme.spacing.md,
225
+ }
226
+
227
+ return (
228
+ <View
229
+ style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 }}
230
+ onLayout={(event) => {
231
+ const { width, height } = event.nativeEvent.layout
232
+ setContainer((current) =>
233
+ current.width === width && current.height === height ? current : { width, height },
234
+ )
235
+ }}
236
+ >
237
+ {/* Subdues the canvas so the text being typed reads clearly. Preview-only
238
+ React Native UI — never part of renderDocument, so it cannot export. */}
239
+ <View
240
+ pointerEvents="none"
241
+ style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, backgroundColor: '#00000055' }}
242
+ />
243
+
244
+ {/* The invisible editing surface. The visible glyphs are the Skia layer
245
+ underneath; this owns the caret, selection handles and IME. */}
246
+ <TextInput
247
+ ref={input}
248
+ value={session.value}
249
+ onChangeText={session.setValue}
250
+ onSelectionChange={onSelectionChange}
251
+ multiline
252
+ // Enter inserts a newline. Without this, a submit blurs the input and
253
+ // the session ends on the first line break the user types.
254
+ submitBehavior="newline"
255
+ // Android draws IME composing text itself, ignoring the transparent
256
+ // colour below, which ghosts the input's glyphs beside the Skia ones.
257
+ // Correction, spell-check and autofill are off to keep the composing
258
+ // region as small as possible.
259
+ //
260
+ // DECISION: `keyboardType="visible-password"` suppresses composing
261
+ // entirely and removes the ghost completely — but it also strips the
262
+ // emoji key and language switching from the keyboard, which this
263
+ // milestone needs for emoji and Bengali. A faint composing underline on
264
+ // the word being typed is the better trade.
265
+ autoCorrect={false}
266
+ spellCheck={false}
267
+ autoComplete="off"
268
+ importantForAutofill="no"
269
+ accessibilityLabel="Story text"
270
+ placeholder=""
271
+ style={{
272
+ position: 'absolute',
273
+ left: layout.left,
274
+ width: layout.width,
275
+ top: layout.top,
276
+ height: layout.height,
277
+ // Fully transparent glyphs. The Skia layer is what the user reads;
278
+ // drawing the same text twice would double it on screen. Written as
279
+ // rgba rather than the 'transparent' keyword because the keyword is
280
+ // not reliably honoured for TextInput colour on Android.
281
+ color: 'rgba(0,0,0,0)',
282
+ fontSize: layout.fontSize,
283
+ lineHeight: layout.lineHeight,
284
+ textAlign: data?.align ?? 'center',
285
+ ...(Platform.OS === 'ios' ? {} : { textAlignVertical: 'center' as const }),
286
+ padding: 0,
287
+ }}
288
+ // The caret and the selection highlight must stay visible even though
289
+ // the glyphs are not.
290
+ selectionColor={theme.colors.accent}
291
+ cursorColor={theme.colors.accent}
292
+ />
293
+
294
+ {/* Top bar: Cancel / Done, above the keyboard-independent area. */}
295
+ <View
296
+ style={{
297
+ position: 'absolute',
298
+ left: 0,
299
+ right: 0,
300
+ top: 0,
301
+ height: 56,
302
+ flexDirection: 'row',
303
+ alignItems: 'center',
304
+ justifyContent: 'space-between',
305
+ paddingHorizontal: theme.spacing.lg,
306
+ }}
307
+ >
308
+ <Pressable accessibilityRole="button" accessibilityLabel="Cancel text" onPress={onCancel}>
309
+ <Text style={{ color: theme.colors.text, fontSize: theme.font.size }}>Cancel</Text>
310
+ </Pressable>
311
+ <Pressable accessibilityRole="button" accessibilityLabel="Done editing text" onPress={onDone}>
312
+ <Text
313
+ style={{
314
+ color: theme.colors.accent,
315
+ fontSize: theme.font.size,
316
+ fontWeight: theme.font.weight,
317
+ }}
318
+ >
319
+ Done
320
+ </Text>
321
+ </Pressable>
322
+ </View>
323
+
324
+ {/* Controls ride the keyboard. The canvas never moves. */}
325
+ <View
326
+ style={{
327
+ position: 'absolute',
328
+ left: 0,
329
+ right: 0,
330
+ bottom: insets.bottom,
331
+ paddingVertical: theme.spacing.sm,
332
+ gap: theme.spacing.sm,
333
+ backgroundColor: '#00000066',
334
+ }}
335
+ >
336
+ <View style={controlRow}>
337
+ <Text
338
+ style={{ color: theme.colors.textMuted, fontSize: theme.font.sizeSm, width: 34 }}
339
+ accessibilityLabel="Text size"
340
+ >
341
+ Aa
342
+ </Text>
343
+ <View style={{ flex: 1 }}>
344
+ <Slider
345
+ value={span?.fontSize ?? MIN_TEXT_SIZE}
346
+ min={MIN_TEXT_SIZE}
347
+ max={MAX_TEXT_SIZE}
348
+ centered={false}
349
+ onChange={(size) => session.setSize(size)}
350
+ />
351
+ </View>
352
+ <Pressable
353
+ accessibilityRole="button"
354
+ accessibilityLabel={`Alignment ${data?.align ?? 'center'}`}
355
+ onPress={cycleAlign}
356
+ style={{ padding: theme.spacing.sm }}
357
+ >
358
+ <Text style={{ color: theme.colors.text, fontWeight: theme.font.weight }}>
359
+ {ALIGN_GLYPH[data?.align ?? 'center']}
360
+ </Text>
361
+ </Pressable>
362
+ <Pressable
363
+ accessibilityRole="button"
364
+ accessibilityLabel="Text background"
365
+ accessibilityState={{ selected: Boolean(data?.fill) }}
366
+ onPress={cycleFill}
367
+ style={{ padding: theme.spacing.sm }}
368
+ >
369
+ <Text
370
+ style={{
371
+ color: data?.fill ? theme.colors.accent : theme.colors.text,
372
+ fontWeight: theme.font.weight,
373
+ }}
374
+ >
375
+ {data?.fill?.mode === 'perLine' ? 'A⃞⃞' : 'A⃞'}
376
+ </Text>
377
+ </Pressable>
378
+ <Pressable
379
+ accessibilityRole="button"
380
+ accessibilityLabel="Text outline"
381
+ accessibilityState={{ selected: Boolean(data?.stroke) }}
382
+ onPress={cycleStroke}
383
+ style={{ padding: theme.spacing.sm }}
384
+ >
385
+ <Text
386
+ style={{
387
+ color: data?.stroke ? theme.colors.accent : theme.colors.text,
388
+ fontWeight: theme.font.weight,
389
+ }}
390
+ >
391
+ Ⓐ
392
+ </Text>
393
+ </Pressable>
394
+ <Pressable
395
+ accessibilityRole="button"
396
+ accessibilityLabel="Text shadow"
397
+ accessibilityState={{ selected: Boolean(data?.shadow) }}
398
+ onPress={cycleShadow}
399
+ style={{ padding: theme.spacing.sm }}
400
+ >
401
+ <Text
402
+ style={{
403
+ color: data?.shadow ? theme.colors.accent : theme.colors.text,
404
+ fontWeight: theme.font.weight,
405
+ }}
406
+ >
407
+ A˙
408
+ </Text>
409
+ </Pressable>
410
+ </View>
411
+
412
+ <ScrollView
413
+ horizontal
414
+ keyboardShouldPersistTaps="always"
415
+ showsHorizontalScrollIndicator={false}
416
+ contentContainerStyle={{ gap: theme.spacing.sm, paddingHorizontal: theme.spacing.md, alignItems: 'center' }}
417
+ style={{ flexGrow: 0 }}
418
+ >
419
+ {fonts.map((font) => (
420
+ <Pressable
421
+ key={font.family}
422
+ accessibilityRole="button"
423
+ accessibilityLabel={`Font ${font.label}`}
424
+ accessibilityState={{ selected: span?.fontFamily === font.family }}
425
+ onPress={() => keepFocus(() => session.styleSpan({ fontFamily: font.family }))}
426
+ style={{
427
+ paddingHorizontal: theme.spacing.md,
428
+ paddingVertical: theme.spacing.xs,
429
+ borderRadius: theme.radii.md,
430
+ backgroundColor:
431
+ span?.fontFamily === font.family ? theme.colors.surface : 'transparent',
432
+ borderWidth: 1,
433
+ borderColor:
434
+ span?.fontFamily === font.family ? theme.colors.accent : theme.colors.track,
435
+ }}
436
+ >
437
+ <Text style={{ color: theme.colors.text, fontSize: theme.font.sizeSm }}>
438
+ {font.label}
439
+ </Text>
440
+ </Pressable>
441
+ ))}
442
+ </ScrollView>
443
+
444
+ <ScrollView
445
+ horizontal
446
+ keyboardShouldPersistTaps="always"
447
+ showsHorizontalScrollIndicator={false}
448
+ contentContainerStyle={{ gap: theme.spacing.sm, paddingHorizontal: theme.spacing.md, alignItems: 'center' }}
449
+ style={{ flexGrow: 0 }}
450
+ >
451
+ {SWATCHES.map((color) => (
452
+ <Pressable
453
+ key={color}
454
+ accessibilityRole="button"
455
+ accessibilityLabel={`Text colour ${color}`}
456
+ accessibilityState={{ selected: span?.color === color }}
457
+ onPress={() => keepFocus(() => session.styleSpan({ color }))}
458
+ style={{
459
+ width: 28,
460
+ height: 28,
461
+ borderRadius: 14,
462
+ backgroundColor: color,
463
+ borderWidth: span?.color === color ? 3 : 1,
464
+ borderColor: span?.color === color ? theme.colors.accent : theme.colors.track,
465
+ }}
466
+ />
467
+ ))}
468
+ </ScrollView>
469
+ </View>
470
+ </View>
471
+ )
472
+ }
473
+
474
+ /**
475
+ * Mounts a text session and the composer for it, and keeps the keyboard inset
476
+ * up to date. This is what `<Editor>` renders while the text tool is active.
477
+ *
478
+ * @experimental
479
+ */
480
+ export function TextComposerHost({
481
+ Composer = TextComposer,
482
+ }: {
483
+ /** Replace the default composer without replacing the canvas or editor. */
484
+ Composer?: React.ComponentType<TextComposerProps>
485
+ }) {
486
+ const { setActiveTool } = useEditor()
487
+ const session = useTextSession()
488
+ const [frame, setFrame] = useState(HIDDEN_KEYBOARD)
489
+
490
+ // A monotonic token so a keyboard event that arrives after this session has
491
+ // closed cannot resize a later one.
492
+ const token = useRef(0)
493
+ useEffect(() => {
494
+ token.current += 1
495
+ const mine = token.current
496
+ const show = Keyboard.addListener(
497
+ Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow',
498
+ (event) => {
499
+ if (mine !== token.current) return
500
+ setFrame({ height: event.endCoordinates?.height ?? 0, duration: event.duration })
501
+ },
502
+ )
503
+ const hide = Keyboard.addListener(
504
+ Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide',
505
+ (event) => {
506
+ if (mine !== token.current) return
507
+ setFrame({ height: 0, duration: event?.duration })
508
+ },
509
+ )
510
+ return () => {
511
+ token.current += 1
512
+ show.remove()
513
+ hide.remove()
514
+ }
515
+ }, [])
516
+
517
+ const insets = useMemo(() => composerInsets(frame), [frame])
518
+
519
+ const finish = useCallback(
520
+ (commit: boolean) => {
521
+ if (commit) session.commit()
522
+ else session.cancel()
523
+ Keyboard.dismiss()
524
+ setActiveTool(null)
525
+ },
526
+ [session, setActiveTool],
527
+ )
528
+
529
+ return (
530
+ <Composer
531
+ session={session}
532
+ insets={insets}
533
+ onDone={() => finish(true)}
534
+ onCancel={() => finish(false)}
535
+ />
536
+ )
537
+ }
538
+
539
+ /** Narrow re-export so the composer's data type is reachable without deep imports. */
540
+ export type { TextLayerData }