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