node-red-contrib-room-tools 1.8.15 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,112 +1,182 @@
1
1
  # ControlPanel 前端页面编写指南
2
2
 
3
- 适用基线:`node-red-contrib-room-tools@1.8.9` 的 `ui-control-panel`,当前本地修订为 `node-red-contrib-room-tools@1.8.15`,Dashboard 2,Vue Options API。本文按当前代码审核,不表示支持任意 Vue、Vuetify 或第三方组件。
3
+ 适用基线:当前统一工程中的 `node-red-contrib-room-tools@1.8.16`、Dashboard 2 和 Vue Options API。
4
4
 
5
- 给 AI 编写页面前阅读本文。目标是:页面作者只写普通前端页面;部署人员在网页编辑模式选择设备与点位;ControlPanel 保存映射并负责设备命令、反馈和网页侧量程换算。
5
+ 本文件是给页面编写 AI 的实现契约,不是完整的 Vue 或 Vuetify 手册。目标是:页面作者只写普通 HTML、CSS 和 Vue 页面;设备、驱动、映射表、反馈订阅和通信细节全部由 `ControlPanel` 节点处理。
6
6
 
7
- ## 1. 职责边界
7
+ ## 1. 最重要的规则
8
8
 
9
- 页面作者使用普通 HTML、CSS 和 Vue 编写布局、样式、导航、控件及其本地状态。无需知道驱动协议、点位路径、内部 Control ID 或绑定表结构。标准 HTML `id`、Vue `v-model` 和 ARIA 属性不是 ControlPanel 专用标签。
9
+ 页面作者只需要:
10
10
 
11
- 节点负责登录界面、普通与编辑权限、控件识别、映射保存、桥接生成、命令发送和反馈更新。页面不调用这些内部服务,不实现第二套设备通信。
11
+ - 按普通 H5/Vue 方式写布局、样式、导航、控件和页面本地状态。
12
+ - 为需要绑定的控件准备简单、可写、类型正确的 Vue 数据字段。
13
+ - 使用稳定的普通 HTML `id` 或 `name`,并为重复列表使用稳定的 `:key`。
14
+ - 用 `volume`、`muted`、`source`、`meter` 等字段表达页面语义。字段名只是页面模型名,不需要使用任何内部 Control ID。
12
15
 
13
- 不得从旧页面复制以下内容:
16
+ 页面作者不需要知道:
14
17
 
15
- - 手写 `data-cp-*` 属性。这些属性会被拒绝;运行时自动添加的内部标记不属于页面交付代码。
16
- - `sendControl`、`postControl`、`setControlSession`、`deviceValue` 等旧页面接口。
17
- - 写死设备路径、驱动命令、绑定表,或调用 ControlPanel 的 HTTP、WebSocket、SSE 接口。
18
- - 为修复反馈而添加设备轮询、强制回写滑杆、重复发送监听器或设备状态定时器。
18
+ - 驱动协议、设备路径、编号方式或 `program.json`。
19
+ - ControlPanel 的映射 JSON、桥接节点、stream、socket、SSE、帧格式或订阅主题。
20
+ - 内部 `data-cp-*` 标记、ControlPanel HTTP API 或设备发送接口。
21
+ - 量程换算、连续值节流、latest-wins、命令队列和反馈重连。
19
22
 
20
- 这些限制针对设备绑定,不禁止正常的页面导航、CSS 动画、图片明暗变化等纯前端功能。未绑定控件不会自动控制设备。待绑定控件的模型应独立;不要让两个不同设备控件共用一个 `v-model` 字段。
23
+ 不要在页面里实现第二套设备通信。不要写设备轮询、定时回写滑杆、反馈回写命令、重复发送监听器或自定义发送函数。未绑定的控件不会自动控制设备。
21
24
 
22
- ## 2. 页面格式
25
+ 页面里的 `volume` 和 `meter` 是两个不同语义:
23
26
 
24
- 交付可直接粘贴到节点 Template 的组件片段:一个 `<template>`,可选的 `<script>`、`<style>`。不要交付带 `<html>`、`<head>`、`<body>` 的完整文档,也不要交付需要外部构建才能运行的项目。
27
+ - `volume` 等普通数值字段用于音量/增益等可写连续控制,并可接收普通真实值反馈。
28
+ - `meter` 等字段用于只读实时信号电平,只能接收驱动声明了 Meter 元数据的点位。
25
29
 
26
- 脚本使用 `export default { data() { ... }, computed: { ... }, methods: { ... } }`。待绑定字段必须是 `data()` 中的顶层、可写、简单字段,例如 `volume`、`muted`、`source`、`status`。不支持将 `state.volume`、数组下标、计算属性、函数调用或表达式作为绑定模型;不使用 `<script setup>`、组件导入或自行创建 Vue 应用。
30
+ 不要把同一个模型字段同时用于不同设备点位,尤其不要让 Meter 和 Volume 共用一个模型。
27
31
 
28
- 为待绑定控件设置稳定、唯一的普通 `id`,尤其是重复控件。也可使用标准 `name`,但优先使用 `id`。不要根据运行时随机值生成 ID。改动 ID、标签类型或绑定写法后,原映射可能失效,需要重新检查。
32
+ ## 2. 页面格式与 Vue 约束
29
33
 
30
- 所有 Vuetify 标签都成对闭合,包括装饰组件:`<v-icon></v-icon>`,不要写 `<v-icon/>`。Dashboard 的 HTML 渲染可能错误地吞入后续元素,节点会拒绝这种自闭合写法。原生 HTML 的 void 元素,如 `<input>`、`<img>`,不受此限制。
34
+ 交付可以直接粘贴到节点 Template 的组件片段:一个外层 `<template>`,可选的 `<script>` 和 `<style>`。不要交付带 `<html>`、`<head>`、`<body>` 的完整文档,也不要交付需要外部构建才能运行的项目。
31
35
 
32
- ## 3. 可绑定控件
36
+ 脚本使用 Vue Options API:
33
37
 
34
- 以下表格是当前自动绑定适配器的范围,不是整个 Vuetify 的组件清单。
38
+ ```js
39
+ export default {
40
+ data () {
41
+ return { volume: 0, muted: false, status: '待机' }
42
+ },
43
+ computed: {},
44
+ methods: {}
45
+ }
46
+ ```
35
47
 
36
- | 用途 | 原生 HTML | Vuetify | 数据与提交方式 |
37
- | --- | --- | --- | --- |
38
- | 连续数值 | `input[type=range]`、`input[type=number]` | `v-slider` | 数字;原生 input/change 发送,Vuetify 模型变化发送并在变化停止后提交最终值。 |
39
- | 布尔开关 | `input[type=checkbox]`、语义开关按钮 | `v-switch`、`v-checkbox` | 布尔值;切换发送。语义按钮写法见下节。 |
40
- | 单选枚举 | `select` | `v-select`、`v-radio-group` | 字符串或数字的单个值,必须与设备枚举值对应。 |
41
- | 文本输入 | `input[type=text]`、`textarea` | `v-text-field`、`v-textarea` | 字符串;原生 change 提交,Vuetify 在 change 或失焦时提交,`v-text-field` 还支持 Enter。 |
42
- | 只读数值 | `meter`、`progress` | `v-progress-linear`、`v-progress-circular` | 只绑定 Feedback point,不发送命令。 |
43
- | 只读文本 | `span`、`strong`、`b`、`output`、`h1`、`h2`、`h3` | 暂无 | 使用简单字段插值,如 `{{ status }}`,或由节点替换的纯文本;只绑定读点位。 |
48
+ 不要使用 `<script setup>`、组件导入、自行创建 Vue 应用或外部运行时依赖。普通绑定模型应是 `data()` 中的顶层字段,或本指南 3.2 节规定的列表模型。计算属性、函数调用、取反、条件表达式和动态索引不能作为绑定模型。
44
49
 
45
- 原生输入可以不写 Vue 模型,也支持简单顶层 `v-model`。Vuetify 交互控件必须写简单 `v-model`;进度组件可以使用简单 `:model-value="progress"`。初始化数值为数字、开关为布尔值、文本为字符串,不用数组、对象或空值占位。
50
+ 不要手写任何 `data-cp-*` 属性。节点会在渲染副本中自动加入内部标记,页面源码不应依赖这些标记;手写此类属性会被拒绝。
46
51
 
47
- ### 3.1 通用限制
52
+ 不要给绑定控件添加 `@click`、`@input`、`@change`、`@update:model-value` 等事件指令,也不要额外绑定 `:value`、`:checked` 或 `:model-value`。节点需要独占这些事件和模型更新。页面导航、标签切换、纯视觉交互等事件可以放在不绑定设备的独立控件上。
48
53
 
49
- - 待绑定控件自身不能有 `@click`、`@input`、`@change`、`@update:model-value` 等任何事件指令。即使事件只做本地操作,也会被当前识别器判为已有逻辑。页面导航等事件可放在独立、不绑定设备的控件上。
50
- - 不额外叠加 `:value`、`:checked`、`:model-value`。只读进度组件的 `:model-value` 是例外;语义开关按钮的简单 ARIA 布尔绑定也是例外。
51
- - 只允许 range/select 的 `v-model.number`;不要用 `.lazy`、`.trim`、带参数的 `v-model` 或其他修饰符。
52
- - 待绑定元素不能位于 `v-for` 中,即使写了 `:key`。相同模板会生成多个元素,当前不能按列表项分别绑定。
53
- - 标签页、`v-if` 等正常布局可以保留。编辑该控件时先让它显示出来,并确保同一定位只出现一个元素。绑定提示会随元素重建恢复。
54
- - 普通 Vuetify 布局组件可使用,但 `v-btn`、`v-range-slider`、`v-autocomplete`、`v-combobox`、原生 radio 等不在自动设备绑定清单中。不要假定外观相似就支持。
54
+ 以下是两个例外:
55
55
 
56
- ### 3.2 各类控件限制
56
+ - 只读原生 `<meter>`/`<progress>` 可以使用 `:value`。
57
+ - 只读 `v-progress-linear`/`v-progress-circular` 可以使用简单 `v-model` 或 `:model-value`。
58
+ - 语义开关按钮可以使用简单的 `:aria-pressed` 或 `:aria-checked`。
57
59
 
58
- - 数值:`min`、`max`、`step` 用固定数字,也可用 `:min="0"`、`:max="100"`、`:step="1"`。不能绑定到动态字段或表达式。`v-slider` 要求 min < max、step > 0,不支持 `step="any"`。
59
- - 原生 range 默认 0–100;原生 meter/progress 默认 0–1。原生 number 没有缺省完整量程;需要自动缩放时明确写 min/max。不要把原生缺省量程误当成设备量程。
60
- - Vuetify switch/checkbox 不支持 `value`、自定义 `true-value`/`false-value`、`multiple` 或数组模型。
61
- - Vuetify select 不支持多选、`return-object`、`clearable` 或对象模型;选项可用标量数组,或普通 `{ title, value }` 选项但模型必须是标量。`v-radio-group` 使用标量模型,子项也不能自带事件处理器。
62
- - Vuetify 文本控件只用字符串模型。`v-text-field` 使用文本类型,不用数字、密码等类型;不支持 `.number`。`v-textarea` 的 Enter 是换行,不是提交。
63
- - `v-progress-linear` 从 0 到固定正数 max,默认 max=100;不支持 min、step、indeterminate、clickable。`v-progress-circular` 固定 0–100,不声明 min/max/step,也不使用 indeterminate。
64
- - 文本展示推荐只放一个简单插值,如 `<span>{{ status }}</span>`,不使用 `status.toFixed()`、拼接表达式或多个插值。复杂展示可用另外的纯前端元素,不把它作为设备反馈绑定目标。
60
+ 所有 Vuetify 组件标签都成对闭合,例如 `<v-icon></v-icon>`。Dashboard 的模板渲染可能吞掉自闭合组件后面的内容,节点会在部署前拒绝 `<v-slider />`、`<v-progress-linear />` 等写法。原生 HTML void 元素,如 `<input>` 和 `<img>`,不受此限制。
65
61
 
66
- ### 3.3 普通按钮与开关按钮
62
+ ## 3. 可绑定控件
67
63
 
68
- 一个只有文字的普通 `<button>` 不会自动成为设备控件。需要布尔设备开关时使用标准语义:
64
+ 以下是当前适配器支持的范围,不代表任意 Vuetify 或第三方组件都支持。
69
65
 
70
- ```html
71
- <button id="room-power" type="button" :aria-pressed="powered">电源</button>
72
- <button id="room-mute" type="button" role="switch" :aria-checked="muted">静音</button>
73
- ```
66
+ | 用途 | 原生 HTML | Vuetify | 模型与行为 |
67
+ | --- | --- | --- | --- |
68
+ | 连续数值控制 | `input[type=range]`、`input[type=number]` | `v-slider` | 数字;range 在拖动时发送中间值,结束时发送最终值。 |
69
+ | 布尔控制 | `input[type=checkbox]`、带语义属性的 `button` | `v-switch`、`v-checkbox` | 布尔值;切换发送。 |
70
+ | 单选枚举 | `select` | `v-select`、`v-radio-group` | 单个字符串或数字,必须是设备允许的枚举值。 |
71
+ | 文本控制 | `input[type=text]`、`textarea` | `v-text-field`、`v-textarea` | 字符串;原生控件在 change 时提交,Vuetify 文本框在 change、失焦时提交,`v-text-field` 也支持 Enter 提交。 |
72
+ | 普通只读反馈 | `span`、`output` 等简单文本展示 | 暂无专用组件 | 只读普通反馈,不发送命令。 |
73
+ | 实时信号电平 Meter | `meter`、`progress` | `v-progress-linear`、`v-progress-circular` | 只读数值;必须绑定驱动的 Meter 点位,不发送命令。 |
74
74
 
75
- 在 `data()` 中将 `powered`、`muted` 初始化为布尔值。也可使用静态 `aria-pressed="false"` 或 `role="switch" aria-checked="false"`,由节点更新按钮状态。不要叠加点击事件。`aria-pressed`/`aria-checked` 的动态表达式只写顶层字段,不写取反、条件或函数。
75
+ 交互控件建议写简单顶层 `v-model`。原生 range/select 可以使用 `.number`;不要给布尔、文本或 Meter 加 `.number`。Vuetify 进度组件可以使用简单 `v-model`,也可以使用简单 `:model-value`。原生 Meter/Progress 可以使用简单顶层 `v-model`,节点会把它转换成只读 `:value`;在 `v-for` 列表中应直接写 `:value="item.meter"`,不要写嵌套字段的原生 `v-model`。
76
76
 
77
- ## 4. 示例
77
+ 初始化值应匹配控件类型:数值为数字,开关为布尔值,文本和枚举为字符串。不要用数组、对象或 `null` 作为普通绑定控件的占位值。
78
78
 
79
- ### 4.1 原生 H5 控件
79
+ ### 3.1 普通控件写法
80
80
 
81
81
  ```html
82
82
  <template>
83
83
  <section>
84
84
  <label for="room-volume">音量</label>
85
85
  <input id="room-volume" type="range" min="0" max="100" step="1" v-model.number="volume">
86
- <label><input id="room-mute" type="checkbox" v-model="muted">静音</label>
86
+
87
+ <label>
88
+ <input id="room-mute" type="checkbox" v-model="muted">
89
+ 静音
90
+ </label>
91
+
87
92
  <button id="room-power" type="button" :aria-pressed="powered">电源</button>
93
+
88
94
  <select id="room-source" v-model="source">
89
95
  <option value="A">输入 A</option>
90
96
  <option value="B">输入 B</option>
91
97
  </select>
98
+
92
99
  <span id="room-status">{{ status }}</span>
100
+ <meter id="room-meter" :value="meter" min="0" max="100" aria-label="实时信号电平"></meter>
101
+ <progress id="room-progress" :value="progress" max="100" aria-label="进度"></progress>
93
102
  </section>
94
103
  </template>
104
+
95
105
  <script>
96
106
  export default {
97
107
  data () {
98
- return { volume: 0, muted: false, powered: false, source: 'A', status: '待机' }
108
+ return {
109
+ volume: 0,
110
+ muted: false,
111
+ powered: false,
112
+ source: 'A',
113
+ status: '待机',
114
+ meter: 0,
115
+ progress: 0
116
+ }
99
117
  }
100
118
  }
101
119
  </script>
102
120
  ```
103
121
 
104
- ### 4.2 Vuetify 控件
122
+ ### 3.2 `v-for` 列表绑定
123
+
124
+ 当前支持一层、来源为 `data()` 顶层数组字段的列表。对象列表使用稳定对象 ID:
125
+
126
+ ```html
127
+ <div v-for="item in items" :key="item.id">
128
+ <span>{{ item.name }}</span>
129
+ <input :id="'volume-' + item.id" type="range"
130
+ min="0" max="100" step="1" v-model.number="item.volume">
131
+ </div>
132
+ ```
133
+
134
+ 也支持带索引别名的对象列表:
135
+
136
+ ```html
137
+ <div v-for="(item, index) in items" :key="item.id">
138
+ <input id="row-volume" type="range" v-model.number="items[index].volume">
139
+ </div>
140
+ ```
141
+
142
+ 数组列表使用索引作为 key:
143
+
144
+ ```html
145
+ <input v-for="(value, index) in values" :key="index"
146
+ :id="'value-' + index" type="range" v-model.number="values[index]">
147
+ ```
148
+
149
+ 对象项可以使用静态嵌套字段,例如 `item.audio.volume` 或 `items[index].audio.volume`。数组项可以使用 `values[index]`。模型路径必须是标识符和静态点路径,不能调用函数、做计算或使用动态属性。
150
+
151
+ 一个列表项需要多个并列控件时,可使用内层 `<template v-for>`,但它必须是外层节点 Template 的子节点:
152
+
153
+ ```html
154
+ <template>
155
+ <template v-for="item in items" :key="item.id">
156
+ <input id="row-volume" type="range" v-model.number="item.volume">
157
+ <input id="row-balance" type="range" v-model.number="item.balance">
158
+ </template>
159
+ </template>
160
+ ```
161
+
162
+ 列表规则:
163
+
164
+ - 对象列表必须使用精确的 `:key="item.id"`;数组列表必须使用精确的 `:key="index"`。
165
+ - `item.id` 必须稳定且在当前列表中唯一。重复 key/ID 的实例不会被安全绑定;Vue 重渲染期间短暂出现的重复 DOM 会被节点暂时忽略,key 恢复唯一后自动重新关联,但页面不应依赖这种容错。
166
+ - 列表外的每个绑定控件都应有唯一的普通 `id` 或 `name`。列表内的重复实例可以使用同一个静态 `id`,节点通过列表 key 区分实例;不要为每一项拼接依赖运行时随机值的 ID。
167
+ - 列表控件在编辑页面中按实际渲染出的实例逐项选择。列表增加或删除后,应重新进入编辑器、补齐或移除对应映射,再 Apply。
168
+ - `v-for` 可以写在实际容器上,也可以写在内层 `<template>` 上。不要把 `v-for` 直接写在唯一的外层节点 Template 上。
169
+ - 不支持嵌套 `v-for`、动态列表来源、过滤或计算表达式、复杂 key、slot scope、自定义列表项事件和任意动态索引。
170
+
171
+ `v-if`、标签页切换、普通 DOM 重建和过渡造成的重新渲染可以使用。节点会在元素重新出现后恢复绑定;不要通过 `mounted`、`watch` 或定时器手动补绑或回写模型。
172
+
173
+ ### 3.3 Vuetify 控件
105
174
 
106
175
  ```html
107
176
  <template>
108
177
  <section>
109
- <v-slider id="room-volume" label="音量" v-model="volume" :min="0" :max="100" :step="1"></v-slider>
178
+ <v-slider id="room-volume" label="音量" v-model="volume"
179
+ :min="0" :max="100" :step="1"></v-slider>
110
180
  <v-switch id="room-mute" label="静音" v-model="muted"></v-switch>
111
181
  <v-select id="room-source" label="输入源" v-model="source" :items="sources"></v-select>
112
182
  <v-radio-group id="room-mode" label="模式" v-model="mode">
@@ -114,78 +184,132 @@ export default {
114
184
  <v-radio label="模式 B" value="B"></v-radio>
115
185
  </v-radio-group>
116
186
  <v-text-field id="room-title" label="名称" v-model="title"></v-text-field>
117
- <v-progress-linear id="room-level" :model-value="level" :max="100"></v-progress-linear>
187
+ <v-progress-linear id="room-meter" v-model="meter" :max="100"></v-progress-linear>
118
188
  <span id="room-status">{{ status }}</span>
119
189
  </section>
120
190
  </template>
191
+
121
192
  <script>
122
193
  export default {
123
194
  data () {
124
195
  return {
125
- volume: 0, muted: false, source: 'A', sources: ['A', 'B'],
126
- mode: 'A', title: '', level: 0, status: '待机'
196
+ volume: 0,
197
+ muted: false,
198
+ sources: ['A', 'B'],
199
+ source: 'A',
200
+ mode: 'A',
201
+ title: '',
202
+ meter: 0,
203
+ status: '待机'
127
204
  }
128
205
  }
129
206
  }
130
207
  </script>
131
208
  ```
132
209
 
133
- 示例选项 A/B 只是占位数据,不保证与实际设备相符。部署人员应确认驱动的枚举值,再决定前端选项。节点不会自动生成或改写下拉菜单选项。
210
+ 当前 Vuetify 适配器包括 `v-slider`、`v-switch`、`v-checkbox`、`v-select`、`v-radio-group`、`v-text-field`、`v-textarea`、`v-progress-linear` 和 `v-progress-circular`。普通布局组件可以使用,但布局组件本身不是绑定目标。
211
+
212
+ 以下写法当前不支持:
134
213
 
135
- 图片明暗、数值旁显等效果可以正常依赖 `volume` 或 `light` 做 Vue/CSS 展示。这些展示元素不发送设备命令,也不要反过来用 watch、mounted 或定时器重置正在绑定的字段。
214
+ - `v-btn` 作为自动设备按钮;需要布尔设备控制时使用语义按钮写法。
215
+ - `v-range-slider`、多选、`return-object`、对象模型、数组模型、自定义 `true-value`/`false-value`。
216
+ - `v-autocomplete`、`v-combobox`、原生 radio 和带独立事件处理器的子控件。
217
+ - 动态量程;range 的 `min`、`max`、`step` 必须是数字字面量或数字字面量绑定。`v-slider` 必须满足 min < max、step > 0。
218
+ - `v-progress-linear` 的 `min`/`step`、`indeterminate`、`clickable`;`v-progress-circular` 的 `min`/`max`/`step`、`indeterminate`。前者按 0 到 max 工作,后者固定按 0 到 100 工作。
136
219
 
137
- ## 5. 部署与绑定
220
+ 下拉选项可以是标量数组或普通 `{ title, value }` 选项,但模型必须是单个标量,且值必须与设备枚举值一致。示例中的 A/B 只是页面占位数据,节点不会自动生成或修改下拉选项。
138
221
 
139
- 1. 将页面粘贴到 `ui-control-panel` Template,选择 Dashboard 页面,设置 Room ID、普通账号及独立的编辑账号。普通账号密码至少 12 个字符;账号密码由节点配置,不能写入 H5。保存并 Deploy。
140
- 2. 重新打开节点,点击 `Open binding editor`,用编辑账号登录。Select 默认激活,可以点击、右键或触屏长按可绑定控件。`Open page` 打开普通控制页面,使用普通用户账号登录。
141
- 3. 选择在线设备、模式、Write point、Feedback point、编号方式,保存草稿。草稿和亮紫色边框不表示已部署;边框只在编辑模式显示。
142
- 4. 点 Apply。节点检查当前模板、目录参数和 Flow 版本,备份后替换该面板此前生成的桥接并保存。Apply 不替用户 Deploy;当 Automator 报告外部 Flow 变化时,编辑器只刷新一次 Flow 页面,之后由用户检查并手动 Deploy。不会无限追加旧桥接,也不会清除其他面板的绑定。Apply 成功后仍在编辑页,无需再次登录。
143
- 5. 点 Exit,回到普通页面登录页,再输入普通账号密码。普通账号不能进入编辑模式;不登录不能操作设备。进程重启或凭据变化可能要求重新登录。
222
+ ## 4. Meter 与普通数值反馈
144
223
 
145
- Bindings 列表可 Edit 或 Remove;这些操作先改草稿,最后统一 Apply。从列表进入 Edit,保存或取消后返回列表。刷新、离开或重启前先 Apply,避免草稿丢失。
224
+ Meter 是驱动端的特殊只读数据类型,不是“另一个滑杆”。页面作者只声明普通进度/电平控件;编辑人员把它绑定到带 Meter 元数据的 Feedback point 后,ControlPanel 自动完成批量订阅、同帧投影、页面侧量程换算、过期判断和重连。
146
225
 
147
- 普通 Deploy 不能替代网页 Apply。Template 改动可能使已保存控件失效;重新 Deploy 后检查绑定。Apply 的原有模板、目录、Flow 版本和输入校验保持不变;Flow 页面刷新不自动合并画布改动,也不自动 Deploy。
226
+ - 一个 Meter 绑定只能使用 `read` 模式,不能配置 Write point。
227
+ - 只有驱动点位带有有效 `.metadata.meter`,并且是只读整数/浮点参数时,才会出现在 Meter 可选目标中。
228
+ - 普通 numeric `level`、`volume` 或其他连续参数不会因为名字像电平就自动变成 Meter。
229
+ - 一个面板最多支持 256 个 Meter 绑定。多个 Meter 会按流聚合,前端每帧一次性更新本页所有对应表头;页面不编写任何批处理或 socket 代码。
230
+ - Meter 需要独立字段,例如 `meter1`、`meter2`。不要与音量滑杆的 `volume` 共用字段,也不要让不同点位共用一个 Meter 模型。
231
+ - 断线、无效帧和过期帧由节点处理;页面看到的是最后值并带无效状态,页面作者不添加轮询或回写逻辑。
232
+ - 原生 `<meter>`/`<progress>` 默认页面范围是 0 到 1;Vuetify 进度组件分别使用固定的 0 到 max 或 0 到 100。应按页面显示范围声明固定量程。
148
233
 
149
- 设备必须在线且当前运行环境的 AMX 目录可返回参数,才能查询或重新 Apply 相关绑定。已保存映射在 Flow 中,启动和显示页面不要求驱动在线;但断线时不能保证设备控制或新反馈。Mac 安装模块不等于 MUSE 已安装,Push 也不等于复制本地 node_modules。部署模块属于运行环境操作,不属于前端开发职责。
234
+ 量程适配发生在网页侧:页面量程与驱动量程之间做线性换算,反馈按相反方向换算,不修改驱动或协议。两端都必须是有限且 max > min 的数字;否则按原值传递,不能猜测量程。整数驱动值会取整,反馈会按页面 step 对齐。
235
+
236
+ 普通只读反馈使用文本展示即可:
237
+
238
+ ```html
239
+ <span id="room-status">{{ status }}</span>
240
+ <output id="room-value">{{ outputValue }}</output>
241
+ ```
150
242
 
151
- ## 6. 匹配、编号与范围
243
+ 文本展示只使用一个简单字段插值,例如 `{{ status }}` 或 `{{ outputValue }}`。不要使用 `toFixed()`、拼接表达式或多个插值作为绑定模型。
152
244
 
153
- 点位筛选基于参数描述,而不是页面文字或驱动显式 `controlType`:
245
+ ## 5. 语义开关按钮
246
+
247
+ 普通文字 `<button>` 不会自动成为设备控件。需要布尔设备控制时使用标准语义,不添加事件:
248
+
249
+ ```html
250
+ <button id="room-power" type="button" :aria-pressed="powered">电源</button>
251
+ <button id="room-mute" type="button" role="switch" :aria-checked="muted">静音</button>
252
+ ```
253
+
254
+ 在 `data()` 中把 `powered` 和 `muted` 初始化为布尔值。也可以使用静态 `aria-pressed="false"` 或 `aria-checked="false"`,由节点更新状态。动态 ARIA 表达式只能是顶层字段,不能写取反、条件或函数。
255
+
256
+ ## 6. 绑定与编辑流程
257
+
258
+ 页面作者只需交付 Template。实际绑定由使用者在编辑页面完成:
259
+
260
+ 1. 将 Template 保存到 `ui-control-panel`,配置 Dashboard 页面、Room ID、普通登录账号和独立编辑账号,然后保存并 Deploy。
261
+ 2. 重新打开节点,点击 `Open binding editor`。编辑页登录使用编辑账号;进入后 Select 默认激活。`Open page` 打开普通控制页面,普通页面必须使用普通账号登录。
262
+ 3. 在 Select 模式选择控件,选择设备、模式、Write point、Feedback point 和编号方式。已绑定控件在编辑模式显示亮紫色边框;这只是编辑提示。
263
+ 4. 点 Apply。节点验证当前模板、设备目录和映射,备份当前 Flow,并通过运行时 Flow API 更新当前面板的映射和受管理桥接。它不会导入、清除或追加其他面板的 Flow。
264
+ 5. Apply 成功后仍停留在编辑页;Bindings 列表可以 Edit 或 Remove,取消/保存后回到列表。Apply 后不应要求再次登录;点 Exit 才回到普通页面登录页。
265
+
266
+ Automator 的同步脚本只监听更新后的运行时 Flow 版本,并最多刷新一次 Flow 编辑器页面;它不读取、导入、合并、清除或再次提交 Flow。当前实现不判断画布是否有未 Deploy 改动,也不会合并画布草稿;在 Apply 前应先处理好 Automator 中未保存的画布改动。
267
+
268
+ Template 修改可能使已有映射失效。重新 Deploy 后,应重新打开绑定编辑器检查候选控件;列表增删、控件 ID/标签类型/模型写法改变后,也应重新检查并 Apply。普通页面登录账号不能进入编辑模式,未登录不能操作设备。
269
+
270
+ ## 7. 点位匹配与量程
271
+
272
+ 编辑器根据 AMX 参数描述推导可匹配类型,不根据页面文字,也不把驱动作者写入的旧式显式 `controlType` 当作页面契约。主要依据是 `.kind`、`.type`、`.access`、`.min`、`.max`、`.enums` 和 `.metadata`。Meter 的 `.metadata.meter` 是实时通道所需的特殊元数据,不能省略。
154
273
 
155
274
  | 参数描述 | 可匹配控件 |
156
275
  | --- | --- |
157
- | boolean,或无枚举且范围恰为 0–1 的 integer | 布尔开关;整数 0/1 由节点转换为前端布尔值。 |
158
- | integer/float 数值参数 | range、数值进度;有枚举或 0/1 布尔语义时优先对应相应类别。 |
159
- | enum,或带枚举描述的 integer/string | 单选枚举。当前整数路径以 `/source`、`/preset` 结尾也会按选择类处理;仍需确认合法选项。 |
160
- | string/dstring | 文本输入。 |
161
- | 上述类型的可读参数 | 简单文本展示也可绑定。 |
162
-
163
- 只有 `kind=param` 且具备相应 ro/wo/rw 权限的点位才参与绑定。范围未知不会通过“读取一个当前值”猜出量程;普通命令、数组或未知描述不保证适配。页面无需自己判断这些类型。
276
+ | boolean,或没有枚举且范围恰为 0 到 1 的 integer | 布尔开关;整数 0/1 会转换为前端布尔值。 |
277
+ | 可写 integer/float | range 或 `v-slider`;用于音量/增益等普通连续控制。 |
278
+ | 只读 integer/float,没有 Meter 元数据 | 文本展示等普通只读反馈,不能选择为 Meter。 |
279
+ | 只读 integer/float,带有效 `.metadata.meter` | Meter、原生 `meter`/`progress` 或 Vuetify 进度组件;只能读。 |
280
+ | enum,或带枚举描述的 integer/string | 单选枚举。 |
281
+ | 没有枚举但路径以 `/source` 或 `/preset` 结尾的 integer | 选择类参数,仍需确认合法选项。 |
282
+ | string/dstring | 文本输入或文本展示。 |
164
283
 
165
- Write point 改动时 Feedback point 跟随;单独改 Feedback point 不反向改 Write point。只读模式没有 Write point。编号选择默认 Zero-based;实际保存优先采用目录里的 `.onebased`,目录未提供时才采用用户选择。它不是对网页音量值加减 1,也不是驱动 `protocolChannel` 的编号。
284
+ 只有 `kind=param` 且具备相应读/写权限的点位参与绑定。普通命令、数组和未知描述不保证可绑定。
166
285
 
167
- 量程适配只发生在网页侧:有效的页面 [uiMin, uiMax] 与驱动 [driverMin, driverMax] 之间线性换算,反馈反向换算,不改驱动代码或协议。两边都必须是有限数字且 max > min;缺少有效量程时原值传递,不能假定自动缩放。整数设备目标会取整;回到网页时按页面 step 对齐。Write/Feedback 使用不同量程时分别换算,部署人员应确认二者属于同一逻辑控制量。
286
+ Write point 改动时 Feedback point 会跟随;单独改 Feedback point 不会反向改 Write point。Meter 没有 Write point。编号方式默认 Zero-based;如果目录点位带有 `.onebased`,保存时以目录描述为准,否则使用编辑页选择。编号方式只影响驱动点位编号,不会给网页值加减 1。
168
287
 
169
- ## 7. 排查顺序
288
+ ## 8. 常见排查
170
289
 
171
290
  | 现象 | 先检查 |
172
291
  | --- | --- |
173
- | 控件不能选中 | 是否在支持清单、是否有事件指令/复杂模型/v-for、自闭合 Vuetify、重复 ID;阅读编辑页给出的拒绝原因。 |
174
- | 有设备但点位为空 | 在线状态、参数目录、值类型与 ro/wo/rw 权限;不是所有设备命令都属于可绑定参数。 |
175
- | 草稿有紫框但普通页无效 | Apply 是否成功、模式和点位是否正确、桥接是否同步、MUSE 是否加载预期模块版本。 |
176
- | 滑杆动不了 | 是否仍在 Select 编辑模式、模型类型是否正确、是否存在手写回写逻辑;不要先改驱动。 |
177
- | 页面变化但设备不变 | 写映射和驱动收发日志。前端变化不证明命令到达设备。 |
178
- | 设备已变但页面旧值 | Feedback point、事件/读取是否有新反馈。节点不能凭写入成功制造真实设备反馈。 |
179
- | 原生与 Vuetify 表现不同 | 是否符合各自模型、事件和量程约束;不要给绑定控件额外增加发送监听。 |
180
-
181
- 隔离测试只验证软件逻辑,不能证明真实设备闭环、MUSE 网络或驱动版本都正确。
182
-
183
- ## 8. 给 AI 的交付检查
184
-
185
- - 输出可粘贴的 Template 组件片段,不输出 Node-RED Flow、设备路径、绑定 JSON 或账号密码。
186
- - 所有待绑定控件位于支持清单,使用稳定唯一 ID;数据模型简单、独立且类型正确。
187
- - 页面量程明确、数值常量有效;下拉菜单选项与实际设备值对应。
188
- - 没有手写 `data-cp-*`、旧发送接口、设备通信、绑定控件事件或复杂模型。
189
- - Vuetify 标签成对闭合;不把自动绑定目标放入 v-for。
190
- - 保留正常的视觉与纯前端交互;不为了绑定重写驱动或添加第二套控制逻辑。
191
- - 不声称任意普通页面都可无条件自动绑定;不支持的组件要明确说明,不能伪造兼容写法。
292
+ | 控件不能选择 | 是否在支持清单;是否有事件指令、复杂模型、动态量程、重复/不唯一 key、重复 ID 或自闭合 Vuetify 标签。 |
293
+ | `v-for` 实例没有出现 | 来源是否是顶层数组;key 是否严格符合对象/数组规则;列表项是否实际渲染且 key 唯一。 |
294
+ | 设备有了但点位为空 | 设备在线状态、AMX 参数目录、`kind/type/access` 和读写权限;不是所有命令都属于可绑定参数。 |
295
+ | Meter 可以选但无值 | 点位是否带 `.metadata.meter`;驱动是否实际发布 manifest 和 frame;页面字段是否与普通 Volume 分开。 |
296
+ | 页面一直 Loading | 查看模板编译错误,重点检查原生 Meter/Progress 的 v-model 写法、自闭合 Vuetify、动态表达式和不支持的组件。设备离线不应被页面作者通过轮询“修复”。 |
297
+ | 滑杆动不了 | 是否仍处于 Select 编辑模式;模型是否为数字;是否给绑定控件添加了自己的事件或回写逻辑。 |
298
+ | 页面变化但设备不变 | 检查映射的 Write point、设备权限和运行日志。前端模型变化不等于设备已确认执行。 |
299
+ | 设备值变了但页面旧 | 检查 Feedback point、普通反馈事件或 Meter frame 是否到达;节点不会凭写入成功伪造真实反馈。 |
300
+ | Apply 失败并提示 Flow changed | 先处理 Automator 中的未 Deploy 改动,再刷新编辑器并重新打开绑定页;不要手工复制或追加生成桥接。 |
301
+
302
+ 隔离测试只能证明模板识别、映射和软件通信逻辑,不能证明真实设备、MUSE 网络或驱动版本已经完成闭环测试。
303
+
304
+ ## 9. 给 AI 的交付检查
305
+
306
+ - 输出可粘贴的 Template 片段,不输出 Node-RED Flow、设备路径、绑定 JSON、账号密码或 ControlPanel API 调用。
307
+ - 所有待绑定控件位于支持清单,模型字段简单、独立、可写且类型正确。
308
+ - 列表只使用本指南规定的 `v-for`、`:key` 和模型路径;对象 key 稳定唯一,数组 key 使用 index。
309
+ - 列表内的 Meter、Volume 和其他控件使用不同模型字段;重复实例依靠稳定 key 区分,不生成随机 ID。
310
+ - 页面量程明确、数值常量有效;枚举选项与实际设备值对应。
311
+ - 没有手写 `data-cp-*`、设备通信、轮询、反馈回写、绑定控件事件或第二套发送逻辑。
312
+ - 实时表头仍是普通 HTML/Vue/Vuetify 控件;没有 stream、socket、订阅、帧协议或驱动代码。
313
+ - Vuetify 标签成对闭合;不使用本指南明确列出的不支持组件和属性。
314
+ - 保留正常的视觉和纯前端交互;不要为了绑定修改驱动或猜测设备量程。
315
+ - 不声称任意普通页面都能无条件自动绑定;遇到不支持的组件或表达式,应改写为支持的普通写法。
package/README.md CHANGED
@@ -10,7 +10,9 @@ Both logins work over HTTP on a trusted local network. HTTPS is recommended for
10
10
 
11
11
  Bindings are stored in `visualBindings` on the node. The runtime reads that mapping to authenticate commands, route feedback, and generate a native bridge. The page author does not put device paths, `data-cp-*` attributes, or device-send code in the Template. Author-provided `data-cp-*` attributes are rejected.
12
12
 
13
- Supported controls include native range, checkbox, semantic pressed button, select, text input, meter, progress, and simple text display. Give repeated controls distinct standard IDs. Simple top-level Vue `v-model` works; controls with existing send handlers or ambiguous dynamic behavior are rejected until a suitable adapter exists. See `CONTROL_PANEL_AI_GUIDE.md` for examples and limits.
13
+ Supported controls include native range, checkbox, semantic pressed button, select, text input, meter, progress, and simple text display. Give repeated controls distinct standard IDs. Simple top-level Vue `v-model` works, including object-list models such as `item.volume`, `item.audio.volume`, or `items[index].volume`, primitive array models such as `values[index]`, and an inner `<template v-for>` for multiple controls per row. During Vue re-rendering, a temporary duplicate list key is ignored until the key becomes unique again, so bindings do not attach twice. Controls with existing send handlers or ambiguous dynamic behavior are rejected until a suitable adapter exists. See `CONTROL_PANEL_AI_GUIDE.md` for examples and limits.
14
+
15
+ Realtime meter delivery does not change the page-authoring contract. Authors use ordinary `meter`, `progress`, `v-progress-linear`, or `v-progress-circular` controls with stable IDs and simple top-level fields. They do not declare devices, point paths, stream IDs, sockets, subscriptions, or frame formats. A `volume` binding is a writable gain setting with ordinary feedback; a `meter` binding is read-only realtime signal telemetry and requires a target that advertises Meter metadata. A normal numeric `level` target is not automatically a Meter. When the selected feedback point advertises realtime meter delivery, ControlPanel automatically shares the device stream, projects complete frames to the bound controls, applies webpage-side range conversion, and handles stale/reconnected data.
14
16
 
15
17
  Vuetify adapters cover `v-slider`, `v-switch`, `v-checkbox`, `v-select`, `v-radio-group`, `v-text-field`, `v-textarea`, and read-only `v-progress-linear` and `v-progress-circular`. Interactive components use a simple top-level `v-model`; progress may also use a simple `:model-value` field. Sliders accept fixed numeric `min`, `max`, and `step` props, including numeric Vue bindings such as `:max="100"`. Text fields commit on change, blur, or Enter (`v-text-field` only). Selection values must match the device's enum values; circular progress uses a fixed 0-100 range. `v-model.number` is accepted for numeric sliders and scalar selections, but not for boolean or text controls. Dynamic bounds, array-valued switches, multi-select, object selections, custom true/false values, indeterminate progress, and controls with their own event handlers need a separate adapter.
16
18
 
@@ -66,7 +66,7 @@ function resolveBoundFeedback (routes, message) {
66
66
  const matches = []
67
67
  for (const [id, route] of routes) {
68
68
  if (route.control.mode === 'write' || route.binding?.device !== message.device ||
69
- route.binding.readPath !== message.path) continue
69
+ route.binding.readPath !== message.path || route.binding.readMeta?.meter) continue
70
70
  const type = route.control.type
71
71
  const metadata = route.binding.readMeta
72
72
  let value = message.payload
@@ -1,5 +1,6 @@
1
1
  const { schemaVersion } = require('./control-type-contract.json')
2
2
  const { runtimeJsonRequest } = require('./panel-http')
3
+ const { meterRoute } = require('./meter-route')
3
4
 
4
5
  function catalogBase (settings) {
5
6
  const port = Number(settings?.uiPort)
@@ -73,6 +74,7 @@ async function listCatalogTargets (base, device, fetcher = fetch) {
73
74
  const min = catalogBound(properties['.min'])
74
75
  const max = catalogBound(properties['.max'])
75
76
  const enums = properties['.enums']
77
+ const meter = properties['.metadata']?.meter ? meterRoute(properties['.metadata'].meter) : null
76
78
  return {
77
79
  path: item.path,
78
80
  kind: properties['.kind'] || '',
@@ -81,9 +83,10 @@ async function listCatalogTargets (base, device, fetcher = fetch) {
81
83
  onebased: typeof properties['.onebased'] === 'boolean' ? properties['.onebased']
82
84
  : typeof item.onebased === 'boolean' ? item.onebased : undefined,
83
85
  min, max, enums,
84
- ...inferredControl(item.path, properties, type, min, max, enums),
86
+ ...(meter ? { controlType: 'meter' } : inferredControl(item.path, properties, type, min, max, enums)),
85
87
  controlSchemaVersion: schemaVersion,
86
- unit: properties['.metadata']?.unit || ''
88
+ unit: properties['.metadata']?.unit || '',
89
+ ...(meter ? { meter } : {})
87
90
  }
88
91
  })
89
92
  const unique = new Map()
package/control-panel.js CHANGED
@@ -10,6 +10,7 @@ const { createPanelEditor } = require('./panel-editor-auth')
10
10
  const { applyVisualBindings, editorConfig, getSnapshot } = require('./panel-visual-apply')
11
11
  const { createGateway } = require('./gateway.cjs')
12
12
  const { managedBridgeRevision } = require('./managed-bridge-revision')
13
+ const { createMeterRoom, createPanelMeter } = require('./panel-meter')
13
14
 
14
15
  let statestore
15
16
  const authHandoffKey = crypto.randomBytes(32)
@@ -69,6 +70,7 @@ module.exports = function (RED) {
69
70
  const base = catalogBase(RED.settings)
70
71
  const activePanels = new Map()
71
72
  const activeEditors = new Map()
73
+ const meterRooms = new Map()
72
74
  RED.httpAdmin.get('/ui-control-panel/editor-sync.js', RED.auth.needsPermission('nodes.read'), (_req, res) => {
73
75
  res.setHeader('Cache-Control', 'no-store')
74
76
  res.sendFile(path.join(__dirname, 'panel-flow-editor-sync.js'))
@@ -147,6 +149,16 @@ module.exports = function (RED) {
147
149
  }
148
150
  node.status?.(bridgeReady ? {} : { fill: 'red', shape: 'ring', text: 'bridge out of sync' })
149
151
 
152
+ let meter
153
+ if ([...routes.values()].some(route => route.binding.readMeta?.meter) && /^[a-zA-Z0-9_-]+$/.test(boundRoom || '')) {
154
+ try {
155
+ let hub = meterRooms.get(boundRoom)
156
+ if (!hub) { hub = createMeterRoom({ room: boundRoom }); meterRooms.set(boundRoom, hub) }
157
+ meter = createPanelMeter({ panelId: node.id, room: boundRoom, bindings: visualBindings, hub,
158
+ onSubscribe: () => { if (bridgeReady) node.send({ kind: 'read-all', room: boundRoom }) } })
159
+ } catch (error) { node.error(error) }
160
+ }
161
+
150
162
  let gateway
151
163
  let gatewayAuthKey
152
164
  if (visualBinding && /^[a-zA-Z0-9_-]+$/.test(boundRoom || '')) {
@@ -160,6 +172,8 @@ module.exports = function (RED) {
160
172
  prefix: endpoint,
161
173
  secureCookie: config.secureCookie === true,
162
174
  restoredSessions: takeSessions(gatewayHandoffs, node.id, gatewayAuthKey),
175
+ onAuthenticatedRequest: (req, res, identity) => meter?.ticket(req, res, identity,
176
+ () => gateway?.sessionActive(identity.token)) || false,
163
177
  onBoundCommand: (message, identity) => {
164
178
  if (!bridgeReady) return false
165
179
  node.send({ ...resolveBoundCommand(routes, message), room: identity.room, user: identity.user })
@@ -188,6 +202,8 @@ module.exports = function (RED) {
188
202
  password: node.credentials?.editorPassword,
189
203
  secureCookie: config.secureCookie === true,
190
204
  restoredSessions: takeSessions(editorHandoffs, node.id, editorAuthKey),
205
+ onAuthenticatedRequest: (req, res, identity) => meter?.ticket(req, res, identity,
206
+ () => editor?.sessionActive(identity.token)) || false,
191
207
  getConfig: async req => editorConfig(await getSnapshot(req.socket.localPort, fetch,
192
208
  RED.settings.httpAdminRoot ?? '/'), node.id),
193
209
  getDevices: async () => {
@@ -214,7 +230,7 @@ module.exports = function (RED) {
214
230
  }
215
231
 
216
232
  const panelConfig = () => (boundMode || visualBinding) ? {
217
- endpoint, room: boundRoom,
233
+ endpoint, room: boundRoom, meter: Boolean(meter),
218
234
  ...(visualBinding ? { visual: {
219
235
  panelId: node.id,
220
236
  editorEndpoint: `/control-panel-edit/panels/${node.id}`,
@@ -225,8 +241,15 @@ module.exports = function (RED) {
225
241
 
226
242
  const events = {
227
243
  onAction: !boundMode,
244
+ ...(meter ? { onSocket: meter.hooks } : {}),
228
245
  beforeSend (msg) {
229
246
  if (msg?._controlPanel?.kind === 'command') throw new Error('Bound commands require authenticated HTTP')
247
+ if (['device-meter-frame', 'device-meter-manifest'].includes(msg?.kind)) {
248
+ if (bridgeReady) meter?.accept(msg)
249
+ // Dashboard 1.31 reads properties before checking whether a message
250
+ // should be emitted. `false` is property-safe and suppresses emit.
251
+ return false
252
+ }
230
253
  if (boundMode) {
231
254
  if (msg?.kind === 'device-feedback' && gateway) {
232
255
  gateway.boundFeedback(resolveBoundFeedback(routes, msg))
@@ -279,7 +302,13 @@ module.exports = function (RED) {
279
302
  } catch (error) {
280
303
  node.error(error)
281
304
  node.status?.({ fill: 'red', shape: 'ring', text: 'visual template unsupported' })
282
- return
305
+ const message = String(error.message).replace(/[&<>]/g,
306
+ char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[char])
307
+ widgetConfig = { ...config, type: 'ui-template',
308
+ format: '<template><section role="alert" style="padding:24px;color:#a52a35;background:#fff">' +
309
+ '<h2>ControlPanel template error</h2><pre v-pre style="white-space:pre-wrap;overflow-wrap:anywhere">' +
310
+ message + '</pre></section></template>',
311
+ passthru: false, storeOutMessages: false, resendOnRefresh: false }
283
312
  }
284
313
  parentNode = group || page || ui
285
314
  if (group) group.register(node, widgetConfig, events)
@@ -295,6 +324,8 @@ module.exports = function (RED) {
295
324
  if (editor) holdSessions(editorHandoffs, node.id, editorAuthKey, editor.sessionSnapshot())
296
325
  gateway?.close()
297
326
  editor?.close()
327
+ meter?.close()
328
+ if (meterRooms.get(boundRoom)?.stats().panels === 0) meterRooms.delete(boundRoom)
298
329
  if (activePanels.get(node.id) === gateway) activePanels.delete(node.id)
299
330
  if (activeEditors.get(node.id) === editor) activeEditors.delete(node.id)
300
331
  })