node-red-contrib-room-tools 1.8.2 → 1.8.3

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.
@@ -0,0 +1,771 @@
1
+ function initializeVisualBinding () {
2
+ const component = this
3
+ const config = this.__boundPanelConfig()?.visual
4
+ if (!config || !this.$el || typeof document === 'undefined') return
5
+ const root = this.$el
6
+ const base = config.editorEndpoint
7
+ const saved = new Map(Object.entries(config.bindings || {}))
8
+ const attached = new WeakSet()
9
+ const feedbackModels = new Map()
10
+ const pendingText = new Map()
11
+ const cleanup = []
12
+ let editing = new URL(window.location.href).searchParams.get('cp-edit') === config.panelId
13
+ let editSession = null
14
+ let snapshot = null
15
+ let draft = []
16
+ let devices = []
17
+ let selecting = false
18
+ let observer
19
+ let toolbar
20
+ let overlay
21
+ let status
22
+ let loginScreen
23
+
24
+ function element (tag, text, className) {
25
+ const node = document.createElement(tag)
26
+ if (text !== undefined && text !== null) node.textContent = String(text)
27
+ if (className) node.className = className
28
+ return node
29
+ }
30
+ function clear (node) { while (node.firstChild) node.removeChild(node.firstChild) }
31
+ function field (form, label, input) {
32
+ const row = element('label', null, 'cpv-field')
33
+ row.append(element('span', label), input)
34
+ form.append(row)
35
+ return input
36
+ }
37
+ function button (label, action, className) {
38
+ const node = element('button', label, className)
39
+ node.type = 'button'
40
+ node.addEventListener('click', action)
41
+ return node
42
+ }
43
+ function inform (message, error = false) {
44
+ if (status) {
45
+ status.textContent = message || ''
46
+ status.dataset.error = error ? 'true' : 'false'
47
+ }
48
+ }
49
+ async function request (path, method = 'GET', body) {
50
+ const response = await fetch(base + path, {
51
+ method, credentials: 'same-origin',
52
+ headers: method === 'POST'
53
+ ? { 'Content-Type': 'application/json', 'X-Panel-CSRF': editSession?.csrf || '' }
54
+ : undefined,
55
+ body: method === 'POST' ? JSON.stringify(body || {}) : undefined,
56
+ signal: AbortSignal.timeout(12000)
57
+ })
58
+ const result = await response.json().catch(() => ({}))
59
+ if (!response.ok) throw new Error(result.error || `Request failed (${response.status})`)
60
+ return result
61
+ }
62
+ function family (target) {
63
+ if (target.controlSchemaVersion !== config.contract.schemaVersion) return null
64
+ const known = config.contract.types[String(target.controlType || '').toLowerCase()]
65
+ if (!known) return null
66
+ const type = target.type
67
+ if (known.family === 'number' && ['integer', 'float'].includes(type)) return 'number'
68
+ if (known.family === 'boolean' && (type === 'boolean' ||
69
+ (type === 'integer' && target.booleanEncoding === 'integer01' &&
70
+ target.min === 0 && target.max === 1))) return 'boolean'
71
+ if (known.family === 'text' && ['string', 'dstring'].includes(type)) return 'text'
72
+ if (known.family === 'enum' && ['enum', 'integer', 'string'].includes(type)) return 'enum'
73
+ return null
74
+ }
75
+ function compatible (adapter, target, direction, candidate) {
76
+ const families = {
77
+ range: ['number'], switch: ['boolean'], meter: ['number'],
78
+ 'text-display': ['number', 'boolean', 'text', 'enum'],
79
+ 'text-input': ['text'], select: ['enum']
80
+ }
81
+ if (target.kind !== 'param' || !families[adapter]?.includes(family(target))) return false
82
+ if (direction === 'write' && ['meter', 'text-display'].includes(adapter)) return false
83
+ if (candidate?.unit && candidate.unit !== target.unit) return false
84
+ return direction === 'write' ? ['wo', 'rw'].includes(target.access) : ['ro', 'rw'].includes(target.access)
85
+ }
86
+ function numericRange (binding, node) {
87
+ if (binding.uiRange) return binding.uiRange
88
+ const tag = node.tagName?.toLowerCase()
89
+ const type = node.type?.toLowerCase()
90
+ const defaultMax = tag === 'meter' || tag === 'progress' ? 1 : 100
91
+ const min = node.min === '' || node.min == null ? (type === 'number' ? undefined : 0) : Number(node.min)
92
+ const max = node.max === '' || node.max == null ? (type === 'number' ? undefined : defaultMax) : Number(node.max)
93
+ return { min, max, step: node.step || (tag === 'meter' || tag === 'progress' ? 'any' : '1') }
94
+ }
95
+ function mappedValue (value, source, target, integer = false) {
96
+ if (!Number.isFinite(value)) return value
97
+ if (!Number.isFinite(source?.min) || !Number.isFinite(source?.max) || source.max <= source.min ||
98
+ !Number.isFinite(target?.min) || !Number.isFinite(target?.max) || target.max <= target.min) return value
99
+ const clamped = Math.min(source.max, Math.max(source.min, value))
100
+ let result = target.min + (clamped - source.min) * (target.max - target.min) / (source.max - source.min)
101
+ if (integer) result = Math.round(result)
102
+ else if (target.step && target.step !== 'any') {
103
+ const step = Number(target.step)
104
+ if (Number.isFinite(step) && step > 0) {
105
+ const precision = String(target.step).split('.')[1]?.length || 0
106
+ result = Number((target.min + Math.round((result - target.min) / step) * step).toFixed(Math.min(precision, 12)))
107
+ }
108
+ }
109
+ return Math.min(target.max, Math.max(target.min, result))
110
+ }
111
+ function buttonStateAttribute (node) {
112
+ return node.getAttribute('role') === 'switch' && !node.hasAttribute('aria-pressed')
113
+ ? 'aria-checked' : 'aria-pressed'
114
+ }
115
+ function valueFor (binding, node) {
116
+ if (binding.adapter === 'switch') {
117
+ if (node.tagName === 'BUTTON') return node.getAttribute(buttonStateAttribute(node)) !== 'true'
118
+ return Boolean(node.checked)
119
+ }
120
+ if (binding.adapter === 'range') {
121
+ return mappedValue(Number(node.value), numericRange(binding, node), binding.writeMeta,
122
+ binding.writeMeta?.type === 'integer')
123
+ }
124
+ if (binding.adapter === 'select' && ['integer', 'float'].includes(binding.writeMeta?.type)) {
125
+ return Number(node.value)
126
+ }
127
+ return String(node.value)
128
+ }
129
+ function showFeedback () {
130
+ for (const [id, binding] of saved) {
131
+ if (binding.mode === 'write') continue
132
+ const matches = root.querySelectorAll('[data-cp-auto="' + binding.locator + '"]')
133
+ if (matches.length !== 1) continue
134
+ if (!Object.prototype.hasOwnProperty.call(component.boundState, id)) continue
135
+ const value = component.boundDisplayValue(id, component.boundState[id])
136
+ const node = matches[0]
137
+ const displayValue = ['range', 'meter'].includes(binding.adapter)
138
+ ? mappedValue(Number(value), binding.readMeta || binding.writeMeta,
139
+ numericRange(binding, node)) : value
140
+ if (binding.model) {
141
+ if (binding.component && binding.adapter === 'text-input' &&
142
+ (pendingText.has(id) || node.contains?.(document.activeElement))) continue
143
+ if (binding.model in component && !component.$options?.computed?.[binding.model]) {
144
+ if (!Object.is(component[binding.model], displayValue)) {
145
+ pendingText.delete(id)
146
+ if (binding.component) feedbackModels.set(id, displayValue)
147
+ component[binding.model] = displayValue
148
+ if (binding.component && component.$nextTick) {
149
+ void component.$nextTick(() => {
150
+ if (Object.is(feedbackModels.get(id), displayValue)) feedbackModels.delete(id)
151
+ })
152
+ }
153
+ }
154
+ } else console.error('ControlPanel display field is unavailable: ' + binding.model)
155
+ }
156
+ else if (binding.adapter === 'switch') {
157
+ if (node.tagName === 'BUTTON') {
158
+ const state = value === true ? 'true' : 'false'
159
+ node.setAttribute(buttonStateAttribute(node), state)
160
+ if (node.getAttribute('role') === 'switch' && node.hasAttribute('aria-pressed')) node.setAttribute('aria-checked', state)
161
+ } else node.checked = value === true
162
+ }
163
+ else if (binding.adapter === 'text-display') {
164
+ if (node.textContent !== String(value)) node.textContent = String(value)
165
+ }
166
+ else if (binding.adapter === 'meter') node.value = Number(displayValue)
167
+ else if (document.activeElement !== node || binding.adapter === 'range') node.value = String(displayValue)
168
+ }
169
+ }
170
+ function attachControls () {
171
+ for (const node of root.querySelectorAll('[data-cp-auto]')) {
172
+ if (attached.has(node)) continue
173
+ attached.add(node)
174
+ const locator = node.getAttribute('data-cp-auto')
175
+ const matched = [...saved].find(([, entry]) => entry.locator === locator)
176
+ if (!matched) continue
177
+ const [id, binding] = matched
178
+ if (binding.mode === 'read') continue
179
+ if (binding.component) {
180
+ if (binding.adapter === 'text-input') {
181
+ const commit = () => {
182
+ if (!pendingText.has(id) || editing || !component.boundAuthenticated) return
183
+ const value = pendingText.get(id)
184
+ pendingText.delete(id)
185
+ try { component.sendBoundControl({ id, value, final: true }) } catch (error) {
186
+ console.error('ControlPanel binding command failed', error)
187
+ }
188
+ }
189
+ const afterModelUpdate = () => {
190
+ if (component.$nextTick) void component.$nextTick(commit)
191
+ else commit()
192
+ }
193
+ node.addEventListener('change', afterModelUpdate)
194
+ node.addEventListener('focusout', afterModelUpdate)
195
+ if (binding.component === 'v-text-field') {
196
+ node.addEventListener('keydown', event => {
197
+ if (event.key === 'Enter') afterModelUpdate()
198
+ })
199
+ }
200
+ }
201
+ continue
202
+ }
203
+ const send = final => {
204
+ if (editing || !component.boundAuthenticated) return
205
+ try { component.sendBoundControl({ id, value: valueFor(binding, node), final }) } catch (error) {
206
+ console.error('ControlPanel binding command failed', error)
207
+ }
208
+ }
209
+ if (binding.adapter === 'range') {
210
+ node.addEventListener('input', () => send(false), true)
211
+ node.addEventListener('change', () => send(true), true)
212
+ } else if (binding.adapter === 'switch' && node.tagName === 'BUTTON') {
213
+ node.addEventListener('click', event => {
214
+ event.preventDefault()
215
+ send(true)
216
+ })
217
+ } else node.addEventListener('change', () => send(true))
218
+ }
219
+ showFeedback()
220
+ }
221
+ function attachModelControls () {
222
+ for (const [id, binding] of saved) {
223
+ if (!binding.component || !binding.model || binding.mode === 'read') continue
224
+ let finishTimer
225
+ const send = (value, final) => {
226
+ if (editing || !component.boundAuthenticated) return
227
+ try {
228
+ let commandValue
229
+ if (binding.adapter === 'range') {
230
+ const numeric = Number(value)
231
+ if (!Number.isFinite(numeric)) return
232
+ commandValue = mappedValue(numeric, binding.uiRange, binding.writeMeta,
233
+ binding.writeMeta?.type === 'integer')
234
+ } else if (binding.adapter === 'switch') {
235
+ if (typeof value !== 'boolean') return
236
+ commandValue = value
237
+ } else if (binding.adapter === 'select') {
238
+ if (typeof value !== 'string' &&
239
+ !(typeof value === 'number' && Number.isFinite(value))) return
240
+ if (['integer', 'float'].includes(binding.writeMeta?.type)) {
241
+ commandValue = Number(value)
242
+ if (!Number.isFinite(commandValue)) return
243
+ } else commandValue = value
244
+ const options = binding.writeMeta?.enums
245
+ if (Array.isArray(options) && options.length &&
246
+ !options.some(option => Object.is(option, commandValue) ||
247
+ Object.is(option?.value, commandValue))) {
248
+ console.error('ControlPanel selection is not a device option: ' + id)
249
+ return
250
+ }
251
+ } else return
252
+ component.sendBoundControl({ id, value: commandValue, final })
253
+ } catch (error) {
254
+ console.error('ControlPanel binding command failed', error)
255
+ }
256
+ }
257
+ const unwatch = component.$watch(binding.model, value => {
258
+ if (feedbackModels.has(id)) {
259
+ const feedbackValue = feedbackModels.get(id)
260
+ feedbackModels.delete(id)
261
+ if (Object.is(value, feedbackValue)) return
262
+ }
263
+ if (editing || !component.boundAuthenticated) return
264
+ if (binding.adapter === 'text-input') {
265
+ if (typeof value === 'string' || value == null) pendingText.set(id, value ?? '')
266
+ return
267
+ }
268
+ if (binding.adapter === 'range') {
269
+ send(value, false)
270
+ clearTimeout(finishTimer)
271
+ finishTimer = setTimeout(() => send(value, true), 160)
272
+ } else send(value, true)
273
+ })
274
+ cleanup.push(() => { clearTimeout(finishTimer); unwatch() })
275
+ }
276
+ }
277
+ function closeOverlay () { overlay?.remove(); overlay = null }
278
+ function updateBindingMarkers () {
279
+ for (const node of root.querySelectorAll('[data-cpv-bound="true"]')) {
280
+ node.removeAttribute('data-cpv-bound')
281
+ node.removeAttribute('data-cpv-binding-id')
282
+ }
283
+ if (!editing || !editSession) return
284
+ const bindings = new Map(draft.map(entry => [entry.locator, entry]))
285
+ for (const node of root.querySelectorAll('[data-cp-auto]')) {
286
+ const entry = bindings.get(node.getAttribute('data-cp-auto'))
287
+ if (!entry) continue
288
+ node.setAttribute('data-cpv-bound', 'true')
289
+ node.setAttribute('data-cpv-binding-id', entry.id)
290
+ }
291
+ }
292
+ function contextMenu (candidate, x, y) {
293
+ closeOverlay()
294
+ overlay = element('div', null, 'cpv-menu')
295
+ overlay.setAttribute('role', 'menu')
296
+ const existing = draft.find(entry => entry.locator === candidate.locator)
297
+ overlay.append(button(existing ? 'Edit binding' : 'Bind control', () => bindingDialog(candidate)))
298
+ if (existing) overlay.append(button('Remove binding', () => {
299
+ draft = draft.filter(entry => entry.locator !== candidate.locator)
300
+ updateBindingMarkers()
301
+ closeOverlay()
302
+ inform('Binding removed from draft')
303
+ }))
304
+ overlay.style.left = Math.min(x, window.innerWidth - 180) + 'px'
305
+ overlay.style.top = Math.min(y, window.innerHeight - 90) + 'px'
306
+ document.body.append(overlay)
307
+ overlay.querySelector('button')?.focus()
308
+ const outside = event => {
309
+ if (!overlay?.contains(event.target)) {
310
+ closeOverlay()
311
+ document.removeEventListener('pointerdown', outside, true)
312
+ }
313
+ }
314
+ const timer = setTimeout(() => document.addEventListener('pointerdown', outside, true), 0)
315
+ cleanup.push(() => {
316
+ clearTimeout(timer)
317
+ document.removeEventListener('pointerdown', outside, true)
318
+ })
319
+ }
320
+ function showDialog (title) {
321
+ closeOverlay()
322
+ overlay = element('div', null, 'cpv-scrim')
323
+ const dialog = element('section', null, 'cpv-dialog')
324
+ dialog.setAttribute('role', 'dialog')
325
+ dialog.setAttribute('aria-modal', 'true')
326
+ dialog.setAttribute('aria-label', title)
327
+ dialog.append(element('h2', title))
328
+ overlay.append(dialog)
329
+ document.body.append(overlay)
330
+ return dialog
331
+ }
332
+ function coverPage (title, mode, state = 'loading') {
333
+ loginScreen?.remove()
334
+ loginScreen = element('div', null, 'cpv-login-screen')
335
+ loginScreen.setAttribute('role', 'dialog')
336
+ loginScreen.setAttribute('aria-modal', 'true')
337
+ loginScreen.setAttribute('aria-label', title)
338
+ loginScreen.dataset.mode = mode
339
+ loginScreen.dataset.state = state
340
+ const marks = element('div', null, 'cpv-brand-row')
341
+ marks.append(element('strong', 'BSS'), element('strong', 'OMNI'))
342
+ if (mode === 'panel') {
343
+ const brand = element('div', null, 'cpv-login-brand')
344
+ brand.append(marks)
345
+ brand.append(element('span', 'The Unified Audio, Video & Control Platform', 'cpv-brand-tagline'))
346
+ loginScreen.append(brand)
347
+ }
348
+ const content = element('section', null, 'cpv-login-content')
349
+ if (mode === 'editor') content.append(marks)
350
+ content.append(element('h1', title))
351
+ loginScreen.append(content)
352
+ document.body.append(loginScreen)
353
+ return content
354
+ }
355
+ function closeLoginScreen () { loginScreen?.remove(); loginScreen = null }
356
+ function showLoginForm (title, login, editor = false) {
357
+ const content = coverPage(title, editor ? 'editor' : 'panel', 'login')
358
+ const form = element('form')
359
+ const user = field(form, editor ? 'Editor username' : 'Username', element('input'))
360
+ user.name = editor ? 'cp-editor-username' : 'cp-panel-username'
361
+ user.autocomplete = editor ? 'section-cp-editor username' : 'section-cp-panel username'
362
+ const password = field(form, editor ? 'Editor password' : 'Password', element('input'))
363
+ password.type = 'password'
364
+ password.name = editor ? 'cp-editor-password' : 'cp-panel-password'
365
+ password.autocomplete = editor ? 'section-cp-editor current-password' : 'section-cp-panel current-password'
366
+ const message = element('p', '', 'cpv-message')
367
+ message.setAttribute('role', 'status')
368
+ form.append(message)
369
+ const submit = element('button', 'Log in')
370
+ submit.type = 'submit'
371
+ form.append(submit)
372
+ form.addEventListener('submit', async event => {
373
+ event.preventDefault()
374
+ submit.disabled = true
375
+ submit.textContent = editor ? 'Loading...' : 'Signing in...'
376
+ submit.setAttribute('aria-busy', 'true')
377
+ message.textContent = ''
378
+ try {
379
+ await login(user.value, password.value)
380
+ closeLoginScreen()
381
+ } catch (error) {
382
+ message.textContent = error.message
383
+ submit.textContent = 'Log in'
384
+ submit.removeAttribute('aria-busy')
385
+ submit.disabled = false
386
+ }
387
+ })
388
+ content.append(form)
389
+ user.focus()
390
+ }
391
+ function loginView () {
392
+ showLoginForm('ControlPanel editor', async (username, password) => {
393
+ const response = await fetch(base + '/login', {
394
+ method: 'POST', credentials: 'same-origin',
395
+ headers: { 'Content-Type': 'application/json' },
396
+ body: JSON.stringify({ username, password })
397
+ })
398
+ const result = await response.json()
399
+ if (!response.ok) throw new Error(result.error || 'Login failed')
400
+ editSession = result
401
+ await loadEditor()
402
+ }, true)
403
+ }
404
+ function normalLoginView () {
405
+ showLoginForm('Sign in', (username, password) => component.loginBoundPanel(username, password))
406
+ }
407
+ async function loadEditor () {
408
+ snapshot = await request('/config')
409
+ const response = await request('/devices')
410
+ devices = response.devices
411
+ draft = snapshot.bindings.map(entry => ({ ...entry, onebased: numberingMode(entry) }))
412
+ closeOverlay()
413
+ selecting = true
414
+ renderToolbar()
415
+ inform('Choose a control on the page')
416
+ updateBindingMarkers()
417
+ }
418
+ function choose (label, options, selected) {
419
+ const select = element('select')
420
+ select.setAttribute('aria-label', label)
421
+ select.append(new Option('Choose...', ''))
422
+ for (const item of options) select.append(new Option(item.text, item.value))
423
+ select.value = selected || ''
424
+ return select
425
+ }
426
+ function numberingMode (binding) {
427
+ for (const value of [binding?.onebased, binding?.writeMeta?.onebased, binding?.readMeta?.onebased]) {
428
+ if (typeof value === 'boolean') return value
429
+ }
430
+ return false
431
+ }
432
+ function finishBindingEdit (returnToBindings) {
433
+ if (returnToBindings) showBindingsDialog()
434
+ else closeOverlay()
435
+ }
436
+ async function bindingDialog (candidate, options = {}) {
437
+ const returnToBindings = options.returnToBindings === true
438
+ const existing = draft.find(entry => entry.locator === candidate.locator)
439
+ const dialog = showDialog(existing ? 'Edit binding' : 'Bind control')
440
+ const form = element('form')
441
+ const name = field(form, 'Name', element('input'))
442
+ name.value = existing?.label || candidate.label
443
+ name.maxLength = 80
444
+ const id = field(form, 'Control ID', element('input'))
445
+ id.value = existing?.id || 'control.' + candidate.locator.slice(-12)
446
+ const modeOptions = ['meter', 'text-display'].includes(candidate.adapter)
447
+ ? ['read'] : ['both', 'write', 'read']
448
+ const mode = field(form, 'Mode', choose('Mode', modeOptions.map(value => ({ value, text: value })), existing?.mode || modeOptions[0]))
449
+ const device = field(form, 'Device', choose('Device', devices.filter(item => item.online)
450
+ .map(item => ({ value: item.id, text: item.id })), existing?.device))
451
+ const write = field(form, 'Write point', choose('Write point', [], existing?.writePath))
452
+ const read = field(form, 'Feedback point', choose('Feedback point', [], existing?.readPath || existing?.writePath))
453
+ const numbering = field(form, 'Numbering', choose('Numbering', [
454
+ { value: 'false', text: 'Zero-based' }, { value: 'true', text: 'One-based' }
455
+ ], String(numberingMode(existing))))
456
+ const message = element('p', '', 'cpv-message')
457
+ form.append(message)
458
+ let targets = []
459
+ function updateTargets () {
460
+ const oldWrite = write.value || existing?.writePath
461
+ const oldRead = read.value || existing?.readPath || existing?.writePath
462
+ clear(write); clear(read)
463
+ write.append(new Option('Choose...', ''))
464
+ read.append(new Option('Choose...', ''))
465
+ let writeCount = 0
466
+ let readCount = 0
467
+ for (const target of targets) {
468
+ const label = `${target.path} (${target.type})`
469
+ if (compatible(candidate.adapter, target, 'write', candidate)) {
470
+ write.append(new Option(label, target.path))
471
+ writeCount++
472
+ }
473
+ if (compatible(candidate.adapter, target, 'read', candidate)) {
474
+ read.append(new Option(label, target.path))
475
+ readCount++
476
+ }
477
+ }
478
+ write.value = oldWrite || ''
479
+ read.value = oldRead || ''
480
+ write.closest('label').hidden = mode.value === 'read'
481
+ read.closest('label').hidden = mode.value === 'write'
482
+ const available = mode.value === 'read' ? readCount
483
+ : mode.value === 'write' ? writeCount : Math.min(writeCount, readCount)
484
+ message.textContent = targets.length && !available ? 'No compatible points for this control'
485
+ : targets.length ? '' : 'No points available'
486
+ }
487
+ device.addEventListener('change', async () => {
488
+ message.textContent = 'Loading points...'
489
+ try {
490
+ targets = device.value ? (await request('/targets?device=' + encodeURIComponent(device.value))).targets : []
491
+ updateTargets()
492
+ } catch (error) { targets = []; updateTargets(); message.textContent = error.message }
493
+ })
494
+ write.addEventListener('change', () => { read.value = write.value })
495
+ mode.addEventListener('change', updateTargets)
496
+ if (device.value) device.dispatchEvent(new Event('change'))
497
+ const actions = element('div', null, 'cpv-actions')
498
+ actions.append(button('Cancel', () => finishBindingEdit(returnToBindings)))
499
+ if (existing) actions.append(button('Remove', () => {
500
+ draft = draft.filter(entry => entry.locator !== candidate.locator)
501
+ finishBindingEdit(returnToBindings)
502
+ inform('Binding removed from draft')
503
+ }))
504
+ const save = element('button', 'Save draft')
505
+ save.type = 'submit'
506
+ actions.append(save)
507
+ form.append(actions)
508
+ form.addEventListener('submit', event => {
509
+ event.preventDefault()
510
+ if (!/^[A-Za-z][A-Za-z0-9._/-]{0,127}$/.test(id.value) ||
511
+ draft.some(entry => entry.id === id.value && entry.locator !== candidate.locator)) {
512
+ message.textContent = 'Control ID must be unique and valid'; return
513
+ }
514
+ if (!name.value.trim() || !device.value || (mode.value !== 'read' && !write.value) ||
515
+ (mode.value !== 'write' && !read.value) || !numbering.value) {
516
+ message.textContent = 'Complete the binding fields'; return
517
+ }
518
+ const entry = {
519
+ id: id.value, locator: candidate.locator, adapter: candidate.adapter,
520
+ ...(candidate.model ? { model: candidate.model } : {}),
521
+ label: name.value.trim(), mode: mode.value, device: device.value,
522
+ writePath: mode.value === 'read' ? '' : write.value,
523
+ readPath: mode.value === 'write' ? '' : read.value,
524
+ onebased: numbering.value === 'true'
525
+ }
526
+ draft = draft.filter(item => item.locator !== candidate.locator)
527
+ draft.push(entry)
528
+ updateBindingMarkers()
529
+ finishBindingEdit(returnToBindings)
530
+ inform('Binding saved in draft')
531
+ })
532
+ dialog.append(form)
533
+ name.focus()
534
+ }
535
+ function renderToolbar () {
536
+ toolbar?.remove()
537
+ toolbar = element('div', null, 'cpv-toolbar')
538
+ const select = button('Select', () => {
539
+ selecting = !selecting
540
+ select.setAttribute('aria-pressed', String(selecting))
541
+ inform(selecting ? 'Choose a control on the page' : '')
542
+ })
543
+ select.setAttribute('aria-pressed', String(selecting))
544
+ toolbar.append(select)
545
+ toolbar.append(button('Bindings', showBindingsDialog))
546
+ const apply = button('Apply', async () => {
547
+ if (apply.disabled) return
548
+ apply.disabled = true
549
+ apply.setAttribute('aria-busy', 'true')
550
+ inform('Applying bindings...')
551
+ try {
552
+ await request('/apply', 'POST', {
553
+ rev: snapshot.rev, templateHash: snapshot.templateHash, entries: draft
554
+ })
555
+ window.location.reload()
556
+ } catch (error) {
557
+ inform(error.message, true)
558
+ } finally {
559
+ apply.disabled = false
560
+ apply.removeAttribute('aria-busy')
561
+ }
562
+ })
563
+ toolbar.append(apply)
564
+ toolbar.append(button('Exit', async () => {
565
+ try { await request('/logout', 'POST') } catch (_) {}
566
+ try { await component.logoutBoundPanel() } catch (_) {}
567
+ returnToPanel()
568
+ }))
569
+ status = element('span', '', 'cpv-status')
570
+ status.setAttribute('role', 'status')
571
+ toolbar.append(status)
572
+ document.body.append(toolbar)
573
+ }
574
+ function sortedDraftEntries () {
575
+ const order = new Map((snapshot?.candidates || []).map((candidate, index) => [candidate.locator, index]))
576
+ return [...draft].sort((a, b) => {
577
+ const position = (order.get(a.locator) ?? Number.MAX_SAFE_INTEGER) -
578
+ (order.get(b.locator) ?? Number.MAX_SAFE_INTEGER)
579
+ if (position) return position
580
+ return String(a.id).localeCompare(String(b.id))
581
+ })
582
+ }
583
+ function showBindingsDialog () {
584
+ const dialog = showDialog('Bindings')
585
+ if (!draft.length) dialog.append(element('p', 'No bindings'))
586
+ for (const entry of sortedDraftEntries()) {
587
+ const row = element('div', null, 'cpv-binding-row')
588
+ const path = entry.writePath || entry.readPath || '(no target)'
589
+ const details = element('div', null, 'cpv-binding-details')
590
+ details.append(element('span', entry.label, 'cpv-binding-label'))
591
+ details.append(element('span', path, 'cpv-binding-path'))
592
+ row.append(details)
593
+ const actions = element('div', null, 'cpv-binding-actions')
594
+ const candidate = snapshot.candidates.find(item => item.locator === entry.locator && item.supported)
595
+ const edit = button('Edit', () => bindingDialog(candidate, { returnToBindings: true }))
596
+ edit.disabled = !candidate
597
+ if (!candidate) edit.title = 'This control is no longer available on the page'
598
+ actions.append(edit)
599
+ actions.append(button('Remove', () => {
600
+ draft = draft.filter(item => item.id !== entry.id)
601
+ updateBindingMarkers()
602
+ row.remove()
603
+ inform('Binding removed from draft')
604
+ }))
605
+ row.append(actions)
606
+ dialog.append(row)
607
+ }
608
+ dialog.append(button('Close', closeOverlay))
609
+ }
610
+ function returnToPanel () {
611
+ const url = new URL(window.location.href)
612
+ url.searchParams.delete('cp-edit')
613
+ window.location.replace(url.href)
614
+ }
615
+ function candidateFromEvent (event) {
616
+ const node = event.target?.closest?.('[data-cp-auto]')
617
+ if (!node || !root.contains(node) || !snapshot) return null
618
+ const candidate = snapshot.candidates.find(item => item.locator === node.getAttribute('data-cp-auto'))
619
+ if (!candidate?.component || !candidate.model) return candidate
620
+ const value = component[candidate.model]
621
+ if (value == null) return candidate
622
+ if (candidate.adapter === 'switch' && typeof value !== 'boolean') {
623
+ return { ...candidate, supported: false, reason: 'This Vuetify switch needs a boolean v-model field' }
624
+ }
625
+ if (candidate.adapter === 'range' && (typeof value !== 'number' || !Number.isFinite(value))) {
626
+ return { ...candidate, supported: false, reason: 'This Vuetify slider needs a numeric v-model field' }
627
+ }
628
+ if (candidate.adapter === 'meter' && (typeof value !== 'number' || !Number.isFinite(value))) {
629
+ return { ...candidate, supported: false, reason: 'This Vuetify progress needs a numeric model field' }
630
+ }
631
+ if (candidate.adapter === 'select' && typeof value !== 'string' &&
632
+ !(typeof value === 'number' && Number.isFinite(value))) {
633
+ return { ...candidate, supported: false, reason: 'This Vuetify selection needs one scalar model field' }
634
+ }
635
+ if (candidate.adapter === 'text-input' && typeof value !== 'string') {
636
+ return { ...candidate, supported: false, reason: 'This Vuetify text field needs a string model field' }
637
+ }
638
+ return candidate
639
+ }
640
+ function chooseCandidate (event) {
641
+ if (!editing || !editSession) return
642
+ const candidate = candidateFromEvent(event)
643
+ if (!candidate) return
644
+ event.preventDefault()
645
+ event.stopImmediatePropagation()
646
+ if (!candidate.supported) { inform(candidate.reason, true); return }
647
+ contextMenu(candidate, event.clientX || 12, event.clientY || 12)
648
+ }
649
+ root.addEventListener('contextmenu', chooseCandidate, true)
650
+ for (const name of ['input', 'change']) {
651
+ root.addEventListener(name, event => {
652
+ if (!editing || !event.target?.closest?.('[data-cp-auto]')) return
653
+ event.preventDefault()
654
+ event.stopImmediatePropagation()
655
+ }, true)
656
+ }
657
+ root.addEventListener('click', event => { if (selecting) chooseCandidate(event) }, true)
658
+ root.addEventListener('keydown', event => {
659
+ if (event.key === 'Enter' && selecting) chooseCandidate(event)
660
+ }, true)
661
+ let longPress
662
+ root.addEventListener('pointerdown', event => {
663
+ if (!editing || event.pointerType === 'mouse') return
664
+ const candidate = candidateFromEvent(event)
665
+ if (candidate) longPress = setTimeout(() => chooseCandidate(event), 550)
666
+ }, true)
667
+ for (const name of ['pointerup', 'pointercancel', 'pointermove']) {
668
+ root.addEventListener(name, () => clearTimeout(longPress), true)
669
+ }
670
+ attachControls()
671
+ attachModelControls()
672
+ observer = new MutationObserver(() => {
673
+ attachControls()
674
+ updateBindingMarkers()
675
+ })
676
+ observer.observe(root, { childList: true, subtree: true })
677
+ const unwatch = this.$watch('boundState', showFeedback)
678
+ cleanup.push(() => unwatch(), () => observer.disconnect(), () => toolbar?.remove(),
679
+ () => closeOverlay(), () => closeLoginScreen())
680
+ this.__visualBindingCleanup = cleanup
681
+
682
+ const style = element('style')
683
+ style.textContent = `
684
+ .cpv-toolbar,.cpv-dialog,.cpv-menu{font:14px/1.4 system-ui,sans-serif;color:#182224;background:#fff}
685
+ .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)}
686
+ .cpv-toolbar button,.cpv-dialog button{padding:7px 11px;border:1px solid #809296;background:#fff;color:#182224;border-radius:4px;cursor:pointer;font:inherit}
687
+ .cpv-toolbar button[aria-pressed=true],.cpv-dialog button[type=submit]{background:#8b1ec4;color:white;border-color:#8b1ec4}
688
+ .cpv-toolbar button[aria-busy=true]{cursor:wait}
689
+ .cpv-toolbar button[aria-busy=true]::before{content:'';display:inline-block;width:14px;height:14px;margin-right:8px;vertical-align:-2px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:cpv-spin .8s linear infinite}
690
+ .cpv-status{max-width:260px;font-size:12px}.cpv-status[data-error=true],.cpv-message{color:#a52a35}
691
+ .cpv-login-screen{position:fixed;inset:0;z-index:2147483003;display:grid;box-sizing:border-box;min-height:100dvh;overflow:auto;font:16px/1.45 system-ui,sans-serif}
692
+ .cpv-login-content{position:relative;z-index:1;box-sizing:border-box;width:min(440px,100%)}
693
+ .cpv-login-content h1{font-size:30px;line-height:1.15;margin:0 0 28px;font-weight:650}
694
+ .cpv-login-content .cpv-field{margin:0 0 18px}.cpv-login-content .cpv-field span{font-size:13px;margin-bottom:7px}
695
+ .cpv-login-content input{box-sizing:border-box;width:100%;min-height:48px;padding:11px 14px;border-radius:4px;font:inherit}
696
+ .cpv-login-content button{width:100%;min-height:50px;padding:10px 16px;border:1px solid #008d35;border-radius:4px;background:#008d35;color:#fff;font:inherit;font-weight:650;cursor:pointer}
697
+ .cpv-login-content button:hover:not(:disabled){background:#067a32;border-color:#067a32}
698
+ .cpv-login-content button:disabled{opacity:.8;cursor:wait}
699
+ .cpv-login-content button[aria-busy=true]::before{content:'';display:inline-block;width:14px;height:14px;margin-right:10px;vertical-align:-2px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:cpv-spin .8s linear infinite}
700
+ .cpv-login-content .cpv-message{min-height:20px;margin:0 0 8px;font-size:13px}
701
+ .cpv-login-screen[data-state=loading] .cpv-login-content::before{content:'';display:block;width:30px;height:30px;margin-bottom:22px;border:3px solid #a9dcc0;border-top-color:#008d35;border-radius:50%;animation:cpv-spin .8s linear infinite}
702
+ @keyframes cpv-spin{to{transform:rotate(360deg)}}
703
+ .cpv-login-screen[data-mode=panel]{grid-template-columns:minmax(0,1fr) minmax(360px,42%);align-items:stretch;gap:clamp(24px,5vw,96px);padding:clamp(24px,4vw,64px);background:linear-gradient(90deg,#03170bc9 0%,#03170b70 42%,#03170be8 100%),url('/control-panel-assets/panel-login-bg.png') center/cover,#071a11;color:#f6fff7}
704
+ .cpv-login-brand{grid-column:1;grid-row:1;display:flex;flex-direction:column;justify-content:flex-end;gap:14px;min-width:0;padding-bottom:3vh}
705
+ .cpv-brand-row{display:flex;align-items:baseline;gap:clamp(12px,2vw,24px);white-space:nowrap;font-size:clamp(44px,5.2vw,82px);line-height:1;font-weight:800}
706
+ .cpv-brand-row strong{font:inherit}
707
+ .cpv-brand-tagline{max-width:610px;padding-top:14px;border-top:1px solid #b6d6bb;font-size:22px}
708
+ .cpv-login-screen[data-mode=panel] .cpv-login-content{grid-column:2;grid-row:1;align-self:center;justify-self:center;border-left:1px solid #71c88780;padding-left:clamp(24px,4vw,64px)}
709
+ .cpv-login-screen[data-mode=panel] .cpv-field span{color:#d1e5d5}
710
+ .cpv-login-screen[data-mode=panel] input{border:1px solid #73a780;background:#092516d9;color:#fff}
711
+ .cpv-login-screen[data-mode=panel] input:focus-visible{outline:2px solid #35c964;outline-offset:2px}
712
+ .cpv-login-screen[data-mode=panel] .cpv-message{color:#ffb9b1}
713
+ .cpv-login-screen[data-mode=editor]{grid-template-columns:minmax(320px,45%) minmax(0,1fr);align-items:stretch;background:#f7f8f6;color:#111a13}
714
+ .cpv-login-screen[data-mode=editor]::after{content:'';grid-column:2;grid-row:1;min-height:100dvh;background:linear-gradient(90deg,#f7f8f640 0%,#03211015 20%),url('/control-panel-assets/panel-login-bg.png') center/cover,#082313}
715
+ .cpv-login-screen[data-mode=editor] .cpv-login-content{grid-column:1;grid-row:1;align-self:center;justify-self:center;padding:48px;width:min(520px,100%)}
716
+ .cpv-login-screen[data-mode=editor] .cpv-brand-row{margin-bottom:40px;color:#0b110d}
717
+ .cpv-login-screen[data-mode=editor] .cpv-brand-row strong:first-child{color:#008d35}
718
+ .cpv-login-screen[data-mode=editor] .cpv-login-content h1{font-size:26px}
719
+ .cpv-login-screen[data-mode=editor] input{border:1px solid #a8b5aa;background:#fff;color:#111a13}
720
+ .cpv-login-screen[data-mode=editor] input:focus-visible{outline:2px solid #008d35;outline-offset:2px}
721
+ .cpv-login-screen[data-mode=editor] .cpv-message{color:#a52635}
722
+ @media(max-width:800px){
723
+ .cpv-login-screen[data-mode=panel]{grid-template-columns:1fr;grid-template-rows:auto auto;align-content:center;gap:32px;min-height:100dvh;padding:28px;background-image:linear-gradient(180deg,#03170bab 0%,#03170bf0 68%),url('/control-panel-assets/panel-login-bg.png');background-position:25% center}
724
+ .cpv-login-brand{grid-column:1;grid-row:1;gap:8px;padding:0}.cpv-brand-row{font-size:clamp(40px,8vw,56px)}.cpv-brand-tagline{font-size:16px}
725
+ .cpv-login-screen[data-mode=panel] .cpv-login-content{grid-column:1;grid-row:2;justify-self:start;width:min(390px,100%);border-left:0;padding-left:0}
726
+ .cpv-login-screen[data-mode=editor]{grid-template-columns:1fr}
727
+ .cpv-login-screen[data-mode=editor]::after{display:none}
728
+ .cpv-login-screen[data-mode=editor] .cpv-login-content{grid-column:1;grid-row:1;width:min(480px,100%);padding:32px 24px}
729
+ }
730
+ .cpv-scrim{position:fixed;inset:0;z-index:2147483001;background:#0008;display:flex;align-items:center;justify-content:center;padding:16px}
731
+ .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}
732
+ .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}
733
+ .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}
734
+ .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}
735
+ .cpv-menu{position:fixed;z-index:2147483002;width:170px;padding:4px;border:1px solid #9baaad;box-shadow:0 8px 24px #0003}
736
+ .cpv-menu button{display:block;width:100%;padding:8px;text-align:left;border:0;background:white;color:#182224;font:inherit;cursor:pointer}
737
+ .cpv-menu button:hover,.cpv-menu button:focus{background:#f1e2ff}
738
+ .cpv-binding-row{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid #d3dedd}
739
+ .cpv-binding-details{display:flex;flex-direction:column;gap:2px;min-width:0}
740
+ .cpv-binding-details span{min-width:0;overflow-wrap:anywhere}.cpv-binding-path{color:#526467}
741
+ .cpv-binding-actions{display:flex;gap:6px;flex-shrink:0}
742
+ body.cpv-editing [data-cpv-bound="true"]{outline:2px solid #b526f2 !important;outline-offset:3px;box-shadow:0 0 0 4px #b526f255 !important}
743
+ body.cpv-editing [data-cp-auto]:hover{outline:2px solid #b526f2;outline-offset:2px}
744
+ `
745
+ document.head.append(style)
746
+ cleanup.push(() => style.remove())
747
+ if (editing) {
748
+ document.body.classList.add('cpv-editing')
749
+ cleanup.push(() => document.body.classList.remove('cpv-editing'))
750
+ coverPage('Loading...', 'editor')
751
+ request('/session').then(session => { editSession = session; return loadEditor() })
752
+ .then(closeLoginScreen).catch(() => loginView())
753
+ } else {
754
+ coverPage('Loading...', 'panel')
755
+ cleanup.push(this.$watch('boundAuthenticated', authenticated => {
756
+ if (authenticated) closeLoginScreen()
757
+ else if (!loginScreen) normalLoginView()
758
+ }))
759
+ component.restoreBoundPanelSession().then(session => {
760
+ if (session || component.boundAuthenticated) closeLoginScreen()
761
+ else normalLoginView()
762
+ }).catch(() => normalLoginView())
763
+ }
764
+ }
765
+
766
+ function disposeVisualBinding () {
767
+ for (const stop of this.__visualBindingCleanup || []) stop()
768
+ this.__visualBindingCleanup = null
769
+ }
770
+
771
+ module.exports = { initializeVisualBinding, disposeVisualBinding }