@pictor-sdk/ui 0.2.1 → 0.3.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 +38 -2
- package/dist/index.d.ts +38 -2
- package/dist/index.js +108 -24
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +103 -20
- 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 +54 -9
- package/src/index.ts +1 -1
- package/src/panels/layoutMath.ts +23 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pictor-sdk/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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,
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"react-native-reanimated": ">=3.16.0",
|
|
35
35
|
"react-native-worklets": ">=0.5.1",
|
|
36
36
|
"@pictor-sdk/core": "^0.2.0",
|
|
37
|
-
"@pictor-sdk/skia": "^0.
|
|
37
|
+
"@pictor-sdk/skia": "^0.3.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/core": "0.2.0",
|
|
48
|
+
"@pictor-sdk/skia": "0.3.0"
|
|
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
|
+
}
|
|
@@ -150,6 +150,21 @@ export function LayerGestureSurface({ onSnap }: LayerGestureSurfaceProps) {
|
|
|
150
150
|
const focalY = useSharedValue(0.5)
|
|
151
151
|
const pointers = useSharedValue(0)
|
|
152
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)
|
|
153
168
|
const snapCX = useSharedValue(false)
|
|
154
169
|
const snapCY = useSharedValue(false)
|
|
155
170
|
const snapR = useSharedValue(false)
|
|
@@ -360,9 +375,30 @@ export function LayerGestureSurface({ onSnap }: LayerGestureSurfaceProps) {
|
|
|
360
375
|
)
|
|
361
376
|
}
|
|
362
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
|
+
|
|
363
385
|
const pan = Gesture.Pan()
|
|
364
386
|
.minPointers(1)
|
|
365
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
|
+
})
|
|
366
402
|
.onBegin((e) => {
|
|
367
403
|
'worklet'
|
|
368
404
|
if (active.value) return
|
|
@@ -412,17 +448,22 @@ export function LayerGestureSurface({ onSnap }: LayerGestureSurfaceProps) {
|
|
|
412
448
|
})
|
|
413
449
|
.onFinalize(() => {
|
|
414
450
|
'worklet'
|
|
415
|
-
|
|
451
|
+
releaseIfEmpty()
|
|
416
452
|
})
|
|
417
453
|
|
|
418
|
-
const pinch = Gesture.Pinch()
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
+
})
|
|
426
467
|
|
|
427
468
|
const rotate = Gesture.Rotation()
|
|
428
469
|
.enabled(config.rotationEnabled)
|
|
@@ -434,6 +475,10 @@ export function LayerGestureSurface({ onSnap }: LayerGestureSurfaceProps) {
|
|
|
434
475
|
syncPointers(e.numberOfPointers)
|
|
435
476
|
recompute(false)
|
|
436
477
|
})
|
|
478
|
+
.onFinalize(() => {
|
|
479
|
+
'worklet'
|
|
480
|
+
releaseIfEmpty()
|
|
481
|
+
})
|
|
437
482
|
|
|
438
483
|
const tap = Gesture.Tap().onEnd((e) => {
|
|
439
484
|
'worklet'
|
package/src/index.ts
CHANGED
|
@@ -67,7 +67,7 @@ export { StickerPanel, type StickerAsset } from './panels/StickerPanel'
|
|
|
67
67
|
export { LayersPanel } from './panels/LayersPanel'
|
|
68
68
|
export { LayoutPanel } from './panels/LayoutPanel'
|
|
69
69
|
export { layoutCells, cellToTransformPatch, type LayoutCell, type LayoutPreset } from './panels/layoutMath'
|
|
70
|
-
export { layoutCellBounds, fitImageToLayoutCell, type LayoutCropOptions, type LayoutImageFit } from './panels/layoutMath'
|
|
70
|
+
export { layoutCellBounds, layoutCellBox, fitImageToLayoutCell, type LayoutCropOptions, type LayoutImageFit } from './panels/layoutMath'
|
|
71
71
|
export { DrawingSurface, DEFAULT_BRUSH } from './gestures/DrawingSurface'
|
|
72
72
|
export { createDrawController, type BrushConfig, type DrawController } from './gestures/drawController'
|
|
73
73
|
export { UiStateProvider, useUiState, createUiStateStore, type UiStateStore } from './uiState'
|
package/src/panels/layoutMath.ts
CHANGED
|
@@ -110,3 +110,26 @@ export function fitImageToLayoutCell(
|
|
|
110
110
|
export function cellToTransformPatch(cell: LayoutCell): Partial<Transform> {
|
|
111
111
|
return { x: cell.x, y: cell.y, scale: cell.scale, rotation: 0 }
|
|
112
112
|
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The box a photo draws into when it fills one cell, in document pixels.
|
|
116
|
+
*
|
|
117
|
+
* The cell fixes the box: `fitImageToLayoutCell` picks a crop whose aspect
|
|
118
|
+
* matches the cell, so zooming and panning inside it move the source window
|
|
119
|
+
* and leave the box alone. That is what makes a live crop expressible as a
|
|
120
|
+
* source-space override at all, and it is why a cell's bounds cannot drift
|
|
121
|
+
* while a gesture is running.
|
|
122
|
+
*
|
|
123
|
+
* Paired with `imageSourceTransform` from `@pictor-sdk/skia`, this is
|
|
124
|
+
* everything a host needs to draw an uncommitted crop from a worklet, using
|
|
125
|
+
* the renderer's own arithmetic rather than a second copy of it.
|
|
126
|
+
*
|
|
127
|
+
* @experimental
|
|
128
|
+
*/
|
|
129
|
+
export function layoutCellBox(cell: Rect, documentSize: Size): Size {
|
|
130
|
+
'worklet'
|
|
131
|
+
return {
|
|
132
|
+
width: cell.width * documentSize.width,
|
|
133
|
+
height: cell.height * documentSize.height,
|
|
134
|
+
}
|
|
135
|
+
}
|