@rafaelaugusto/jolt 0.1.0-beta.5 → 0.1.0-beta.7
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 +122 -5
- package/dist/animate.d.ts +4 -2
- package/dist/animate.d.ts.map +1 -1
- package/dist/animate.js +48 -11
- package/dist/animate.js.map +1 -1
- package/dist/config.d.ts +22 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +34 -0
- package/dist/config.js.map +1 -0
- package/dist/driver/android.d.ts.map +1 -1
- package/dist/driver/android.js +7 -0
- package/dist/driver/android.js.map +1 -1
- package/dist/driver/index.d.ts +2 -0
- package/dist/driver/index.d.ts.map +1 -1
- package/dist/driver/index.js.map +1 -1
- package/dist/driver/ios-color.js +1 -1
- package/dist/driver/ios-color.js.map +1 -1
- package/dist/driver/ios-decoration.js +1 -1
- package/dist/driver/ios-decoration.js.map +1 -1
- package/dist/driver/ios-playback.d.ts +1 -1
- package/dist/driver/ios-playback.d.ts.map +1 -1
- package/dist/driver/ios-playback.js +14 -0
- package/dist/driver/ios-playback.js.map +1 -1
- package/dist/driver/ios.d.ts.map +1 -1
- package/dist/driver/ios.js +1 -0
- package/dist/driver/ios.js.map +1 -1
- package/dist/engine.d.ts +16 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +33 -1
- package/dist/engine.js.map +1 -1
- 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 +15 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- 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 +17 -0
- package/dist/keyboard.d.ts.map +1 -0
- package/dist/keyboard.js +116 -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/liquid.d.ts.map +1 -1
- package/dist/liquid.js +6 -1
- package/dist/liquid.js.map +1 -1
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +2 -1
- package/dist/loader.js.map +1 -1
- package/dist/particles.d.ts.map +1 -1
- package/dist/particles.js +4 -3
- package/dist/particles.js.map +1 -1
- 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.map +1 -1
- package/dist/playback-group.js +29 -1
- package/dist/playback-group.js.map +1 -1
- package/dist/press.d.ts.map +1 -1
- package/dist/press.js +3 -3
- package/dist/press.js.map +1 -1
- package/dist/reveal.d.ts.map +1 -1
- package/dist/reveal.js +3 -2
- package/dist/reveal.js.map +1 -1
- 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 +10 -5
- package/dist/sequence.d.ts.map +1 -1
- package/dist/sequence.js +202 -14
- package/dist/sequence.js.map +1 -1
- package/dist/shimmer.d.ts.map +1 -1
- package/dist/shimmer.js +2 -1
- package/dist/shimmer.js.map +1 -1
- 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.map +1 -1
- package/dist/symbol-effect.js +4 -3
- package/dist/symbol-effect.js.map +1 -1
- package/dist/text-layout.d.ts +8 -0
- package/dist/text-layout.d.ts.map +1 -1
- package/dist/text-layout.js +8 -2
- package/dist/text-layout.js.map +1 -1
- package/dist/text.d.ts +1 -1
- package/dist/text.d.ts.map +1 -1
- package/dist/text.js +60 -64
- package/dist/text.js.map +1 -1
- package/dist/track.d.ts +2 -0
- package/dist/track.d.ts.map +1 -1
- package/dist/track.js +62 -15
- package/dist/track.js.map +1 -1
- package/dist/types.d.ts +7 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/vue/index.d.ts +89 -0
- package/dist/vue/index.d.ts.map +1 -0
- package/dist/vue/index.js +134 -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 +16 -3
- 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/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 +10 -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/JoltPhotoZoom.h +23 -0
- package/platforms/ios/src/JoltPhotoZoom.m +229 -0
- package/platforms/ios/src/module.modulemap +5 -0
- package/src/android-plugin.d.ts +142 -0
- package/src/animate.ts +80 -30
- package/src/config.ts +46 -0
- package/src/driver/android.ts +14 -0
- package/src/driver/index.ts +2 -0
- package/src/driver/ios-color.ts +1 -1
- package/src/driver/ios-decoration.ts +1 -1
- package/src/driver/ios-playback.ts +15 -1
- package/src/driver/ios.ts +2 -0
- package/src/engine.ts +47 -2
- 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 +20 -1
- package/src/ios-plugin.d.ts +59 -0
- package/src/items.ts +140 -0
- package/src/keyboard.ts +132 -0
- package/src/label.ts +76 -0
- package/src/liquid.ts +6 -1
- package/src/loader.ts +2 -1
- package/src/particles.ts +4 -4
- package/src/path.ts +124 -0
- package/src/playback-group.ts +34 -1
- package/src/press.ts +3 -5
- package/src/reveal.ts +3 -3
- package/src/roll-diff.ts +57 -0
- package/src/roll.ts +515 -0
- package/src/scroll-linked.ts +73 -0
- package/src/sequence.ts +199 -14
- package/src/shimmer.ts +2 -1
- package/src/svg-path.ts +355 -0
- package/src/symbol-effect.ts +4 -3
- package/src/text-layout.ts +14 -2
- package/src/text.ts +62 -81
- package/src/track.ts +89 -24
- package/src/types.ts +8 -1
- package/src/vue/index.ts +189 -0
- package/src/zoom-native.ts +124 -0
- package/src/zoomable.ts +330 -0
package/src/svg-path.ts
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/** SVG path geometry in local DIPs. No DOM or platform path API is used. */
|
|
2
|
+
export interface PathPoint {
|
|
3
|
+
x: number
|
|
4
|
+
y: number
|
|
5
|
+
}
|
|
6
|
+
export interface PathPose extends PathPoint {
|
|
7
|
+
rotate: number
|
|
8
|
+
}
|
|
9
|
+
type Segment = { point(t: number): PathPoint; tangent(t: number): PathPoint }
|
|
10
|
+
type Vertex = PathPoint & {
|
|
11
|
+
segment: number
|
|
12
|
+
t: number
|
|
13
|
+
distance: number
|
|
14
|
+
heading: number
|
|
15
|
+
}
|
|
16
|
+
export interface MotionPath {
|
|
17
|
+
readonly length: number
|
|
18
|
+
at(progress: number): PathPose
|
|
19
|
+
}
|
|
20
|
+
const LIMIT = 1_000_000
|
|
21
|
+
const MAX_SEGMENTS = 1024
|
|
22
|
+
const MAX_VERTICES = 8192
|
|
23
|
+
const TOLERANCE = 0.025
|
|
24
|
+
const distance = (a: PathPoint, b: PathPoint) => Math.hypot(b.x - a.x, b.y - a.y)
|
|
25
|
+
const mix = (a: PathPoint, b: PathPoint, t: number): PathPoint => ({
|
|
26
|
+
x: a.x + (b.x - a.x) * t,
|
|
27
|
+
y: a.y + (b.y - a.y) * t,
|
|
28
|
+
})
|
|
29
|
+
const checked = (p: PathPoint) => {
|
|
30
|
+
if (![p.x, p.y].every((n) => Number.isFinite(n) && Math.abs(n) <= LIMIT))
|
|
31
|
+
throw new RangeError('Path coordinates must stay within ±1000000 DIPs.')
|
|
32
|
+
return p
|
|
33
|
+
}
|
|
34
|
+
const unwrap = (angle: number, reference: number) =>
|
|
35
|
+
angle + 360 * Math.round((reference - angle) / 360)
|
|
36
|
+
|
|
37
|
+
class Scanner {
|
|
38
|
+
index = 0
|
|
39
|
+
private hasNumber = false
|
|
40
|
+
constructor(readonly text: string) {}
|
|
41
|
+
peek() {
|
|
42
|
+
while (/[\t\n\r ]/.test(this.text[this.index] ?? '\0')) this.index++
|
|
43
|
+
return this.text[this.index]
|
|
44
|
+
}
|
|
45
|
+
command() {
|
|
46
|
+
const value = this.peek()!
|
|
47
|
+
this.index++
|
|
48
|
+
this.hasNumber = false
|
|
49
|
+
return value
|
|
50
|
+
}
|
|
51
|
+
number(flag = false) {
|
|
52
|
+
this.peek()
|
|
53
|
+
if (this.text[this.index] === ',') {
|
|
54
|
+
if (!this.hasNumber) this.error()
|
|
55
|
+
this.index++
|
|
56
|
+
this.peek()
|
|
57
|
+
}
|
|
58
|
+
const rest = this.text.slice(this.index)
|
|
59
|
+
const token = flag
|
|
60
|
+
? /^[01]/.exec(rest)?.[0]
|
|
61
|
+
: /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?/i.exec(rest)?.[0]
|
|
62
|
+
if (!token) this.error()
|
|
63
|
+
const value = Number(token)
|
|
64
|
+
if (!Number.isFinite(value) || Math.abs(value) > LIMIT)
|
|
65
|
+
throw new RangeError('Path numbers must be finite and within ±1000000.')
|
|
66
|
+
this.index += token!.length
|
|
67
|
+
this.hasNumber = true
|
|
68
|
+
return value
|
|
69
|
+
}
|
|
70
|
+
error(): never {
|
|
71
|
+
throw new SyntaxError(`Invalid SVG path at character ${this.index}.`)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function line(a: PathPoint, b: PathPoint): Segment {
|
|
76
|
+
return { point: (t) => mix(a, b, t), tangent: () => ({ x: b.x - a.x, y: b.y - a.y }) }
|
|
77
|
+
}
|
|
78
|
+
function bezier(points: readonly PathPoint[]): Segment {
|
|
79
|
+
const evaluate = (values: readonly PathPoint[], t: number): PathPoint => {
|
|
80
|
+
let row = [...values]
|
|
81
|
+
while (row.length > 1) row = row.slice(1).map((p, i) => mix(row[i]!, p, t))
|
|
82
|
+
return row[0]!
|
|
83
|
+
}
|
|
84
|
+
const derivative = points.slice(1).map((p, i) => ({
|
|
85
|
+
x: (p.x - points[i]!.x) * (points.length - 1),
|
|
86
|
+
y: (p.y - points[i]!.y) * (points.length - 1),
|
|
87
|
+
}))
|
|
88
|
+
return { point: (t) => evaluate(points, t), tangent: (t) => evaluate(derivative, t) }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Endpoint-to-center conversion: https://www.w3.org/TR/SVG/implnote.html#ArcConversionEndpointToCenter */
|
|
92
|
+
function arc(
|
|
93
|
+
a: PathPoint,
|
|
94
|
+
b: PathPoint,
|
|
95
|
+
rx: number,
|
|
96
|
+
ry: number,
|
|
97
|
+
degrees: number,
|
|
98
|
+
large: boolean,
|
|
99
|
+
sweep: boolean,
|
|
100
|
+
): Segment {
|
|
101
|
+
rx = Math.abs(rx)
|
|
102
|
+
ry = Math.abs(ry)
|
|
103
|
+
if (!rx || !ry || distance(a, b) === 0) return line(a, b)
|
|
104
|
+
const phi = ((degrees % 360) * Math.PI) / 180,
|
|
105
|
+
cos = Math.cos(phi),
|
|
106
|
+
sin = Math.sin(phi)
|
|
107
|
+
const dx = (a.x - b.x) / 2,
|
|
108
|
+
dy = (a.y - b.y) / 2
|
|
109
|
+
const xp = cos * dx + sin * dy,
|
|
110
|
+
yp = -sin * dx + cos * dy
|
|
111
|
+
const correction = Math.max(1, Math.hypot(xp / rx, yp / ry))
|
|
112
|
+
rx *= correction
|
|
113
|
+
ry *= correction
|
|
114
|
+
const u = xp / rx,
|
|
115
|
+
v = yp / ry,
|
|
116
|
+
norm = u * u + v * v
|
|
117
|
+
const factor = (large === sweep ? -1 : 1) * Math.sqrt(Math.max(0, (1 - norm) / norm))
|
|
118
|
+
const cxp = factor * rx * v,
|
|
119
|
+
cyp = -factor * ry * u
|
|
120
|
+
const cx = cos * cxp - sin * cyp + (a.x + b.x) / 2,
|
|
121
|
+
cy = sin * cxp + cos * cyp + (a.y + b.y) / 2
|
|
122
|
+
const ux = (xp - cxp) / rx,
|
|
123
|
+
uy = (yp - cyp) / ry,
|
|
124
|
+
vx = (-xp - cxp) / rx,
|
|
125
|
+
vy = (-yp - cyp) / ry
|
|
126
|
+
const start = Math.atan2(uy, ux)
|
|
127
|
+
let delta = Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy)
|
|
128
|
+
if (!sweep && delta > 0) delta -= 2 * Math.PI
|
|
129
|
+
if (sweep && delta < 0) delta += 2 * Math.PI
|
|
130
|
+
if (![cx, cy, rx, ry, start, delta].every(Number.isFinite))
|
|
131
|
+
throw new RangeError('Path arc exceeds finite geometry limits.')
|
|
132
|
+
return {
|
|
133
|
+
point(t) {
|
|
134
|
+
if (t === 0) return a
|
|
135
|
+
if (t === 1) return b
|
|
136
|
+
const angle = start + t * delta,
|
|
137
|
+
x = rx * Math.cos(angle),
|
|
138
|
+
y = ry * Math.sin(angle)
|
|
139
|
+
return checked({ x: cx + cos * x - sin * y, y: cy + sin * x + cos * y })
|
|
140
|
+
},
|
|
141
|
+
tangent(t) {
|
|
142
|
+
const angle = start + t * delta,
|
|
143
|
+
x = -rx * Math.sin(angle) * delta,
|
|
144
|
+
y = ry * Math.cos(angle) * delta
|
|
145
|
+
return { x: cos * x - sin * y, y: sin * x + cos * y }
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function parsePath(data: string): MotionPath {
|
|
151
|
+
if (typeof data !== 'string' || !data.trim())
|
|
152
|
+
throw new TypeError('Path must be a nonempty SVG path string.')
|
|
153
|
+
if (data.length > 100_000)
|
|
154
|
+
throw new RangeError('SVG path is too long (100000 characters maximum).')
|
|
155
|
+
const scanner = new Scanner(data)
|
|
156
|
+
const segments: Segment[] = []
|
|
157
|
+
let current: PathPoint = { x: 0, y: 0 },
|
|
158
|
+
first: PathPoint | undefined
|
|
159
|
+
let command = '',
|
|
160
|
+
previous = '',
|
|
161
|
+
control: PathPoint = current
|
|
162
|
+
const add = (segment: Segment, end: PathPoint) => {
|
|
163
|
+
if (segments.length >= MAX_SEGMENTS)
|
|
164
|
+
throw new RangeError('SVG path has too many segments (1024 maximum).')
|
|
165
|
+
segments.push(segment)
|
|
166
|
+
current = checked(end)
|
|
167
|
+
}
|
|
168
|
+
while (scanner.peek() !== undefined) {
|
|
169
|
+
const token = scanner.peek()!
|
|
170
|
+
if (/[a-z]/i.test(token)) {
|
|
171
|
+
command = scanner.command()
|
|
172
|
+
if (!/[mlhvcsqtaz]/i.test(command)) scanner.error()
|
|
173
|
+
} else if (!command) scanner.error()
|
|
174
|
+
const upper = command.toUpperCase(),
|
|
175
|
+
relative = command !== upper,
|
|
176
|
+
origin = current
|
|
177
|
+
const point = () =>
|
|
178
|
+
checked({
|
|
179
|
+
x: scanner.number() + (relative ? origin.x : 0),
|
|
180
|
+
y: scanner.number() + (relative ? origin.y : 0),
|
|
181
|
+
})
|
|
182
|
+
if (!first && upper !== 'M') throw new SyntaxError('SVG path must start with M or m.')
|
|
183
|
+
if (upper === 'M') {
|
|
184
|
+
if (first)
|
|
185
|
+
throw new SyntaxError(
|
|
186
|
+
'Motion paths support one continuous subpath; use a single M command.',
|
|
187
|
+
)
|
|
188
|
+
first = current = point()
|
|
189
|
+
command = relative ? 'l' : 'L'
|
|
190
|
+
} else if (upper === 'Z') {
|
|
191
|
+
add(line(current, first!), first!)
|
|
192
|
+
command = ''
|
|
193
|
+
} else if (upper === 'L') {
|
|
194
|
+
const end = point()
|
|
195
|
+
add(line(current, end), end)
|
|
196
|
+
} else if (upper === 'H' || upper === 'V') {
|
|
197
|
+
const n = scanner.number(),
|
|
198
|
+
end = checked(
|
|
199
|
+
upper === 'H'
|
|
200
|
+
? { x: n + (relative ? origin.x : 0), y: current.y }
|
|
201
|
+
: { x: current.x, y: n + (relative ? origin.y : 0) },
|
|
202
|
+
)
|
|
203
|
+
add(line(current, end), end)
|
|
204
|
+
} else if (upper === 'C' || upper === 'S') {
|
|
205
|
+
const c1 =
|
|
206
|
+
upper === 'C'
|
|
207
|
+
? point()
|
|
208
|
+
: /[CS]/.test(previous)
|
|
209
|
+
? { x: 2 * current.x - control.x, y: 2 * current.y - control.y }
|
|
210
|
+
: current
|
|
211
|
+
const c2 = point(),
|
|
212
|
+
end = point()
|
|
213
|
+
checked(c1)
|
|
214
|
+
add(bezier([current, c1, c2, end]), end)
|
|
215
|
+
control = c2
|
|
216
|
+
} else if (upper === 'Q' || upper === 'T') {
|
|
217
|
+
const c =
|
|
218
|
+
upper === 'Q'
|
|
219
|
+
? point()
|
|
220
|
+
: /[QT]/.test(previous)
|
|
221
|
+
? { x: 2 * current.x - control.x, y: 2 * current.y - control.y }
|
|
222
|
+
: current
|
|
223
|
+
const end = point()
|
|
224
|
+
checked(c)
|
|
225
|
+
add(bezier([current, c, end]), end)
|
|
226
|
+
control = c
|
|
227
|
+
} else if (upper === 'A') {
|
|
228
|
+
const rx = scanner.number(),
|
|
229
|
+
ry = scanner.number(),
|
|
230
|
+
rotation = scanner.number(),
|
|
231
|
+
large = scanner.number(true),
|
|
232
|
+
sweep = scanner.number(true),
|
|
233
|
+
end = point()
|
|
234
|
+
add(arc(current, end, rx, ry, rotation, !!large, !!sweep), end)
|
|
235
|
+
}
|
|
236
|
+
previous = upper
|
|
237
|
+
}
|
|
238
|
+
return measure(first!, segments)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function measure(first: PathPoint, segments: readonly Segment[]): MotionPath {
|
|
242
|
+
const vertices: Vertex[] = [{ ...first, segment: -1, t: 0, distance: 0, heading: 0 }]
|
|
243
|
+
const heading = (segment: Segment, t: number, fallback: PathPoint) => {
|
|
244
|
+
let v = segment.tangent(t)
|
|
245
|
+
if (Math.hypot(v.x, v.y) < 1e-9) {
|
|
246
|
+
const a = segment.point(Math.max(0, t - 1e-5)),
|
|
247
|
+
b = segment.point(Math.min(1, t + 1e-5))
|
|
248
|
+
v = { x: b.x - a.x, y: b.y - a.y }
|
|
249
|
+
if (Math.hypot(v.x, v.y) < 1e-9) v = fallback
|
|
250
|
+
}
|
|
251
|
+
return (Math.atan2(v.y, v.x) * 180) / Math.PI
|
|
252
|
+
}
|
|
253
|
+
const append = (p: PathPoint, index: number, t: number) => {
|
|
254
|
+
const last = vertices.at(-1)!,
|
|
255
|
+
length = distance(last, p)
|
|
256
|
+
if (!length) return
|
|
257
|
+
if (vertices.length >= MAX_VERTICES)
|
|
258
|
+
throw new RangeError('Path needs too many geometry samples (8192 maximum).')
|
|
259
|
+
const direction = { x: p.x - last.x, y: p.y - last.y }
|
|
260
|
+
const start = heading(
|
|
261
|
+
segments[index]!,
|
|
262
|
+
last.segment === index ? last.t : 0,
|
|
263
|
+
direction,
|
|
264
|
+
)
|
|
265
|
+
if (vertices.length === 1) last.heading = start
|
|
266
|
+
const angle = unwrap(
|
|
267
|
+
heading(segments[index]!, t, direction),
|
|
268
|
+
unwrap(start, last.heading),
|
|
269
|
+
)
|
|
270
|
+
vertices.push({
|
|
271
|
+
...checked(p),
|
|
272
|
+
segment: index,
|
|
273
|
+
t,
|
|
274
|
+
distance: last.distance + length,
|
|
275
|
+
heading: angle,
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
segments.forEach((segment, index) => {
|
|
279
|
+
const flatten = (
|
|
280
|
+
t0: number,
|
|
281
|
+
t1: number,
|
|
282
|
+
a: PathPoint,
|
|
283
|
+
b: PathPoint,
|
|
284
|
+
depth: number,
|
|
285
|
+
) => {
|
|
286
|
+
const span = t1 - t0,
|
|
287
|
+
q1 = segment.point(t0 + span * 0.25),
|
|
288
|
+
mid = segment.point(t0 + span * 0.5),
|
|
289
|
+
q3 = segment.point(t0 + span * 0.75)
|
|
290
|
+
const poly =
|
|
291
|
+
distance(a, q1) + distance(q1, mid) + distance(mid, q3) + distance(q3, b),
|
|
292
|
+
chord = distance(a, b)
|
|
293
|
+
const deviation = Math.max(
|
|
294
|
+
distance(q1, mix(a, b, 0.25)),
|
|
295
|
+
distance(mid, mix(a, b, 0.5)),
|
|
296
|
+
distance(q3, mix(a, b, 0.75)),
|
|
297
|
+
)
|
|
298
|
+
const directions = [
|
|
299
|
+
t0,
|
|
300
|
+
t0 + span * 0.25,
|
|
301
|
+
t0 + span * 0.5,
|
|
302
|
+
t0 + span * 0.75,
|
|
303
|
+
t1,
|
|
304
|
+
].map((t) => segment.tangent(t))
|
|
305
|
+
const turn = directions.some((v, i) => {
|
|
306
|
+
const before = directions[i - 1]
|
|
307
|
+
return (
|
|
308
|
+
before &&
|
|
309
|
+
Math.hypot(v.x, v.y) > 1e-12 &&
|
|
310
|
+
Math.hypot(before.x, before.y) > 1e-12 &&
|
|
311
|
+
Math.abs(
|
|
312
|
+
Math.atan2(before.x * v.y - before.y * v.x, before.x * v.x + before.y * v.y),
|
|
313
|
+
) >
|
|
314
|
+
Math.PI / 8
|
|
315
|
+
)
|
|
316
|
+
})
|
|
317
|
+
const flat = poly - chord <= TOLERANCE && deviation <= TOLERANCE
|
|
318
|
+
if (flat && (!turn || depth >= 18)) {
|
|
319
|
+
append(b, index, t1)
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
if (depth >= 18)
|
|
323
|
+
throw new RangeError('Path geometry exceeds the sampling precision limit.')
|
|
324
|
+
flatten(t0, (t0 + t1) / 2, a, mid, depth + 1)
|
|
325
|
+
flatten((t0 + t1) / 2, t1, mid, b, depth + 1)
|
|
326
|
+
}
|
|
327
|
+
flatten(0, 1, segment.point(0), segment.point(1), 0)
|
|
328
|
+
})
|
|
329
|
+
const length = vertices.at(-1)!.distance
|
|
330
|
+
return {
|
|
331
|
+
length,
|
|
332
|
+
at(progress) {
|
|
333
|
+
if (!length) return { ...first, rotate: 0 }
|
|
334
|
+
const target = Math.max(0, Math.min(1, progress)) * length
|
|
335
|
+
let low = 1,
|
|
336
|
+
high = vertices.length - 1
|
|
337
|
+
while (low < high) {
|
|
338
|
+
const middle = (low + high) >>> 1
|
|
339
|
+
if (vertices[middle]!.distance <= target) low = middle + 1
|
|
340
|
+
else high = middle
|
|
341
|
+
}
|
|
342
|
+
const b = vertices[low]!,
|
|
343
|
+
a = vertices[low - 1]!,
|
|
344
|
+
fraction = Math.max(
|
|
345
|
+
0,
|
|
346
|
+
Math.min(1, (target - a.distance) / (b.distance - a.distance)),
|
|
347
|
+
)
|
|
348
|
+
const point = mix(a, b, fraction),
|
|
349
|
+
t0 = a.segment === b.segment ? a.t : 0,
|
|
350
|
+
t = t0 + (b.t - t0) * fraction
|
|
351
|
+
const angle = heading(segments[b.segment]!, t, { x: b.x - a.x, y: b.y - a.y })
|
|
352
|
+
return { ...point, rotate: unwrap(angle, b.heading) }
|
|
353
|
+
},
|
|
354
|
+
}
|
|
355
|
+
}
|
package/src/symbol-effect.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Image, ImageSource, isIOS } from '@nativescript/core'
|
|
|
2
2
|
import { toView } from './animate.js'
|
|
3
3
|
import type { Animation } from './engine.js'
|
|
4
4
|
import type { Target } from './types.js'
|
|
5
|
+
import { reducedMotion } from './config.js'
|
|
5
6
|
|
|
6
7
|
export type SymbolEffectName = 'bounce' | 'pulse' | 'scale' | 'variableColor'
|
|
7
8
|
|
|
@@ -121,7 +122,7 @@ class SymbolAnimation implements Animation {
|
|
|
121
122
|
return
|
|
122
123
|
this.started = true
|
|
123
124
|
const native = (this.native = view.nativeViewProtected as UIImageView)
|
|
124
|
-
const
|
|
125
|
+
const reduced = reducedMotion()
|
|
125
126
|
|
|
126
127
|
if (typeof effect !== 'string') {
|
|
127
128
|
// Resolve first so a symbol absent on this OS doesn't clear the current image.
|
|
@@ -145,7 +146,7 @@ class SymbolAnimation implements Animation {
|
|
|
145
146
|
const image = native.image
|
|
146
147
|
if (!image) return this.end(false)
|
|
147
148
|
if (
|
|
148
|
-
|
|
149
|
+
reduced ||
|
|
149
150
|
!previous?.symbolImage ||
|
|
150
151
|
!native.respondsToSelector(
|
|
151
152
|
'setSymbolImage:withContentTransition:options:completion:',
|
|
@@ -165,7 +166,7 @@ class SymbolAnimation implements Animation {
|
|
|
165
166
|
}
|
|
166
167
|
|
|
167
168
|
if (
|
|
168
|
-
|
|
169
|
+
reduced ||
|
|
169
170
|
!native.image?.symbolImage ||
|
|
170
171
|
!native.respondsToSelector('addSymbolEffect:options:animated:completion:')
|
|
171
172
|
)
|
package/src/text-layout.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
/** TextKit lays out the complete string before any glyphs are separated. */
|
|
2
2
|
type Insets = { top: number; left: number; bottom: number; right: number }
|
|
3
3
|
type NativeLabel = UILabel & { padding?: Insets; borderThickness?: Insets }
|
|
4
|
+
/** The characters one piece draws, as UTF-16 offsets into the label text. */
|
|
5
|
+
export interface TextPiece {
|
|
6
|
+
text: string
|
|
7
|
+
location: number
|
|
8
|
+
length: number
|
|
9
|
+
}
|
|
4
10
|
const MAX_PIECES = 256
|
|
5
11
|
const MAX_PIXELS = 4_000_000
|
|
6
12
|
|
|
@@ -30,7 +36,7 @@ export function textLayer(native: NativeLabel, by: 'letter' | 'word') {
|
|
|
30
36
|
if (native.adjustsFontSizeToFitWidth) return undefined
|
|
31
37
|
const original = native.attributedText
|
|
32
38
|
const text = original?.string ?? native.text ?? ''
|
|
33
|
-
if (!text.trim()) return { text, layer: undefined }
|
|
39
|
+
if (!text.trim()) return { text, layer: undefined, pieces: [] as TextPiece[] }
|
|
34
40
|
const string = NSString.stringWithString(text)
|
|
35
41
|
const full = { location: 0, length: string.length }
|
|
36
42
|
const attributed = original
|
|
@@ -169,11 +175,17 @@ export function textLayer(native: NativeLabel, by: 'letter' | 'word') {
|
|
|
169
175
|
)
|
|
170
176
|
if (attachments) return undefined
|
|
171
177
|
const shadowColor = native.shadowColor
|
|
178
|
+
const drawn: TextPiece[] = []
|
|
172
179
|
try {
|
|
173
180
|
native.shadowColor = null
|
|
174
181
|
for (const { range, rect } of pieces) {
|
|
175
182
|
const fragment = NSMutableAttributedString.alloc().initWithAttributedString(hidden)
|
|
176
183
|
const characters = manager.characterRangeForGlyphRangeActualGlyphRange(range, null)
|
|
184
|
+
drawn.push({
|
|
185
|
+
text: string.substringWithRange(characters),
|
|
186
|
+
location: characters.location,
|
|
187
|
+
length: characters.length,
|
|
188
|
+
})
|
|
177
189
|
attributed.enumerateAttributesInRangeOptionsUsingBlock(
|
|
178
190
|
characters,
|
|
179
191
|
0 as NSAttributedStringEnumerationOptions,
|
|
@@ -212,5 +224,5 @@ export function textLayer(native: NativeLabel, by: 'letter' | 'word') {
|
|
|
212
224
|
else native.text = text
|
|
213
225
|
native.shadowColor = shadowColor
|
|
214
226
|
}
|
|
215
|
-
return { text, layer }
|
|
227
|
+
return { text, layer, pieces: drawn }
|
|
216
228
|
}
|
package/src/text.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { stopIOSColorText } from './driver/ios-color.js'
|
|
2
|
-
import { Application, Label, isIOS } from '@nativescript/core'
|
|
2
|
+
import { Application, Label, Utils, isAndroid, isIOS } from '@nativescript/core'
|
|
3
3
|
import { textProperty, formattedTextProperty } from '@nativescript/core/ui/text-base'
|
|
4
4
|
import { toView } from './animate.js'
|
|
5
|
+
import { reducedMotion, timeScale } from './config.js'
|
|
5
6
|
import type { Animation } from './engine.js'
|
|
7
|
+
import { completion, forgetCompletion, watchLabel } from './label.js'
|
|
6
8
|
import { textLayer } from './text-layout.js'
|
|
7
9
|
import type { Target } from './types.js'
|
|
8
10
|
|
|
@@ -20,34 +22,8 @@ export interface TextOptions {
|
|
|
20
22
|
}
|
|
21
23
|
type Options = Required<TextOptions>
|
|
22
24
|
const running = new WeakMap<Label, TextAnimation>()
|
|
23
|
-
const callbacks = new WeakMap<NSObject, (finished: boolean) => void>()
|
|
24
|
-
let Delegate: typeof NSObject | undefined
|
|
25
|
-
const styleEvents = [
|
|
26
|
-
'fontInternalChange',
|
|
27
|
-
'colorChange',
|
|
28
|
-
'textAlignmentChange',
|
|
29
|
-
'textDecorationChange',
|
|
30
|
-
'textTransformChange',
|
|
31
|
-
'letterSpacingChange',
|
|
32
|
-
'lineHeightChange',
|
|
33
|
-
'textShadowChange',
|
|
34
|
-
'textStrokeChange',
|
|
35
|
-
'whiteSpaceChange',
|
|
36
|
-
'textOverflowChange',
|
|
37
|
-
'maxLinesChange',
|
|
38
|
-
'paddingLeftChange',
|
|
39
|
-
'paddingRightChange',
|
|
40
|
-
'paddingTopChange',
|
|
41
|
-
'paddingBottomChange',
|
|
42
|
-
'visibilityChange',
|
|
43
|
-
'directionChange',
|
|
44
|
-
'borderLeftWidthChange',
|
|
45
|
-
'borderRightWidthChange',
|
|
46
|
-
'borderTopWidthChange',
|
|
47
|
-
'borderBottomWidthChange',
|
|
48
|
-
]
|
|
49
25
|
|
|
50
|
-
/** Reveals the text of
|
|
26
|
+
/** Reveals the text of a Label without splitting its layout or accessibility element. */
|
|
51
27
|
export function animateText(target: Target, options: TextOptions = {}): Animation {
|
|
52
28
|
const config: Options = {
|
|
53
29
|
by: options.by ?? 'letter',
|
|
@@ -69,41 +45,14 @@ export function animateText(target: Target, options: TextOptions = {}): Animatio
|
|
|
69
45
|
return new TextAnimation(view, config)
|
|
70
46
|
}
|
|
71
47
|
|
|
72
|
-
function completion(
|
|
73
|
-
callback: (finished: boolean) => void,
|
|
74
|
-
): NSObject & CAAnimationDelegate {
|
|
75
|
-
if (!Delegate) {
|
|
76
|
-
const base = NSObject as typeof NSObject & {
|
|
77
|
-
extend(
|
|
78
|
-
methods: object,
|
|
79
|
-
options: { protocols: unknown[]; name: string },
|
|
80
|
-
): typeof NSObject
|
|
81
|
-
}
|
|
82
|
-
Delegate = base.extend(
|
|
83
|
-
{
|
|
84
|
-
animationDidStopFinished(
|
|
85
|
-
this: NSObject,
|
|
86
|
-
_animation: CAAnimation,
|
|
87
|
-
finished: boolean,
|
|
88
|
-
) {
|
|
89
|
-
callbacks.get(this)?.(finished)
|
|
90
|
-
},
|
|
91
|
-
},
|
|
92
|
-
{ protocols: [CAAnimationDelegate], name: 'JoltTextAnimationDelegate' },
|
|
93
|
-
)
|
|
94
|
-
}
|
|
95
|
-
const delegate = Delegate.new()
|
|
96
|
-
callbacks.set(delegate, callback)
|
|
97
|
-
return delegate
|
|
98
|
-
}
|
|
99
|
-
|
|
100
48
|
class TextAnimation implements Animation {
|
|
101
49
|
readonly finished: Promise<boolean>
|
|
102
50
|
private resolve!: (finished: boolean) => void
|
|
103
|
-
private readonly formatted: Label['formattedText']
|
|
104
51
|
private settled = false
|
|
105
52
|
private queued = false
|
|
106
|
-
private native: UILabel | undefined
|
|
53
|
+
private native: UILabel | android.widget.TextView | undefined
|
|
54
|
+
private reveal: org.rafaelaugusto.jolt.TextReveal | undefined
|
|
55
|
+
private unwatch: (() => void) | undefined
|
|
107
56
|
private bounds: CGRect | undefined
|
|
108
57
|
private layer: CALayer | undefined
|
|
109
58
|
private originalText: string | null = null
|
|
@@ -121,23 +70,19 @@ class TextAnimation implements Animation {
|
|
|
121
70
|
this.resolve = resolve
|
|
122
71
|
})
|
|
123
72
|
if (isIOS) stopIOSColorText(view)
|
|
124
|
-
this.formatted = view.formattedText
|
|
125
73
|
running.set(view, this)
|
|
126
|
-
if (!isIOS) {
|
|
74
|
+
if (!isIOS && !isAndroid) {
|
|
127
75
|
this.end(false)
|
|
128
76
|
return
|
|
129
77
|
}
|
|
130
|
-
if (options.duration === 0 ||
|
|
78
|
+
if (options.duration === 0 || reducedMotion()) {
|
|
131
79
|
this.end(true)
|
|
132
80
|
return
|
|
133
81
|
}
|
|
134
82
|
view.on('loaded', this.schedule)
|
|
135
83
|
view.on('layoutChanged', this.layoutChanged)
|
|
136
84
|
view.on('unloaded', this.stop)
|
|
137
|
-
|
|
138
|
-
view.on('formattedTextChange', this.changed)
|
|
139
|
-
this.formatted?.on('propertyChange', this.changed)
|
|
140
|
-
for (const event of styleEvents) view.style.on(event, this.changed)
|
|
85
|
+
this.unwatch = watchLabel(view, this.changed)
|
|
141
86
|
this.schedule()
|
|
142
87
|
}
|
|
143
88
|
|
|
@@ -151,9 +96,14 @@ class TextAnimation implements Animation {
|
|
|
151
96
|
stop = () => this.end(false)
|
|
152
97
|
private changed = () => this.end(false, true)
|
|
153
98
|
private motionChanged = () => {
|
|
154
|
-
if (
|
|
99
|
+
if (reducedMotion()) this.end(true)
|
|
155
100
|
}
|
|
156
101
|
private layoutChanged = () => {
|
|
102
|
+
if (isAndroid) {
|
|
103
|
+
if (!this.reveal) this.schedule()
|
|
104
|
+
else if (this.view.nativeViewProtected !== this.native) this.stop()
|
|
105
|
+
return
|
|
106
|
+
}
|
|
157
107
|
const native = this.view.nativeViewProtected as UILabel | undefined
|
|
158
108
|
const a = this.bounds
|
|
159
109
|
const b = native?.bounds
|
|
@@ -176,7 +126,7 @@ class TextAnimation implements Animation {
|
|
|
176
126
|
this.queued = true
|
|
177
127
|
void Promise.resolve().then(() => {
|
|
178
128
|
this.queued = false
|
|
179
|
-
if (this.settled || this.layer || !this.view.isLoaded) return
|
|
129
|
+
if (this.settled || this.layer || this.reveal || !this.view.isLoaded) return
|
|
180
130
|
try {
|
|
181
131
|
this.start()
|
|
182
132
|
} catch {
|
|
@@ -185,21 +135,21 @@ class TextAnimation implements Animation {
|
|
|
185
135
|
})
|
|
186
136
|
}
|
|
187
137
|
private start() {
|
|
188
|
-
if (this.options.duration === 0 ||
|
|
189
|
-
return this.end(true)
|
|
138
|
+
if (this.options.duration === 0 || reducedMotion()) return this.end(true)
|
|
190
139
|
if (this.view.visibility !== 'visible') return this.end(false)
|
|
140
|
+
if (isAndroid) return this.startAndroid()
|
|
191
141
|
const native = this.view.nativeViewProtected as UILabel | undefined
|
|
192
142
|
if (!(native instanceof UILabel) || !native.window) return
|
|
193
143
|
if (native.bounds.size.width <= 0 || native.bounds.size.height <= 0) return
|
|
194
144
|
const prepared = textLayer(native, this.options.by)
|
|
195
145
|
if (!prepared) return this.end(false)
|
|
196
146
|
if (!prepared.layer?.sublayers?.count) return this.end(true)
|
|
147
|
+
const slowdown = 1 / timeScale()
|
|
148
|
+
const duration = this.options.duration * slowdown
|
|
149
|
+
const stagger = this.options.stagger * slowdown
|
|
150
|
+
const delay = this.options.delay * slowdown
|
|
197
151
|
if (
|
|
198
|
-
!Number.isFinite(
|
|
199
|
-
this.options.delay +
|
|
200
|
-
this.options.duration +
|
|
201
|
-
(prepared.layer.sublayers.count - 1) * this.options.stagger,
|
|
202
|
-
)
|
|
152
|
+
!Number.isFinite(delay + duration + (prepared.layer.sublayers.count - 1) * stagger)
|
|
203
153
|
)
|
|
204
154
|
return this.end(false)
|
|
205
155
|
this.native = native
|
|
@@ -209,7 +159,7 @@ class TextAnimation implements Animation {
|
|
|
209
159
|
this.originalAttributed = native.attributedText
|
|
210
160
|
this.accessibility = native.accessibilityLabel
|
|
211
161
|
this.temporaryAccessibility = this.accessibility ?? prepared.text
|
|
212
|
-
const {
|
|
162
|
+
const { y } = this.options
|
|
213
163
|
const start = CACurrentMediaTime()
|
|
214
164
|
CATransaction.begin()
|
|
215
165
|
CATransaction.setDisableActions(true)
|
|
@@ -247,24 +197,55 @@ class TextAnimation implements Animation {
|
|
|
247
197
|
CATransaction.commit()
|
|
248
198
|
}
|
|
249
199
|
}
|
|
200
|
+
private startAndroid() {
|
|
201
|
+
const native = this.view.nativeViewProtected as android.widget.TextView | undefined
|
|
202
|
+
if (!(native instanceof android.widget.TextView)) return this.end(false)
|
|
203
|
+
const { by, duration, stagger, delay, y } = this.options
|
|
204
|
+
let reveal: org.rafaelaugusto.jolt.TextReveal
|
|
205
|
+
try {
|
|
206
|
+
reveal = new org.rafaelaugusto.jolt.TextReveal(
|
|
207
|
+
native,
|
|
208
|
+
by === 'word',
|
|
209
|
+
Math.round(duration),
|
|
210
|
+
Math.round(stagger),
|
|
211
|
+
Math.round(delay),
|
|
212
|
+
y * Utils.layout.getDisplayDensity(),
|
|
213
|
+
new org.rafaelaugusto.jolt.TextReveal.Callback({
|
|
214
|
+
onEnd: (finished: boolean) => {
|
|
215
|
+
if (this.reveal !== reveal) return
|
|
216
|
+
this.reveal = undefined
|
|
217
|
+
this.end(finished)
|
|
218
|
+
},
|
|
219
|
+
}),
|
|
220
|
+
)
|
|
221
|
+
} catch {
|
|
222
|
+
// A JS-only update cannot install the native plugin. Keep the label as it is.
|
|
223
|
+
return this.end(false)
|
|
224
|
+
}
|
|
225
|
+
if (!reveal.isSupported()) return this.end(false)
|
|
226
|
+
this.native = native
|
|
227
|
+
this.reveal = reveal
|
|
228
|
+
reveal.start()
|
|
229
|
+
}
|
|
250
230
|
private end(finished: boolean, refresh = false) {
|
|
251
231
|
if (this.settled) return
|
|
252
232
|
this.settled = true
|
|
253
|
-
const { view,
|
|
233
|
+
const { view, layer } = this
|
|
234
|
+
const native = this.native as UILabel | undefined
|
|
235
|
+
const reveal = this.reveal
|
|
236
|
+
this.reveal = undefined
|
|
237
|
+
reveal?.cancel()
|
|
254
238
|
view.off('loaded', this.schedule)
|
|
255
239
|
view.off('layoutChanged', this.layoutChanged)
|
|
256
240
|
view.off('unloaded', this.stop)
|
|
257
|
-
|
|
258
|
-
view.off('formattedTextChange', this.changed)
|
|
259
|
-
this.formatted?.off('propertyChange', this.changed)
|
|
260
|
-
for (const event of styleEvents) view.style.off(event, this.changed)
|
|
241
|
+
this.unwatch?.()
|
|
261
242
|
if (running.get(view) === this) running.delete(view)
|
|
262
243
|
if (this.observer)
|
|
263
244
|
Application.ios.removeNotificationObserver(
|
|
264
245
|
this.observer,
|
|
265
246
|
UIAccessibilityReduceMotionStatusDidChangeNotification,
|
|
266
247
|
)
|
|
267
|
-
if (this.delegate)
|
|
248
|
+
if (this.delegate) forgetCompletion(this.delegate)
|
|
268
249
|
if (native && layer) {
|
|
269
250
|
CATransaction.begin()
|
|
270
251
|
CATransaction.setDisableActions(true)
|