@pictor-sdk/ui 0.2.0 → 0.2.2

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.2.0",
3
+ "version": "0.2.2",
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.2.0"
36
+ "@pictor-sdk/skia": "^0.2.1",
37
+ "@pictor-sdk/core": "^0.2.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@shopify/react-native-skia": "^2.0.0",
@@ -45,7 +45,7 @@
45
45
  "react-native-reanimated": "^4.1.0",
46
46
  "react-native-worklets": "0.5.1",
47
47
  "@pictor-sdk/core": "0.2.0",
48
- "@pictor-sdk/skia": "0.2.0"
48
+ "@pictor-sdk/skia": "0.2.1"
49
49
  },
50
50
  "publishConfig": {
51
51
  "access": "public",
@@ -21,9 +21,10 @@ import {
21
21
  } from 'react-native'
22
22
  import { Slider } from '../components/Slider'
23
23
  import { useTheme } from '../theme'
24
+ import { useRenderer } from '@pictor-sdk/skia'
24
25
  import {
26
+ composerInputBox,
25
27
  composerInsets,
26
- fitCanvasRect,
27
28
  HIDDEN_KEYBOARD,
28
29
  type ComposerInsets,
29
30
  } from './keyboardLayout'
@@ -82,6 +83,7 @@ export function TextComposer({ session, insets, onDone, onCancel }: TextComposer
82
83
  // Measured rather than taken from `useCanvasMetrics`: that context belongs to
83
84
  // EditorCanvas's children, and the composer is its sibling.
84
85
  const [container, setContainer] = useState({ width: 0, height: 0 })
86
+ const renderer = useRenderer()
85
87
  const input = useRef<TextInput>(null)
86
88
  const [selectionRange, setSelectionRange] = useState<{ start: number; end: number } | undefined>(
87
89
  undefined,
@@ -90,30 +92,58 @@ export function TextComposer({ session, insets, onDone, onCancel }: TextComposer
90
92
  const data = session.data
91
93
  const span = session.span
92
94
 
95
+ /*
96
+ The drawn text box, republished by the renderer after every scene.
97
+
98
+ It lives outside React, so a subscription is how a new measurement reaches
99
+ the layout — reading it during render would pin the box to whatever was
100
+ measured when the composer mounted, which for a layer created a frame ago is
101
+ nothing at all.
102
+ */
103
+ const layerId = session.layer?.id
104
+ const [measured, setMeasured] = useState<{ width: number; height: number } | null>(null)
105
+ useEffect(() => {
106
+ if (!layerId) {
107
+ setMeasured(null)
108
+ return
109
+ }
110
+ const refresh = () => setMeasured(renderer.textMeasures.get(layerId) ?? null)
111
+ const unsubscribe = renderer.textMeasures.subscribe(refresh)
112
+ refresh()
113
+ return unsubscribe
114
+ }, [renderer, layerId])
115
+
93
116
  /**
94
117
  * Lay the input out over the text it is editing.
95
118
  *
96
119
  * The visible glyphs are the Skia layer; this input is transparent and only
97
120
  * 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.
121
+ * where the user sees their text, the input's box has to be the *text's* box:
122
+ * same font size in dp, same alignment, and the same height, sitting on the
123
+ * layer's normalized centre. It will never be glyph-exact — Skia and the
124
+ * platform measure differently — but it tracks closely enough to edit by.
102
125
  */
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])
126
+ const layout = useMemo(
127
+ () =>
128
+ composerInputBox({
129
+ container,
130
+ document: document.size,
131
+ fontSize: span?.fontSize ?? 0.08,
132
+ lineHeight: span?.lineHeight ?? 1.2,
133
+ centerY: session.layer?.transform.y ?? 0.42,
134
+ measured,
135
+ lines: session.value.split('\n').length,
136
+ }),
137
+ [
138
+ container,
139
+ document.size,
140
+ span?.fontSize,
141
+ span?.lineHeight,
142
+ session.layer?.transform.y,
143
+ session.value,
144
+ measured,
145
+ ],
146
+ )
117
147
  const fonts = useMemo(() => fontChoices(store.registry.fonts), [store.registry.fonts])
118
148
 
119
149
  // Focus once the overlay is up. A frame's delay lets the mount settle so the
@@ -291,13 +321,13 @@ export function TextComposer({ session, insets, onDone, onCancel }: TextComposer
291
321
  cursorColor={theme.colors.accent}
292
322
  />
293
323
 
294
- {/* Top bar: Cancel / Done, above the keyboard-independent area. */}
324
+ {/* Top bar: Cancel / Done, below the status bar and any notch. */}
295
325
  <View
296
326
  style={{
297
327
  position: 'absolute',
298
328
  left: 0,
299
329
  right: 0,
300
- top: 0,
330
+ top: insets.top,
301
331
  height: 56,
302
332
  flexDirection: 'row',
303
333
  alignItems: 'center',
@@ -479,9 +509,18 @@ export function TextComposer({ session, insets, onDone, onCancel }: TextComposer
479
509
  */
480
510
  export function TextComposerHost({
481
511
  Composer = TextComposer,
512
+ safeArea,
482
513
  }: {
483
514
  /** Replace the default composer without replacing the canvas or editor. */
484
515
  Composer?: React.ComponentType<TextComposerProps>
516
+ /**
517
+ * The app's safe-area insets, in dp.
518
+ *
519
+ * Supplied by the host because reading them needs a dependency this package
520
+ * does not take. Omit it and the composer draws exactly where it always did:
521
+ * its bar at the very top, which on a device with a notch means under one.
522
+ */
523
+ safeArea?: { top?: number; bottom?: number }
485
524
  }) {
486
525
  const { setActiveTool } = useEditor()
487
526
  const session = useTextSession()
@@ -514,7 +553,10 @@ export function TextComposerHost({
514
553
  }
515
554
  }, [])
516
555
 
517
- const insets = useMemo(() => composerInsets(frame), [frame])
556
+ const insets = useMemo(
557
+ () => composerInsets(frame, safeArea?.bottom ?? 0, safeArea?.top ?? 0),
558
+ [frame, safeArea?.bottom, safeArea?.top],
559
+ )
518
560
 
519
561
  const finish = useCallback(
520
562
  (commit: boolean) => {
@@ -21,6 +21,15 @@ export interface KeyboardFrame {
21
21
  }
22
22
 
23
23
  export interface ComposerInsets {
24
+ /**
25
+ * Distance from the top of the screen to place the Cancel/Done bar.
26
+ *
27
+ * The composer cannot work this out for itself: reading the safe area needs
28
+ * `react-native-safe-area-context`, and this package takes no dependency the
29
+ * host has not already chosen. So the host supplies it, and a host that does
30
+ * not gets 0 — which is what every release before this one drew.
31
+ */
32
+ top: number
24
33
  /** Distance from the bottom of the screen to place the control bar. */
25
34
  bottom: number
26
35
  /** True while the keyboard occupies screen space. */
@@ -46,6 +55,7 @@ const FALLBACK_DURATION = 180
46
55
  export function composerInsets(
47
56
  frame: KeyboardFrame,
48
57
  safeAreaBottom = 0,
58
+ safeAreaTop = 0,
49
59
  ): ComposerInsets {
50
60
  const height = Number.isFinite(frame.height) ? Math.max(0, frame.height) : 0
51
61
  const visible = height > 0
@@ -55,6 +65,9 @@ export function composerInsets(
55
65
  ? FALLBACK_DURATION
56
66
  : Math.min(MAX_DURATION, raw)
57
67
  return {
68
+ // The keyboard never covers the top, so this one is the safe area and
69
+ // nothing else.
70
+ top: Number.isFinite(safeAreaTop) ? Math.max(0, safeAreaTop) : 0,
58
71
  bottom: visible ? height : Math.max(0, safeAreaBottom),
59
72
  keyboardVisible: visible,
60
73
  duration,
@@ -128,3 +141,73 @@ export function fitCanvasRect(container: Box, document: Box): CanvasRect {
128
141
  top: (container.height - height) / 2,
129
142
  }
130
143
  }
144
+
145
+ export interface InputBoxRequest {
146
+ /** The composer's own bounds. */
147
+ container: Box
148
+ /** Document size, for the canvas fit. */
149
+ document: Box
150
+ /** Font size as a fraction of document width, as the layer stores it. */
151
+ fontSize: number
152
+ /** Line height as a multiple of the font size. */
153
+ lineHeight: number
154
+ /** The layer's normalized vertical centre. */
155
+ centerY: number
156
+ /** The drawn text box in document pixels, once the renderer has measured it. */
157
+ measured?: Box | null
158
+ /** Lines in the value — the estimate used until a measurement arrives. */
159
+ lines: number
160
+ }
161
+
162
+ export interface InputBox extends CanvasRect {
163
+ fontSize: number
164
+ lineHeight: number
165
+ }
166
+
167
+ /**
168
+ * Where to put the invisible `TextInput` that carries the caret.
169
+ *
170
+ * The box has to be **the size of the text**, not the size of the canvas. A
171
+ * full-height input only looked right on Android, where `textAlignVertical`
172
+ * pushed its content to the middle; that property does not exist on iOS, so
173
+ * there the caret sat at the top of the screen while the glyphs it belonged to
174
+ * were halfway down. Sizing the box to the text puts the first line at the top
175
+ * of the box on every platform, and the box itself where the glyphs are.
176
+ *
177
+ * The renderer's measurement is preferred because it is the box that actually
178
+ * draws. Before the first measurement arrives — the frame a layer is created —
179
+ * the line count is the estimate, which is exact until a line wraps.
180
+ */
181
+ export function composerInputBox(request: InputBoxRequest): InputBox {
182
+ const canvas = fitCanvasRect(request.container, request.document)
183
+ const fontSize = Math.max(1, request.fontSize * canvas.width)
184
+ const lineHeight = Math.max(1, fontSize * request.lineHeight)
185
+
186
+ const scale = request.document.width > 0 ? canvas.width / request.document.width : 0
187
+ const measuredHeight =
188
+ request.measured && request.measured.height > 0 ? request.measured.height * scale : 0
189
+ const estimated = lineHeight * Math.max(1, request.lines)
190
+ // At least one line, never taller than the canvas: a box that overflows the
191
+ // screen puts the caret off it again, which is the bug this replaced.
192
+ const height = Math.min(
193
+ Math.max(lineHeight, measuredHeight > 0 ? measuredHeight : estimated),
194
+ Math.max(lineHeight, canvas.height),
195
+ )
196
+
197
+ const centre = canvas.top + request.centerY * canvas.height
198
+ // Keep the box on screen even for a layer dragged to an edge — the caret has
199
+ // to be reachable, and a few points of drift beats an unreachable caret.
200
+ const top = Math.min(
201
+ Math.max(0, centre - height / 2),
202
+ Math.max(0, request.container.height - height),
203
+ )
204
+
205
+ return {
206
+ left: canvas.left,
207
+ width: Math.max(1, canvas.width),
208
+ top,
209
+ height: Math.max(1, height),
210
+ fontSize,
211
+ lineHeight,
212
+ }
213
+ }
@@ -73,10 +73,13 @@ export function LayerGestureSurface({ onSnap }: LayerGestureSurfaceProps) {
73
73
  // Rebuild hit geometry when the renderer publishes a new text measurement,
74
74
  // so a layer's touch box tracks the box it actually draws.
75
75
  const [measureVersion, setMeasureVersion] = useState(0)
76
- useEffect(
77
- () => renderer.textMeasures.subscribe(() => setMeasureVersion((v) => v + 1)),
78
- [renderer],
79
- )
76
+ useEffect(() => {
77
+ const refresh = () => setMeasureVersion((v) => v + 1)
78
+ const unsubscribe = renderer.textMeasures.subscribe(refresh)
79
+ // The first scene may have committed before this passive effect subscribed.
80
+ refresh()
81
+ return unsubscribe
82
+ }, [renderer])
80
83
 
81
84
  const hitCandidates = useMemo(
82
85
  () =>
@@ -147,6 +150,21 @@ export function LayerGestureSurface({ onSnap }: LayerGestureSurfaceProps) {
147
150
  const focalY = useSharedValue(0.5)
148
151
  const pointers = useSharedValue(0)
149
152
  const active = useSharedValue(false)
153
+ /*
154
+ Fingers still on the glass.
155
+
156
+ Completion used to belong to the pan's `onFinalize` alone, and on iOS a
157
+ two-finger twist could leave that one recognizer without a finalize: the
158
+ layer rotated on screen, the document was never written, and the preview and
159
+ its guides stayed up for good — what you saw was not what exported.
160
+
161
+ The touch stream is the authority instead. It is the one thing no recognizer
162
+ can withhold: when the last finger leaves, the gesture is over, whatever the
163
+ recognizers do or do not report. Every finalize still settles too, for the
164
+ case where a handler is cancelled and stops receiving touches at all —
165
+ `endIfIdle` is idempotent, so the first of them to arrive wins.
166
+ */
167
+ const livePointers = useSharedValue(0)
150
168
  const snapCX = useSharedValue(false)
151
169
  const snapCY = useSharedValue(false)
152
170
  const snapR = useSharedValue(false)
@@ -357,9 +375,30 @@ export function LayerGestureSurface({ onSnap }: LayerGestureSurfaceProps) {
357
375
  )
358
376
  }
359
377
 
378
+ /** Settle only once every finger has gone; a lone finger left is still a drag. */
379
+ const releaseIfEmpty = () => {
380
+ 'worklet'
381
+ if (livePointers.value > 0) return
382
+ endIfIdle()
383
+ }
384
+
360
385
  const pan = Gesture.Pan()
361
386
  .minPointers(1)
362
387
  .maxPointers(2)
388
+ .onTouchesDown((e) => {
389
+ 'worklet'
390
+ livePointers.value = e.allTouches.length
391
+ })
392
+ .onTouchesUp((e) => {
393
+ 'worklet'
394
+ livePointers.value = Math.max(0, e.allTouches.length - e.changedTouches.length)
395
+ releaseIfEmpty()
396
+ })
397
+ .onTouchesCancelled(() => {
398
+ 'worklet'
399
+ livePointers.value = 0
400
+ releaseIfEmpty()
401
+ })
363
402
  .onBegin((e) => {
364
403
  'worklet'
365
404
  if (active.value) return
@@ -409,17 +448,22 @@ export function LayerGestureSurface({ onSnap }: LayerGestureSurfaceProps) {
409
448
  })
410
449
  .onFinalize(() => {
411
450
  'worklet'
412
- endIfIdle()
451
+ releaseIfEmpty()
413
452
  })
414
453
 
415
- const pinch = Gesture.Pinch().onUpdate((e) => {
416
- 'worklet'
417
- rawPinch.value = e.scale
418
- focalX.value = e.focalX / metricsWidth
419
- focalY.value = e.focalY / metricsHeight
420
- syncPointers(e.numberOfPointers)
421
- recompute(false)
422
- })
454
+ const pinch = Gesture.Pinch()
455
+ .onUpdate((e) => {
456
+ 'worklet'
457
+ rawPinch.value = e.scale
458
+ focalX.value = e.focalX / metricsWidth
459
+ focalY.value = e.focalY / metricsHeight
460
+ syncPointers(e.numberOfPointers)
461
+ recompute(false)
462
+ })
463
+ .onFinalize(() => {
464
+ 'worklet'
465
+ releaseIfEmpty()
466
+ })
423
467
 
424
468
  const rotate = Gesture.Rotation()
425
469
  .enabled(config.rotationEnabled)
@@ -431,6 +475,10 @@ export function LayerGestureSurface({ onSnap }: LayerGestureSurfaceProps) {
431
475
  syncPointers(e.numberOfPointers)
432
476
  recompute(false)
433
477
  })
478
+ .onFinalize(() => {
479
+ 'worklet'
480
+ releaseIfEmpty()
481
+ })
434
482
 
435
483
  const tap = Gesture.Tap().onEnd((e) => {
436
484
  'worklet'