@rafaelaugusto/jolt 0.1.0-rc.13 → 0.1.0-rc.14

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.
Files changed (86) hide show
  1. package/README.md +2 -2
  2. package/dist/core/drop.d.ts +34 -1
  3. package/dist/core/drop.d.ts.map +1 -1
  4. package/dist/core/drop.js +93 -8
  5. package/dist/core/drop.js.map +1 -1
  6. package/dist/core/loading.d.ts +125 -0
  7. package/dist/core/loading.d.ts.map +1 -0
  8. package/dist/core/loading.js +119 -0
  9. package/dist/core/loading.js.map +1 -0
  10. package/dist/core/preset-recipe.d.ts +2 -2
  11. package/dist/core/preset-recipe.d.ts.map +1 -1
  12. package/dist/core/preset-recipe.js +3 -1
  13. package/dist/core/preset-recipe.js.map +1 -1
  14. package/dist/core/text-pieces.d.ts +18 -0
  15. package/dist/core/text-pieces.d.ts.map +1 -0
  16. package/dist/core/text-pieces.js +43 -0
  17. package/dist/core/text-pieces.js.map +1 -0
  18. package/dist/drag.d.ts +7 -1
  19. package/dist/drag.d.ts.map +1 -1
  20. package/dist/drag.js +72 -37
  21. package/dist/drag.js.map +1 -1
  22. package/dist/drop-zones.d.ts +1 -1
  23. package/dist/drop-zones.d.ts.map +1 -1
  24. package/dist/drop-zones.js +2 -0
  25. package/dist/drop-zones.js.map +1 -1
  26. package/dist/engine.d.ts.map +1 -1
  27. package/dist/engine.js +7 -2
  28. package/dist/engine.js.map +1 -1
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/loader.d.ts +2 -1
  33. package/dist/loader.d.ts.map +1 -1
  34. package/dist/loader.js +17 -15
  35. package/dist/loader.js.map +1 -1
  36. package/dist/reveal.d.ts.map +1 -1
  37. package/dist/reveal.js +4 -3
  38. package/dist/reveal.js.map +1 -1
  39. package/dist/shimmer.d.ts.map +1 -1
  40. package/dist/shimmer.js +6 -4
  41. package/dist/shimmer.js.map +1 -1
  42. package/dist/web/drag.d.ts +8 -0
  43. package/dist/web/drag.d.ts.map +1 -1
  44. package/dist/web/drag.js +68 -14
  45. package/dist/web/drag.js.map +1 -1
  46. package/dist/web/drop-zones.d.ts +2 -2
  47. package/dist/web/drop-zones.d.ts.map +1 -1
  48. package/dist/web/drop-zones.js +21 -4
  49. package/dist/web/drop-zones.js.map +1 -1
  50. package/dist/web/loading.d.ts +2 -1
  51. package/dist/web/loading.d.ts.map +1 -1
  52. package/dist/web/loading.js +19 -15
  53. package/dist/web/loading.js.map +1 -1
  54. package/dist/web/pointer.d.ts +2 -0
  55. package/dist/web/pointer.d.ts.map +1 -1
  56. package/dist/web/pointer.js +13 -0
  57. package/dist/web/pointer.js.map +1 -1
  58. package/dist/web/public.d.ts +1 -1
  59. package/dist/web/public.d.ts.map +1 -1
  60. package/dist/web/public.js.map +1 -1
  61. package/dist/web/reveal.d.ts.map +1 -1
  62. package/dist/web/reveal.js +4 -3
  63. package/dist/web/reveal.js.map +1 -1
  64. package/dist/web/text-effect.d.ts.map +1 -1
  65. package/dist/web/text-effect.js +10 -16
  66. package/dist/web/text-effect.js.map +1 -1
  67. package/package.json +12 -3
  68. package/platforms/android/java/org/rafaelaugusto/jolt/PointerInput.java +26 -4
  69. package/src/core/drop.ts +123 -5
  70. package/src/core/loading.ts +172 -0
  71. package/src/core/preset-recipe.ts +6 -2
  72. package/src/core/text-pieces.ts +52 -0
  73. package/src/drag.ts +81 -38
  74. package/src/drop-zones.ts +13 -3
  75. package/src/engine.ts +8 -3
  76. package/src/index.ts +3 -0
  77. package/src/loader.ts +23 -20
  78. package/src/reveal.ts +4 -3
  79. package/src/shimmer.ts +6 -4
  80. package/src/web/drag.ts +70 -12
  81. package/src/web/drop-zones.ts +31 -6
  82. package/src/web/loading.ts +38 -17
  83. package/src/web/pointer.ts +14 -0
  84. package/src/web/public.ts +3 -0
  85. package/src/web/reveal.ts +7 -8
  86. package/src/web/text-effect.ts +20 -17
package/src/web/drag.ts CHANGED
@@ -26,6 +26,8 @@ export interface WebDragOptions {
26
26
  rubberband?: boolean
27
27
  /** Drive a borrowed numeric value instead of the input element's translation. */
28
28
  value?: WebMotionValue
29
+ /** Element inside the dragged element that alone starts the drag; it may be a button. */
30
+ handle?: HTMLElement
29
31
  onStart?: (event: WebDragEvent) => void
30
32
  onMove?: (event: WebDragEvent) => void
31
33
  onRelease?: (event: WebDragRelease) => void
@@ -86,6 +88,7 @@ export function dragOptions(options: WebDragOptions) {
86
88
  'friction',
87
89
  'rubberband',
88
90
  'value',
91
+ 'handle',
89
92
  'onStart',
90
93
  'onMove',
91
94
  'onRelease',
@@ -197,14 +200,42 @@ export function assertDragSpace(target: HTMLElement, baselineIdentity: boolean)
197
200
  }
198
201
  }
199
202
 
203
+ interface DragAdapter {
204
+ shift(delta: DragPoint): void
205
+ beforeRelease?: (moved: boolean) => void
206
+ }
207
+ const adapters = new WeakMap<WebDrag, DragAdapter>()
208
+ /** Internal adapter hook: offset an active contact without moving it under the pointer. */
209
+ export function shiftDrag(drag: WebDrag, delta: DragPoint) {
210
+ adapters.get(drag)?.shift(delta)
211
+ }
212
+ /** Internal adapter hook: inspect the final input before release motion changes values. */
213
+ export function beforeDragRelease(drag: WebDrag, callback: (moved: boolean) => void) {
214
+ const adapter = adapters.get(drag)
215
+ if (adapter) adapter.beforeRelease = callback
216
+ }
217
+ const contains = (target: HTMLElement, handle: HTMLElement) => {
218
+ for (const node of renderedAncestors(handle))
219
+ if (node === target) return handle !== target
220
+ return false
221
+ }
222
+
200
223
  export function createDrag(target: HTMLElement, input: WebDragOptions, host: DragHost) {
201
224
  const options = dragOptions(input)
202
225
  const document = target.ownerDocument
203
226
  const view = document.defaultView! as Window & typeof globalThis
227
+ const handle = options.handle ?? target
228
+ if (
229
+ options.handle !== undefined &&
230
+ (!(handle instanceof view.HTMLElement) ||
231
+ handle.ownerDocument !== document ||
232
+ !contains(target, handle))
233
+ )
234
+ throw new TypeError('A drag handle must be an element inside the dragged element.')
204
235
  const abort = new view.AbortController()
205
- const originalTouch = target.style.touchAction
236
+ const originalTouch = handle.style.touchAction
206
237
  const touch = options.axis === 'x' ? 'pan-y' : options.axis === 'y' ? 'pan-x' : 'none'
207
- target.style.touchAction = touch
238
+ handle.style.touchAction = touch
208
239
  let pointer: number | undefined,
209
240
  disabled = options.disabled ?? false,
210
241
  disposed = false,
@@ -251,10 +282,11 @@ export function createDrag(target: HTMLElement, input: WebDragOptions, host: Dra
251
282
  disposed ||
252
283
  !target.isConnected ||
253
284
  target.ownerDocument !== document ||
254
- target.matches(':disabled')
285
+ target.matches(':disabled') ||
286
+ (handle !== target && (handle.matches(':disabled') || !contains(target, handle)))
255
287
  )
256
288
  return true
257
- for (const node of renderedAncestors(target)) {
289
+ for (const node of renderedAncestors(handle)) {
258
290
  if (
259
291
  node.hasAttribute('inert') ||
260
292
  node.getAttribute('aria-disabled') === 'true' ||
@@ -285,7 +317,7 @@ export function createDrag(target: HTMLElement, input: WebDragOptions, host: Dra
285
317
  notify('onMove', velocity(samples.at(-1)?.time ?? 0))
286
318
  }
287
319
  const releaseCapture = (id: number) => {
288
- if (target.hasPointerCapture(id)) target.releasePointerCapture(id)
320
+ if (handle.hasPointerCapture(id)) handle.releasePointerCapture(id)
289
321
  }
290
322
  const stopRelease = () => {
291
323
  const current = animation
@@ -363,7 +395,7 @@ export function createDrag(target: HTMLElement, input: WebDragOptions, host: Dra
363
395
  passive,
364
396
  capture: captureEvent,
365
397
  })
366
- listen(target, 'pointerdown', (event: PointerEvent) => {
398
+ listen(handle, 'pointerdown', (event: PointerEvent) => {
367
399
  if (
368
400
  disposed ||
369
401
  pointer !== undefined ||
@@ -374,7 +406,7 @@ export function createDrag(target: HTMLElement, input: WebDragOptions, host: Dra
374
406
  )
375
407
  return
376
408
  for (const node of event.composedPath()) {
377
- if (node === target) break
409
+ if (node === handle) break
378
410
  if (
379
411
  node instanceof view.Element &&
380
412
  node.matches(
@@ -404,7 +436,7 @@ export function createDrag(target: HTMLElement, input: WebDragOptions, host: Dra
404
436
  samples = []
405
437
  capture(event)
406
438
  try {
407
- target.setPointerCapture(pointer)
439
+ handle.setPointerCapture(pointer)
408
440
  notify('onStart')
409
441
  } catch (error) {
410
442
  cancel()
@@ -436,6 +468,15 @@ export function createDrag(target: HTMLElement, input: WebDragOptions, host: Dra
436
468
  capture(event)
437
469
  flush()
438
470
  if (pointer === undefined) return
471
+ const hook = adapter.beforeRelease
472
+ if (hook) {
473
+ try {
474
+ hook(moved)
475
+ } catch (error) {
476
+ host.error(error)
477
+ }
478
+ if (pointer === undefined) return
479
+ }
439
480
  const p = position(),
440
481
  id = pointer
441
482
  pointer = undefined
@@ -494,7 +535,7 @@ export function createDrag(target: HTMLElement, input: WebDragOptions, host: Dra
494
535
  }
495
536
  watchRoot()
496
537
  listen(
497
- target,
538
+ handle,
498
539
  'dragstart',
499
540
  (event) => {
500
541
  if (pointer !== undefined) event.preventDefault()
@@ -502,7 +543,7 @@ export function createDrag(target: HTMLElement, input: WebDragOptions, host: Dra
502
543
  false,
503
544
  )
504
545
  listen(
505
- target,
546
+ handle,
506
547
  'click',
507
548
  (event: MouseEvent) => {
508
549
  if (event.detail > 0 && view.performance.now() < suppressUntil) {
@@ -525,10 +566,25 @@ export function createDrag(target: HTMLElement, input: WebDragOptions, host: Dra
525
566
  scrollRoot = undefined
526
567
  if (stopAnimation && target.isConnected && target.ownerDocument === document)
527
568
  animation?.stop()
528
- if (target.style.touchAction === touch) target.style.touchAction = originalTouch
569
+ if (handle.style.touchAction === touch) handle.style.touchAction = originalTouch
529
570
  host.dispose()
530
571
  }
531
- return {
572
+ const adapter: DragAdapter = {
573
+ shift: (delta) => shift(delta),
574
+ }
575
+ const shift = (delta: DragPoint) => {
576
+ if (pointer === undefined || disposed) return
577
+ const before = position()
578
+ origin = { x: origin.x + delta.x, y: origin.y + delta.y }
579
+ const after = position()
580
+ samples = samples.map((sample) => ({
581
+ ...sample,
582
+ x: sample.x + after.x - before.x,
583
+ y: sample.y + after.y - before.y,
584
+ }))
585
+ host.write(after, velocity(samples.at(-1)?.time ?? 0))
586
+ }
587
+ const controller = {
532
588
  handle: {
533
589
  get dragging() {
534
590
  return pointer !== undefined
@@ -580,4 +636,6 @@ export function createDrag(target: HTMLElement, input: WebDragOptions, host: Dra
580
636
  },
581
637
  detach: () => dispose(!!options.value),
582
638
  }
639
+ adapters.set(controller.handle, adapter)
640
+ return controller
583
641
  }
@@ -1,7 +1,20 @@
1
1
  import { createDropPolicy, type DropOptions, type DropZones } from '../core/drop.js'
2
- import type { WebDrag, WebDragOptions } from './drag.js'
2
+ import {
3
+ beforeDragRelease,
4
+ shiftDrag,
5
+ type WebDrag,
6
+ type WebDragOptions,
7
+ } from './drag.js'
3
8
  import { inputBlocked, inputEvents, type InteractionHost } from './interaction.js'
4
- export type { DropEvent, DropTarget, DropZones, DropEffect } from '../core/drop.js'
9
+ export type {
10
+ DropEvent,
11
+ DropMoveEvent,
12
+ DropPadding,
13
+ DropRect,
14
+ DropTarget,
15
+ DropZones,
16
+ DropEffect,
17
+ } from '../core/drop.js'
5
18
  export interface WebDropZoneOptions extends DropOptions<HTMLElement> {
6
19
  drag?: Omit<
7
20
  WebDragOptions,
@@ -46,6 +59,12 @@ export function createDropZones(
46
59
  enabled: () => !document.hidden && !inputOptions.disabled,
47
60
  cancelDrag: cancelInput,
48
61
  error: host.error,
62
+ translation: () => input?.position ?? { x: 0, y: 0 },
63
+ shift: (x, y) => {
64
+ if (!input) return
65
+ shiftDrag(input, { x, y })
66
+ flushSource()
67
+ },
49
68
  })
50
69
  const releases: (() => void)[] = []
51
70
  const controller: DropZones = {
@@ -65,7 +84,11 @@ export function createDropZones(
65
84
  return policy.handle.disposed
66
85
  },
67
86
  request: (id) => policy.handle.request(id),
68
- refresh: () => policy.handle.refresh(),
87
+ refresh: () => {
88
+ // Measure the painted translation, not a newer unrendered input sample.
89
+ if (policy.handle.dragging) flushSource()
90
+ policy.handle.refresh()
91
+ },
69
92
  cancel() {
70
93
  try {
71
94
  policy.handle.cancel()
@@ -104,13 +127,15 @@ export function createDropZones(
104
127
  flushSource()
105
128
  policy.move()
106
129
  },
107
- onRelease: () => {
108
- policy.release()
109
- },
110
130
  onCancel: () => {
111
131
  if (!handoff) policy.handle.cancel()
112
132
  },
113
133
  })
134
+ // Commit before the return motion starts; reduced motion would settle it at once.
135
+ beforeDragRelease(input, (moved) => {
136
+ if (moved) policy.release()
137
+ else policy.handle.cancel()
138
+ })
114
139
  releases.push(host.register(source, controller, controller.refresh))
115
140
  // A removed accepting target cancels its owned effect.
116
141
  for (const entry of options.targets)
@@ -8,13 +8,14 @@ import {
8
8
  type EffectHost,
9
9
  } from './effect.js'
10
10
  import { parseColor } from './style.js'
11
+ import { loaderDefinition, shimmerDefinition, type LoaderStyle } from '../core/loading.js'
11
12
  import type { WebInteraction } from './interaction.js'
12
13
 
13
14
  export interface WebShimmerOptions {
14
15
  duration?: number
15
16
  }
16
17
  export interface WebLoaderOptions {
17
- style?: 'dots' | 'bars' | 'ring'
18
+ style?: LoaderStyle
18
19
  color?: string
19
20
  size?: number
20
21
  duration?: number
@@ -35,17 +36,26 @@ export function createShimmer(
35
36
  effectTarget(target, host)
36
37
  effectOptions(options, ['duration'])
37
38
  const duration =
38
- finiteOption(options.duration ?? 1500, 'Shimmer duration', 0, false) /
39
- host.settings().timeScale
39
+ finiteOption(
40
+ options.duration ?? shimmerDefinition.duration,
41
+ 'Shimmer duration',
42
+ 0,
43
+ false,
44
+ ) / host.settings().timeScale
45
+ const { angle, stops, opacity, travel } = shimmerDefinition.highlight
46
+ const band = stops
47
+ .map((stop, i) =>
48
+ opacity[i] ? `rgba(255,255,255,${opacity[i]}) ${stop}%` : `transparent ${stop}%`,
49
+ )
50
+ .join(',')
40
51
  return loading(target, 'shimmer', host, (layer) => {
41
52
  const beam = host.document.createElement('span')
42
- beam.style.cssText =
43
- 'display:block;position:absolute;inset:0;background:linear-gradient(100deg,transparent 15%,rgba(255,255,255,.55) 50%,transparent 85%);'
53
+ beam.style.cssText = `display:block;position:absolute;inset:0;background:linear-gradient(${angle}deg,${band});`
44
54
  layer.append(beam)
45
55
  return [
46
56
  {
47
57
  target: beam,
48
- frames: [{ transform: 'translateX(-100%)' }, { transform: 'translateX(100%)' }],
58
+ frames: travel.map((x) => ({ transform: `translateX(${x * 100}%)` })),
49
59
  timing: { duration },
50
60
  },
51
61
  ]
@@ -59,17 +69,27 @@ export function createLoader(
59
69
  ) {
60
70
  effectTarget(target, host)
61
71
  effectOptions(options, ['style', 'color', 'size', 'duration'])
62
- const style = options.style ?? 'dots',
63
- color = options.color ?? '#71717a'
72
+ const style = options.style ?? loaderDefinition.style,
73
+ color = options.color ?? loaderDefinition.color
64
74
  if (!['dots', 'bars', 'ring'].includes(style))
65
75
  throw new TypeError('Unknown loader style.')
66
76
  if (typeof color !== 'string')
67
77
  throw new TypeError('Loader color must be a resolved CSS color.')
68
78
  parseColor(host.document, color)
69
- const size = finiteOption(options.size ?? 48, 'Loader size', 0, false)
79
+ const size = finiteOption(
80
+ options.size ?? loaderDefinition.size,
81
+ 'Loader size',
82
+ 0,
83
+ false,
84
+ )
70
85
  const duration =
71
- finiteOption(options.duration ?? 1200, 'Loader duration', 0, false) /
72
- host.settings().timeScale
86
+ finiteOption(
87
+ options.duration ?? loaderDefinition.duration,
88
+ 'Loader duration',
89
+ 0,
90
+ false,
91
+ ) / host.settings().timeScale
92
+ const css = loaderDefinition.css
73
93
  return loading(target, 'loader', host, (layer) => {
74
94
  layer.style.display = 'grid'
75
95
  layer.style.placeItems = 'center'
@@ -78,7 +98,7 @@ export function createLoader(
78
98
  layer.append(box)
79
99
  if (style === 'ring') {
80
100
  const ring = host.document.createElement('span')
81
- ring.style.cssText = `display:block;width:75%;aspect-ratio:1;box-sizing:border-box;border:3px solid ${color};border-right-color:transparent;border-radius:50%;`
101
+ ring.style.cssText = `display:block;width:${css.ring.diameter}%;aspect-ratio:1;box-sizing:border-box;border:${css.ring.border}px solid ${color};border-right-color:transparent;border-radius:50%;`
82
102
  box.append(ring)
83
103
  return [
84
104
  {
@@ -88,18 +108,19 @@ export function createLoader(
88
108
  },
89
109
  ]
90
110
  }
91
- return Array.from({ length: style === 'dots' ? 3 : 5 }, (_, i) => {
111
+ const shape = css[style]
112
+ return Array.from({ length: loaderDefinition.kinds[style].count }, (_, i) => {
92
113
  const item = host.document.createElement('span')
93
- item.style.cssText = `display:block;background:${color};width:${style === 'dots' ? 20 : 12}%;height:${style === 'dots' ? 20 : 65}%;border-radius:${style === 'dots' ? '50%' : '3px'};`
114
+ item.style.cssText = `display:block;background:${color};width:${shape.width}%;height:${shape.height}%;border-radius:${shape.radius};`
94
115
  box.append(item)
95
116
  return {
96
117
  target: item,
97
118
  frames: [
98
- { transform: 'scaleY(.45)', opacity: 0.45 },
119
+ { transform: `scaleY(${css.pulse})`, opacity: css.pulse },
99
120
  { transform: 'scaleY(1)', opacity: 1 },
100
- { transform: 'scaleY(.45)', opacity: 0.45 },
121
+ { transform: `scaleY(${css.pulse})`, opacity: css.pulse },
101
122
  ],
102
- timing: { duration, delay: (i * duration) / 7, easing: 'ease-in-out' },
123
+ timing: { duration, delay: (i * duration) / css.stagger, easing: css.ease },
103
124
  }
104
125
  })
105
126
  })
@@ -20,6 +20,8 @@ export interface WebPointerFollow {
20
20
  readonly y: WebReadableValue
21
21
  readonly presence: WebReadableValue
22
22
  readonly active: boolean
23
+ /** Pointer Events plus `(any-hover: hover)` while the source is listening; not a connected-device check. */
24
+ readonly available: boolean
23
25
  readonly disposed: boolean
24
26
  readonly suspended: boolean
25
27
  update(state: { disabled: boolean }): void
@@ -110,6 +112,10 @@ export function createPointerFollow(
110
112
  active = false
111
113
  let dirty = false,
112
114
  latest: { x: number; y: number; id: number } | undefined
115
+ const hover =
116
+ typeof view.PointerEvent === 'function'
117
+ ? view.matchMedia?.('(any-hover: hover)')
118
+ : undefined
113
119
  let abort: AbortController | undefined
114
120
  let observer: MutationObserver | undefined
115
121
  let resize: ResizeObserver | undefined
@@ -204,6 +210,10 @@ export function createPointerFollow(
204
210
  if (document.hidden) reset(true)
205
211
  else invalidate()
206
212
  }
213
+ const capability = () => {
214
+ if (!hover?.matches) reset()
215
+ else invalidate()
216
+ }
207
217
  const detach = () => {
208
218
  abort?.abort()
209
219
  abort = undefined
@@ -222,6 +232,7 @@ export function createPointerFollow(
222
232
  region.addEventListener('pointercancel', leave, options)
223
233
  view.addEventListener('blur', () => reset(true), options)
224
234
  document.addEventListener('visibilitychange', visibility, options)
235
+ hover?.addEventListener?.('change', capability, options)
225
236
  document.addEventListener('scroll', invalidate, { ...options, capture: true })
226
237
  view.addEventListener('resize', invalidate, options)
227
238
  document.fonts?.addEventListener('loadingdone', invalidate, options)
@@ -260,6 +271,9 @@ export function createPointerFollow(
260
271
  get active() {
261
272
  return active
262
273
  },
274
+ get available() {
275
+ return !disposed && !suspended && !!hover?.matches && !blocked()
276
+ },
263
277
  get disposed() {
264
278
  return disposed
265
279
  },
package/src/web/public.ts CHANGED
@@ -39,6 +39,9 @@ export type {
39
39
  DropZones,
40
40
  DropTarget,
41
41
  DropEvent,
42
+ DropMoveEvent,
43
+ DropPadding,
44
+ DropRect,
42
45
  DropEffect,
43
46
  } from './drop-zones.js'
44
47
  export type {
package/src/web/reveal.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createTrack } from '../core/track.js'
2
2
  import { resolveTransition } from '../core/transition.js'
3
+ import { revealDefinition, revealRadius } from '../core/loading.js'
3
4
  import { flatten, sampled } from './curve.js'
4
5
  import { borderBox } from './style.js'
5
6
  import {
@@ -31,7 +32,10 @@ export function createReveal(
31
32
  ) {
32
33
  const view = effectTarget(target, host)
33
34
  effectOptions(options, [opening ? 'from' : 'to', 'duration'])
34
- const duration = finiteOption(options.duration ?? 400, 'Reveal duration')
35
+ const duration = finiteOption(
36
+ options.duration ?? revealDefinition.duration,
37
+ 'Reveal duration',
38
+ )
35
39
  const point = opening
36
40
  ? (options as WebRevealOptions).from
37
41
  : (options as WebConcealOptions).to
@@ -46,18 +50,13 @@ export function createReveal(
46
50
  const bounds = borderBox(target),
47
51
  x = point?.x ?? bounds.width / 2,
48
52
  y = point?.y ?? bounds.height / 2
49
- const radius = Math.max(
50
- Math.hypot(x, y),
51
- Math.hypot(bounds.width - x, y),
52
- Math.hypot(x, bounds.height - y),
53
- Math.hypot(bounds.width - x, bounds.height - y),
54
- )
53
+ const radius = revealRadius(bounds.width, bounds.height, x, y)
55
54
  const initial = before?.circle() ?? { x, y, radius: opening ? 0 : radius }
56
55
  const destination = { x, y, radius: opening ? radius : 0 }
57
56
  const track = createTrack(
58
57
  'x',
59
58
  [0, 1],
60
- resolveTransition({ duration, ease: 'easeInOut' }, 'x', 2),
59
+ resolveTransition({ duration, ease: revealDefinition.ease }, 'x', 2),
61
60
  0,
62
61
  host.settings(),
63
62
  )
@@ -1,4 +1,11 @@
1
1
  import { graphemes, diffFromEnd } from '../core/roll-diff.js'
2
+ import {
3
+ MAX_TEXT_GRAPHEMES,
4
+ isVisiblePiece,
5
+ staggeredStart,
6
+ textPieces as pieces,
7
+ type TextPiece as Piece,
8
+ } from '../core/text-pieces.js'
2
9
  import { createTrack, type Track } from '../core/track.js'
3
10
  import { resolveTransition } from '../core/transition.js'
4
11
  import { flatten } from './curve.js'
@@ -31,7 +38,6 @@ export interface WebRollOptions {
31
38
  direction?: 'auto' | 'up' | 'down'
32
39
  tabular?: boolean
33
40
  }
34
- type Piece = { start: number; end: number; text: string; group: number }
35
41
  type Fragment = { element: HTMLElement; start: number; text: string; group: number }
36
42
  type Copy = { layer: HTMLElement; fragments: Fragment[]; width: number; height: number }
37
43
  type Active = { host: EffectHost; animation: EffectAnimation; snapshot(): HTMLElement }
@@ -115,22 +121,11 @@ function source(target: HTMLElement, host: EffectHost) {
115
121
  if (old && old.host.identity !== host.identity)
116
122
  throw new Error('Text belongs to another motion context.')
117
123
  const text = (nodes[0] as Text | undefined)?.data ?? ''
118
- if (graphemes(text).length > 256)
124
+ if (graphemes(text).length > MAX_TEXT_GRAPHEMES)
119
125
  throw new RangeError('Text effects support at most 256 graphemes.')
120
126
  return { view, css, node: nodes[0] as Text | undefined, text, old }
121
127
  }
122
128
 
123
- function pieces(text: string, by: 'letter' | 'word'): Piece[] {
124
- if (by === 'letter') return graphemes(text).map((part, group) => ({ ...part, group }))
125
- const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' })
126
- return [...segmenter.segment(text)].map(({ segment, index }, group) => ({
127
- text: segment,
128
- start: index,
129
- end: index + segment.length,
130
- group,
131
- }))
132
- }
133
-
134
129
  /** Each piece clips a complete shaped copy, preserving ligatures, bidi and line wrapping. */
135
130
  function copy(
136
131
  target: HTMLElement,
@@ -156,7 +151,7 @@ function copy(
156
151
  if (!node) return { layer, fragments, width, height }
157
152
  const range = host.document.createRange()
158
153
  for (const piece of parts) {
159
- if (!piece.text.trim()) continue
154
+ if (!isVisiblePiece(piece)) continue
160
155
  range.setStart(node, piece.start)
161
156
  range.setEnd(node, piece.end)
162
157
  for (const rect of range.getClientRects()) {
@@ -321,7 +316,11 @@ export function createTextAnimation(
321
316
  'x',
322
317
  [0, 1],
323
318
  resolveTransition(
324
- { duration, delay: delay + fragment.group * stagger, ease: 'easeOut' },
319
+ {
320
+ duration,
321
+ delay: staggeredStart(fragment.group, stagger, delay),
322
+ ease: 'easeOut',
323
+ },
325
324
  'x',
326
325
  2,
327
326
  ),
@@ -375,7 +374,7 @@ export function createRoll(
375
374
  )
376
375
  throw new TypeError('Roll value must be finite or a string.')
377
376
  const text = typeof value === 'string' ? value : (options.format ?? String)(value)
378
- if (typeof text !== 'string' || graphemes(text).length > 256)
377
+ if (typeof text !== 'string' || graphemes(text).length > MAX_TEXT_GRAPHEMES)
379
378
  throw new RangeError('Roll formatting must produce at most 256 graphemes.')
380
379
  const initial = source(target, host)
381
380
  const previous = lastValues.get(target) ?? Number(initial.text)
@@ -419,7 +418,11 @@ export function createRoll(
419
418
  createTrack(
420
419
  'x',
421
420
  [0, 1],
422
- resolveTransition({ duration, bounce, delay: delay + index * stagger }, 'x', 2),
421
+ resolveTransition(
422
+ { duration, bounce, delay: staggeredStart(index, stagger, delay) },
423
+ 'x',
424
+ 2,
425
+ ),
423
426
  0,
424
427
  host.settings(),
425
428
  )