@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pictor-sdk/ui",
3
- "version": "0.3.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.2.0",
37
- "@pictor-sdk/skia": "^0.3.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/core": "0.2.0",
48
- "@pictor-sdk/skia": "0.3.0"
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
- setWidth(e.nativeEvent.layout.width)
64
- widthRef.current = e.nativeEvent.layout.width
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
- * the caret, the platform selection handles and the IME, while the *visible*
74
- * text is the real Skia layer underneath. That is what keeps preview and export
75
- * identical without giving up native text editing.
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 = () => setMeasured(renderer.textMeasures.get(layerId) ?? null)
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(() => input.current?.focus(), 50)
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
- setSelectionRange(event.nativeEvent.selection)
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
- {/* The invisible editing surface. The visible glyphs are the Skia layer
275
- underneath; this owns the caret, selection handles and IME. */}
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 canvas = fitCanvasRect(request.container, request.document)
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
+ }