@px-petals/roku.motion-web 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/audio/audio.cues.js +27 -0
- package/audio/audio.js +259 -0
- package/bands/bands.js +392 -0
- package/bundle/bundle.fonts.js +55 -0
- package/bundle/bundle.js +131 -0
- package/focus/focus.direction.js +46 -0
- package/focus/focus.dom.js +158 -0
- package/focus/focus.js +277 -0
- package/focus/focus.tree.js +146 -0
- package/hit/hit.geometry.js +113 -0
- package/hit/hit.js +129 -0
- package/list/list.flex.js +181 -0
- package/list/list.items.js +80 -0
- package/list/list.js +518 -0
- package/lottie/lottie.js +124 -0
- package/lottie/lottie.layers.js +416 -0
- package/lottie/lottie.paint.js +238 -0
- package/lottie/lottie.paths.js +474 -0
- package/lottie/lottie.props.js +265 -0
- package/lottie/lottie.shapes.js +259 -0
- package/mixer/mixer.js +307 -0
- package/mixer/mixer.mix.js +107 -0
- package/mixer/mixer.stage.js +431 -0
- package/motion/motion.convert.js +404 -0
- package/motion/motion.flow.js +421 -0
- package/motion/motion.keys.js +150 -0
- package/motion/motion.machine.js +382 -0
- package/motion/motion.nested.js +345 -0
- package/motion/motion.values.js +368 -0
- package/motion.js +1155 -0
- package/package.json +47 -4
- package/player/overlay/overlay.js +325 -0
- package/player/overlay/overlay.layout.js +208 -0
- package/player/player.fit.js +101 -0
- package/player/player.js +421 -0
- package/player/player.raster.js +127 -0
- package/pointer/pointer.js +292 -0
- package/readme.md +345 -0
- package/script/luau/lib/base/base.js +202 -0
- package/script/luau/lib/base/base.table.js +189 -0
- package/script/luau/lib/lib.args.js +152 -0
- package/script/luau/lib/lib.js +23 -0
- package/script/luau/lib/math/math.bit32.js +144 -0
- package/script/luau/lib/math/math.js +288 -0
- package/script/luau/lib/math/math.vector.js +125 -0
- package/script/luau/lib/string/string.format.js +143 -0
- package/script/luau/lib/string/string.js +160 -0
- package/script/luau/lib/string/string.pattern.js +505 -0
- package/script/luau/load/load.js +362 -0
- package/script/luau/load/load.ops.js +79 -0
- package/script/luau/luau.convert.js +259 -0
- package/script/luau/luau.js +132 -0
- package/script/luau/luau.md +111 -0
- package/script/luau/vm/vm.js +167 -0
- package/script/luau/vm/vm.meta.js +401 -0
- package/script/luau/vm/vm.num.js +236 -0
- package/script/luau/vm/vm.run.js +842 -0
- package/script/luau/vm/vm.value.js +406 -0
- package/script/play/play.js +199 -0
- package/script/play/play.objects.js +295 -0
- package/script/play/play.scope.js +91 -0
- package/script/rive/api/api.statics.js +31 -0
- package/script/rive/api/api.type.js +188 -0
- package/script/rive/api/artboard/artboard.js +89 -0
- package/script/rive/api/assets/assets.js +72 -0
- package/script/rive/api/data/data.value.js +99 -0
- package/script/rive/api/draw/draw.contours.js +163 -0
- package/script/rive/api/draw/draw.measure.js +196 -0
- package/script/rive/api/draw/draw.paint.js +235 -0
- package/script/rive/api/draw/draw.path.js +153 -0
- package/script/rive/api/draw/draw.renderer.js +117 -0
- package/script/rive/api/math/math.color.js +96 -0
- package/script/rive/api/math/math.mat2d.js +180 -0
- package/script/rive/api/math/math.vector.js +121 -0
- package/script/rive/api/media/media.audio.js +113 -0
- package/script/rive/api/media/media.draw.js +73 -0
- package/script/rive/api/media/media.image.js +61 -0
- package/script/rive/api/media/media.mesh.js +50 -0
- package/script/rive/api/pointer/pointer.event.js +64 -0
- package/script/rive/api/pointer/pointer.route.js +69 -0
- package/script/rive/api/text/text.font.js +129 -0
- package/script/rive/api/text/text.js +278 -0
- package/script/rive/api/text/text.layout.js +259 -0
- package/script/rive/context/context.js +76 -0
- package/script/rive/context/context.memory.js +196 -0
- package/script/rive/context/context.viewmodel.js +302 -0
- package/script/rive/context/lists/lists.js +160 -0
- package/script/rive/context/lists/lists.values.js +281 -0
- package/script/rive/host/host.globals.js +44 -0
- package/script/rive/host/host.js +533 -0
- package/script/rive/host/host.node.js +45 -0
- package/script/rive/rive.js +23 -0
- package/script/rive/rive.md +180 -0
- package/script/script.js +11 -0
- package/skin/skin.deform.js +514 -0
- package/skin/skin.ik.js +260 -0
- package/skin/skin.js +297 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@px-petals/roku.motion-web",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Motion for the web: plays a lottie-roku bundle on a 2D canvas, vector from its Lottie or from its raster sheets, with its state machine, live data, keys and text, for browsers that can't run Rive's own runtime.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "motion.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./motion.js",
|
|
10
|
+
"./*/.tests": null,
|
|
11
|
+
"./*.test.js": null,
|
|
12
|
+
"./*.js": "./*.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"**/*",
|
|
16
|
+
"!**/.tests",
|
|
17
|
+
"!**/*.test.js",
|
|
18
|
+
"!jsconfig*.json",
|
|
19
|
+
"!legacy",
|
|
20
|
+
"!.artifacts"
|
|
21
|
+
],
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"browserslist": [
|
|
24
|
+
"chrome >= 32",
|
|
25
|
+
"safari >= 7.1",
|
|
26
|
+
"ios_saf >= 8",
|
|
27
|
+
"firefox >= 29",
|
|
28
|
+
"android >= 4.4.3",
|
|
29
|
+
"samsung >= 4",
|
|
30
|
+
"edge >= 18"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build:legacy": "node legacy/legacy.js",
|
|
34
|
+
"legacy": "node .tests/legacy/legacy.js",
|
|
35
|
+
"parity": "node .tests/parity/parity.js",
|
|
36
|
+
"test": "node --test \".tests/*.test.js\" \"audio/.tests/*.test.js\" \"bands/.tests/*.test.js\" \"hit/.tests/*.test.js\" \"focus/.tests/*.test.js\" \"player/.tests/*.test.js\" \"pointer/.tests/*.test.js\" \"lottie/.tests/*.test.js\" \"list/.tests/*.test.js\" \"mixer/.tests/*.test.js\" \"skin/.tests/*.test.js\" \"motion/.tests/*.test.js\" \"script/luau/.tests/*.test.js\" \"script/luau/.tests/reference/*.test.js\" \"script/rive/.tests/*.test.js\" \"script/play/.tests/*.test.js\" \".tests/browser/*.test.js\"",
|
|
37
|
+
"typecheck": "tsc -p jsconfig.json",
|
|
38
|
+
"luau:fetch": "node script/luau/.tests/reference/reference.fetch.js",
|
|
39
|
+
"luau:fixtures": "node script/luau/.tests/reference/reference.regen.js"
|
|
40
|
+
},
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/px-petals/motion.git",
|
|
44
|
+
"directory": "src/runtime/web"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text overlays: the text layers lottie-roku lifts out of an animation,
|
|
3
|
+
* drawn on the canvas each frame along their tracks. A track's keys are
|
|
4
|
+
* linear per frame (lottie-roku samples the Lottie's easing), so drawing at
|
|
5
|
+
* a frame is a linear blend between two keys. A live input replaces the
|
|
6
|
+
* text it fills: the first run's, when the text has several.
|
|
7
|
+
*
|
|
8
|
+
* A text is one or more styled runs. They flow together through lines that
|
|
9
|
+
* wrap at the box width (`wrap`), align across it (`align`) and sit in its
|
|
10
|
+
* height (`valign`); see overlay.layout.js.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { fontOf, layoutRuns } from './overlay.layout.js'
|
|
14
|
+
|
|
15
|
+
/** @import { Matrix } from '../../lottie/lottie.paths.js' */
|
|
16
|
+
/** @import { Line, Run } from './overlay.layout.js' */
|
|
17
|
+
|
|
18
|
+
/** Where a font's baseline sits below the top of its em box, as a fraction of its size */
|
|
19
|
+
const ASCENT = 0.8
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {CanvasRenderingContext2D} ctx
|
|
23
|
+
* @param {any[]} texts an animation's `texts`
|
|
24
|
+
* @param {number} frame from the animation's start
|
|
25
|
+
* @param {Record<string, any>} values live inputs
|
|
26
|
+
* @param {Record<string, string>} families bundle font name → registered family
|
|
27
|
+
* @param {Matrix} matrix the animation's space onto the canvas
|
|
28
|
+
* @param {Record<string, string>} inputOf overlay slot id → the input that fills it
|
|
29
|
+
*/
|
|
30
|
+
export function drawOverlays(ctx, texts, frame, values, families, matrix, inputOf) {
|
|
31
|
+
for (let n = 0; n < (texts || []).length; n++) {
|
|
32
|
+
const text = texts[n]
|
|
33
|
+
const track = text.track
|
|
34
|
+
const isVisible = frame >= track.visible[0] && frame < track.visible[1]
|
|
35
|
+
if (!isVisible) continue
|
|
36
|
+
|
|
37
|
+
const opacity = keyAt(track.opacity, frame)[0] / 100
|
|
38
|
+
if (opacity <= 0) continue
|
|
39
|
+
|
|
40
|
+
const input = text.sid ? inputOf[text.sid] : undefined
|
|
41
|
+
const live = input !== undefined && values[input] !== undefined && values[input] !== null ? String(values[input]) : null
|
|
42
|
+
const position = keyAt(track.position, frame)
|
|
43
|
+
const scale = keyAt(track.scale, frame)
|
|
44
|
+
const rotation = (keyAt(track.rotation, frame)[0] * Math.PI) / 180
|
|
45
|
+
|
|
46
|
+
ctx.save()
|
|
47
|
+
ctx.setTransform(matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5])
|
|
48
|
+
ctx.translate(position[0], position[1])
|
|
49
|
+
ctx.rotate(rotation)
|
|
50
|
+
ctx.scale(scale[0] / 100, scale[1] / 100)
|
|
51
|
+
ctx.globalAlpha = Math.min(1, opacity)
|
|
52
|
+
ctx.textAlign = 'left'
|
|
53
|
+
drawText(ctx, text, runsOf(text, live), families)
|
|
54
|
+
ctx.restore()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A text layer drawn where the renderer is: a Lottie text document, which a
|
|
60
|
+
* nested artboard's text stays as inside its precomp, drawn at a matrix to
|
|
61
|
+
* the canvas as an overlay is, with the live words in place of the document's.
|
|
62
|
+
*
|
|
63
|
+
* @param {CanvasRenderingContext2D} ctx
|
|
64
|
+
* @param {any} doc the layer's text document
|
|
65
|
+
* @param {string | null} live the words a data bind sets
|
|
66
|
+
* @param {Record<string, string>} families bundle font name → registered family
|
|
67
|
+
* @param {Matrix} matrix the layer's space onto the canvas
|
|
68
|
+
*/
|
|
69
|
+
export function drawDocument(ctx, doc, live, families, matrix) {
|
|
70
|
+
const text = overlayOf(doc)
|
|
71
|
+
ctx.save()
|
|
72
|
+
ctx.setTransform(matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5])
|
|
73
|
+
ctx.textAlign = 'left'
|
|
74
|
+
drawText(ctx, text, runsOf(text, live), families)
|
|
75
|
+
ctx.restore()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The overlay lottie-roku makes of a text document, without its track.
|
|
80
|
+
*
|
|
81
|
+
* @param {any} doc
|
|
82
|
+
* @returns {any}
|
|
83
|
+
*/
|
|
84
|
+
function overlayOf(doc) {
|
|
85
|
+
const size = doc.s || 12
|
|
86
|
+
const spacing = (/** @type {number} */ tracking) => Math.round(((tracking || 0) / 1000) * size * 100) / 100
|
|
87
|
+
const color = (/** @type {number[] | undefined} */ fc) => {
|
|
88
|
+
const channels = (fc || [ 0, 0, 0 ]).map((/** @type {number} */ channel) => Math.round(Math.min(1, Math.max(0, channel)) * 255))
|
|
89
|
+
return '0x' + channels.map((/** @type {number} */ n) => (n < 16 ? '0' : '') + n.toString(16)).join('') + 'ff'
|
|
90
|
+
}
|
|
91
|
+
const runs = Array.isArray(doc.rs) && doc.rs.length > 1 ? doc.rs.map((/** @type {any} */ run) => {
|
|
92
|
+
const own = run.s || size
|
|
93
|
+
return { text: String(run.t || '').replace(/\r/g, '\n'), font: run.f || doc.f || '', size: own, color: color(run.fc || doc.fc), lineHeight: run.lh || own * 1.2, letterSpacing: Math.round(((run.tr || 0) / 1000) * own * 100) / 100 }
|
|
94
|
+
}) : null
|
|
95
|
+
return {
|
|
96
|
+
text: String(doc.t || '').replace(/\r/g, '\n'),
|
|
97
|
+
font: doc.f || '',
|
|
98
|
+
size,
|
|
99
|
+
color: color(doc.fc),
|
|
100
|
+
align: [ 'left', 'right', 'center' ][doc.j] || 'left',
|
|
101
|
+
lineHeight: doc.lh || size * 1.2,
|
|
102
|
+
letterSpacing: spacing(doc.tr),
|
|
103
|
+
runs,
|
|
104
|
+
wrap: !doc.nw,
|
|
105
|
+
valign: doc.va === 'middle' || doc.va === 'bottom' ? doc.va : 'top',
|
|
106
|
+
box: doc.sz ? [ doc.sz[0], doc.sz[1] ] : null,
|
|
107
|
+
boxOrigin: doc.ps ? [ doc.ps[0], doc.ps[1] ] : [ 0, 0 ],
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A track's value at a frame: before the first key, its value; between two,
|
|
113
|
+
* their linear blend; after the last, its value.
|
|
114
|
+
*
|
|
115
|
+
* @param {{ t: number, v: number[] }[]} keys
|
|
116
|
+
* @param {number} frame
|
|
117
|
+
* @returns {number[]}
|
|
118
|
+
*/
|
|
119
|
+
export function keyAt(keys, frame) {
|
|
120
|
+
if (!keys.length) return [ 0, 0 ]
|
|
121
|
+
if (frame <= keys[0].t) return keys[0].v
|
|
122
|
+
for (let n = 1; n < keys.length; n++) {
|
|
123
|
+
const next = keys[n]
|
|
124
|
+
if (frame > next.t) continue
|
|
125
|
+
const previous = keys[n - 1]
|
|
126
|
+
const span = next.t - previous.t
|
|
127
|
+
const t = span > 0 ? (frame - previous.t) / span : 1
|
|
128
|
+
return previous.v.map((value, d) => value + (next.v[d] - value) * t)
|
|
129
|
+
}
|
|
130
|
+
return keys[keys.length - 1].v
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The channels of a text's track, and how many values each key holds */
|
|
134
|
+
const CHANNELS = [ 'position', 'scale', 'rotation', 'opacity' ]
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* What several animations playing at once show of their texts: each text of
|
|
138
|
+
* the first, with every channel of its track mixed as Rive mixes the
|
|
139
|
+
* animations that key it, in order, each lerping toward its own value by its
|
|
140
|
+
* weight. A channel an animation does not move (one key) is left alone by it.
|
|
141
|
+
* A text shows when it shows in the animation with the most weight.
|
|
142
|
+
*
|
|
143
|
+
* The texts of every animation are the same list, in the same order. The
|
|
144
|
+
* result is for `drawOverlays` at frame 0.
|
|
145
|
+
*
|
|
146
|
+
* @param {{ texts: any[], frame: number, weight: number }[]} poses in the order they apply
|
|
147
|
+
* @returns {any[]}
|
|
148
|
+
*/
|
|
149
|
+
export function mixOverlays(poses) {
|
|
150
|
+
const first = poses.length ? poses[0].texts || [] : []
|
|
151
|
+
/** @type {any[]} */
|
|
152
|
+
const mixed = []
|
|
153
|
+
for (let n = 0; n < first.length; n++) {
|
|
154
|
+
let heaviest = poses[0]
|
|
155
|
+
for (let p = 1; p < poses.length; p++) if (poses[p].weight >= heaviest.weight) heaviest = poses[p]
|
|
156
|
+
const lead = (heaviest.texts || [])[n]
|
|
157
|
+
if (!lead) continue
|
|
158
|
+
const isVisible = heaviest.frame >= lead.track.visible[0] && heaviest.frame < lead.track.visible[1]
|
|
159
|
+
if (!isVisible) continue
|
|
160
|
+
|
|
161
|
+
/** @type {Record<string, any>} */
|
|
162
|
+
const track = { frames: lead.track.frames, fps: lead.track.fps, visible: [ 0, 1 ] }
|
|
163
|
+
for (let c = 0; c < CHANNELS.length; c++) {
|
|
164
|
+
const channel = CHANNELS[c]
|
|
165
|
+
// what no animation moves: the static value, from the one that is heaviest
|
|
166
|
+
let value = keyAt(lead.track[channel], heaviest.frame)
|
|
167
|
+
for (let p = 0; p < poses.length; p++) {
|
|
168
|
+
const other = (poses[p].texts || [])[n]
|
|
169
|
+
if (other && other.track[channel].length < 2) value = keyAt(other.track[channel], 0)
|
|
170
|
+
}
|
|
171
|
+
for (let p = 0; p < poses.length; p++) {
|
|
172
|
+
const other = (poses[p].texts || [])[n]
|
|
173
|
+
if (!other || other.track[channel].length < 2) continue
|
|
174
|
+
const sample = keyAt(other.track[channel], poses[p].frame)
|
|
175
|
+
const weight = Math.min(1, Math.max(0, poses[p].weight))
|
|
176
|
+
value = value.map((v, d) => v + (sample[d] - v) * weight)
|
|
177
|
+
}
|
|
178
|
+
track[channel] = [ { t: 0, v: value } ]
|
|
179
|
+
}
|
|
180
|
+
mixed.push({ ...lead, track })
|
|
181
|
+
}
|
|
182
|
+
return mixed
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The runs a text draws: its own, or one in the text's style, with a live
|
|
187
|
+
* value in place of the first run's words.
|
|
188
|
+
*
|
|
189
|
+
* @param {any} text
|
|
190
|
+
* @param {string | null} live
|
|
191
|
+
* @returns {Run[]}
|
|
192
|
+
*/
|
|
193
|
+
function runsOf(text, live) {
|
|
194
|
+
const spacing = text.letterSpacing || 0
|
|
195
|
+
const hasRuns = text.runs && text.runs.length
|
|
196
|
+
if (!hasRuns) {
|
|
197
|
+
return [ { text: live === null ? text.text : live, font: text.font, size: text.size, color: text.color, lineHeight: text.lineHeight || text.size * 1.2, letterSpacing: spacing } ]
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const runs = []
|
|
201
|
+
for (let n = 0; n < text.runs.length; n++) {
|
|
202
|
+
const run = text.runs[n]
|
|
203
|
+
runs.push({ text: n === 0 && live !== null ? live : run.text, font: run.font, size: run.size, color: run.color, lineHeight: run.lineHeight || run.size * 1.2, letterSpacing: run.letterSpacing || 0 })
|
|
204
|
+
}
|
|
205
|
+
return runs
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Box text wraps at the box's width and sits in its height from its
|
|
210
|
+
* top-left; point text hangs from its origin, the first baseline on it.
|
|
211
|
+
*
|
|
212
|
+
* @param {CanvasRenderingContext2D} ctx
|
|
213
|
+
* @param {any} text
|
|
214
|
+
* @param {Run[]} runs
|
|
215
|
+
* @param {Record<string, string>} families
|
|
216
|
+
*/
|
|
217
|
+
function drawText(ctx, text, runs, families) {
|
|
218
|
+
const [ x, y ] = text.boxOrigin
|
|
219
|
+
const box = text.box
|
|
220
|
+
const lines = layoutRuns(ctx, runs, box ? box[0] : null, text.wrap !== false, families)
|
|
221
|
+
|
|
222
|
+
let top = y
|
|
223
|
+
if (box) {
|
|
224
|
+
let total = 0
|
|
225
|
+
for (let n = 0; n < lines.length; n++) total += lines[n].height
|
|
226
|
+
if (text.valign === 'middle') top += (box[1] - total) / 2
|
|
227
|
+
else if (text.valign === 'bottom') top += box[1] - total
|
|
228
|
+
} else if (lines.length) {
|
|
229
|
+
// the first baseline sits on the origin
|
|
230
|
+
top -= lines[0].height
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
for (let n = 0; n < lines.length; n++) {
|
|
234
|
+
const line = lines[n]
|
|
235
|
+
const left = lineLeft(text.align, line.width, x, box)
|
|
236
|
+
drawLine(ctx, line, left, box ? top : top + line.height, !!box, families)
|
|
237
|
+
top += line.height
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* @param {string} align
|
|
243
|
+
* @param {number} width the line's
|
|
244
|
+
* @param {number} x the box's left, or point text's origin
|
|
245
|
+
* @param {number[] | null} box
|
|
246
|
+
* @returns {number} where the line starts
|
|
247
|
+
*/
|
|
248
|
+
function lineLeft(align, width, x, box) {
|
|
249
|
+
const room = box ? box[0] - width : -width
|
|
250
|
+
if (align === 'center') return x + room / 2
|
|
251
|
+
if (align === 'right') return x + room
|
|
252
|
+
return x
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Draws a line's pieces side by side. In a box a line hangs from its top
|
|
257
|
+
* and a smaller run is dropped to share the line's baseline; point text
|
|
258
|
+
* stands on its baseline.
|
|
259
|
+
*
|
|
260
|
+
* @param {CanvasRenderingContext2D} ctx
|
|
261
|
+
* @param {Line} line
|
|
262
|
+
* @param {number} left
|
|
263
|
+
* @param {number} y the line's top in a box, its baseline otherwise
|
|
264
|
+
* @param {boolean} isBoxed
|
|
265
|
+
* @param {Record<string, string>} families
|
|
266
|
+
*/
|
|
267
|
+
function drawLine(ctx, line, left, y, isBoxed, families) {
|
|
268
|
+
let x = left
|
|
269
|
+
for (let n = 0; n < line.pieces.length; n++) {
|
|
270
|
+
const piece = line.pieces[n]
|
|
271
|
+
const run = piece.run
|
|
272
|
+
ctx.font = fontOf(run, families)
|
|
273
|
+
ctx.fillStyle = cssColor(run.color)
|
|
274
|
+
// a line in a box hangs from its top: the font's own ascent above the baseline, as Rive sets it, where the browser measures it
|
|
275
|
+
const ascent = isBoxed ? ascentOf(ctx) : null
|
|
276
|
+
ctx.textBaseline = isBoxed && ascent === null ? 'top' : 'alphabetic'
|
|
277
|
+
const at = isBoxed ? y + (line.size - run.size) * ASCENT + (ascent === null ? 0 : ascent) : y
|
|
278
|
+
if (run.letterSpacing) drawSpaced(ctx, piece.text, x, at, run.letterSpacing)
|
|
279
|
+
else ctx.fillText(piece.text, x, at)
|
|
280
|
+
x += piece.width
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* How far above its baseline a font's line reaches, where the browser
|
|
286
|
+
* measures it (an old one does not: its text hangs from the em box's top).
|
|
287
|
+
*
|
|
288
|
+
* @param {CanvasRenderingContext2D} ctx the context, with the run's font set
|
|
289
|
+
* @returns {number | null}
|
|
290
|
+
*/
|
|
291
|
+
function ascentOf(ctx) {
|
|
292
|
+
const ascent = /** @type {any} */ (ctx.measureText('Hg')).fontBoundingBoxAscent
|
|
293
|
+
return typeof ascent === 'number' && ascent > 0 ? ascent : null
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Canvas text has no letter spacing in older browsers, so each character is
|
|
298
|
+
* placed on its own.
|
|
299
|
+
*
|
|
300
|
+
* @param {CanvasRenderingContext2D} ctx
|
|
301
|
+
* @param {string} text
|
|
302
|
+
* @param {number} x
|
|
303
|
+
* @param {number} y
|
|
304
|
+
* @param {number} spacing px after each character
|
|
305
|
+
*/
|
|
306
|
+
function drawSpaced(ctx, text, x, y, spacing) {
|
|
307
|
+
let pen = x
|
|
308
|
+
for (let n = 0; n < text.length; n++) {
|
|
309
|
+
const char = text.charAt(n)
|
|
310
|
+
ctx.fillText(char, pen, y)
|
|
311
|
+
pen += ctx.measureText(char).width + spacing
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* lottie-roku's `0xRRGGBBAA` as CSS.
|
|
317
|
+
*
|
|
318
|
+
* @param {string} value
|
|
319
|
+
*/
|
|
320
|
+
function cssColor(value) {
|
|
321
|
+
const hex = String(value).replace(/^0x|^#/i, '')
|
|
322
|
+
const byte = (/** @type {number} */ at) => parseInt(hex.slice(at, at + 2), 16)
|
|
323
|
+
const alpha = hex.length >= 8 ? byte(6) / 255 : 1
|
|
324
|
+
return 'rgba(' + byte(0) + ',' + byte(2) + ',' + byte(4) + ',' + alpha + ')'
|
|
325
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lays styled runs out in lines with `measureText`: words wrap at the box
|
|
3
|
+
* width, a word wider than the box breaks between characters, and each line
|
|
4
|
+
* records the pieces to draw, its width and its height.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One run's style, as an overlay carries it.
|
|
9
|
+
*
|
|
10
|
+
* @typedef {object} Run
|
|
11
|
+
* @property {string} text
|
|
12
|
+
* @property {string} font bundle font name
|
|
13
|
+
* @property {number} size px
|
|
14
|
+
* @property {string} color 0xRRGGBBAA
|
|
15
|
+
* @property {number} lineHeight px
|
|
16
|
+
* @property {number} letterSpacing px after each character
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A stretch of one run on one line.
|
|
21
|
+
*
|
|
22
|
+
* @typedef {object} Piece
|
|
23
|
+
* @property {Run} run
|
|
24
|
+
* @property {string} text
|
|
25
|
+
* @property {number} width
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {object} Line
|
|
30
|
+
* @property {Piece[]} pieces
|
|
31
|
+
* @property {number} width without trailing spaces
|
|
32
|
+
* @property {number} height the tallest run's line height
|
|
33
|
+
* @property {number} size the largest run's font size
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {Run} run
|
|
38
|
+
* @param {Record<string, string>} families bundle font name → registered family
|
|
39
|
+
* @returns {string} a canvas font
|
|
40
|
+
*/
|
|
41
|
+
export function fontOf(run, families) {
|
|
42
|
+
return run.size + 'px "' + (families[run.font] || run.font) + '", sans-serif'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {CanvasRenderingContext2D} ctx
|
|
47
|
+
* @param {Run[]} runs
|
|
48
|
+
* @param {number | null} width the box's width; null for point text, which never wraps
|
|
49
|
+
* @param {boolean} isWrapped
|
|
50
|
+
* @param {Record<string, string>} families
|
|
51
|
+
* @returns {Line[]}
|
|
52
|
+
*/
|
|
53
|
+
export function layoutRuns(ctx, runs, width, isWrapped, families) {
|
|
54
|
+
const limit = isWrapped && width !== null ? width : Infinity
|
|
55
|
+
const lines = [ newLine(runs[0]) ]
|
|
56
|
+
|
|
57
|
+
const words = wordsOf(ctx, runs, families)
|
|
58
|
+
for (let n = 0; n < words.length; n++) {
|
|
59
|
+
const word = words[n]
|
|
60
|
+
if (word.isBreak) {
|
|
61
|
+
lines.push(newLine(word.run))
|
|
62
|
+
} else if (word.isSpace) {
|
|
63
|
+
// a space never starts a wrapped line, and trailing ones don't count
|
|
64
|
+
const line = lines[lines.length - 1]
|
|
65
|
+
if (line.pieces.length) addPiece(line, word.pieces[0])
|
|
66
|
+
} else {
|
|
67
|
+
placeWord(ctx, lines, word, limit, families)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (let n = 0; n < lines.length; n++) trim(lines[n])
|
|
71
|
+
return lines
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {Run} [run] the run the line starts in, for the height of an empty one
|
|
76
|
+
* @returns {Line}
|
|
77
|
+
*/
|
|
78
|
+
function newLine(run) {
|
|
79
|
+
return { pieces: [], width: 0, height: run ? run.lineHeight : 0, size: run ? run.size : 0 }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {Line} line
|
|
84
|
+
* @param {Piece} piece added to the end of the line
|
|
85
|
+
*/
|
|
86
|
+
function addPiece(line, piece) {
|
|
87
|
+
line.pieces.push(piece)
|
|
88
|
+
line.height = Math.max(line.height, piece.run.lineHeight)
|
|
89
|
+
line.size = Math.max(line.size, piece.run.size)
|
|
90
|
+
line.width += piece.width
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Words, spaces and line breaks of the runs, in order. A word is the
|
|
95
|
+
* stretch between spaces and may cross runs, which only changes its pieces'
|
|
96
|
+
* style.
|
|
97
|
+
*
|
|
98
|
+
* @param {CanvasRenderingContext2D} ctx
|
|
99
|
+
* @param {Run[]} runs
|
|
100
|
+
* @param {Record<string, string>} families
|
|
101
|
+
* @returns {Array<{ pieces: Piece[], width: number, isSpace: boolean, isBreak: boolean, run: Run }>}
|
|
102
|
+
*/
|
|
103
|
+
function wordsOf(ctx, runs, families) {
|
|
104
|
+
const out = []
|
|
105
|
+
/** @type {{ pieces: Piece[], width: number, isSpace: boolean, isBreak: boolean, run: Run } | null} */
|
|
106
|
+
let word = null
|
|
107
|
+
const flush = () => {
|
|
108
|
+
if (word) out.push(word)
|
|
109
|
+
word = null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (let r = 0; r < runs.length; r++) {
|
|
113
|
+
const run = runs[r]
|
|
114
|
+
const text = String(run.text).replace(/\r\n|\r/g, '\n')
|
|
115
|
+
let start = 0
|
|
116
|
+
for (let n = 0; n <= text.length; n++) {
|
|
117
|
+
const char = n < text.length ? text.charAt(n) : ''
|
|
118
|
+
const isEdge = char === ' ' || char === '\n' || n === text.length
|
|
119
|
+
if (!isEdge) continue
|
|
120
|
+
|
|
121
|
+
if (n > start) {
|
|
122
|
+
const piece = pieceOf(ctx, run, text.slice(start, n), families)
|
|
123
|
+
word = word || { pieces: [], width: 0, isSpace: false, isBreak: false, run }
|
|
124
|
+
word.pieces.push(piece)
|
|
125
|
+
word.width += piece.width
|
|
126
|
+
}
|
|
127
|
+
start = n + 1
|
|
128
|
+
// a word ends at a space or a break; at the end of a run it may go on
|
|
129
|
+
if (char === ' ') {
|
|
130
|
+
flush()
|
|
131
|
+
out.push({ pieces: [ pieceOf(ctx, run, ' ', families) ], width: 0, isSpace: true, isBreak: false, run })
|
|
132
|
+
} else if (char === '\n') {
|
|
133
|
+
flush()
|
|
134
|
+
out.push({ pieces: [], width: 0, isSpace: false, isBreak: true, run })
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
flush()
|
|
139
|
+
return out
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @param {CanvasRenderingContext2D} ctx
|
|
144
|
+
* @param {Run} run
|
|
145
|
+
* @param {string} text
|
|
146
|
+
* @param {Record<string, string>} families
|
|
147
|
+
* @returns {Piece}
|
|
148
|
+
*/
|
|
149
|
+
function pieceOf(ctx, run, text, families) {
|
|
150
|
+
ctx.font = fontOf(run, families)
|
|
151
|
+
return { run, text, width: ctx.measureText(text).width + run.letterSpacing * text.length }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Puts a word on the current line, or the next when it doesn't fit, or
|
|
156
|
+
* across several when it is wider than a line of its own.
|
|
157
|
+
*
|
|
158
|
+
* @param {CanvasRenderingContext2D} ctx
|
|
159
|
+
* @param {Line[]} lines
|
|
160
|
+
* @param {{ pieces: Piece[], width: number, run: Run }} word
|
|
161
|
+
* @param {number} limit
|
|
162
|
+
* @param {Record<string, string>} families
|
|
163
|
+
*/
|
|
164
|
+
function placeWord(ctx, lines, word, limit, families) {
|
|
165
|
+
let line = lines[lines.length - 1]
|
|
166
|
+
if (line.pieces.length && line.width + word.width > limit) {
|
|
167
|
+
trim(line)
|
|
168
|
+
line = newLine(word.run)
|
|
169
|
+
lines.push(line)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const isTooWide = word.width > limit
|
|
173
|
+
if (!isTooWide) {
|
|
174
|
+
for (let n = 0; n < word.pieces.length; n++) addPiece(line, word.pieces[n])
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// a word wider than the line breaks between characters
|
|
179
|
+
for (let n = 0; n < word.pieces.length; n++) {
|
|
180
|
+
const piece = word.pieces[n]
|
|
181
|
+
const text = piece.text
|
|
182
|
+
for (let c = 0; c < text.length; c++) {
|
|
183
|
+
let chars = text.charAt(c)
|
|
184
|
+
// keep a surrogate pair together
|
|
185
|
+
const code = chars.charCodeAt(0)
|
|
186
|
+
if (code >= 0xd800 && code < 0xdc00 && c + 1 < text.length) chars += text.charAt(++c)
|
|
187
|
+
const one = pieceOf(ctx, piece.run, chars, families)
|
|
188
|
+
if (line.pieces.length && line.width + one.width > limit) {
|
|
189
|
+
trim(line)
|
|
190
|
+
line = newLine(piece.run)
|
|
191
|
+
lines.push(line)
|
|
192
|
+
}
|
|
193
|
+
addPiece(line, one)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Drops a line's trailing spaces, so they neither count towards its width
|
|
200
|
+
* nor push centred or right-aligned text off balance.
|
|
201
|
+
*
|
|
202
|
+
* @param {Line} line
|
|
203
|
+
*/
|
|
204
|
+
function trim(line) {
|
|
205
|
+
while (line.pieces.length && line.pieces[line.pieces.length - 1].text === ' ') {
|
|
206
|
+
line.width -= /** @type {Piece} */ (line.pieces.pop()).width
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where an animation goes in its canvas: Rive's fit and alignment, and which
|
|
3
|
+
* of a responsive artboard's layout bands the canvas is wide enough for.
|
|
4
|
+
*
|
|
5
|
+
* Fit is how the artboard scales into the canvas (`contain`, `cover`,
|
|
6
|
+
* `fill`, `fitWidth`, `fitHeight`, `scaleDown`, `none`, `layout`); alignment
|
|
7
|
+
* is where the rest of the canvas goes, from -1 (left, top) to 1 (right,
|
|
8
|
+
* bottom) on each axis. A fit that does not fill the canvas leaves the
|
|
9
|
+
* remainder to the alignment, and one that overflows it is cut by the canvas.
|
|
10
|
+
*
|
|
11
|
+
* `none` and `scaleDown` count in CSS pixels, so an animation keeps its size
|
|
12
|
+
* on a high-density screen. `layout` plays the layout band for the canvas's
|
|
13
|
+
* width, then fits it as `contain` does.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** @import { Matrix } from '../lottie/lottie.paths.js' */
|
|
17
|
+
|
|
18
|
+
/** Rive's alignment names, as [x, y] */
|
|
19
|
+
const ALIGNMENTS = {
|
|
20
|
+
topLeft: [ -1, -1 ],
|
|
21
|
+
topCenter: [ 0, -1 ],
|
|
22
|
+
topRight: [ 1, -1 ],
|
|
23
|
+
centerLeft: [ -1, 0 ],
|
|
24
|
+
center: [ 0, 0 ],
|
|
25
|
+
centerRight: [ 1, 0 ],
|
|
26
|
+
bottomLeft: [ -1, 1 ],
|
|
27
|
+
bottomCenter: [ 0, 1 ],
|
|
28
|
+
bottomRight: [ 1, 1 ],
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const FITS = [ 'contain', 'cover', 'fill', 'fitWidth', 'fitHeight', 'scaleDown', 'none', 'layout' ]
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {any} fit one of Rive's fits; anything else is `contain`
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function fitName(fit) {
|
|
38
|
+
return FITS.indexOf(fit) >= 0 ? fit : 'contain'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {any} alignment a Rive alignment name, or `[x, y]` from -1 to 1
|
|
43
|
+
* @returns {[number, number]} centre for anything unrecognised
|
|
44
|
+
*/
|
|
45
|
+
export function alignmentOf(alignment) {
|
|
46
|
+
if (typeof alignment === 'string' && ALIGNMENTS[/** @type {keyof typeof ALIGNMENTS} */ (alignment)]) return /** @type {[number, number]} */ (ALIGNMENTS[/** @type {keyof typeof ALIGNMENTS} */ (alignment)])
|
|
47
|
+
const isPair = alignment && alignment.length === 2 && typeof alignment[0] === 'number' && typeof alignment[1] === 'number'
|
|
48
|
+
return isPair ? [ clamp(alignment[0]), clamp(alignment[1]) ] : [ 0, 0 ]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @param {number} value */
|
|
52
|
+
function clamp(value) {
|
|
53
|
+
return Math.max(-1, Math.min(1, value))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The matrix taking the animation's space onto the canvas's.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} fit
|
|
60
|
+
* @param {[number, number]} alignment
|
|
61
|
+
* @param {number} width the animation's own width
|
|
62
|
+
* @param {number} height
|
|
63
|
+
* @param {number} canvasWidth in canvas pixels
|
|
64
|
+
* @param {number} canvasHeight
|
|
65
|
+
* @param {number} [ratio] canvas pixels per CSS pixel; default 1
|
|
66
|
+
* @returns {Matrix}
|
|
67
|
+
*/
|
|
68
|
+
export function fitMatrix(fit, alignment, width, height, canvasWidth, canvasHeight, ratio) {
|
|
69
|
+
const scaleX = canvasWidth / width
|
|
70
|
+
const scaleY = canvasHeight / height
|
|
71
|
+
const contain = Math.min(scaleX, scaleY)
|
|
72
|
+
const unit = ratio || 1
|
|
73
|
+
|
|
74
|
+
let sx = contain
|
|
75
|
+
let sy = contain
|
|
76
|
+
if (fit === 'cover') sx = sy = Math.max(scaleX, scaleY)
|
|
77
|
+
else if (fit === 'fill') {
|
|
78
|
+
sx = scaleX
|
|
79
|
+
sy = scaleY
|
|
80
|
+
} else if (fit === 'fitWidth') sx = sy = scaleX
|
|
81
|
+
else if (fit === 'fitHeight') sx = sy = scaleY
|
|
82
|
+
else if (fit === 'none') sx = sy = unit
|
|
83
|
+
else if (fit === 'scaleDown') sx = sy = Math.min(unit, contain)
|
|
84
|
+
|
|
85
|
+
return [ sx, 0, 0, sy, ((canvasWidth - width * sx) / 2) * (1 + alignment[0]), ((canvasHeight - height * sy) / 2) * (1 + alignment[1]) ]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The layout band a canvas is wide enough for: the widest that fits, else the
|
|
90
|
+
* narrowest, which then scales down.
|
|
91
|
+
*
|
|
92
|
+
* @param {any[] | undefined} bands a bundle's `bands`, narrowest first
|
|
93
|
+
* @param {number} width the canvas's width in CSS pixels
|
|
94
|
+
* @returns {any | null} null without bands
|
|
95
|
+
*/
|
|
96
|
+
export function pickBand(bands, width) {
|
|
97
|
+
if (!bands || !bands.length) return null
|
|
98
|
+
let picked = bands[0]
|
|
99
|
+
for (let n = 1; n < bands.length; n++) if (bands[n].width <= width) picked = bands[n]
|
|
100
|
+
return picked
|
|
101
|
+
}
|