@lightningjs/blits 1.49.0 → 1.51.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.
@@ -18,7 +18,7 @@
18
18
  import Component from '../component.js'
19
19
  import Router, { state as routerState } from '../router/router.js'
20
20
  import symbols from '../lib/symbols.js'
21
- import Focus from '../focus.js'
21
+ import Focus from '../focus/focus.js'
22
22
 
23
23
  let hashchangeHandler = null
24
24
 
@@ -85,8 +85,7 @@ export default () =>
85
85
  ],
86
86
  context: {},
87
87
  },
88
- props: ['image', 'map', 'frame', 'color', 'effects'],
89
-
88
+ props: ['image', 'map', 'frame', 'color', 'effects', '@loaded', '@error'],
90
89
  state() {
91
90
  return {
92
91
  spriteTexture: null,
@@ -94,6 +93,33 @@ export default () =>
94
93
  }
95
94
  },
96
95
 
96
+ hooks: {
97
+ ready() {
98
+ const loaded = this['@loaded']
99
+ if (loaded && typeof loaded === 'function') {
100
+ const cb = (payload) => loaded({ w: payload?.w, h: payload?.h }, this[symbols.wrapper])
101
+ this._loadedCb = cb
102
+ if (this.spriteTexture !== undefined && this.spriteTexture !== null) {
103
+ this.spriteTexture.on('loaded', cb)
104
+ }
105
+ }
106
+ const error = this['@error']
107
+ if (error && typeof error === 'function') {
108
+ const cb = (payload) => error(payload, this[symbols.wrapper])
109
+ this._failedCb = cb
110
+ if (this.spriteTexture !== undefined && this.spriteTexture !== null) {
111
+ this.spriteTexture.on('failed', cb)
112
+ }
113
+ }
114
+ },
115
+ destroy() {
116
+ if (this.spriteTexture !== undefined && this.spriteTexture !== null) {
117
+ if (this._loadedCb) this.spriteTexture.off('loaded', this._loadedCb)
118
+ if (this._failedCb) this.spriteTexture.off('failed', this._failedCb)
119
+ }
120
+ },
121
+ },
122
+
97
123
  computed: {
98
124
  texture() {
99
125
  // If no image - nothing to render
@@ -113,10 +139,17 @@ export default () =>
113
139
  this.spriteTexture === null ||
114
140
  this.currentSrc !== this.image
115
141
  ) {
142
+ const prevTexture = this.spriteTexture
143
+ if (prevTexture !== undefined && prevTexture !== null) {
144
+ if (this._loadedCb) prevTexture.off('loaded', this._loadedCb)
145
+ if (this._failedCb) prevTexture.off('failed', this._failedCb)
146
+ }
116
147
  this.spriteTexture = renderer.createTexture('ImageTexture', {
117
148
  src: this.image,
118
149
  })
119
150
  this.currentSrc = this.image
151
+ if (this._loadedCb) this.spriteTexture.on('loaded', this._loadedCb)
152
+ if (this._failedCb) this.spriteTexture.on('failed', this._failedCb)
120
153
  }
121
154
 
122
155
  // Resolve frame data from sprite map
@@ -18,11 +18,9 @@
18
18
  import Circle from './Circle.js'
19
19
  import RouterView from './RouterView.js'
20
20
  import Sprite from './Sprite.js'
21
- import FPScounter from './FPScounter.js'
22
21
 
23
22
  export default () => ({
24
23
  Circle: Circle(),
25
24
  RouterView: RouterView(),
26
25
  Sprite: Sprite(),
27
- FPScounter: FPScounter(),
28
26
  })
@@ -22,6 +22,8 @@ import { Log } from '../../lib/log.js'
22
22
  import symbols from '../../lib/symbols.js'
23
23
  import Settings from '../../settings.js'
24
24
 
25
+ const holderComponentMap = new WeakMap()
26
+
25
27
  /**
26
28
  * Creates a padding object from a value and direction.
27
29
  * @param {number|object|string|undefined} padding - The padding value.
@@ -512,6 +514,9 @@ const propsTransformer = {
512
514
  this.props['data'] = v
513
515
  }
514
516
  },
517
+ set holder(v) {
518
+ this.props['interactive'] = v
519
+ },
515
520
  }
516
521
 
517
522
  const Element = {
@@ -555,6 +560,10 @@ const Element = {
555
560
  ? renderer.createTextNode({ ...textDefaults, ...this.props.props })
556
561
  : renderer.createNode(this.props.props)
557
562
 
563
+ if (this.props['holder'] === true) {
564
+ holderComponentMap.set(this.node, this.component)
565
+ }
566
+
558
567
  if (props['@loaded'] !== undefined && typeof props['@loaded'] === 'function') {
559
568
  this.node.on('loaded', (el, { type, dimensions }) => {
560
569
  props['@loaded']({ w: dimensions.width, h: dimensions.height, type }, this)
@@ -118,7 +118,9 @@ export default (App, target, settings = {}) => {
118
118
 
119
119
  shaderLoader()
120
120
  fontLoader()
121
- initApp()
121
+ // Defer app initialization until after the current call stack is cleared,
122
+ // allowing the renderer to finish setting up before the app starts creating components
123
+ Promise.resolve().then(initApp)
122
124
 
123
125
  return renderer
124
126
  }
@@ -15,11 +15,12 @@
15
15
  * SPDX-License-Identifier: Apache-2.0
16
16
  */
17
17
 
18
- import symbols from './lib/symbols.js'
19
- import { state } from './router/router.js'
20
- import Settings from './settings.js'
21
- import { DEFAULT_HOLD_TIMEOUT_MS } from './constants.js'
22
- import { Log } from './lib/log.js'
18
+ import symbols from '../lib/symbols.js'
19
+ import { state } from '../router/router.js'
20
+ import Settings from '../settings.js'
21
+ import { DEFAULT_HOLD_TIMEOUT_MS } from '../constants.js'
22
+ import { Log } from '../lib/log.js'
23
+ import { getAncestors } from './helpers.js'
23
24
 
24
25
  let focusedComponent = null
25
26
  let focusChain = []
@@ -99,19 +100,6 @@ export default {
99
100
  },
100
101
  }
101
102
 
102
- /**
103
- * Recursive function that retrieves the ancestors of a component
104
- * @param {Array} components
105
- * @returns array components
106
- */
107
- const getAncestors = (components) => {
108
- if (components[0].parent !== undefined) {
109
- components.unshift(components[0].parent)
110
- return getAncestors(components)
111
- }
112
- return components
113
- }
114
-
115
103
  /**
116
104
  * Get the component in the ancestors chain that has a handler for a certain key code
117
105
  * @param {Object} component
@@ -0,0 +1,29 @@
1
+ /*
2
+ * Copyright 2025 Comcast Cable Communications Management, LLC
3
+ * Licensed under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License.
5
+ * You may obtain a copy of the License at
6
+ *
7
+ * http://www.apache.org/licenses/LICENSE-2.0
8
+ *
9
+ * Unless required by applicable law or agreed to in writing, software
10
+ * distributed under the License is distributed on an "AS IS" BASIS,
11
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ * See the License for the specific language governing permissions and
13
+ * limitations under the License.
14
+ *
15
+ * SPDX-License-Identifier: Apache-2.0
16
+ */
17
+
18
+ /**
19
+ * Recursive function that retrieves the ancestors of a component
20
+ * @param {Array} components
21
+ * @returns array components
22
+ */
23
+ export const getAncestors = (components) => {
24
+ if (components[0].parent !== undefined) {
25
+ components.unshift(components[0].parent)
26
+ return getAncestors(components)
27
+ }
28
+ return components
29
+ }
@@ -0,0 +1,93 @@
1
+ /*
2
+ * Copyright 2025 Comcast Cable Communications Management, LLC
3
+ * Licensed under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License.
5
+ * You may obtain a copy of the License at
6
+ *
7
+ * http://www.apache.org/licenses/LICENSE-2.0
8
+ *
9
+ * Unless required by applicable law or agreed to in writing, software
10
+ * distributed under the License is distributed on an "AS IS" BASIS,
11
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ * See the License for the specific language governing permissions and
13
+ * limitations under the License.
14
+ *
15
+ * SPDX-License-Identifier: Apache-2.0
16
+ */
17
+
18
+ import { Log } from '../lib/log.js'
19
+ import { getAncestors } from './helpers.js'
20
+
21
+ let hoveredComponent = null
22
+ let hoverChain = []
23
+
24
+ export default {
25
+ get() {
26
+ return hoveredComponent
27
+ },
28
+ set(component) {
29
+ if (component === undefined || component.eol === true) return
30
+
31
+ // early return if already hovered
32
+ if (component === hoveredComponent) return
33
+
34
+ if (hoveredComponent === null) {
35
+ hoverChain = getAncestors([component])
36
+ }
37
+
38
+ // unhover currently hovered components
39
+ if (hoveredComponent !== null) {
40
+ if (hoverChain[hoverChain.length - 1] === component.parent) {
41
+ hoverChain.push(component)
42
+ } else {
43
+ const newhoverChain = getAncestors([component])
44
+ let i = hoverChain.length
45
+ while (i--) {
46
+ // don't unhover when part of the new hover chain
47
+ if (newhoverChain.indexOf(hoverChain[i]) > -1) break
48
+ // skip if component is destroyed
49
+ if (hoverChain[i].eol !== true) {
50
+ hoverChain[i].lifecycle.state = 'unhover'
51
+ }
52
+ }
53
+ hoverChain = newhoverChain
54
+ }
55
+ }
56
+
57
+ // ensure that all components in the hover path have hover state
58
+ let i = 0
59
+ while (i < hoverChain.length - 1) {
60
+ // skip if component is destroyed
61
+ if (hoverChain[i].eol !== true) {
62
+ hoverChain[i].lifecycle.state = 'hover'
63
+ }
64
+ i++
65
+ }
66
+
67
+ setHover(component)
68
+ },
69
+ clear() {
70
+ if (hoveredComponent === null) return
71
+ for (let i = 0; i < hoverChain.length; i++) {
72
+ if (hoverChain[i].eol !== true) {
73
+ hoverChain[i].lifecycle.state = 'unhover'
74
+ }
75
+ }
76
+ hoveredComponent = null
77
+ hoverChain = []
78
+ },
79
+ }
80
+
81
+ /**
82
+ * Set the hover to the Component
83
+ * @param {Object} component - The component to hover
84
+ */
85
+ const setHover = (component) => {
86
+ Log.info(
87
+ '\nHover chain:\n',
88
+ hoverChain.map((c, index) => '\t'.repeat(index) + '↳ ' + c.componentId).join('\n')
89
+ )
90
+
91
+ hoveredComponent = component
92
+ component.lifecycle.state = 'hover'
93
+ }
package/src/launch.js CHANGED
@@ -89,6 +89,7 @@ async function rendererVersion() {
89
89
  * @property {number} [textureProcessingTimeLimit] - Max time renderer can process textures in a frame
90
90
  * @property {Partial<import('@lightningjs/renderer').RendererMainSettings>} [advanced] - Advanced renderer settings
91
91
  * @property {boolean} [announcer] - Whether or not the announcer should be activated on initialization
92
+ * @property {boolean} [enableMouse] - Enable mouse support (hover and click-to-focus). Defaults to false.
92
93
  * @property {number} [maxFPS] - Maximum FPS
93
94
  */
94
95
 
@@ -174,6 +174,10 @@ const generateElementCode = function (
174
174
  renderCode.push(`elementConfigs[${counter}][Symbol.for('isSlot')] = true`)
175
175
  }
176
176
 
177
+ if (options.holder) {
178
+ renderCode.push(`elementConfigs[${counter}]['holder'] = true`)
179
+ }
180
+
177
181
  Object.keys(templateObject).forEach((key) => {
178
182
  if (key === 'slot') {
179
183
  renderCode.push(`
@@ -31,6 +31,8 @@ const states = [
31
31
  'ready', // fired when component instantiated, reactivity setup done and template spawned
32
32
  'focus', // fired when receiving focus (can occur multiple times)
33
33
  'unfocus', // fired when losing focus (can occur multiple times)
34
+ 'hover', // fired when receiving focus (can occur multiple times)
35
+ 'unhover', // fired when losing focus (can occur multiple times)
34
36
  'destroy', // fired when component is destroyed and removed
35
37
  'attach', // fired when entering the viewport margin and attached to the render tree
36
38
  'detach', // fired when leaving the viewport margin and detached from the render tree
@@ -85,11 +87,16 @@ export default {
85
87
  // emit 'public' hook
86
88
  emit(v, this.component[symbols.identifier], this.component)
87
89
  // update the built-in hasFocus state variable
90
+ if (v === 'focus') this.component[symbols.state].hasFocus = true
91
+ if (v === 'unfocus') this.component[symbols.state].hasFocus = false
88
92
  if (v === 'focus' || v === 'unfocus') {
89
93
  if (inspectorEnabled === null) {
90
94
  inspectorEnabled = Settings.get('inspector', false)
91
95
  }
92
96
  }
97
+ // update the built-in isHovered state variable
98
+ if (v === 'hover') this.component[symbols.state].isHovered = true
99
+ if (v === 'unhover') this.component[symbols.state].isHovered = false
93
100
  if (v === 'focus') {
94
101
  this.component[symbols.state].hasFocus = true
95
102
  if (
@@ -21,7 +21,7 @@ import { reactive } from '../lib/reactivity/reactive.js'
21
21
  import symbols from '../lib/symbols.js'
22
22
  import { Log } from '../lib/log.js'
23
23
  import { stage } from '../launch.js'
24
- import Focus from '../focus.js'
24
+ import Focus from '../focus/focus.js'
25
25
  import Announcer from '../announcer/announcer.js'
26
26
  import Settings from '../settings.js'
27
27
 
@@ -216,12 +216,17 @@ const defaultOptions = {
216
216
  }
217
217
 
218
218
  const makeRouteObject = (route, overrides) => {
219
+ // FIX: exclude keepAlive from the destination route options. Unlike other
220
+ // overrides, keepAlive applies to the route being LEFT, not the destination.
221
+ // It is consumed by removeView() instead.
222
+ const { keepAlive: _keepAlive, ...destOverrides } = overrideOptions // eslint-disable-line no-unused-vars
223
+
219
224
  const cleanRoute = {
220
225
  hash: overrides.hash,
221
226
  path: route.path,
222
227
  component: route.component,
223
228
  transition: 'transition' in route ? route.transition : fadeInFadeOutTransition,
224
- options: { ...defaultOptions, ...route.options, ...overrideOptions },
229
+ options: { ...defaultOptions, ...route.options, ...destOverrides },
225
230
  announce: route.announce || false,
226
231
  hooks: route.hooks || {},
227
232
  data: { ...route.data, ...navigationData, ...overrides.queryParams },
@@ -342,8 +347,12 @@ export const navigate = async function () {
342
347
 
343
348
  // add the previous route (technically still the current route at this point)
344
349
  // into the history stack when inHistory is true and we're not navigating back
350
+ //
351
+ // FIX: use truthy check instead of `!== undefined` because matchHash()
352
+ // can return `false`, which survives `!== undefined` but has no `.options`.
345
353
  if (
346
- previousRoute !== undefined &&
354
+ previousRoute &&
355
+ previousRoute.options &&
347
356
  previousRoute.options.inHistory === true &&
348
357
  navigatingBack === false
349
358
  ) {
@@ -485,7 +494,8 @@ export const navigate = async function () {
485
494
 
486
495
  // apply out out transition on previous view if available, unless
487
496
  // we're reusing the prvious page component
488
- if (previousRoute !== undefined && reuse === false) {
497
+ // FIX: truthy guard — previousRoute can be `false` (see history-push comment above).
498
+ if (previousRoute && reuse === false) {
489
499
  // only animate when there is a previous route
490
500
  shouldAnimate = true
491
501
  const oldView = this[symbols.children].splice(1, 1).pop()
@@ -542,6 +552,12 @@ export const navigate = async function () {
542
552
  }
543
553
  }
544
554
 
555
+ // Clear module-level variables after removeView has consumed them.
556
+ // Placed here so it executes for all navigation flows, not only when
557
+ // previousRoute exists and reuse is false.
558
+ overrideOptions = {}
559
+ navigationData = {}
560
+
545
561
  // reset navigating indicators
546
562
  navigatingBack = false
547
563
  state.navigating = false
@@ -569,11 +585,18 @@ const removeView = async (route, view, transition, navigatingBack) => {
569
585
  }
570
586
  }
571
587
 
588
+ // Resolve effective keepAlive: runtime override from $router.to() takes precedence
589
+ // over the static route config option
590
+ const keepAlive =
591
+ overrideOptions.keepAlive !== undefined
592
+ ? overrideOptions.keepAlive
593
+ : route.options && route.options.keepAlive
594
+
572
595
  // cache the page when it's as 'keepAlive' instead of destroying
573
596
  if (
574
597
  navigatingBack === false &&
575
598
  route.options &&
576
- route.options.keepAlive === true &&
599
+ keepAlive === true &&
577
600
  route.options.inHistory === true
578
601
  ) {
579
602
  const historyItem = history[history.length - 1]
@@ -588,7 +611,7 @@ const removeView = async (route, view, transition, navigatingBack) => {
588
611
  * 2. Navigating back, and the previous route is configured with "keep alive" set to true.
589
612
  * 3. Navigating back, and the previous route is not configured with "keep alive" set to true.
590
613
  */
591
- if (route.options && (route.options.keepAlive !== true || navigatingBack === true)) {
614
+ if (route.options && (keepAlive !== true || navigatingBack === true)) {
592
615
  view.destroy()
593
616
  view = null
594
617
  }