vite-plugin-visual-selector 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,795 +1,369 @@
1
1
  # vite-plugin-visual-selector
2
2
 
3
- 一个面向 **React / JSX / TSX** 的 Vite 插件,用来实现类似 Base44 可视化编辑器中的“源码同源元素联动高亮”能力。
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
- 如果你要跑本仓库 demo / 本地开发:
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 { setupSelectionBridge } from 'vite-plugin-visual-selector/runtime'
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 SelectionBridgeOptions {
56
+ interface VisualEditAgentOptions {
57
+ /** 属性名,默认 "data-source-location" */
228
58
  attributeName?: string
229
- targetWindow?: Window
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
- #### `attributeName?: string`
240
-
241
- 要读取的元素标识属性名。
242
-
243
- 默认值:
66
+ `setupVisualEditAgent()` 返回控制实例:
244
67
 
245
68
  ```ts
246
- 'data-source-location'
247
- ```
248
-
249
- #### `targetWindow?: Window`
250
-
251
- bridge 所工作的目标 window。
252
-
253
- 默认值:
254
-
255
- ```ts
256
- window
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
- #### `targetOrigin?: string`
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
- 是否启用 hover 联动高亮。
286
-
287
- 默认值:
288
-
289
- ```ts
290
- true
89
+ // 运行时入口 "vite-plugin-visual-selector/runtime"
90
+ export { setupVisualEditAgent }
291
91
  ```
292
92
 
293
- #### `enableClickSelection?: boolean`
294
-
295
- 是否启用点击选中。
296
-
297
- 默认值:
298
-
299
- ```ts
300
- true
301
- ```
93
+ 完整类型导出见 [src/index.ts](src/index.ts)。
302
94
 
303
95
  ---
304
96
 
305
- ## 八、`setupSelectionBridge()` 返回什么
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
- 当前 runtime 会发出类似下面的消息:
101
+ 父窗口通过 `postMessage` 控制 iframe 内的 agent:
348
102
 
349
103
  ```ts
350
- {
351
- type: 'element-selected',
352
- tagName: 'DIV',
353
- className: 'box',
354
- selectorId: 'src/App.tsx:12:6',
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
- - `type`
370
- - 当前可能值:`element-selected` / `element-hovered`
371
- - `tagName`
372
- - 被命中元素的标签名
373
- - `className`
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
- ```ts
385
- window.addEventListener('message', (event) => {
386
- const data = event.data
128
+ // 取消选中
129
+ iframe.contentWindow.postMessage({ type: 'unselect-element' }, '*')
387
130
 
388
- if (data?.type === 'element-selected') {
389
- console.log('用户选中了元素:', data)
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
- ### `vite.config.ts`
143
+ // 注入字体
144
+ iframe.contentWindow.postMessage(
145
+ { type: 'inject-font-import', data: { fontUrl: 'https://fonts.googleapis.com/css2?family=Inter' } },
146
+ '*'
147
+ )
399
148
 
400
- ```ts
401
- import { defineConfig } from 'vite'
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
- export default defineConfig({
406
- plugins: [
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
- ### `src/preview-bridge.ts`
156
+ ### 监听 iframe 上报消息
416
157
 
417
158
  ```ts
418
- import { setupSelectionBridge } from 'vite-plugin-visual-selector/runtime'
159
+ window.addEventListener('message', (e) => {
160
+ switch (e.data?.type) {
161
+ case 'visual-edit-agent-ready':
162
+ // agent 初始化完成,可以开始发送指令
163
+ break
419
164
 
420
- setupSelectionBridge({
421
- parentWindow: window.parent,
422
- enableHover: true,
423
- enableClickSelection: true,
424
- })
425
- ```
165
+ case 'element-selected':
166
+ // 元素被选中
167
+ // e.data: { tagName, classes, visualSelectorId, content?, position, isTextElement, computedStyles, ... }
168
+ break
426
169
 
427
- ### `src/main.tsx`
170
+ case 'element-position-update':
171
+ // 选中元素位置更新(滚动/resize 时)
172
+ // e.data: { position, isInViewport, visualSelectorId }
173
+ break
428
174
 
429
- ```ts
430
- import './preview-bridge'
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
- ```ts
436
- window.addEventListener('message', (event) => {
437
- if (event.data?.type === 'element-selected') {
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
- ```ts
562
- magicString.appendLeft(node.name.end, ` ${attributeName}="${location}"`)
563
- ```
196
+ 以下消息类型保留向后兼容,建议迁移到新版 API:
564
197
 
565
- 这样就不会手写字符串拼接整段 JSX,而是进行更可靠的源码片段注入。
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
- #### 步骤 F:输出 sourcemap
205
+ ### 完整消息协议类型
568
206
 
569
- 如果文件确实发生了变更,就返回:
207
+ 所有消息类型均从主入口导出,可用于父编辑器的 TypeScript 类型检查:
570
208
 
571
209
  ```ts
572
- {
573
- code: magicString.toString(),
574
- map: magicString.generateMap({ hires: true }),
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
- ### 2)运行时实现过程
583
-
584
- 核心文件:
585
-
586
- - `src/runtime.ts`
234
+ ## 三、实现原理
587
235
 
588
- #### 步骤 A:安装 bridge
236
+ ### 整体架构
589
237
 
590
- ```ts
591
- setupSelectionBridge({...})
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
- - 同一个页面只存在一个激活 bridge
603
- - 再次调用时会先清理上一个 bridge
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
- #### 步骤 B:识别可选元素
259
+ 编译前后对比:
606
260
 
607
- runtime 会从事件目标开始向上查找最近的:
261
+ ```tsx
262
+ // 编译前
263
+ <div className="cell">{item.label}</div>
608
264
 
609
- ```txt
610
- [data-source-location]
265
+ // 编译后
266
+ <div data-source-location="src/App.tsx:12:6" className="cell">{item.label}</div>
611
267
  ```
612
268
 
613
- 如果事件命中的是 overlay 本身,则会直接跳过,避免“高亮层选中高亮层”的问题。
614
-
615
- #### 步骤 C:读取 selectorId
269
+ ### 运行时模块结构
616
270
 
617
- 当前 selectorId 本质上就是元素的:
618
-
619
- ```ts
620
- element.getAttribute(attributeName)
621
271
  ```
622
-
623
- 通常就是 `data-source-location` 的值。
624
-
625
- #### 步骤 D:查找所有同源元素
626
-
627
- 一旦拿到 selectorId,runtime 会执行:
628
-
629
- ```ts
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
- 从整个文档中找出所有具有相同 source-location 的元素。
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
- 然后根据目标元素的 `getBoundingClientRect()`,把 overlay 定位到正确位置。
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
- #### 步骤 F:发消息给父页面
293
+ ### 关键实现细节
649
294
 
650
- click 时,runtime 会调用:
651
-
652
- ```ts
653
- parentWindow.postMessage(message, targetOrigin)
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
- ### 为什么只给原生 DOM 打标?
312
+ 启用编辑模式后,agent 会对页面产生以下副作用:
678
313
 
679
- 因为编辑器最终操作的是浏览器里的真实 DOM,而不是 React 组件抽象层。
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
- ### 为什么 overlay 挂在 `document.body`?
322
+ 关闭编辑模式后(`enableEditMode(false)` `destroy()`),以上副作用全部清除。
682
323
 
683
- 因为这样不需要改动原始业务 DOM,侵入性最低,也更容易做绝对定位。
324
+ ### 行内编辑限制
684
325
 
685
- ### 为什么 runtime 不自动注入?
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 install
732
- npm run typecheck
733
- npm test
734
- npm run build
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
- 运行 demo:
348
+ ### 项目结构
738
349
 
739
- ```bash
740
- npm run demo
741
350
  ```
742
-
743
- ---
744
-
745
- ## 十七、项目结构
746
-
747
- ```txt
748
351
  src/
749
- index.ts # 插件入口导出
750
- plugin.ts # 编译时 JSX 注入逻辑
751
- runtime.ts # 运行时 bridge / overlay / postMessage
752
- shared/
753
- constants.ts # 常量
754
- types.ts # 公共类型
755
-
756
- demo/
757
- vite.config.ts
758
- src/
759
- App.tsx
760
- bridge.ts
761
- main.tsx
762
-
763
- test/
764
- plugin.test.ts
765
- runtime.test.ts
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 那种“点击一个元素,所有同类一起高亮”的可视化编辑体验。