@lightningjs/blits 2.0.0-beta.1 → 2.0.0-beta.2

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.
@@ -19,33 +19,61 @@ import { Log } from '../lib/log.js'
19
19
 
20
20
  const syn = window.speechSynthesis
21
21
 
22
- const isAndroid = /android/i.test((window.navigator || {}).userAgent || '')
23
-
24
- const utterances = new Map() // Strong references with unique keys
22
+ const utterances = new Map() // id -> { utterance, timer, ignoreResume }
25
23
 
26
24
  let initialized = false
27
- let infinityTimer = null
28
25
 
29
- const clear = () => {
30
- if (infinityTimer) {
31
- clearTimeout(infinityTimer)
32
- infinityTimer = null
26
+ const clear = (id) => {
27
+ const state = utterances.get(id)
28
+ if (state === undefined) {
29
+ return
30
+ }
31
+ if (state.timer !== null) {
32
+ clearTimeout(state.timer)
33
+ state.timer = null
33
34
  }
35
+ utterances.delete(id)
34
36
  }
35
37
 
36
- const resumeInfinity = (target) => {
37
- if (!target || infinityTimer) {
38
- return clear()
38
+ const startKeepAlive = (id) => {
39
+ const state = utterances.get(id)
40
+
41
+ // utterance status: utterance was removed (cancelled or finished)
42
+ if (state == undefined) {
43
+ return
44
+ }
45
+
46
+ // Clear existing timer for this specific utterance
47
+ if (state.timer !== null) {
48
+ clearTimeout(state.timer)
49
+ state.timer = null
50
+ }
51
+
52
+ // syn status: syn might be undefined or cancelled
53
+ if (!syn) {
54
+ clear(id)
55
+ return
39
56
  }
40
57
 
41
58
  syn.pause()
42
59
  setTimeout(() => {
43
- syn.resume()
60
+ // utterance status: utterance might have been removed during setTimeout
61
+ const currentState = utterances.get(id)
62
+ if (currentState) {
63
+ currentState.ignoreResume = true
64
+ syn.resume()
65
+ }
44
66
  }, 0)
45
67
 
46
- infinityTimer = setTimeout(() => {
47
- resumeInfinity(target)
48
- }, 5000)
68
+ // Check if utterance still exists before scheduling next cycle
69
+ if (utterances.has(id) === true) {
70
+ state.timer = setTimeout(() => {
71
+ // Double-check utterance still exists before resuming
72
+ if (utterances.has(id) === true) {
73
+ startKeepAlive(id)
74
+ }
75
+ }, 5000)
76
+ }
49
77
  }
50
78
 
51
79
  const defaultUtteranceProps = {
@@ -57,45 +85,124 @@ const defaultUtteranceProps = {
57
85
  }
58
86
 
59
87
  const initialize = () => {
88
+ // syn api check: syn might not have getVoices method
89
+ if (!syn || typeof syn.getVoices !== 'function') {
90
+ initialized = false
91
+ return
92
+ }
93
+
60
94
  const voices = syn.getVoices()
61
95
  defaultUtteranceProps.voice = voices[0] || null
62
96
  initialized = true
63
97
  }
64
98
 
65
- const speak = (options) => {
66
- const utterance = new SpeechSynthesisUtterance(options.message)
99
+ const waitForSynthReady = (timeoutMs = 2000, checkIntervalMs = 100) => {
100
+ return new Promise((resolve) => {
101
+ if (!syn) {
102
+ Log.debug('SpeechSynthesis - syn unavailable')
103
+ resolve()
104
+ return
105
+ }
106
+
107
+ if (!syn.speaking && !syn.pending) {
108
+ Log.debug('SpeechSynthesis - ready immediately')
109
+ resolve()
110
+ return
111
+ }
112
+
113
+ Log.debug('SpeechSynthesis - waiting for ready state...')
114
+
115
+ const startTime = Date.now()
116
+
117
+ const intervalId = setInterval(() => {
118
+ const elapsed = Date.now() - startTime
119
+ const isReady = !syn.speaking && !syn.pending
120
+
121
+ if (isReady) {
122
+ Log.debug(`SpeechSynthesis - ready after ${elapsed}ms`)
123
+ clearInterval(intervalId)
124
+ resolve()
125
+ } else if (elapsed >= timeoutMs) {
126
+ Log.debug(`SpeechSynthesis - timeout after ${elapsed}ms, forcing ready`, {
127
+ speaking: syn.speaking,
128
+ pending: syn.pending,
129
+ })
130
+ clearInterval(intervalId)
131
+ resolve()
132
+ }
133
+ }, checkIntervalMs)
134
+ })
135
+ }
136
+
137
+ const speak = async (options) => {
138
+ // options check: missing required options
139
+ if (!options || !options.message) {
140
+ return Promise.reject({ error: 'Missing message' })
141
+ }
142
+
143
+ // options check: missing or invalid id
67
144
  const id = options.id
145
+ if (id === undefined || id === null) {
146
+ return Promise.reject({ error: 'Missing id' })
147
+ }
148
+
149
+ // utterance status: utterance with same id already exists
150
+ if (utterances.has(id)) {
151
+ clear(id)
152
+ }
153
+
154
+ // Wait for engine to be ready
155
+ await waitForSynthReady()
156
+
157
+ const utterance = new SpeechSynthesisUtterance(options.message)
68
158
  utterance.lang = options.lang || defaultUtteranceProps.lang
69
159
  utterance.pitch = options.pitch || defaultUtteranceProps.pitch
70
160
  utterance.rate = options.rate || defaultUtteranceProps.rate
71
161
  utterance.voice = options.voice || defaultUtteranceProps.voice
72
162
  utterance.volume = options.volume || defaultUtteranceProps.volume
73
- utterances.set(id, utterance) // Strong reference
74
-
75
- if (isAndroid === false) {
76
- utterance.onstart = () => {
77
- resumeInfinity(utterance)
78
- }
79
163
 
80
- utterance.onresume = () => {
81
- resumeInfinity(utterance)
82
- }
83
- }
164
+ utterances.set(id, { utterance, timer: null, ignoreResume: false })
84
165
 
85
166
  return new Promise((resolve, reject) => {
86
- utterance.onend = () => {
87
- clear()
88
- utterances.delete(id) // Cleanup
89
- resolve()
167
+ utterance.onend = (result) => {
168
+ clear(id)
169
+ resolve(result)
90
170
  }
91
171
 
92
172
  utterance.onerror = (e) => {
93
- clear()
94
- utterances.delete(id) // Cleanup
95
- reject(e)
173
+ Log.warn('SpeechSynthesisUtterance error:', e)
174
+ clear(id)
175
+ resolve()
96
176
  }
97
177
 
98
- syn.speak(utterance)
178
+ if (options.enableUtteranceKeepAlive === true) {
179
+ utterance.onstart = () => {
180
+ // utterances status: check if utterance still exists
181
+ if (utterances.has(id)) {
182
+ startKeepAlive(id)
183
+ }
184
+ }
185
+
186
+ utterance.onresume = () => {
187
+ const state = utterances.get(id)
188
+ // utterance status: utterance might have been removed
189
+ if (!state) return
190
+
191
+ if (state.ignoreResume === true) {
192
+ state.ignoreResume = false
193
+ return
194
+ }
195
+
196
+ startKeepAlive(id)
197
+ }
198
+ }
199
+ // handle error: syn.speak might throw
200
+ try {
201
+ syn.speak(utterance)
202
+ } catch (error) {
203
+ clear(id)
204
+ reject(error)
205
+ }
99
206
  })
100
207
  }
101
208
 
@@ -113,8 +220,20 @@ export default {
113
220
  },
114
221
  cancel() {
115
222
  if (syn !== undefined) {
116
- syn.cancel()
117
- clear()
223
+ // timers: clear all timers before cancelling
224
+ for (const id of utterances.keys()) {
225
+ clear(id)
226
+ }
227
+
228
+ // handle errors: syn.cancel might throw
229
+ try {
230
+ syn.cancel()
231
+ } catch (error) {
232
+ Log.error('Error cancelling speech synthesis:', error)
233
+ }
234
+
235
+ // utterances status: ensure all utterances are cleaned up
236
+ utterances.clear()
118
237
  }
119
238
  },
120
239
  // @todo
@@ -100,6 +100,8 @@ export default {
100
100
  // pass focus to the parent so we don't get lost in focus limbo
101
101
  if (this.$hasFocus === true) this[symbols.parent].$focus()
102
102
 
103
+ // @todo - is this really necessary?
104
+ // This cause an issue with auto sizing of parent (and required an extra eol check there)
103
105
  for (let key in this[symbols.state]) {
104
106
  if (Array.isArray(this[symbols.state][key])) {
105
107
  this[symbols.state][key] = []
@@ -108,6 +110,8 @@ export default {
108
110
 
109
111
  this.$clearTimeouts()
110
112
  this.$clearIntervals()
113
+ this.$clearDebounces()
114
+
111
115
  eventListeners.removeListeners(this)
112
116
 
113
117
  const rendererEventListenersLength = this[symbols.rendererEventListeners].length
@@ -23,6 +23,12 @@ export default {
23
23
  value: {
24
24
  to,
25
25
  back,
26
+ get backNavigation() {
27
+ return state.backNavigation !== false
28
+ },
29
+ set backNavigation(enabled) {
30
+ state.backNavigation = enabled !== false
31
+ },
26
32
  get currentRoute() {
27
33
  return currentRoute
28
34
  },
@@ -96,4 +96,60 @@ export default {
96
96
  enumerable: true,
97
97
  configurable: false,
98
98
  },
99
+ $debounce: {
100
+ value: function (name, fn, ms, ...params) {
101
+ // early exit when component is marked as end of life
102
+ if (this.eol === true) return
103
+
104
+ // clear existing debounce for this name if it exists
105
+ const existing = this[symbols.debounces].get(name)
106
+ if (existing !== undefined) {
107
+ this.$clearTimeout(existing)
108
+ this[symbols.debounces].delete(name)
109
+ }
110
+
111
+ // create new timeout
112
+ const timeoutId = setTimeout(() => {
113
+ this[symbols.debounces].delete(name)
114
+ this.$clearTimeout(timeoutId)
115
+ fn.apply(this, params)
116
+ }, ms)
117
+
118
+ // track timeout in timeouts array for automatic cleanup
119
+ this[symbols.timeouts].push(timeoutId)
120
+
121
+ // store timeoutId per name to enable replace behavior and lifecycle cleanup
122
+ this[symbols.debounces].set(name, timeoutId)
123
+
124
+ return timeoutId
125
+ },
126
+ writable: false,
127
+ enumerable: true,
128
+ configurable: false,
129
+ },
130
+ $clearDebounce: {
131
+ value: function (name) {
132
+ const existing = this[symbols.debounces].get(name)
133
+ if (existing !== undefined) {
134
+ this.$clearTimeout(existing)
135
+ this[symbols.debounces].delete(name)
136
+ }
137
+ },
138
+ writable: false,
139
+ enumerable: true,
140
+ configurable: false,
141
+ },
142
+ $clearDebounces: {
143
+ value: function () {
144
+ // clear all timeouts associated with debounces
145
+ const timeoutIds = Array.from(this[symbols.debounces].values())
146
+ for (let i = 0; i < timeoutIds.length; i++) {
147
+ this.$clearTimeout(timeoutIds[i])
148
+ }
149
+ this[symbols.debounces].clear()
150
+ },
151
+ writable: false,
152
+ enumerable: true,
153
+ configurable: false,
154
+ },
99
155
  }
@@ -16,12 +16,17 @@
16
16
  */
17
17
 
18
18
  import symbols from '../../lib/symbols.js'
19
+ import { state as routerState } from '../../router/router.js'
19
20
 
20
21
  export default (component, data) => {
21
22
  let routes = data
22
23
  if (Array.isArray(data) === false) {
23
24
  component[symbols.routerHooks] = data.hooks
24
25
  routes = data.routes
26
+ // Set initial backNavigation value if provided in router config
27
+ if (data.backNavigation !== undefined) {
28
+ routerState.backNavigation = data.backNavigation !== false
29
+ }
25
30
  }
26
31
  component[symbols.routes] = []
27
32
 
package/src/component.js CHANGED
@@ -229,6 +229,9 @@ const Component = (name = required('name'), config = required('config')) => {
229
229
  // create an empty array for storing intervals created by this component (via this.$setInterval)
230
230
  this[symbols.intervals] = []
231
231
 
232
+ // create a Map for storing debounced functions (via this.$debounce)
233
+ this[symbols.debounces] = new Map()
234
+
232
235
  // apply the state function (passing in the this reference to utilize configured props)
233
236
  // and store a reference to this original state
234
237
  this[symbols.originalState] =
@@ -361,7 +364,7 @@ const Component = (name = required('name'), config = required('config')) => {
361
364
  }
362
365
  }
363
366
 
364
- let old = this[key]
367
+ let old = target[key]
365
368
 
366
369
  const eff = (force = false) => {
367
370
  const newValue = target[key]
@@ -456,6 +459,9 @@ const Component = (name = required('name'), config = required('config')) => {
456
459
  // during the code generation step
457
460
  factory[Symbol.for('config')] = config
458
461
 
462
+ // Display name for inspector
463
+ factory[Symbol.for('componentType')] = name
464
+
459
465
  // To determine whether dynamic component is actual Blits component or not
460
466
  factory[symbols.isComponent] = true
461
467
 
@@ -78,6 +78,7 @@ export default () =>
78
78
  let slotChildCounter = 0
79
79
  let cmps = []
80
80
  elementConfigs[0] = {}
81
+ elementConfigs[0]['___wrapper'] = true
81
82
 
82
83
  elms[0] = this.element(
83
84
  { parent: parent || 'root' },
@@ -80,6 +80,7 @@ export default () =>
80
80
  let slotChildCounter = 0
81
81
  let cmps = []
82
82
  elementConfigs[0] = {}
83
+ elementConfigs[0]['___wrapper'] = true
83
84
 
84
85
  elms[0] = this.element(
85
86
  { parent: parent || 'root' },
@@ -16,141 +16,157 @@
16
16
  */
17
17
 
18
18
  import Component from '../component.js'
19
- import Router from '../router/router.js'
19
+ import Router, { state as routerState } from '../router/router.js'
20
20
  import symbols from '../lib/symbols.js'
21
21
  import Focus from '../focus.js'
22
22
 
23
23
  let hashchangeHandler = null
24
24
 
25
+ /** @typedef {{ $input?: (event: any) => boolean, $focus?: (event: any) => void }} RouterViewParent */
26
+
25
27
  export default () =>
26
- Component('RouterView', {
27
- /* eslint-disable no-unused-vars */
28
- code: {
29
- render: function anonymous(parent, component, context, components, effect, getRaw, Log) {
30
- const elms = []
31
- const validAttributes = [
32
- 'parent',
33
- 'rotation',
34
- 'w',
35
- 'width',
36
- 'h',
37
- 'height',
38
- 'x',
39
- 'y',
40
- 'z',
41
- 'zIndex',
42
- 'color',
43
- 'src',
44
- 'texture',
45
- 'fit',
46
- 'rtt',
47
- 'mount',
48
- 'pivot',
49
- 'scale',
50
- 'show',
51
- 'alpha',
52
- 'rounded',
53
- 'border',
54
- 'shadow',
55
- 'shader',
56
- 'clipping',
57
- 'overflow',
58
- 'font',
59
- 'size',
60
- 'maxwidth',
61
- 'maxheight',
62
- 'maxlines',
63
- 'textoverflow',
64
- 'letterspacing',
65
- 'lineheight',
66
- 'contain',
67
- 'align',
68
- 'content',
69
- 'placement',
70
- 'inspector-data',
71
- ]
72
- const elementConfigs = []
73
- const forloops = []
74
- const props = []
75
- const created = []
76
- const effects = {}
77
- const skips = []
78
- let componentType
79
- let rootComponent = component
80
- let propData
81
- let slotComponent
82
- let inSlot = false
83
- let slotChildCounter = 0
84
- let cmps = []
85
- elementConfigs[0] = {}
28
+ Component(
29
+ 'RouterView',
30
+ /** @type {any} */ ({
31
+ /* eslint-disable no-unused-vars */
32
+ code: {
33
+ render: function anonymous(parent, component, context, components, effect, getRaw, Log) {
34
+ const elms = []
35
+ const validAttributes = [
36
+ 'parent',
37
+ 'rotation',
38
+ 'w',
39
+ 'width',
40
+ 'h',
41
+ 'height',
42
+ 'x',
43
+ 'y',
44
+ 'z',
45
+ 'zIndex',
46
+ 'color',
47
+ 'src',
48
+ 'texture',
49
+ 'fit',
50
+ 'rtt',
51
+ 'mount',
52
+ 'pivot',
53
+ 'scale',
54
+ 'show',
55
+ 'alpha',
56
+ 'rounded',
57
+ 'border',
58
+ 'shadow',
59
+ 'shader',
60
+ 'clipping',
61
+ 'overflow',
62
+ 'font',
63
+ 'size',
64
+ 'maxwidth',
65
+ 'maxheight',
66
+ 'maxlines',
67
+ 'textoverflow',
68
+ 'letterspacing',
69
+ 'lineheight',
70
+ 'contain',
71
+ 'align',
72
+ 'content',
73
+ 'placement',
74
+ 'inspector-data',
75
+ ]
76
+ const elementConfigs = []
77
+ const forloops = []
78
+ const props = []
79
+ const created = []
80
+ const effects = {}
81
+ const skips = []
82
+ let componentType
83
+ let rootComponent = component
84
+ let propData
85
+ let slotComponent
86
+ let inSlot = false
87
+ let slotChildCounter = 0
88
+ let cmps = []
89
+ elementConfigs[0] = {}
90
+ elementConfigs[0]['___wrapper'] = true
86
91
 
87
- elms[0] = this.element(
88
- { parent: parent || 'root' },
89
- inSlot === true ? slotComponent : component
90
- )
92
+ elms[0] = this.element(
93
+ { parent: parent || 'root' },
94
+ inSlot === true ? slotComponent : component
95
+ )
91
96
 
92
- elementConfigs[0]['w'] = parent.node.w * (100 / 100)
93
- elementConfigs[0]['h'] = parent.node.h * (100 / 100)
94
- elms[0].populate(elementConfigs[0])
97
+ elementConfigs[0]['w'] = parent.node.w * (100 / 100)
98
+ elementConfigs[0]['height'] = parent.node.h * (100 / 100)
99
+ elms[0].populate(elementConfigs[0])
95
100
 
96
- if (inSlot === true) {
97
- slotChildCounter -= 1
98
- }
101
+ if (inSlot === true) {
102
+ slotChildCounter -= 1
103
+ }
99
104
 
100
- return {
101
- elms,
102
- cleanup: () => {
103
- rootComponent = null
104
- propData = null
105
- slotComponent = null
106
- parent = null
107
- component = null
108
- cmps.length = 0
109
- elms.length = 0
110
- components.length = 0
111
- elementConfigs.length = 0
112
- forloops.length = 0
113
- props.length = 0
114
- skips.length = 0
115
- },
116
- skips,
117
- }
105
+ return {
106
+ elms,
107
+ cleanup: () => {
108
+ rootComponent = null
109
+ propData = null
110
+ slotComponent = null
111
+ parent = null
112
+ component = null
113
+ cmps.length = 0
114
+ elms.length = 0
115
+ components.length = 0
116
+ elementConfigs.length = 0
117
+ forloops.length = 0
118
+ props.length = 0
119
+ skips.length = 0
120
+ },
121
+ skips,
122
+ }
123
+ },
124
+ effects: [],
125
+ context: {},
118
126
  },
119
- effects: [],
120
- context: {},
121
- },
122
- state() {
123
- return {
124
- activeView: null,
125
- }
126
- },
127
- hooks: {
128
- async ready() {
129
- if (
130
- this[symbols.parent][symbols.routerHooks] &&
131
- this[symbols.parent][symbols.routerHooks].init
132
- ) {
133
- await this[symbols.parent][symbols.routerHooks].init.apply(this[symbols.parent])
127
+ state() {
128
+ return {
129
+ activeView: null,
134
130
  }
135
- hashchangeHandler = () => Router.navigate.apply(this)
136
- Router.navigate.apply(this)
137
- window.addEventListener('hashchange', hashchangeHandler)
138
- },
139
- destroy() {
140
- window.removeEventListener('hashchange', hashchangeHandler, false)
141
131
  },
142
- focus() {
143
- if (this.activeView && Focus.get() === this) {
144
- this.activeView.$focus()
145
- }
132
+ hooks: {
133
+ async ready() {
134
+ const parent = this[symbols.parent]
135
+ if (parent && parent[symbols.routerHooks] && parent[symbols.routerHooks].init) {
136
+ await parent[symbols.routerHooks].init.apply(parent)
137
+ }
138
+
139
+ hashchangeHandler = () => Router.navigate.apply(this)
140
+ Router.navigate.apply(this)
141
+ window.addEventListener('hashchange', hashchangeHandler)
142
+ },
143
+ destroy() {
144
+ window.removeEventListener('hashchange', hashchangeHandler, false)
145
+ },
146
+ focus() {
147
+ if (this.activeView && Focus.get() === this) {
148
+ this.activeView.$focus()
149
+ }
150
+ },
146
151
  },
147
- },
148
- input: {
149
- back(e) {
150
- const navigating = Router.back.call(this)
151
- if (navigating === false) {
152
- this[symbols.parent].$focus(e)
153
- }
152
+ input: {
153
+ back(e) {
154
+ const parent = this[symbols.parent]
155
+
156
+ if (routerState.backNavigation === false) {
157
+ if (parent && parent.$input) {
158
+ parent.$input(e)
159
+ }
160
+ return
161
+ }
162
+
163
+ const navigating = Router.back.call(this)
164
+ if (navigating === false) {
165
+ if (parent && parent.$focus) {
166
+ parent.$focus(e)
167
+ }
168
+ }
169
+ },
154
170
  },
155
- },
156
- })
171
+ })
172
+ )