vite-plugin-visual-selector
v0.1.3
Published
面向 React/JSX/TSX 的 Vite 插件,实现"源码同源元素联动高亮"能力。
Downloads
437
Readme
vite-plugin-visual-selector
面向 React/JSX/TSX 的 Vite 插件,实现"源码同源元素联动高亮"能力。
编译时给 DOM 打上源码坐标(data-source-location),运行时根据坐标将同源元素聚合高亮,支持行内编辑和远程 DOM 修改。
一、稳定公开 API
安装
npm install vite-plugin-visual-selector编译时插件
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { visualSelectorPlugin } from 'vite-plugin-visual-selector'
export default defineConfig({
plugins: [react(), visualSelectorPlugin()],
})VisualSelectorPluginOptions
interface VisualSelectorPluginOptions {
/** 需要处理的文件匹配规则,默认 [/\.[jt]sx$/] */
include?: RegExp[]
/** 需要排除的文件匹配规则,默认 [/node_modules/] */
exclude?: RegExp[]
/** 注入属性名,默认 "data-source-location" */
attributeName?: string
/** 项目根目录,用于计算相对路径,默认 process.cwd() */
root?: string
}运行时 Agent
import { setupVisualEditAgent } from 'vite-plugin-visual-selector/runtime'
const agent = setupVisualEditAgent()VisualEditAgentOptions
interface VisualEditAgentOptions {
/** 属性名,默认 "data-source-location" */
attributeName?: string
/** postMessage 的目标 origin,默认 "*" */
targetOrigin?: string
}VisualEditAgentInstance
setupVisualEditAgent() 返回控制实例:
interface VisualEditAgentInstance {
/** 销毁 agent,移除所有监听器和 overlay */
destroy(): void
/** 开启/关闭编辑模式 */
enableEditMode(enabled: boolean): void
/** 手动选中元素 */
selectElement(element: Element): void
/** 清除所有选中和高亮 */
clearSelection(): void
/** 获取当前选中的 source id */
getSelectedId(): string | null
}npm 导出
// 主入口 "vite-plugin-visual-selector"
export { visualSelectorPlugin, setupVisualEditAgent }
// 运行时入口 "vite-plugin-visual-selector/runtime"
export { setupVisualEditAgent }完整类型导出见 src/index.ts。
二、高级集成能力
父编辑器控制
父窗口通过 postMessage 控制 iframe 内的 agent:
// 开启编辑模式
iframe.contentWindow.postMessage(
{ type: 'toggle-visual-edit-mode', data: { enabled: true } },
'*'
)
// 修改元素 class
iframe.contentWindow.postMessage(
{ type: 'update-classes', data: { visualSelectorId: 'src/App.tsx:12:6', classes: 'text-lg font-bold' } },
'*'
)
// 修改元素文本
iframe.contentWindow.postMessage(
{ type: 'update-content', data: { visualSelectorId: 'src/App.tsx:12:6', content: '新文本' } },
'*'
)
// 修改元素属性
iframe.contentWindow.postMessage(
{ type: 'update-attribute', data: { visualSelectorId: 'src/App.tsx:12:6', attribute: 'src', value: '/new-image.png' } },
'*'
)
// 取消选中
iframe.contentWindow.postMessage({ type: 'unselect-element' }, '*')
// 切换行内编辑
iframe.contentWindow.postMessage(
{ type: 'toggle-inline-edit-mode', data: { dataSourceLocation: 'src/App.tsx:12:6', inlineEditingMode: true } },
'*'
)
// 修改 CSS 变量
iframe.contentWindow.postMessage(
{ type: 'update-theme-variables', data: { variables: { '--primary': '#e11d48' } } },
'*'
)
// 注入字体
iframe.contentWindow.postMessage(
{ type: 'inject-font-import', data: { fontUrl: 'https://fonts.googleapis.com/css2?family=Inter' } },
'*'
)
// 刷新页面
iframe.contentWindow.postMessage({ type: 'refresh-page' }, '*')
// 请求当前选中元素位置
iframe.contentWindow.postMessage({ type: 'request-element-position' }, '*')监听 iframe 上报消息
window.addEventListener('message', (e) => {
switch (e.data?.type) {
case 'visual-edit-agent-ready':
// agent 初始化完成,可以开始发送指令
break
case 'element-selected':
// 元素被选中
// e.data: { tagName, classes, visualSelectorId, content?, position, isTextElement, computedStyles, ... }
break
case 'element-position-update':
// 选中元素位置更新(滚动/resize 时)
// e.data: { position, isInViewport, visualSelectorId }
break
case 'inline-edit':
// 行内编辑内容变更
// e.data: { elementInfo, originalContent, newContent }
break
case 'content-editing-started':
case 'content-editing-ended':
// 行内编辑状态变更
// e.data: { visualSelectorId }
break
case 'sandbox:onMounted':
case 'sandbox:onUnmounted':
// 页面中带标记元素的挂载/卸载状态
break
}
})旧版兼容
以下消息类型保留向后兼容,建议迁移到新版 API:
| 旧版 | 新版替代 |
|------|---------|
| visual-selector:set-active | toggle-visual-edit-mode |
| update-element-style | update-classes + CSS class |
| update-element-text | update-content |
| update-element-class | update-classes |
完整消息协议类型
所有消息类型均从主入口导出,可用于父编辑器的 TypeScript 类型检查:
import type {
// iframe → 主页面
ElementSelectedMessage,
ElementPositionUpdateMessage,
InlineEditMessage,
ContentEditingMessage,
AgentReadyMessage,
SandboxMountMessage,
// 主页面 → iframe
ToggleVisualEditModeMessage,
UpdateClassesMessage,
UpdateAttributeMessage,
UpdateContentMessage,
UpdateThemeVariablesMessage,
ToggleInlineEditModeMessage,
InjectFontImportMessage,
UnselectElementMessage,
RefreshPageMessage,
RequestElementPositionMessage,
} from 'vite-plugin-visual-selector'三、实现原理
整体架构
编译时(Vite transform) 运行时(iframe 内)
┌─────────────────────┐ ┌──────────────────────────────┐
│ .jsx/.tsx 文件 │ │ setupVisualEditAgent() │
│ ↓ @babel/parser │ │ ↓ 绑定 click/mousemove 监听 │
│ ↓ @babel/traverse │ │ ↓ 查找 [data-source-location] │
│ ↓ 只处理小写标签 │ │ ↓ querySelectorAll 同源元素 │
│ ↓ magic-string 注入 │ │ ↓ 创建 overlay 高亮 │
│ ↓ 输出 sourcemap │ │ ↓ postMessage 上报 │
└─────────────────────┘ └──────────────────────────────┘编译时
- Vite
transform钩子(enforce: 'pre'),在 React 插件之前运行 @babel/parser解析 JSX AST,@babel/traverse遍历JSXOpeningElement- 只处理原生 DOM 元素(小写标签),跳过 React 组件(大写标签)
magic-string在标签名末尾注入data-source-location="相对路径:行:列"- 已有该属性的元素不重复注入
- 生成高精度 sourcemap
编译前后对比:
// 编译前
<div className="cell">{item.label}</div>
// 编译后
<div data-source-location="src/App.tsx:12:6" className="cell">{item.label}</div>运行时模块结构
src/runtime/
├── index.ts # setupVisualEditAgent 组装入口
├── state.ts # AgentState 状态管理
├── utils.ts # 纯工具函数(getSourceId, findAllElementsById 等)
├── overlay.ts # overlay 创建/定位 + 动画冻结
├── messages.ts # 消息收发协议
├── inline-edit.ts # 行内编辑
└── layer-navigation.ts # 层级导航下拉菜单运行时核心流程
- 初始化:绑定
message监听器,通知父页面visual-edit-agent-ready - 激活编辑模式:冻结动画、禁用 pointer-events、设置 crosshair 光标、绑定鼠标事件
- Hover 高亮:
elementFromPoint穿透 overlay 查找真实元素,querySelectorAll找同源元素,创建半透明 overlay - Click 选中:创建实线 overlay + 标签名 tag + 层级导航入口,
postMessage上报element-selected - 位置跟踪:scroll/resize 时通过
requestAnimationFrame节流更新 overlay 位置,上报element-position-update - DOM 变化检测:
MutationObserver监听 style/class/宽高变化,自动重定位 overlay - 行内编辑:将文本元素设为
contentEditable,防抖上报inline-edit - 远程修改:接收父窗口的
update-classes/update-content/update-attribute指令,直接操作 DOM
关键实现细节
- 穿透 overlay 查找元素:临时禁用
freeze-pointer-events样式表,调用elementFromPoint,再恢复 - 冻结动画:注入全局 CSS 规则
animation-play-state: paused+transition: none,并调用getAnimations().forEach(anim => anim.finish()) - pointer-events 管理:全局
pointer-events: none,agent 元素通过data-vite-plugin-element属性选择器恢复pointer-events: auto - tag 定位:默认在元素上方 27px,上方空间不足时移到下方或内部,水平方向通过
requestAnimationFrame后修正保证不超出视口
四、限制与副作用
支持范围
- 只支持 React / JSX / TSX:插件通过 Babel 解析 JSX 语法,不支持 Vue SFC、Svelte 等其他模板语法
- 只处理原生 DOM 元素:小写标签(
div、span、button等)会被注入属性,大写标签(React 组件如<Card />)不处理 - Vite 5+ 环境:peer 依赖要求
vite >= 5
运行时侵入性
启用编辑模式后,agent 会对页面产生以下副作用:
- 冻结所有 CSS 动画和过渡:通过注入全局样式
animation-play-state: paused; transition: none - 接管 pointer-events:全局设置
pointer-events: none,只有 agent 创建的元素可交互 - 修改 cursor:
document.body.style.cursor = 'crosshair' - 锁定 overflow:
overflow-y: scroll; overflow-x: hidden防止 tag 标签导致的滚动条闪烁 - 注入 DOM 元素:overlay、tag 标签、layer dropdown 挂在
document.body上 - 拦截 click 事件:在捕获阶段阻止事件冒泡(
stopImmediatePropagation) - 启动 MutationObserver:监听
document.body的子树变化
关闭编辑模式后(enableEditMode(false) 或 destroy()),以上副作用全部清除。
行内编辑限制
- 只有叶子节点(无子元素)的文本类标签可行内编辑
- 包含
img/video/canvas/svg子元素的不可编辑 - 空文本元素不可编辑
- 编辑通过
contentEditable实现,可能与某些 CSS 样式冲突
消息安全
postMessage默认使用targetOrigin: '*',生产环境建议设置具体 origin- agent 会校验
targetOrigin,只接受匹配 origin 的消息
开发
npm run build # tsup 构建(ESM + dts + sourcemap)
npm test # vitest run
npm run test:watch # vitest watch
npm run typecheck # tsc --noEmit
npm run demo # 启动 demo项目结构
src/
├── index.ts # 主入口导出
├── plugin.ts # 编译时 JSX 注入逻辑
├── runtime.ts # 运行时入口(re-export)
├── runtime/
│ ├── index.ts # setupVisualEditAgent 组装
│ ├── state.ts # 状态管理
│ ├── utils.ts # 工具函数
│ ├── overlay.ts # overlay + 冻结动画
│ ├── messages.ts # 消息协议
│ ├── inline-edit.ts # 行内编辑
│ └── layer-navigation.ts # 层级导航
└── shared/
├── constants.ts # 常量
└── types.ts # 公共类型
demo/ # 示例项目
test/ # Vitest 测试
docs/ # 实现文档