@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/README.md +1 -1
- package/dist/index.d.mts +21 -1
- package/dist/index.d.ts +21 -1
- package/dist/index.js +105 -28
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +101 -24
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/composer/TextComposer.tsx +64 -22
- package/src/composer/keyboardLayout.ts +83 -0
- package/src/gestures/LayerGestureSurface.tsx +61 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pictor-sdk/ui",
|
|
3
|
-
"version": "0.2.
|
|
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/
|
|
37
|
-
"@pictor-sdk/
|
|
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.
|
|
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
|
|
99
|
-
*
|
|
100
|
-
* layer's normalized
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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,
|
|
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:
|
|
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(
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
451
|
+
releaseIfEmpty()
|
|
413
452
|
})
|
|
414
453
|
|
|
415
|
-
const pinch = Gesture.Pinch()
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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'
|