@px-petals/roku.motion-web 0.0.0-stage → 1.0.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.
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The canvas as a focus target, for `keys: 'auto'`: it can be given DOM focus
3
+ * (it gets a `tabIndex`), and while it has it, its keys go to Rive's focus.
4
+ *
5
+ * Motion never takes DOM focus: nothing here calls `focus()`. The user or the
6
+ * host gives it (Tab, a click on the canvas, `canvas.focus()`), and this
7
+ * reacts, as `@rive-app/canvas` does:
8
+ * - DOM focus arrives by keyboard: focus enters the tree at once, at the first
9
+ * stop for a Tab from before the canvas, the last for a Shift+Tab from after.
10
+ * Arriving by pointer leaves the tree unfocused until the next Tab (or a
11
+ * listener's focus action), so a click does not pick a stop.
12
+ * - DOM focus leaves (the user moved on): Rive's focus is cleared, so Blur
13
+ * listeners run. Not when the window itself lost focus.
14
+ * - Tab from the last stop (or Shift+Tab from the first) is left to the
15
+ * browser, which moves DOM focus on; the keys that follow are ignored until
16
+ * focus is given again.
17
+ * - `preventDefault` only for a key Rive used: one a listener took, a Tab or
18
+ * arrow that moved focus or is held by a `stop` edge.
19
+ */
20
+
21
+ import { keyFromEvent, keyNames, modifiersFromEvent } from '../motion/motion.keys.js'
22
+ import { PHASES } from './focus.js'
23
+
24
+ /** A focus this soon after a mouse or touch press is the pointer's */
25
+ const POINTER_FOCUS_MS = 600
26
+
27
+ /**
28
+ * @typedef {object} DomOptions
29
+ * @property {ReturnType<typeof import('./focus.js').createFocus>} focus
30
+ * @property {(down: boolean) => void} [pressed] a key went through (for the host's `key` event)
31
+ */
32
+
33
+ /**
34
+ * @param {HTMLCanvasElement} canvas
35
+ * @param {DomOptions} options
36
+ */
37
+ export function createDom(canvas, options) {
38
+ const focus = options.focus
39
+ let isBound = false
40
+ let hasDomFocus = typeof document !== 'undefined' && document.activeElement === canvas
41
+ /** Tab left the tree: the page has the keys until focus comes back */
42
+ let released = false
43
+ let lastPointer = 0
44
+ /** @type {{ target: any, name: string, listener: (event: any) => void }[]} */
45
+ const bound = []
46
+
47
+ const onPointer = () => {
48
+ lastPointer = new Date().getTime()
49
+ }
50
+
51
+ /**
52
+ * Whether the focus that just arrived came from the keyboard. `:focus-visible`
53
+ * is the browser's own answer; where it is missing, a recent press says it
54
+ * was the pointer.
55
+ */
56
+ function isKeyboardFocus() {
57
+ try {
58
+ return canvas.matches(':focus-visible')
59
+ } catch {
60
+ return new Date().getTime() - lastPointer > POINTER_FOCUS_MS
61
+ }
62
+ }
63
+
64
+ /**
65
+ * @param {any} from where focus came from
66
+ * @returns {boolean} whether that is before the canvas in the page
67
+ */
68
+ function isFromBefore(from) {
69
+ if (!from || !from.compareDocumentPosition) return true
70
+ const position = canvas.compareDocumentPosition(from)
71
+ if (position & 2) return true
72
+ if (position & 4) return false
73
+ return true
74
+ }
75
+
76
+ /** @param {any} event */
77
+ function onFocus(event) {
78
+ hasDomFocus = true
79
+ released = false
80
+ if (focus.current !== null || !focus.hasNodes) return
81
+ if (isKeyboardFocus()) focus.move(isFromBefore(event.relatedTarget) ? 'next' : 'previous')
82
+ }
83
+
84
+ /** @param {any} event */
85
+ function onBlur(event) {
86
+ hasDomFocus = false
87
+ // the window itself lost focus (another tab, another app): the canvas keeps Rive's focus for when it returns
88
+ if (!event.relatedTarget && typeof document.hasFocus === 'function' && !document.hasFocus()) return
89
+ focus.clear()
90
+ }
91
+
92
+ /**
93
+ * @param {any} event
94
+ * @param {number} phase
95
+ * @returns {boolean} whether Rive used it
96
+ */
97
+ function handle(event, phase) {
98
+ if (event.isComposing || event.keyCode === 229) return false
99
+ if (released) return false
100
+ const name = keyFromEvent(event)
101
+ if (!name) return false
102
+ const names = keyNames(name)
103
+ if (name === 'tab' && !focus.hasNodes) return false
104
+ return focus.press({ names, mods: modifiersFromEvent(event), phase })
105
+ }
106
+
107
+ /** @param {any} event */
108
+ function onKeyDown(event) {
109
+ if (!hasDomFocus && event.target !== canvas) return
110
+ const used = handle(event, event.repeat ? PHASES.repeat : PHASES.down)
111
+ if (used) {
112
+ if (event.preventDefault) event.preventDefault()
113
+ return
114
+ }
115
+ // Tab (or Shift+Tab) off the end: the page moves focus on, and Rive's keys wait until it is given back
116
+ if (focus.hasNodes && keyFromEvent(event) === 'tab') released = true
117
+ }
118
+
119
+ /** @param {any} event */
120
+ function onKeyUp(event) {
121
+ if (!hasDomFocus && event.target !== canvas) return
122
+ handle(event, PHASES.up)
123
+ }
124
+
125
+ /** @type {Record<string, (event: any) => void>} */
126
+ const handlers = { focus: onFocus, blur: onBlur, keydown: onKeyDown, keyup: onKeyUp, mousedown: onPointer, touchstart: onPointer, pointerdown: onPointer }
127
+
128
+ /**
129
+ * Makes the canvas able to be given focus, and starts listening, or stops.
130
+ *
131
+ * @param {boolean} wanted whether the bundle has keys or a focus tree to answer
132
+ */
133
+ function listen(wanted) {
134
+ if (wanted === isBound) return
135
+ isBound = wanted
136
+ if (wanted) {
137
+ // a tabIndex lets the canvas be given focus; it never takes it
138
+ if (canvas.tabIndex < 0 || !canvas.hasAttribute('tabindex')) canvas.tabIndex = 0
139
+ for (const name in handlers) {
140
+ bound.push({ target: canvas, name, listener: handlers[name] })
141
+ canvas.addEventListener(name, handlers[name], false)
142
+ }
143
+ hasDomFocus = document.activeElement === canvas
144
+ } else {
145
+ for (let n = 0; n < bound.length; n++) bound[n].target.removeEventListener(bound[n].name, bound[n].listener, false)
146
+ bound.length = 0
147
+ }
148
+ }
149
+
150
+ return {
151
+ listen,
152
+ destroy: () => listen(false),
153
+ /** Whether the canvas has DOM focus */
154
+ get hasFocus() {
155
+ return hasDomFocus
156
+ },
157
+ }
158
+ }
package/focus/focus.js ADDED
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Rive's focus, in a bundle: which node of its focus tree holds focus, how
3
+ * focus moves (Tab, the arrows, focus actions), the Focus and Blur listeners
4
+ * that fire as it does, and the keyboard listeners that hear a key through the
5
+ * focused node and the nodes above it.
6
+ *
7
+ * This is Rive's own focus, not the browser's: it says which target of the
8
+ * graphic the keys belong to. It never touches DOM focus. Whether keys reach
9
+ * it at all is `focus.dom.js` (a canvas that has DOM focus) or the host
10
+ * (`motion.key`). See the web readme, "Focus and keys".
11
+ *
12
+ * Rive's rules, as read from `@rive-app/canvas` and checked by the parity
13
+ * fixtures:
14
+ * - A key goes to the focused node's listeners first. If any matched, it is
15
+ * spent. If none did, it goes to the node above, and so on up the tree.
16
+ * - Focus and Blur listeners fire for every node whose subtree gains or loses
17
+ * the focused node: moving from a node to its child fires nothing on the
18
+ * node, leaving the node's subtree fires its Blur.
19
+ * - Tab on a key a listener took does not move focus. Tab from the last stop
20
+ * leaves the tree (focus is cleared). An arrow no listener took moves
21
+ * focus in that direction.
22
+ */
23
+
24
+ import { pickInDirection } from './focus.direction.js'
25
+ import { createTree } from './focus.tree.js'
26
+
27
+ /** Key modifier bits, as a KeyboardInput's `modifiers` has them */
28
+ export const MODIFIERS = { shift: 1, ctrl: 2, alt: 4, meta: 8 }
29
+
30
+ /** The phases of a keystroke, as a KeyboardInput's `keyPhase` has them */
31
+ export const PHASES = { down: 1, repeat: 2, up: 4 }
32
+
33
+ const ARROWS = { up: 'up', down: 'down', left: 'left', right: 'right' }
34
+
35
+ /**
36
+ * @typedef {object} FocusOptions
37
+ * @property {(type: 'Focus' | 'Blur', rid: string) => void} fire a node gained or lost focus: its listeners run
38
+ * @property {(actions: any[], scope: string) => void} run runs a key binding's actions, in the machine its `scope` names ("" for the bundle's own; a nested artboard's binding has the machine's id)
39
+ * @property {(rid: string) => ({ x: number, y: number } | null)} centerOf where a node was drawn (canvas pixels); null while it has no geometry
40
+ * @property {(rid: string | null) => void} changed the focused node changed
41
+ */
42
+
43
+ /**
44
+ * @param {FocusOptions} options
45
+ */
46
+ export function createFocus(options) {
47
+ let tree = createTree([])
48
+ /** @type {any[]} the machine's key bindings (`remoteKeys`) */
49
+ let bindings = []
50
+ /** @type {string | null} */
51
+ let current = null
52
+
53
+ /**
54
+ * @param {any[]} nodes the machine's `focus.nodes`
55
+ * @param {any[]} keys the bundle's `remoteKeys`
56
+ */
57
+ function setup(nodes, keys) {
58
+ tree = createTree(nodes || [])
59
+ bindings = keys || []
60
+ current = null
61
+ }
62
+
63
+ /**
64
+ * Swaps in a new tree and key bindings while the page runs, as a list's rows
65
+ * come and go. Focus stays where it is. On a node that is gone it stays too,
66
+ * as in `@rive-app/canvas`: nothing hears a key, and the next Tab, which has
67
+ * no place to go on from, leaves the tree and clears it.
68
+ *
69
+ * @param {any[]} nodes
70
+ * @param {any[]} keys
71
+ */
72
+ function retree(nodes, keys) {
73
+ tree = createTree(nodes || [])
74
+ bindings = keys || []
75
+ }
76
+
77
+ /**
78
+ * Moves focus to a node (or nowhere), firing the listeners of every node
79
+ * whose subtree lost or gained it.
80
+ *
81
+ * @param {string | null} next
82
+ */
83
+ function set(next) {
84
+ if (next === current) return
85
+ const before = tree.chain(current)
86
+ const after = tree.chain(next)
87
+ current = next
88
+ for (let n = 0; n < before.length; n++) if (after.indexOf(before[n]) < 0) options.fire('Blur', before[n])
89
+ for (let n = after.length - 1; n >= 0; n--) if (before.indexOf(after[n]) < 0) options.fire('Focus', after[n])
90
+ options.changed(current)
91
+ }
92
+
93
+ /**
94
+ * A focus node by its `rid` or its name.
95
+ *
96
+ * @param {string} target
97
+ * @returns {string | null}
98
+ */
99
+ function resolve(target) {
100
+ if (tree.has(target)) return target
101
+ for (let n = 0; n < tree.nodes.length; n++) if (tree.nodes[n].name === target) return tree.nodes[n].rid
102
+ return null
103
+ }
104
+
105
+ /**
106
+ * @param {string} target a node's `rid` or name
107
+ * @returns {boolean} whether it took focus (a node that cannot focus does not)
108
+ */
109
+ function focus(target) {
110
+ const rid = resolve(target)
111
+ if (rid === null || !tree.canFocus(rid)) return false
112
+ set(rid)
113
+ return true
114
+ }
115
+
116
+ /**
117
+ * Tab (`next`), Shift+Tab (`previous`), or a direction.
118
+ *
119
+ * @param {string} kind next, previous, up, down, left or right
120
+ * @returns {boolean} whether focus moved
121
+ */
122
+ function move(kind) {
123
+ if (kind === 'next' || kind === 'previous') {
124
+ const step = tree.step(current, kind === 'next')
125
+ if (step.stays) return false
126
+ set(step.to)
127
+ return step.to !== null
128
+ }
129
+ if (current === null || !ARROWS[/** @type {keyof typeof ARROWS} */ (kind)]) return false
130
+ const from = options.centerOf(current)
131
+ if (!from) return false
132
+ const to = pickInDirection(kind, from, tree.stops, options.centerOf)
133
+ if (to === null) return false
134
+ set(to)
135
+ return true
136
+ }
137
+
138
+ function clear() {
139
+ set(null)
140
+ }
141
+
142
+ /**
143
+ * The key bindings a key sets off on one node.
144
+ *
145
+ * @param {string} rid
146
+ * @param {string[]} names the Rive names the key has
147
+ * @param {number} phase
148
+ * @param {number} mods
149
+ * @returns {any[]}
150
+ */
151
+ function bound(rid, names, phase, mods) {
152
+ const found = []
153
+ for (let n = 0; n < bindings.length; n++) {
154
+ const binding = bindings[n]
155
+ if (binding.target !== rid) continue
156
+ if (((binding.phase === undefined ? PHASES.down : binding.phase) & phase) === 0) continue
157
+ if ((binding.modifiers || 0) !== mods) continue
158
+ if (matches(binding.keys, names)) found.push(binding)
159
+ }
160
+ return found
161
+ }
162
+
163
+ /**
164
+ * Offers a key to the focused node's listeners, then the nodes above it; the
165
+ * first node with a listener for it keeps it.
166
+ *
167
+ * @param {string[]} names
168
+ * @param {number} phase
169
+ * @param {number} mods
170
+ * @returns {boolean} whether a listener took it
171
+ */
172
+ function route(names, phase, mods) {
173
+ if (!tree.hasNodes) return runUnscoped(names, phase)
174
+ const chain = tree.chain(current)
175
+ for (let n = 0; n < chain.length; n++) {
176
+ const found = bound(chain[n], names, phase, mods)
177
+ if (!found.length) continue
178
+ for (let k = 0; k < found.length; k++) options.run(found[k].actions, found[k].scope || '')
179
+ return true
180
+ }
181
+ return false
182
+ }
183
+
184
+ /**
185
+ * A bundle with nothing focusable has no focus to route by: its listeners
186
+ * hear a key press wherever it is sent, as they did before focus.
187
+ *
188
+ * @param {string[]} names
189
+ * @param {number} phase
190
+ * @returns {boolean}
191
+ */
192
+ function runUnscoped(names, phase) {
193
+ if (phase !== PHASES.down) return false
194
+ let took = false
195
+ for (let n = 0; n < bindings.length; n++) {
196
+ if (bindings[n].phase !== undefined && bindings[n].phase !== PHASES.down) continue
197
+ if (!matches(bindings[n].keys, names)) continue
198
+ options.run(bindings[n].actions, '')
199
+ took = true
200
+ }
201
+ return took
202
+ }
203
+
204
+ /**
205
+ * A keystroke, as Rive's canvas runtime handles it.
206
+ *
207
+ * @param {object} key
208
+ * @param {string[]} key.names the Rive names the key has
209
+ * @param {number} key.mods MODIFIERS bits held
210
+ * @param {number} key.phase PHASES: down, repeat or up
211
+ * @returns {boolean} whether it was used (a listener took it, or focus moved or is held); false lets the page or the host have it
212
+ */
213
+ function press({ names, mods, phase }) {
214
+ if (phase === PHASES.up) {
215
+ route(names, phase, mods)
216
+ return false
217
+ }
218
+ const isTab = names.indexOf('tab') >= 0
219
+ if (isTab && tree.hasNodes) {
220
+ if (current !== null && route(names, phase, mods)) return true
221
+ move(mods & MODIFIERS.shift ? 'previous' : 'next')
222
+ // focus walked off the end (cleared) is a Tab for the page
223
+ return current !== null
224
+ }
225
+
226
+ let used = route(names, phase, mods)
227
+ if (!used && tree.hasNodes && !(mods & (MODIFIERS.ctrl | MODIFIERS.alt | MODIFIERS.meta))) {
228
+ for (let n = 0; n < names.length && !used; n++) {
229
+ if (ARROWS[/** @type {keyof typeof ARROWS} */ (names[n])]) used = move(names[n]) || current !== null
230
+ }
231
+ }
232
+ return used
233
+ }
234
+
235
+ return {
236
+ setup,
237
+ retree,
238
+ focus,
239
+ move,
240
+ clear,
241
+ press,
242
+ resolve,
243
+ /** The node that has focus (its `rid`), or null */
244
+ get current() {
245
+ return current
246
+ },
247
+ get hasNodes() {
248
+ return tree.hasNodes
249
+ },
250
+ /** @param {string} rid */
251
+ node: (rid) => tree.node(rid),
252
+ get nodes() {
253
+ return tree.nodes
254
+ },
255
+ get stops() {
256
+ return tree.stops
257
+ },
258
+ /** Whether any key binding could hear this key, wherever focus is: the keys a bundle uses */
259
+ /** @param {string[]} names */
260
+ uses(names) {
261
+ for (let n = 0; n < bindings.length; n++) if (matches(bindings[n].keys, names)) return true
262
+ return false
263
+ },
264
+ }
265
+ }
266
+
267
+ /**
268
+ * @param {string[]} keys a binding's key names; "any" matches every key
269
+ * @param {string[]} names the key's names
270
+ * @returns {boolean}
271
+ */
272
+ function matches(keys, names) {
273
+ for (let n = 0; n < (keys || []).length; n++) {
274
+ if (keys[n] === 'any' || names.indexOf(keys[n]) >= 0) return true
275
+ }
276
+ return false
277
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Rive's focus tree as a bundle's `machine.focus.nodes` lists it: the nodes
3
+ * that own a FocusData, in the order Tab visits them (a parent before its
4
+ * children), each with the focus node it sits inside.
5
+ *
6
+ * - A node is a Tab stop when `canFocus` and `canTraverse` are on (they are,
7
+ * unless the node says `false`); a node that is not is skipped and traversal
8
+ * goes on into its children.
9
+ * - `edge` says what traversal does when it would leave that node's subtree:
10
+ * `closedLoop` wraps within it (the node itself is part of the loop if it is
11
+ * a stop) and `stop` stays on the last stop. The artboard (`rid` "") is the
12
+ * outermost scope; by default leaving it ends focus.
13
+ */
14
+
15
+ /**
16
+ * @typedef {object} FocusNode
17
+ * @property {string} rid
18
+ * @property {string} [name]
19
+ * @property {string} [parent]
20
+ * @property {boolean} [canFocus]
21
+ * @property {boolean} [canTouch]
22
+ * @property {boolean} [canTraverse]
23
+ * @property {string} [edge]
24
+ */
25
+
26
+ /**
27
+ * @param {FocusNode[]} list a machine's `focus.nodes`
28
+ */
29
+ export function createTree(list) {
30
+ /** @type {Record<string, FocusNode>} */
31
+ const byRid = {}
32
+ /** @type {Record<string, number>} */
33
+ const position = {}
34
+ /** @type {string} the artboard's own edge behaviour */
35
+ let rootEdge = ''
36
+ for (let n = 0; n < list.length; n++) {
37
+ byRid[list[n].rid] = list[n]
38
+ position[list[n].rid] = n
39
+ if (list[n].rid === '') rootEdge = list[n].edge || ''
40
+ }
41
+
42
+ /** @param {string} rid */
43
+ const isStop = (rid) => {
44
+ const node = byRid[rid]
45
+ return !!node && node.canFocus !== false && node.canTraverse !== false
46
+ }
47
+
48
+ /** The Tab stops, in order @type {string[]} */
49
+ const stops = []
50
+ for (let n = 0; n < list.length; n++) if (list[n].rid !== '' && isStop(list[n].rid)) stops.push(list[n].rid)
51
+
52
+ /**
53
+ * Whether `rid` is `scope` or sits inside it.
54
+ *
55
+ * @param {string} rid
56
+ * @param {string} scope
57
+ * @returns {boolean}
58
+ */
59
+ function isInside(rid, scope) {
60
+ if (scope === '') return true
61
+ for (let at = /** @type {string | undefined} */ (rid); at !== undefined && at !== ''; at = byRid[at] ? byRid[at].parent : undefined) if (at === scope) return true
62
+ return false
63
+ }
64
+
65
+ /**
66
+ * The nearest scope around a node that has an edge behaviour: itself, or the
67
+ * node above it, up to the artboard.
68
+ *
69
+ * @param {string} rid
70
+ * @returns {string}
71
+ */
72
+ function scopeOf(rid) {
73
+ for (let at = /** @type {string | undefined} */ (rid); at !== undefined && at !== ''; at = byRid[at] ? byRid[at].parent : undefined) {
74
+ if (byRid[at] && (byRid[at].edge === 'closedLoop' || byRid[at].edge === 'stop')) return at
75
+ }
76
+ return ''
77
+ }
78
+
79
+ /** @param {string} scope */
80
+ const edgeOf = (scope) => (scope === '' ? rootEdge : byRid[scope].edge)
81
+
82
+ return {
83
+ /** Whether the artboard has anything to focus */
84
+ get hasNodes() {
85
+ return list.length > 0
86
+ },
87
+ nodes: list,
88
+ stops,
89
+ /** @param {string} rid */
90
+ has: (rid) => !!byRid[rid],
91
+ /** @param {string} rid */
92
+ node: (rid) => byRid[rid] || null,
93
+ /**
94
+ * The focus nodes from a node up to the outermost, itself first.
95
+ *
96
+ * @param {string | null} rid
97
+ * @returns {string[]}
98
+ */
99
+ chain(rid) {
100
+ /** @type {string[]} */
101
+ const found = []
102
+ for (let at = rid; at !== null && at !== undefined && at !== '' && byRid[at]; at = byRid[at].parent || '') found.push(at)
103
+ if (rid !== null && byRid[''] ) found.push('')
104
+ return found
105
+ },
106
+ /** @param {string} rid */
107
+ canFocus: (rid) => !!byRid[rid] && byRid[rid].canFocus !== false,
108
+ /**
109
+ * Tab, or Shift+Tab, from a node (or from nothing).
110
+ *
111
+ * @param {string | null} from
112
+ * @param {boolean} forward
113
+ * @returns {{ to: string | null, stays: boolean }} `to` the stop it lands on; null with `stays` false when it walked off the end, and `stays` true when a `stop` edge holds it where it is
114
+ */
115
+ step(from, forward) {
116
+ if (!stops.length) return { to: null, stays: false }
117
+ if (from === null) return { to: forward ? stops[0] : stops[stops.length - 1], stays: false }
118
+
119
+ // the nearest stop after (or before) the node in the tree's order
120
+ const at = position[from]
121
+ /** @type {string | null} */
122
+ let candidate = null
123
+ if (forward) {
124
+ for (let n = 0; n < stops.length; n++) if (position[stops[n]] > at) { candidate = stops[n]; break }
125
+ } else {
126
+ for (let n = stops.length - 1; n >= 0; n--) if (position[stops[n]] < at) { candidate = stops[n]; break }
127
+ }
128
+
129
+ const scope = scopeOf(from)
130
+ const leaves = candidate === null || !isInside(candidate, scope)
131
+ if (!leaves) return { to: candidate, stays: false }
132
+ const edge = edgeOf(scope)
133
+ if (edge === 'stop') return { to: from, stays: true }
134
+ if (edge !== 'closedLoop') return { to: null, stays: false }
135
+
136
+ // wrap within the scope: its first (or last) stop
137
+ /** @type {string | null} */
138
+ let wrapped = null
139
+ for (let n = 0; n < stops.length; n++) {
140
+ const stop = stops[forward ? n : stops.length - 1 - n]
141
+ if (isInside(stop, scope)) { wrapped = stop; break }
142
+ }
143
+ return { to: wrapped, stays: false }
144
+ },
145
+ }
146
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Point-in-shape maths for hit tests, on the traced paths the renderer draws.
3
+ *
4
+ * `isPointInPath` would need the context's current path and transform, and
5
+ * re-tracing every target on each pointer move; here the traced paths are
6
+ * kept as they were drawn, and flattened to polygons only when a point is
7
+ * tested. A fill is nonzero-wound, as Lottie and Rive fill by default.
8
+ */
9
+
10
+ import { apply } from '../lottie/lottie.paths.js'
11
+
12
+ /** @import { Matrix, Path, Point } from '../lottie/lottie.paths.js' */
13
+
14
+ /** Line segments a curve is flattened to; a 16th of a quarter circle is well inside a pixel */
15
+ const CURVE_STEPS = 16
16
+
17
+ /**
18
+ * A path as a polygon in the space `matrix` maps it to. Curves are flattened,
19
+ * and an open path is closed, as a fill closes it.
20
+ *
21
+ * @param {Path} path
22
+ * @param {Matrix} matrix
23
+ * @returns {Point[]}
24
+ */
25
+ export function outline(path, matrix) {
26
+ const count = path.points.length
27
+ if (!count) return []
28
+
29
+ const points = path.points.map((point) => apply(matrix, point.x, point.y))
30
+ const cIn = path.cIn.map((point) => apply(matrix, point.x, point.y))
31
+ const cOut = path.cOut.map((point) => apply(matrix, point.x, point.y))
32
+
33
+ const polygon = [ points[0] ]
34
+ // the segment closing the path counts when the path is closed or has a curve back to its start
35
+ for (let k = 1; k <= count; k++) {
36
+ const isClosing = k === count
37
+ if (isClosing && count < 2) break
38
+ const from = k - 1
39
+ const to = isClosing ? 0 : k
40
+ // an open path is closed by a straight line, whatever its end tangents
41
+ const isCurved = !isClosing || path.closed
42
+ appendSegment(polygon, points[from], isCurved ? cOut[from] : points[from], isCurved ? cIn[to] : points[to], points[to], isClosing)
43
+ }
44
+ return polygon
45
+ }
46
+
47
+ /**
48
+ * @param {Point[]} polygon
49
+ * @param {Point} a start
50
+ * @param {Point} b first control
51
+ * @param {Point} c second control
52
+ * @param {Point} d end
53
+ * @param {boolean} isLast the segment back to the polygon's first point, which is not added again
54
+ */
55
+ function appendSegment(polygon, a, b, c, d, isLast) {
56
+ const isStraight = same(a, b) && same(c, d)
57
+ if (!isStraight) {
58
+ for (let step = 1; step < CURVE_STEPS; step++) {
59
+ const t = step / CURVE_STEPS
60
+ const u = 1 - t
61
+ polygon.push({
62
+ x: u * u * u * a.x + 3 * u * u * t * b.x + 3 * u * t * t * c.x + t * t * t * d.x,
63
+ y: u * u * u * a.y + 3 * u * u * t * b.y + 3 * u * t * t * c.y + t * t * t * d.y,
64
+ })
65
+ }
66
+ }
67
+ if (!isLast) polygon.push(d)
68
+ }
69
+
70
+ /**
71
+ * @param {Point} a
72
+ * @param {Point} b
73
+ * @returns {boolean}
74
+ */
75
+ function same(a, b) {
76
+ return Math.abs(a.x - b.x) < 1e-9 && Math.abs(a.y - b.y) < 1e-9
77
+ }
78
+
79
+ /**
80
+ * Whether a point is inside a polygon by the nonzero rule: the winding
81
+ * number around it is not zero.
82
+ *
83
+ * @param {Point[]} polygon
84
+ * @param {number} x
85
+ * @param {number} y
86
+ * @returns {boolean}
87
+ */
88
+ export function contains(polygon, x, y) {
89
+ let winding = 0
90
+ for (let k = 0, j = polygon.length - 1; k < polygon.length; j = k++) {
91
+ const a = polygon[j]
92
+ const b = polygon[k]
93
+ if (a.y <= y) {
94
+ if (b.y > y && side(a, b, x, y) > 0) winding++
95
+ } else if (b.y <= y && side(a, b, x, y) < 0) {
96
+ winding--
97
+ }
98
+ }
99
+ return winding !== 0
100
+ }
101
+
102
+ /**
103
+ * Which side of the line a→b the point is on: positive to its left.
104
+ *
105
+ * @param {Point} a
106
+ * @param {Point} b
107
+ * @param {number} x
108
+ * @param {number} y
109
+ * @returns {number}
110
+ */
111
+ function side(a, b, x, y) {
112
+ return (b.x - a.x) * (y - a.y) - (x - a.x) * (b.y - a.y)
113
+ }