@pictor-sdk/ui 0.3.0 → 0.4.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/dist/index.d.mts +29 -6
- package/dist/index.d.ts +29 -6
- package/dist/index.js +171 -15
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +171 -15
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
- package/src/components/Slider.tsx +3 -2
- package/src/composer/TextComposer.tsx +180 -9
- package/src/composer/keyboardLayout.ts +94 -1
- package/src/composer/tapGesture.ts +31 -0
- package/src/gestures/LayerGestureSurface.tsx +40 -4
- package/src/gestures/selectionTap.ts +22 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pictor-sdk/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Default slot-based editor UI for Pictor, built entirely on the public headless API.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"react-native-gesture-handler": ">=2.20.0",
|
|
34
34
|
"react-native-reanimated": ">=3.16.0",
|
|
35
35
|
"react-native-worklets": ">=0.5.1",
|
|
36
|
-
"@pictor-sdk/core": "^0.
|
|
37
|
-
"@pictor-sdk/skia": "^0.
|
|
36
|
+
"@pictor-sdk/core": "^0.3.0",
|
|
37
|
+
"@pictor-sdk/skia": "^0.4.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@shopify/react-native-skia": "^2.0.0",
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
"react-native-gesture-handler": "^2.28.0",
|
|
45
45
|
"react-native-reanimated": "^4.1.0",
|
|
46
46
|
"react-native-worklets": "0.5.1",
|
|
47
|
-
"@pictor-sdk/
|
|
48
|
-
"@pictor-sdk/
|
|
47
|
+
"@pictor-sdk/skia": "0.4.0",
|
|
48
|
+
"@pictor-sdk/core": "0.3.0"
|
|
49
49
|
},
|
|
50
50
|
"publishConfig": {
|
|
51
51
|
"access": "public",
|
|
@@ -60,8 +60,9 @@ export function Slider({
|
|
|
60
60
|
valueRef.current = value
|
|
61
61
|
|
|
62
62
|
const onLayout = (e: LayoutChangeEvent) => {
|
|
63
|
-
|
|
64
|
-
widthRef.current =
|
|
63
|
+
const next = e.nativeEvent.layout.width
|
|
64
|
+
widthRef.current = next
|
|
65
|
+
setWidth((previous) => (previous === next ? previous : next))
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
const handlers = useMemo(() => {
|
|
@@ -17,17 +17,25 @@ import {
|
|
|
17
17
|
TextInput,
|
|
18
18
|
View,
|
|
19
19
|
type TextInputSelectionChangeEventData,
|
|
20
|
+
type TextLayoutEventData,
|
|
21
|
+
type TextStyle,
|
|
20
22
|
type NativeSyntheticEvent,
|
|
21
23
|
} from 'react-native'
|
|
22
24
|
import { Slider } from '../components/Slider'
|
|
23
25
|
import { useTheme } from '../theme'
|
|
24
26
|
import { useRenderer } from '@pictor-sdk/skia'
|
|
25
27
|
import {
|
|
28
|
+
composerCaretBox,
|
|
26
29
|
composerInputBox,
|
|
30
|
+
composerInputOpacity,
|
|
27
31
|
composerInsets,
|
|
28
32
|
HIDDEN_KEYBOARD,
|
|
33
|
+
sameCaretLine,
|
|
34
|
+
type CanvasRect,
|
|
35
|
+
type CaretLine,
|
|
29
36
|
type ComposerInsets,
|
|
30
37
|
} from './keyboardLayout'
|
|
38
|
+
import { pressMoved } from './tapGesture'
|
|
31
39
|
import { useTextSession, type TextSessionController } from './useTextSession'
|
|
32
40
|
|
|
33
41
|
const SWATCHES = ['#ffffff', '#000000', '#ed4956', '#ffe25c', '#78de85', '#4dc4ff', '#a15fed']
|
|
@@ -60,6 +68,11 @@ export interface TextComposerProps {
|
|
|
60
68
|
onDone(): void
|
|
61
69
|
/** Abandon, restoring the document. */
|
|
62
70
|
onCancel(): void
|
|
71
|
+
/**
|
|
72
|
+
* The canvas rectangle in this composer's coordinates, when the host draws a
|
|
73
|
+
* cover canvas. Omitted, the composer assumes a centred contain fit.
|
|
74
|
+
*/
|
|
75
|
+
canvas?: CanvasRect
|
|
63
76
|
}
|
|
64
77
|
|
|
65
78
|
/**
|
|
@@ -70,13 +83,15 @@ export interface TextComposerProps {
|
|
|
70
83
|
* `renderDocument` — so what is on screen while composing is what exports.
|
|
71
84
|
*
|
|
72
85
|
* The `TextInput` itself is invisible and stretched over the canvas: it owns
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
86
|
+
* focus and the IME, while the *visible* text is the real Skia layer
|
|
87
|
+
* underneath. On Android the platform paints composing glyphs black regardless
|
|
88
|
+
* of the input's colour, so the input is fully hidden and a caret is drawn at
|
|
89
|
+
* the measured selection instead. That is what keeps preview and export
|
|
90
|
+
* identical without giving up text editing.
|
|
76
91
|
*
|
|
77
92
|
* @experimental
|
|
78
93
|
*/
|
|
79
|
-
export function TextComposer({ session, insets, onDone, onCancel }: TextComposerProps) {
|
|
94
|
+
export function TextComposer({ session, insets, onDone, onCancel, canvas }: TextComposerProps) {
|
|
80
95
|
const theme = useTheme()
|
|
81
96
|
const store = useEditorStore()
|
|
82
97
|
const { document } = useEditor()
|
|
@@ -88,6 +103,8 @@ export function TextComposer({ session, insets, onDone, onCancel }: TextComposer
|
|
|
88
103
|
const [selectionRange, setSelectionRange] = useState<{ start: number; end: number } | undefined>(
|
|
89
104
|
undefined,
|
|
90
105
|
)
|
|
106
|
+
const [focused, setFocused] = useState(false)
|
|
107
|
+
const [caretLine, setCaretLine] = useState<CaretLine | null>(null)
|
|
91
108
|
|
|
92
109
|
const data = session.data
|
|
93
110
|
const span = session.span
|
|
@@ -107,7 +124,14 @@ export function TextComposer({ session, insets, onDone, onCancel }: TextComposer
|
|
|
107
124
|
setMeasured(null)
|
|
108
125
|
return
|
|
109
126
|
}
|
|
110
|
-
const refresh = () =>
|
|
127
|
+
const refresh = () => {
|
|
128
|
+
const next = renderer.textMeasures.get(layerId) ?? null
|
|
129
|
+
setMeasured((previous) =>
|
|
130
|
+
previous && next && previous.width === next.width && previous.height === next.height
|
|
131
|
+
? previous
|
|
132
|
+
: next,
|
|
133
|
+
)
|
|
134
|
+
}
|
|
111
135
|
const unsubscribe = renderer.textMeasures.subscribe(refresh)
|
|
112
136
|
refresh()
|
|
113
137
|
return unsubscribe
|
|
@@ -133,6 +157,7 @@ export function TextComposer({ session, insets, onDone, onCancel }: TextComposer
|
|
|
133
157
|
centerY: session.layer?.transform.y ?? 0.42,
|
|
134
158
|
measured,
|
|
135
159
|
lines: session.value.split('\n').length,
|
|
160
|
+
...(canvas ? { canvas } : {}),
|
|
136
161
|
}),
|
|
137
162
|
[
|
|
138
163
|
container,
|
|
@@ -142,6 +167,7 @@ export function TextComposer({ session, insets, onDone, onCancel }: TextComposer
|
|
|
142
167
|
session.layer?.transform.y,
|
|
143
168
|
session.value,
|
|
144
169
|
measured,
|
|
170
|
+
canvas,
|
|
145
171
|
],
|
|
146
172
|
)
|
|
147
173
|
const fonts = useMemo(() => fontChoices(store.registry.fonts), [store.registry.fonts])
|
|
@@ -149,17 +175,68 @@ export function TextComposer({ session, insets, onDone, onCancel }: TextComposer
|
|
|
149
175
|
// Focus once the overlay is up. A frame's delay lets the mount settle so the
|
|
150
176
|
// keyboard animates in rather than appearing half-drawn.
|
|
151
177
|
useEffect(() => {
|
|
152
|
-
const handle = setTimeout(() =>
|
|
178
|
+
const handle = setTimeout(() => {
|
|
179
|
+
input.current?.focus()
|
|
180
|
+
// Opening an existing layer should put the caret after its text, not at
|
|
181
|
+
// the start, which is what an untouched native input defaults to.
|
|
182
|
+
if (session.kind === 'existing' && session.value.length > 0) {
|
|
183
|
+
const end = session.value.length
|
|
184
|
+
setSelectionRange({ start: end, end })
|
|
185
|
+
input.current?.setNativeProps({ selection: { start: end, end } })
|
|
186
|
+
}
|
|
187
|
+
}, 50)
|
|
153
188
|
return () => clearTimeout(handle)
|
|
189
|
+
// Once, when the session opens; later value changes come from typing.
|
|
190
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
154
191
|
}, [])
|
|
155
192
|
|
|
156
193
|
const onSelectionChange = useCallback(
|
|
157
194
|
(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
|
|
158
|
-
|
|
195
|
+
const next = event.nativeEvent.selection
|
|
196
|
+
setSelectionRange((previous) =>
|
|
197
|
+
previous && previous.start === next.start && previous.end === next.end
|
|
198
|
+
? previous
|
|
199
|
+
: next,
|
|
200
|
+
)
|
|
159
201
|
},
|
|
160
202
|
[],
|
|
161
203
|
)
|
|
162
204
|
|
|
205
|
+
const caretAt = selectionRange?.end ?? session.value.length
|
|
206
|
+
const caretSource = session.value.slice(0, Math.max(0, caretAt))
|
|
207
|
+
|
|
208
|
+
/*
|
|
209
|
+
Measure where the caret belongs.
|
|
210
|
+
|
|
211
|
+
The hidden input cannot draw its own caret on Android without also drawing
|
|
212
|
+
its glyphs, so the visible caret is an overlay. A second, fully transparent
|
|
213
|
+
Text holds everything before the selection with the same font, size, width
|
|
214
|
+
and alignment as the input. React Native reports the laid-out lines; the end
|
|
215
|
+
of the last one is the caret position. This tracks wrapping and every
|
|
216
|
+
alignment because the platform is doing the layout, not an estimate here.
|
|
217
|
+
*/
|
|
218
|
+
const onCaretLayout = useCallback((event: NativeSyntheticEvent<TextLayoutEventData>) => {
|
|
219
|
+
const lines = event.nativeEvent.lines
|
|
220
|
+
const last = lines[lines.length - 1]
|
|
221
|
+
const next = last
|
|
222
|
+
? { x: last.x, y: last.y, width: last.width, height: last.height }
|
|
223
|
+
: null
|
|
224
|
+
/*
|
|
225
|
+
Layout events can fire again after the state update they cause. Returning
|
|
226
|
+
the previous object for an unchanged measurement lets React bail out;
|
|
227
|
+
always allocating a new one re-rendered the composer from inside its own
|
|
228
|
+
layout pass and reached "Maximum update depth exceeded" after a size
|
|
229
|
+
change.
|
|
230
|
+
*/
|
|
231
|
+
setCaretLine((previous) => (sameCaretLine(previous, next) ? previous : next))
|
|
232
|
+
}, [])
|
|
233
|
+
|
|
234
|
+
const caret = composerCaretBox(layout, caretLine)
|
|
235
|
+
const letterSpacing =
|
|
236
|
+
span?.letterSpacing && span.fontSize > 0
|
|
237
|
+
? (span.letterSpacing * layout.fontSize) / span.fontSize
|
|
238
|
+
: undefined
|
|
239
|
+
|
|
163
240
|
/**
|
|
164
241
|
* Apply a style without disturbing the caret.
|
|
165
242
|
*
|
|
@@ -247,6 +324,16 @@ export function TextComposer({ session, insets, onDone, onCancel }: TextComposer
|
|
|
247
324
|
return () => subscription.remove()
|
|
248
325
|
}, [onCancel, insets.keyboardVisible])
|
|
249
326
|
|
|
327
|
+
/*
|
|
328
|
+
A tap, with the movement measured ourselves.
|
|
329
|
+
|
|
330
|
+
Pressable alone can report a press after a short move, and the canvas is
|
|
331
|
+
exactly where a person drags. The touch-down point is kept and the release
|
|
332
|
+
point compared: past eight points this was a drag, so it does not finish the
|
|
333
|
+
session.
|
|
334
|
+
*/
|
|
335
|
+
const tapStart = useRef<{ x: number; y: number } | null>(null)
|
|
336
|
+
|
|
250
337
|
const controlRow = {
|
|
251
338
|
flexDirection: 'row' as const,
|
|
252
339
|
alignItems: 'center' as const,
|
|
@@ -271,13 +358,73 @@ export function TextComposer({ session, insets, onDone, onCancel }: TextComposer
|
|
|
271
358
|
style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, backgroundColor: '#00000055' }}
|
|
272
359
|
/>
|
|
273
360
|
|
|
274
|
-
{/*
|
|
275
|
-
|
|
361
|
+
{/*
|
|
362
|
+
Empty canvas finishes editing.
|
|
363
|
+
|
|
364
|
+
The input and the controls are later siblings, so they sit above this
|
|
365
|
+
and still receive their own touches. A measured drag leaves the session open.
|
|
366
|
+
*/}
|
|
367
|
+
<Pressable
|
|
368
|
+
accessible
|
|
369
|
+
collapsable={false}
|
|
370
|
+
accessibilityRole="button"
|
|
371
|
+
accessibilityLabel="Finish editing text"
|
|
372
|
+
onAccessibilityTap={onDone}
|
|
373
|
+
onPressIn={(event) => {
|
|
374
|
+
tapStart.current = {
|
|
375
|
+
x: event.nativeEvent.pageX,
|
|
376
|
+
y: event.nativeEvent.pageY,
|
|
377
|
+
}
|
|
378
|
+
}}
|
|
379
|
+
onPress={(event) => {
|
|
380
|
+
const start = tapStart.current
|
|
381
|
+
const end = { x: event.nativeEvent.pageX, y: event.nativeEvent.pageY }
|
|
382
|
+
if (!pressMoved(start, end)) onDone()
|
|
383
|
+
}}
|
|
384
|
+
style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0 }}
|
|
385
|
+
/>
|
|
386
|
+
|
|
387
|
+
{/*
|
|
388
|
+
Caret measurement. Transparent and non-interactive; it never exports
|
|
389
|
+
and never duplicates a glyph, because it is painted at zero opacity.
|
|
390
|
+
*/}
|
|
391
|
+
<Text
|
|
392
|
+
key={`${caretSource}:${caretAt}`}
|
|
393
|
+
accessible={false}
|
|
394
|
+
importantForAccessibility="no-hide-descendants"
|
|
395
|
+
pointerEvents="none"
|
|
396
|
+
onTextLayout={onCaretLayout}
|
|
397
|
+
style={{
|
|
398
|
+
position: 'absolute',
|
|
399
|
+
left: layout.left,
|
|
400
|
+
width: layout.width,
|
|
401
|
+
top: layout.top,
|
|
402
|
+
height: layout.height,
|
|
403
|
+
opacity: 0,
|
|
404
|
+
fontSize: layout.fontSize,
|
|
405
|
+
lineHeight: layout.lineHeight,
|
|
406
|
+
fontFamily: span?.fontFamily,
|
|
407
|
+
fontWeight: span?.fontWeight
|
|
408
|
+
? (`${span.fontWeight}` as TextStyle['fontWeight'])
|
|
409
|
+
: undefined,
|
|
410
|
+
letterSpacing,
|
|
411
|
+
textAlign: data?.align ?? 'center',
|
|
412
|
+
padding: 0,
|
|
413
|
+
}}
|
|
414
|
+
>
|
|
415
|
+
{`${caretSource}\u200B`}
|
|
416
|
+
</Text>
|
|
417
|
+
|
|
418
|
+
{/* The editing surface. Its glyphs are hidden on Android because the IME
|
|
419
|
+
paints composing text black regardless of colour; the Skia layer is
|
|
420
|
+
the only visible copy. It still owns focus, selection and the IME. */}
|
|
276
421
|
<TextInput
|
|
277
422
|
ref={input}
|
|
278
423
|
value={session.value}
|
|
279
424
|
onChangeText={session.setValue}
|
|
280
425
|
onSelectionChange={onSelectionChange}
|
|
426
|
+
onFocus={() => setFocused(true)}
|
|
427
|
+
onBlur={() => setFocused(false)}
|
|
281
428
|
multiline
|
|
282
429
|
// Enter inserts a newline. Without this, a submit blurs the input and
|
|
283
430
|
// the session ends on the first line break the user types.
|
|
@@ -309,6 +456,7 @@ export function TextComposer({ session, insets, onDone, onCancel }: TextComposer
|
|
|
309
456
|
// rgba rather than the 'transparent' keyword because the keyword is
|
|
310
457
|
// not reliably honoured for TextInput colour on Android.
|
|
311
458
|
color: 'rgba(0,0,0,0)',
|
|
459
|
+
opacity: composerInputOpacity(Platform.OS),
|
|
312
460
|
fontSize: layout.fontSize,
|
|
313
461
|
lineHeight: layout.lineHeight,
|
|
314
462
|
textAlign: data?.align ?? 'center',
|
|
@@ -321,6 +469,25 @@ export function TextComposer({ session, insets, onDone, onCancel }: TextComposer
|
|
|
321
469
|
cursorColor={theme.colors.accent}
|
|
322
470
|
/>
|
|
323
471
|
|
|
472
|
+
{/* Android hides the native caret with the input, so draw the replacement
|
|
473
|
+
at the measured end of the text before the selection. Preview-only; it
|
|
474
|
+
is not inside renderDocument. */}
|
|
475
|
+
{focused && caret ? (
|
|
476
|
+
<View
|
|
477
|
+
pointerEvents="none"
|
|
478
|
+
accessible={false}
|
|
479
|
+
style={{
|
|
480
|
+
position: 'absolute',
|
|
481
|
+
left: caret.left,
|
|
482
|
+
top: caret.top,
|
|
483
|
+
width: caret.width,
|
|
484
|
+
height: caret.height,
|
|
485
|
+
borderRadius: caret.width / 2,
|
|
486
|
+
backgroundColor: theme.colors.accent,
|
|
487
|
+
}}
|
|
488
|
+
/>
|
|
489
|
+
) : null}
|
|
490
|
+
|
|
324
491
|
{/* Top bar: Cancel / Done, below the status bar and any notch. */}
|
|
325
492
|
<View
|
|
326
493
|
style={{
|
|
@@ -510,6 +677,7 @@ export function TextComposer({ session, insets, onDone, onCancel }: TextComposer
|
|
|
510
677
|
export function TextComposerHost({
|
|
511
678
|
Composer = TextComposer,
|
|
512
679
|
safeArea,
|
|
680
|
+
canvas,
|
|
513
681
|
}: {
|
|
514
682
|
/** Replace the default composer without replacing the canvas or editor. */
|
|
515
683
|
Composer?: React.ComponentType<TextComposerProps>
|
|
@@ -521,6 +689,8 @@ export function TextComposerHost({
|
|
|
521
689
|
* its bar at the very top, which on a device with a notch means under one.
|
|
522
690
|
*/
|
|
523
691
|
safeArea?: { top?: number; bottom?: number }
|
|
692
|
+
/** The canvas rectangle, for a host that draws a cover canvas. */
|
|
693
|
+
canvas?: CanvasRect
|
|
524
694
|
}) {
|
|
525
695
|
const { setActiveTool } = useEditor()
|
|
526
696
|
const session = useTextSession()
|
|
@@ -574,6 +744,7 @@ export function TextComposerHost({
|
|
|
574
744
|
insets={insets}
|
|
575
745
|
onDone={() => finish(true)}
|
|
576
746
|
onCancel={() => finish(false)}
|
|
747
|
+
{...(canvas ? { canvas } : {})}
|
|
577
748
|
/>
|
|
578
749
|
)
|
|
579
750
|
}
|
|
@@ -157,6 +157,15 @@ export interface InputBoxRequest {
|
|
|
157
157
|
measured?: Box | null
|
|
158
158
|
/** Lines in the value — the estimate used until a measurement arrives. */
|
|
159
159
|
lines: number
|
|
160
|
+
/**
|
|
161
|
+
* The canvas rectangle, when the host already knows it.
|
|
162
|
+
*
|
|
163
|
+
* A host that draws a *cover* canvas (a story filling the screen, with the
|
|
164
|
+
* sides clipped) cannot use a contain fit here: the glyphs would sit one
|
|
165
|
+
* inset below the caret. Such a host passes the rectangle it draws into.
|
|
166
|
+
* Omitted means contain, which is what the bare editor does.
|
|
167
|
+
*/
|
|
168
|
+
canvas?: CanvasRect
|
|
160
169
|
}
|
|
161
170
|
|
|
162
171
|
export interface InputBox extends CanvasRect {
|
|
@@ -179,7 +188,11 @@ export interface InputBox extends CanvasRect {
|
|
|
179
188
|
* the line count is the estimate, which is exact until a line wraps.
|
|
180
189
|
*/
|
|
181
190
|
export function composerInputBox(request: InputBoxRequest): InputBox {
|
|
182
|
-
const
|
|
191
|
+
const provided = request.canvas
|
|
192
|
+
const canvas =
|
|
193
|
+
provided && provided.width > 0 && provided.height > 0
|
|
194
|
+
? provided
|
|
195
|
+
: fitCanvasRect(request.container, request.document)
|
|
183
196
|
const fontSize = Math.max(1, request.fontSize * canvas.width)
|
|
184
197
|
const lineHeight = Math.max(1, fontSize * request.lineHeight)
|
|
185
198
|
|
|
@@ -211,3 +224,83 @@ export function composerInputBox(request: InputBoxRequest): InputBox {
|
|
|
211
224
|
lineHeight,
|
|
212
225
|
}
|
|
213
226
|
}
|
|
227
|
+
|
|
228
|
+
/** A line rectangle as React Native's `onTextLayout` reports it, in dp. */
|
|
229
|
+
export interface CaretLine {
|
|
230
|
+
x: number
|
|
231
|
+
y: number
|
|
232
|
+
width: number
|
|
233
|
+
height: number
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Whether two layout reports describe the same caret line.
|
|
238
|
+
*
|
|
239
|
+
* `onTextLayout` can fire again after the state update it causes. The composer
|
|
240
|
+
* uses this to return the previous object for an unchanged line, so React can
|
|
241
|
+
* bail out instead of re-rendering from inside its own layout pass.
|
|
242
|
+
*/
|
|
243
|
+
export function sameCaretLine(previous: CaretLine | null, next: CaretLine | null): boolean {
|
|
244
|
+
return (
|
|
245
|
+
previous === next ||
|
|
246
|
+
(previous !== null &&
|
|
247
|
+
next !== null &&
|
|
248
|
+
previous.x === next.x &&
|
|
249
|
+
previous.y === next.y &&
|
|
250
|
+
previous.width === next.width &&
|
|
251
|
+
previous.height === next.height)
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export interface CaretRect {
|
|
256
|
+
left: number
|
|
257
|
+
top: number
|
|
258
|
+
width: number
|
|
259
|
+
height: number
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Width of the drawn caret, in dp. Two points reads at phone density. */
|
|
263
|
+
export const CARET_WIDTH = 2
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Opacity of the native input's glyphs.
|
|
267
|
+
*
|
|
268
|
+
* Android's IME paints composing text black even when the input's colour is
|
|
269
|
+
* transparent, so the whole input is hidden there and only the Skia layer is
|
|
270
|
+
* visible. Other platforms keep the native input's own glyphs aligned under the
|
|
271
|
+
* Skia layer; there is no composing ghost to hide.
|
|
272
|
+
*/
|
|
273
|
+
export function composerInputOpacity(platform: string): number {
|
|
274
|
+
return platform === 'android' ? 0 : 1
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Place the visible caret at the end of a measured line.
|
|
279
|
+
*
|
|
280
|
+
* The native input is fully hidden on Android because its IME paints composing
|
|
281
|
+
* glyphs black even when its text colour is transparent. Hiding it also hides
|
|
282
|
+
* the native caret, so the composer draws one over the same glyphs. A hidden
|
|
283
|
+
* `Text` containing the text before the selection is laid out with the input's
|
|
284
|
+
* own width and style; the last line's end is where the caret belongs.
|
|
285
|
+
*
|
|
286
|
+
* Returns null until a line has been measured, rather than guessing a position
|
|
287
|
+
* and then jumping when the measurement arrives.
|
|
288
|
+
*/
|
|
289
|
+
export function composerCaretBox(box: InputBox, line: CaretLine | null): CaretRect | null {
|
|
290
|
+
if (
|
|
291
|
+
!line ||
|
|
292
|
+
!Number.isFinite(line.x) ||
|
|
293
|
+
!Number.isFinite(line.y) ||
|
|
294
|
+
!Number.isFinite(line.width) ||
|
|
295
|
+
!Number.isFinite(line.height)
|
|
296
|
+
) {
|
|
297
|
+
return null
|
|
298
|
+
}
|
|
299
|
+
const height = Math.max(1, line.height || box.lineHeight)
|
|
300
|
+
return {
|
|
301
|
+
left: box.left + line.x + line.width - CARET_WIDTH / 2,
|
|
302
|
+
top: box.top + line.y,
|
|
303
|
+
width: CARET_WIDTH,
|
|
304
|
+
height,
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a press moved far enough to be a drag.
|
|
3
|
+
*
|
|
4
|
+
* Pressable can report a press after a short move, and the composer's backdrop
|
|
5
|
+
* is exactly where a person drags the canvas. The decision is measured from the
|
|
6
|
+
* press event itself — not from a flag set by `onPressOut` — because Android can
|
|
7
|
+
* deliver the release before the press callback runs.
|
|
8
|
+
*
|
|
9
|
+
* @experimental
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface Point {
|
|
13
|
+
x: number
|
|
14
|
+
y: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Movement at or below this many dp is still a tap. */
|
|
18
|
+
export const TAP_SLOP = 8
|
|
19
|
+
|
|
20
|
+
export function pressMoved(start: Point | null, end: Point | null, slop = TAP_SLOP): boolean {
|
|
21
|
+
if (!start || !end) return false
|
|
22
|
+
if (
|
|
23
|
+
!Number.isFinite(start.x) ||
|
|
24
|
+
!Number.isFinite(start.y) ||
|
|
25
|
+
!Number.isFinite(end.x) ||
|
|
26
|
+
!Number.isFinite(end.y)
|
|
27
|
+
) {
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
return Math.hypot(end.x - start.x, end.y - start.y) > Math.max(0, slop)
|
|
31
|
+
}
|
|
@@ -26,11 +26,18 @@ import {
|
|
|
26
26
|
} from './layerGestureWorklet'
|
|
27
27
|
import { useInteractionState } from './interactionState'
|
|
28
28
|
import { type GestureSnapEvent } from './controller'
|
|
29
|
+
import { selectedLayerTapId } from './selectionTap'
|
|
29
30
|
|
|
30
31
|
/** Overrides for overlay-layer gestures, defaulting to the profile's settings. */
|
|
31
32
|
export interface LayerGestureSurfaceProps {
|
|
32
33
|
/** Fired once per snap engagement — hook up haptics here if you want them. */
|
|
33
34
|
onSnap?: (event: GestureSnapEvent) => void
|
|
35
|
+
/**
|
|
36
|
+
* Fired when a tap lands on a layer that was already selected when the
|
|
37
|
+
* gesture began. Hosts use this for a second-tap action such as opening the
|
|
38
|
+
* text composer; the SDK itself stays unaware of what a layer means.
|
|
39
|
+
*/
|
|
40
|
+
onSelectedLayerTap?: (layerId: string) => void
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
/**
|
|
@@ -49,8 +56,8 @@ export interface LayerGestureSurfaceProps {
|
|
|
49
56
|
* - **One history entry.** The document is written exactly once, when the
|
|
50
57
|
* gesture settles, and not at all if nothing actually moved.
|
|
51
58
|
*/
|
|
52
|
-
export function LayerGestureSurface({ onSnap }: LayerGestureSurfaceProps) {
|
|
53
|
-
const { document, dispatch, profile } = useEditor()
|
|
59
|
+
export function LayerGestureSurface({ onSnap, onSelectedLayerTap }: LayerGestureSurfaceProps) {
|
|
60
|
+
const { document, dispatch, profile, selection } = useEditor()
|
|
54
61
|
const store = useEditorStore()
|
|
55
62
|
const metrics = useCanvasMetrics()
|
|
56
63
|
// Destructure the setters: the context value's identity changes on every
|
|
@@ -195,11 +202,12 @@ export function LayerGestureSurface({ onSnap }: LayerGestureSurfaceProps) {
|
|
|
195
202
|
* callbacks + a mutable snapshot gives both fresh data and a gesture object
|
|
196
203
|
* that never needs rebuilding.
|
|
197
204
|
*/
|
|
198
|
-
const latest = useRef({ document, metrics, store, dispatch, onSnap, profile })
|
|
199
|
-
latest.current = { document, metrics, store, dispatch, onSnap, profile }
|
|
205
|
+
const latest = useRef({ document, metrics, store, dispatch, onSnap, profile, selection, onSelectedLayerTap })
|
|
206
|
+
latest.current = { document, metrics, store, dispatch, onSnap, profile, selection, onSelectedLayerTap }
|
|
200
207
|
|
|
201
208
|
const grabbedId = useRef<string | null>(null)
|
|
202
209
|
const grabbedFrom = useRef<Transform | null>(null)
|
|
210
|
+
const tapWasSelected = useRef(false)
|
|
203
211
|
const snapState = useRef<LayerSnapState>(NO_SNAP)
|
|
204
212
|
|
|
205
213
|
const onGrabLayer = useCallback((id: string) => {
|
|
@@ -214,9 +222,32 @@ export function LayerGestureSurface({ onSnap }: LayerGestureSurfaceProps) {
|
|
|
214
222
|
setLayerDragActive(true)
|
|
215
223
|
}, [setPreview, setLayerDragActive, previewTransform])
|
|
216
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Record whether the layer under the first touch was already selected.
|
|
227
|
+
*
|
|
228
|
+
* This has to happen on touch-down, not when the pan grabs: a tap does not
|
|
229
|
+
* activate the pan, so a snapshot taken there never existed and every tap
|
|
230
|
+
* looked like a first tap. It also runs before the pan's grab selects the
|
|
231
|
+
* layer, which is what would otherwise turn a first tap into a second one.
|
|
232
|
+
*/
|
|
233
|
+
const snapshotTapAt = useCallback((xDp: number, yDp: number) => {
|
|
234
|
+
const { document: doc, metrics: m, store: s, selection: selected } = latest.current
|
|
235
|
+
if (!m) return
|
|
236
|
+
const layerInteraction = latest.current.profile.interaction?.layers
|
|
237
|
+
const hit = hitTestDocument(
|
|
238
|
+
doc,
|
|
239
|
+
{ x: xDp / m.width, y: yDp / m.height },
|
|
240
|
+
s.registry,
|
|
241
|
+
(layer, index) => isDirectManipulationLayer(layer, index, layerInteraction),
|
|
242
|
+
)
|
|
243
|
+
tapWasSelected.current = Boolean(hit && selected.includes(hit.id))
|
|
244
|
+
}, [])
|
|
245
|
+
|
|
217
246
|
const onSelectAt = useCallback((xDp: number, yDp: number) => {
|
|
218
247
|
const { document: doc, metrics: m, store: s } = latest.current
|
|
219
248
|
if (!m) return
|
|
249
|
+
const wasSelected = tapWasSelected.current
|
|
250
|
+
tapWasSelected.current = false
|
|
220
251
|
const layerInteraction = latest.current.profile.interaction?.layers
|
|
221
252
|
const hit = hitTestDocument(
|
|
222
253
|
doc,
|
|
@@ -225,6 +256,8 @@ export function LayerGestureSurface({ onSnap }: LayerGestureSurfaceProps) {
|
|
|
225
256
|
(layer, index) => isDirectManipulationLayer(layer, index, layerInteraction),
|
|
226
257
|
)
|
|
227
258
|
s.select(hit && !hit.locked ? [hit.id] : [])
|
|
259
|
+
const editId = selectedLayerTapId(hit ?? null, wasSelected)
|
|
260
|
+
if (editId) latest.current.onSelectedLayerTap?.(editId)
|
|
228
261
|
}, [])
|
|
229
262
|
|
|
230
263
|
const onSnapChanged = useCallback(
|
|
@@ -388,6 +421,8 @@ export function LayerGestureSurface({ onSnap }: LayerGestureSurfaceProps) {
|
|
|
388
421
|
.onTouchesDown((e) => {
|
|
389
422
|
'worklet'
|
|
390
423
|
livePointers.value = e.allTouches.length
|
|
424
|
+
const touch = e.changedTouches[0] ?? e.allTouches[0]
|
|
425
|
+
if (touch) runOnJS(snapshotTapAt)(touch.x, touch.y)
|
|
391
426
|
})
|
|
392
427
|
.onTouchesUp((e) => {
|
|
393
428
|
'worklet'
|
|
@@ -493,6 +528,7 @@ export function LayerGestureSurface({ onSnap }: LayerGestureSurfaceProps) {
|
|
|
493
528
|
deleteTarget,
|
|
494
529
|
aspect,
|
|
495
530
|
onGrabLayer,
|
|
531
|
+
snapshotTapAt,
|
|
496
532
|
onSelectAt,
|
|
497
533
|
onSnapChanged,
|
|
498
534
|
onHoverChanged,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve whether a tap should open the selected layer for editing.
|
|
3
|
+
*
|
|
4
|
+
* A tap has two jobs. The first one selects the layer under the finger; a tap
|
|
5
|
+
* on a layer that was already selected opens it for editing. The selection is
|
|
6
|
+
* snapshotted when the gesture begins, before the pan's grab selects the layer,
|
|
7
|
+
* otherwise every first tap would look like a second one.
|
|
8
|
+
*
|
|
9
|
+
* @experimental
|
|
10
|
+
*/
|
|
11
|
+
export interface SelectionTapHit {
|
|
12
|
+
id: string
|
|
13
|
+
locked?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function selectedLayerTapId(
|
|
17
|
+
hit: SelectionTapHit | null,
|
|
18
|
+
wasSelectedAtStart: boolean,
|
|
19
|
+
): string | null {
|
|
20
|
+
if (!hit || hit.locked || !wasSelectedAtStart) return null
|
|
21
|
+
return hit.id
|
|
22
|
+
}
|