@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## v1.51.0
4
+
5
+ _04 mar 2026_
6
+
7
+ - Fixed wrong message from being interrupted in announcer
8
+ - Added `@loaded` and `@error` callbacks to Sprites
9
+
10
+ ## v1.50.0
11
+
12
+ _27 feb 2026_
13
+
14
+ - Fixed issue with router navigation options not properly being overriden
15
+ - Fixed FPS counter component and made it into a separate importable component (`import { FPScounter } from @lightningjs/blits/components`)
16
+ - Added mouse (magic wand) support (including new `hover` lifecycle hook and `isHovered` state indicator)
17
+ - Updated template attributes for VScode extension
18
+
3
19
 
4
20
  ## v1.49.0
5
21
 
@@ -8,8 +24,8 @@ _20 feb 2026_
8
24
  - Added `data-blits-renderstate` inspector attribute
9
25
  - Fixed `data-blits-hasfocus` inspector attribute to always be available
10
26
  - Added boolean attribute to `idle` lifecycle hook to indicate `idle` or `active` state
11
- - Fixed type definitions for
12
- - Fixed double processiong of reactivity guard
27
+ - Fixed type definitions for router beforeEach hooks
28
+ - Fixed double processing of reactivity guard
13
29
 
14
30
  ## v1.48.0
15
31
 
package/index.d.ts CHANGED
@@ -170,6 +170,8 @@ declare module '@lightningjs/blits' {
170
170
  * Fires when the Component is being destroyed and removed.
171
171
  */
172
172
  destroy?: () => void;
173
+ hover?: () => void;
174
+ unhover?: () => void;
173
175
  /**
174
176
  * Fires upon each frame start (allowing you to tap directly into the renderloop)
175
177
  *
@@ -394,6 +396,13 @@ declare module '@lightningjs/blits' {
394
396
  */
395
397
  hasFocus: boolean,
396
398
 
399
+ /**
400
+ * Indicates whether the component currently is hovered
401
+ *
402
+ * @returns Boolean
403
+ */
404
+ isHovered: boolean,
405
+
397
406
  /**
398
407
  * Listen to events emitted by other components
399
408
  */
@@ -1253,6 +1262,16 @@ declare module '@lightningjs/blits' {
1253
1262
  * ```
1254
1263
  */
1255
1264
  announcerOptions?: AnnouncerUtteranceOptions,
1265
+ /**
1266
+ * Enable mouse support (hover and click-to-focus).
1267
+ *
1268
+ * When set to `true`, pointer movement over the canvas updates hover state on components
1269
+ * and click dispatches focus and Enter key input to the component under the cursor.
1270
+ * When set to `false`, no mouse or pointer listeners are registered.
1271
+ *
1272
+ * @default false
1273
+ */
1274
+ enableMouse?: boolean,
1256
1275
  /**
1257
1276
  * Maximum FPS at which the App will be rendered
1258
1277
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightningjs/blits",
3
- "version": "1.49.0",
3
+ "version": "1.51.0",
4
4
  "description": "Blits: The Lightning 3 App Development Framework",
5
5
  "bin": "bin/index.js",
6
6
  "exports": {
@@ -29,7 +29,8 @@
29
29
  "default": "./src/plugins/storage/storage.js"
30
30
  },
31
31
  "./symbols": "./src/lib/symbols.js",
32
- "./blitsFileConverter": "./src/lib/blitsfileconverter/blitsfileconverter.js"
32
+ "./blitsFileConverter": "./src/lib/blitsfileconverter/blitsfileconverter.js",
33
+ "./components": "./src/components/FPScounter.js"
33
34
  },
34
35
  "scripts": {
35
36
  "test": "c8 node scripts/runTests.js",
@@ -149,9 +149,11 @@ const processQueue = async () => {
149
149
  processQueue()
150
150
  })
151
151
  .catch((e) => {
152
- currentId = null
153
- currentResolveFn = null
154
- isProcessing = false
152
+ if (id === currentId) {
153
+ currentId = null
154
+ currentResolveFn = null
155
+ isProcessing = false
156
+ }
155
157
  Log.debug(`Announcer - error ("${e.error}") while speaking: "${message}" (id: ${id})`)
156
158
  resolveFn(e.error)
157
159
  processQueue()
@@ -15,12 +15,14 @@
15
15
  * SPDX-License-Identifier: Apache-2.0
16
16
  */
17
17
 
18
- import Component from './component.js'
19
- import { default as Focus, keyUpCallbacks } from './focus.js'
20
- import Settings from './settings.js'
18
+ import { default as Component, componentMap } from './component.js'
19
+ import { default as Focus, keyUpCallbacks } from './focus/focus.js'
20
+ import Hover from './focus/hover.js'
21
21
 
22
+ import Settings from './settings.js'
22
23
  import symbols from './lib/symbols.js'
23
24
  import { DEFAULT_HOLD_TIMEOUT_MS, DEFAULT_KEYMAP } from './constants.js'
25
+ import { renderer } from './launch.js'
24
26
 
25
27
  /**
26
28
  * Merged keyMap (default + custom settings).
@@ -34,13 +36,27 @@ const Application = (config) => {
34
36
 
35
37
  let keyDownHandler
36
38
  let keyUpHandler
39
+ let mouseMoveHandler
40
+ let mouseClickHandler
41
+ let updateCanvasRect
37
42
  let holdTimeout
38
43
  let lastInputTime = 0
39
44
  let lastInputKey = null
45
+ let mouseListenersAdded = false
40
46
 
41
47
  config.hooks[symbols.destroy] = function () {
48
+ // Cancel pending key-hold timeout and reset hold state so focus is not left in hold mode after teardown
49
+ clearTimeout(holdTimeout)
50
+ Focus.hold = false
42
51
  document.removeEventListener('keydown', keyDownHandler)
43
52
  document.removeEventListener('keyup', keyUpHandler)
53
+ if (mouseListenersAdded) {
54
+ document.removeEventListener('mousemove', mouseMoveHandler)
55
+ document.removeEventListener('click', mouseClickHandler)
56
+ window.removeEventListener('resize', updateCanvasRect)
57
+ window.removeEventListener('scroll', updateCanvasRect)
58
+ Hover.clear()
59
+ }
44
60
  }
45
61
 
46
62
  config.hooks[symbols.init] = function () {
@@ -57,6 +73,8 @@ const Application = (config) => {
57
73
  /** @type {number} Input throttle time in milliseconds (0 = disabled) */
58
74
  const throttleMs = Settings.get('inputThrottle', 0)
59
75
 
76
+ const mouseEnabled = Settings.get('enableMouse', false)
77
+
60
78
  keyDownHandler = async (e) => {
61
79
  const currentTime = performance.now()
62
80
 
@@ -89,6 +107,7 @@ const Application = (config) => {
89
107
  }
90
108
 
91
109
  Focus.input(key, e)
110
+ if (mouseEnabled === true) Hover.clear()
92
111
  clearTimeout(holdTimeout)
93
112
  holdTimeout = setTimeout(
94
113
  () => {
@@ -108,8 +127,84 @@ const Application = (config) => {
108
127
  Focus.hold = false
109
128
  }
110
129
 
130
+ let lastMoved = 0
131
+ let currentNode = undefined
132
+ let currentComponent = undefined
133
+ let canvasRect = null
134
+
135
+ updateCanvasRect = () => {
136
+ if (renderer.canvas !== undefined) {
137
+ canvasRect = renderer.canvas.getBoundingClientRect()
138
+ }
139
+ }
140
+
141
+ // limit the amount of move events per time frame
142
+ mouseMoveHandler = (e) => {
143
+ if (e.timeStamp - lastMoved < 100) return
144
+ lastMoved = e.timeStamp
145
+
146
+ this.$emit('mouse::move', e)
147
+
148
+ if (canvasRect === null && renderer.canvas != null) {
149
+ updateCanvasRect()
150
+ }
151
+ if (canvasRect === null) {
152
+ return
153
+ }
154
+
155
+ const stage = renderer.stage
156
+ if (stage == null) {
157
+ return
158
+ }
159
+
160
+ // Convert viewport → canvas display coordinates
161
+ const x = e.clientX - canvasRect.left
162
+ const y = e.clientY - canvasRect.top
163
+
164
+ const node = stage.getNodeFromPosition({ x, y })
165
+
166
+ if (node === null) {
167
+ currentComponent = undefined
168
+ Hover.clear()
169
+ return
170
+ }
171
+ if (node === currentNode) return
172
+
173
+ currentNode = node
174
+ currentComponent = componentMap.get(currentNode)
175
+
176
+ if (currentComponent === undefined) {
177
+ Hover.clear()
178
+ return
179
+ }
180
+ Hover.set(currentComponent)
181
+ }
182
+
183
+ mouseClickHandler = () => {
184
+ if (currentComponent === undefined || currentComponent.eol === true) return
185
+
186
+ const e = new KeyboardEvent('keydown', {
187
+ key: 'Enter',
188
+ code: 'Enter',
189
+ keyCode: 13,
190
+ bubbles: true,
191
+ cancelable: true,
192
+ composed: true,
193
+ })
194
+ currentComponent.$focus()
195
+ currentComponent.$input(e)
196
+ }
197
+
111
198
  document.addEventListener('keydown', keyDownHandler)
112
199
  document.addEventListener('keyup', keyUpHandler)
200
+ if (mouseEnabled === true) {
201
+ updateCanvasRect()
202
+ document.addEventListener('mousemove', mouseMoveHandler)
203
+ document.addEventListener('click', mouseClickHandler)
204
+ window.addEventListener('resize', updateCanvasRect)
205
+ window.addEventListener('scroll', updateCanvasRect)
206
+ mouseListenersAdded = true
207
+ }
113
208
 
114
209
  // next tick
115
210
  setTimeout(() => Focus.set(this))
@@ -16,8 +16,7 @@
16
16
  */
17
17
 
18
18
  import symbols from '../../lib/symbols.js'
19
- import Focus from '../../focus.js'
20
- import { keyUpCallbacks, getComponentWithInputEvent } from '../../focus.js'
19
+ import { default as Focus, keyUpCallbacks, getComponentWithInputEvent } from '../../focus/focus.js'
21
20
  import eventListeners from '../../lib/eventListeners.js'
22
21
  import { trigger } from '../../lib/reactivity/effect.js'
23
22
  import { Log } from '../../lib/log.js'
@@ -36,6 +36,8 @@ export default (component, state = () => {}) => {
36
36
  // add built-in hasFocus key
37
37
  stateKeys.push(['hasFocus'])
38
38
  }
39
+ // add built-in isHovered key
40
+ stateKeys.push(['isHovered'])
39
41
  const stateKeysLength = stateKeys.length
40
42
 
41
43
  for (let i = 0; i < stateKeysLength; i++) {
package/src/component.js CHANGED
@@ -43,6 +43,9 @@ const required = (name) => {
43
43
  throw new Error(`Parameter ${name} is required`)
44
44
  }
45
45
 
46
+ // map that holds component to their holder node
47
+ export const componentMap = new WeakMap()
48
+
46
49
  /**
47
50
  * @typedef {function} BlitsComponentFactory
48
51
  * @param {object} opts - The options for the component instance
@@ -216,6 +219,10 @@ const Component = (name = required('name'), config = required('config')) => {
216
219
  // such as rotation and scale to components)
217
220
  this[symbols.holder] = parentEl
218
221
 
222
+ if (parentEl !== undefined && parentEl.node !== undefined) {
223
+ componentMap.set(parentEl.node, this)
224
+ }
225
+
219
226
  // generate an internal id (simple counter)
220
227
  this[symbols.id] = createInternalId()
221
228
 
@@ -238,6 +245,9 @@ const Component = (name = required('name'), config = required('config')) => {
238
245
  // add hasFocus key in
239
246
  this[symbols.originalState]['hasFocus'] = false
240
247
 
248
+ // add isHovered key in
249
+ this[symbols.originalState]['isHovered'] = false
250
+
241
251
  // generate a reactive state (using the result of previously execute state function)
242
252
  // and store it
243
253
  this[symbols.state] = reactive(this[symbols.originalState], Settings.get('reactivityMode'))
@@ -291,38 +301,35 @@ const Component = (name = required('name'), config = required('config')) => {
291
301
 
292
302
  // register hooks if component has hooks specified
293
303
  if (config.hooks) {
294
- // push to next tick to ensure
295
- this.$setTimeout(() => {
296
- // frame tick event
297
- if (config.hooks.frameTick) {
298
- const cb = (_r, data) => emit('frameTick', this[symbols.identifier], this, [data])
299
- renderer.on('frameTick', cb)
300
- this[symbols.rendererEventListeners].push({ event: 'frameTick', cb })
301
- }
304
+ // frame tick event
305
+ if (config.hooks.frameTick) {
306
+ const cb = (_r, data) => emit('frameTick', this[symbols.identifier], this, [data])
307
+ renderer.on('frameTick', cb)
308
+ this[symbols.rendererEventListeners].push({ event: 'frameTick', cb })
309
+ }
302
310
 
303
- // idle event
304
- if (config.hooks.idle) {
305
- const idleCb = () => {
306
- emit('idle', this[symbols.identifier], this, [true])
307
- }
308
- const activeCb = () => {
309
- emit('idle', this[symbols.identifier], this, [false])
310
- }
311
- renderer.on('idle', idleCb)
312
- renderer.on('active', activeCb)
313
- this[symbols.rendererEventListeners].push({ event: 'idle', cb: idleCb })
314
- this[symbols.rendererEventListeners].push({ event: 'active', cb: activeCb })
311
+ // idle event
312
+ if (config.hooks.idle) {
313
+ const idleCb = () => {
314
+ emit('idle', this[symbols.identifier], this, [true])
315
315
  }
316
+ const activeCb = () => {
317
+ emit('idle', this[symbols.identifier], this, [false])
318
+ }
319
+ renderer.on('idle', idleCb)
320
+ renderer.on('active', activeCb)
321
+ this[symbols.rendererEventListeners].push({ event: 'idle', cb: idleCb })
322
+ this[symbols.rendererEventListeners].push({ event: 'active', cb: activeCb })
323
+ }
316
324
 
317
- // fpsUpdate event
318
- if (config.hooks.fpsUpdate) {
319
- const cb = (_r, data) => {
320
- emit('fpsUpdate', this[symbols.identifier], this, [data.fps])
321
- }
322
- renderer.on('fpsUpdate', cb)
323
- this[symbols.rendererEventListeners].push({ event: 'fpsUpdate', cb })
325
+ // fpsUpdate event
326
+ if (config.hooks.fpsUpdate) {
327
+ const cb = (_r, data) => {
328
+ emit('fpsUpdate', this[symbols.identifier], this, [data.fps])
324
329
  }
325
- })
330
+ renderer.on('fpsUpdate', cb)
331
+ this[symbols.rendererEventListeners].push({ event: 'fpsUpdate', cb })
332
+ }
326
333
 
327
334
  // inBounds event emiting a lifecycle attach event
328
335
  if (config.hooks.attach) {