@rafaelaugusto/jolt 0.1.0-beta.0 → 0.1.0-beta.10
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 +441 -25
- package/dist/animate.d.ts +21 -5
- package/dist/animate.d.ts.map +1 -1
- package/dist/animate.js +205 -25
- package/dist/animate.js.map +1 -1
- package/dist/color.d.ts +11 -0
- package/dist/color.d.ts.map +1 -0
- package/dist/color.js +73 -0
- package/dist/color.js.map +1 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +73 -0
- package/dist/config.js.map +1 -0
- package/dist/decay.d.ts +11 -0
- package/dist/decay.d.ts.map +1 -0
- package/dist/decay.js +22 -0
- package/dist/decay.js.map +1 -0
- package/dist/decoration.d.ts +18 -0
- package/dist/decoration.d.ts.map +1 -0
- package/dist/decoration.js +155 -0
- package/dist/decoration.js.map +1 -0
- package/dist/drag-input.d.ts +15 -0
- package/dist/drag-input.d.ts.map +1 -0
- package/dist/drag-input.js +66 -0
- package/dist/drag-input.js.map +1 -0
- package/dist/drag-physics.d.ts +20 -0
- package/dist/drag-physics.d.ts.map +1 -0
- package/dist/drag-physics.js +75 -0
- package/dist/drag-physics.js.map +1 -0
- package/dist/drag.d.ts +58 -0
- package/dist/drag.d.ts.map +1 -0
- package/dist/drag.js +324 -0
- package/dist/drag.js.map +1 -0
- package/dist/driver/android-color.d.ts +20 -0
- package/dist/driver/android-color.d.ts.map +1 -0
- package/dist/driver/android-color.js +136 -0
- package/dist/driver/android-color.js.map +1 -0
- package/dist/driver/android-decoration.d.ts +22 -0
- package/dist/driver/android-decoration.d.ts.map +1 -0
- package/dist/driver/android-decoration.js +139 -0
- package/dist/driver/android-decoration.js.map +1 -0
- package/dist/driver/android-playback.d.ts +13 -0
- package/dist/driver/android-playback.d.ts.map +1 -0
- package/dist/driver/android-playback.js +141 -0
- package/dist/driver/android-playback.js.map +1 -0
- package/dist/driver/android.d.ts +1 -1
- package/dist/driver/android.d.ts.map +1 -1
- package/dist/driver/android.js +165 -13
- package/dist/driver/android.js.map +1 -1
- package/dist/driver/index.d.ts +25 -2
- package/dist/driver/index.d.ts.map +1 -1
- package/dist/driver/index.js.map +1 -1
- package/dist/driver/ios-color.d.ts +16 -0
- package/dist/driver/ios-color.d.ts.map +1 -0
- package/dist/driver/ios-color.js +392 -0
- package/dist/driver/ios-color.js.map +1 -0
- package/dist/driver/ios-decoration.d.ts +17 -0
- package/dist/driver/ios-decoration.d.ts.map +1 -0
- package/dist/driver/ios-decoration.js +271 -0
- package/dist/driver/ios-decoration.js.map +1 -0
- package/dist/driver/ios-layout.d.ts +9 -0
- package/dist/driver/ios-layout.d.ts.map +1 -0
- package/dist/driver/ios-layout.js +52 -0
- package/dist/driver/ios-layout.js.map +1 -0
- package/dist/driver/ios-playback.d.ts +10 -0
- package/dist/driver/ios-playback.d.ts.map +1 -0
- package/dist/driver/ios-playback.js +47 -0
- package/dist/driver/ios-playback.js.map +1 -0
- package/dist/driver/ios-shadow.d.ts +6 -0
- package/dist/driver/ios-shadow.d.ts.map +1 -0
- package/dist/driver/ios-shadow.js +52 -0
- package/dist/driver/ios-shadow.js.map +1 -0
- package/dist/driver/ios.d.ts.map +1 -1
- package/dist/driver/ios.js +164 -20
- package/dist/driver/ios.js.map +1 -1
- package/dist/engine.d.ts +76 -5
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +303 -23
- package/dist/engine.js.map +1 -1
- package/dist/haptics.d.ts +21 -0
- package/dist/haptics.d.ts.map +1 -0
- package/dist/haptics.js +120 -0
- package/dist/haptics.js.map +1 -0
- package/dist/image-effect-native.d.ts +26 -0
- package/dist/image-effect-native.d.ts.map +1 -0
- package/dist/image-effect-native.js +73 -0
- package/dist/image-effect-native.js.map +1 -0
- package/dist/image-effect.d.ts +50 -0
- package/dist/image-effect.d.ts.map +1 -0
- package/dist/image-effect.js +290 -0
- package/dist/image-effect.js.map +1 -0
- package/dist/image-owner.d.ts +3 -0
- package/dist/image-owner.d.ts.map +1 -0
- package/dist/image-owner.js +11 -0
- package/dist/image-owner.js.map +1 -0
- package/dist/index.d.ts +45 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -2
- package/dist/index.js.map +1 -1
- package/dist/interaction-surface.d.ts +12 -0
- package/dist/interaction-surface.d.ts.map +1 -0
- package/dist/interaction-surface.js +36 -0
- package/dist/interaction-surface.js.map +1 -0
- package/dist/items.d.ts +20 -0
- package/dist/items.d.ts.map +1 -0
- package/dist/items.js +109 -0
- package/dist/items.js.map +1 -0
- package/dist/keyboard.d.ts +41 -0
- package/dist/keyboard.d.ts.map +1 -0
- package/dist/keyboard.js +256 -0
- package/dist/keyboard.js.map +1 -0
- package/dist/label.d.ts +7 -0
- package/dist/label.d.ts.map +1 -0
- package/dist/label.js +60 -0
- package/dist/label.js.map +1 -0
- package/dist/layout.d.ts +13 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +348 -0
- package/dist/layout.js.map +1 -0
- package/dist/liquid-grid.d.ts +3 -0
- package/dist/liquid-grid.d.ts.map +1 -0
- package/dist/liquid-grid.js +24 -0
- package/dist/liquid-grid.js.map +1 -0
- package/dist/liquid.d.ts +18 -0
- package/dist/liquid.d.ts.map +1 -0
- package/dist/liquid.js +234 -0
- package/dist/liquid.js.map +1 -0
- package/dist/loader.d.ts +19 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +173 -0
- package/dist/loader.js.map +1 -0
- package/dist/motion-scope.d.ts +39 -0
- package/dist/motion-scope.d.ts.map +1 -0
- package/dist/motion-scope.js +238 -0
- package/dist/motion-scope.js.map +1 -0
- package/dist/motion-states.d.ts +36 -0
- package/dist/motion-states.d.ts.map +1 -0
- package/dist/motion-states.js +165 -0
- package/dist/motion-states.js.map +1 -0
- package/dist/particles.d.ts +39 -0
- package/dist/particles.d.ts.map +1 -0
- package/dist/particles.js +265 -0
- package/dist/particles.js.map +1 -0
- package/dist/path.d.ts +12 -0
- package/dist/path.d.ts.map +1 -0
- package/dist/path.js +90 -0
- package/dist/path.js.map +1 -0
- package/dist/playback-group.d.ts +6 -0
- package/dist/playback-group.d.ts.map +1 -0
- package/dist/playback-group.js +141 -0
- package/dist/playback-group.js.map +1 -0
- package/dist/playback.d.ts +25 -0
- package/dist/playback.d.ts.map +1 -0
- package/dist/playback.js +35 -0
- package/dist/playback.js.map +1 -0
- package/dist/presets.d.ts +16 -1
- package/dist/presets.d.ts.map +1 -1
- package/dist/presets.js +24 -6
- package/dist/presets.js.map +1 -1
- package/dist/press.d.ts.map +1 -1
- package/dist/press.js +25 -7
- package/dist/press.js.map +1 -1
- package/dist/properties.d.ts +6 -3
- package/dist/properties.d.ts.map +1 -1
- package/dist/properties.js +26 -2
- package/dist/properties.js.map +1 -1
- package/dist/reveal.d.ts +25 -0
- package/dist/reveal.d.ts.map +1 -0
- package/dist/reveal.js +294 -0
- package/dist/reveal.js.map +1 -0
- package/dist/roll-diff.d.ts +22 -0
- package/dist/roll-diff.d.ts.map +1 -0
- package/dist/roll-diff.js +36 -0
- package/dist/roll-diff.js.map +1 -0
- package/dist/roll.d.ts +29 -0
- package/dist/roll.d.ts.map +1 -0
- package/dist/roll.js +442 -0
- package/dist/roll.js.map +1 -0
- package/dist/scroll-linked.d.ts +10 -0
- package/dist/scroll-linked.d.ts.map +1 -0
- package/dist/scroll-linked.js +58 -0
- package/dist/scroll-linked.js.map +1 -0
- package/dist/sequence.d.ts +13 -7
- package/dist/sequence.d.ts.map +1 -1
- package/dist/sequence.js +214 -19
- package/dist/sequence.js.map +1 -1
- package/dist/shimmer.d.ts +12 -0
- package/dist/shimmer.d.ts.map +1 -0
- package/dist/shimmer.js +161 -0
- package/dist/shimmer.js.map +1 -0
- package/dist/svg-path.d.ts +14 -0
- package/dist/svg-path.d.ts.map +1 -0
- package/dist/svg-path.js +281 -0
- package/dist/svg-path.js.map +1 -0
- package/dist/symbol-effect.d.ts +23 -0
- package/dist/symbol-effect.d.ts.map +1 -0
- package/dist/symbol-effect.js +187 -0
- package/dist/symbol-effect.js.map +1 -0
- package/dist/text-layout.d.ts +30 -0
- package/dist/text-layout.d.ts.map +1 -0
- package/dist/text-layout.js +185 -0
- package/dist/text-layout.js.map +1 -0
- package/dist/text.d.ts +17 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +263 -0
- package/dist/text.js.map +1 -0
- package/dist/track.d.ts +3 -1
- package/dist/track.d.ts.map +1 -1
- package/dist/track.js +266 -13
- package/dist/track.js.map +1 -1
- package/dist/transition.d.ts +17 -1
- package/dist/transition.d.ts.map +1 -1
- package/dist/transition.js +41 -6
- package/dist/transition.js.map +1 -1
- package/dist/types.d.ts +50 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/vue/index.d.ts +95 -0
- package/dist/vue/index.d.ts.map +1 -0
- package/dist/vue/index.js +163 -0
- package/dist/vue/index.js.map +1 -0
- package/dist/zoom-native.d.ts +30 -0
- package/dist/zoom-native.d.ts.map +1 -0
- package/dist/zoom-native.js +56 -0
- package/dist/zoom-native.js.map +1 -0
- package/dist/zoomable.d.ts +48 -0
- package/dist/zoomable.d.ts.map +1 -0
- package/dist/zoomable.js +249 -0
- package/dist/zoomable.js.map +1 -0
- package/package.json +33 -5
- package/platforms/android/include.gradle +7 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/ColorTarget.java +102 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/DecorationTarget.java +154 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/ImageEffect.java +288 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFollower.java +91 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardObserver.java +97 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/NumberRoll.java +190 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/PhotoZoom.java +250 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/TextReveal.java +180 -0
- package/platforms/android/proguard-rules.pro +19 -0
- package/platforms/ios/build.xcconfig +1 -0
- package/platforms/ios/src/JoltImageEffect.h +25 -0
- package/platforms/ios/src/JoltImageEffect.mm +242 -0
- package/platforms/ios/src/JoltInteractiveAnimation.h +18 -0
- package/platforms/ios/src/JoltInteractiveAnimation.m +123 -0
- package/platforms/ios/src/JoltPhotoZoom.h +23 -0
- package/platforms/ios/src/JoltPhotoZoom.m +229 -0
- package/platforms/ios/src/module.modulemap +6 -0
- package/src/android-plugin.d.ts +155 -0
- package/src/animate.ts +278 -38
- package/src/color.ts +101 -0
- package/src/config.ts +84 -0
- package/src/decay.ts +45 -0
- package/src/decoration.ts +180 -0
- package/src/drag-input.ts +74 -0
- package/src/drag-physics.ts +110 -0
- package/src/drag.ts +395 -0
- package/src/driver/android-color.ts +170 -0
- package/src/driver/android-decoration.ts +182 -0
- package/src/driver/android-playback.ts +157 -0
- package/src/driver/android.ts +208 -20
- package/src/driver/index.ts +37 -2
- package/src/driver/ios-color.ts +441 -0
- package/src/driver/ios-decoration.ts +332 -0
- package/src/driver/ios-layout.ts +63 -0
- package/src/driver/ios-playback.ts +51 -0
- package/src/driver/ios-shadow.ts +50 -0
- package/src/driver/ios.ts +194 -25
- package/src/engine.ts +329 -27
- package/src/haptics.ts +148 -0
- package/src/image-effect-native.ts +129 -0
- package/src/image-effect.ts +336 -0
- package/src/image-owner.ts +12 -0
- package/src/index.ts +61 -4
- package/src/interaction-surface.ts +41 -0
- package/src/ios-plugin.d.ts +81 -0
- package/src/items.ts +140 -0
- package/src/keyboard.ts +291 -0
- package/src/label.ts +76 -0
- package/src/layout.ts +408 -0
- package/src/liquid-grid.ts +28 -0
- package/src/liquid.ts +287 -0
- package/src/loader.ts +202 -0
- package/src/motion-scope.ts +279 -0
- package/src/motion-states.ts +215 -0
- package/src/particles.ts +323 -0
- package/src/path.ts +124 -0
- package/src/playback-group.ts +143 -0
- package/src/playback.ts +62 -0
- package/src/presets.ts +31 -7
- package/src/press.ts +32 -10
- package/src/properties.ts +32 -6
- package/src/reveal.ts +374 -0
- package/src/roll-diff.ts +57 -0
- package/src/roll.ts +515 -0
- package/src/scroll-linked.ts +73 -0
- package/src/sequence.ts +212 -19
- package/src/shimmer.ts +180 -0
- package/src/svg-path.ts +355 -0
- package/src/symbol-effect.ts +226 -0
- package/src/text-layout.ts +228 -0
- package/src/text.ts +279 -0
- package/src/track.ts +347 -12
- package/src/transition.ts +63 -6
- package/src/types.ts +51 -1
- package/src/vue/index.ts +231 -0
- package/src/zoom-native.ts +124 -0
- package/src/zoomable.ts +330 -0
package/src/presets.ts
CHANGED
|
@@ -5,6 +5,9 @@ export interface Preset {
|
|
|
5
5
|
transition: AnimateOptions
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
/** Add custom preset names through module augmentation, with `true` as each value. */
|
|
9
|
+
export interface Presets {}
|
|
10
|
+
|
|
8
11
|
const fadeIn = { duration: 300, ease: 'easeOut' } as const
|
|
9
12
|
const fadeOut = { duration: 200, ease: 'easeIn' } as const
|
|
10
13
|
const enter = { type: 'spring', duration: 500, bounce: 0 } as const
|
|
@@ -77,28 +80,30 @@ export const presets = {
|
|
|
77
80
|
|
|
78
81
|
pulse: {
|
|
79
82
|
keyframes: { scale: [1, 1.06, 1] },
|
|
80
|
-
transition: { duration: 500, ease: 'easeInOut' },
|
|
83
|
+
transition: { duration: 500, ease: 'easeInOut', relative: true },
|
|
81
84
|
},
|
|
82
85
|
bounce: {
|
|
83
86
|
keyframes: { y: [0, -18, 0, -8, 0] },
|
|
84
87
|
transition: {
|
|
85
88
|
duration: 650,
|
|
89
|
+
relative: true,
|
|
86
90
|
times: [0, 0.3, 0.6, 0.8, 1],
|
|
87
91
|
ease: ['easeOut', 'easeIn', 'easeOut', 'easeIn'],
|
|
88
92
|
},
|
|
89
93
|
},
|
|
90
94
|
shake: {
|
|
91
95
|
keyframes: { x: [0, -10, 10, -8, 8, -4, 4, 0] },
|
|
92
|
-
transition: { duration: 500, ease: 'easeInOut' },
|
|
96
|
+
transition: { duration: 500, ease: 'easeInOut', relative: true },
|
|
93
97
|
},
|
|
94
98
|
wiggle: {
|
|
95
99
|
keyframes: { rotate: [0, -8, 8, -5, 5, 0] },
|
|
96
|
-
transition: { duration: 500, ease: 'easeInOut' },
|
|
100
|
+
transition: { duration: 500, ease: 'easeInOut', relative: true },
|
|
97
101
|
},
|
|
98
102
|
heartbeat: {
|
|
99
103
|
keyframes: { scale: [1, 1.15, 1, 1.15, 1, 1] },
|
|
100
104
|
transition: {
|
|
101
105
|
duration: 1000,
|
|
106
|
+
relative: true,
|
|
102
107
|
times: [0, 0.14, 0.28, 0.42, 0.7, 1],
|
|
103
108
|
ease: 'easeInOut',
|
|
104
109
|
},
|
|
@@ -110,25 +115,44 @@ export const presets = {
|
|
|
110
115
|
},
|
|
111
116
|
transition: {
|
|
112
117
|
duration: 800,
|
|
118
|
+
relative: true,
|
|
113
119
|
times: [0, 0.3, 0.4, 0.5, 0.65, 0.75, 1],
|
|
114
120
|
ease: 'easeInOut',
|
|
115
121
|
},
|
|
116
122
|
},
|
|
117
123
|
flash: {
|
|
118
124
|
keyframes: { opacity: [1, 0, 1, 0, 1] },
|
|
119
|
-
transition: { duration: 800, ease: 'easeInOut' },
|
|
125
|
+
transition: { duration: 800, ease: 'easeInOut', relative: true },
|
|
120
126
|
},
|
|
121
127
|
tada: {
|
|
122
128
|
keyframes: {
|
|
123
129
|
scale: [1, 0.9, 0.9, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1.1, 1],
|
|
124
130
|
rotate: [0, -3, -3, 3, -3, 3, -3, 3, -3, 3, 0],
|
|
125
131
|
},
|
|
126
|
-
transition: { duration: 1000, ease: 'easeInOut' },
|
|
132
|
+
transition: { duration: 1000, ease: 'easeInOut', relative: true },
|
|
127
133
|
},
|
|
128
134
|
spin: {
|
|
129
135
|
keyframes: { rotate: [0, 360] },
|
|
130
|
-
transition: { duration: 1000, ease: 'linear' },
|
|
136
|
+
transition: { duration: 1000, ease: 'linear', relative: true },
|
|
131
137
|
},
|
|
132
138
|
} satisfies Record<string, Preset>
|
|
133
139
|
|
|
134
|
-
export type PresetName = keyof typeof presets
|
|
140
|
+
export type PresetName = keyof typeof presets | Extract<keyof Presets, string>
|
|
141
|
+
|
|
142
|
+
const customPresets = new Map<string, Preset>()
|
|
143
|
+
|
|
144
|
+
/** Registers a custom preset. Registering it again replaces it for future animations. */
|
|
145
|
+
export function definePreset(name: PresetName, preset: Preset): void {
|
|
146
|
+
if (Object.hasOwn(presets, name)) {
|
|
147
|
+
throw new Error(`Preset "${name}" is built in and cannot be replaced.`)
|
|
148
|
+
}
|
|
149
|
+
customPresets.set(name, preset)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function getPreset(name: PresetName): Preset {
|
|
153
|
+
const preset =
|
|
154
|
+
customPresets.get(name) ??
|
|
155
|
+
(Object.hasOwn(presets, name) ? presets[name as keyof typeof presets] : undefined)
|
|
156
|
+
if (!preset) throw new Error(`Unknown preset "${name}".`)
|
|
157
|
+
return preset
|
|
158
|
+
}
|
package/src/press.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { Color, type EventData, type TouchGestureEventData } from '@nativescript/core'
|
|
2
|
+
import { isColorProperty } from './color.js'
|
|
3
|
+
import { animate, startingValues, toView } from './animate.js'
|
|
4
|
+
import type { PlaybackAnimation } from './playback.js'
|
|
5
|
+
import { readValue } from './properties.js'
|
|
4
6
|
import type { SpringName, Transition } from './transition.js'
|
|
5
7
|
import type { AnimateOptions, Keyframes, Target } from './types.js'
|
|
6
8
|
|
|
@@ -22,22 +24,42 @@ export function press(
|
|
|
22
24
|
options?: AnimateOptions | SpringName,
|
|
23
25
|
): () => void {
|
|
24
26
|
const view = toView(target)
|
|
25
|
-
const properties =
|
|
26
|
-
key === 'scale' ? ['scaleX', 'scaleY'] : isProperty(key) ? [key] : [],
|
|
27
|
-
)
|
|
27
|
+
const properties = [...startingValues(pressed, options).keys()]
|
|
28
28
|
let rest: Keyframes | undefined
|
|
29
|
+
let active: PlaybackAnimation | undefined
|
|
29
30
|
|
|
30
31
|
const onTouch = (event: EventData) => {
|
|
31
32
|
const { action } = event as TouchGestureEventData
|
|
32
33
|
if (action === 'down' && !rest && view.isEnabled !== false) {
|
|
33
|
-
rest = Object.fromEntries(
|
|
34
|
-
|
|
34
|
+
rest = Object.fromEntries(
|
|
35
|
+
properties.map((p) => [
|
|
36
|
+
p,
|
|
37
|
+
isColorProperty(p) ? new Color(readValue(view, p)) : readValue(view, p),
|
|
38
|
+
]),
|
|
39
|
+
)
|
|
40
|
+
active = animate(view, pressed, options ?? pressIn)
|
|
35
41
|
} else if ((action === 'up' || action === 'cancel') && rest) {
|
|
36
|
-
animate(
|
|
42
|
+
active = animate(
|
|
43
|
+
view,
|
|
44
|
+
rest,
|
|
45
|
+
typeof options === 'object'
|
|
46
|
+
? { ...options, relative: false }
|
|
47
|
+
: (options ?? release),
|
|
48
|
+
)
|
|
37
49
|
rest = undefined
|
|
38
50
|
}
|
|
39
51
|
}
|
|
40
52
|
|
|
53
|
+
const cancel = () => {
|
|
54
|
+
active?.stop()
|
|
55
|
+
active = undefined
|
|
56
|
+
rest = undefined
|
|
57
|
+
}
|
|
41
58
|
view.on('touch', onTouch)
|
|
42
|
-
|
|
59
|
+
view.on('unloaded', cancel)
|
|
60
|
+
return () => {
|
|
61
|
+
view.off('touch', onTouch)
|
|
62
|
+
view.off('unloaded', cancel)
|
|
63
|
+
cancel()
|
|
64
|
+
}
|
|
43
65
|
}
|
package/src/properties.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { Color, type View } from '@nativescript/core'
|
|
2
|
+
import {
|
|
3
|
+
isDecorationProperty,
|
|
4
|
+
readDecoration,
|
|
5
|
+
writeDecoration,
|
|
6
|
+
type DecorationProperty,
|
|
7
|
+
} from './decoration.js'
|
|
8
|
+
import { isColorProperty, readColor, type ColorProperty } from './color.js'
|
|
2
9
|
|
|
3
|
-
export type
|
|
10
|
+
export type NumericProperty =
|
|
4
11
|
'opacity' | 'x' | 'y' | 'scaleX' | 'scaleY' | 'rotate' | 'rotateX' | 'rotateY'
|
|
12
|
+
export type Property = NumericProperty | ColorProperty | DecorationProperty
|
|
5
13
|
|
|
6
14
|
const viewKeys = {
|
|
7
15
|
opacity: 'opacity',
|
|
@@ -12,9 +20,9 @@ const viewKeys = {
|
|
|
12
20
|
rotate: 'rotate',
|
|
13
21
|
rotateX: 'rotateX',
|
|
14
22
|
rotateY: 'rotateY',
|
|
15
|
-
} as const satisfies Record<
|
|
23
|
+
} as const satisfies Record<NumericProperty, keyof View>
|
|
16
24
|
|
|
17
|
-
const defaults: Record<
|
|
25
|
+
const defaults: Record<NumericProperty, number> = {
|
|
18
26
|
opacity: 1,
|
|
19
27
|
x: 0,
|
|
20
28
|
y: 0,
|
|
@@ -25,8 +33,19 @@ const defaults: Record<Property, number> = {
|
|
|
25
33
|
rotateY: 0,
|
|
26
34
|
}
|
|
27
35
|
|
|
28
|
-
/** How close to its target a spring has to be to count as settled. */
|
|
36
|
+
/** How close to its target a spring or decay has to be to count as settled. */
|
|
29
37
|
export const precision: Record<Property, number> = {
|
|
38
|
+
borderRadius: 0.1,
|
|
39
|
+
borderWidth: 0.1,
|
|
40
|
+
shadowOpacity: 0.002,
|
|
41
|
+
shadowRadius: 0.1,
|
|
42
|
+
shadowOffsetX: 0.1,
|
|
43
|
+
shadowOffsetY: 0.1,
|
|
44
|
+
elevation: 0.1,
|
|
45
|
+
translationZ: 0.1,
|
|
46
|
+
backgroundColor: 0.002,
|
|
47
|
+
borderColor: 0.002,
|
|
48
|
+
color: 0.002,
|
|
30
49
|
opacity: 0.002,
|
|
31
50
|
x: 0.25,
|
|
32
51
|
y: 0.25,
|
|
@@ -38,14 +57,21 @@ export const precision: Record<Property, number> = {
|
|
|
38
57
|
}
|
|
39
58
|
|
|
40
59
|
export function isProperty(key: string): key is Property {
|
|
41
|
-
return key
|
|
60
|
+
return Object.hasOwn(viewKeys, key) || isColorProperty(key) || isDecorationProperty(key)
|
|
42
61
|
}
|
|
43
62
|
|
|
44
63
|
export function readValue(view: View, property: Property): number {
|
|
64
|
+
if (isColorProperty(property)) return readColor(view, property)
|
|
65
|
+
if (isDecorationProperty(property)) return readDecoration(view, property)
|
|
45
66
|
const value = view[viewKeys[property]]
|
|
46
67
|
return typeof value === 'number' && !Number.isNaN(value) ? value : defaults[property]
|
|
47
68
|
}
|
|
48
69
|
|
|
49
70
|
export function writeValue(view: View, property: Property, value: number) {
|
|
71
|
+
if (isColorProperty(property)) {
|
|
72
|
+
view[property] = new Color(value)
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
if (isDecorationProperty(property)) return writeDecoration(view, property, value)
|
|
50
76
|
view[viewKeys[property]] = value
|
|
51
77
|
}
|
package/src/reveal.ts
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { Application, isIOS, type View } from '@nativescript/core'
|
|
2
|
+
import { toView } from './animate.js'
|
|
3
|
+
import { resolveEasing } from './easing.js'
|
|
4
|
+
import type { Animation } from './engine.js'
|
|
5
|
+
import type { Target } from './types.js'
|
|
6
|
+
import { reducedMotion } from './config.js'
|
|
7
|
+
|
|
8
|
+
export interface RevealOptions {
|
|
9
|
+
/** Circle origin in DIPs from the target's top-left corner. Defaults to its center. */
|
|
10
|
+
from?: { x: number; y: number }
|
|
11
|
+
/** Milliseconds. Defaults to 400; zero changes visibility immediately. */
|
|
12
|
+
duration?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ConcealOptions {
|
|
16
|
+
/** Circle destination in DIPs from the target's top-left corner. Defaults to its center. */
|
|
17
|
+
to?: { x: number; y: number }
|
|
18
|
+
/** Milliseconds. Defaults to 400; zero changes visibility immediately. */
|
|
19
|
+
duration?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type Circle = { x: number; y: number; radius: number }
|
|
23
|
+
type MaskedView = UIView & { originalMask?: CALayer | null; maskType?: unknown }
|
|
24
|
+
type Snapshot = { circle: Circle; native: UIView; bounds: CGRect }
|
|
25
|
+
const running = new WeakMap<View, CircularAnimation>()
|
|
26
|
+
const callbacks = new WeakMap<NSObject, (finished: boolean) => void>()
|
|
27
|
+
const ease = resolveEasing('easeInOut')
|
|
28
|
+
const key = 'jolt:reveal'
|
|
29
|
+
let Delegate: typeof NSObject | undefined
|
|
30
|
+
|
|
31
|
+
/** Reveals an iOS view through an expanding circle, then removes the temporary mask. */
|
|
32
|
+
export function reveal(target: Target, options: RevealOptions = {}): Animation {
|
|
33
|
+
return start(target, true, options.from, options.duration ?? 400)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Contracts an iOS view to a point, then sets visibility to 'hidden' without collapsing layout. */
|
|
37
|
+
export function conceal(target: Target, options: ConcealOptions = {}): Animation {
|
|
38
|
+
return start(target, false, options.to, options.duration ?? 400)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function start(
|
|
42
|
+
target: Target,
|
|
43
|
+
opening: boolean,
|
|
44
|
+
point: { x: number; y: number } | undefined,
|
|
45
|
+
duration: number,
|
|
46
|
+
): Animation {
|
|
47
|
+
if (!Number.isFinite(duration) || duration < 0)
|
|
48
|
+
throw new RangeError('Reveal duration must be a nonnegative finite number.')
|
|
49
|
+
if (
|
|
50
|
+
point !== undefined &&
|
|
51
|
+
(!point || !Number.isFinite(point.x) || !Number.isFinite(point.y))
|
|
52
|
+
)
|
|
53
|
+
throw new TypeError('Reveal origin must have finite x and y coordinates.')
|
|
54
|
+
const view = toView(target)
|
|
55
|
+
const previous = running.get(view)
|
|
56
|
+
const snapshot = previous?.snapshot()
|
|
57
|
+
const original = previous?.originalVisibility ?? view.visibility
|
|
58
|
+
previous?.stop()
|
|
59
|
+
return new CircularAnimation(
|
|
60
|
+
view,
|
|
61
|
+
opening,
|
|
62
|
+
point && { ...point },
|
|
63
|
+
duration,
|
|
64
|
+
original,
|
|
65
|
+
snapshot,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sameBounds(a: CGRect, b: CGRect) {
|
|
70
|
+
return (
|
|
71
|
+
a.origin.x === b.origin.x &&
|
|
72
|
+
a.origin.y === b.origin.y &&
|
|
73
|
+
a.size.width === b.size.width &&
|
|
74
|
+
a.size.height === b.size.height
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function path(circle: Circle): CAShapeLayer['path'] {
|
|
79
|
+
// Keep the same oval topology even at the closed endpoint.
|
|
80
|
+
const radius = Math.max(0.01, circle.radius)
|
|
81
|
+
return UIBezierPath.bezierPathWithOvalInRect(
|
|
82
|
+
CGRectMake(circle.x - radius, circle.y - radius, radius * 2, radius * 2),
|
|
83
|
+
).CGPath
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function completion(
|
|
87
|
+
callback: (finished: boolean) => void,
|
|
88
|
+
): NSObject & CAAnimationDelegate {
|
|
89
|
+
if (!Delegate) {
|
|
90
|
+
// Use the runtime subclass API so published JS needs no NativeClass transform.
|
|
91
|
+
const base = NSObject as typeof NSObject & {
|
|
92
|
+
extend(
|
|
93
|
+
methods: object,
|
|
94
|
+
options: { protocols: unknown[]; name: string },
|
|
95
|
+
): typeof NSObject
|
|
96
|
+
}
|
|
97
|
+
Delegate = base.extend(
|
|
98
|
+
{
|
|
99
|
+
animationDidStopFinished(
|
|
100
|
+
this: NSObject,
|
|
101
|
+
_animation: CAAnimation,
|
|
102
|
+
finished: boolean,
|
|
103
|
+
) {
|
|
104
|
+
callbacks.get(this)?.(finished)
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
{ protocols: [CAAnimationDelegate], name: 'JoltCircularRevealDelegate' },
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
const delegate = Delegate.new()
|
|
111
|
+
callbacks.set(delegate, callback)
|
|
112
|
+
return delegate
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
class CircularAnimation implements Animation {
|
|
116
|
+
readonly finished: Promise<boolean>
|
|
117
|
+
private resolve!: (finished: boolean) => void
|
|
118
|
+
private settled = false
|
|
119
|
+
private queued = false
|
|
120
|
+
private changingVisibility = false
|
|
121
|
+
private waitingForLoad: boolean
|
|
122
|
+
private native: MaskedView | undefined
|
|
123
|
+
private bounds: CGRect | undefined
|
|
124
|
+
private wrapper: CALayer | undefined
|
|
125
|
+
private shape: CAShapeLayer | undefined
|
|
126
|
+
private originalMask: CALayer | null = null
|
|
127
|
+
private savedMask: CALayer | null = null
|
|
128
|
+
private delegate: NSObject | undefined
|
|
129
|
+
private observer: ReturnType<typeof Application.ios.addNotificationObserver> | undefined
|
|
130
|
+
private from: Circle | undefined
|
|
131
|
+
private to: Circle | undefined
|
|
132
|
+
private startedAt = 0
|
|
133
|
+
|
|
134
|
+
constructor(
|
|
135
|
+
private readonly view: View,
|
|
136
|
+
private readonly opening: boolean,
|
|
137
|
+
private readonly point: { x: number; y: number } | undefined,
|
|
138
|
+
private readonly duration: number,
|
|
139
|
+
readonly originalVisibility: View['visibility'],
|
|
140
|
+
private readonly inherited?: Snapshot,
|
|
141
|
+
) {
|
|
142
|
+
this.finished = new Promise((resolve) => {
|
|
143
|
+
this.resolve = resolve
|
|
144
|
+
})
|
|
145
|
+
this.waitingForLoad = !view.isLoaded
|
|
146
|
+
running.set(view, this)
|
|
147
|
+
if (!isIOS) {
|
|
148
|
+
this.end(false)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
view.on('loaded', this.loaded)
|
|
152
|
+
view.on('layoutChanged', this.layoutChanged)
|
|
153
|
+
view.on('unloaded', this.stop)
|
|
154
|
+
view.style.on('backgroundInternalChange', this.stop)
|
|
155
|
+
view.style.on('visibilityChange', this.visibilityChanged)
|
|
156
|
+
if (opening && view.visibility === 'collapse') this.visibility('hidden')
|
|
157
|
+
this.schedule()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
then<A = boolean, B = never>(
|
|
161
|
+
onfulfilled?: ((finished: boolean) => A | PromiseLike<A>) | null,
|
|
162
|
+
onrejected?: ((reason: unknown) => B | PromiseLike<B>) | null,
|
|
163
|
+
): Promise<A | B> {
|
|
164
|
+
return this.finished.then(onfulfilled, onrejected)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Cancels and restores visibility from before the interrupted reveal/conceal chain. */
|
|
168
|
+
stop = () => this.end(false)
|
|
169
|
+
|
|
170
|
+
snapshot(): Snapshot | undefined {
|
|
171
|
+
if (!this.shape || !this.native || !this.bounds || !this.from || !this.to) return
|
|
172
|
+
const presented = this.shape.presentationLayer() as CAShapeLayer | null
|
|
173
|
+
let circle: Circle
|
|
174
|
+
if (presented?.path) {
|
|
175
|
+
const box = CGPathGetBoundingBox(presented.path)
|
|
176
|
+
circle = {
|
|
177
|
+
x: box.origin.x + box.size.width / 2,
|
|
178
|
+
y: box.origin.y + box.size.height / 2,
|
|
179
|
+
radius: box.size.width / 2,
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
const progress = ease(
|
|
183
|
+
Math.max(
|
|
184
|
+
0,
|
|
185
|
+
Math.min(1, ((CACurrentMediaTime() - this.startedAt) * 1000) / this.duration),
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
const lerp = (a: number, b: number) => a + (b - a) * progress
|
|
189
|
+
circle = {
|
|
190
|
+
x: lerp(this.from.x, this.to.x),
|
|
191
|
+
y: lerp(this.from.y, this.to.y),
|
|
192
|
+
radius: lerp(this.from.radius, this.to.radius),
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { circle, native: this.native, bounds: this.bounds }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private visibility(value: View['visibility']) {
|
|
199
|
+
this.changingVisibility = true
|
|
200
|
+
try {
|
|
201
|
+
this.view.visibility = value
|
|
202
|
+
} finally {
|
|
203
|
+
this.changingVisibility = false
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private visibilityChanged = () => {
|
|
208
|
+
if (!this.changingVisibility) this.end(false, true)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private loaded = () => {
|
|
212
|
+
this.waitingForLoad = false
|
|
213
|
+
this.schedule()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private layoutChanged = () => {
|
|
217
|
+
if (this.shape) {
|
|
218
|
+
const native = this.view.nativeViewProtected as UIView | undefined
|
|
219
|
+
if (
|
|
220
|
+
native !== this.native ||
|
|
221
|
+
!native?.window ||
|
|
222
|
+
!sameBounds(native.layer.bounds, this.bounds!)
|
|
223
|
+
) {
|
|
224
|
+
this.stop()
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
this.schedule()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private schedule = () => {
|
|
232
|
+
if (this.settled || this.queued) return
|
|
233
|
+
this.queued = true
|
|
234
|
+
void Promise.resolve().then(() => {
|
|
235
|
+
this.queued = false
|
|
236
|
+
if (this.settled) return
|
|
237
|
+
try {
|
|
238
|
+
this.update()
|
|
239
|
+
} catch (error) {
|
|
240
|
+
this.stop()
|
|
241
|
+
throw error
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private motionChanged = () => {
|
|
247
|
+
if (reducedMotion()) this.end(true)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private update() {
|
|
251
|
+
if (!this.opening && !this.inherited && this.view.visibility !== 'visible')
|
|
252
|
+
return this.end(true, true)
|
|
253
|
+
if (this.duration === 0 || reducedMotion()) return this.end(true)
|
|
254
|
+
if (this.waitingForLoad || !this.view.isLoaded) return
|
|
255
|
+
const native = this.view.nativeViewProtected as MaskedView | undefined
|
|
256
|
+
if (this.shape) {
|
|
257
|
+
if (native !== this.native || native?.layer.mask !== this.wrapper) this.stop()
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
if (!native?.window) return
|
|
261
|
+
const bounds = native.layer.bounds
|
|
262
|
+
if (bounds.size.width <= 0 || bounds.size.height <= 0) return
|
|
263
|
+
const inherited =
|
|
264
|
+
this.inherited?.native === native && sameBounds(this.inherited.bounds, bounds)
|
|
265
|
+
? this.inherited.circle
|
|
266
|
+
: undefined
|
|
267
|
+
const x = this.point
|
|
268
|
+
? bounds.origin.x + this.point.x
|
|
269
|
+
: (inherited?.x ?? bounds.origin.x + bounds.size.width / 2)
|
|
270
|
+
const y = this.point
|
|
271
|
+
? bounds.origin.y + this.point.y
|
|
272
|
+
: (inherited?.y ?? bounds.origin.y + bounds.size.height / 2)
|
|
273
|
+
const radius =
|
|
274
|
+
Math.hypot(
|
|
275
|
+
Math.max(
|
|
276
|
+
Math.abs(x - bounds.origin.x),
|
|
277
|
+
Math.abs(x - bounds.origin.x - bounds.size.width),
|
|
278
|
+
),
|
|
279
|
+
Math.max(
|
|
280
|
+
Math.abs(y - bounds.origin.y),
|
|
281
|
+
Math.abs(y - bounds.origin.y - bounds.size.height),
|
|
282
|
+
),
|
|
283
|
+
) + 1
|
|
284
|
+
this.from = inherited ?? { x, y, radius: this.opening ? 0 : radius }
|
|
285
|
+
this.to = { x, y, radius: this.opening ? radius : 0 }
|
|
286
|
+
this.native = native
|
|
287
|
+
this.bounds = bounds
|
|
288
|
+
this.originalMask = native.layer.mask
|
|
289
|
+
this.savedMask =
|
|
290
|
+
native.maskType != null ? (native.originalMask ?? null) : this.originalMask
|
|
291
|
+
|
|
292
|
+
CATransaction.begin()
|
|
293
|
+
CATransaction.setDisableActions(true)
|
|
294
|
+
try {
|
|
295
|
+
// A plain container prevents Core from mistaking our circle for its own clip path.
|
|
296
|
+
const wrapper = (this.wrapper = CALayer.new())
|
|
297
|
+
const shape = (this.shape = CAShapeLayer.new())
|
|
298
|
+
wrapper.name = key
|
|
299
|
+
for (const layer of [wrapper, shape]) {
|
|
300
|
+
layer.bounds = bounds
|
|
301
|
+
layer.position = CGPointMake(
|
|
302
|
+
bounds.origin.x + bounds.size.width / 2,
|
|
303
|
+
bounds.origin.y + bounds.size.height / 2,
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
shape.fillColor = UIColor.blackColor.CGColor
|
|
307
|
+
shape.path = path(this.to)
|
|
308
|
+
wrapper.addSublayer(shape)
|
|
309
|
+
// Same coordinate space as the original host, including bounds origins.
|
|
310
|
+
native.layer.mask = null
|
|
311
|
+
wrapper.mask = this.originalMask
|
|
312
|
+
native.layer.mask = wrapper
|
|
313
|
+
const animation = CABasicAnimation.animationWithKeyPath('path')
|
|
314
|
+
animation.fromValue = path(this.from)
|
|
315
|
+
animation.toValue = shape.path
|
|
316
|
+
animation.duration = this.duration / 1000
|
|
317
|
+
animation.timingFunction = CAMediaTimingFunction.functionWithName(
|
|
318
|
+
kCAMediaTimingFunctionEaseInEaseOut,
|
|
319
|
+
)
|
|
320
|
+
this.delegate = animation.delegate = completion((finished) => this.end(finished))
|
|
321
|
+
this.startedAt = CACurrentMediaTime()
|
|
322
|
+
shape.addAnimationForKey(animation, key)
|
|
323
|
+
this.observer = Application.ios.addNotificationObserver(
|
|
324
|
+
UIAccessibilityReduceMotionStatusDidChangeNotification,
|
|
325
|
+
this.motionChanged,
|
|
326
|
+
)
|
|
327
|
+
this.visibility('visible')
|
|
328
|
+
} finally {
|
|
329
|
+
CATransaction.commit()
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private end(finished: boolean, keepVisibility = false) {
|
|
334
|
+
if (this.settled) return
|
|
335
|
+
this.settled = true
|
|
336
|
+
const { view, native, wrapper, shape } = this
|
|
337
|
+
view.off('loaded', this.loaded)
|
|
338
|
+
view.off('layoutChanged', this.layoutChanged)
|
|
339
|
+
view.off('unloaded', this.stop)
|
|
340
|
+
view.style.off('backgroundInternalChange', this.stop)
|
|
341
|
+
view.style.off('visibilityChange', this.visibilityChanged)
|
|
342
|
+
if (running.get(view) === this) running.delete(view)
|
|
343
|
+
if (this.observer)
|
|
344
|
+
Application.ios.removeNotificationObserver(
|
|
345
|
+
this.observer,
|
|
346
|
+
UIAccessibilityReduceMotionStatusDidChangeNotification,
|
|
347
|
+
)
|
|
348
|
+
if (this.delegate) callbacks.delete(this.delegate)
|
|
349
|
+
if (native && wrapper) {
|
|
350
|
+
CATransaction.begin()
|
|
351
|
+
CATransaction.setDisableActions(true)
|
|
352
|
+
try {
|
|
353
|
+
// Hide before removing a closed mask so conceal never flashes its content.
|
|
354
|
+
if (!keepVisibility)
|
|
355
|
+
this.visibility(
|
|
356
|
+
finished ? (this.opening ? 'visible' : 'hidden') : this.originalVisibility,
|
|
357
|
+
)
|
|
358
|
+
shape?.removeAnimationForKey(key)
|
|
359
|
+
wrapper.mask = null
|
|
360
|
+
if (native.layer.mask === wrapper) native.layer.mask = this.originalMask
|
|
361
|
+
// Core can save the temporary mask while installing a new clip/border mask.
|
|
362
|
+
if (native.originalMask === wrapper) native.originalMask = this.savedMask
|
|
363
|
+
} finally {
|
|
364
|
+
CATransaction.commit()
|
|
365
|
+
}
|
|
366
|
+
} else if (isIOS && !keepVisibility) {
|
|
367
|
+
this.visibility(
|
|
368
|
+
finished ? (this.opening ? 'visible' : 'hidden') : this.originalVisibility,
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
this.native = this.wrapper = this.shape = this.delegate = this.observer = undefined
|
|
372
|
+
this.resolve(finished)
|
|
373
|
+
}
|
|
374
|
+
}
|
package/src/roll-diff.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export interface Grapheme {
|
|
2
|
+
text: string
|
|
3
|
+
/** UTF-16 offsets, the units NSString locations and Java char indices use. */
|
|
4
|
+
start: number
|
|
5
|
+
end: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface RollChange {
|
|
9
|
+
/** The grapheme that rolls in, placed in the new text. */
|
|
10
|
+
next: Grapheme
|
|
11
|
+
/** The grapheme it replaces at the same position from the end, when there is one. */
|
|
12
|
+
previous: Grapheme | undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RollDiff {
|
|
16
|
+
/** Positions that differ, from the last one backwards. */
|
|
17
|
+
changes: RollChange[]
|
|
18
|
+
/** Old graphemes to the left of the new text's first one; they roll out alone. */
|
|
19
|
+
removed: Grapheme[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const segmenter =
|
|
23
|
+
typeof Intl !== 'undefined' && 'Segmenter' in Intl
|
|
24
|
+
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
25
|
+
: undefined
|
|
26
|
+
|
|
27
|
+
export function graphemes(text: string): Grapheme[] {
|
|
28
|
+
if (segmenter)
|
|
29
|
+
return [...segmenter.segment(text)].map(({ segment, index }) => ({
|
|
30
|
+
text: segment,
|
|
31
|
+
start: index,
|
|
32
|
+
end: index + segment.length,
|
|
33
|
+
}))
|
|
34
|
+
const pieces: Grapheme[] = []
|
|
35
|
+
let offset = 0
|
|
36
|
+
for (const character of text) {
|
|
37
|
+
pieces.push({ text: character, start: offset, end: offset + character.length })
|
|
38
|
+
offset += character.length
|
|
39
|
+
}
|
|
40
|
+
return pieces
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Aligns both texts on their last grapheme, so the units digit stays put when the count grows. */
|
|
44
|
+
export function diffFromEnd(previous: string, next: string): RollDiff {
|
|
45
|
+
const before = graphemes(previous)
|
|
46
|
+
const after = graphemes(next)
|
|
47
|
+
const changes: RollChange[] = []
|
|
48
|
+
const removed: Grapheme[] = []
|
|
49
|
+
const count = Math.max(before.length, after.length)
|
|
50
|
+
for (let offset = 1; offset <= count; offset++) {
|
|
51
|
+
const old = before[before.length - offset]
|
|
52
|
+
const piece = after[after.length - offset]
|
|
53
|
+
if (!piece) removed.push(old!)
|
|
54
|
+
else if (!old || old.text !== piece.text) changes.push({ next: piece, previous: old })
|
|
55
|
+
}
|
|
56
|
+
return { changes, removed }
|
|
57
|
+
}
|