@lightningjs/blits 1.48.0 → 1.50.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,24 @@
1
1
  # Changelog
2
2
 
3
+ ## v1.50.0
4
+
5
+ _27 feb 2026_
6
+
7
+ - Fixed issue with router navigation options not properly being overriden
8
+ - Fixed FPS counter component and made it into a separate importable component (`import { FPScounter } from @lightningjs/blits/components`)
9
+ - Added mouse (magic wand) support (including new `hover` lifecycle hook and `isHovered` state indicator)
10
+ - Updated template attributes for VScode extension
11
+
12
+
13
+ ## v1.49.0
14
+
15
+ _20 feb 2026_
16
+
17
+ - Added `data-blits-renderstate` inspector attribute
18
+ - Fixed `data-blits-hasfocus` inspector attribute to always be available
19
+ - Added boolean attribute to `idle` lifecycle hook to indicate `idle` or `active` state
20
+ - Fixed type definitions for router beforeEach hooks
21
+ - Fixed double processing of reactivity guard
3
22
 
4
23
  ## v1.48.0
5
24
 
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
  *
@@ -201,11 +203,14 @@ declare module '@lightningjs/blits' {
201
203
  */
202
204
  exit?: () => void;
203
205
  /**
204
- * Fires when the renderer is done rendering and enters an idle state
206
+ * Fires when the renderer is done rendering and enters an idle state (idle = true)
207
+ * and when the renderer starts rendering again and leaves the idle state (idle = false)
208
+ *
209
+ * @param idle - boolean to indicate whether the renderer is in idle state or not
205
210
  *
206
211
  * Note: This event can fire multiple times
207
212
  */
208
- idle?: () => void;
213
+ idle?: (idle: boolean) => void;
209
214
  /**
210
215
  * Fires at a predefined interval and reports the current FPS value
211
216
  *
@@ -391,6 +396,13 @@ declare module '@lightningjs/blits' {
391
396
  */
392
397
  hasFocus: boolean,
393
398
 
399
+ /**
400
+ * Indicates whether the component currently is hovered
401
+ *
402
+ * @returns Boolean
403
+ */
404
+ isHovered: boolean,
405
+
394
406
  /**
395
407
  * Listen to events emitted by other components
396
408
  */
@@ -666,9 +678,9 @@ declare module '@lightningjs/blits' {
666
678
 
667
679
  export interface RouterHooks {
668
680
  init?: () => Promise<void> | void;
669
- beforeEach?: (to: Route, from: Route) => string | Route | Promise<string | Route> | void;
670
- afterEach?: (to: Route, from: Route) => string | Route | Promise<string | Route> | void;
671
- error?: (err: string) => string | Route | Promise<string | Route> | void;
681
+ beforeEach?: (to: Route, from: Route) => string | Route | void | boolean | Promise<string | Route | void | boolean>;
682
+ afterEach?: (to: Route, from: Route) => string | Route | void | boolean | Promise<string | Route | void | boolean>;
683
+ error?: (err: string) => string | Route | void | boolean | Promise<string | Route | void | boolean>;
672
684
  }
673
685
 
674
686
  export interface RouterConfig<P extends Props, S, M, C> {
@@ -694,13 +706,13 @@ declare module '@lightningjs/blits' {
694
706
 
695
707
  /**
696
708
  * Enable or disable RouterView history navigation on Back input
697
- *
709
+ *
698
710
  * @default true
699
- *
711
+ *
700
712
  * @remarks
701
713
  * This is an app-wide setting that affects all RouterView instances in your application.
702
714
  * The router state is global and shared across all router instances.
703
- *
715
+ *
704
716
  * @example
705
717
  * ```js
706
718
  * router: {
@@ -809,8 +821,8 @@ declare module '@lightningjs/blits' {
809
821
  }[keyof T]
810
822
 
811
823
  export interface RouteHooks {
812
- before?: (to: Route, from: Route) => string | Route | Promise<string | Route>;
813
- after?: (to: Route, from: Route) => string | Route | Promise<string | Route>;
824
+ before?: (to: Route, from: Route) => string | Route | void | boolean | Promise<string | Route | void | boolean>;
825
+ after?: (to: Route, from: Route) => string | Route | void | boolean | Promise<string | Route | void | boolean>;
814
826
  }
815
827
 
816
828
  export type Route = {
@@ -1250,6 +1262,16 @@ declare module '@lightningjs/blits' {
1250
1262
  * ```
1251
1263
  */
1252
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,
1253
1275
  /**
1254
1276
  * Maximum FPS at which the App will be rendered
1255
1277
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightningjs/blits",
3
- "version": "1.48.0",
3
+ "version": "1.50.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",
@@ -74,7 +75,7 @@
74
75
  },
75
76
  "dependencies": {
76
77
  "@lightningjs/msdf-generator": "^1.2.0",
77
- "@lightningjs/renderer": "^2.21.0",
78
+ "@lightningjs/renderer": "^2.23.0",
78
79
  "magic-string": "^0.30.21"
79
80
  },
80
81
  "repository": {
@@ -19,7 +19,7 @@ import fs from 'fs'
19
19
  import path from 'path'
20
20
  import { fileURLToPath } from 'url'
21
21
  import compiler from '../src/lib/precompiler/precompiler.js'
22
- import { exec } from 'child_process'
22
+ import childProcess from 'child_process'
23
23
 
24
24
  const currentDir = process.cwd()
25
25
  const componentDirs = ['src/components']
@@ -71,7 +71,7 @@ function processFile(filePath) {
71
71
  function formatFileWithESLint(filePath) {
72
72
  const command = `eslint "${filePath}" --fix`
73
73
 
74
- exec(command, (error, stdout, stderr) => {
74
+ childProcess.exec(command, (error, stdout, stderr) => {
75
75
  if (error) {
76
76
  console.error(`Execution Error: ${error.message}`)
77
77
  console.error(`Exit Code: ${error.code}`)
@@ -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,24 @@ 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 () {
42
48
  document.removeEventListener('keydown', keyDownHandler)
43
49
  document.removeEventListener('keyup', keyUpHandler)
50
+ if (mouseListenersAdded) {
51
+ document.removeEventListener('mousemove', mouseMoveHandler)
52
+ document.removeEventListener('click', mouseClickHandler)
53
+ window.removeEventListener('resize', updateCanvasRect)
54
+ window.removeEventListener('scroll', updateCanvasRect)
55
+ Hover.clear()
56
+ }
44
57
  }
45
58
 
46
59
  config.hooks[symbols.init] = function () {
@@ -57,6 +70,8 @@ const Application = (config) => {
57
70
  /** @type {number} Input throttle time in milliseconds (0 = disabled) */
58
71
  const throttleMs = Settings.get('inputThrottle', 0)
59
72
 
73
+ const mouseEnabled = Settings.get('enableMouse', false)
74
+
60
75
  keyDownHandler = async (e) => {
61
76
  const currentTime = performance.now()
62
77
 
@@ -89,6 +104,7 @@ const Application = (config) => {
89
104
  }
90
105
 
91
106
  Focus.input(key, e)
107
+ if (mouseEnabled === true) Hover.clear()
92
108
  clearTimeout(holdTimeout)
93
109
  holdTimeout = setTimeout(
94
110
  () => {
@@ -108,8 +124,84 @@ const Application = (config) => {
108
124
  Focus.hold = false
109
125
  }
110
126
 
127
+ let lastMoved = 0
128
+ let currentNode = undefined
129
+ let currentComponent = undefined
130
+ let canvasRect = null
131
+
132
+ updateCanvasRect = () => {
133
+ if (renderer.canvas !== undefined) {
134
+ canvasRect = renderer.canvas.getBoundingClientRect()
135
+ }
136
+ }
137
+
138
+ // limit the amount of move events per time frame
139
+ mouseMoveHandler = (e) => {
140
+ if (e.timeStamp - lastMoved < 100) return
141
+ lastMoved = e.timeStamp
142
+
143
+ this.$emit('mouse::move', e)
144
+
145
+ if (canvasRect === null && renderer.canvas != null) {
146
+ updateCanvasRect()
147
+ }
148
+ if (canvasRect === null) {
149
+ return
150
+ }
151
+
152
+ const stage = renderer.stage
153
+ if (stage == null) {
154
+ return
155
+ }
156
+
157
+ // Convert viewport → canvas display coordinates
158
+ const x = e.clientX - canvasRect.left
159
+ const y = e.clientY - canvasRect.top
160
+
161
+ const node = stage.getNodeFromPosition({ x, y })
162
+
163
+ if (node === null) {
164
+ currentComponent = undefined
165
+ Hover.clear()
166
+ return
167
+ }
168
+ if (node === currentNode) return
169
+
170
+ currentNode = node
171
+ currentComponent = componentMap.get(currentNode)
172
+
173
+ if (currentComponent === undefined) {
174
+ Hover.clear()
175
+ return
176
+ }
177
+ Hover.set(currentComponent)
178
+ }
179
+
180
+ mouseClickHandler = () => {
181
+ if (currentComponent === undefined || currentComponent.eol === true) return
182
+
183
+ const e = new KeyboardEvent('keydown', {
184
+ key: 'Enter',
185
+ code: 'Enter',
186
+ keyCode: 13,
187
+ bubbles: true,
188
+ cancelable: true,
189
+ composed: true,
190
+ })
191
+ currentComponent.$focus()
192
+ currentComponent.$input(e)
193
+ }
194
+
111
195
  document.addEventListener('keydown', keyDownHandler)
112
196
  document.addEventListener('keyup', keyUpHandler)
197
+ if (mouseEnabled === true) {
198
+ updateCanvasRect()
199
+ document.addEventListener('mousemove', mouseMoveHandler)
200
+ document.addEventListener('click', mouseClickHandler)
201
+ window.addEventListener('resize', updateCanvasRect)
202
+ window.addEventListener('scroll', updateCanvasRect)
203
+ mouseListenersAdded = true
204
+ }
113
205
 
114
206
  // next tick
115
207
  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'))
@@ -267,35 +277,59 @@ const Component = (name = required('name'), config = required('config')) => {
267
277
  this[symbols.slots] = this[symbols.children].filter((child) => child[symbols.isSlot])
268
278
 
269
279
  this[symbols.rendererEventListeners] = []
280
+
281
+ if (this[symbols.holder] !== undefined && Settings.get('inspector', false)) {
282
+ this[symbols.holder].setInspectorMetadata({ 'blits-hasFocus': false })
283
+ }
284
+
285
+ // setup inspector data for render state
286
+ if (name === 'App' && Settings.get('inspector', false)) {
287
+ this.$setTimeout(() => {
288
+ const wrapperEl = this[symbols.wrapper]
289
+ const idleCb = () => {
290
+ wrapperEl.setInspectorMetadata({ 'blits-renderState': 'idle' })
291
+ }
292
+ const activeCb = () => {
293
+ wrapperEl.setInspectorMetadata({ 'blits-renderState': 'active' })
294
+ }
295
+ renderer.on('idle', idleCb)
296
+ renderer.on('active', activeCb)
297
+ this[symbols.rendererEventListeners].push({ event: 'idle', cb: idleCb })
298
+ this[symbols.rendererEventListeners].push({ event: 'active', cb: activeCb })
299
+ })
300
+ }
301
+
270
302
  // register hooks if component has hooks specified
271
303
  if (config.hooks) {
272
- // push to next tick to ensure
273
- setTimeout(() => {
274
- // frame tick event
275
- if (config.hooks.frameTick) {
276
- const cb = (r, data) => emit('frameTick', this[symbols.identifier], this, [data])
277
- this[symbols.rendererEventListeners].push({ event: 'frameTick', cb })
278
- renderer.on('frameTick', cb)
279
- }
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
+ }
280
310
 
281
- // idle event
282
- if (config.hooks.idle) {
283
- const cb = () => {
284
- emit('idle', this[symbols.identifier], this)
285
- }
286
- this[symbols.rendererEventListeners].push({ event: 'idle', cb })
287
- renderer.on('idle', cb)
311
+ // idle event
312
+ if (config.hooks.idle) {
313
+ const idleCb = () => {
314
+ emit('idle', this[symbols.identifier], this, [true])
288
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
+ }
289
324
 
290
- // fpsUpdate event
291
- if (config.hooks.fpsUpdate) {
292
- const cb = (r, data) => {
293
- emit('fpsUpdate', this[symbols.identifier], this, [data.fps])
294
- }
295
- this[symbols.rendererEventListeners].push({ event: 'fpsUpdate', cb })
296
- renderer.on('fpsUpdate', cb)
325
+ // fpsUpdate event
326
+ if (config.hooks.fpsUpdate) {
327
+ const cb = (_r, data) => {
328
+ emit('fpsUpdate', this[symbols.identifier], this, [data.fps])
297
329
  }
298
- })
330
+ renderer.on('fpsUpdate', cb)
331
+ this[symbols.rendererEventListeners].push({ event: 'fpsUpdate', cb })
332
+ }
299
333
 
300
334
  // inBounds event emiting a lifecycle attach event
301
335
  if (config.hooks.attach) {