@hy-bricks/editor
嵌入式低代码组件编辑器:Monaco 三栏(html/js/css)+ 拖 tab 多分栏 + 实时预览 + 草稿持久化 + Monaco 类型注入。

┌─────────────────────────────────────────────────────────────────────┐
│ demo-component v3 · 已发布 ☐ 自动预览 ⚙ 保存草稿 推版本 │
├──────────────────────────────────┬──────────────────────────────────┤
│ html │ javascript │ + css │ 预览 ⧉ │
│ ─────────────────────────────── │ ─────────────────────────────── │
│ <div class="root"> │ │
│ <h2>{{ greeting }}</h2> │ Hello World ! │
│ <el-input v-model="msg" /> │ ┌───────────────┐ │
│ <el-button @click="add"> │ │ 试试输入 │ │
│ 点我 +1 │ └───────────────┘ │
│ </el-button> │ 你输入了: │
│ </div> │ │
│ │ [ 点我 +1 ] 点了 0 次 │
└──────────────────────────────────┴──────────────────────────────────┘
(拖 tab 头 → 多分栏)
<HyperCardEditor> 是一个受控组件:你给一份 {html, js, css} 源码,它给你一个 IDE 风界面 + 一份能跑的实时预览。所有副作用(保存、推版本、开窗口、设置面板)都通过事件抛回宿主,SDK 不调任何 API、不依赖 vue-router、不强制宿主目录结构。
Quickstart(5 分钟,从空白 Vite + Vue 3 项目接进来)
1. 安装 SDK + 全部 peerDeps
reka-ui / cva / clsx / tailwind-merge / lucide-vue-next 在 SDK 构建时被 vite external,必须由宿主装(详见下面的 peerDependencies)。
pnpm add @hy-bricks/editor @hy-bricks/core \
vue monaco-editor \
reka-ui class-variance-authority clsx tailwind-merge lucide-vue-next \
tailwindcss tailwindcss-animate autoprefixer postcss
2. tailwind.config.js —— 不要用 SDK 的 preset
包内 .vue 用了大量 Tailwind utility,但 Tailwind v3 默认 ignore node_modules,即便 content 写上 node_modules/@hy-bricks/** 也大概率扫不全(symlink、pnpm hoist 路径变化)。
正确做法:本包发布时已经 self-compile 出 dist/style.css,把全部需要的 utility 都打进去了。宿主只要 import 这一个 css 就行,不需要让自己的 Tailwind 扫包内源码。
export default {
content: [
'./index.html',
'./src/**/*.{vue,ts,tsx,js,jsx}',
],
theme: { extend: {} },
plugins: [],
}
3. src/style.css
@tailwind base;
@tailwind components;
@tailwind utilities;
⚠ 不要在这个 css 里 @import '@hy-bricks/editor/style.css'。PostCSS 的 @import
走它自己的解析器,不识别 npm scope alias(@hy-bricks/...),通常会 silent 失败:
编译没报错,主题变量却丢失。唯一推荐:在 main.ts 里用 JS import(下一步),
走 bundler 的 ESM 解析,稳定可靠。
4. src/main.ts
import { createApp } from 'vue'
import { createHyperCard } from '@hy-bricks/core'
import '@hy-bricks/editor/style.css'
import App from './App.vue'
import './style.css'
const app = createApp(App)
app.use(
createHyperCard({
libs: {
http: undefined,
ui: undefined,
},
}),
)
app.mount('#app')
5. App.vue
<script setup lang="ts">
import { ref } from 'vue'
import { HyperCardEditor } from '@hy-bricks/editor'
const source = ref({ html: '', js: '', css: '' })
</script>
<template>
<div class="h-screen">
<HyperCardEditor v-model="source" draft-key="my-component" />
</div>
</template>
跑 pnpm dev 就能看到完整 IDE。三栏 Monaco 都能编辑、改了立刻预览、Cmd+S 存草稿、刷新页面草稿还在。
peerDependencies(必装清单)
vue | ^3.5 | ✅ | Composition API + <script setup> |
monaco-editor | ^0.55 | ✅ | 三栏代码编辑器内核 |
tailwindcss | ^3.4 || ^4 | ✅ | 宿主自己的 Tailwind(只扫自己,不扫 SDK) |
@hy-bricks/core | 同 SDK 版本 | ✅ | 运行时(预览盒子、createHyperCard plugin) |
reka-ui | ^1.0.0-alpha.8 | ✅ | shadcn-vue 的 headless 底座(Button 等用了) |
class-variance-authority | ^0.7 | ✅ | buttonVariants 生成器 |
clsx | ^2.1 | ✅ | className 合并 |
tailwind-merge | ^2.5 | ✅ | dedupe Tailwind 冲突 class |
lucide-vue-next | ^0.469 | ✅ | 顶栏图标(Settings / RefreshCw 等) |
tailwindcss-animate | ^1.0 | 推荐 | shadcn-vue 默认动画(若你启用宿主侧 shadcn 组件) |
SDK package.json 里 reka-ui / cva / clsx / tailwind-merge / lucide-vue-next 已经移到 peerDependencies(语义就是"宿主项目需要装"):宿主必须显式安装;SDK build 时把它们 external,避免重复打包。
API
<HyperCardEditor> props
modelValue | { html: string; js: string; css: string } | — | 必填。v-model 双向绑定;形状写死,宿主必须适配 |
draftKey | string | undefined | 启用 localStorage 草稿;实际 key = hc:draft:<draftKey>。留空 = 不启用草稿 |
componentId | string | 回退 draftKey,再回退实例级唯一 id(__hc_editor_<uid>__,用 getCurrentInstance().uid) | 给运行时实例的 id。默认回退已自动每实例唯一,不再撞;多实例仍建议显式传,方便 runtime / cssScope / 调试定位 |
showBackButton | boolean | false | 顶栏左侧返回按钮。点击触发 go-back 事件,SDK 自己不路由 |
title | string | undefined | 顶栏组件标题 |
statusLabel | string | undefined | 顶栏右侧的版本号 / 状态文字(如 'v3 · 已发布') |
autoPreview | boolean | true | 改源码后自动 debounce 渲染预览;false 时显示「重新渲染」按钮,需手点 |
previewRatio | number | 0.62 | 编辑器与预览的初始宽度比 0–1 |
extraLibs | ExtraLib[] | undefined | 给 Monaco 注入的额外类型 lib(自动补全 __HYPERCARD__.libs.*),详见 Monaco 类型注入 |
interface ExtraLib {
filePath: string
content: string
}
emit 事件
update:modelValue | { html, js, css } | 任意一栏 Monaco 内容变化(throttle) | v-model 自动接,无需手写 |
dirty | boolean | 用户改了源码 / 调用 applyDraft 后 → true;v-model 重置或保存草稿后 → false | 顶栏 dot、disable「推版本」按钮等 |
save-draft | — | 用户点顶栏「保存草稿」或 Cmd+S(草稿已写入 localStorage) | toast「已保存」即可,无需自己再写 |
push-version | — | 用户点顶栏「推版本」 | 弹自家 dialog 让填备注 → 调后端 API |
open-preview-window | — | 用户点预览面板右上的「⧉ 独立窗口」 | window.open(routerResolve('/preview/...'), ...);开了之后 SDK 自动用 BroadcastChannel 同步源码 |
open-settings | — | 用户点顶栏齿轮图标 | 弹自家组件元数据 drawer(name / desc / tags 等) |
go-back | — | showBackButton=true 时点击返回按钮 | router.back() 之类 |
error | { message, stack? } | 编译 / 运行时报错 | 写 console、上报 Sentry、Toast 等 |
expose(ref 拿到的 instance)
const editor = useTemplateRef<{
isDirty: Ref<boolean>
confirmLeave(): boolean
applyDraft(draft: ComponentDraft): void
manualPreview(): void
applyPreview(): void
}>('editor')
isDirty | Ref<boolean> | 是 ref 不是裸值,读用 editor.value.isDirty.value、模板里直接 editor?.isDirty |
confirmLeave() | () => boolean | 同步弹 confirm,dirty 时提示「未保存,确定离开?」;返回 true 表示放行,false 表示用户取消 |
applyDraft(draft) | (d: ComponentDraft) => void | 把外部草稿灌进编辑器(用于「恢复历史版本」),会把 dirty 设为 true |
manualPreview() | () => void | autoPreview=false 时手动触发预览 |
applyPreview() | () => void | 用 v-model 灌进新 source 后,如果想立刻刷预览(watch(modelValue) 已经会调,基本用不到) |
vue-router 离开守卫
import { onBeforeRouteLeave } from 'vue-router'
const editor = useTemplateRef<{ confirmLeave(): boolean }>('editor')
onBeforeRouteLeave(() => editor.value?.confirmLeave() ?? true)
beforeunload(关浏览器、刷新)SDK 自动接,无需宿主写。
Monaco 类型注入
让组件作者写 __HYPERCARD__.libs.http. 时 Monaco 自动补全到具体方法。SDK 默认已经注册 __HYPERCARD__ / runtime / assets 三个 ambient,但 libs 的具体类型必须由宿主补(因为只有宿主知道你提供了 axios 还是 ky 还是 ElementPlus)。
import { HyperCardEditor, type ExtraLib } from '@hy-bricks/editor'
const extraLibs: ExtraLib[] = [
{
filePath: 'file:///host-libs.d.ts',
content: `
// 关键:用 declaration merging 跟 SDK 自带的 HyperCardLibs 接口合并,
// 不要写 export {},一旦文件里有 import/export,
// 它就变成 module 而不是 ambient,声明就泄漏不到全局了
interface HyperCardLibs {
http: {
get<T = any>(url: string, config?: any): Promise<{ data: T; status: number }>
post<T = any>(url: string, data?: any, config?: any): Promise<{ data: T; status: number }>
}
ui: { version: string; install: (...args: any[]) => void }
}
`,
},
]
<HyperCardEditor v-model="source" :extra-libs="extraLibs" />
⚠ 致命陷阱:content 字符串里不能出现 export {} / import ...,否则 .d.ts 自动从 ambient 转成 module,顶层 declare const __HYPERCARD__ 就不再是 global,组件作者那边补全直接灰掉。
主题定制
SDK 用 shadcn-vue 风的 HSL CSS variables。不需要重 build,在宿主自己的 :root 覆盖任意一条即可。
--background / --foreground | 0 0% 100% / 222.2 84% 4.9% | 页面底色 / 主文字 |
--primary / --primary-foreground | 222.2 47.4% 11.2% / 210 40% 98% | 「推版本」主按钮 |
--secondary / --secondary-foreground | 210 40% 96.1% / 222.2 47.4% 11.2% | 次按钮 |
--muted / --muted-foreground | 210 40% 96.1% / 215.4 16.3% 46.9% | 弱化区域、placeholder |
--accent / --accent-foreground | 210 40% 96.1% / 222.2 47.4% 11.2% | hover 反馈 |
--destructive / --destructive-foreground | 0 84.2% 60.2% / 210 40% 98% | 删除 / 警告 |
--border / --input / --ring | 214.3 31.8% 91.4% / 同左 / 222.2 84% 4.9% | 边框 / input / focus ring |
--radius | 0.5rem | 基础圆角(Button、面板) |
:root {
--primary: 220 80% 50%;
--radius: 0.25rem;
}
.dark {
--primary: 220 70% 60%;
--background: 222.2 84% 4.9%;
}
值要写纯 HSL 三元(无 hsl() 包裹、无逗号),包内 hsl(var(--primary) / <alpha>) 会自己拼。
草稿持久化 + 离开守卫
设置 draft-key="abc" | 启用 localStorage,key = hc:draft:abc | 传 prop |
顶栏「保存草稿」/ Cmd+S | 写 localStorage,清除 dirty,触发 @save-draft | 接事件 toast 反馈 |
| 进入页面检测到旧草稿 | 顶栏渲染琥珀色 pill「本地草稿 · 5 分钟前 · [恢复] [丢弃]」 | — |
关浏览器 / 刷新 (beforeunload) | dirty 时弹浏览器原生 confirm | — |
切路由 (onBeforeRouteLeave) | 暴露 confirmLeave() | 在 router guard 中调:return editor.value?.confirmLeave() |
| 推版本成功 | — | 宿主自己 clearDraft(key)(从 SDK 导出) |
import { clearDraft, loadDraft, saveDraft } from '@hy-bricks/editor'
clearDraft('my-component')
const d = loadDraft('my-component')
saveDraft('my-component', { html, javascript, css })
子组件单独导出(高级用户)
不想要默认的「IDE 顶栏 + 三栏 + 预览」布局?可以拿乐高块自己拼:
import {
EditorLayout, EditorGroup, MonacoEditor, Splitter,
createEditorScope, useEditorScope, EDITOR_SCOPE_KEY,
defaultLayout, activateTab, closeTab, splitWithTab, moveTab, setSplitRatio,
loadDraft, saveDraft, clearDraft,
createPreviewBus,
Button, buttonVariants,
setupHyperCardMonacoTypes, addMonacoExtraLib,
} from '@hy-bricks/editor'
EditorLayout / EditorGroup / MonacoEditor / Splitter —— 自拼 IDE 界面
createEditorScope / useEditorScope —— multi-instance 的 monaco model 隔离
Button / buttonVariants —— vendor 自 shadcn-vue,跟 SDK 主题变量一致
Troubleshooting
❌ 装了 SDK,顶栏 Button 没样式 / 圆角错 / 颜色全黑
原因:Tailwind v3 出于性能默认 ignore node_modules,所以即便宿主 tailwind.config.js 的 content 写了 node_modules/@hy-bricks/**,也基本扫不到包内 .vue 里的 utility class。
解法:不要让宿主 Tailwind 扫这个包。本包发布时已经 self-compile 出 dist/style.css,把全部需要的 utility 都打进去。宿主只要:
import '@hy-bricks/editor/style.css'
就拿到完整样式 + 主题变量。Tailwind preset 共享只是给「我也想跟本包用同一套色板」的宿主用,跟样式生效无关。
❌ Cannot read file ... ?worker 报错
原因:Vite 默认会把 node_modules 里的 ESM 包预 bundle(esbuild),但 esbuild 不认识 Vite 的 ?worker query —— monaco-editor 的 worker import 就这样炸。
解法:宿主 vite.config.ts 把 SDK 排除:
export default defineConfig({
optimizeDeps: {
exclude: ['@hy-bricks/core', '@hy-bricks/editor'],
},
})
❌ PostCSS Cannot find module 'postcss' / CJS 加载失败
原因:Vite 把 SDK 当 ESM 加载时,如果宿主和 SDK 用了不同 PostCSS 版本,Node 模块解析会撞到 CJS/ESM 边界。
解法:SDK 已经把 postcss 处理打进自己的 dist 里了。如果宿主仍然另行 import postcss,在 vite.config.ts 里加 dedupe:
export default defineConfig({
resolve: {
dedupe: ['postcss', 'monaco-editor', 'vue'],
},
})
❌ editor.value.isDirty 永远 truthy
原因:isDirty 暴露的是 Ref<boolean> 不是裸 boolean,而 Ref 对象本身永远 truthy。
解法:读 .value,或在模板里依赖自动 unwrap。TS 类型也要写对:
const editor = useTemplateRef<{ isDirty: Ref<boolean> }>('editor')
if (editor.value?.isDirty.value) { }
或干脆听 @dirty 事件,最推荐:
<HyperCardEditor v-model="source" @dirty="d => isDirty = d" />
❌ Monaco 补全 __HYPERCARD__.libs. 是空的
原因:你的 extraLibs[].content 里出现了 export {} / import x from ...,文件被 TS 识别成 module,声明不再 global。
解法:见 Monaco 类型注入。content 必须是纯 ambient,只有 declare / interface / type,没有任何 import / export。
⚠ 多个 <HyperCardEditor> 同页,样式 / 实例 id 区分
默认行为:不传 componentId 时,自动回退成实例级唯一 id(__hc_editor_<uid>__,基于 getCurrentInstance().uid),每个 <HyperCardEditor> 实例自动拿不同 id,不会撞。
仍建议:多实例并存时显式传 componentId(或不同 draftKey,会被自动回退用),让 runtime / cssScope / 调试堆栈里看得清是哪个实例:
<HyperCardEditor v-model="a" component-id="card-a" />
<HyperCardEditor v-model="b" component-id="card-b" />
配合使用 / Ecosystem
License
MIT © hy_top