@solidrt/core 0.0.60 → 0.0.62
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/AGENTS.md +100 -16
- package/agents/performance.md +40 -3
- package/docs/index.md +13 -0
- package/examples/README.md +2 -1
- package/examples/gestures.tsx +197 -0
- package/examples/layout-slide.tsx +44 -15
- package/package.json +6 -5
- package/src/arena.ts +55 -0
- package/src/camera-control.ts +428 -0
- package/src/cursor.ts +113 -0
- package/src/double-tap.ts +150 -0
- package/src/environment.ts +27 -0
- package/src/gesture.ts +32 -0
- package/src/index.ts +17 -3
- package/src/input-axes.ts +12 -7
- package/src/input-chord.ts +3 -0
- package/src/input-gamepad-device.ts +115 -6
- package/src/input-id.ts +92 -0
- package/src/input-keyboard.ts +49 -2
- package/src/input-map.ts +268 -68
- package/src/input-pointer.ts +240 -16
- package/src/input-processors.ts +214 -0
- package/src/input.ts +4 -2
- package/src/long-press.ts +137 -0
- package/src/pan.ts +23 -4
- package/src/renderer.ts +16 -6
- package/src/runtime-modules.d.ts +27 -0
- package/src/svg.ts +1 -0
- package/src/swipe.ts +130 -0
- package/src/text-input.ts +193 -48
- package/src/transform.ts +23 -3
- package/src/types.d.ts +44 -7
- package/src/velocity.ts +134 -0
- package/src/video.ts +7 -6
- package/src/window.ts +48 -10
package/src/text-input.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// are policy and belong to the component (the "skin") that composes these.
|
|
6
6
|
|
|
7
7
|
import { createMemo, createSignal, flush, untrack } from "@solidjs/signals"
|
|
8
|
-
import {
|
|
8
|
+
import { getLayoutBox, layoutNextLine, measureText, prepareText, unitInk } from "./core"
|
|
9
9
|
import type { MeasureTextOptions, PreparedText, TextRunRange, TextUnit } from "flux:rendertree"
|
|
10
10
|
import { onLayout } from "./window"
|
|
11
11
|
|
|
@@ -247,50 +247,111 @@ export type TextEditorLayout = {
|
|
|
247
247
|
*
|
|
248
248
|
* The scroll offsets are retained between frames and only adjusted when the
|
|
249
249
|
* caret would fall outside the visible range, so stationary text does not
|
|
250
|
-
* jump.
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
250
|
+
* jump. They derive from the caret, the lines and the viewport size, so a
|
|
251
|
+
* frame that changes none of them does no scroll work. The viewport size is
|
|
252
|
+
* the node's layout box (the untransformed solved box: the lines are drawn in
|
|
253
|
+
* the node's own frame, so an ancestor's scale must not change the wrap
|
|
254
|
+
* width), read in onLayout; the post-layout flush drains the update before
|
|
255
|
+
* paint, so lines and scroll track a caret, text or size change in the same
|
|
256
|
+
* frame. A size change that re-breaks nothing (an empty field, lines that
|
|
257
|
+
* still fit, a height-only change) stops at the line placement: the lines,
|
|
258
|
+
* the caret and everything the caller derives from them keep their values
|
|
259
|
+
* and re-run nothing, and the placement reads the break inputs through a
|
|
260
|
+
* memo of its own rather than `input()` (which a caller builds per read),
|
|
261
|
+
* so a field inside a resizing box costs one placement of its units per
|
|
262
|
+
* frame, not a flush of its whole graph. Pure geometry: no caret rendering
|
|
263
|
+
* and no placeholder/visual policy.
|
|
254
264
|
*/
|
|
255
265
|
export function createTextEditorLayout(
|
|
256
266
|
viewport: () => { id: number } | undefined,
|
|
257
267
|
input: () => TextEditorLayoutInput,
|
|
258
268
|
): TextEditorLayout {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
let [
|
|
269
|
+
// Width and height apart: wrapping depends on the width alone, so a
|
|
270
|
+
// height-only change (a field growing with its content) re-breaks nothing.
|
|
271
|
+
let [viewportWidth, setViewportWidth] = createSignal(0)
|
|
272
|
+
let [viewportHeight, setViewportHeight] = createSignal(0)
|
|
273
|
+
|
|
274
|
+
// The font as a value: input() builds a fresh options object per read, so
|
|
275
|
+
// field equality is what lets the metrics below key on the font itself.
|
|
276
|
+
let font = createMemo(() => input().font, { equals: sameOptions })
|
|
277
|
+
|
|
278
|
+
// One space in the font: its height sits the synthesized blank line, its
|
|
279
|
+
// width is the selection's break sliver. Measured once per font, not once
|
|
280
|
+
// per placement.
|
|
281
|
+
let space = createMemo(() => measureText(" ", font()))
|
|
262
282
|
|
|
263
283
|
let prepared = createMemo(() => {
|
|
264
|
-
let { text,
|
|
265
|
-
return prepareText(text, { ...font, runs, carets: true })
|
|
284
|
+
let { text, runs } = input()
|
|
285
|
+
return prepareText(text, { ...font(), runs, carets: true })
|
|
266
286
|
})
|
|
267
287
|
|
|
288
|
+
// What the breaks depend on, apart from the rest of the input: the caller
|
|
289
|
+
// builds input() per read (a field's value, font, caret and runs - some
|
|
290
|
+
// twenty reactive reads), and a placement re-run for a viewport width
|
|
291
|
+
// change must not pay for that. Equal by field, so only a real change
|
|
292
|
+
// reaches the placement.
|
|
293
|
+
let breaking = createMemo(
|
|
294
|
+
(): BreakInput => {
|
|
295
|
+
let { text, wrap, caretWidth = 0 } = input()
|
|
296
|
+
return { text, wrap, caretWidth }
|
|
297
|
+
},
|
|
298
|
+
{ equals: sameBreakInput },
|
|
299
|
+
)
|
|
300
|
+
|
|
268
301
|
// Lines carry their unit range so the caret math walks only their units.
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
let
|
|
289
|
-
let
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
302
|
+
// Equal placements (same units, same breaks and metrics) keep the previous
|
|
303
|
+
// value, so nothing downstream re-runs for a width change that changed
|
|
304
|
+
// no line. Within a changed placement, a line equal to its predecessor
|
|
305
|
+
// keeps the previous object: a row bound to it (a non-keyed <For> item)
|
|
306
|
+
// sees no change and writes nothing, so a moved break costs the lines it
|
|
307
|
+
// touched, not every line of the field.
|
|
308
|
+
type PlacedLine = EditorLine & { from: number; to: number; hardBreak: boolean }
|
|
309
|
+
// `holds` is the range of wrap widths [min, max) the breaker yields these
|
|
310
|
+
// same lines at (see holdRange); the layout handler skips a width inside it.
|
|
311
|
+
type Placement = { units: TextUnit[]; lines: PlacedLine[]; holds: [number, number] }
|
|
312
|
+
let placed = createMemo(
|
|
313
|
+
(prev): Placement => {
|
|
314
|
+
let { text, wrap, caretWidth } = breaking()
|
|
315
|
+
// Wrapped lines leave room for the caret at the end of a full line, so a
|
|
316
|
+
// wrapping editor never scrolls horizontally.
|
|
317
|
+
let width = wrap ? Math.max(0, viewportWidth() - caretWidth) : Infinity
|
|
318
|
+
let units = wrap ? splitWide(prepared(), width) : prepared()
|
|
319
|
+
let out: PlacedLine[] = []
|
|
320
|
+
let y = 0
|
|
321
|
+
let cursor = 0
|
|
322
|
+
let line = layoutNextLine(units, cursor, width)
|
|
323
|
+
let hardBreak = false
|
|
324
|
+
while (line) {
|
|
325
|
+
out.push({
|
|
326
|
+
start: line.start,
|
|
327
|
+
end: line.end,
|
|
328
|
+
y,
|
|
329
|
+
height: line.height,
|
|
330
|
+
width: line.width,
|
|
331
|
+
from: line.from,
|
|
332
|
+
to: line.to,
|
|
333
|
+
hardBreak: line.hardBreak,
|
|
334
|
+
})
|
|
335
|
+
y += line.height
|
|
336
|
+
hardBreak = line.hardBreak
|
|
337
|
+
line = layoutNextLine(units, line.cursor, width)
|
|
338
|
+
}
|
|
339
|
+
if (out.length === 0 || hardBreak) {
|
|
340
|
+
let n = units.units.length
|
|
341
|
+
out.push({ start: text.length, end: text.length, y, height: space().height, width: 0, from: n, to: n, hardBreak: false })
|
|
342
|
+
}
|
|
343
|
+
if (prev && prev.units === units.units) {
|
|
344
|
+
for (let i = 0; i < out.length && i < prev.lines.length; i++) {
|
|
345
|
+
if (sameLine(prev.lines[i]!, out[i]!)) out[i] = prev.lines[i]!
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// Split units are the width's own (a unit wider than it), so those
|
|
349
|
+
// lines hold for no other width.
|
|
350
|
+
let holds: [number, number] = units.units === prepared().units ? holdRange(units.units, out) : [Infinity, Infinity]
|
|
351
|
+
return { units: units.units, lines: out, holds }
|
|
352
|
+
},
|
|
353
|
+
{ equals: samePlacement },
|
|
354
|
+
)
|
|
294
355
|
let lines = createMemo((): EditorLine[] => placed().lines)
|
|
295
356
|
|
|
296
357
|
// The caret stops of a line, left to right, with the pen advanced per unit;
|
|
@@ -375,7 +436,7 @@ export function createTextEditorLayout(
|
|
|
375
436
|
let ls = lines()
|
|
376
437
|
let first = lineOf(start)
|
|
377
438
|
let last = lineOf(end)
|
|
378
|
-
let breakWidth =
|
|
439
|
+
let breakWidth = space().width
|
|
379
440
|
let out: SelectionRect[] = []
|
|
380
441
|
for (let i = first; i <= last; i++) {
|
|
381
442
|
let line = ls[i]!
|
|
@@ -416,22 +477,60 @@ export function createTextEditorLayout(
|
|
|
416
477
|
return 0
|
|
417
478
|
}
|
|
418
479
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
let
|
|
426
|
-
let
|
|
480
|
+
// Each offset follows from its own previous value: `follow` moves it only
|
|
481
|
+
// when the caret has left the visible range, so the memo carries the
|
|
482
|
+
// retained position across recomputes.
|
|
483
|
+
let scrollX = createMemo((prev: number | undefined): number => {
|
|
484
|
+
let { caretWidth, wrap } = breaking()
|
|
485
|
+
if (wrap) return 0
|
|
486
|
+
let contentWidth = lines().reduce((w, l) => Math.max(w, l.width), 0)
|
|
487
|
+
let c = caret()
|
|
488
|
+
return follow(prev ?? 0, c.x, caretWidth, viewportWidth(), contentWidth + caretWidth)
|
|
489
|
+
})
|
|
490
|
+
let scrollY = createMemo((prev: number | undefined): number => {
|
|
427
491
|
let ls = lines()
|
|
428
|
-
let contentWidth = ls.reduce((w, l) => Math.max(w, l.width), 0)
|
|
429
492
|
let last = ls[ls.length - 1]!
|
|
430
|
-
let contentHeight = last.y + last.height
|
|
431
493
|
let c = caret()
|
|
494
|
+
return follow(prev ?? 0, c.y, c.height, viewportHeight(), last.y + last.height)
|
|
495
|
+
})
|
|
432
496
|
|
|
433
|
-
|
|
434
|
-
|
|
497
|
+
// Whether the lines placed at the last written width are what the breaker
|
|
498
|
+
// yields at `width` too. Such a width is not written: the write would mark
|
|
499
|
+
// the whole graph below the placement for a re-check that finds nothing,
|
|
500
|
+
// which is what a field inside a resizing box paid per frame. So
|
|
501
|
+
// viewportWidth lags the box by less than a break while wrapping, and a
|
|
502
|
+
// placement re-run for a text change at the lagging width is checked here
|
|
503
|
+
// again on that frame and corrected before paint (writes from onLayout
|
|
504
|
+
// land in the same frame's re-layout; the same holds for the height
|
|
505
|
+
// below). Unwrapped, the width is the horizontal scroll's extent and is
|
|
506
|
+
// always written.
|
|
507
|
+
let keepsBreaks = (width: number): boolean => {
|
|
508
|
+
let { wrap, caretWidth } = breaking()
|
|
509
|
+
if (!wrap) return false
|
|
510
|
+
let [min, max] = placed().holds
|
|
511
|
+
let w = Math.max(0, width - caretWidth)
|
|
512
|
+
return w >= min && w < max
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// The same for the height: a height at which the retained scroll offset
|
|
516
|
+
// still keeps the caret in view is not written, so the scroll graph stays
|
|
517
|
+
// untouched while a short field's box grows and shrinks.
|
|
518
|
+
let keepsScroll = (height: number): boolean => {
|
|
519
|
+
let ls = lines()
|
|
520
|
+
let last = ls[ls.length - 1]!
|
|
521
|
+
let c = caret()
|
|
522
|
+
let current = scrollY()
|
|
523
|
+
return follow(current, c.y, c.height, height, last.y + last.height) === current
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
onLayout(() => {
|
|
527
|
+
let node = viewport()
|
|
528
|
+
if (!node) return
|
|
529
|
+
let box = getLayoutBox(node)
|
|
530
|
+
let width = box?.width ?? 0
|
|
531
|
+
let height = box?.height ?? 0
|
|
532
|
+
if (!keepsBreaks(width)) setViewportWidth(width)
|
|
533
|
+
if (!keepsScroll(height)) setViewportHeight(height)
|
|
435
534
|
})
|
|
436
535
|
|
|
437
536
|
return { lines, caret, caretLine, offsetAtX, selectionRects, lineAtY, step, scrollX, scrollY }
|
|
@@ -480,6 +579,52 @@ function splitWide(prepared: PreparedText, width: number): PreparedText {
|
|
|
480
579
|
return { text: prepared.text, units }
|
|
481
580
|
}
|
|
482
581
|
|
|
582
|
+
// The wrap widths [min, max) at which the greedy breaker yields exactly
|
|
583
|
+
// `lines` over `units`: every line's ink fits (min), and no line's first
|
|
584
|
+
// unit fits at the end of the line before it (max), except across a hard
|
|
585
|
+
// break, where it never joins. Greedy breaking is decided line by line on
|
|
586
|
+
// just those two tests, so inside the range the placement is unchanged.
|
|
587
|
+
function holdRange(units: TextUnit[], lines: { from: number; to: number; width: number; hardBreak: boolean }[]): [number, number] {
|
|
588
|
+
let min = 0
|
|
589
|
+
let max = Infinity
|
|
590
|
+
for (let i = 0; i < lines.length; i++) {
|
|
591
|
+
let line = lines[i]!
|
|
592
|
+
if (line.width > min) min = line.width
|
|
593
|
+
let next = lines[i + 1]
|
|
594
|
+
if (!next || line.hardBreak || next.from >= units.length) continue
|
|
595
|
+
let pen = 0
|
|
596
|
+
for (let u = line.from; u < line.to; u++) pen += units[u]!.advance
|
|
597
|
+
let join = pen + unitInk(units, next.from)
|
|
598
|
+
if (join < max) max = join
|
|
599
|
+
}
|
|
600
|
+
return [min, max]
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
type BreakInput = { text: string; wrap: boolean; caretWidth: number }
|
|
604
|
+
|
|
605
|
+
function sameBreakInput(a: BreakInput, b: BreakInput): boolean {
|
|
606
|
+
return a.text === b.text && a.wrap === b.wrap && a.caretWidth === b.caretWidth
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Font options compared field by field: the same keys with the same values.
|
|
610
|
+
function sameOptions(a: MeasureTextOptions, b: MeasureTextOptions): boolean {
|
|
611
|
+
let ka = Object.keys(a) as (keyof MeasureTextOptions)[]
|
|
612
|
+
let kb = Object.keys(b) as (keyof MeasureTextOptions)[]
|
|
613
|
+
return ka.length === kb.length && ka.every((k) => a[k] === b[k])
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// The same break (text range) at the same place with the same metrics.
|
|
617
|
+
function sameLine(a: EditorLine, b: EditorLine): boolean {
|
|
618
|
+
return a.start === b.start && a.end === b.end && a.y === b.y && a.height === b.height && a.width === b.width
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Two placements of the same units with the same lines, break for break and
|
|
622
|
+
// metric for metric.
|
|
623
|
+
function samePlacement(a: { units: TextUnit[]; lines: EditorLine[] }, b: { units: TextUnit[]; lines: EditorLine[] }): boolean {
|
|
624
|
+
if (a.units !== b.units || a.lines.length !== b.lines.length) return false
|
|
625
|
+
return a.lines.every((l, i) => sameLine(l, b.lines[i]!))
|
|
626
|
+
}
|
|
627
|
+
|
|
483
628
|
// The scroll offset along one axis that keeps [pos, pos + size] within a
|
|
484
629
|
// viewport of `extent`, moved only when it is out of view and clamped to the
|
|
485
630
|
// content.
|
package/src/transform.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { onSettled } from "@solidjs/signals"
|
|
|
2
2
|
import { on } from "srt:events"
|
|
3
3
|
import type { PointerEvent } from "./types"
|
|
4
4
|
import { arena, type ArenaOwner } from "./arena"
|
|
5
|
+
import { createVelocityTracker, flingVelocity } from "./velocity"
|
|
6
|
+
import type { Velocity } from "./velocity"
|
|
5
7
|
|
|
6
8
|
// Focal travel or span change in logical pixels before the transform
|
|
7
9
|
// activates; the same threshold createPan uses, so the two race fairly.
|
|
@@ -53,7 +55,10 @@ export interface TransformOptions {
|
|
|
53
55
|
onTransformStart?: () => void
|
|
54
56
|
/** Streams one delta per frame; compose them multiplicatively (scale) / additively (dx, dy, rotation). */
|
|
55
57
|
onTransformMove?: (t: TransformDelta) => void
|
|
56
|
-
|
|
58
|
+
/** The last finger's lift, with the focal point's velocity at it in
|
|
59
|
+
* parent-frame pixels per second (velocity.ts; zero after a rest or
|
|
60
|
+
* under the fling minimum). */
|
|
61
|
+
onTransformEnd?: (velocity: Velocity) => void
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
// The merged transform recognizer: pan + pinch + rotate as ONE gesture over
|
|
@@ -147,6 +152,10 @@ export function createTransform(options: TransformOptions) {
|
|
|
147
152
|
// immediately would bake a jolt into the next delta. A rebase frame
|
|
148
153
|
// emits nothing.
|
|
149
154
|
let rebase = false
|
|
155
|
+
// Parent-frame focal positions per delivering flush, for the lift's
|
|
156
|
+
// velocity; a rebase translates the history by the focal jump so a
|
|
157
|
+
// finger joining or leaving keeps it continuous.
|
|
158
|
+
let tracker = createVelocityTracker()
|
|
150
159
|
|
|
151
160
|
// Centroids in all three frames, the span in window pixels (it feeds the
|
|
152
161
|
// finger-travel gates), the pair angle in the parent frame.
|
|
@@ -193,6 +202,7 @@ export function createTransform(options: TransformOptions) {
|
|
|
193
202
|
pinch = false
|
|
194
203
|
dirty = false
|
|
195
204
|
rebase = false
|
|
205
|
+
tracker.reset()
|
|
196
206
|
}
|
|
197
207
|
let cancel = reset
|
|
198
208
|
let owner: ArenaOwner = { cancel }
|
|
@@ -204,8 +214,12 @@ export function createTransform(options: TransformOptions) {
|
|
|
204
214
|
if (rebase) {
|
|
205
215
|
// Anchor from same-age positions; emits nothing - an activation or
|
|
206
216
|
// set change must not produce a jump delta. Motion that arrived in
|
|
207
|
-
// the same batch folds into the anchor
|
|
217
|
+
// the same batch folds into the anchor (and into the velocity
|
|
218
|
+
// history's shift, one frame the fit never sees).
|
|
219
|
+
let prev = ref
|
|
208
220
|
ref = measure()
|
|
221
|
+
if (prev) tracker.shift(ref.px - prev.px, ref.py - prev.py)
|
|
222
|
+
tracker.push(ref.px, ref.py)
|
|
209
223
|
spanBase = ref.span
|
|
210
224
|
smoothSpan = ref.span
|
|
211
225
|
rebase = false
|
|
@@ -215,6 +229,7 @@ export function createTransform(options: TransformOptions) {
|
|
|
215
229
|
if (!dirty || !ref) return
|
|
216
230
|
dirty = false
|
|
217
231
|
let m = measure()
|
|
232
|
+
tracker.push(m.px, m.py)
|
|
218
233
|
let prevSpan = smoothSpan
|
|
219
234
|
smoothSpan += (m.span - smoothSpan) * SPAN_SMOOTH
|
|
220
235
|
spanRate += (Math.abs(smoothSpan - prevSpan) - spanRate) * QUIET_SMOOTH
|
|
@@ -299,6 +314,7 @@ export function createTransform(options: TransformOptions) {
|
|
|
299
314
|
pinch = Math.abs(m.span - ref.span) >= SLOP
|
|
300
315
|
spanRate = pinch ? 1 : 0
|
|
301
316
|
rebase = true
|
|
317
|
+
tracker.reset()
|
|
302
318
|
options.onTransformStart?.()
|
|
303
319
|
return
|
|
304
320
|
}
|
|
@@ -310,12 +326,16 @@ export function createTransform(options: TransformOptions) {
|
|
|
310
326
|
arena.release(e.pointerId, owner)
|
|
311
327
|
pointers.delete(e.pointerId)
|
|
312
328
|
if (pointers.size === 0) {
|
|
329
|
+
// The lift's own position is not measured (no terminator
|
|
330
|
+
// follows it); the fit over the delivered frames is the speed.
|
|
331
|
+
let velocity = flingVelocity(tracker.velocity())
|
|
313
332
|
active = false
|
|
314
333
|
ref = null
|
|
315
334
|
pinch = false
|
|
316
335
|
dirty = false
|
|
317
336
|
rebase = false
|
|
318
|
-
|
|
337
|
+
tracker.reset()
|
|
338
|
+
options.onTransformEnd?.(velocity)
|
|
319
339
|
} else {
|
|
320
340
|
rebase = true
|
|
321
341
|
pinch = pinch && pointers.size >= 2
|
package/src/types.d.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
/// <reference path="./runtime-modules.d.ts" />
|
|
3
3
|
|
|
4
4
|
import type { Gradient } from "./color"
|
|
5
|
+
import type { CursorId, CursorName } from "./cursor"
|
|
5
6
|
import type { ProgramId, TextureBindings, TextureId } from "flux:gpu"
|
|
6
7
|
import type { TextInputHints } from "flux:rendertree"
|
|
7
8
|
import type { Element } from "solid-js"
|
|
@@ -172,6 +173,11 @@ export interface PaintProps {
|
|
|
172
173
|
strokeCap?: StrokeCap
|
|
173
174
|
strokeJoin?: StrokeJoin
|
|
174
175
|
strokeMiter?: number
|
|
176
|
+
/**
|
|
177
|
+
* Stroke thickness in logical px; default 1 (SVG's). 0 draws no stroke at
|
|
178
|
+
* all, like CSS `border-width: 0` - never a hairline - so a stroke-only
|
|
179
|
+
* shape (or a line) at 0 draws nothing.
|
|
180
|
+
*/
|
|
175
181
|
strokeWidth?: number
|
|
176
182
|
}
|
|
177
183
|
|
|
@@ -380,6 +386,13 @@ export interface PointerProps {
|
|
|
380
386
|
*/
|
|
381
387
|
focusable?: boolean
|
|
382
388
|
pointerEvents?: "auto" | "none" | "all"
|
|
389
|
+
/**
|
|
390
|
+
* The mouse cursor while this element is hovered: a platform shape by its
|
|
391
|
+
* CSS name, `"none"` to hide it, or a createCursor handle for an image.
|
|
392
|
+
* The innermost hovered element that sets one wins; none on the path
|
|
393
|
+
* means the default arrow. Mouse and pen; touch shows no cursor.
|
|
394
|
+
*/
|
|
395
|
+
cursor?: CursorName | CursorId
|
|
383
396
|
}
|
|
384
397
|
|
|
385
398
|
/**
|
|
@@ -643,17 +656,22 @@ export interface TransitionProps {
|
|
|
643
656
|
*/
|
|
644
657
|
stagger?: number
|
|
645
658
|
/**
|
|
646
|
-
* Layout slide: when a layout moves this element (a row
|
|
647
|
-
* above it, one inserted, a `<For>` reorder, a
|
|
648
|
-
*
|
|
649
|
-
*
|
|
659
|
+
* Layout slide: when a layout moves or resizes this element (a row
|
|
660
|
+
* removed above it, one inserted, a `<For>` reorder, a card growing
|
|
661
|
+
* to fit more content, a window resize), it slides from the box it
|
|
662
|
+
* was painted at to its new box on this motion instead of jumping,
|
|
663
|
+
* position and size, and is painted, clipped, hit-tested and
|
|
664
|
+
* backdrop-filtered at the box it is drawn at. While its size is in
|
|
665
|
+
* flight its children are laid out against that box every frame
|
|
666
|
+
* (text re-wraps, rows re-flow), as under a CSS width transition; a
|
|
667
|
+
* child's own `layout` slide yields to the parent's motion. A reflow
|
|
650
668
|
* mid-slide retargets (a spring keeps its velocity). Its first
|
|
651
669
|
* layout, a reparent and a `display: none` toggle snap. The slide is
|
|
652
670
|
* parent-relative: a parent that moves carries its children at
|
|
653
671
|
* once, so declare it on every level that should glide. `true`
|
|
654
672
|
* borrows the `all` entry's motion (an error without one); `all`
|
|
655
|
-
* alone never slides.
|
|
656
|
-
*
|
|
673
|
+
* alone never slides. Settles with onTransitionEnd property
|
|
674
|
+
* `"layout"`.
|
|
657
675
|
*/
|
|
658
676
|
layout?: Omit<TransitionSpring, "from" | "exit"> | Omit<TransitionTween, "from" | "exit"> | TransitionShorthand | boolean
|
|
659
677
|
} & {
|
|
@@ -804,7 +822,16 @@ export interface ViewOwnProps extends TransformProps, PointerProps {
|
|
|
804
822
|
* pixels beneath at that point in the frame, so treat it as a deliberate
|
|
805
823
|
* panel, not a casual style. Inside a `repaintBoundary="snapshot"`
|
|
806
824
|
* subtree it sees only that boundary's own offscreen content, never what
|
|
807
|
-
* is behind the boundary (the same containment `blendMode` has there)
|
|
825
|
+
* is behind the boundary (the same containment `blendMode` has there),
|
|
826
|
+
* and under an ancestor with a `filter` it sees that ancestor's
|
|
827
|
+
* composited layer. An ancestor's `opacity` is not a containment: the
|
|
828
|
+
* glass keeps its blur through the fade (the runtime re-filters the
|
|
829
|
+
* window ahead of the fading group at the group's opacity), so a panel
|
|
830
|
+
* entering with `from: { opacity: 0 }` frosts in as it appears. That
|
|
831
|
+
* re-filter is an approximation: the group's sharp pixels then draw over
|
|
832
|
+
* the blurred ones at its opacity, so mid-fade the glass reads partly
|
|
833
|
+
* sharp, most at a low opacity; exact group blending would need the
|
|
834
|
+
* capture inside the group's layer, which the renderer cannot do.
|
|
808
835
|
*/
|
|
809
836
|
backdropFilter?: FilterProps
|
|
810
837
|
}
|
|
@@ -1052,6 +1079,16 @@ export interface TextProps extends PaintProps, PointerProps, TextRunProps {
|
|
|
1052
1079
|
*/
|
|
1053
1080
|
export interface TextureProps extends PaintProps, PointerProps {
|
|
1054
1081
|
src?: TextureId
|
|
1082
|
+
/**
|
|
1083
|
+
* Corner radius, as on a rect: the image is drawn as a rounded rect filled
|
|
1084
|
+
* with the texture, in one draw and with no clip, so a frosted or image
|
|
1085
|
+
* pane keeps rounded corners at the cost of the plain draw (a rounded
|
|
1086
|
+
* `clipRadius` around it costs a tiled GPU a third of the frame per ten
|
|
1087
|
+
* panes while the box is in flight). A single number rounds all four
|
|
1088
|
+
* corners; an array is [top-left, top-right, bottom-right, bottom-left].
|
|
1089
|
+
* Animatable in its single-number form, like a rect's.
|
|
1090
|
+
*/
|
|
1091
|
+
radius?: number | [number, number, number, number]
|
|
1055
1092
|
/**
|
|
1056
1093
|
* How the texture's pixels map to the element box (CSS object-fit).
|
|
1057
1094
|
* "fill" (default) stretches; "cover" and "none" crop; "contain" and
|
package/src/velocity.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// The velocity tracker every recognizer reads at a lift: the pointer's
|
|
2
|
+
// speed from the positions of its last VELOCITY_WINDOW_MS as a
|
|
3
|
+
// least-squares line, not the last two samples. The final sample before a
|
|
4
|
+
// lift is often stationary, and frame batching makes the last delta a
|
|
5
|
+
// whole frame old, so a two-point estimate reads anything from zero to a
|
|
6
|
+
// jolt; a fit over the window reads the finger's actual speed. One
|
|
7
|
+
// estimator whatever recognizer asks (createPan, createTransform, the
|
|
8
|
+
// pointer feed), so the 2d camera and a control after it never re-derive
|
|
9
|
+
// their own.
|
|
10
|
+
//
|
|
11
|
+
// Samples are positions in whatever frame the caller measures (the
|
|
12
|
+
// recognizers push the node's parent frame, so the velocity applies 1:1
|
|
13
|
+
// as their deltas do; a swipe classifier pushes window pixels, the
|
|
14
|
+
// finger's own travel) with performance.now() at handler time, the
|
|
15
|
+
// precedent in transform.ts: PointerEvent carries no timestamp.
|
|
16
|
+
|
|
17
|
+
// Fit horizon: Flutter's and Android's VelocityTracker window.
|
|
18
|
+
const VELOCITY_WINDOW_MS = 100
|
|
19
|
+
// Largest speed reported, px/s (Flutter's kMaxFlingVelocity): a sensor
|
|
20
|
+
// glitch cannot launch the content across the world.
|
|
21
|
+
const VELOCITY_MAX = 8000
|
|
22
|
+
// A position unchanged for this long at the read means the finger rested
|
|
23
|
+
// before lifting: the velocity is zero, whatever the window still holds.
|
|
24
|
+
// Measured from the last sample that MOVED, not the newest sample's age:
|
|
25
|
+
// the runtime's resampler delivers an extrapolated step and its correction
|
|
26
|
+
// up to two frames after the finger stopped, and any same-position
|
|
27
|
+
// re-delivery must not restart the clock either.
|
|
28
|
+
const VELOCITY_REST_MS = 50
|
|
29
|
+
// Ring capacity: a 100 ms window holds 12 samples at 120 Hz; the rest is
|
|
30
|
+
// room for a burst of same-frame samples.
|
|
31
|
+
const VELOCITY_SAMPLES = 20
|
|
32
|
+
/** Release speeds under this (px/s, Flutter's kMinFlingVelocity) are not a
|
|
33
|
+
* fling: a recognizer delivers zero for them, so a finger that slowed to a
|
|
34
|
+
* stop leaves the content where it is. */
|
|
35
|
+
export const FLING_MIN_VELOCITY = 50
|
|
36
|
+
|
|
37
|
+
export type Velocity = { vx: number; vy: number }
|
|
38
|
+
|
|
39
|
+
export interface VelocityTracker {
|
|
40
|
+
/** Record a position; `at` defaults to performance.now(). */
|
|
41
|
+
push(x: number, y: number, at?: number): void
|
|
42
|
+
/** Translate every sample: a recognizer rebasing its reference point
|
|
43
|
+
* (a finger joining or leaving a transform) keeps the history
|
|
44
|
+
* continuous instead of dropping it. */
|
|
45
|
+
shift(dx: number, dy: number): void
|
|
46
|
+
reset(): void
|
|
47
|
+
/** The speed at `at` (default now), px/s per axis: a least-squares fit
|
|
48
|
+
* over the window, zero after a rest, clamped to VELOCITY_MAX. */
|
|
49
|
+
velocity(at?: number): Velocity
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const ZERO: Velocity = { vx: 0, vy: 0 }
|
|
53
|
+
|
|
54
|
+
/** Zero under FLING_MIN_VELOCITY, else the velocity itself. */
|
|
55
|
+
export let flingVelocity = (v: Velocity): Velocity => (Math.hypot(v.vx, v.vy) < FLING_MIN_VELOCITY ? ZERO : v)
|
|
56
|
+
|
|
57
|
+
export function createVelocityTracker(): VelocityTracker {
|
|
58
|
+
let xs = new Float64Array(VELOCITY_SAMPLES)
|
|
59
|
+
let ys = new Float64Array(VELOCITY_SAMPLES)
|
|
60
|
+
let ts = new Float64Array(VELOCITY_SAMPLES)
|
|
61
|
+
// Ring: `count` samples ending at index `head - 1`.
|
|
62
|
+
let head = 0
|
|
63
|
+
let count = 0
|
|
64
|
+
// When the position last changed (the rest rule's clock).
|
|
65
|
+
let movedAt = -Infinity
|
|
66
|
+
return {
|
|
67
|
+
push(x, y, at = performance.now()) {
|
|
68
|
+
if (count === 0) movedAt = at
|
|
69
|
+
else {
|
|
70
|
+
let last = (head - 1 + VELOCITY_SAMPLES) % VELOCITY_SAMPLES
|
|
71
|
+
if (xs[last] !== x || ys[last] !== y) movedAt = at
|
|
72
|
+
}
|
|
73
|
+
xs[head] = x
|
|
74
|
+
ys[head] = y
|
|
75
|
+
ts[head] = at
|
|
76
|
+
head = (head + 1) % VELOCITY_SAMPLES
|
|
77
|
+
if (count < VELOCITY_SAMPLES) count++
|
|
78
|
+
},
|
|
79
|
+
shift(dx, dy) {
|
|
80
|
+
for (let i = 0; i < count; i++) {
|
|
81
|
+
let k = (head - 1 - i + VELOCITY_SAMPLES) % VELOCITY_SAMPLES
|
|
82
|
+
xs[k] = xs[k]! + dx
|
|
83
|
+
ys[k] = ys[k]! + dy
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
reset() {
|
|
87
|
+
head = 0
|
|
88
|
+
count = 0
|
|
89
|
+
},
|
|
90
|
+
velocity(at = performance.now()) {
|
|
91
|
+
if (count < 2) return ZERO
|
|
92
|
+
if (at - movedAt > VELOCITY_REST_MS) return ZERO
|
|
93
|
+
// Means over the window, then the slope of each axis against time.
|
|
94
|
+
let n = 0
|
|
95
|
+
let tm = 0
|
|
96
|
+
let xm = 0
|
|
97
|
+
let ym = 0
|
|
98
|
+
for (let i = 0; i < count; i++) {
|
|
99
|
+
let k = (head - 1 - i + VELOCITY_SAMPLES) % VELOCITY_SAMPLES
|
|
100
|
+
if (at - ts[k]! > VELOCITY_WINDOW_MS) break
|
|
101
|
+
n++
|
|
102
|
+
tm += ts[k]!
|
|
103
|
+
xm += xs[k]!
|
|
104
|
+
ym += ys[k]!
|
|
105
|
+
}
|
|
106
|
+
if (n < 2) return ZERO
|
|
107
|
+
tm /= n
|
|
108
|
+
xm /= n
|
|
109
|
+
ym /= n
|
|
110
|
+
let tt = 0
|
|
111
|
+
let tx = 0
|
|
112
|
+
let ty = 0
|
|
113
|
+
for (let i = 0; i < n; i++) {
|
|
114
|
+
let k = (head - 1 - i + VELOCITY_SAMPLES) % VELOCITY_SAMPLES
|
|
115
|
+
let dt = ts[k]! - tm
|
|
116
|
+
tt += dt * dt
|
|
117
|
+
tx += dt * (xs[k]! - xm)
|
|
118
|
+
ty += dt * (ys[k]! - ym)
|
|
119
|
+
}
|
|
120
|
+
// Every sample the same age (one batched frame): no slope to read.
|
|
121
|
+
if (tt === 0) return ZERO
|
|
122
|
+
// Slopes are px per ms; the result is px per second.
|
|
123
|
+
let vx = (tx / tt) * 1000
|
|
124
|
+
let vy = (ty / tt) * 1000
|
|
125
|
+
let speed = Math.hypot(vx, vy)
|
|
126
|
+
if (speed > VELOCITY_MAX) {
|
|
127
|
+
let f = VELOCITY_MAX / speed
|
|
128
|
+
vx *= f
|
|
129
|
+
vy *= f
|
|
130
|
+
}
|
|
131
|
+
return { vx, vy }
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
}
|
package/src/video.ts
CHANGED
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
// A plane player (`present: "plane"`, Android) has no texture: the platform
|
|
9
9
|
// composites the picture fullscreen beneath the UI on the video's own clock,
|
|
10
10
|
// and the UI simply draws over it. It exists from open until the owner is
|
|
11
|
-
// disposed
|
|
12
|
-
//
|
|
13
|
-
//
|
|
11
|
+
// disposed. On a platform without a plane the open fails (error() with kind
|
|
12
|
+
// "no-plane"), and an app that runs everywhere falls back to a texture
|
|
13
|
+
// player itself. Both players stream (the source may be a URL, read as it
|
|
14
|
+
// plays) and both run off the frame loop: a texture player's frames are
|
|
15
|
+
// decoded on their own thread and shown at the UI's cadence.
|
|
14
16
|
//
|
|
15
17
|
// The imperative primitive lives in the `flux:video` module; import { open }
|
|
16
18
|
// from "flux:video" for non-reactive use.
|
|
@@ -56,9 +58,8 @@ export type VideoStream = {
|
|
|
56
58
|
/** Pause playback; the current frame stays displayed. */
|
|
57
59
|
pause(): void
|
|
58
60
|
/**
|
|
59
|
-
* Seek to a time in seconds. A no-op while opening
|
|
60
|
-
*
|
|
61
|
-
* target frame is shown.
|
|
61
|
+
* Seek to a time in seconds. A no-op while opening and on a source that
|
|
62
|
+
* cannot seek (see `seekable`). While paused the target frame is shown.
|
|
62
63
|
*/
|
|
63
64
|
seek(seconds: number): void
|
|
64
65
|
/** Whether `seek` does anything (plain read, not a signal). */
|