node-red-contrib-room-tools 1.9.3 → 1.9.5

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.
@@ -6,6 +6,12 @@ function initializeVisualBinding () {
6
6
  const root = this.$el
7
7
  const base = config.editorEndpoint
8
8
  const saved = new Map(Object.entries(config.bindings || {}))
9
+ const bindingsByLocator = new Map()
10
+ for (const entry of saved) {
11
+ const locator = entry[1].locator
12
+ if (!bindingsByLocator.has(locator)) bindingsByLocator.set(locator, [])
13
+ bindingsByLocator.get(locator).push(entry)
14
+ }
9
15
  const meterEntries = [...saved].filter(([, entry]) => entry.readMeta?.meter)
10
16
  const xySeriesEntries = [...saved].filter(([, entry]) => entry.readMeta?.xySeries)
11
17
  const meterTargets = new Map()
@@ -13,25 +19,100 @@ function initializeVisualBinding () {
13
19
  let meterClient = null, lastMeterFrame = null
14
20
  let xySeriesClient = null, lastXYSeriesFrame = null
15
21
  const attached = new WeakSet()
22
+ const controlListeners = new Map()
16
23
  const buttonPresses = new Map()
17
24
  const momentaryButtons = new Map()
18
25
  const momentaryStates = new Map()
19
26
  const feedbackModels = new Map()
20
27
  const pendingText = new Map()
28
+ const disabledNodes = new Map()
29
+ const blockedTelemetry = new Map()
21
30
  const cleanup = []
22
31
  let disposed = false
23
32
  cleanup.push(() => { disposed = true })
24
33
  let editing = new URL(window.location.href).searchParams.get('cp-edit') === config.panelId
34
+
35
+ function deviceAvailable (id) {
36
+ return editing || component.__deviceAvailable?.(id) !== false
37
+ }
38
+ function sourceVersion (frame, binding) {
39
+ const stream = binding.readMeta?.meter || binding.readMeta?.xySeries
40
+ const source = frame?.sources?.find(item => item.device === binding.device && item.streamId === stream?.streamId)
41
+ return source ? `${source.epoch}:${source.seq}` : frame ? `legacy:${frame.seq}` : null
42
+ }
43
+ function setAvailable (node, available) {
44
+ available = editing || available
45
+ if (!available) {
46
+ if (!disabledNodes.has(node)) disabledNodes.set(node, {
47
+ inert: node.hasAttribute('inert'), aria: node.getAttribute('aria-disabled')
48
+ })
49
+ if (node.getAttribute('data-cpv-unavailable') !== 'true') node.setAttribute('data-cpv-unavailable', 'true')
50
+ if (!node.hasAttribute('inert')) node.setAttribute('inert', '')
51
+ if (node.getAttribute('aria-disabled') !== 'true') node.setAttribute('aria-disabled', 'true')
52
+ } else {
53
+ const original = disabledNodes.get(node)
54
+ if (!original) return
55
+ node.removeAttribute('data-cpv-unavailable')
56
+ if (!original.inert) node.removeAttribute('inert')
57
+ if (original.aria === null) node.removeAttribute('aria-disabled')
58
+ else node.setAttribute('aria-disabled', original.aria)
59
+ disabledNodes.delete(node)
60
+ }
61
+ }
62
+ function refreshAvailability () {
63
+ for (const [id, binding] of saved) {
64
+ const node = nodeForBinding(binding)
65
+ if (!node) continue
66
+ if (!deviceAvailable(id) && (binding.readMeta?.meter || binding.readMeta?.xySeries)) {
67
+ const latest = binding.readMeta.meter ? lastMeterFrame : lastXYSeriesFrame
68
+ if (!blockedTelemetry.has(id)) blockedTelemetry.set(id, sourceVersion(latest?.frame, binding))
69
+ node.setAttribute(binding.readMeta.meter ? 'data-cpv-meter-valid' : 'data-cpv-xy-series-valid', 'false')
70
+ }
71
+ const telemetryValid = !binding.readMeta?.meter && !binding.readMeta?.xySeries ||
72
+ node.getAttribute(binding.readMeta?.meter ? 'data-cpv-meter-valid' : 'data-cpv-xy-series-valid') === 'true'
73
+ setAvailable(node, deviceAvailable(id) && telemetryValid)
74
+ }
75
+ for (const [node] of disabledNodes) if (!root.contains(node)) setAvailable(node, true)
76
+ for (const button of buttonPresses.values()) button.controller.check()
77
+ for (const button of momentaryButtons.values()) button.controller.check()
78
+ }
25
79
  let editSession = null
26
80
  let snapshot = null
27
81
  let draft = []
28
82
  let devices = []
29
83
  let selecting = false
30
84
  let observer
85
+ let nodeIndex = null
31
86
  let toolbar
32
87
  let overlay
88
+ let overlayCleanup
33
89
  let status
34
90
  let loginScreen
91
+ const resumeKey = `cpv-editor-resume:${config.panelId}`
92
+ const resumeDataKey = `cpv-editor-resume-data:${config.panelId}`
93
+
94
+ function resumePending () {
95
+ try { return window.sessionStorage?.getItem(resumeKey) === '1' } catch (_) { return false }
96
+ }
97
+ function markResumePending (pending) {
98
+ try {
99
+ if (pending) window.sessionStorage?.setItem(resumeKey, '1')
100
+ else window.sessionStorage?.removeItem(resumeKey)
101
+ } catch (_) {}
102
+ }
103
+ function readResumeData () {
104
+ if (!resumePending()) return null
105
+ try {
106
+ const value = JSON.parse(window.sessionStorage?.getItem(resumeDataKey) || 'null')
107
+ return value?.editorConfig && Array.isArray(value.devices) ? value : null
108
+ } catch (_) { return null }
109
+ }
110
+ function clearResumeData () {
111
+ try { window.sessionStorage?.removeItem(resumeDataKey) } catch (_) {}
112
+ }
113
+ function wait (milliseconds) {
114
+ return new Promise(resolve => setTimeout(resolve, milliseconds))
115
+ }
35
116
 
36
117
  function element (tag, text, className) {
37
118
  const node = document.createElement(tag)
@@ -52,6 +133,10 @@ function initializeVisualBinding () {
52
133
  node.addEventListener('click', action)
53
134
  return node
54
135
  }
136
+ function listen (node, name, action, capture = false, stops = cleanup) {
137
+ node.addEventListener(name, action, capture)
138
+ stops.push(() => node.removeEventListener(name, action, capture))
139
+ }
55
140
  function inform (message, error = false) {
56
141
  if (status) {
57
142
  status.textContent = message || ''
@@ -67,10 +152,14 @@ function initializeVisualBinding () {
67
152
  body: method === 'POST' ? JSON.stringify(body || {}) : undefined
68
153
  }, path === '/apply' ? 120000 : (timeoutMs ?? 12000),
69
154
  async response => ({ response, result: await response.json() }))
70
- if (!response.ok) throw new Error(result.error || `Request failed (${response.status})`)
155
+ if (!response.ok) {
156
+ const error = new Error(result.error || `Request failed (${response.status})`)
157
+ error.status = response.status
158
+ throw error
159
+ }
71
160
  return result
72
161
  }
73
- function numericRange (binding, node) {
162
+ function numericRange (binding, node) {
74
163
  if (binding.uiRange) return binding.uiRange
75
164
  const tag = node.tagName?.toLowerCase()
76
165
  const type = node.type?.toLowerCase()
@@ -130,7 +219,30 @@ function initializeVisualBinding () {
130
219
  return repeatMarkerFor(node, binding.repeat) === String(binding.instanceKey)
131
220
  }
132
221
  function nodesForBinding (binding) {
133
- return [...root.querySelectorAll('[data-cp-auto]')].filter(node => bindingMatchesNode(node, binding))
222
+ if (!nodeIndex) {
223
+ nodeIndex = new Map()
224
+ for (const node of root.querySelectorAll('[data-cp-auto]')) {
225
+ const locator = node.getAttribute('data-cp-auto')
226
+ if (!nodeIndex.has(locator)) nodeIndex.set(locator, { nodes: [], repeats: new Map() })
227
+ nodeIndex.get(locator).nodes.push(node)
228
+ }
229
+ }
230
+ const group = nodeIndex.get(binding.locator)
231
+ if (!group) return []
232
+ let nodes = group.nodes
233
+ if (binding.repeat) {
234
+ if (!group.repeats.has(binding.repeat.locator)) {
235
+ const keys = new Map()
236
+ for (const node of nodes) {
237
+ const key = repeatMarkerFor(node, binding.repeat)
238
+ if (!keys.has(key)) keys.set(key, [])
239
+ keys.get(key).push(node)
240
+ }
241
+ group.repeats.set(binding.repeat.locator, keys)
242
+ }
243
+ nodes = group.repeats.get(binding.repeat.locator).get(String(binding.instanceKey)) || []
244
+ }
245
+ return nodes.filter(node => root.contains(node))
134
246
  }
135
247
  function nodeForBinding (binding) {
136
248
  const matches = nodesForBinding(binding)
@@ -195,6 +307,7 @@ function initializeVisualBinding () {
195
307
  }
196
308
  function showFeedback () {
197
309
  for (const [id, binding] of saved) {
310
+ if (!deviceAvailable(id)) continue
198
311
  if (binding.mode === 'write' || binding.readMeta?.meter || binding.readMeta?.xySeries) continue
199
312
  const node = nodeForBinding(binding)
200
313
  if (!node) continue
@@ -238,6 +351,17 @@ function initializeVisualBinding () {
238
351
  }
239
352
  }
240
353
  function attachControls () {
354
+ if (disposed) return
355
+ nodeIndex = null
356
+ for (const [node, listeners] of controlListeners) {
357
+ const binding = saved.get(listeners.id)
358
+ if (!binding || nodeForBinding(binding) !== node) {
359
+ for (const stop of listeners.stops) stop()
360
+ controlListeners.delete(node)
361
+ pendingText.delete(listeners.id)
362
+ attached.delete(node)
363
+ }
364
+ }
241
365
  for (const [node, button] of buttonPresses) {
242
366
  const binding = saved.get(button.id)
243
367
  if (!binding || nodeForBinding(binding) !== node) {
@@ -256,16 +380,18 @@ function initializeVisualBinding () {
256
380
  }
257
381
  for (const node of root.querySelectorAll('[data-cp-auto]')) {
258
382
  if (attached.has(node)) continue
259
- const locator = node.getAttribute('data-cp-auto')
260
- const matched = [...saved].find(([, entry]) => bindingMatchesNode(node, entry))
383
+ const matched = bindingsByLocator.get(node.getAttribute('data-cp-auto'))
384
+ ?.find(([, entry]) => bindingMatchesNode(node, entry))
261
385
  if (!matched) continue
262
386
  const [id, binding] = matched
263
387
  if (nodeForBinding(binding) !== node) continue
264
388
  attached.add(node)
265
389
  if (binding.mode === 'read') continue
390
+ const stops = []
391
+ controlListeners.set(node, { id, stops })
266
392
  if (node.tagName === 'BUTTON' && binding.adapter === 'switch') {
267
393
  buttonPresses.set(node, { id, controller: component.__attachButtonPress({ node,
268
- canPress: () => !disposed && !editing && component.boundAuthenticated && component.boundFeedbackConnected }) })
394
+ canPress: () => !disposed && !editing && component.boundAuthenticated && component.boundFeedbackConnected && deviceAvailable(id) }) })
269
395
  }
270
396
  if (binding.buttonBehavior === 'momentary' && node.tagName === 'BUTTON') {
271
397
  if (!momentaryStates.has(id)) momentaryStates.set(id, { seq: 0 })
@@ -273,7 +399,7 @@ function initializeVisualBinding () {
273
399
  state: momentaryStates.get(id),
274
400
  endpoint: component.__boundPanelConfig().endpoint,
275
401
  getSession: () => component.__boundPanelSession,
276
- canStart: () => !disposed && !editing && component.boundAuthenticated && component.boundFeedbackConnected,
402
+ canStart: () => !disposed && !editing && component.boundAuthenticated && component.boundFeedbackConnected && deviceAvailable(id),
277
403
  request: (...args) => component.__panelFetch(...args),
278
404
  onError: error => console.error('ControlPanel momentary command failed', error) })
279
405
  momentaryButtons.set(node, { id, controller })
@@ -282,7 +408,8 @@ function initializeVisualBinding () {
282
408
  if (binding.component) {
283
409
  if (binding.adapter === 'text-input') {
284
410
  const commit = () => {
285
- if (!pendingText.has(id) || editing || !component.boundAuthenticated) return
411
+ if (disposed || !pendingText.has(id) || editing || !component.boundAuthenticated || !deviceAvailable(id) ||
412
+ nodeForBinding(binding) !== node) return
286
413
  const value = pendingText.get(id)
287
414
  pendingText.delete(id)
288
415
  try { component.sendBoundControl({ id, value, final: true }) } catch (error) {
@@ -293,61 +420,71 @@ function initializeVisualBinding () {
293
420
  if (component.$nextTick) void component.$nextTick(commit)
294
421
  else commit()
295
422
  }
296
- node.addEventListener('change', afterModelUpdate)
297
- node.addEventListener('focusout', afterModelUpdate)
423
+ listen(node, 'change', afterModelUpdate, false, stops)
424
+ listen(node, 'focusout', afterModelUpdate, false, stops)
298
425
  if (binding.component === 'v-text-field') {
299
- node.addEventListener('keydown', event => {
426
+ listen(node, 'keydown', event => {
300
427
  if (event.key === 'Enter') afterModelUpdate()
301
- })
428
+ }, false, stops)
302
429
  }
303
430
  }
304
431
  continue
305
432
  }
306
433
  const send = final => {
307
- if (editing || !component.boundAuthenticated) return
434
+ if (disposed || editing || !component.boundAuthenticated || !deviceAvailable(id) || nodeForBinding(binding) !== node) return
308
435
  try { component.sendBoundControl({ id, value: valueFor(binding, node), final }) } catch (error) {
309
436
  console.error('ControlPanel binding command failed', error)
310
437
  }
311
438
  }
312
439
  if (binding.adapter === 'range') {
313
- node.addEventListener('input', () => send(false), true)
314
- node.addEventListener('change', () => send(true), true)
440
+ listen(node, 'input', () => send(false), true, stops)
441
+ listen(node, 'change', () => send(true), true, stops)
315
442
  } else if (binding.adapter === 'switch' && node.tagName === 'BUTTON') {
316
- node.addEventListener('click', event => {
443
+ listen(node, 'click', event => {
317
444
  event.preventDefault()
318
445
  send(true)
319
- })
320
- } else node.addEventListener('change', () => send(true))
446
+ }, false, stops)
447
+ } else listen(node, 'change', () => send(true), false, stops)
321
448
  }
322
449
  if (meterEntries.length) {
450
+ const previous = new Map(meterTargets)
323
451
  meterTargets.clear()
324
452
  for (const [id, binding] of meterEntries) {
325
453
  const node = nodeForBinding(binding)
326
- if (node) meterTargets.set(id, { node, binding })
454
+ if (node) meterTargets.set(id, previous.get(id)?.node === node ? previous.get(id) : { node, binding })
327
455
  }
328
456
  if (lastMeterFrame) applyMeterFrame(lastMeterFrame.frame, lastMeterFrame.ids)
329
457
  else invalidateMeters()
330
458
  }
331
459
  if (xySeriesEntries.length) {
460
+ const previous = new Map(xySeriesTargets)
332
461
  xySeriesTargets.clear()
333
462
  for (const [id, binding] of xySeriesEntries) {
334
463
  const node = nodeForBinding(binding)
335
- if (node) xySeriesTargets.set(id, { node, binding })
464
+ if (node) xySeriesTargets.set(id, previous.get(id)?.node === node ? previous.get(id) : { node, binding })
336
465
  }
337
466
  if (lastXYSeriesFrame) applyXYSeriesFrame(lastXYSeriesFrame.frame, lastXYSeriesFrame.ids)
338
467
  else invalidateXYSeries()
339
468
  }
340
469
  showFeedback()
470
+ refreshAvailability()
341
471
  }
342
472
  function applyMeterFrame (frame, ids) {
343
473
  lastMeterFrame = { frame, ids }
344
474
  for (let index = 0; index < ids.length; index++) {
345
475
  const target = meterTargets.get(ids[index])
346
- if (!target) continue
476
+ if (!target || target.frame === frame) continue
477
+ target.frame = frame
347
478
  const { node, binding } = target
348
- node.setAttribute('data-cpv-meter-valid', String(frame.valid[index]))
349
- node.setAttribute('aria-invalid', String(!frame.valid[index]))
350
- if (!frame.valid[index]) continue
479
+ const id = ids[index], version = sourceVersion(frame, binding)
480
+ if (!deviceAvailable(id)) blockedTelemetry.set(id, version)
481
+ const valid = frame.valid[index] && deviceAvailable(id) &&
482
+ (!blockedTelemetry.has(id) || blockedTelemetry.get(id) !== version)
483
+ node.setAttribute('data-cpv-meter-valid', String(valid))
484
+ node.setAttribute('aria-invalid', String(!valid))
485
+ setAvailable(node, valid)
486
+ if (!valid) continue
487
+ blockedTelemetry.delete(id)
351
488
  const value = mappedValue(frame.values[index], binding.readMeta, numericRange(binding, node))
352
489
  if (binding.model) {
353
490
  if (binding.repeat) setRepeatModel(binding, value)
@@ -360,17 +497,30 @@ function initializeVisualBinding () {
360
497
  for (const { node } of meterTargets.values()) {
361
498
  node.setAttribute('data-cpv-meter-valid', 'false')
362
499
  node.setAttribute('aria-invalid', 'true')
500
+ setAvailable(node, false)
363
501
  }
364
502
  }
365
503
  function applyXYSeriesFrame (frame, ids) {
366
504
  lastXYSeriesFrame = { frame, ids }
367
505
  for (let index = 0; index < ids.length; index++) {
368
506
  const target = xySeriesTargets.get(ids[index])
369
- if (!target) continue
507
+ if (!target || target.frame === frame) continue
508
+ target.frame = frame
509
+ target.invalidated = false
370
510
  const { node, binding } = target
511
+ const id = ids[index], version = sourceVersion(frame, binding)
512
+ if (!deviceAvailable(id)) blockedTelemetry.set(id, version)
513
+ if (!deviceAvailable(id) || (blockedTelemetry.has(id) && blockedTelemetry.get(id) === version)) {
514
+ node.setAttribute('data-cpv-xy-series-valid', 'false')
515
+ setAvailable(node, false)
516
+ continue
517
+ }
518
+ if (frame.valid[index]) blockedTelemetry.delete(id)
371
519
  node.setAttribute('data-cpv-xy-series-valid', String(frame.valid[index]))
520
+ node.setAttribute('aria-invalid', String(!frame.valid[index]))
521
+ setAvailable(node, frame.valid[index])
372
522
  node.setAttribute('data-cpv-xy-series-seq', String(frame.seq))
373
- const points = frame.valid[index] ? frame.values[index].map(point => point.slice()) : []
523
+ const points = frame.valid[index] ? frame.values[index].map(point => [point[0], point[1]]) : []
374
524
  if (binding.model) {
375
525
  if (binding.repeat) setRepeatModel(binding, points)
376
526
  else if (binding.model in component && !component.$options?.computed?.[binding.model]) component[binding.model] = points
@@ -379,9 +529,15 @@ function initializeVisualBinding () {
379
529
  }
380
530
  function invalidateXYSeries () {
381
531
  lastXYSeriesFrame = null
382
- for (const { node, binding } of xySeriesTargets.values()) {
532
+ for (const [id, target] of xySeriesTargets) {
533
+ if (target.invalidated) continue
534
+ target.invalidated = true
535
+ target.frame = null
536
+ const { node, binding } = target
383
537
  node.setAttribute('data-cpv-xy-series-valid', 'false')
384
538
  node.setAttribute('aria-invalid', 'true')
539
+ setAvailable(node, false)
540
+ if (!deviceAvailable(id)) continue
385
541
  if (binding.model) {
386
542
  if (binding.repeat) setRepeatModel(binding, [])
387
543
  else if (binding.model in component && !component.$options?.computed?.[binding.model]) component[binding.model] = []
@@ -393,7 +549,7 @@ function initializeVisualBinding () {
393
549
  if (!binding.component || !binding.model || binding.mode === 'read') continue
394
550
  let finishTimer
395
551
  const send = (value, final) => {
396
- if (editing || !component.boundAuthenticated) return
552
+ if (disposed || editing || !component.boundAuthenticated || !deviceAvailable(id)) return
397
553
  try {
398
554
  let commandValue
399
555
  if (binding.adapter === 'range') {
@@ -432,7 +588,7 @@ function initializeVisualBinding () {
432
588
  feedbackModels.delete(id)
433
589
  if (Object.is(value, feedbackValue)) return
434
590
  }
435
- if (editing || !component.boundAuthenticated) return
591
+ if (disposed || editing || !component.boundAuthenticated || !deviceAvailable(id)) return
436
592
  if (binding.adapter === 'text-input') {
437
593
  if (typeof value === 'string' || value == null) pendingText.set(id, value ?? '')
438
594
  return
@@ -446,7 +602,12 @@ function initializeVisualBinding () {
446
602
  cleanup.push(() => { clearTimeout(finishTimer); unwatch() })
447
603
  }
448
604
  }
449
- function closeOverlay () { overlay?.remove(); overlay = null }
605
+ function closeOverlay () {
606
+ overlayCleanup?.()
607
+ overlayCleanup = null
608
+ overlay?.remove()
609
+ overlay = null
610
+ }
450
611
  function sameBindingTarget (entry, candidate) {
451
612
  return entry.locator === candidate.locator &&
452
613
  (!candidate.repeat || String(entry.instanceKey) === String(candidate.instanceKey))
@@ -484,14 +645,13 @@ function initializeVisualBinding () {
484
645
  const outside = event => {
485
646
  if (!overlay?.contains(event.target)) {
486
647
  closeOverlay()
487
- document.removeEventListener('pointerdown', outside, true)
488
648
  }
489
649
  }
490
650
  const timer = setTimeout(() => document.addEventListener('pointerdown', outside, true), 0)
491
- cleanup.push(() => {
651
+ overlayCleanup = () => {
492
652
  clearTimeout(timer)
493
653
  document.removeEventListener('pointerdown', outside, true)
494
- })
654
+ }
495
655
  }
496
656
  function showDialog (title) {
497
657
  closeOverlay()
@@ -553,9 +713,9 @@ function initializeVisualBinding () {
553
713
  submit.setAttribute('aria-busy', 'true')
554
714
  message.textContent = ''
555
715
  try {
556
- await login(user.value, password.value)
716
+ const loaded = await login(user.value, password.value)
557
717
  if (disposed) return
558
- closeLoginScreen()
718
+ if (loaded !== false) closeLoginScreen()
559
719
  } catch (error) {
560
720
  message.textContent = error.message
561
721
  submit.textContent = 'Log in'
@@ -576,17 +736,20 @@ function initializeVisualBinding () {
576
736
  if (!response.ok) throw new Error(result.error || 'Login failed')
577
737
  if (disposed) return
578
738
  editSession = result
579
- await loadEditor()
739
+ markResumePending(false)
740
+ return startEditor()
580
741
  }, true)
581
742
  }
582
743
  function normalLoginView () {
583
744
  showLoginForm('Sign in', (username, password) => component.loginBoundPanel(username, password))
584
745
  }
585
746
  async function loadEditor () {
586
- snapshot = await request('/config')
587
- if (disposed) return
588
- const response = await request('/devices')
747
+ const resumed = readResumeData()
748
+ const [config, response] = resumed
749
+ ? [resumed.editorConfig, { devices: resumed.devices }]
750
+ : await Promise.all([request('/config'), request('/devices')])
589
751
  if (disposed) return
752
+ snapshot = config
590
753
  devices = response.devices
591
754
  draft = snapshot.bindings.map(entry => ({ ...entry, onebased: numberingMode(entry) }))
592
755
  closeOverlay()
@@ -595,6 +758,58 @@ function initializeVisualBinding () {
595
758
  inform('Choose a control on the page')
596
759
  updateBindingMarkers()
597
760
  meterClient?.setAuth({ endpoint: base, csrf: editSession.csrf, header: 'X-Panel-CSRF' })
761
+ xySeriesClient?.setAuth({ endpoint: base, csrf: editSession.csrf, header: 'X-Panel-CSRF' })
762
+ closeLoginScreen()
763
+ clearResumeData()
764
+ }
765
+ function loadingError (error, editor, retry) {
766
+ if (disposed) return
767
+ const content = coverPage('Unable to load panel', editor ? 'editor' : 'panel', 'error')
768
+ const message = element('p', error.message, 'cpv-message')
769
+ message.setAttribute('role', 'alert')
770
+ content.append(message, button('Retry', () => {
771
+ coverPage('Loading...', editor ? 'editor' : 'panel')
772
+ retry()
773
+ }))
774
+ }
775
+ async function startEditor () {
776
+ try { await loadEditor(); return true } catch (error) {
777
+ if (disposed) return false
778
+ if (error.status === 401) { editSession = null; loginView() }
779
+ else loadingError(error, true, startEditor)
780
+ return false
781
+ }
782
+ }
783
+ async function restoreEditor () {
784
+ const resumingAfterApply = resumePending()
785
+ const attempts = resumingAfterApply ? 25 : 1
786
+ let lastError
787
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
788
+ try {
789
+ const session = await request('/session')
790
+ if (disposed) return
791
+ editSession = session
792
+ const loaded = await startEditor()
793
+ if (loaded) markResumePending(false)
794
+ return
795
+ } catch (error) {
796
+ lastError = error
797
+ if (disposed) return
798
+ if (!resumingAfterApply || attempt + 1 >= attempts) break
799
+ await wait(Math.min(250 + attempt * 250, 1000))
800
+ }
801
+ }
802
+ if (disposed) return
803
+ if (lastError?.status === 401) loginView()
804
+ else loadingError(lastError || new Error('Editor session unavailable'), true, restoreEditor)
805
+ }
806
+ async function restorePanel () {
807
+ try {
808
+ const session = await component.restoreBoundPanelSession(12000)
809
+ if (disposed) return
810
+ if (session || component.boundAuthenticated) closeLoginScreen()
811
+ else normalLoginView()
812
+ } catch (error) { loadingError(error, false, restorePanel) }
598
813
  }
599
814
  function choose (label, options, selected) {
600
815
  const select = element('select')
@@ -627,8 +842,7 @@ function initializeVisualBinding () {
627
842
  ? '.' + String(candidate.instanceKey).replace(/[^A-Za-z0-9._/-]/g, '-').slice(0, 48)
628
843
  : ''
629
844
  id.value = existing?.id || 'control.' + candidate.locator.slice(-12) + instanceSuffix
630
- const modeOptions = ['meter', 'text-display'].includes(candidate.adapter)
631
- ? ['read'] : ['both', 'write', 'read']
845
+ const modeOptions = matcher.modes(candidate.adapter)
632
846
  const mode = field(form, 'Mode', choose('Mode', modeOptions.map(value => ({ value, text: value })), existing?.mode || modeOptions[0]))
633
847
  const behavior = candidate.button ? field(form, 'Button behavior', choose('Button behavior', [
634
848
  { value: 'toggle', text: 'Click to toggle / 点击切换' },
@@ -645,6 +859,22 @@ function initializeVisualBinding () {
645
859
  .map(item => ({ value: item.id, text: item.id })), existing?.device))
646
860
  const write = field(form, 'Write point', choose('Write point', [], existing?.writePath))
647
861
  const read = field(form, 'Feedback point', choose('Feedback point', [], existing?.readPath || existing?.writePath))
862
+ const reverse = candidate.adapter === 'switch' ? field(form, 'State reverse', element('input')) : null
863
+ if (reverse) {
864
+ reverse.type = 'checkbox'
865
+ reverse.setAttribute('aria-label', 'State reverse')
866
+ reverse.checked = matcher.reverse(existing)
867
+ reverse.closest('label').classList.add('cpv-state-reverse')
868
+ }
869
+ function updateReverse () {
870
+ if (!reverse) return
871
+ const active = [mode.value !== 'read' && write.value, mode.value !== 'write' && read.value].filter(Boolean)
872
+ reverse.disabled = !active.length || active.some(path => {
873
+ const point = targets.find(target => target.path === path)
874
+ return !point || !matcher.states(point)
875
+ })
876
+ reverse.closest('label').dataset.disabled = String(reverse.disabled)
877
+ }
648
878
  const numbering = field(form, 'Numbering', choose('Numbering', [
649
879
  { value: 'false', text: 'Zero-based' }, { value: 'true', text: 'One-based' }
650
880
  ], String(numberingMode(existing))))
@@ -682,6 +912,7 @@ function initializeVisualBinding () {
682
912
  : mode.value === 'write' ? writeCount : Math.min(writeCount, readCount)
683
913
  message.textContent = targets.length && !available ? 'No compatible points for this control'
684
914
  : targets.length ? '' : 'No points available'
915
+ updateReverse()
685
916
  }
686
917
  device.addEventListener('change', async () => {
687
918
  const generation = ++targetGeneration
@@ -703,7 +934,9 @@ function initializeVisualBinding () {
703
934
  })
704
935
  write.addEventListener('change', () => {
705
936
  if (mode.value === 'both') read.value = write.value
937
+ updateReverse()
706
938
  })
939
+ read.addEventListener('change', updateReverse)
707
940
  mode.addEventListener('change', updateTargets)
708
941
  behavior?.addEventListener('change', () => { updateButtonMode(); updateTargets() })
709
942
  updateTargets()
@@ -737,6 +970,7 @@ function initializeVisualBinding () {
737
970
  label: name.value.trim(), mode: mode.value, device: device.value,
738
971
  writePath: mode.value === 'read' ? '' : write.value,
739
972
  readPath: mode.value === 'write' ? '' : read.value,
973
+ ...(reverse ? { stateReverse: reverse.checked } : {}),
740
974
  onebased: numbering.value === 'true'
741
975
  }
742
976
  draft = draft.filter(item => !sameBindingTarget(item, candidate))
@@ -767,7 +1001,12 @@ function initializeVisualBinding () {
767
1001
  try {
768
1002
  await request('/apply', 'POST', {
769
1003
  rev: snapshot.rev, templateHash: snapshot.templateHash, entries: draft
1004
+ }).then(result => {
1005
+ try {
1006
+ window.sessionStorage?.setItem(resumeDataKey, JSON.stringify(result))
1007
+ } catch (_) {}
770
1008
  })
1009
+ markResumePending(true)
771
1010
  window.location.reload()
772
1011
  } catch (error) {
773
1012
  inform(error.message, true)
@@ -869,7 +1108,7 @@ function initializeVisualBinding () {
869
1108
  return candidate
870
1109
  }
871
1110
  function chooseCandidate (event) {
872
- if (!editing || !editSession) return
1111
+ if (disposed || !editing || !editSession) return
873
1112
  const candidate = candidateFromEvent(event)
874
1113
  if (!candidate) return
875
1114
  event.preventDefault()
@@ -877,26 +1116,34 @@ function initializeVisualBinding () {
877
1116
  if (!candidate.supported) { inform(candidate.reason, true); return }
878
1117
  contextMenu(candidate, event.clientX || 12, event.clientY || 12)
879
1118
  }
880
- root.addEventListener('contextmenu', chooseCandidate, true)
1119
+ listen(root, 'contextmenu', chooseCandidate, true)
881
1120
  for (const name of ['input', 'change']) {
882
- root.addEventListener(name, event => {
1121
+ listen(root, name, event => {
883
1122
  if (!editing || !event.target?.closest?.('[data-cp-auto]')) return
884
1123
  event.preventDefault()
885
1124
  event.stopImmediatePropagation()
886
1125
  }, true)
887
1126
  }
888
- root.addEventListener('click', event => { if (selecting) chooseCandidate(event) }, true)
889
- root.addEventListener('keydown', event => {
1127
+ for (const name of ['click', 'pointerdown', 'keydown', 'input', 'change']) {
1128
+ listen(root, name, event => {
1129
+ if (!editing && event.target?.closest?.('[data-cpv-unavailable="true"]')) {
1130
+ event.preventDefault()
1131
+ event.stopImmediatePropagation()
1132
+ }
1133
+ }, true)
1134
+ }
1135
+ listen(root, 'click', event => { if (selecting) chooseCandidate(event) }, true)
1136
+ listen(root, 'keydown', event => {
890
1137
  if (event.key === 'Enter' && selecting) chooseCandidate(event)
891
1138
  }, true)
892
1139
  let longPress
893
- root.addEventListener('pointerdown', event => {
1140
+ listen(root, 'pointerdown', event => {
894
1141
  if (!editing || event.pointerType === 'mouse') return
895
1142
  const candidate = candidateFromEvent(event)
896
1143
  if (candidate) longPress = setTimeout(() => chooseCandidate(event), 550)
897
1144
  }, true)
898
1145
  for (const name of ['pointerup', 'pointercancel', 'pointermove']) {
899
- root.addEventListener(name, () => clearTimeout(longPress), true)
1146
+ listen(root, name, () => clearTimeout(longPress), true)
900
1147
  }
901
1148
  attachControls()
902
1149
  attachModelControls()
@@ -942,8 +1189,15 @@ function initializeVisualBinding () {
942
1189
  attachControls()
943
1190
  updateBindingMarkers()
944
1191
  })
945
- observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['disabled'] })
1192
+ observer.observe(root, { childList: true, subtree: true, attributes: true,
1193
+ attributeFilter: ['disabled', 'data-cp-auto', 'data-cp-repeat', 'data-cp-repeat-key'] })
946
1194
  cleanup.push(() => {
1195
+ clearTimeout(longPress)
1196
+ for (const listeners of controlListeners.values()) {
1197
+ for (const stop of listeners.stops) stop()
1198
+ }
1199
+ controlListeners.clear()
1200
+ pendingText.clear()
947
1201
  for (const button of buttonPresses.values()) button.controller.dispose()
948
1202
  buttonPresses.clear()
949
1203
  for (const button of momentaryButtons.values()) button.controller.dispose()
@@ -955,14 +1209,18 @@ function initializeVisualBinding () {
955
1209
  for (const button of buttonPresses.values()) button.controller.release()
956
1210
  for (const button of momentaryButtons.values()) button.controller.release()
957
1211
  }
1212
+ refreshAvailability()
958
1213
  }))
1214
+ cleanup.push(this.$watch('boundAvailability', () => { refreshAvailability(); showFeedback() }))
959
1215
  const unwatch = this.$watch('boundState', showFeedback)
960
1216
  cleanup.push(() => unwatch(), () => observer.disconnect(), () => toolbar?.remove(),
961
1217
  () => closeOverlay(), () => closeLoginScreen())
1218
+ cleanup.push(() => { for (const [node] of disabledNodes) setAvailable(node, true) })
962
1219
  this.__visualBindingCleanup = cleanup
963
1220
 
964
1221
  const style = element('style')
965
1222
  style.textContent = `
1223
+ [data-cpv-unavailable="true"]{filter:grayscale(1) !important;opacity:.45 !important;pointer-events:none !important}
966
1224
  button[data-cpv-press="true"]{translate:0 2px !important;box-shadow:inset 0 2px 4px #0003 !important}
967
1225
  .cpv-toolbar,.cpv-dialog,.cpv-menu{font:14px/1.4 system-ui,sans-serif;color:#182224;background:#fff}
968
1226
  .cpv-toolbar{position:fixed;z-index:2147483000;top:12px;right:12px;display:flex;align-items:center;gap:8px;padding:8px;border:1px solid #9baaad;box-shadow:0 8px 24px #0003;max-width:calc(100vw - 24px)}
@@ -1013,7 +1271,9 @@ function initializeVisualBinding () {
1013
1271
  .cpv-scrim{position:fixed;inset:0;z-index:2147483001;background:#0008;display:flex;align-items:center;justify-content:center;padding:16px}
1014
1272
  .cpv-dialog{width:min(460px,100%);max-height:min(90vh,720px);overflow:auto;padding:28px 18px 18px;border-radius:6px;box-shadow:0 12px 36px #0005}
1015
1273
  .cpv-dialog h2{font-size:18px;margin:0 0 20px}.cpv-field{display:block;margin:10px 0}.cpv-field span{display:block;font-size:12px;margin-bottom:4px}
1274
+ .cpv-field[hidden]{display:none}
1016
1275
  .cpv-field input,.cpv-field select{box-sizing:border-box;width:100%;padding:8px;border:1px solid #86999c;border-radius:4px;background:white;color:#182224;font:inherit}
1276
+ .cpv-state-reverse{display:flex;align-items:center;gap:10px}.cpv-state-reverse span{margin:0}.cpv-field input[type=checkbox]{width:18px;height:18px;padding:0;accent-color:#008d35;flex:none}
1017
1277
  .cpv-field[data-disabled=true] span{color:#66787c}.cpv-field select:disabled{background:#edf1f2;color:#66787c;border-color:#c5cfd1;cursor:not-allowed;opacity:1}
1018
1278
  .cpv-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}.cpv-dialog>button:last-child{margin-top:20px}.cpv-message{min-height:18px;font-size:12px}
1019
1279
  .cpv-menu{position:fixed;z-index:2147483002;width:170px;padding:4px;border:1px solid #9baaad;box-shadow:0 8px 24px #0003}
@@ -1033,25 +1293,14 @@ function initializeVisualBinding () {
1033
1293
  document.body.classList.add('cpv-editing')
1034
1294
  cleanup.push(() => document.body.classList.remove('cpv-editing'))
1035
1295
  coverPage('Loading...', 'editor')
1036
- request('/session', 'GET', undefined, 2500).then(session => {
1037
- if (disposed) return
1038
- editSession = session
1039
- return loadEditor()
1040
- })
1041
- .then(closeLoginScreen).catch(() => loginView())
1296
+ restoreEditor()
1042
1297
  } else {
1043
1298
  coverPage('Loading...', 'panel')
1044
1299
  cleanup.push(this.$watch('boundAuthenticated', authenticated => {
1045
1300
  if (authenticated) closeLoginScreen()
1046
1301
  else if (!loginScreen) normalLoginView()
1047
1302
  }))
1048
- component.restoreBoundPanelSession(2500).then(session => {
1049
- if (disposed) return
1050
- if (session || component.boundAuthenticated) closeLoginScreen()
1051
- else normalLoginView()
1052
- }).catch(() => {
1053
- normalLoginView()
1054
- })
1303
+ restorePanel()
1055
1304
  }
1056
1305
  }
1057
1306