vite-plugin-visual-selector 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +235 -661
- package/dist/index.d.ts +6 -2
- package/dist/index.js +900 -6
- package/dist/index.js.map +1 -1
- package/dist/runtime-rRIWQKz-.d.ts +204 -0
- package/dist/runtime.d.ts +1 -8
- package/dist/runtime.js +846 -106
- package/dist/runtime.js.map +1 -1
- package/package.json +7 -5
- package/dist/types-DlgpChn_.d.ts +0 -16
package/README.md
CHANGED
|
@@ -1,795 +1,369 @@
|
|
|
1
1
|
# vite-plugin-visual-selector
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
面向 React/JSX/TSX 的 Vite 插件,实现"源码同源元素联动高亮"能力。
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
1. **编译时注入**:在 JSX 编译阶段给原生 DOM 元素注入 `data-source-location`
|
|
8
|
-
2. **运行时桥接**:在预览页 / iframe 中启用 `setupSelectionBridge()`,实现 hover、高亮、同源元素批量选中、`postMessage` 通知父页面
|
|
9
|
-
|
|
10
|
-
---
|
|
11
|
-
|
|
12
|
-
## 一、它解决什么问题
|
|
13
|
-
|
|
14
|
-
在可视化编辑器里,用户点到页面上的某个元素时,编辑器通常需要知道:
|
|
15
|
-
|
|
16
|
-
- 这个 DOM 节点来自哪一段源码
|
|
17
|
-
- 页面上还有哪些元素与它来自同一个 JSX 表达式
|
|
18
|
-
- 如何把这些“同源元素”一起高亮出来
|
|
19
|
-
- 如何把选中结果同步给父级编辑器 UI
|
|
20
|
-
|
|
21
|
-
这个插件就是为这个目标设计的。
|
|
22
|
-
|
|
23
|
-
典型效果:
|
|
24
|
-
|
|
25
|
-
```tsx
|
|
26
|
-
{items.map((item) => (
|
|
27
|
-
<div className="cell">{item.label}</div>
|
|
28
|
-
))}
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
如果这几个 `div` 都来自同一行 `.map()` JSX,那么点击其中一个时,所有同样带有相同 `data-source-location` 的元素都会一起被高亮。
|
|
5
|
+
编译时给 DOM 打上源码坐标(`data-source-location`),运行时根据坐标将同源元素聚合高亮,支持行内编辑和远程 DOM 修改。
|
|
32
6
|
|
|
33
7
|
---
|
|
34
8
|
|
|
35
|
-
##
|
|
36
|
-
|
|
37
|
-
### 编译时
|
|
38
|
-
|
|
39
|
-
- 只处理 `.jsx` / `.tsx`
|
|
40
|
-
- 只给**原生 DOM 标签**注入属性,例如:`div`、`span`、`button`
|
|
41
|
-
- 不给 React 组件注入,例如:`<Card />`
|
|
42
|
-
- 默认属性名:`data-source-location`
|
|
43
|
-
- 默认值格式:`相对路径:行:列`
|
|
44
|
-
|
|
45
|
-
示例:
|
|
46
|
-
|
|
47
|
-
```html
|
|
48
|
-
<div data-source-location="src/App.tsx:12:6">...</div>
|
|
49
|
-
```
|
|
9
|
+
## 一、稳定公开 API
|
|
50
10
|
|
|
51
|
-
###
|
|
52
|
-
|
|
53
|
-
- hover 时高亮同 source-location 的元素
|
|
54
|
-
- click 时选中同 source-location 的元素
|
|
55
|
-
- 给每个匹配元素绘制 overlay
|
|
56
|
-
- 向父页面发送 `postMessage`
|
|
57
|
-
- 支持销毁和清空当前高亮状态
|
|
58
|
-
|
|
59
|
-
---
|
|
60
|
-
|
|
61
|
-
## 三、安装
|
|
11
|
+
### 安装
|
|
62
12
|
|
|
63
13
|
```bash
|
|
64
14
|
npm install vite-plugin-visual-selector
|
|
65
15
|
```
|
|
66
16
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
```bash
|
|
70
|
-
npm install
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
---
|
|
74
|
-
|
|
75
|
-
## 四、最基本的使用方式
|
|
76
|
-
|
|
77
|
-
### 1)在 `vite.config.ts` 中注册插件
|
|
17
|
+
### 编译时插件
|
|
78
18
|
|
|
79
19
|
```ts
|
|
20
|
+
// vite.config.ts
|
|
80
21
|
import { defineConfig } from 'vite'
|
|
81
22
|
import react from '@vitejs/plugin-react'
|
|
82
23
|
import { visualSelectorPlugin } from 'vite-plugin-visual-selector'
|
|
83
24
|
|
|
84
25
|
export default defineConfig({
|
|
85
|
-
plugins: [
|
|
86
|
-
react(),
|
|
87
|
-
visualSelectorPlugin(),
|
|
88
|
-
],
|
|
26
|
+
plugins: [react(), visualSelectorPlugin()],
|
|
89
27
|
})
|
|
90
28
|
```
|
|
91
29
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
注册后,插件会在 Vite transform 阶段扫描 JSX / TSX 模块,并给原生 DOM 节点自动插入 `data-source-location`。
|
|
95
|
-
|
|
96
|
-
---
|
|
97
|
-
|
|
98
|
-
## 五、进阶配置
|
|
99
|
-
|
|
100
|
-
插件入口:
|
|
101
|
-
|
|
102
|
-
```ts
|
|
103
|
-
import { visualSelectorPlugin } from 'vite-plugin-visual-selector'
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
类型定义:
|
|
30
|
+
#### `VisualSelectorPluginOptions`
|
|
107
31
|
|
|
108
32
|
```ts
|
|
109
33
|
interface VisualSelectorPluginOptions {
|
|
34
|
+
/** 需要处理的文件匹配规则,默认 [/\.[jt]sx$/] */
|
|
110
35
|
include?: RegExp[]
|
|
36
|
+
/** 需要排除的文件匹配规则,默认 [/node_modules/] */
|
|
111
37
|
exclude?: RegExp[]
|
|
38
|
+
/** 注入属性名,默认 "data-source-location" */
|
|
112
39
|
attributeName?: string
|
|
40
|
+
/** 项目根目录,用于计算相对路径,默认 process.cwd() */
|
|
113
41
|
root?: string
|
|
114
42
|
}
|
|
115
43
|
```
|
|
116
44
|
|
|
117
|
-
###
|
|
118
|
-
|
|
119
|
-
```ts
|
|
120
|
-
import { defineConfig } from 'vite'
|
|
121
|
-
import react from '@vitejs/plugin-react'
|
|
122
|
-
import { visualSelectorPlugin } from 'vite-plugin-visual-selector'
|
|
123
|
-
|
|
124
|
-
export default defineConfig({
|
|
125
|
-
plugins: [
|
|
126
|
-
react(),
|
|
127
|
-
visualSelectorPlugin({
|
|
128
|
-
include: [/\.[jt]sx$/],
|
|
129
|
-
exclude: [/node_modules/, /\.test\./],
|
|
130
|
-
attributeName: 'data-source-location',
|
|
131
|
-
root: process.cwd(),
|
|
132
|
-
}),
|
|
133
|
-
],
|
|
134
|
-
})
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
### 配置项说明
|
|
138
|
-
|
|
139
|
-
#### `include?: RegExp[]`
|
|
140
|
-
|
|
141
|
-
控制哪些文件会进入 transform。
|
|
142
|
-
|
|
143
|
-
默认值:
|
|
144
|
-
|
|
145
|
-
```ts
|
|
146
|
-
[/\.[jt]sx$/]
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
也就是默认只处理 JSX / TSX 文件。
|
|
150
|
-
|
|
151
|
-
#### `exclude?: RegExp[]`
|
|
152
|
-
|
|
153
|
-
控制哪些文件会被跳过。
|
|
154
|
-
|
|
155
|
-
默认值:
|
|
156
|
-
|
|
157
|
-
```ts
|
|
158
|
-
[/node_modules/]
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
#### `attributeName?: string`
|
|
162
|
-
|
|
163
|
-
自定义注入属性名。
|
|
164
|
-
|
|
165
|
-
默认值:
|
|
166
|
-
|
|
167
|
-
```ts
|
|
168
|
-
'data-source-location'
|
|
169
|
-
```
|
|
170
|
-
|
|
171
|
-
如果你的编辑器协议想用别的名字,可以改。
|
|
172
|
-
|
|
173
|
-
#### `root?: string`
|
|
174
|
-
|
|
175
|
-
用于生成相对路径。
|
|
176
|
-
|
|
177
|
-
例如源码绝对路径是:
|
|
178
|
-
|
|
179
|
-
```txt
|
|
180
|
-
/Users/me/project/src/App.tsx
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
如果 `root = /Users/me/project`,最终注入值会变成:
|
|
184
|
-
|
|
185
|
-
```txt
|
|
186
|
-
src/App.tsx:12:6
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
---
|
|
190
|
-
|
|
191
|
-
## 六、运行时接入方式
|
|
192
|
-
|
|
193
|
-
仅仅注册 Vite 插件还不够。
|
|
194
|
-
如果你想获得 Base44 那种“点击元素时,同源元素一起高亮”的能力,还需要在预览页中显式启用 runtime bridge。
|
|
195
|
-
|
|
196
|
-
运行时入口:
|
|
45
|
+
### 运行时 Agent
|
|
197
46
|
|
|
198
47
|
```ts
|
|
199
|
-
import {
|
|
200
|
-
```
|
|
48
|
+
import { setupVisualEditAgent } from 'vite-plugin-visual-selector/runtime'
|
|
201
49
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
```ts
|
|
205
|
-
import { setupSelectionBridge } from 'vite-plugin-visual-selector/runtime'
|
|
206
|
-
|
|
207
|
-
setupSelectionBridge({
|
|
208
|
-
parentWindow: window.parent,
|
|
209
|
-
enableHover: true,
|
|
210
|
-
enableClickSelection: true,
|
|
211
|
-
})
|
|
50
|
+
const agent = setupVisualEditAgent()
|
|
212
51
|
```
|
|
213
52
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
- 预览页入口
|
|
217
|
-
- iframe 内部页面入口
|
|
218
|
-
- 或专门的 preview bootstrap 文件里
|
|
219
|
-
|
|
220
|
-
---
|
|
221
|
-
|
|
222
|
-
## 七、runtime API 详细说明
|
|
223
|
-
|
|
224
|
-
类型定义:
|
|
53
|
+
#### `VisualEditAgentOptions`
|
|
225
54
|
|
|
226
55
|
```ts
|
|
227
|
-
interface
|
|
56
|
+
interface VisualEditAgentOptions {
|
|
57
|
+
/** 属性名,默认 "data-source-location" */
|
|
228
58
|
attributeName?: string
|
|
229
|
-
|
|
230
|
-
parentWindow?: Pick<Window, 'postMessage'>
|
|
59
|
+
/** postMessage 的目标 origin,默认 "*" */
|
|
231
60
|
targetOrigin?: string
|
|
232
|
-
enableHover?: boolean
|
|
233
|
-
enableClickSelection?: boolean
|
|
234
61
|
}
|
|
235
62
|
```
|
|
236
63
|
|
|
237
|
-
|
|
64
|
+
#### `VisualEditAgentInstance`
|
|
238
65
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
要读取的元素标识属性名。
|
|
242
|
-
|
|
243
|
-
默认值:
|
|
66
|
+
`setupVisualEditAgent()` 返回控制实例:
|
|
244
67
|
|
|
245
68
|
```ts
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
通常不需要改,除非你在特殊上下文中手动桥接别的 window。
|
|
260
|
-
|
|
261
|
-
#### `parentWindow?: Pick<Window, 'postMessage'>`
|
|
262
|
-
|
|
263
|
-
接收消息的父窗口对象。
|
|
264
|
-
|
|
265
|
-
默认值:
|
|
266
|
-
|
|
267
|
-
```ts
|
|
268
|
-
window.parent
|
|
69
|
+
interface VisualEditAgentInstance {
|
|
70
|
+
/** 销毁 agent,移除所有监听器和 overlay */
|
|
71
|
+
destroy(): void
|
|
72
|
+
/** 开启/关闭编辑模式 */
|
|
73
|
+
enableEditMode(enabled: boolean): void
|
|
74
|
+
/** 手动选中元素 */
|
|
75
|
+
selectElement(element: Element): void
|
|
76
|
+
/** 清除所有选中和高亮 */
|
|
77
|
+
clearSelection(): void
|
|
78
|
+
/** 获取当前选中的 source id */
|
|
79
|
+
getSelectedId(): string | null
|
|
80
|
+
}
|
|
269
81
|
```
|
|
270
82
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
`postMessage` 的目标 origin。
|
|
274
|
-
|
|
275
|
-
默认值:
|
|
83
|
+
### npm 导出
|
|
276
84
|
|
|
277
85
|
```ts
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
生产环境里如果你有固定编辑器域名,建议收紧为明确 origin。
|
|
282
|
-
|
|
283
|
-
#### `enableHover?: boolean`
|
|
86
|
+
// 主入口 "vite-plugin-visual-selector"
|
|
87
|
+
export { visualSelectorPlugin, setupVisualEditAgent }
|
|
284
88
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
默认值:
|
|
288
|
-
|
|
289
|
-
```ts
|
|
290
|
-
true
|
|
89
|
+
// 运行时入口 "vite-plugin-visual-selector/runtime"
|
|
90
|
+
export { setupVisualEditAgent }
|
|
291
91
|
```
|
|
292
92
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
是否启用点击选中。
|
|
296
|
-
|
|
297
|
-
默认值:
|
|
298
|
-
|
|
299
|
-
```ts
|
|
300
|
-
true
|
|
301
|
-
```
|
|
93
|
+
完整类型导出见 [src/index.ts](src/index.ts)。
|
|
302
94
|
|
|
303
95
|
---
|
|
304
96
|
|
|
305
|
-
##
|
|
306
|
-
|
|
307
|
-
当前返回一个控制对象:
|
|
308
|
-
|
|
309
|
-
```ts
|
|
310
|
-
const bridge = setupSelectionBridge({...})
|
|
311
|
-
|
|
312
|
-
bridge.destroy()
|
|
313
|
-
bridge.clearSelection()
|
|
314
|
-
```
|
|
315
|
-
|
|
316
|
-
### `destroy()`
|
|
317
|
-
|
|
318
|
-
作用:
|
|
319
|
-
|
|
320
|
-
- 移除事件监听器
|
|
321
|
-
- 删除当前所有 overlay
|
|
322
|
-
- 释放当前激活的 bridge
|
|
323
|
-
|
|
324
|
-
适合场景:
|
|
325
|
-
|
|
326
|
-
- 预览页卸载
|
|
327
|
-
- iframe 切换页面
|
|
328
|
-
- 编辑器销毁
|
|
329
|
-
|
|
330
|
-
### `clearSelection()`
|
|
331
|
-
|
|
332
|
-
作用:
|
|
333
|
-
|
|
334
|
-
- 清除 selected overlays
|
|
335
|
-
- 清除 hover overlays
|
|
336
|
-
|
|
337
|
-
适合场景:
|
|
338
|
-
|
|
339
|
-
- 父编辑器主动取消选中
|
|
340
|
-
- 切换右侧面板时重置视觉状态
|
|
341
|
-
- 执行删除 / 替换元素后重置高亮
|
|
342
|
-
|
|
343
|
-
---
|
|
97
|
+
## 二、高级集成能力
|
|
344
98
|
|
|
345
|
-
|
|
99
|
+
### 父编辑器控制
|
|
346
100
|
|
|
347
|
-
|
|
101
|
+
父窗口通过 `postMessage` 控制 iframe 内的 agent:
|
|
348
102
|
|
|
349
103
|
```ts
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
dataSourceLocation: 'src/App.tsx:12:6',
|
|
356
|
-
rect: {
|
|
357
|
-
top: 120,
|
|
358
|
-
left: 300,
|
|
359
|
-
right: 348,
|
|
360
|
-
bottom: 168,
|
|
361
|
-
width: 48,
|
|
362
|
-
height: 48,
|
|
363
|
-
},
|
|
364
|
-
}
|
|
365
|
-
```
|
|
104
|
+
// 开启编辑模式
|
|
105
|
+
iframe.contentWindow.postMessage(
|
|
106
|
+
{ type: 'toggle-visual-edit-mode', data: { enabled: true } },
|
|
107
|
+
'*'
|
|
108
|
+
)
|
|
366
109
|
|
|
367
|
-
|
|
110
|
+
// 修改元素 class
|
|
111
|
+
iframe.contentWindow.postMessage(
|
|
112
|
+
{ type: 'update-classes', data: { visualSelectorId: 'src/App.tsx:12:6', classes: 'text-lg font-bold' } },
|
|
113
|
+
'*'
|
|
114
|
+
)
|
|
368
115
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
- 元素的 className
|
|
375
|
-
- `selectorId`
|
|
376
|
-
- 当前选中的 source 标识
|
|
377
|
-
- `dataSourceLocation`
|
|
378
|
-
- 当前和 `selectorId` 保持一致
|
|
379
|
-
- `rect`
|
|
380
|
-
- 元素在 viewport 中的矩形信息
|
|
116
|
+
// 修改元素文本
|
|
117
|
+
iframe.contentWindow.postMessage(
|
|
118
|
+
{ type: 'update-content', data: { visualSelectorId: 'src/App.tsx:12:6', content: '新文本' } },
|
|
119
|
+
'*'
|
|
120
|
+
)
|
|
381
121
|
|
|
382
|
-
|
|
122
|
+
// 修改元素属性
|
|
123
|
+
iframe.contentWindow.postMessage(
|
|
124
|
+
{ type: 'update-attribute', data: { visualSelectorId: 'src/App.tsx:12:6', attribute: 'src', value: '/new-image.png' } },
|
|
125
|
+
'*'
|
|
126
|
+
)
|
|
383
127
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
const data = event.data
|
|
128
|
+
// 取消选中
|
|
129
|
+
iframe.contentWindow.postMessage({ type: 'unselect-element' }, '*')
|
|
387
130
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
---
|
|
131
|
+
// 切换行内编辑
|
|
132
|
+
iframe.contentWindow.postMessage(
|
|
133
|
+
{ type: 'toggle-inline-edit-mode', data: { dataSourceLocation: 'src/App.tsx:12:6', inlineEditingMode: true } },
|
|
134
|
+
'*'
|
|
135
|
+
)
|
|
395
136
|
|
|
396
|
-
|
|
137
|
+
// 修改 CSS 变量
|
|
138
|
+
iframe.contentWindow.postMessage(
|
|
139
|
+
{ type: 'update-theme-variables', data: { variables: { '--primary': '#e11d48' } } },
|
|
140
|
+
'*'
|
|
141
|
+
)
|
|
397
142
|
|
|
398
|
-
|
|
143
|
+
// 注入字体
|
|
144
|
+
iframe.contentWindow.postMessage(
|
|
145
|
+
{ type: 'inject-font-import', data: { fontUrl: 'https://fonts.googleapis.com/css2?family=Inter' } },
|
|
146
|
+
'*'
|
|
147
|
+
)
|
|
399
148
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
import react from '@vitejs/plugin-react'
|
|
403
|
-
import { visualSelectorPlugin } from 'vite-plugin-visual-selector'
|
|
149
|
+
// 刷新页面
|
|
150
|
+
iframe.contentWindow.postMessage({ type: 'refresh-page' }, '*')
|
|
404
151
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
react(),
|
|
408
|
-
visualSelectorPlugin({
|
|
409
|
-
root: process.cwd(),
|
|
410
|
-
}),
|
|
411
|
-
],
|
|
412
|
-
})
|
|
152
|
+
// 请求当前选中元素位置
|
|
153
|
+
iframe.contentWindow.postMessage({ type: 'request-element-position' }, '*')
|
|
413
154
|
```
|
|
414
155
|
|
|
415
|
-
###
|
|
156
|
+
### 监听 iframe 上报消息
|
|
416
157
|
|
|
417
158
|
```ts
|
|
418
|
-
|
|
159
|
+
window.addEventListener('message', (e) => {
|
|
160
|
+
switch (e.data?.type) {
|
|
161
|
+
case 'visual-edit-agent-ready':
|
|
162
|
+
// agent 初始化完成,可以开始发送指令
|
|
163
|
+
break
|
|
419
164
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
})
|
|
425
|
-
```
|
|
165
|
+
case 'element-selected':
|
|
166
|
+
// 元素被选中
|
|
167
|
+
// e.data: { tagName, classes, visualSelectorId, content?, position, isTextElement, computedStyles, ... }
|
|
168
|
+
break
|
|
426
169
|
|
|
427
|
-
|
|
170
|
+
case 'element-position-update':
|
|
171
|
+
// 选中元素位置更新(滚动/resize 时)
|
|
172
|
+
// e.data: { position, isInViewport, visualSelectorId }
|
|
173
|
+
break
|
|
428
174
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
175
|
+
case 'inline-edit':
|
|
176
|
+
// 行内编辑内容变更
|
|
177
|
+
// e.data: { elementInfo, originalContent, newContent }
|
|
178
|
+
break
|
|
432
179
|
|
|
433
|
-
|
|
180
|
+
case 'content-editing-started':
|
|
181
|
+
case 'content-editing-ended':
|
|
182
|
+
// 行内编辑状态变更
|
|
183
|
+
// e.data: { visualSelectorId }
|
|
184
|
+
break
|
|
434
185
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
186
|
+
case 'sandbox:onMounted':
|
|
187
|
+
case 'sandbox:onUnmounted':
|
|
188
|
+
// 页面中带标记元素的挂载/卸载状态
|
|
189
|
+
break
|
|
439
190
|
}
|
|
440
191
|
})
|
|
441
192
|
```
|
|
442
193
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
## 十一、编译前后效果示例
|
|
446
|
-
|
|
447
|
-
源码:
|
|
448
|
-
|
|
449
|
-
```tsx
|
|
450
|
-
{items.map((item) => (
|
|
451
|
-
<div className="cell">{item.label}</div>
|
|
452
|
-
))}
|
|
453
|
-
```
|
|
454
|
-
|
|
455
|
-
经过插件 transform 后,效果近似于:
|
|
456
|
-
|
|
457
|
-
```tsx
|
|
458
|
-
{items.map((item) => (
|
|
459
|
-
<div
|
|
460
|
-
data-source-location="src/App.tsx:12:6"
|
|
461
|
-
className="cell"
|
|
462
|
-
>
|
|
463
|
-
{item.label}
|
|
464
|
-
</div>
|
|
465
|
-
))}
|
|
466
|
-
```
|
|
467
|
-
|
|
468
|
-
如果页面最终渲染出多个 `div`,它们都会带相同的 `data-source-location`,于是 runtime 就能把它们视为“同源元素组”。
|
|
469
|
-
|
|
470
|
-
---
|
|
471
|
-
|
|
472
|
-
## 十二、实现过程详解
|
|
473
|
-
|
|
474
|
-
这一部分重点解释:这个插件到底是怎么工作的。
|
|
475
|
-
|
|
476
|
-
### 整体分层
|
|
477
|
-
|
|
478
|
-
插件分成两层:
|
|
479
|
-
|
|
480
|
-
```txt
|
|
481
|
-
编译时:JSX -> 注入 data-source-location
|
|
482
|
-
运行时:读 data-source-location -> 查找同源元素 -> 绘制 overlay -> postMessage
|
|
483
|
-
```
|
|
484
|
-
|
|
485
|
-
也就是:
|
|
486
|
-
|
|
487
|
-
1. **先在编译时埋点**
|
|
488
|
-
2. **再在运行时消费这些标记**
|
|
489
|
-
|
|
490
|
-
---
|
|
491
|
-
|
|
492
|
-
### 1)编译时实现过程
|
|
493
|
-
|
|
494
|
-
核心文件:
|
|
495
|
-
|
|
496
|
-
- `src/plugin.ts`
|
|
497
|
-
|
|
498
|
-
使用到的核心工具:
|
|
499
|
-
|
|
500
|
-
- `@babel/parser`
|
|
501
|
-
- `@babel/traverse`
|
|
502
|
-
- `@babel/types`
|
|
503
|
-
- `magic-string`
|
|
504
|
-
|
|
505
|
-
#### 步骤 A:过滤目标文件
|
|
506
|
-
|
|
507
|
-
插件只处理匹配 `include` 的文件,并跳过 `exclude`。
|
|
508
|
-
|
|
509
|
-
默认情况就是:
|
|
510
|
-
|
|
511
|
-
- 处理 `.jsx` / `.tsx`
|
|
512
|
-
- 跳过 `node_modules`
|
|
513
|
-
|
|
514
|
-
#### 步骤 B:解析 AST
|
|
515
|
-
|
|
516
|
-
```ts
|
|
517
|
-
const ast = parse(code, {
|
|
518
|
-
sourceType: 'module',
|
|
519
|
-
plugins: ['jsx', 'typescript'],
|
|
520
|
-
})
|
|
521
|
-
```
|
|
522
|
-
|
|
523
|
-
这样插件就能拿到 JSX 节点的结构信息和源码位置信息。
|
|
524
|
-
|
|
525
|
-
#### 步骤 C:只处理原生标签
|
|
526
|
-
|
|
527
|
-
插件会检查当前节点是不是原生 DOM 元素。
|
|
528
|
-
|
|
529
|
-
例如:
|
|
530
|
-
|
|
531
|
-
- `div` → 处理
|
|
532
|
-
- `span` → 处理
|
|
533
|
-
- `button` → 处理
|
|
534
|
-
- `Card` → 不处理
|
|
535
|
-
- `Layout` → 不处理
|
|
536
|
-
|
|
537
|
-
原因很简单:
|
|
538
|
-
|
|
539
|
-
React 组件本身并不一定会直接成为真实 DOM,只有最终的原生标签才适合被 visual selector 消费。
|
|
540
|
-
|
|
541
|
-
#### 步骤 D:生成 source-location
|
|
542
|
-
|
|
543
|
-
插件会根据:
|
|
544
|
-
|
|
545
|
-
- 文件相对路径
|
|
546
|
-
- AST 节点起始行号
|
|
547
|
-
- AST 节点起始列号
|
|
548
|
-
|
|
549
|
-
生成:
|
|
550
|
-
|
|
551
|
-
```txt
|
|
552
|
-
src/App.tsx:12:6
|
|
553
|
-
```
|
|
554
|
-
|
|
555
|
-
这里列号做了 `+1`,是为了输出更符合人类习惯的 1-based 列号。
|
|
556
|
-
|
|
557
|
-
#### 步骤 E:把属性插回源码
|
|
558
|
-
|
|
559
|
-
通过 `MagicString` 在 JSX opening tag 中插入属性:
|
|
194
|
+
### 旧版兼容
|
|
560
195
|
|
|
561
|
-
|
|
562
|
-
magicString.appendLeft(node.name.end, ` ${attributeName}="${location}"`)
|
|
563
|
-
```
|
|
196
|
+
以下消息类型保留向后兼容,建议迁移到新版 API:
|
|
564
197
|
|
|
565
|
-
|
|
198
|
+
| 旧版 | 新版替代 |
|
|
199
|
+
|------|---------|
|
|
200
|
+
| `visual-selector:set-active` | `toggle-visual-edit-mode` |
|
|
201
|
+
| `update-element-style` | `update-classes` + CSS class |
|
|
202
|
+
| `update-element-text` | `update-content` |
|
|
203
|
+
| `update-element-class` | `update-classes` |
|
|
566
204
|
|
|
567
|
-
|
|
205
|
+
### 完整消息协议类型
|
|
568
206
|
|
|
569
|
-
|
|
207
|
+
所有消息类型均从主入口导出,可用于父编辑器的 TypeScript 类型检查:
|
|
570
208
|
|
|
571
209
|
```ts
|
|
572
|
-
{
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
210
|
+
import type {
|
|
211
|
+
// iframe → 主页面
|
|
212
|
+
ElementSelectedMessage,
|
|
213
|
+
ElementPositionUpdateMessage,
|
|
214
|
+
InlineEditMessage,
|
|
215
|
+
ContentEditingMessage,
|
|
216
|
+
AgentReadyMessage,
|
|
217
|
+
SandboxMountMessage,
|
|
218
|
+
// 主页面 → iframe
|
|
219
|
+
ToggleVisualEditModeMessage,
|
|
220
|
+
UpdateClassesMessage,
|
|
221
|
+
UpdateAttributeMessage,
|
|
222
|
+
UpdateContentMessage,
|
|
223
|
+
UpdateThemeVariablesMessage,
|
|
224
|
+
ToggleInlineEditModeMessage,
|
|
225
|
+
InjectFontImportMessage,
|
|
226
|
+
UnselectElementMessage,
|
|
227
|
+
RefreshPageMessage,
|
|
228
|
+
RequestElementPositionMessage,
|
|
229
|
+
} from 'vite-plugin-visual-selector'
|
|
576
230
|
```
|
|
577
231
|
|
|
578
|
-
这样宿主工具链还能继续保持较好的调试体验。
|
|
579
|
-
|
|
580
232
|
---
|
|
581
233
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
核心文件:
|
|
585
|
-
|
|
586
|
-
- `src/runtime.ts`
|
|
234
|
+
## 三、实现原理
|
|
587
235
|
|
|
588
|
-
|
|
236
|
+
### 整体架构
|
|
589
237
|
|
|
590
|
-
```
|
|
591
|
-
|
|
238
|
+
```
|
|
239
|
+
编译时(Vite transform) 运行时(iframe 内)
|
|
240
|
+
┌─────────────────────┐ ┌──────────────────────────────┐
|
|
241
|
+
│ .jsx/.tsx 文件 │ │ setupVisualEditAgent() │
|
|
242
|
+
│ ↓ @babel/parser │ │ ↓ 绑定 click/mousemove 监听 │
|
|
243
|
+
│ ↓ @babel/traverse │ │ ↓ 查找 [data-source-location] │
|
|
244
|
+
│ ↓ 只处理小写标签 │ │ ↓ querySelectorAll 同源元素 │
|
|
245
|
+
│ ↓ magic-string 注入 │ │ ↓ 创建 overlay 高亮 │
|
|
246
|
+
│ ↓ 输出 sourcemap │ │ ↓ postMessage 上报 │
|
|
247
|
+
└─────────────────────┘ └──────────────────────────────┘
|
|
592
248
|
```
|
|
593
249
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
- 绑定 click 监听
|
|
597
|
-
- 绑定 mousemove 监听
|
|
598
|
-
- 维护 selected / hover overlay 状态
|
|
599
|
-
|
|
600
|
-
而且当前实现会保证:
|
|
250
|
+
### 编译时
|
|
601
251
|
|
|
602
|
-
-
|
|
603
|
-
-
|
|
252
|
+
- Vite `transform` 钩子(`enforce: 'pre'`),在 React 插件之前运行
|
|
253
|
+
- `@babel/parser` 解析 JSX AST,`@babel/traverse` 遍历 `JSXOpeningElement`
|
|
254
|
+
- 只处理原生 DOM 元素(小写标签),跳过 React 组件(大写标签)
|
|
255
|
+
- `magic-string` 在标签名末尾注入 `data-source-location="相对路径:行:列"`
|
|
256
|
+
- 已有该属性的元素不重复注入
|
|
257
|
+
- 生成高精度 sourcemap
|
|
604
258
|
|
|
605
|
-
|
|
259
|
+
编译前后对比:
|
|
606
260
|
|
|
607
|
-
|
|
261
|
+
```tsx
|
|
262
|
+
// 编译前
|
|
263
|
+
<div className="cell">{item.label}</div>
|
|
608
264
|
|
|
609
|
-
|
|
610
|
-
|
|
265
|
+
// 编译后
|
|
266
|
+
<div data-source-location="src/App.tsx:12:6" className="cell">{item.label}</div>
|
|
611
267
|
```
|
|
612
268
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
#### 步骤 C:读取 selectorId
|
|
269
|
+
### 运行时模块结构
|
|
616
270
|
|
|
617
|
-
当前 selectorId 本质上就是元素的:
|
|
618
|
-
|
|
619
|
-
```ts
|
|
620
|
-
element.getAttribute(attributeName)
|
|
621
271
|
```
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
document.querySelectorAll(`[data-source-location="..."]`)
|
|
272
|
+
src/runtime/
|
|
273
|
+
├── index.ts # setupVisualEditAgent 组装入口
|
|
274
|
+
├── state.ts # AgentState 状态管理
|
|
275
|
+
├── utils.ts # 纯工具函数(getSourceId, findAllElementsById 等)
|
|
276
|
+
├── overlay.ts # overlay 创建/定位 + 动画冻结
|
|
277
|
+
├── messages.ts # 消息收发协议
|
|
278
|
+
├── inline-edit.ts # 行内编辑
|
|
279
|
+
└── layer-navigation.ts # 层级导航下拉菜单
|
|
631
280
|
```
|
|
632
281
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
这就是“点击一个元素,所有同类一起高亮”的核心。
|
|
636
|
-
|
|
637
|
-
#### 步骤 E:绘制 overlay
|
|
638
|
-
|
|
639
|
-
对每一个匹配元素,runtime 都会创建一个新的 overlay:
|
|
640
|
-
|
|
641
|
-
- `position: absolute`
|
|
642
|
-
- `pointer-events: none`
|
|
643
|
-
- 高 z-index
|
|
644
|
-
- hover / selected 两套样式
|
|
282
|
+
### 运行时核心流程
|
|
645
283
|
|
|
646
|
-
|
|
284
|
+
1. **初始化**:绑定 `message` 监听器,通知父页面 `visual-edit-agent-ready`
|
|
285
|
+
2. **激活编辑模式**:冻结动画、禁用 pointer-events、设置 crosshair 光标、绑定鼠标事件
|
|
286
|
+
3. **Hover 高亮**:`elementFromPoint` 穿透 overlay 查找真实元素,`querySelectorAll` 找同源元素,创建半透明 overlay
|
|
287
|
+
4. **Click 选中**:创建实线 overlay + 标签名 tag + 层级导航入口,`postMessage` 上报 `element-selected`
|
|
288
|
+
5. **位置跟踪**:scroll/resize 时通过 `requestAnimationFrame` 节流更新 overlay 位置,上报 `element-position-update`
|
|
289
|
+
6. **DOM 变化检测**:`MutationObserver` 监听 style/class/宽高变化,自动重定位 overlay
|
|
290
|
+
7. **行内编辑**:将文本元素设为 `contentEditable`,防抖上报 `inline-edit`
|
|
291
|
+
8. **远程修改**:接收父窗口的 `update-classes`/`update-content`/`update-attribute` 指令,直接操作 DOM
|
|
647
292
|
|
|
648
|
-
|
|
293
|
+
### 关键实现细节
|
|
649
294
|
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
```
|
|
655
|
-
|
|
656
|
-
父编辑器收到消息后,就可以:
|
|
657
|
-
|
|
658
|
-
- 打开元素编辑面板
|
|
659
|
-
- 更新工具栏
|
|
660
|
-
- 选中对应源码节点
|
|
661
|
-
- 做 hover / selection 同步
|
|
295
|
+
- **穿透 overlay 查找元素**:临时禁用 `freeze-pointer-events` 样式表,调用 `elementFromPoint`,再恢复
|
|
296
|
+
- **冻结动画**:注入全局 CSS 规则 `animation-play-state: paused` + `transition: none`,并调用 `getAnimations().forEach(anim => anim.finish())`
|
|
297
|
+
- **pointer-events 管理**:全局 `pointer-events: none`,agent 元素通过 `data-vite-plugin-element` 属性选择器恢复 `pointer-events: auto`
|
|
298
|
+
- **tag 定位**:默认在元素上方 27px,上方空间不足时移到下方或内部,水平方向通过 `requestAnimationFrame` 后修正保证不超出视口
|
|
662
299
|
|
|
663
300
|
---
|
|
664
301
|
|
|
665
|
-
##
|
|
666
|
-
|
|
667
|
-
### 为什么在编译时注入属性?
|
|
302
|
+
## 四、限制与副作用
|
|
668
303
|
|
|
669
|
-
|
|
304
|
+
### 支持范围
|
|
670
305
|
|
|
671
|
-
|
|
306
|
+
- **只支持 React / JSX / TSX**:插件通过 Babel 解析 JSX 语法,不支持 Vue SFC、Svelte 等其他模板语法
|
|
307
|
+
- **只处理原生 DOM 元素**:小写标签(`div`、`span`、`button` 等)会被注入属性,大写标签(React 组件如 `<Card />`)不处理
|
|
308
|
+
- **Vite 5+ 环境**:peer 依赖要求 `vite >= 5`
|
|
672
309
|
|
|
673
|
-
|
|
674
|
-
- DOM 一渲染出来就带有可追踪标识
|
|
675
|
-
- 同一个 JSX 表达式渲染出的多个元素天然共享同一个标识
|
|
310
|
+
### 运行时侵入性
|
|
676
311
|
|
|
677
|
-
|
|
312
|
+
启用编辑模式后,agent 会对页面产生以下副作用:
|
|
678
313
|
|
|
679
|
-
|
|
314
|
+
1. **冻结所有 CSS 动画和过渡**:通过注入全局样式 `animation-play-state: paused; transition: none`
|
|
315
|
+
2. **接管 pointer-events**:全局设置 `pointer-events: none`,只有 agent 创建的元素可交互
|
|
316
|
+
3. **修改 cursor**:`document.body.style.cursor = 'crosshair'`
|
|
317
|
+
4. **锁定 overflow**:`overflow-y: scroll; overflow-x: hidden` 防止 tag 标签导致的滚动条闪烁
|
|
318
|
+
5. **注入 DOM 元素**:overlay、tag 标签、layer dropdown 挂在 `document.body` 上
|
|
319
|
+
6. **拦截 click 事件**:在捕获阶段阻止事件冒泡(`stopImmediatePropagation`)
|
|
320
|
+
7. **启动 MutationObserver**:监听 `document.body` 的子树变化
|
|
680
321
|
|
|
681
|
-
|
|
322
|
+
关闭编辑模式后(`enableEditMode(false)` 或 `destroy()`),以上副作用全部清除。
|
|
682
323
|
|
|
683
|
-
|
|
324
|
+
### 行内编辑限制
|
|
684
325
|
|
|
685
|
-
|
|
326
|
+
- 只有叶子节点(无子元素)的文本类标签可行内编辑
|
|
327
|
+
- 包含 `img`/`video`/`canvas`/`svg` 子元素的不可编辑
|
|
328
|
+
- 空文本元素不可编辑
|
|
329
|
+
- 编辑通过 `contentEditable` 实现,可能与某些 CSS 样式冲突
|
|
686
330
|
|
|
687
|
-
|
|
331
|
+
### 消息安全
|
|
688
332
|
|
|
689
|
-
-
|
|
690
|
-
-
|
|
691
|
-
- 容易和 iframe / preview 场景集成
|
|
333
|
+
- `postMessage` 默认使用 `targetOrigin: '*'`,生产环境建议设置具体 origin
|
|
334
|
+
- agent 会校验 `targetOrigin`,只接受匹配 origin 的消息
|
|
692
335
|
|
|
693
336
|
---
|
|
694
337
|
|
|
695
|
-
##
|
|
696
|
-
|
|
697
|
-
当前版本有这些限制:
|
|
698
|
-
|
|
699
|
-
1. **只支持 React / JSX / TSX**
|
|
700
|
-
2. **只处理原生 DOM 元素**
|
|
701
|
-
3. **runtime 需要手动调用 `setupSelectionBridge()`**
|
|
702
|
-
4. **当前 overlay 会在 hover 时“先清再画”**
|
|
703
|
-
- 逻辑简单
|
|
704
|
-
- 但还没有做更细的性能优化
|
|
705
|
-
5. **当前尚未内建 scroll / resize 自动重定位增强**
|
|
706
|
-
6. **当前消息结构是最小可用版,不是完整编辑器协议**
|
|
707
|
-
|
|
708
|
-
---
|
|
709
|
-
|
|
710
|
-
## 十五、推荐接入方式
|
|
711
|
-
|
|
712
|
-
如果你要把它接入自己的可视化编辑器,我建议这样分工:
|
|
713
|
-
|
|
714
|
-
### 预览页 / iframe 内部
|
|
715
|
-
|
|
716
|
-
- 注册 `visualSelectorPlugin()`
|
|
717
|
-
- 启用 `setupSelectionBridge()`
|
|
718
|
-
- 渲染业务页面
|
|
719
|
-
|
|
720
|
-
### 父编辑器页面
|
|
721
|
-
|
|
722
|
-
- 监听 `postMessage`
|
|
723
|
-
- 根据 `element-selected` 更新 UI
|
|
724
|
-
- 必要时主动调用 preview 内部能力做状态复位
|
|
725
|
-
|
|
726
|
-
---
|
|
727
|
-
|
|
728
|
-
## 十六、本地开发命令
|
|
338
|
+
## 开发
|
|
729
339
|
|
|
730
340
|
```bash
|
|
731
|
-
npm
|
|
732
|
-
npm run
|
|
733
|
-
npm test
|
|
734
|
-
npm run
|
|
341
|
+
npm run build # tsup 构建(ESM + dts + sourcemap)
|
|
342
|
+
npm test # vitest run
|
|
343
|
+
npm run test:watch # vitest watch
|
|
344
|
+
npm run typecheck # tsc --noEmit
|
|
345
|
+
npm run demo # 启动 demo
|
|
735
346
|
```
|
|
736
347
|
|
|
737
|
-
|
|
348
|
+
### 项目结构
|
|
738
349
|
|
|
739
|
-
```bash
|
|
740
|
-
npm run demo
|
|
741
350
|
```
|
|
742
|
-
|
|
743
|
-
---
|
|
744
|
-
|
|
745
|
-
## 十七、项目结构
|
|
746
|
-
|
|
747
|
-
```txt
|
|
748
351
|
src/
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
runtime-api.test.ts
|
|
767
|
-
package.test.ts
|
|
768
|
-
demo-docs.test.ts
|
|
769
|
-
```
|
|
770
|
-
|
|
771
|
-
---
|
|
772
|
-
|
|
773
|
-
## 十八、导出 API 一览
|
|
774
|
-
|
|
775
|
-
### 主入口
|
|
776
|
-
|
|
777
|
-
```ts
|
|
778
|
-
import { visualSelectorPlugin } from 'vite-plugin-visual-selector'
|
|
352
|
+
├── index.ts # 主入口导出
|
|
353
|
+
├── plugin.ts # 编译时 JSX 注入逻辑
|
|
354
|
+
├── runtime.ts # 运行时入口(re-export)
|
|
355
|
+
├── runtime/
|
|
356
|
+
│ ├── index.ts # setupVisualEditAgent 组装
|
|
357
|
+
│ ├── state.ts # 状态管理
|
|
358
|
+
│ ├── utils.ts # 工具函数
|
|
359
|
+
│ ├── overlay.ts # overlay + 冻结动画
|
|
360
|
+
│ ├── messages.ts # 消息协议
|
|
361
|
+
│ ├── inline-edit.ts # 行内编辑
|
|
362
|
+
│ └── layer-navigation.ts # 层级导航
|
|
363
|
+
└── shared/
|
|
364
|
+
├── constants.ts # 常量
|
|
365
|
+
└── types.ts # 公共类型
|
|
366
|
+
demo/ # 示例项目
|
|
367
|
+
test/ # Vitest 测试
|
|
368
|
+
docs/ # 实现文档
|
|
779
369
|
```
|
|
780
|
-
|
|
781
|
-
### runtime 入口
|
|
782
|
-
|
|
783
|
-
```ts
|
|
784
|
-
import { setupSelectionBridge } from 'vite-plugin-visual-selector/runtime'
|
|
785
|
-
```
|
|
786
|
-
|
|
787
|
-
---
|
|
788
|
-
|
|
789
|
-
## 十九、总结
|
|
790
|
-
|
|
791
|
-
如果你只记住一句话,可以记这个:
|
|
792
|
-
|
|
793
|
-
> 这个插件的核心思想是:**编译时给 DOM 打上源码坐标,运行时再根据这个坐标把同源元素重新聚合起来。**
|
|
794
|
-
|
|
795
|
-
这样就能用非常简单、非常稳定的方式,做出类似 Base44 那种“点击一个元素,所有同类一起高亮”的可视化编辑体验。
|