# 扣盯小子 — 全文文档(LLM 用)
> 由 VitePress build 自动生成。每段以分隔线 + 源 URL 开头。
========================================================================
# 扣盯小子首页
URL: /
------------------------------------------------------------------------
========================================================================
# CanvasHandle
URL: /api/canvas-handle
------------------------------------------------------------------------
# CanvasHandle
`CanvasHandle` 是宿主操控画布的主要入口。它是稳定对外接口;宿主不要直接依赖内部 `CanvasContext`。
拿到方式:
```vue
```
## 快照和选中
| API | 说明 |
| --- | --- |
| `getSnapshot()` | 返回 `PageDocument` 深拷贝 |
| `selectedIds` | readonly ref,当前选中实例 id |
| `selectedInstances` | computed,当前选中实例对象 |
| `getInstanceAtClientPoint(point)` | 右键菜单 / hover 命中实例 |
| `duplicateInstance(id, options?)` | 复制实例并返回新 id |
属性面板通常监听 `selectedInstances`:
```ts
watch(
() => handle.value?.selectedInstances.value,
(instances) => {
primary.value = instances?.[0] ?? null
},
)
```
## 文档修改
| API | 说明 |
| --- | --- |
| `dispatch(action)` | 文档修改唯一入口 |
| `beginBatch()` / `endBatch(type?)` | 把一组修改合成一条 undo;**0.6.0 起深度计数**:begin/end 成对配平、可嵌套,只有最外层 `endBatch` 才 flush(重复 `beginBatch` 不再幂等) |
| `isBatching` | readonly ref,当前是否处于 batch |
| `undo()` / `redo()` | 命令栈 |
| `canUndo` / `canRedo` | computed,工具栏按钮状态 |
| `undoStackSize` / `redoStackSize` | computed,调试 / 埋点用 |
| `clearHistory()` | 清空历史 |
| `getRemoveImpact(id)` | dispatch `removeInstance` **之前**预检:返回会被一并删掉的子树 `{ instanceIds, descendantCount }`,宿主据此弹确认 |
常见 action:
```ts
handle.dispatch({
type: 'updateInstance',
payload: {
instanceId,
patch: { props: nextProps },
},
})
```
批量修改:
```ts
handle.beginBatch()
try {
for (const id of ids) {
handle.dispatch({ type: 'removeInstance', payload: { instanceId: id } })
}
} finally {
handle.endBatch('delete-many')
}
```
### 删除与级联
删除实例走 `dispatch({ type: 'removeInstance', payload: { instanceId } })`,**没有单独的 `deleteInstance` 方法**。
- **默认级联(0.6.0 起)**:删容器一并删整棵子树,并清掉只引用被删实例的 binding;`undo` 完整恢复子树 + binding。
- **退回旧口径**:`createCanvasContext({ cascadeRemove: false })` 或 `` → 只删本实例,子实例**留成 `missing-parent` 孤儿**(不是升级到 root)。
- **删前预检**:`getRemoveImpact(id)` 纯读返回 `{ instanceIds, descendantCount }`(根在首位),`descendantCount > 0` 时宿主自行弹确认 /「已删 N 个 · 可撤销」提示。
```ts
const impact = handle.getRemoveImpact(id)
if (impact.descendantCount > 0 && !confirm(`将一并删除 ${impact.descendantCount} 个子组件(可 ⌘Z 撤销)`)) return
handle.dispatch({ type: 'removeInstance', payload: { instanceId: id } })
```
## 命令系统
| API | 说明 |
| --- | --- |
| `executeCommand(id, payload?)` | 菜单、快捷键、AI 命令统一入口 |
| `canExecuteCommand(id)` | 判断命令当前能不能执行 |
| `executeShortcut(event)` | 跑默认快捷键表 |
| `getDefaultShortcuts()` | 返回默认快捷键配置 |
常见命令:
- `undo`
- `redo`
- `deleteSelection`
- `duplicateSelection`
- `clearSelection`
- `cancelInteraction`
- `lockSelection`
- `unlockSelection`
- `bringForward`
- `sendBackward`
- `bringToFront`
- `sendToBack`
- `zoomIn`
- `zoomOut`
- `zoom100`
- `resetViewport`
- `fitToContent`
- `fitToSelection`
- `fitToSelectionOrContent`
- `nudgeSelection`
- `nudgeSelectionLarge`
- `toggleGrid`
- `toggleRuler`
命令适合 toolbar、右键菜单和快捷键共用。宿主业务命令可以自己分发,不要假装成 SDK 内置命令。
## 视口
| API | 说明 |
| --- | --- |
| `viewport` | 底层 ViewportStore 逃生口 |
| `clientToCanvasPoint(point)` | DOM client 坐标转 canvas 坐标 |
| `getViewportState()` | 获取当前 scale / pan |
| `getVisibleBounds()` | 当前可见区域 |
| `getCanvasBounds()` | 画布物理边界 |
| `getContentBounds()` | 全部实例外包围盒 |
| `getSelectionBounds()` | 当前选区外包围盒 |
| `getInstanceBounds(id)` | 单实例 bounds |
| `panBy(dx, dy)` / `panTo(x, y)` | 平移视口 |
| `zoomTo(scale)` / `zoomBy(delta)` | 缩放 |
| `zoomToPoint(scale, clientPoint)` | 围绕鼠标点缩放 |
| `fitToContent()` / `fitToSelection()` | 自动适配内容或选区 |
| `scrollToRect(rect, options?)` | 滚动并缩放到指定 canvas rect |
| `resetViewport()` | 回到 100% + 原点 |
视口是瞬时态,不进 undo。
## 工具模式和快捷键
| API | 说明 |
| --- | --- |
| `toolMode` | 当前生效工具模式 |
| `getToolMode()` | 同步读取工具模式 |
| `setToolMode(mode)` | 设置永久工具模式 |
| `setTemporaryToolMode(mode | null)` | Space-hold 这类临时工具 |
| `getDefaultShortcuts()` | 默认快捷键表 |
| `executeShortcut(event)` | 跑默认快捷键表并执行命令 |
SDK 不自动绑定 `window`:
```ts
window.addEventListener('keydown', (event) => {
handle.executeShortcut(event)
})
```
## 实例树
| API | 说明 |
| --- | --- |
| `getInstanceTree()` | 树面板 |
| `getInstanceList()` | 平铺列表 |
| `getInstance(id)` | 按 id 查单实例,命中返深拷贝 / 不命中 `null` |
| `getSelectedPath()` | 面包屑 |
| `getBreadcrumb(id)` | 任意实例路径 |
| `focusInstance(id)` | 选中并滚动到实例 |
| `scrollToInstance(id)` | 只滚动不选中 |
`getInstanceTree()` 会按 parent/slot 派生嵌套树;`getInstanceList()` 保留文档平铺顺序。
`getInstance(id)` / `getInstanceList()` / `getBreadcrumb(id)` 都是 **深拷贝口径**:宿主修改返回对象的嵌套字段(`props` / `rect` / `layoutBox`)不污染 SDK 内部 reactive。命中失败 `getInstance` 返 `null`,不是 `undefined`。
## 布局
| API | 说明 |
| --- | --- |
| `getLayoutConfig()` | 当前画布 layout 深拷贝 |
| `updateCanvasSize(patch)` | 更新画布尺寸配置 |
| `updateCanvasBackground(patch | null)` | 更新或清除背景 |
| `updateBehavior(patch)` | 持久化行为开关 |
| `setInteractionOptions(patch)` | 临时行为开关,不进文档 |
| `getEffectiveBehavior()` | mode + layout.behavior + 临时开关合并结果 |
| `getLayoutBox(id)` / `updateLayoutBox(id, patch)` | 实例外层盒子 |
| `getLayoutItem(id)` / `updateLayoutItem(id, patch)` | 子项布局参数 |
| `getContainerLayout(id)` / `updateContainerLayout(id, layout)` | 容器内部布局 |
| `getRotation(id)` / `updateRotation(id, value)` | 旋转角 |
| `getInstanceOutletRect(parentId, slot?)` | 拿 slot outlet 的 DOM rect |
| `setRootLayout(modeOrCfg \| null)` | 改 root 实例的容器布局规则(走 undo 栈) |
新代码优先使用 `layoutBox` / `layoutItem`:
```ts
handle.updateLayoutBox(instanceId, {
widthMode: 'percent',
width: 100,
heightMode: 'auto',
})
```
`getInstanceSize()` / `updateInstanceSize()` 仍是 public,但只作为兼容层。
`setRootLayout` 用于切换顶层布局(从自由摆位切到 flex / grid / split,或反过来),它走 `dispatch` 路径,可 undo/redo。字符串便捷形式自动展开为完整 `ContainerLayoutConfig`:
```ts
// 字符串便捷:展开成 { mode: 'flex', direction: 'horizontal', ... }
handle.setRootLayout('flex')
// 完整 cfg
handle.setRootLayout({ mode: 'grid', columns: 3, rows: 2 })
// null 清字段,回到默认 { mode: 'free' }
handle.setRootLayout(null)
```
## 辅助线和网格
| API | 说明 |
| --- | --- |
| `setRulerVisible(visible)` | 显隐标尺 |
| `setGridVisible(visible)` | 显隐网格 |
| `updateRulerConfig(patch)` | 更新标尺配置 |
| `updateGridConfig(patch)` | 更新网格配置 |
| `addGuide(guide)` | 添加手动辅助线并返回 id |
| `removeGuide(id)` | 删除辅助线 |
| `updateGuide(id, patch)` | 更新辅助线 |
| `getGuides()` | 获取辅助线深拷贝 |
| `clearGuides()` | 清空辅助线 |
| `getMouseCanvasPoint()` | 当前鼠标 canvas 坐标 |
这些配置写入 `PageDocument.layout.guides`,会进入 undo 栈。
## 跨容器移动
| API | 说明 |
| --- | --- |
| `getDropTarget(point, root?)` | client 坐标命中容器 outlet |
| `canReparent(instanceId, target)` | dry-run,看能不能放 |
| `moveInstanceInTree(instanceId, target)` | 真正移动,可 undo |
| `cannotReparentEvent` | reparent 守门拒绝事件,host 可以 toast |
| `cannotDragLayoutManagedChildEvent` | layout-managed child 拖动失败事件(flex/grid/split 子项位置由父决定,直接拖会被拒) |
多画布场景直接调用 `getDropTarget` 时,建议传当前 canvas stage/root,避免全局扫描命中其它画布。
`cannotReparentEvent` 的 `reason` 是 union:`'self-parent'` / `'parent-not-exists'` / `'circular'` / `'kind-mismatch'` / `'slot-single-occupied'` / `'max-children-exceeded'` / ...。
`'max-children-exceeded'` 对应 `SlotDecl.maxChildren`:
```ts
// 组件契约里声明 slot 上限
const contract: ComponentContract = {
slotsDecl: [
{ key: 'children', multiple: true, maxChildren: 3 },
],
// ...
}
```
`canReparent` dry-run 时返 `{ ok: false, reason: 'max-children-exceeded' }`,真拖时 `cannotReparentEvent` 同款触发。`maxChildren` 优先级低于 `multiple: false`(单 slot 排他先拒)。
`cannotDragLayoutManagedChildEvent` 是 devtools 自动接通的事件。在 flex / grid / split 容器里,子项位置由父布局算,直接 drag 会被 SDK 拒。host 可以监听做 toast 提示,devtools collector 已自动 watch 进 `recentDragFailures`(见 [devtools API](/api/devtools))。
## 校验和覆盖
| API | 说明 |
| --- | --- |
| `getLayoutIssues()` | 当前布局校验问题 |
| `getStaleComponentOverrides()` | 页面级实例覆盖中已失效的项 |
stale override 表示 `override.baseVersionKey !== instance.componentVersionKey`;Renderer 不会消费它。
## 资源
```ts
const url = await handle.resolveAsset(assetRef)
```
如果宿主没有传 adapter,SDK 会直接使用 `ref.url`。如果传了 `assets.resolve(ref)`,SDK 优先调用 adapter 并缓存结果。
## 进一步阅读
`CanvasHandle` 还提供 0.3.0 mount-ready 信号(`getInstanceReady` / `getInstanceRuntime` / `getCachedBySourceId` / `HandleDisposedError`)、dispose 终态守门、binding 事件订阅等能力。
========================================================================
# Canvas Types
URL: /api/canvas-types
------------------------------------------------------------------------
# Canvas Types
这一页列出 `@hy-bricks/canvas` 接入时最常用的公开类型。完整导出以 `packages/canvas/src/index.ts` 为准。
## Source
```ts
interface Source {
html: string
js: string
css: string
}
```
组件源码永远是三段式。SDK 负责编译,宿主负责保存、发布和传输。
## ComponentVersionKey
```ts
type ComponentVersionKey = string
```
推荐格式:
| 类型 | 形态 | 示例 |
| --- | --- | --- |
| 正式版本 | `${componentId}@${version}` | `chart@3` |
| 编辑草稿 | `${componentId}@draft:${pageId}:${baseVersion}` | `chart@draft:p1:3` |
运行时用 helper 判断:
```ts
import { isDraftKey, parseComponentVersionKey } from '@hy-bricks/canvas'
```
页面推版本时后端必须拒收 `@draft:` key。
## ComponentContract
```ts
interface ComponentContract {
propsDecl: PropDecl[]
emitsDecl: EmitDecl[]
methodsDecl: MethodDecl[]
slotsDecl: SlotDecl[]
modelDecl: ModelDecl[]
dataInputsDecl: DataInputDecl[]
dataOutputsDecl: DataOutputDecl[]
layoutDecl?: ComponentLayoutDecl
}
```
命名口径:
| 字段 | 用途 |
| --- | --- |
| `key` | 代码标识符,用于 prop / method / model / data |
| `event` | emit 事件名 |
| `name` | slot 名,保持 Vue `` 语义 |
| `label` | 展示名 |
`layoutDecl` 是容器能力声明,用于告诉宿主“这是容器、支持哪些布局、默认如何摆子实例”。SDK 当前不会替组件源码自动生成 split/grid DOM。
## ComponentVersionAsset
```ts
type ComponentVersionStatus = 'ok' | 'unavailable' | 'missing' | 'broken'
interface ComponentVersionAsset {
key: ComponentVersionKey
componentId: string
version: string
status: ComponentVersionStatus
invalidReason?: string
name?: string
source?: Source
contract?: ComponentContract
kind?: 'visual' | 'layout' | 'data'
updatedAt?: string
}
```
`status === 'ok'` 时应提供 `source` 和 `contract`。其它状态表示宿主无法提供源码,SDK 渲染降级卡。
## DraftComponentVersionAsset
```ts
interface DraftComponentVersionAsset {
key: ComponentVersionKey
baseVersionKey: ComponentVersionKey
componentId: string
isDraft: true
status: 'ok'
source: Source
contract: ComponentContract
contractParseError?: { message: string; at: string }
lastGoodSource: Source
lastGoodContract: ComponentContract
createdAt: string
updatedAt: string
}
```
草稿只在前端编辑期使用。RuntimeBox 编译应使用 `lastGoodSource`,属性面板读取 `lastGoodContract`。
## PageComponentOverride
```ts
interface PageComponentOverride {
instanceId: string
baseVersionKey: ComponentVersionKey
source: Source
contract: ComponentContract
contractParseError?: {
message: string
line?: number
column?: number
}
createdAt?: string
updatedAt?: string
}
```
覆盖只影响当前页面的当前实例。只有 `baseVersionKey === instance.componentVersionKey` 时生效,否则是 stale override。
## PageInstance
```ts
interface PageInstance {
instanceId: string
componentId: string
componentVersionKey: ComponentVersionKey
alias?: string
rect: { x: number; y: number; w: number; h: number }
zIndex: number
rotation?: number
locked?: boolean
hidden?: boolean
props: Record
layoutBox?: LayoutBox
layoutItem?: LayoutItem
parentId?: string
slot?: string
placement?: 'canvas' | 'container' | 'absolute' | 'slot'
size?: PageInstanceSize
containerLayout?: ContainerLayoutConfig
}
```
新接入优先写:
- `layoutBox`:实例外层盒子。
- `layoutItem`:在父 flex/grid/split 中的位置、顺序、比例。
- `placement: 'canvas' | 'container'`:当前的标准值。
兼容字段:
- `rect`:老 reducer / hit-test 仍依赖,normalize 会和 `layoutBox` 同步维护。
- `size`:兼容层,已被 `layoutBox` 合并。
- `placement: 'absolute' | 'slot'`:老值,normalize 后转成 `canvas` / `container`。
## LayoutBox
```ts
type LayoutBoxAxisMode = 'px' | 'percent' | 'fill' | 'auto'
interface LayoutBox {
x: number
y: number
width: number
height: number
widthMode: LayoutBoxAxisMode
heightMode: LayoutBoxAxisMode
overflow?: 'hidden' | 'visible' | 'auto'
}
```
`LayoutBox` 不包含 `zIndex`、`rotation`、`locked`、`hidden`。这些仍在 `PageInstance` 顶层。
## LayoutItem
```ts
interface LayoutItem {
order?: number
grow?: number
shrink?: number
row?: number
column?: number
rowSpan?: number
columnSpan?: number
ratio?: number
}
```
父布局决定哪个字段生效:
| 父布局 | 消费字段 |
| --- | --- |
| `flex` | `order` / `grow` / `shrink` |
| `grid` | `row` / `column` / `rowSpan` / `columnSpan` |
| `split` | `ratio` |
| `free` / `none` | 不消费 `layoutItem` |
## ContainerLayoutConfig
```ts
type ContainerLayoutConfig =
| { mode: 'none' }
| { mode: 'free' }
| FlexContainerLayout
| SplitContainerLayout
| GridContainerLayout
```
```ts
interface FlexContainerLayout {
mode: 'flex'
direction: 'row' | 'column'
wrap?: boolean
gap?: number
justify?: 'start' | 'center' | 'end' | 'space-between' | 'space-around'
align?: 'start' | 'center' | 'end' | 'stretch'
}
interface SplitContainerLayout {
mode: 'split'
direction: 'horizontal' | 'vertical'
ratios: number[]
gap?: number
}
interface GridContainerLayout {
mode: 'grid'
columns: Array<{ mode: 'fr' | 'px' | 'percent'; value: number }>
rows: Array<{ mode: 'fr' | 'px' | 'percent' | 'auto'; value?: number }>
gap?: number | { row?: number; column?: number }
cells?: Record
}
```
`layout.rootLayout` 控制顶层实例布局;`instance.containerLayout` 控制某个容器实例 default slot 的子布局。
## PageDocument
```ts
interface PageDocument {
schemaVersion?: '1'
layout: PageLayout | CanvasLayoutConfig
instances: PageInstance[]
bindings: PageBinding[]
componentOverrides?: Record
}
```
`PageDocument` 是设计器写视图,只存结构和 pinned key,不存源码字典。
## PageRenderPayload
```ts
interface PageRenderPayload {
page: {
id: string
name: string
targets: Array<'pc' | 'mobile' | 'screen'>
}
version: {
version: string
label: string
publishedAt: string
} | null
document: PageDocument
componentVersions: Record<
ComponentVersionKey,
ComponentVersionAsset | DraftComponentVersionAsset
>
}
```
`PageRenderPayload` 是运行态只读视图。宿主拿到后可以直接传给 `HyperCardPageRenderer`。
## CanvasLayoutConfig
```ts
interface CanvasLayoutConfig {
type: 'free' | 'flow' | 'nested'
canvas: CanvasViewportConfig
background?: CanvasBackgroundConfig
behavior?: CanvasBehaviorConfig
rootLayout?: ContainerLayoutConfig
guides?: {
grid?: CanvasGridConfig
ruler?: CanvasRulerConfig
items?: CanvasGuide[]
}
}
```
`type` 是历史字段。顶层布局以 `rootLayout` 为准。
## AssetRef
```ts
interface AssetRef {
id: string
type?: 'image' | 'video' | 'font' | 'json' | 'file'
url?: string
name?: string
meta?: Record
}
```
没有 adapter 时 SDK 使用 `url`;有 `assets.resolve(ref)` adapter 时优先调 adapter。
## Tree And Commands
常用辅助类型:
| 类型 | 说明 |
| --- | --- |
| `PageInstanceTreeNode` | `handle.getInstanceTree()` 的树节点 |
| `TreeMoveTarget` | `moveInstanceInTree()` 的目标位置 |
| `CannotReparentEvent` | reparent 被拒事件 |
| `DropTarget` | `getDropTarget()` 命中的 outlet |
| `CanvasToolMode` | `select` / `hand` / `marquee` / `inspect` 等工具态 |
| `CanvasCommandId` | toolbar / 快捷键 / 右键菜单的命令 id |
| `CanvasShortcutBinding` | 默认快捷键绑定描述 |
## 纯函数 Helpers
常用导出:
| Helper | 说明 |
| --- | --- |
| `normalizePageDocument` | v0/v1 文档归一,补齐 layoutBox / rootLayout 等字段 |
| `validateInstanceTree` | 校验 parent/slot/accepts/multiple/depth |
| `canReparent` | 单次 reparent dry-run |
| `computeContentBounds` / `computeSelectionBounds` | 视口 fit 计算前置几何 |
| `computeFit` / `computeVisibleBounds` | 视口适配和可见范围 |
| `getDefaultShortcuts` / `matchBinding` | 默认快捷键表和匹配逻辑 |
| `renderLayoutBoxStyle` | 把 LayoutBox 渲染成 wrapper CSS |
| `computeEffectiveSplitRatios` | split 子数量变化后的有效 ratios |
## 进一步阅读
`@hy-bricks/canvas` 还导出 trace 协议相关类型(`TraceCollectorOptions` / `StoreTraceKind` 及 13 个 trace kind)、`PageBinding` 规范化、`BindingErrorCode` 全枚举、`HandleDisposedError`、0.3.0 mount-ready 信号类型等。完整导出以 `packages/canvas/src/index.ts` 为准。
========================================================================
# @hy-bricks/canvas
URL: /api/canvas
------------------------------------------------------------------------
# @hy-bricks/canvas
`@hy-bricks/canvas` 是页面渲染器和嵌入式设计器。它只消费宿主传入的数据,不直接请求后端。
## 公开组件
| 组件 | 用途 |
| --- | --- |
| `HyperCardPageRenderer` | 只读运行态渲染器,适合业务页面、预览页、外部 demo |
| `HyperCardCanvasDesigner` | 默认整机设计器,内含 stage、runtime layer、interaction layer 和 handle |
| `CanvasStage` | 低层舞台容器,高级宿主自组装时使用 |
| `RuntimeLayer` | 低层运行时层,从 `CanvasContext` 读文档并嵌入 Renderer |
| `InteractionLayer` | 低层交互层,负责选中、拖拽、resize、reparent ghost |
常规业务只需要前两个组件。后三个是高级拼装 API,用于宿主需要替换默认设计器结构时。
## HyperCardPageRenderer
运行态入口:
```vue
```
### Props
| Prop | 类型 | 说明 |
| --- | --- | --- |
| `payload` | `PageRenderPayload` | `/render` 聚合接口返回值;优先级高于 `document` |
| `document` | `PageDocument` | 设计器内嵌场景或宿主已拆开的页面结构 |
| `componentSourceMap` | `ComponentSourceMap` | `componentVersionKey -> source asset` 字典 |
| `schedulerOptions` | `RenderSchedulerOptions` | 渐进挂载、视口缓冲、dispose 延迟等配置 |
| `canvasId` | `string` | 多画布运行时身份;业务 v-for 场景必须稳定且唯一 |
| `adapters` | `CanvasAdapters` | 独立 Renderer 场景的资源解析 adapter |
| `keepHiddenMounted` | `boolean` | **0.6.2 起**,默认 `true`。隐藏(`instance.hidden`)实例保活:隐藏不卸载、组件状态保留,恢复显示无缝;`false` 退回老行为(隐藏可被 dispose,省内存但恢复时丢状态) |
| `persistInstanceState` | `boolean` | **0.6.3 起**,默认 `false`(opt-in)。开启后:实例被真销毁(滚出视口虚拟化 dispose)前拍 `$data` 可序列化快照,重建时还原,使滚出 → 滚回保留用户选值 / 查询条件 |
`payload` 和 `document + componentSourceMap` 二选一。运行页推荐 `payload`;设计器内部会用 `document + componentSourceMap`。
### 运行时规则
- Renderer 不 fetch。
- Renderer 不挂交互层。
- `canvasId` 在组件生命周期内固定;想换画布身份应通过 Vue `:key` 重建。
- 隐藏实例(`hidden`)默认保活(`keepHiddenMounted`)—— 隐藏 → 恢复 不丢组件内部状态(用户选值 / 查询条件 / 取数结果);只 root 实例受此影响(容器子本就一直保活)。
- `persistInstanceState`(默认 `false`)与 `keepHiddenMounted` 互补:`keepHiddenMounted` 让隐藏实例 vm 不被拆,根本不丢状态、不需快照;`persistInstanceState` 针对实例**真被拆掉**的场景(滚出视口超 `disposeDelayMs` 被 dispose、滚回重建)——拍快照、重建时还原。一句话:hidden 走保活(vm 不拆),scroll-out 拆了才用快照。
- `canvas.height.mode = 'fill'` 时,外层容器必须有明确高度,否则画布不可见。
- `componentVersions[key].status !== 'ok'` 时渲染降级卡,不让整页崩溃。
## HyperCardCanvasDesigner
设计器入口:
```vue
```
### Props
| Prop | 类型 | 说明 |
| --- | --- | --- |
| `modelValue` | `PageDocument` | `v-model` 页面结构;内部 mutation 会 emit snapshot |
| `componentSourceMap` | `ComponentSourceMap` | 宿主拼好的源码字典 |
| `mode` | `CanvasMode` | `design` / `preview` / `runtime` / `inspect`,默认 `design` |
| `schedulerOptions` | `RenderSchedulerOptions` | 透传给内部 Renderer |
| `adapters` | `CanvasAdapters` | 设计器统一资源 adapter;内嵌 Renderer 会优先使用它 |
| `persistInstanceState` | `boolean` | **0.6.3 起**,默认 `false`(opt-in)。透传给内嵌 Renderer:实例被真销毁前拍快照、重建时还原。语义见 [Renderer 运行时规则](#运行时规则) |
### Emits
| 事件 | Payload | 说明 |
| --- | --- | --- |
| `update:modelValue` | `PageDocument` | 文档快照变更;drag/resize batch 结束后只 emit 一次 |
| `handle-ready` | `CanvasHandle` | 宿主集成属性面板、工具栏、快捷键的主入口 |
| `context-ready` | `CanvasContext` | 兼容旧事件;新接入不要依赖内部 context |
| `cannot-drag-slot-child` | `{ instanceId, parentId }` | 旧 slot child 禁拖位事件 |
| `cannot-drag-layout-managed-child` | `{ instanceId, parentId, parentLayoutMode }` | flex/grid/split 受管实例被尝试拖位 |
### Slots
| Slot | Props | 说明 |
| --- | --- | --- |
| `canvas-overlay` | `{ viewport, handle }` | 渲染在 stage world 内,自动跟随 pan/scale |
| `default` | 无 | 保留给高级宿主塞同层扩展 |
`canvas-overlay` 适合画 palette 拖入 ghost、业务辅助线、协作光标。它已经在 canvas 坐标系里,不要再自己乘 viewport scale。
### Expose
| 字段 | 说明 |
| --- | --- |
| `handle` | 对外稳定的 `CanvasHandle` |
| `context` | 内部 `CanvasContext`,兼容旧接入;不建议新代码依赖 |
## 模式
| Mode | 行为 |
| --- | --- |
| `design` | 完整编辑;选中、拖拽、resize、键盘移动、undo 可用 |
| `preview` | 组件内部交互可用,不修改文档 |
| `runtime` | 纯运行态;不挂 InteractionLayer |
| `inspect` | 可选中查看,但不拖不改 |
`mode` 是会话级。工具级状态走 `handle.toolMode` / `setToolMode()`。
## 自组装低层组件
默认整机已经覆盖大多数场景。只有当宿主要替换舞台结构或交互层时,才需要低层组件:
```vue
```
自组装必须自己创建并 provide `CanvasContext`,并负责 reparent bridge、handle 生命周期等细节。业务项目优先使用 `HyperCardCanvasDesigner`。
## 自组装路径(高级)
下面这一组 API 是给"想自己拼装设计器结构"的宿主用的。大多数业务只需要 ``(整机设计器)和 ``(只读运行态),不需要碰这一层。整机已经把状态层、context、handle、生命周期都装好了。
只有当你要替换默认舞台/交互结构,或在非 Vue 组件树外消费画布状态时,才需要这些工厂。
### createCanvasContext
`CanvasContext` 是设计器内部的"广播站":状态层(document / selection / viewport store)、渲染调度、adapter 槽位都挂在它上面,各 UI 模块通过 Vue `provide` / `inject` 共享它,而不互相 import。
```ts
function createCanvasContext(opts?: CanvasContextOptions): CanvasContext
function provideCanvasContext(ctx: CanvasContext): void
function useCanvasContext(): CanvasContext
const CANVAS_CONTEXT_KEY: InjectionKey
```
`CanvasContextOptions`:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `canvasId` | `string` | 本 ctx 绑定的画布身份;不传走默认 scope,用于过滤 per-canvas 的 `instance:*` 生命周期事件 |
| `initialDocument` | `PageDocument` | 初始文档,不传时空文档 |
| `scheduler` | `RenderSchedulerOptions` | 透传给内部 `createRenderScheduler` |
| `viewport` | `ViewportStoreOptions` | 缩放范围、初始缩放 |
| `adapters` | `CanvasAdapters` | 业务 adapter 槽位 |
| `hooks` | `CanvasActionHooks` | action 生命周期 hooks(command stack / undo / dirty / 埋点) |
| `getMode` | `() => CanvasMode` | lazy 拿当前 mode,派生默认 behavior 用;不传走 `design` |
| `freeSplitEnabled` | `boolean \| (() => boolean \| undefined)` | per-canvas free-split 编辑门控;不传回落构建默认 |
`CanvasAdapters` 是宿主接入业务能力的槽位:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `data` | `DataAdapter` | 数据接入(Pull 模式);不传时数据绑定 emit `no-data-adapter` 并跳过 |
| `assets` | `AssetsAdapter` | 静态资源解析;不传则直接走 `AssetRef.url` |
`provideCanvasContext` / `useCanvasContext` 是子组件树的 provide/inject 对;`useCanvasContext` 必须在 setup 同步上下文调用,没有 provider 时抛错。`CANVAS_CONTEXT_KEY` 是底层 injection key,一般不直接用。
```ts
// 在自组装的顶层组件 setup 里
const ctx = createCanvasContext({ initialDocument: props.modelValue })
provideCanvasContext(ctx)
// 在子模块(自定义属性面板)setup 里
const ctx = useCanvasContext()
```
### createCanvasHandle
`createCanvasHandle` 把内部 `CanvasContext` 精简成对外稳定的 `CanvasHandle`。整机设计器内部就是这么造 handle 再 `@handle-ready` 抛出来的;自组装时你需要自己造一个。
```ts
function createCanvasHandle(
ctx: CanvasContext,
options: CanvasHandleOptions,
): CanvasHandle & { dispose(): void }
```
`CanvasHandleOptions`:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `getStageEl` | `() => HTMLElement \| null` | lazy 拿 stage 外层 DOM;`clientToCanvasPoint` 用它的 rect 做坐标换算 |
| `recorder` | `{ maxSize?: number }` | 命令栈选项(undo 栈上限) |
| `resolveContract` | `ContractResolver` | `componentVersionKey → ComponentContract` 的 lookup;不传则跳过 contract 维度的 reparent 校验。强烈建议传 |
返回值额外带一个 `dispose()`:handle 不再使用时调用,释放 binding 订阅、pending 信号、命令栈。方法全集见 [CanvasHandle](/api/canvas-handle)。
```ts
const handle = createCanvasHandle(ctx, {
getStageEl: () => stageRef.value?.$el ?? null,
resolveContract: (key) => sourceMap.value[key]?.contract,
})
onBeforeUnmount(() => handle.dispose())
```
### Headless 状态层
三个状态 store 是纯逻辑(只用 Vue reactive,不引 Pinia / Zustand)。`createCanvasContext` 内部就是组合它们;宿主也可以单独创建消费,例如在非画布组件里读 / 改文档。
```ts
function createDocumentStore(initial?: PageDocumentInput): DocumentStore
function createSelectionStore(): SelectionStore
function createViewportStore(opts?: ViewportStoreOptions): ViewportStore
function emptyPageDocument(): PageDocument
```
- `DocumentStore`:`PageDocument` 的 reactive 包装 + mutation helper(`addInstance` / `removeInstance` / `updateInstance` / `updateLayout` 等)。`store.document` 是只读 reactive,Vue 模板直接订阅。
- `SelectionStore`:选中实例集合,暴露 `selectedIds` / `primaryId` 计算属性,以及 `select` / `toggleSelect` / `addToSelection` 等写口。
- `ViewportStore`:缩放 + 平移 + 坐标换算(`toCanvasPoint` / `toViewportPoint`);`ViewportStoreOptions` 控制 `minScale`(默认 0.25)/ `maxScale`(默认 4)/ `initialScale`。
`emptyPageDocument()` 造一份最小空 v1 文档(`canvas` 默认 `fill` 模式),`createDocumentStore()` 不传初值时就用它。需要直接喂 Renderer / Designer 的合法文档工厂请用 `createMinimalPageDocument` / `createFreePageDocument`(见 [快速上手](/guide/quick-start))。
```ts
const docStore = createDocumentStore()
docStore.addInstance({ instanceId: 'a', componentId: 'btn' /* ... */ })
docStore.document.instances.length // 1(reactive)
```
### normalizePageDocument
```ts
function normalizePageDocument(raw: PageDocumentInput): PageDocument
```
把宿主灌入的文档归一化到 v1 形态:v0 → v1 layout 升级、补齐 `parentId` / `slot` / `layoutBox` / `rootLayout` 等派生字段、bindings 投影成规范形态。**幂等**:`normalize(normalize(x)) === normalize(x)`。
整机路径不用手动调——`HyperCardPageRenderer` / `HyperCardCanvasDesigner` / `createDocumentStore` / `createCanvasContext` 在入口都已各调一次。需要手动调的场景:
- 自组装时直接操作 store / context 之外的原始 `PageDocument`,想先归一再用。
- 宿主自己持久化文档,想在写库前统一成 v1 形态。
后端 `/render` 返回的 payload normalize 之后已是 v1,业务页面一般无需手动调用。
### isFreeSplitEnabled
```ts
function isFreeSplitEnabled(): boolean
```
读取 free-split 自由分割布局的**编辑门控构建默认值**。free-split 协议默认关闭:关闭态下类型 / normalize / validate 认得 `mode: 'free-split'`,但不产出、也不修整这类持久数据,渲染降级为平铺。per-canvas 门控可通过 `CanvasContextOptions.freeSplitEnabled` 覆盖;不传时回落到本函数。布局语义见 [Free-split 布局](/guide/free-split-layout)。
### createRenderScheduler
```ts
function createRenderScheduler(opts?: RenderSchedulerOptions): RenderScheduler
```
渐进 mount 调度器:用 IntersectionObserver 按需渲染、`requestIdleCallback` 每帧只挂少量实例、出视口延迟 dispose,让单页渲染大量实例时主线程不堵、滚动不抖。`HyperCardPageRenderer` / `CanvasContext` 内部自带一个;只有自组装渲染管线时才需要直接创建。
`RenderSchedulerOptions`:
| 字段 | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `rootMargin` | `string` | `'500px'` | 视口缓冲距离(IntersectionObserver `rootMargin`) |
| `disposeDelayMs` | `number` | `1500` | 出视口多少 ms 才真 dispose;之内回到视口直接 cancel |
| `disposeOnExit` | `boolean` | `true` | **0.6.6 起**。`false` = 渐进挂载但不卸载:仍进视口才分帧挂,但已挂的滚出视口不再 dispose、永久常驻(永不重挂 = 不重渲染、不触发宿主重取)。代价:已挂常驻内存,大画布别用 |
| `mountConcurrency` | `number` | `2` | 每个 idle frame 最多挂几个实例 |
| `root` | `Element \| null \| (() => Element \| null)` | `null`(视口) | IntersectionObserver 的 root(判断"实例可见"的参照系)。**0.6.5 起接受 getter**:渲染器嵌进宿主自有滚动容器时传 `() => ref.value`(裸 `ref.value` 会在调度器创建期退化为视口) |
| `disabled` | `boolean` | `false` | **0.6.5 起**。`true` = 关掉视口虚拟化:所有实例立即且永久 `mounted`,不建 IO、不渐进、永不 dispose(`setHeld`/`refreshRoot` 为 no-op)。少量实例 / 嵌宿主滚动容器用,**不适用大画布** |
`RenderScheduler` 接口:`register(id, el)` / `unregister(id)` 管理监听,`isMounted(id)` 决定渲染真 RuntimeBox 还是占位,`getState(id)` 返回 `MountState`(`idle` / `pending` / `mounting` / `mounted` / `disposing`),`getStats()` 返回 `RenderSchedulerStats`(各状态 O(1) 计数),`refreshRoot()` 重新解析 `root` getter 并按需重建 IO(**0.6.5 起**;渲染器 `onMounted` 自动调一次,消解 root 时序坑),`dispose()` 清监听 + 队列 + timer。
#### 怎么选(卸载策略 × "可见"参照系)
| 你想要 | 配置 |
| --- | --- |
| 大画布省内存:滚出卸载、滚回重挂(会重渲染 + 触发宿主重取) | 默认(`disposeOnExit: true`) |
| **往下滚到哪块才加载哪块(跨页懒加载)+ 绝不重取**,代价是已挂常驻内存 | `{ disposeOnExit: false }`,且**不传 `root`**(按浏览器窗口算可见) |
| 嵌进宿主自有滚动容器、要"整页滚动不卸载",可接受容器内部滚动时卸载/重取 | `{ root: () => 容器ref.value }`(默认卸载策略) |
| 少量实例图省事:立即全挂、永不卸 | `{ disabled: true }` |
> `root` 是"判断可见"的**唯一参照系**,没法既当"浏览器窗口"(给你跨页懒加载)又当"某容器"(给你整页滚不卸载)—— 二选一。传了 `root: () => 容器` 后:可见以该容器为准、整页滚动不再卸载,但**页面外还没滚到的实例也会提前排队挂**(跨页懒加载变弱),且容器**内部**滚动仍会卸载 → 滚回重取。要"跨页懒加载 + 零重取"就别传 root、配 `disposeOnExit: false`。
```ts
const scheduler = createRenderScheduler({ mountConcurrency: 4 })
onMounted(() => scheduler.register(instanceId, wrapperEl))
onBeforeUnmount(() => scheduler.dispose())
// 模板里 v-if="scheduler.isMounted(instanceId)" 决定渲染 RuntimeBox / skeleton
```
## 推荐导入
常规业务:
```ts
import {
HyperCardCanvasDesigner,
HyperCardPageRenderer,
type CanvasHandle,
type PageDocument,
type PageRenderPayload,
type ComponentSourceMap,
} from '@hy-bricks/canvas'
```
自组装路径:
```ts
import {
createCanvasContext,
provideCanvasContext,
useCanvasContext,
CANVAS_CONTEXT_KEY,
createCanvasHandle,
createDocumentStore,
createSelectionStore,
createViewportStore,
emptyPageDocument,
createRenderScheduler,
normalizePageDocument,
isFreeSplitEnabled,
type CanvasContext,
type CanvasContextOptions,
type CanvasAdapters,
type CanvasHandleOptions,
type DocumentStore,
type SelectionStore,
type ViewportStore,
type ViewportStoreOptions,
type RenderScheduler,
type RenderSchedulerOptions,
type RenderSchedulerStats,
type MountState,
} from '@hy-bricks/canvas'
```
协议类型见 [Canvas Types](/api/canvas-types),宿主操作入口见 [CanvasHandle](/api/canvas-handle)。
========================================================================
# @hy-bricks/core
URL: /api/core
------------------------------------------------------------------------
# @hy-bricks/core
`core` 是运行时底座。
## createHyperCard
`createHyperCard` 返回一个 Vue plugin,`app.use()` 之后把运行时实例挂到三处可达位置:`provide('__HYPERCARD__')`、`this.$hc`、`window.__HYPERCARD__`。组件源码渲染期统一通过 `__HYPERCARD__.*` 访问注入的库和运行时 API。
```ts
import { createApp } from 'vue'
import { createHyperCard } from '@hy-bricks/core'
import ElementPlus from 'element-plus'
import axios from 'axios'
import * as echarts from 'echarts'
const app = createApp(App)
app.use(createHyperCard({
libs: {
ui: ElementPlus, // Vue plugin → SDK 自动 app.use + 挂 libs.ui
http: axios, // 普通对象 → 只挂 libs.http
echarts, // 第三方库 → 挂 libs.echarts
formatPrice: (n) => `¥${n}`, // 函数也行
BRAND: 'HyperCard', // 常量也行
},
strict: false,
version: 'v1',
}))
```
## HyperCardConfig
```ts
interface HyperCardConfig {
/** 注入到 __HYPERCARD__.libs 的库 / 函数 / 常量,key 任意命名 */
libs?: LibsConfig
/**
* strict=true 时,启动检测发现"保留词命名 / 多 Vue plugin 共存"等问题会抛 Error;
* strict=false(默认)仅 console.warn,不阻止启动
*/
strict?: boolean
/** SDK 实例版本号(组件作者可读,用于兼容判断)*/
version?: string
}
```
| 字段 | 默认 | 说明 |
| --- | --- | --- |
| `libs` | `{}` | 注入给组件源码使用的库、函数、常量。key 自取,组件源码用 `__HYPERCARD__.libs.` 访问 |
| `strict` | `false` | 启动检测命中保留名 / 多 plugin 共存等问题时,`true` 直接抛错,`false` 仅 warn |
| `version` | `'v1'` | SDK 实例版本号,组件源码可读 |
### libs 槽位允许的形态
```ts
/** 配置时单个 lib 槽位允许的类型 */
type LibConfigValue = T | readonly [T, ...unknown[]]
type LibsConfig = {
[K in keyof LibsRegistry]?: LibConfigValue
} & Record
```
每个槽位可以是:
- **直接的库 / 对象 / 函数 / 常量** —— 原样挂到 `libs.`。
- **Vue plugin**(带 `install` 方法的对象)—— SDK 自动 `app.use(plugin)`,同时把 plugin 本身挂到 `libs.`。
- **元组 `[plugin, ...options]`** —— SDK 自动 `app.use(plugin, ...options)`,然后 `libs.` 仍指向 plugin 本身(不是元组)。
```ts
app.use(createHyperCard({
libs: {
// 需要传 options 的 Vue plugin,用元组
ui: [ElementPlus, { size: 'small', zIndex: 3000 }],
},
}))
// 组件源码里:__HYPERCARD__.libs.ui 拿到的是 ElementPlus 本身
```
### LibsRegistry 与类型补全
`LibsRegistry` 是 SDK 留给宿主做类型增强的空接口,SDK 自身不绑定任何具体库类型(包体积零负担)。宿主在自己的 `*.d.ts` 里 `declare module` 增强它,组件源码写 `__HYPERCARD__.libs..` 就有 IDE 补全:
```ts
// 宿主项目 src/types.d.ts
import 'element-plus'
import type { AxiosStatic } from 'axios'
declare module '@hy-bricks/core' {
interface LibsRegistry {
ui: typeof import('element-plus')
http: AxiosStatic
formatPrice: (n: number) => string
}
}
```
## HyperCardInstance
`app.use(createHyperCard(...))` 后,运行时实例可通过三路拿到:
```ts
import { inject } from 'vue'
import type { HyperCardInstance } from '@hy-bricks/core'
// 1. setup 内 inject
const hc = inject('__HYPERCARD__')!
// 2. Options API 内 this.$hc
this.$hc.libs.http.get('/api/x')
// 3. 组件源码字符串里直接用全局
__HYPERCARD__.libs.http.get('/api/x')
```
```ts
interface HyperCardInstance {
/** 注入的库 / 函数 / 常量集合 */
libs: LibsRegistry & Record
/** 运行时跨实例 API(单画布场景);多画布请用 canvases */
runtime: RuntimeAPI
/** 资源短链工具 */
assets: typeof assets
/** 实例版本号(= config.version) */
version: string
/** 多画布 registry(见下) */
canvases: CanvasesRegistryAPI
}
```
| 字段 | 说明 |
| --- | --- |
| `libs` | `config.libs` 注入的内容(元组已展开为 plugin 本身) |
| `runtime` | 跨实例调用 / 订阅 / emit 的运行时 API。单画布可用,多画布撞名会 warn |
| `assets` | 资源短链工具 |
| `version` | SDK 实例版本号 |
| `canvases` | 多画布 registry,跨画布调用 / 广播 / 查实例 |
## RuntimeAPI
`__HYPERCARD__.runtime` 是单画布场景的跨实例运行时 API。多画布业务请改用 [`canvases`](#多画布-registry),它要求显式 `canvasId`,语义更清晰。
```ts
interface RuntimeAPI {
getInstance(id: string): ComponentInstanceHandle | null
listInstances(filter?: { componentId?: string }): ComponentInstanceHandle[]
call(id: string, method: string, ...args: unknown[]): T | undefined
on(id: string, event: string, handler: (payload: unknown) => void): (() => void) | undefined
emit(id: string, event: string, payload?: unknown): void
}
```
```js
// 组件源码里调另一个实例的方法
__HYPERCARD__.runtime.call('table1', 'refresh')
// 订阅另一个实例 emit 的事件
const off = __HYPERCARD__.runtime.on('filter1', 'change', (payload) => { /* ... */ })
```
跨画布存在同名 `instanceId` 时,`runtime` 的查询会 `console.warn` 并返回第一个匹配项——此时应改用 `canvases.callComponent(canvasId, ...)` 显式指定画布。
## isVuePlugin
`createHyperCard` 用它判断某个 lib 槽位是不是 Vue plugin,从而决定要不要自动 `app.use`。宿主一般无需直接调用,了解判定规则即可:
```ts
function isVuePlugin(v: unknown): boolean
```
- 只认 `{ install: fn }` 对象形式,以及 `[{ install }, ...options]` 元组形式。
- **不认裸函数**。因为 `axios` / `lodash` / `dayjs` 等也是函数,若被当 plugin 调用会引发破坏性错误。极少数纯函数签名的老 plugin,请显式包成 `{ install: fn }` 再传。
## HC_INJECT_KEY
provide / inject 用的字符串 key,等于 `'__HYPERCARD__'`。`createHyperCard` 安装时 `app.provide(HC_INJECT_KEY, instance)`,组件用同 key inject:
```ts
import { inject } from 'vue'
import { HC_INJECT_KEY, type HyperCardInstance } from '@hy-bricks/core'
const hc = inject(HC_INJECT_KEY)!
```
它是字符串字面量而非 Symbol,方便组件源码字符串和跨包复用时无需 import。
## 运行时对象
```ts
window.__HYPERCARD__
```
即上面的 `HyperCardInstance`,包含 `libs` / `runtime` / `assets` / `canvases` / `version`。
## 多画布 registry
`__HYPERCARD__.canvases`(`CanvasesRegistryAPI`)用于宿主以 `v-for` 渲染多个 [``](/guide/render-page) 时,拿到所有活跃画布并跨画布调用 / 广播 / 查实例。
```ts
const canvases = window.__HYPERCARD__?.canvases
canvases?.list() // 所有活跃画布 handle
canvases?.get('card-a') // 单个画布 handle,不存在返 null
canvases?.callComponent('card-a', 'table1', 'refresh') // 显式跨画布调方法
canvases?.emitToCanvas('card-a', 'filter1', 'change') // 给某画布某实例 emit
canvases?.broadcast('reload') // 全局广播,fire-and-forget
```
| API | 说明 |
| --- | --- |
| `list()` | 所有活跃画布 handle 快照 |
| `get(canvasId)` | 单画布 handle;不存在返 `null` |
| `getAll()` | 全表快照(普通对象,不暴露内部 Map) |
| `callComponent(canvasId, instanceId, method, ...args)` | 显式跨画布调用实例方法;找不到 `console.warn` + 返 `undefined`,不抛 |
| `emitToCanvas(canvasId, instanceId, event, payload?)` | 给某画布某实例 emit;找不到 `console.warn` 后 return |
| `broadcast(event, payload?)` | 跨所有画布广播;两层 try/catch,单实例炸不影响其它实例 / 画布 |
单画布 handle(`CanvasRuntimeHandle`)还提供 `listInstances` / `getInstance` / `call` / `emit` / `broadcastInCanvas` / `dispose`。
多画布业务必须优先使用显式 `canvasId`。完整示例见 [多画布 recipe](/recipes/multi-canvas)。
## 进阶:scoped registry 底层 API
大多数宿主用 `` 渲染页面,实例的注册 / 销毁由 SDK 自动完成,**不需要**碰下面这些 API。它们只在你自己组装渲染管线(不走 ``)时才用到。
```ts
import {
registerInCanvas,
unregister,
disposeCanvas,
listCanvasIds,
registryVersion,
DEFAULT_CANVAS_ID,
HC_CANVAS_ID_KEY,
} from '@hy-bricks/core'
```
| API | 说明 |
| --- | --- |
| `registerInCanvas(canvasId, instanceId, componentId, vm)` | 在指定画布内注册组件实例,返回 `ComponentInstanceHandle`。同画布内 `instanceId` 重复会覆盖 + warn;跨画布同名不冲突 |
| `unregister(instanceId, canvasId?)` | 注销实例;不传 `canvasId` 默认 `__default__`;找不到 silent |
| `disposeCanvas(canvasId)` | 销毁整个画布的注册表(dispose 所有 handle + 删内部 Map);多次调 idempotent。`` 卸载时自动调 |
| `listCanvasIds()` | 当前所有活跃画布 id 列表(快照) |
| `registryVersion` | `Ref`,注册 / 注销 / 销毁画布时 `+1`。UI 可 `watch` 它来触发重读 `listInstances()` 等派生视图 |
| `DEFAULT_CANVAS_ID` | 兜底画布字面量 `'__default__'`。不传 `canvasId` 的旧用法 / 编辑器预览落到此 scope。**正式多画布业务请显式传 `canvasId`** |
| `HC_CANVAS_ID_KEY` | provide / inject key(字符串 `'__hcCanvasId'`)。容器组件 `provide(HC_CANVAS_ID_KEY, canvasId)` 向子树传播画布上下文,子组件 `inject(HC_CANVAS_ID_KEY)` 读取 |
`registerInCanvas` 返回的 `ComponentInstanceHandle`:
```ts
interface ComponentInstanceHandle {
readonly instanceId: string
readonly componentId: string
readonly vm: ComponentPublicInstance
call(method: string, ...args: unknown[]): T
on(event: string, handler: (payload: unknown) => void): () => void
emit(event: string, payload?: unknown): void
setProp(key: string, value: unknown): void
setDataInput(key: string, value: unknown): void
}
```
`setProp(key, value)` 写 `vm[key]`(主动改 prop);`setDataInput(key, value)` 写 `vm.custom[key].value`,要求组件源码已用 `custom..dataInput = true` 显式声明该字段接收灌入,否则会 warn 且 no-op。
### 自组装路径下的画布上下文传播
不走 `` 时,自定义容器需要自己用 `HC_CANVAS_ID_KEY` 向子树 provide 画布 id,并在卸载时 `disposeCanvas`:
```ts
import { provide, onBeforeUnmount } from 'vue'
import { HC_CANVAS_ID_KEY, disposeCanvas } from '@hy-bricks/core'
const canvasId = 'card-a'
provide(HC_CANVAS_ID_KEY, canvasId)
onBeforeUnmount(() => disposeCanvas(canvasId))
```
## ErrorBoundary
```ts
import { ErrorBoundary } from '@hy-bricks/core'
```
Vue 3 错误边界组件,**运行时错误兜底**:捕获 slot 内组件 mount / update / render 阶段抛出的异常,渲染降级卡(错误信息 + 可展开堆栈 + 重试按钮),不让单个组件出错拖垮整页。SDK 渲染管线给每个组件实例都包了一层 `ErrorBoundary`,宿主自组装渲染时可手动套用。
| Prop | 类型 | 说明 |
| --- | --- | --- |
| `label` | `string?` | 错误日志定位标签,显示在降级卡标题 |
| `instanceId` | `string?` | 错误流定位维度(可选) |
| `canvasId` | `string?` | 错误流定位维度(可选) |
| `componentId` | `string?` | 错误流定位维度(可选) |
```vue
```
注意:它**不**拦截 `setTimeout` / `Promise.reject` 等异步回调内的错误(Vue `onErrorCaptured` 的固有边界),这类需要宿主用全局 `window.onerror` / `unhandledrejection` 兜底。捕获到错误后默认吞掉(不再向上传播),保证整页继续渲染。
## 组件保留 prop(0.4.0)
SDK 给每个组件实例注入 4 个保留 prop,组件作者可读自己身份(**别用这些名字声明自己的 prop**):
| Prop | 含义 |
| --- | --- |
| `this.hcCustomValues` | 属性面板 override 值 |
| `this.hcInstanceId` | 实例 ID(★ 0.4.0) |
| `this.hcCanvasId` | 所属画布 ID(★ 0.4.0) |
| `this.hcComponentId` | 组件源 ID(★ 0.4.0) |
| `hcPersistInstanceState` | 快照持久化开关(★ 0.6.3,渲染器从 [`persistInstanceState`](/api/canvas#运行时规则) 一路透下来)。组件作者**别声明同名 prop**,也别在 host 直接设——由渲染器注入 |
```js
export default {
mounted() {
// ✅ 0.4.0 起 register 已在 created 完成,mounted 里立即可订阅
__HYPERCARD__.runtime.on(this.hcInstanceId, 'refresh', () => { /* ... */ })
},
}
```
### `hcCustomValues` watcher diff(0.6.3)
`hcCustomValues` 的内部 watch 在 0.6.3 起改为 **diff 同步**:只在某 key 的 override 值【相对上次 prop】真变化时(`Object.is` 比较)才同步进组件 custom 值。无 gate、所有宿主常开。
- 修的问题:渲染器每次重渲染都传"内容相同、引用不同的新对象",老逻辑无条件写回文档默认,会冲掉用户运行时改的 custom 值(如下拉选值)。
- host 经属性面板真改值(old ≠ new)仍正常同步;初始值走 `data()` merge(该 watch 非 immediate)。
## ⚠️ 0.4.0 BREAKING — `instance:ready` 时机前移
0.4.0 把实例 register 从 `mounted` 后挪到组件 `created` 钩子(mounted 前)。`onInstanceLifecycle('instance:ready')` / `handle.on('instance:ready')` 回调被调时**子组件 DOM 还没渲染**,`vm.$el` 是 `null`。
- 不访问 DOM 的回调(push 数据 / `setDataInput`)→ 透明升级
- 访问 DOM → 改 `nextTick(() => vm.$el ...)`
升级前 audit:`grep -rn "instance:ready\|onInstanceLifecycle\|getInstanceReady" src/`
========================================================================
# @hy-bricks/devtools
URL: /api/devtools
------------------------------------------------------------------------
# @hy-bricks/devtools
诊断 SDK —— DEV 模式自动浮窗、prod 可选启用,用来看 hit-test / drag / render 出错时到底是什么原因。SDK 自己不会用,宿主显式启用才生效。
多 Tab 诊断浮窗:总览 / 命中 / 拖动 / 渲染(实例计数 + 渲染开销 + 运行时错误)/ Trace / Events(交互事件流),外加 Instance Inspect + DOM click inspect。运行态渲染场景(`HyperCardPageRenderer`)也会自动注册诊断 handle,无需 designer。
## 安装
```bash
pnpm add @hy-bricks/devtools
```
peerDeps:`vue` / `@hy-bricks/core` / `@hy-bricks/canvas`。CSS 已通过构建内联进 JS bundle,宿主**不需要**额外 `import` 任何 `style.css`。
## 零配置接入(推荐)
绝大多数情况下你只想"DEV 模式自动出浮窗",用带 side-effect 的子入口 `@hy-bricks/devtools/auto` 即可:
```ts
// 任意入口 import 一下即可
if (import.meta.env.DEV) {
void import('@hy-bricks/devtools/auto')
}
```
`@hy-bricks/devtools/auto` 是 `package.json` `exports` 里的子路径,**带 side-effect**:import 它就会无条件尝试启动诊断。它本身**不判 DEV** —— SDK 以 library 形式构建,`import.meta.env.DEV` 在 lib 产物里被固定求值为 `false`,SDK 自己判会永远是 prod,所以**守门交给宿主**,宿主在 import 之前用自己的 `import.meta.env.DEV` 包一层。
它从 `window.__HYPERCARD__.canvases` 自动拿画布注册表(`createHyperCard()` 会自动挂到 window),所以宿主要先 `app.use(createHyperCard({...}))`。考虑到启动顺序,`auto` 内部会短轮询(50ms × 60 次,3s 超时)等 registry 出现,超时只打一行 `console.debug` 不抛错。
如果你不想用 side-effect import,也可以显式调便利函数:
```ts
import { autoEnable } from '@hy-bricks/devtools'
if (import.meta.env.DEV) {
await autoEnable()
}
```
| API | 说明 |
| --- | --- |
| `autoEnable()` | `() => Promise`。短轮询等 `window.__HYPERCARD__.canvases`,出现就调零参 `enableHyBricksDiagnostics()`(全默认值);超时返 `null`,不抛 |
| `autoEnableInDev()` | `autoEnable` 的别名(已 deprecated,内部转发到 `autoEnable`),保留兼容,后续移除 |
需要自定义 `getHandle` / 提高 `level` 时,**不要用 auto**,改用下面的 `enableHyBricksDiagnostics`。
## 手动接入:enableHyBricksDiagnostics
主入口。调用一次就装好 collector + 浮窗,返回一个 `DiagnosticsHandle`。
```ts
import { enableHyBricksDiagnostics } from '@hy-bricks/devtools'
if (import.meta.env.DEV) {
enableHyBricksDiagnostics({ level: 'debug' })
}
```
所有字段都可选,零参 `enableHyBricksDiagnostics()` 即可跑(全默认值 = DEV 友好)。
```ts
interface DiagnosticsConfig {
canvases?: CanvasesRegistryAPI
getHandle?: (canvasId: string) => CanvasHandle | null
ui?: boolean
level?: DiagnosticsLevel
probes?: ReadonlyArray
captureDomRects?: boolean
}
```
| 字段 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `canvases` | | 内部 `createCanvasesRegistry()` | 多画布运行时注册表(从 `@hy-bricks/core` 拿)。不传 SDK 内部 fallback —— 它是同一份 `@hy-bricks/core` 全局 `instanceRegistry` 的**只读视图,不是独立内存池**,跟宿主自己 create 的看同一份数据。复杂多画布场景建议显式传同一个 registry |
| `getHandle` | | 走 canvas 包内置 designer registry | `(canvasId) => CanvasHandle \| null`,把宿主持有的 designer handle 按 id 喂回来,三个 probe 才能干活。默认从 canvas 包的 designer registry 取(designer 会自动注册),宿主零接入也能拿到 handle;返 `null` 时该画布的 probe 返"no-handle"报告 |
| `ui` | | `true` | 是否挂浮窗。`false` 时只有 collector 数据流、不挂 UI |
| `level` | | `'debug'` | `'silent' \| 'error' \| 'warn' \| 'info' \| 'debug'`。`'debug'` 时守门失败事件会额外落 `console.debug` |
| `probes` | | `['hit-test', 'drag', 'render', 'binding-trace']` | 启用哪些 probe。Events(交互事件流)/ 运行时错误流不在 `probes` 里 —— 它们在 collector 内固定启动 |
| `captureDomRects` | | `true` | hit-test probe 是否捕获 wrapper 的 DOM rect(`cachedBox` / `liveBox` 展示字段) |
`canvases` **何时该显式传**(不是"否则看不到画布"):
1. **语义清晰** —— collector 跟宿主共享同一对象引用,debug 时一眼对得上
2. **初始化顺序可控** —— 宿主可提前建 registry,不依赖 `@hy-bricks/core` 全局已初始化
3. **未来防分叉** —— 宿主自定义 registry(过滤 / 多版本 core / 微前端子应用各自持副本)时,显式传杜绝 SDK 跟宿主看到不同视图
```ts
import { createCanvasesRegistry } from '@hy-bricks/core'
const canvases = createCanvasesRegistry() // 宿主 runtime 自己也用这份
enableHyBricksDiagnostics({ canvases, level: 'debug' })
```
### 返回 DiagnosticsHandle
```ts
const handle = enableHyBricksDiagnostics({ level: 'debug' })
handle.dispose() // 关掉诊断器(多次调 idempotent)
const json = handle.dumpJson() // 一键导出全状态 + 三 inspect 快照,复制进 bug report
```
重复调用 `enableHyBricksDiagnostics` 是**单例语义**:旧 handle 先自动 `dispose()`,再生成新的,宿主一般不用主动 dispose。
`DiagnosticsHandle` 关键成员:
| 成员 | 说明 |
| --- | --- |
| `inspectHitTest(canvasId, canvasPt?)` | 给个 canvas 坐标,返该点命中诊断(候选 / winner / 分叉点 / reason) |
| `inspectDrag(canvasId, instanceId)` | 该实例的 layoutMode / lockState / `canReparent` dry-run 采样 |
| `inspectRender(canvasId)` | 各实例 status / layoutIssues / compile cache / 渲染开销汇总 |
| `getPageDocument(canvasId)` | 该画布 `PageDocument` 快照(designer 优先,运行态 fallback,都没有返 `null`) |
| `recentReparentFailures` | `Ref`,reparent 守门失败事件流 |
| `recentDragFailures` | `Ref`,layout-managed child 拖动失败事件流 |
| `recentRuntimeErrors` | `Ref`,运行时错误流(ErrorBoundary 抓到的 mount/render 异常) |
| `recentInteractions` | `Ref`,交互事件流(call / emit / setProp / setDataInput) |
| `recentBindingTraces` | `Ref`,数据绑定 trace 流 |
| `selectionVersion` | `Ref`,选中态版本号(可 watch 后自动刷新诊断) |
| `canvasIds` | `Ref`,当前注册画布 id 快照 |
| `refreshCanvasIds()` | 手动刷新 `canvasIds`(面板早于画布 mount 时用) |
| `level` | `Ref`,运行时可改 |
| `uiEnabled` | `Ref`,浮窗显隐开关 |
| `dumpJson()` / `dispose()` | 导出 bug report / 销毁 |
不需要浮窗、只要数据流的宿主可以关 UI 后自己订阅:
```ts
import { watch } from 'vue'
const handle = enableHyBricksDiagnostics({ canvases, ui: false })
watch(handle.recentReparentFailures, (events) => {
// 自己上报 Sentry / Datadog
})
```
### getActiveDiagnosticsHandle
```ts
import { getActiveDiagnosticsHandle } from '@hy-bricks/devtools'
const handle = getActiveDiagnosticsHandle() // 当前活动 handle 或 null
```
为外部插件 / 扩展预留,SDK 主流程不依赖它。
## prod 可选启用
prod 不该默认开浮窗。常见做法是用 URL 参数或全局开关守门:
```ts
if (window.location.search.includes('_hcdebug=1')) {
enableHyBricksDiagnostics({ level: 'debug' })
}
```
浮窗自身锁了 `isolation: isolate`,不会被宿主 dialog / popover 反向覆盖。
## 浮窗能力一览
`ui: true`(默认)时,SDK 在 `` 挂一个独立 Vue app 渲染浮窗,跟宿主 app 隔离,`dispose()` 时一并 unmount + 清 DOM。浮窗包含:
- 多 Tab:**总览** / **命中** / **拖动** / **渲染**(实例计数 + 渲染开销 p50/p95/max + 运行时错误)/ **Trace**(数据绑定)/ **Events**(交互事件流)
- **Instance Inspect**:选中单实例看其协议字段 / timings / 错误 / 事件
- **DOM click inspect**:在页面上点元素,反查并定位到对应实例
头部可拖拽、双击折成图标,位置 / 尺寸 / 折叠态走 `localStorage` 持久化。
## 低层 API:自组装诊断
想跳过浮窗、自己接第三方面板,或把诊断数据塞进自有监控的宿主,可以直接用 collector + probe 纯函数。
### createDiagnosticsCollector
```ts
import { createDiagnosticsCollector } from '@hy-bricks/devtools'
const collector = createDiagnosticsCollector({
canvases, // CanvasesRegistryAPI,必填
getHandle: (id) => myHandles[id] ?? null, // 可选
level: 'debug', // DiagnosticsLevel,必填
probes: ['hit-test', 'drag', 'render'], // ReadonlyArray,必填
captureDomRects: true, // boolean,必填
})
```
`DiagnosticsCollector` 是 `DiagnosticsHandle` 的核心子集 —— 同样有 `inspectHitTest` / `inspectDrag` / `inspectRender` / `getPageDocument`、各 `recent*` reactive ring buffer、`dumpJson()` / `dispose()`。`enableHyBricksDiagnostics` 本质就是 "collector + 浮窗 + 一组 registry 订阅" 的封装。
注意 `DiagnosticsCollectorOptions` 的字段无默认值(`canvases` / `level` / `probes` / `captureDomRects` 都必填),便利默认值是 `enableHyBricksDiagnostics` 那一层补的。
### RingBuffer
collector 内部用的固定容量 FIFO,溢出 evict 最早的。也单独导出供自建数据流复用:
```ts
import { RingBuffer } from '@hy-bricks/devtools'
const ring = new RingBuffer(20) // capacity 默认 20,必须 > 0
ring.push(ev)
ring.toArray() // readonly 浅拷快照
ring.size
ring.clear()
```
### probe 纯函数
三个 probe 是无副作用纯函数,直接喂 `CanvasHandle` 就能拿到一次性诊断报告(自组装面板、单测、临时排查都能用):
```ts
import { inspectHitTest, inspectDrag, inspectRender } from '@hy-bricks/devtools'
const hit = inspectHitTest(handle, canvasPt, {
captureDomRects: true,
selectedIds: handle.selectedIds.value, // 不传 canvasPt 时优先用选中实例中心点
})
const drag = inspectDrag(handle, instanceId)
const render = inspectRender(canvasId, handle) // designer handle 可选;不传走运行态 fallback
```
| 函数 | 签名 | 说明 |
| --- | --- | --- |
| `inspectHitTest` | `(handle, canvasPt, opts: HitTestProbeOptions) => HitTestReport` | canvasPt 为 canvas 坐标系的点(宿主用 `clientToCanvasPoint` 转好再传) |
| `inspectDrag` | `(handle, instanceId) => DragReport` | —— |
| `inspectRender` | `(canvasId, designerHandle?) => RenderReport` | `designerHandle` 省略 / 为 `null` 时 fallback 到运行态 `RendererDiagnosticsHandle` |
`HitTestProbeOptions`:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `captureDomRects` | `boolean` | 是否捕获 wrapper DOM rect 到 `cachedBox` / `liveBox` |
| `domBoxLookup?` | `(instanceId) => Rect \| null` | DOM box 查询 callback,仅用于展示字段,不参与命中判定 |
| `domScope?` | `HTMLElement \| null` | wrapper 查询 scope,默认 `document.body` |
| `selectedIds?` | `readonly string[]` | 不传 `canvasPt` 时优先用选中实例中心点 |
## report 类型速查
所有 report 都是**值快照**(可直接 JSON 序列化),不带 Vue ref。
### HitTestReport —— 命中诊断
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `winnerId` | `string \| null` | SDK 真实命中实例;`null` = 完全没命中 |
| `candidates` | `HitTestCandidate[]` | 所有落在 box 内的实例,**含 hidden / locked**(看全貌) |
| `forkPoint` | `HitTestForkPoint \| null` | winner 跟"第二名"的分叉点;只有 1 个候选时 `null` |
| `reason` | `string` | 人话:为什么 winner 赢 / 为什么没命中 |
| `queryPoint` | `Point` | 查询的点(canvas 坐标系) |
`HitTestCandidate` 含 `id` / `componentId` / `placement` / `hidden` / `locked` / `path`(root→self id 链)/ `instRect` / `cachedBox` / `liveBox` / `wrapperInStage` / `wrapperInCanvasDesigner`。
### DragReport —— 拖动 / reparent 诊断
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `instanceId` | `string` | —— |
| `exists` | `boolean` | 实例存在性;`false` 时其余字段为默认值 |
| `placement` | `DiagnosticsPlacement` | 归一后的 placement |
| `layoutMode` | `ContainerLayoutMode \| 'none'` | 实例自己作为容器的内部布局模式 |
| `parentLayoutMode` | `ContainerLayoutMode \| 'none'` | 父容器布局模式 —— 决定子能不能自由拖 |
| `mode` | `string` | 当前画布全局 mode(design / preview / readonly,或 `no-handle` / `disabled` / `disposed`) |
| `toolMode` | `string` | 当前工具模式 |
| `locked` | `DragLockState` | `{ position, size, effective }` |
| `canReparentSamples` | `DragReparentSample[]` | 对 root / self / 父 / 选中 等目标的 `canReparent` dry-run 结果 |
### RenderReport —— 渲染诊断
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `instances` | `RenderInstanceRecord[]` | 各实例 `status` / `layoutIssue?` / `timings?` |
| `globalIssues` | `LayoutIssue[]` | `handle.getLayoutIssues()` 透传 |
| `compileCacheSize` | `number` | 全局 compile cache 大小 |
| `schedulerStats?` | `RenderSchedulerStatsSnapshot` | `{ pending, mounting, mounted, disposing }`(运行态路径才有) |
| `timingsSummary?` | `RenderTimingsSummary` | 本画布 `mountMs` 汇总:`sampleCount` / `mountMsP50` / `mountMsP95` / `mountMsMax` / `slowCount`(`mountMs > 16ms` 计入) |
`RenderInstanceStatus`:`'ok' | 'missing-source' | 'compile-pending' | 'layout-error'`。
`RenderInstanceTimings`:`compileStartedAt` / `compileEndedAt` / `mountStartedAt` / `mountEndedAt` / `compileMs` / `mountMs` / `totalMs`(单位 ms,均 optional)。
### 其它导出类型
`DiagnosticsLevel` / `DiagnosticsProbeName` / `DiagnosticsPlacement` / `HitTestCandidate` / `HitTestForkPoint` / `DragLockState` / `DragReparentSample` / `RenderInstanceRecord` / `RenderInstanceStatus` / `RenderInstanceTimings` / `RenderSchedulerStatsSnapshot` / `RenderTimingsSummary`,以及 `RuntimeErrorRecord`(`message` / `stack?` / `name` / `count` 去重计数等)、`InteractionEventRecord`(`kind` / `key` / `args` / `timestamp`)均从包根导出。
## UI 组件清单
想自渲面板(不用内置浮窗、但复用 SDK 的 inspector 视图)的话,所有 UI 组件都从包根导出。它们统一通过 `collector` prop 接 `DiagnosticsHandle`(命中 / 拖动 / 渲染 / Instance 这几个还需要 `canvasId` prop):
| 组件 | 一句话 |
| --- | --- |
| `DevtoolsPanel` | 整个浮窗外壳(多 Tab + 拖拽 + 持久化),`enableHyBricksDiagnostics` 内部挂的就是它 |
| `OverviewInspector` | 总览 Tab:画布列表 + 实例计数 + 全局 issues / 错误概览 |
| `HitTestInspector` | 命中 Tab:`inspectHitTest` 结果可视化 |
| `DragInspector` | 拖动 Tab:`inspectDrag` 结果 + 守门失败事件流 |
| `RenderInspector` | 渲染 Tab:`inspectRender` 结果 + 渲染开销 + 运行时错误 |
| `BindingTraceInspector` | Trace Tab:数据绑定事件流 + filter / clear |
| `ChipBadge` | 共享:小号状态标签 |
| `JsonViewer` | 共享:可折叠 JSON 树查看器 |
| `DataTable` | 共享:轻量表格 |
| `createMockCollector()` | 返回一个填了假数据的 `DiagnosticsHandle`,给 UI 组件做 storybook / 单测 / 离线预览用 |
UI 子树独立于 collector 实现,只认 `DiagnosticsHandle` 接口形状,因此自建 collector 也能直接喂给这些组件。
========================================================================
# @hy-bricks/editor
URL: /api/editor
------------------------------------------------------------------------
# @hy-bricks/editor
`editor` 是组件源码编辑器:嵌入式 Monaco + 可拖拽分屏 + 实时预览。它是**受控组件**,不是业务后台。
设计前提:编辑器只负责"写代码 + 看预览",拉历史、判权限、存组件库这些**全交给宿主**。SDK 不主动碰任何后端,版本信息、保存、发布都通过 props/emits 让宿主接管。
```ts
import { HyperCardEditor } from '@hy-bricks/editor'
```
## HyperCardEditor
主入口组件,`v-model` 双向绑定一份三段式源码(`html` / `js` / `css`)。
### 最小用法
```vue
```
### v-model
```ts
interface Source {
html: string
js: string
css: string
}
```
`v-model` 绑定的是整份 `Source`。编辑器内部用内容比对终止 v-model 循环:宿主回灌同一份内容不会重置光标 / dirty 状态。
宿主要换组件源码(切换正在编辑的组件)时,**不要**只改 `v-model` 值,用 `:key` 强制重建:
```vue
```
这样 scope、Monaco model、草稿检测都会干净重置。
### Props
| Prop | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `modelValue` | `Source` | — | v-model 绑定的源码,必填 |
| `draftKey` | `string` | — | 启用 localStorage 草稿(key = `hc:draft:`);留空 = 不启用草稿 + 独立预览窗 |
| `componentId` | `string` | `draftKey` 或内部 uid | 给运行时预览实例用的 id |
| `features` | `EditorFeatures` | — | 入口按钮显隐 / 启用开关 |
| `versionStatus` | `VersionStatus` | — | 宿主传入的版本摘要,只读、只展示 |
| `extraLibs` | `ExtraLib[]` | — | 额外 Monaco 类型注入(见下文) |
| `autoPreview` | `boolean` | `true` | 是否自动重渲染预览 |
| `previewRatio` | `number` | `0.62` | 编辑区与预览区初始宽度比 |
| `showBackButton` | `boolean` | `false` | 顶栏显示返回按钮 |
| `title` | `string` | — | 顶栏标题 |
| `statusLabel` | `string` | — | 顶栏右侧状态文本(遗留,优先用 `versionStatus.currentVersionLabel`) |
`features` 控制的是**入口是否展示**,不控制权限。真正权限由宿主自己判断后再决定 `enablePublish` 等传不传 `true`。
```ts
interface EditorFeatures {
/** 显示"历史版本"入口 */
showHistory?: boolean
/** 显示"查看改动"入口 */
showDiff?: boolean
/**
* "推版本"入口(三态):
* undefined → 显示(向后兼容)
* true → 显示
* false → 隐藏
*/
enablePublish?: boolean
}
```
`versionStatus` 是宿主告诉编辑器的版本摘要,编辑器自己不加载:
```ts
interface VersionStatus {
/** 当前版本展示名,如 "v1" / "v12" / "draft";undefined = 宿主还没传 */
currentVersionLabel?: string
/**
* 是否有历史版本(三态):
* undefined → 还没加载完,历史 / diff 入口不渲染(避免闪烁)
* false → 确认无历史
* true → 有历史
*/
hasHistory?: boolean
/** 最近发布时间,ISO 8601 字符串;编辑器自己格式化为相对时间 */
lastPublishedAt?: string
}
```
### Emits
宿主拿到事件后自己处理(toast / 弹窗 / 调 API / 路由跳转),编辑器不替宿主做副作用。
| 事件 | 载荷 | 说明 |
| --- | --- | --- |
| `update:modelValue` | `Source` | v-model 回写 |
| `dirty` | `boolean` | 是否有未保存修改 |
| `save-draft` | — | 点"保存草稿"(有 `draftKey` 时 SDK 已写 localStorage,宿主再接 toast) |
| `push-version` | — | 点"推版本",宿主弹 dialog 调发布 API |
| `open-history` | — | 点"历史版本" |
| `open-diff` | — | 点"查看改动" |
| `open-settings` | — | 点组件设置,宿主弹元数据面板 |
| `open-preview-window` | — | 点独立预览窗,宿主自己开新窗口 |
| `go-back` | — | 点返回按钮(`showBackButton` 时) |
| `error` | `{ message: string; stack?: string }` | 预览编译 / 运行时错误 |
```vue
```
### 暴露的 ref
通过组件 `ref` 拿到:
| 成员 | 类型 | 说明 |
| --- | --- | --- |
| `isDirty` | `Ref` | 用户修改未保存 |
| `confirmLeave()` | `() => boolean` | 同步 confirm 弹窗,dirty 才提示;返回 `true` 表示放行 |
| `applyDraft(draft)` | `(draft: ComponentDraft) => void` | 把外部 draft 灌进来(恢复历史版本) |
| `manualPreview()` | `() => void` | 手动触发预览(`autoPreview=false` 时) |
| `applyPreview()` | `() => void` | v-model 外部灌入后想立即刷预览时调 |
路由守卫示例:
```ts
const editor = ref | null>(null)
onBeforeRouteLeave(() => editor.value?.confirmLeave() ?? true)
```
## Monaco 类型注入
编辑器会给组件作者提供 `__HYPERCARD__` / `runtime` / `assets` 的自动补全,这套类型 SDK 自带、不用宿主管。宿主只需要补充**自己注入的 libs**(如 http 客户端、UI 库)的具体类型。
### setupHyperCardMonacoTypes
```ts
function setupHyperCardMonacoTypes(): void
```
注册 HyperCard 全局类型。SDK 内部首次创建 Monaco 编辑器时会自动调一次,多次调用自动去重。一般宿主不用手动调;只有自己单独起 Monaco 实例时才需要。
### retainMonacoExtraLib / releaseMonacoExtraLib
```ts
interface ExtraLib {
/** d.ts 内容字符串 */
content: string
/** 唯一文件路径,影响 Monaco 内部模块识别 */
filePath: string
}
function retainMonacoExtraLib(lib: ExtraLib): void
function releaseMonacoExtraLib(filePath: string): void
```
**带引用计数**的 extra lib 注册/释放。多个 `HyperCardEditor` 实例可能传同一个 `filePath`,ref-count 保证 A 卸载不会把 B 还在用的 lib 删掉 —— 每次 `retain` 计数 +1,`release` 计数 -1,归零才真删。
推荐做法:直接用 `:extra-libs` prop 交给 SDK,SDK 在挂载时 `retain`、卸载时 `release`,无需手动管理:
```vue
```
`HyperCardLibs` 是开放命名空间(默认 `{ [key: string]: any }`),宿主在自己的 d.ts 里**声明合并**补具体类型即可。
如果同一个 `filePath` 用不同 `content` 重复注册,SDK 会 warn 并保留先注册的版本;需要独立内容请用不同 `filePath`。
手动注入(自起 Monaco 时):
```ts
import { setupHyperCardMonacoTypes, retainMonacoExtraLib, releaseMonacoExtraLib } from '@hy-bricks/editor'
setupHyperCardMonacoTypes()
retainMonacoExtraLib({ filePath: 'file:///my-libs.d.ts', content: '...' })
// 清理时
releaseMonacoExtraLib('file:///my-libs.d.ts')
```
### addMonacoExtraLib / removeMonacoExtraLib(已废弃)
```ts
/** @deprecated 用 retainMonacoExtraLib 代替 */
function addMonacoExtraLib(lib: ExtraLib): void
/** @deprecated 用 releaseMonacoExtraLib 代替 */
function removeMonacoExtraLib(filePath: string): void
```
旧 API,内部已转发到 retain/release。新代码一律用 `retainMonacoExtraLib` / `releaseMonacoExtraLib`,避免多实例互删。
## 草稿
源码草稿存取(localStorage,按 `componentId` 隔离,key 前缀 `hc:draft:`)。纯数据进出,不依赖 Monaco 单例。`HyperCardEditor` 传了 `draftKey` 时会自动用这套;宿主想自己做草稿逻辑也可以直接 import。
```ts
interface ComponentDraft {
html: string
javascript: string
css: string
savedAt: number
}
function loadDraft(componentId: string): ComponentDraft | null
function saveDraft(
componentId: string,
sources: { html: string; javascript: string; css: string },
): ComponentDraft | null
function clearDraft(componentId: string): void
function formatRelative(ts: number): string
```
| API | 说明 |
| --- | --- |
| `loadDraft(id)` | 读草稿;无 / 损坏返 `null` |
| `saveDraft(id, sources)` | 写当前源码,返回写入的 `ComponentDraft`;失败返 `null` |
| `clearDraft(id)` | 清掉草稿(如发布成功后) |
| `formatRelative(ts)` | 时间戳 → 中文"多久前"(刚刚 / N 分钟前 / N 小时前 / N 天前) |
注意 `ComponentDraft` 用 `javascript` 字段(而非 `Source` 的 `js`),与 Monaco 语言 id 对齐。
## 实时预览总线
主窗口 ↔ 独立预览窗口之间的 `BroadcastChannel` 总线。`HyperCardEditor` 传了 `draftKey` 时内部自动用;宿主自己做独立预览窗才需要直接调。
```ts
type PreviewMessage =
| { type: 'sources'; html: string; javascript: string; css: string }
| { type: 'hello'; windowId: string }
| { type: 'goodbye'; windowId: string }
| { type: 'request-sources' }
interface PreviewBus {
channel: BroadcastChannel
send(msg: PreviewMessage): void
close(): void
}
function createPreviewBus(componentId: string): PreviewBus
```
channel 名为 `hc-preview-${componentId}`,按组件 id 隔离不同组件的预览。协议:
- 主 → 子:`sources`(源码变化时节流发送)
- 子 → 主:`hello`(子窗挂载)/ `goodbye`(子窗关闭)/ `request-sources`(子窗挂载后主动拉一次完整源码)
```ts
const bus = createPreviewBus(componentId)
bus.channel.addEventListener('message', (e) => {
const msg = e.data as PreviewMessage
if (msg.type === 'sources') render(msg.html, msg.javascript, msg.css)
})
bus.send({ type: 'request-sources' })
// 关闭时
bus.close()
```
## 版本钩子显示判定
一组**纯函数**,把"入口按钮要不要显示""时间相对展示"抽出来。`HyperCardEditor` 内部用它们决定顶栏入口;宿主想在别处复用同款判定 / 同款相对时间格式时可直接调。
```ts
function shouldShowHistoryEntry(features?: EditorFeatures, status?: VersionStatus): boolean
function shouldShowDiffEntry(features?: EditorFeatures, status?: VersionStatus): boolean
function shouldShowPublishEntry(features?: EditorFeatures): boolean
function formatLastPublished(iso?: string, now?: Date): string | null
```
| API | 显示条件 |
| --- | --- |
| `shouldShowHistoryEntry` | `features.showHistory` 且 `status.hasHistory === true`(严格三态,undefined/false 不显示) |
| `shouldShowDiffEntry` | 在历史条件基础上,还要求 `currentVersionLabel` 存在(diff 必须有 baseline) |
| `shouldShowPublishEntry` | `features.enablePublish !== false`(undefined 默认显示) |
| `formatLastPublished` | ISO 8601 → 中文相对时间;无效 / 未提供返 `null`,调用方按 `null` 决定不渲染 |
`shouldShow*` 用严格三态是为了避免宿主版本信息未加载时按钮闪现。
## 高级:自己拼编辑器
如果宿主想绕过 `HyperCardEditor` 顶层壳、自己组合编辑布局,SDK 把内部子组件 / scope / 布局 helper 也导出了。多数宿主用不到,直接用 `HyperCardEditor` 即可。
### 子组件
| 导出 | 说明 |
| --- | --- |
| `EditorLayout` | 按布局树递归渲染分屏 |
| `EditorGroup` | 单个 tab 组(tab bar + Monaco) |
| `MonacoEditor` | 单个 Monaco 编辑器 |
| `Splitter` | 左右 / 上下可拖拽分隔器 |
| `TabDropPayload` | `EditorGroup` 拖拽落点载荷类型 |
这些子组件必须挂在 `HyperCardEditor` / `createEditorScope` 提供的 scope 内,否则 `useEditorScope` 会 throw。
### Editor scope
每个 `HyperCardEditor` 实例自己一份 scope,多实例互不污染 Monaco model。
```ts
function createEditorScope(): EditorScope
function useEditorScope(): EditorScope
const EDITOR_SCOPE_KEY: InjectionKey
type SourceKey = 'html' | 'javascript' | 'css'
interface ModelMap {
html: monaco.editor.ITextModel
javascript: monaco.editor.ITextModel
css: monaco.editor.ITextModel
}
const SOURCE_KEY_LABEL: Record // { html: 'index.html', javascript: 'component.js', css: 'styles.css' }
```
`EditorScope` 提供 `useModels()` / `getSource(key)` / `setSource(key, value)` / `snapshotSources()` / `onSourceChange(handler)` / `dispose()`。`createEditorScope()` 创建后要 `provide(EDITOR_SCOPE_KEY, scope)`,子组件 `useEditorScope()` inject 取用;不在 scope 内调用会 throw(明确开发期错误,不静默回退)。
### 布局 helper
不可变操作:每次返回**新的**布局树,Vue ref 替换触发 reactivity。
```ts
type DropEdge = 'center' | 'left' | 'right' | 'top' | 'bottom'
interface GroupNode { type: 'group'; id: string; tabs: SourceKey[]; activeTab: SourceKey }
interface SplitNode { type: 'split'; id: string; direction: 'horizontal' | 'vertical'; ratio: number; a: LayoutNode; b: LayoutNode }
type LayoutNode = GroupNode | SplitNode
function defaultLayout(): LayoutNode
function activateTab(root: LayoutNode, groupId: string, tab: SourceKey): LayoutNode
function closeTab(root: LayoutNode, groupId: string, tab: SourceKey): LayoutNode
function splitWithTab(root: LayoutNode, sourceGroupId: string, tab: SourceKey, targetGroupId: string, edge: 'left' | 'right' | 'top' | 'bottom', copy?: boolean): LayoutNode
function moveTab(root: LayoutNode, sourceGroupId: string, tab: SourceKey, targetGroupId: string, insertIndex: number | null, copy?: boolean): LayoutNode
function setSplitRatio(root: LayoutNode, splitId: string, ratio: number): LayoutNode
```
`defaultLayout()` 返回单个 group(tabs `html` / `javascript` / `css`,默认激活 `javascript`)。`setSplitRatio` 的 ratio 会 clamp 到 `[0.05, 0.95]`。
### Button
SDK 内置 vendor 的 shadcn-vue 风按钮,宿主装 SDK 后无需 `npx shadcn-vue add`,可直接 import 复用。
```ts
import { Button, buttonVariants, type ButtonVariants } from '@hy-bricks/editor'
```
`variant`:`default` / `destructive` / `outline` / `secondary` / `ghost` / `link`。
`size`:`default` / `xs` / `sm` / `lg` / `icon` / `icon-sm`。
`buttonVariants` 是 `cva` 工厂,可在别处生成同款 class;`ButtonVariants` 是其 variant props 类型。
## 边界
编辑器**不做**:拉历史版本、判断发布权限、知道后端表结构、自动保存到组件库。这些宿主拿到事件后自己处理。
## 导出速查
| 名称 | 类别 | 说明 |
| --- | --- | --- |
| `HyperCardEditor` | 组件 | 主入口受控组件 |
| `Source` / `VersionStatus` / `EditorFeatures` | 类型 | 主组件 props 类型 |
| `setupHyperCardMonacoTypes` | 函数 | 注册 HyperCard 全局 Monaco 类型 |
| `retainMonacoExtraLib` / `releaseMonacoExtraLib` | 函数 | ref-count 安全的 extra lib 增持 / 释放 |
| `addMonacoExtraLib` / `removeMonacoExtraLib` | 函数 | 已废弃,转发到 retain/release |
| `ExtraLib` | 类型 | extra lib 形状 |
| `loadDraft` / `saveDraft` / `clearDraft` | 函数 | 草稿存取 |
| `formatRelative` | 函数 | 时间戳 → 相对时间 |
| `ComponentDraft` | 类型 | 草稿数据形状 |
| `createPreviewBus` | 函数 | 创建预览 BroadcastChannel 总线 |
| `PreviewMessage` | 类型 | 预览总线消息 union |
| `shouldShowHistoryEntry` / `shouldShowDiffEntry` / `shouldShowPublishEntry` | 函数 | 入口显示判定纯函数 |
| `formatLastPublished` | 函数 | ISO 8601 → 相对时间(可空) |
| `EditorLayout` / `EditorGroup` / `MonacoEditor` / `Splitter` | 组件 | 自拼编辑器用的子组件 |
| `TabDropPayload` | 类型 | tab 拖拽落点载荷 |
| `createEditorScope` / `useEditorScope` / `EDITOR_SCOPE_KEY` | scope | editor scope 工厂 / inject / key |
| `ModelMap` / `SourceKey` / `SOURCE_KEY_LABEL` | scope | model 相关类型 / 常量 |
| `defaultLayout` / `activateTab` / `closeTab` / `splitWithTab` / `moveTab` / `setSplitRatio` | 函数 | 布局树操作 helper |
| `LayoutNode` / `GroupNode` / `SplitNode` / `DropEdge` | 类型 | 布局树类型 |
| `Button` / `buttonVariants` / `ButtonVariants` | UI | 内置 shadcn-vue 风按钮 |
## 进一步阅读
- [快速开始](/guide/quick-start)
- [组件事件](/guide/component-events)
- [核心 API](/api/core)
========================================================================
# `@hy-bricks/experimental-vue-page-importer`
URL: /api/experimental-vue-page-importer
------------------------------------------------------------------------
# `@hy-bricks/experimental-vue-page-importer`
> 实验性独立可选包。`.vue` 文件 → opaque page 导入(整页一张画布,**内部 DOM 不可单独编辑**)。
>
> npm dist-tag `experimental`,**不进**主 4 包。MVP 2026-05-25 playground 验证通过。
---
## 功能语义
**上传 `.vue` 文件 → 平台生成一张可渲染画布**。
跟"组件库导入"不同:
- ❌ **不是**把 .vue 拆成多个 hc 组件
- ❌ **不是**让用户在画布上**单独编辑**内部 DOM 元素
- ✅ **是**整页一张画布,挂一个根组件,用户可以加旁路装饰但**不能拆动内部 DOM**
适用:把现有 Vue 单文件页面(Login.vue / Dashboard.vue)快速搬进平台,享受 SDK 的多画布编排 + 数据绑定 + 主题切换。
## 安装
```bash
pnpm add @hy-bricks/experimental-vue-page-importer@experimental
```
**`@experimental` tag 必需**(不带 tag 装会"version not found")。
## 强边界(★ 必读)
1. **不改 runtime / `compileComponent` / `RuntimeBox` / `PageDocument` 主协议**
2. **`@vue/compiler-sfc` 只在本包**(主 bundle 不带,605 KB / gzip 193 KB lazy chunk 只在访问时下载)
3. **release 双层隔离**(`package.json` release 字段 + `.changeset/config.json` ignore)
4. **bundle 实测**:主 bundle 仅 +1 KB,experimental lazy chunk 只在访问 `/vue-import` 时下载
## API
```ts
function importVuePage(source: string, options?: ImportVuePageOptions): ImportResult
interface ImportVuePageOptions {
pageId?: string
fileName?: string
componentVersionKey?: string // 默认 'vue-import@1'
}
interface ImportResult {
ok: boolean
document?: PageDocument
componentVersionAsset?: ComponentVersionAsset
diagnostics: ImportDiagnostic[]
}
```
## 用法
```vue
```
## playground demo
```bash
pnpm --filter @hy-bricks/playground dev
# 浏览器:/vue-import
# 点「载入示例」→ 真渲染 + click button → count 响应式递增
```
verified 2026-05-25:playwright e2e 7/7 全过 + 0 console error。
## MVP 不含(挂后续)
- npm publish(脚本就位,需要时单独发布)
- 业务宿主接入
- 结构化拆节点(范围外,需另起 HC 标注 DSL)
========================================================================
# LayoutBox API
URL: /api/layoutbox
------------------------------------------------------------------------
# LayoutBox API
`LayoutBox` 是当前的布局权威字段。
## 类型
```ts
type LayoutBoxAxisMode = 'px' | 'percent' | 'fill' | 'auto'
interface LayoutBox {
x: number
y: number
width: number
height: number
widthMode: LayoutBoxAxisMode
heightMode: LayoutBoxAxisMode
overflow?: 'hidden' | 'visible' | 'auto'
}
```
## 修改实例宽高
```ts
handle.updateLayoutBox(instanceId, {
widthMode: 'percent',
width: 100,
heightMode: 'auto',
})
```
## 修改实例位置
```ts
handle.updateLayoutBox(instanceId, {
x: 120,
y: 80,
})
```
## 溢出策略
| overflow | 说明 |
| --- | --- |
| `hidden` | 默认,裁剪溢出内容 |
| `visible` | 允许内容超出盒子 |
| `auto` | 内容超出时滚动 |
## 和组件 CSS 的关系
SDK wrapper 会根据 `layoutBox` 给组件外层定尺寸。
组件源码内部建议写:
```css
.root {
width: 100%;
height: 100%;
}
```
这样组件能自然继承外层盒子。
## 不要再用旧 size
- 新代码使用 `layoutBox`
- 旧 `size` 字段仅做兼容
- 属性面板应 emit `patchLayoutBox`,不要再 emit `patch-size`
========================================================================
# 发版历史
URL: /changelog
------------------------------------------------------------------------
# 发版历史
`@hy-bricks` 的 4 个包(`core` / `canvas` / `editor` / `devtools`)走 **fixed 组 lock-step**:同一次发布共享同一个版本号,`pnpm add @hy-bricks/core@x.y.z` 时 4 包对齐即可。
> 本页是**面向使用方的精炼发版历史**;逐条完整变更(含内部实现)见仓库各包 `CHANGELOG.md`。
> 当前阶段 pre-1.0(`0.x`):次版本可能含破坏性变更,升级前看清本页 ⚠️ 标记。
---
## 0.6.6 · 调度器「渐进挂载但不卸载」模式 {#v0-6-6}
2026-06-27 · patch
续 0.6.5:`disabled` 是「立即全挂、永不卸载」(连渐进挂载也省掉);但有时你想**首屏还是分帧渐进挂**(实例多了不卡)、**又不要滚动时卸载抖动**(嵌套滚动里的看板缩略画布)。0.6.6 给这个中间档。全 additive、默认行为不变。
- **新增 `schedulerOptions.disposeOnExit`(默认 `true` = 现行为)**:`false` = 渐进挂载但不卸载。实例仍按「进视口才挂」分帧渐进挂(保留首屏不卡),但**已挂载的滚出视口不再卸载**、常驻 —— 既不重渲染、也不触发宿主重新取数。
- **三档怎么选**:
- 默认(`disposeOnExit: true`):渐进挂 + 滚出卸 —— 省内存,滚回会重挂(重渲染 + 宿主重取)。**大画布**用。
- `disposeOnExit: false`:渐进挂 + 永不卸 —— 首屏分帧快、滚动零抖动,代价是已挂的常驻内存。**嵌套滚动缩略画布**用。
- `disabled: true`:立即全挂 + 永不卸 —— 最简,但首屏一次性全挂、无渐进。**少量实例**用。
📖 [渲染调度](/api/canvas)
## 0.6.5 · 调度器关虚拟化开关 + root 时序修复 {#v0-6-5}
2026-06-27 · patch
把渲染器嵌进**你自己的滚动容器**(整页滚动 → 看板自有滚动 → 画布)时的两个坑,给了正解。全 additive、默认行为不变。
- **新增 `schedulerOptions.disabled`(默认 `false`)**:关掉视口虚拟化的逃生舱。`true` 时画布里所有实例立即且永久挂载、永不卸载——适合「画布嵌在你自己的滚动容器里 / 渲缩略图 / 少量带数据绑定的实例,不想被滚出视口就卸载→重挂(会重置状态、触发重新取数)」。⚠️ **不要用在大画布**(放弃了渐进挂载与卸载兜底,大量实例会卡首屏 + 占内存)。
- **`schedulerOptions.root` 现在接受 getter**(`() => ref.value`)+ **新增 `RenderScheduler.refreshRoot()`**:修「把你自己的祖先滚动容器 ref 当 root 传,却被静默退化成视口」的时序坑。**迁移**:`root: ref.value` → `root: () => ref.value`(渲染器会在挂载后自动用真容器重建)。或干脆 `disabled: true`。
- root getter 解析不到元素时,开发环境会 `console.warn` 提醒一次。
📖 [渲染调度](/api/canvas)
## 0.6.4 · 布局黄框误报修复 + editor peer 放宽 {#v0-6-4}
2026-06-25 · patch
- **修复**:容器卡片滚出视口被虚拟化卸载时,卡内 slot 子组件不再误报「布局问题」黄框 —— 此前会泄漏到运行态终端用户(滚回可视即恢复)。真正的布局配错仍照常提示,不受影响。
- **`@hy-bricks/editor`**:`tailwindcss` / `tailwindcss-animate` 改为可选 `peerDependencies` —— 只引预编译 `@hy-bricks/editor/style.css`、不自带 Tailwind 的宿主装包不再报 `missing peer tailwindcss` 警告。
📖 [渲染一个页面](/guide/render-page)
---
## 0.6.3 · 组件运行态快照持久化 {#v0-6-3}
2026-06-24 · patch
修「看板组件滚出视口 / 界面重渲染后,用户运行时选的值(如维度=年)变回默认、查询条件丢」。
- **修复(所有宿主自动生效,无开关)**:重渲染不再把用户运行时改过的属性面板值冲回文档默认 —— 只有值**真变了**才同步。
- **新增 `persistInstanceState`(opt-in,默认 `false`)**:实例被视口虚拟化**真销毁**再重建时,拍 `$data` 快照并还原,让「滚出视口再回来」不丢状态。`HyperCardCanvasDesigner` / `HyperCardPageRenderer` / `RuntimeLayer` 同名 prop 透传。与 `keepHiddenMounted` 互补:**隐藏的保活;滚没的拍照**。
- **已知限制**:① 组件在 `mounted` 里**异步**重置状态不在保护范围(还原是同步的,异步会盖过);② `instanceId` 必须逻辑唯一、不可删后复用。
📖 [渲染一个页面 › persistInstanceState](/guide/render-page) · [常见问题排查](/guide/troubleshooting)
---
## 0.6.2 · 隐藏实例保活 {#v0-6-2}
2026-06-23 · patch
- **修复**:`hidden`(`v-show` / `display:none`)的实例再恢复显示时不再丢内部状态(此前会被视口调度误判离开视口而销毁)。
- **新增 `keepHiddenMounted`**(`HyperCardPageRenderer`,默认 `true`):隐藏实例保活开关;传 `false` 退回旧行为(省内存但恢复丢状态)。
📖 [渲染一个页面](/guide/render-page)
---
## 0.6.1 · free-split 编辑控件可见性 {#v0-6-1}
2026-06-16 · patch
- **修复**:选中 free-split 容器**内部**子组件时,不再丢父容器的分隔条 / 叶控件(控件可见集改为 ancestor-aware)。
- **新增 `freeSplitControlsAlways`**(默认 `false`):开启后编辑态所有 free-split 容器控件常驻显示。
📖 [自由分割布局](/guide/free-split-layout)
---
## 0.6.0 · 公开面收口 + 级联删除 ⚠️ {#v0-6-0}
2026-06-15 · minor · 含破坏性变更
- **⚠️ 破坏**:① 移除一批 test-only / SDK 内部导出(收紧公开面,正常业务不受影响);② `beginBatch / endBatch` 改**深度计数**(成对配平、可嵌套,只有最外层 `endBatch` 落 undo,不再幂等);③ **`removeInstance` 默认级联删除** —— 删容器会带走整棵子树并清关联绑定(undo 完整恢复);要旧的「留孤儿」口径传 `cascadeRemove: false`。
- **新增** `CanvasHandle.getRemoveImpact(id)`:删前预检受影响实例(宿主自行弹确认)。
- 含本轮审计的正确性 / 性能 / 类型 / 去重修复。
📖 [CanvasHandle](/api/canvas-handle)
---
## 0.5.0 · free-split 自由分割布局 {#v0-5-0}
2026-06-03 · minor
- **新增**:第 5 种容器布局 —— 自由分割(容器内递归 BSP 分割树)。渲染 **data-driven**(任意宿主有合法数据即渲染),`freeSplitEnabled` 只限制编辑能力。
📖 [自由分割布局](/guide/free-split-layout)
---
## 0.4.2 · devtools 强化 {#v0-4-2}
2026-05-28 · patch
诊断 devtools 配套增强(随 fixed 组同步)。📖 [devtools](/api/devtools)
---
## 0.4.1 · 发版说明可见性补丁 {#v0-4-1}
2026-05-27 · patch · 纯文档
把 `CHANGELOG.md` 纳入 npm 包产物 + README 顶部补 BREAKING / 新特性,让 npm 包页面首屏可见。代码零变更,可从 0.4.0 透明升上来。
---
## 0.4.0 · 组件 mounted 里立即可订阅 ⚠️ {#v0-4-0}
2026-05-27 · minor · 含破坏性变更
- **⚠️ 破坏**:`instance:ready` 的触发时机从 `mounted` 后提前到 `created` 后。回调里访问 `vm.$el` 会拿到 `null` —— 需改成 `nextTick()` 后再访问 DOM。
- **新增** 3 个保留 prop:`hcInstanceId` / `hcCanvasId` / `hcComponentId`(组件作者别声明同名)—— 组件在 `mounted`(乃至 `created`)里即可订阅其它实例。
📖 [组件间事件](/guide/component-events)
---
## 0.3.0 · 渲染核心:mount-ready 信号 + 精确读 cache {#v0-3-0}
2026-05-23 · minor
- **新增**:`CanvasHandle.on('instance:ready' | 'instance:unmounted')` 生命周期信号 + `getInstanceReady(id)` / `getInstanceRuntime(id)` / `getCachedBySourceId(sourceId, params?)` 精确读运行态 / 快照。
📖 [CanvasHandle](/api/canvas-handle)
---
## 更早版本
`0.2.x`(布局系统:flex / split / grid / free 容器 + 分隔条拖动)、`0.1.x`(组件运行时 + 嵌入式编辑器 + 设计器骨架)等早期版本的逐条记录见仓库各包 `CHANGELOG.md`。
========================================================================
# 宿主边界
URL: /concepts/host-boundary
------------------------------------------------------------------------
# 宿主边界
HyperCard SDK 的边界要一直守住:SDK 只负责拿数据渲染和提供编辑能力,业务系统负责业务判断。
## SDK 不判断权限
比如“谁能看见某张图表的数据”,这不是 SDK 的事。
正确做法:
1. 宿主业务服务判断权限。
2. 宿主只把用户能看的数据传给组件。
3. 组件按数据渲染。
## SDK 不决定数据怎么请求
组件可以通过 `__HYPERCARD__.libs.http` 调业务接口,但接口本身、鉴权、缓存、错误处理都由宿主负责。
## SDK 不内置属性面板
SDK 提供:
- 选中实例
- contract
- `CanvasHandle`
- `dispatch`
- `updateLayoutBox`
宿主负责:
- 表单 UI
- 字段分组
- 校验提示
- 保存节奏
## SDK 不内置组件库 UI
组件库可以是业务系统自己的产品能力。SDK 只要求拖到画布时能转成 `PageInstance`。
## SDK 不绑定后端 schema
参考后端只是参考实现。真正业务项目可以用 Java、Go、Node、低代码平台自己的后端,只要最终能给 SDK 提供约定的数据结构。
========================================================================
# LayoutBox 布局模型
URL: /concepts/layout-system
------------------------------------------------------------------------
# LayoutBox 布局模型
布局模型的核心是:组件外面永远有一层 SDK 管的盒子,组件自己只管盒子里面怎么画。
## 四层字段
| 字段 | 含义 | 谁消费 |
| --- | --- | --- |
| `PageDocument.layout.canvas` | 画布如何放进宿主容器 | Renderer / Designer |
| `PageInstance.layoutBox` | 实例自己的外层盒子 | SDK wrapper |
| `PageInstance.layoutItem` | 实例作为父布局子项时的位置规则 | 父容器布局 |
| `PageInstance.containerLayout` | 实例作为容器时,它内部怎么排子组件 | Renderer / reference 容器 |
## layoutBox
```ts
interface LayoutBox {
x: number
y: number
width: number
height: number
widthMode: 'px' | 'percent' | 'fill' | 'auto'
heightMode: 'px' | 'percent' | 'fill' | 'auto'
overflow?: 'hidden' | 'visible' | 'auto'
}
```
单位语义:
| mode | 意思 | 典型场景 |
| --- | --- | --- |
| `px` | 固定像素 | 画布上自由摆放的卡片 |
| `percent` | 相对父盒子的百分比 | 容器内宽 100% |
| `fill` | 撑满父可用空间 | 运行态卡片填满业务容器 |
| `auto` | 由内容决定 | 文本、标签、小按钮 |
## containerLayout
容器内部布局有几种模式:
| mode | 用法 |
| --- | --- |
| `free` | 容器内还能自由拖,子实例 `layoutBox.x/y` 相对父 outlet |
| `flex` | 类 CSS flex,适合横排/竖排/换行 |
| `grid` | 类 CSS grid,适合二维卡片网格 |
| `split` | 按比例切分区域,适合左右/上下拆分 |
| `free-split` | 容器内**任意分割树**(递归切左右/上下),每格一个组件;可视化切分 / 合并 / resize / 删除 / 移动交换(0.5.0+) |
> `free-split` 与其它模式有两点不同:**渲染 data-driven**(页面文档里有 free-split 就自动按树渲染,宿主零 opt-in;非 free-split 页零变化)、**编辑受 gate**(设计器要传 `:free-split-enabled="true"` 才能编辑)。详见 [自由分割布局(free-split)](/guide/free-split-layout)。
## 为什么不直接让组件自己写宽高
因为组件源码属于组件库,而页面里的实例属于页面文档。两者必须分层:
- 组件源码决定内部结构和默认样式。
- `layoutBox` 决定这个实例在页面里占多大、放哪儿、如何裁剪。
这样同一个组件能在多个页面、多个容器里复用。
## 画布和宿主容器
运行态推荐让宿主容器决定真实宽高:
```vue
```
页面文档里画布可以是 `fill`。设计态可以模拟宿主容器尺寸,运行态则由真实业务容器接管。
## 当前不做什么
- 不做完整 CSS Grid 可视化编辑器。
- 不做业务布局模板市场。
- 不自动推断组件内部 DOM 的语义。
- 不把 padding/margin 放进 SDK 盒模型;这类一般是组件 props 或组件 CSS。
## 进一步阅读
- [如何写一个布局组件](/guide/write-layout-component) — flex / split / grid / free 最小可用源码 + 自定义布局组件
- [自由分割布局(free-split)](/guide/free-split-layout) — 0.5.0 新增:递归分割树容器 + 宿主接入(渲染/编辑/后端/drop)
========================================================================
# PageDocument
URL: /concepts/page-document
------------------------------------------------------------------------
# PageDocument
`PageDocument` 是页面结构快照。它只存结构,不存组件源码字典。
## 基本形态
```ts
interface PageDocument {
schemaVersion?: '1'
layout: CanvasLayoutConfig
instances: PageInstance[]
bindings: PageBinding[]
componentOverrides?: Record
}
```
## instances
`instances` 是页面上的组件实例列表。
每个实例至少需要:
- `instanceId`
- `componentId`
- `componentVersionKey`
- `layoutBox`
嵌套容器场景还会用:
- `parentId`
- `slot`
- `placement`
- `layoutItem`
- `containerLayout`
## componentVersions 不在 PageDocument 内
组件源码由运行 payload 单独传:
```ts
interface PageRenderPayload {
document: PageDocument
componentVersions: Record
}
```
这样做是为了避免 50 个实例重复携带同一份组件源码。
## componentOverrides
页面级实例代码覆盖按 `instanceId` 存。
它解决的问题是:只改当前页面里某一个组件实例,不影响组件库正式版本,也不影响其它页面。
```ts
componentOverrides: {
timer_1: {
instanceId: 'timer_1',
baseVersionKey: 'timer@v3',
source,
contract,
},
}
```
如果实例已经升级到 `timer@v4`,但 override 仍基于 `timer@v3`,它就是 stale override。运行时不消费 stale override,宿主应该提示用户处理。
========================================================================
# 为 AI 助手提供 SDK 上下文(llms.txt + Skill)
URL: /guide/ai-skill
------------------------------------------------------------------------
# 为 AI 助手提供 SDK 上下文(llms.txt + Skill)
本页提供两份可直接下载的资源,便于在 AI 编程助手中快速获取本 SDK 的写法约定,减少重复查阅文档。
## llms.txt — 通用 LLM 上下文
与文档同源,随每次构建自动更新。
下载 llms.txt(索引) · 下载 llms-full.txt(全文)
**用法**:将 `llms-full.txt` 作为上下文或参考资料提供给 Cursor、Claude 等 AI 助手,即可获得 SDK 的完整可写规则;`llms.txt` 为索引版,便于助手按需查阅具体章节。
## Skill — 组件编写专用
聚焦组件源码中的事件联动、数据灌入与保留 prop 等写法。
下载 SKILL.md
使用方式:
1. **Claude Code**:下载后保存为 `~/.claude/skills/hypercard-component-authoring/SKILL.md`,在相关项目中会自动加载。
2. **其他 AI 助手**:将下方内容作为系统提示或上下文提供。
3. **直接复制**:下方代码块右上角提供复制按钮。
<<< ../.vitepress/public/skills/hypercard-component-authoring.md{md}
::: tip 内容同步
Skill 与 llms.txt 均与文档同源。文档更新并重新部署后,两份资源自动同步,使用者获取到的始终是最新约定。
:::
========================================================================
# 组件间事件(广播 / 接收)
URL: /guide/component-events
------------------------------------------------------------------------
# 组件间事件(广播 / 接收)
低代码画布上经常要做**联动**:点一下「级联切换」组件,旁边的折线图、柱状图、KPI 卡同时换数据。这页讲怎么**在组件源码里**把一个组件的「广播」和另一个组件的「接收」写出来。
## 心智模型:信箱,不是全站喇叭
SDK 的事件不是一个「全局 event bus」。每个组件实例有一个**私有信箱**(基于 [mitt](https://github.com/developit/mitt) 的 emitter,挂在它的 `ComponentInstanceHandle` 上)。三个动作:
| API | 含义 |
| --- | --- |
| `runtime.on(X, event, fn)` | **订** 实例 `X` 的信箱:有人往 X 投 `event` 时,`fn` 触发 |
| `runtime.emit(X, event, payload)` | **投** 一封信到实例 `X` 的信箱(只有 X 收到) |
| `canvas.broadcastInCanvas(event, payload)` | **群投**:往**本画布每一个**实例的信箱都投一封 |
由此推出唯一要记的规则:
> **接收方永远订自己的信箱**(`on(this.hcInstanceId, …)`)。
> 广播方用 `broadcastInCanvas` 往所有信箱投信,接收方的信箱也被投了,于是它收到。
> 如果广播方**知道**该发给谁,就用 `emit(目标id, …)` 定向投递。
::: tip 为什么不是全局总线
私有信箱让「一个画布内组件出错不影响其它画布」「同名实例跨画布互不干扰」成为可能。代价是广播需显式走 `broadcastInCanvas` 群投,而非单一全局 `bus.emit`。对多画布业务而言,这是必要的隔离。
:::
## 组件能读到的「身份」
SDK 给每个组件实例注入 4 个**保留 prop**(Options API 里直接 `this.xxx` 读,**别用这些名字声明自己的 prop**):
| Prop | 用途 |
| --- | --- |
| `this.hcInstanceId` | 自己的实例 ID —— 订自己信箱 / 定向被发都用它 |
| `this.hcCanvasId` | 自己所在画布 ID —— 群投限定本画布用它 |
| `this.hcComponentId` | 组件源 ID(同款多实例共享) |
| `this.hcCustomValues` | 属性面板填的 override 值 |
`__HYPERCARD__` 全局对象上拿运行时:`window.__HYPERCARD__.runtime`(单实例 emit/on)、`window.__HYPERCARD__.canvases`(画布级群投 / 跨画布)。组件代码里 `__HYPERCARD__` 直接可用,无需 import。
---
## 案例 1:一对多联动(最常用)
「级联切换」广播维度+粒度,任意数量的图表接收后各自取数。**广播方不需要知道谁在听**。
### 广播方 — 级联切换
```js
export default {
data() {
return { dim: 'time', grain: 'day' }
},
methods: {
setDim(v) { this.dim = v; this.broadcast() },
setGrain(v) { this.grain = v; this.broadcast() },
broadcast() {
// 往本画布所有实例的信箱投一封 'cascade:change'
window.__HYPERCARD__
?.canvases.get(this.hcCanvasId)
?.broadcastInCanvas('cascade:change', { dim: this.dim, grain: this.grain })
},
},
template: `
`,
}
```
### 接收方 — 折线图(柱状图 / KPI 卡同理)
```js
export default {
data() {
return { _off: null, dim: 'time', grain: 'day' }
},
mounted() {
// 订「自己的信箱」。0.4.0 起 register 已在 created 完成,mounted 里立即可订。
this._off = window.__HYPERCARD__
?.runtime.on(this.hcInstanceId, 'cascade:change', (payload) => {
this.dim = payload.dim
this.grain = payload.grain
this.reload()
})
},
beforeUnmount() {
// ★ 必须退订,否则组件换源 / 卸载会泄漏 handler
this._off && this._off()
},
methods: {
async reload() {
// 拿 dim / grain 去取数 + 重画 echarts
},
},
}
```
::: warning 广播方自己也会收到
`broadcastInCanvas` 不排除发起者本身——它也会收到自己发的 `cascade:change`。一般无所谓(按事件名/业务忽略即可);要排除就在 payload 里带 `from: this.hcInstanceId`,接收方判一下。
:::
---
## 案例 2:点对点定向(已知目标 ID)
广播方明确知道要发给哪个实例时,跳过群投,直接 `emit(目标id, …)` —— 全画布只有那个实例收到。
```js
// 发起方:把 'highlight' 只发给 instanceId = 'chart_main' 的实例
window.__HYPERCARD__?.runtime.emit('chart_main', 'highlight', { seriesId: 7 })
```
```js
// chart_main 这个实例:订自己的信箱
mounted() {
this._off = window.__HYPERCARD__
?.runtime.on(this.hcInstanceId, 'highlight', (p) => this.highlight(p.seriesId))
}
```
::: tip 低代码场景慎用写死 ID
拖拽生成的实例 ID 是运行时分配的(形如 `LineChart_169…_3`),写死在源码里很脆。**联动优先用案例 1 的命名广播**;定向 `emit` 更适合「容器/父组件明确管着某个固定 ID 子件」这类场景。
:::
多画布场景定向投递,用显式带 `canvasId` 的版本,避免同名实例串台:
```js
window.__HYPERCARD__?.canvases.emitToCanvas('card-a', 'chart_main', 'highlight', { seriesId: 7 })
```
---
## 案例 3:把事件冒泡给「宿主」
组件想对外暴露一个事件(「我被点了 / 我的值变了」),让**宿主页面**(不是另一个画布组件)去监听——比如宿主要据此弹窗、改路由、写日志。
组件**往自己的信箱投信**:
```js
methods: {
onPick(row) {
window.__HYPERCARD__?.runtime.emit(this.hcInstanceId, 'rowClick', row)
},
}
```
宿主侧(你的 Vue 页面,拿到 `CanvasHandle` 或用全局 runtime)**订这个实例的信箱**:
```ts
// 宿主代码,不是组件源码
const off = window.__HYPERCARD__?.runtime.on('table_orders', 'rowClick', (row) => {
router.push(`/order/${(row as any).id}`)
})
// 页面卸载时 off?.()
```
::: tip 让属性面板/devtools 认识你的事件
组件源码里**用字面量**调 `runtime.emit('rowClick', …)` / `__hc.emit('rowClick')` 时,后端 `parseComponentSource` 会把 `'rowClick'` 扫进组件契约的 `emitsDecl`,devtools 和属性面板就能列出「这个组件会发哪些事件」。**动态事件名**(`emit(this.evt)`)扫不到,需要手填声明。所以事件名尽量写字面量。
:::
---
## 案例 4:跨画布广播
一个 dashboard 同时挂多个 ``(多画布),要全站联动(换主题、全局刷新):
```js
// 往「所有画布的所有实例」投信
window.__HYPERCARD__?.canvases.broadcast('theme:change', { theme: 'dark' })
```
```js
// 任意画布里的任意组件,订自己的信箱即可
mounted() {
this._off = window.__HYPERCARD__
?.runtime.on(this.hcInstanceId, 'theme:change', (p) => this.applyTheme(p.theme))
}
```
三层范围,按需选最小的:
| 范围 | API | 谁收到 |
| --- | --- | --- |
| 单实例 | `runtime.emit(id, e, p)` / `canvases.emitToCanvas(cid, id, e, p)` | 只有目标实例 |
| 本画布 | `canvases.get(cid).broadcastInCanvas(e, p)` | 该画布全部实例 |
| 全部画布 | `canvases.broadcast(e, p)` | 所有画布全部实例 |
---
## 案例 5:联动 + 取数(广播触发重拉数据)
接收方收到广播后,既可以自己 `fetch`,也可以走 SDK 的**数据灌入**通道把结果塞进 `dataInput` 字段(组件 `custom..dataInput = true` 声明的字段)。后者让取数逻辑可被属性面板/绑定可视化接管:
```js
export default {
// 声明一个接收数据的字段
custom: {
rows: { value: [], dataInput: true },
},
data() { return { _off: null } },
mounted() {
this._off = window.__HYPERCARD__
?.runtime.on(this.hcInstanceId, 'cascade:change', async ({ dim, grain }) => {
const rows = await fetchSeries(dim, grain) // 你的取数
// 走 setDataInput 通道:等价于 this.custom.rows.value = rows,但会被诊断/绑定记录
const h = window.__HYPERCARD__?.runtime.getInstance(this.hcInstanceId)
h?.setDataInput('rows', rows)
})
},
beforeUnmount() { this._off && this._off() },
template: ``,
}
```
数据绑定/可视化连线的完整玩法见 [属性面板暴露机制](./property-panel-binding)。
---
## 生命周期 & 退订(必看)
- **订阅时机**:0.4.0 起组件在 `created`(早于 `mounted`)就 register,所以 `mounted` 里 `runtime.on(this.hcInstanceId, …)` 一定能拿到自己的 handle,**不用** `nextTick` 等微任务。
- **务必退订**:`on(...)` 返回一个 `off` 函数。存起来,在 `beforeUnmount` 调用。漏掉会在组件换源 / 卸载 / HMR 时泄漏 handler,出现「换了组件代码,旧回调还在跑」。
- **实例销毁自动清信箱**:实例 `unregister` / 画布 `dispose` 时,SDK 会清空该实例信箱的所有 listener。但**你订的那一份**最好还是自己 `off`,别依赖兜底。
::: danger instance:ready 回调里别碰 DOM
`runtime.on(this.hcInstanceId, …)` 是订**业务事件**,没问题。但如果你订的是生命周期信号 `instance:ready`(`onInstanceLifecycle` / `handle.on('instance:ready')`),0.4.0 起回调触发时**子组件 DOM 还没渲染**,`vm.$el` 是 `null`。要访问 DOM 包一层 `nextTick(() => vm.$el …)`。详见 [core API](../api/core)。
:::
## 设计期能不能跑?
能。设计器(``)里组件也是经 `RuntimeBox` 真实编译挂载的,信箱机制照常工作。所以**预览模式和发布页行为一致**——你在设计器里点「级联切换」,折线图会真的联动。这也意味着设计期的联动副作用(取数请求)会真的发出去,调试时注意。
## 排查清单
| 现象 | 多半是 |
| --- | --- |
| 接收方没反应 | 接收方订的是**别人的 id** 而不是 `this.hcInstanceId`;或广播方用了 `emit(单id)` 而没用 `broadcastInCanvas` |
| 偶发不触发 / 控制台 `instance not found` warn | 广播方在接收方 `mounted` 之前就发了(时序);或目标 `instanceId` 写错 |
| 换了组件代码旧逻辑还在跑 | 漏了 `beforeUnmount` 里 `off()` |
| 多画布串台,别的卡片也被联动 | 该用 `broadcastInCanvas`(本画布)却用了 `canvases.broadcast`(全站);或定向 `emit` 没带 `canvasId` |
| devtools/属性面板里组件不列事件 | 事件名不是字面量(`emit(this.evt)`),`emitsDecl` 扫不到,需手填 |
## API 速查
```js
const { runtime, canvases } = window.__HYPERCARD__
// —— 单实例信箱 ——
const off = runtime.on(id, event, fn) // 订;返回退订函数
runtime.emit(id, event, payload) // 定向投递
runtime.call(id, method, ...args) // 直接调实例的方法(不是事件)
runtime.getInstance(id) // 拿 handle(setDataInput / setProp 等)
// —— 画布级 ——
const cv = canvases.get(canvasId)
cv.broadcastInCanvas(event, payload) // 本画布群投
cv.listInstances({ componentId }) // 枚举本画布实例
// —— 跨画布 ——
canvases.broadcast(event, payload) // 全站群投
canvases.emitToCanvas(cid, id, event, payload) // 显式 scope 定向
canvases.callComponent(cid, id, method, ...args) // 显式 scope 调方法
```
相关:[多画布渲染](../recipes/multi-canvas) · [core API](../api/core) · [属性面板暴露机制](./property-panel-binding)
========================================================================
# 数据绑定运行时
URL: /guide/data-binding
------------------------------------------------------------------------
# 数据绑定运行时
> 把「数据源(取数)」接到「组件实例的数据字段」,运行时由 SDK 负责 wiring。本页讲**数据怎么在运行时流转**、宿主**怎么提供 `DataAdapter`**。属性面板怎么暴露 binding 字段、用户怎么在 UI 里配 binding,见 [属性面板暴露机制](/guide/property-panel-binding)。
---
## 1. 概念
一条 binding 描述「某个数据源的结果,送进某个组件实例的某个字段」。它由三部分组成:
- **source**:数据从哪来。取数类 binding 用 `{ kind: 'dataSource', sourceId }`。
- **target**:数据送到哪去。灌字段用 `{ kind: 'instanceDataInput', instanceId, key }`。
- **mapping / schedule / errorPolicy**:可选,描述「怎么转一道」「节流防抖」「出错怎么办」。
SDK **不 fetch、不知道你的后端**。它只在合适的时机调用宿主提供的 `DataAdapter.query(...)` 拿数据,再通过 `setDataInput(key, value)` 把结果写进组件声明为 `dataInput: true` 的字段。整条链路是:
```
binding(source/target) + 宿主 DataAdapter
↓ wirePageBindings 在运行期 wire
SDK 调 adapter.query / adapter.subscribe 取数
↓ 命中 mapping
inst.setDataInput(key, value)
↓ Vue 响应式
组件 custom..value 更新 → 组件 $watch / 模板重渲
```
宿主只负责两件事:**提供 `DataAdapter`**(把 `sourceId` 解析到真实后端),以及**在合适的生命周期调用 `wirePageBindings`**。其余 cache、双向索引、节流、错误策略全在 SDK 内部。
---
## 2. `wirePageBindings(options)`
`wirePageBindings` 是运行时数据绑定的主入口。它返回一个 handle,宿主用它切换模式、订阅事件、最后 dispose。
### options
```ts
interface WirePageBindingsOptions {
/** 当前 canvas id;宿主在外算好传进来 */
canvasId: string
/** 文档来源;wirer 靠它读 PageDocument + 感知文档变化 */
source: DocumentSource
/**
* 建议初始模式 —— 仅作 host 可读建议值,wirer 工厂**不**消费。
* 必须在 attach 后显式调 handle.setMode(initialMode)。
*/
initialMode?: CanvasBindingMode
/**
* 数据层 store;传了 dataSource binding 才真取数。
* 不传 → dataSource binding 在 wire 时 emit 'no-data-adapter' 并跳过。
*/
dataStore?: DataSourceStore
}
interface DocumentSource {
/** 当前文档快照(可能 null);wirer 内部需要时再调 */
getDocument(): PageDocument | null
/** Designer / Context 路径:每条 action apply 完之后回调;返 unsubscribe */
onAction?(cb: (action: unknown) => void): () => void
/** 独立 Renderer 路径:文档整体换引用时回调;返 unsubscribe */
onDocumentChange?(cb: () => void): () => void
}
```
`source` 两路回调至少接通一个,否则 wirer 感知不到文档变化。Designer / Context 场景接 `onAction`,独立 Renderer 场景接 `onDocumentChange`。
### 返回的 handle
```ts
interface WirePageBindingsHandle {
setMode(mode: CanvasBindingMode): void
getMode(): CanvasBindingMode
/** 干跑一条 binding(不真调 target、不进 scheduler),返回每个阶段的 trace */
testBinding(bindingId: string, mockPayload?: unknown): BindingTestTrace[]
on(
event: E,
cb: BindingEventCb,
): () => void
/** trace ringbuffer 模式 */
startTracing(opts?: TraceCollectorOptions): void
stopTracing(): BindingTraceEvent[]
getTraceBuffer(): readonly BindingTraceEvent[]
/** 显式 dispose —— 调用方自己管,wirer 内部不挂自动销毁 */
dispose(): void
}
```
`on(...)` 返回退订函数,三类事件:
```ts
interface BindingFireEvent {
bindingId: string
source: BindingSource
target: BindingTarget
payload: unknown // source 原始 payload
args: unknown[] // mapping 之后、送进 target 的实参
}
interface BindingErrorEvent {
bindingId: string
code: BindingErrorCode
message?: string
detail?: unknown
}
```
`BindingErrorCode` 取值:`'target-not-mounted'` / `'unsupported-cross-canvas'` / `'unknown-target-data-input'` / `'no-data-adapter'` / `'data-query-failed'` / `'cycle-detected'` / `'invoke-failed'`。
`'binding:trace'` 推送的是 `BindingTraceEvent`,覆盖 wire / fire / schedule / 取数全生命周期,用于 devtools / 调试。完整 trace 字段见 [Canvas Types](/api/canvas-types)。
### 生命周期
工厂返回时模式为 `'off'`,**不会自动取数**。挂载顺序:
1. 调 `wirePageBindings(options)` 拿 handle;
2. 把 handle 接进宿主(Designer 走 delegate,Renderer 直接持有);
3. 等订阅挂好后,显式 `handle.setMode(initialMode)` 才开始 wire / 取数;
4. 页面销毁时(`onBeforeUnmount` / Designer dispose)调 `handle.dispose()`。
`dispose()` 是幂等的:它会按顺序 fire `pageBeforeUnmount`、解绑所有 binding、停掉文档订阅、清理事件。注意 `wirePageBindings` **不会**替你释放 `dataStore` —— store 的 owner 自己调 `dataStore.disposeAll()`。
### 最小可用示例
```ts
import {
wirePageBindings,
createDataSourceStore,
bindingModeForCanvasMode,
} from '@hy-bricks/canvas'
import { nextTick, onBeforeUnmount } from 'vue'
// 1. 宿主实现 DataAdapter(见第 3 节)
const adapter = {
query: (input) => fetch(`/api/data/${input.sourceId}`).then((r) => r.json()),
}
// 2. 建数据 store
const dataStore = createDataSourceStore(adapter)
// 3. wire
const handle = wirePageBindings({
canvasId: 'main',
source: {
getDocument: () => currentDocument.value,
onDocumentChange: (cb) => watchDocumentRef(cb), // 文档换引用时调 cb
},
dataStore,
})
// 4. 延后一拍 setMode,让订阅先挂上;preview / runtime 才取数
handle.on('binding:error', (e) => console.warn('[binding]', e.code, e.message))
nextTick(() => handle.setMode(bindingModeForCanvasMode('preview')))
// 5. 收尾
onBeforeUnmount(() => {
handle.dispose()
dataStore.disposeAll() // store 的 owner 负责
})
```
---
## 3. `DataAdapter` 协议
`DataAdapter` 是宿主实现的接口。SDK 控制调用时机,宿主内部用 REST / GraphQL / WebSocket 任意方式拿数据。
```ts
interface DataAdapter {
/** 查询数据源;可同步返值,也可返 Promise */
query(input: DataQueryInput): unknown | Promise
/** 可选:监听数据源变化(WebSocket / SSE 推送);返 unsubscribe */
subscribe?(
input: DataSubscribeInput,
cb: (value: unknown) => void,
): () => void
}
interface DataQueryInput {
sourceId: string
params?: Record // 预留;当前版本传 undefined
signal?: AbortSignal // SDK 在 dispose / 重发时主动取消
}
interface DataSubscribeInput {
sourceId: string
params?: Record
}
```
要点:
- `sourceId` 是宿主自己配在 `binding.source` 上的标识,由宿主内部解析到具体后端。SDK 不解析它的格式。
- `query` 返回的值会原样(经 mapping 后)送进 `setDataInput`;SDK 内部用 `await Promise.resolve(result)` 兜底,所以同步返值或返 Promise 都行。
- `query` 抛错 / Promise reject → SDK 走 `error` 状态并按 binding 的 `errorPolicy` 处理,同时 emit `'data-query-failed'`。
- `signal` 是 `AbortSignal`,SDK 在重发或销毁时 abort;adapter 实现可选地 honor 它。
- `subscribe` 不实现时,SDK 只走 `query` 单次拉;binding 标了 `watch: 'change'` 而 adapter 实现了 `subscribe` 时,SDK 用 `subscribe` 持续监听并兜底拉一次保鲜。
### 实现示例
```ts
const adapter: DataAdapter = {
// 同步 cache 命中可直接返,异步走 Promise
async query({ sourceId, params, signal }) {
const res = await fetch(`/api/ds/${sourceId}`, {
method: 'POST',
body: JSON.stringify(params ?? {}),
signal, // 透传,SDK abort 时自动取消请求
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
},
// 可选:WebSocket 推送
subscribe({ sourceId }, cb) {
const ws = openSocket(sourceId)
ws.onmessage = (e) => cb(JSON.parse(e.data))
return () => ws.close() // SDK 在 unwire / dispose 时调
},
}
```
把 adapter 交给 `createDataSourceStore(adapter)`,再把 store 传给 `wirePageBindings({ dataStore })` 即可。多条 binding 引用同一 `sourceId`(+ 同 params)时,SDK 按 cache key 复用同一次 query 结果,不会重复打后端。
---
## 4. 数据如何到达组件
dataSource binding 的 target 是 `{ kind: 'instanceDataInput', instanceId, key }`。SDK 取到数据后,对目标实例调 `setDataInput(key, value)`。这要求组件源码里把该字段声明为 `dataInput: true`:
```js
export default {
custom: {
chartData: { value: [], dataInput: true }, // ★ 标记可被绑定灌入
},
template: ``,
}
```
SDK 有一道守门:**只有声明了 `dataInput: true` 的 custom 字段才能被 `setDataInput` 写入**。若 `target.key` 不存在或没标 `dataInput: true`,SDK emit `'unknown-target-data-input'` 并跳过,不会绕过协议直接写普通字段。
写入后走 Vue 响应式,组件的 `$watch` / 模板自动更新。如果你想在组件里手动取数后走同一通道(而不是配 binding),也可以在组件内自己调 `setDataInput`,详见 [组件间事件 · 案例 5](/guide/component-events)。
---
## 5. binding 字段结构速查
一条规范化 binding(`NormalizedPageBinding`)的核心字段:
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `id` | `string` | binding 唯一 id |
| `label` | `string?` | 宿主 UI 显示名 |
| `disabled` | `boolean?` | 禁用则不 wire |
| `source` | `BindingSource` | 数据 / 事件来源 |
| `target` | `BindingTarget` | 送到哪 |
| `mapping` | `BindingMapping?` | 缺省 `passthrough` |
| `schedule` | `BindingSchedule?` | 缺省无节流 |
| `errorPolicy` | `BindingErrorPolicy?` | 缺省 `log` |
**`BindingSource`**(取数用 `dataSource`;其余 kind 见 [组件间事件](/guide/component-events)):
| kind | 字段 | 含义 |
| --- | --- | --- |
| `dataSource` | `sourceId`、`watch?: 'change'` | 数据源取数;`watch: 'change'` 启用持续订阅 |
| `instanceEvent` | `instanceId`、`event` | 某实例发了某事件 |
| `lifecycle` | `instanceId`、`hook: 'mounted' \| 'unmounted'` | 实例挂载 / 卸载 |
| `page` | `hook: 'pageInit' \| 'pageBeforeUnmount'` | 页面级生命周期 |
**`BindingTarget`**:
| kind | 字段 | 含义 |
| --- | --- | --- |
| `instanceDataInput` | `instanceId`、`key` | 灌进 `custom.`(取数主用) |
| `instanceMethod` | `instanceId`、`method` | 调实例方法 |
| `instanceEmit` | `instanceId`、`event` | 往实例发事件 |
**`BindingMapping`** —— 数据进 target 前转一道:
| kind | 字段 | 含义 |
| --- | --- | --- |
| `passthrough` | — | 原样透传(缺省) |
| `static` | `args: unknown[]` | 忽略 payload,固定实参 |
| `pickPath` | `paths: { from, to? }[]` | 按路径取字段重组 |
**`BindingSchedule`** —— 三字段可任意组合:
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `once` | `boolean?` | 只触发一次 |
| `throttle` | `{ windowMs, leading?, trailing? }?` | 节流窗口 |
| `debounce` | `{ windowMs, leading?, trailing? }?` | 防抖窗口 |
**`BindingErrorPolicy`**:
| 字段 | 取值 | 含义 |
| --- | --- | --- |
| `onError` | `'continue' \| 'stop' \| 'log'` | 出错后继续 / 停掉该 binding / 仅记录(缺省 `log`) |
完整类型定义见 [Canvas Types](/api/canvas-types)。
---
## 6. 校验与规范化
宿主写入 binding 前,可以用两个纯函数:
- `normalizePageBindings(...)` —— 把宽松输入(含老形态)规范化为 `NormalizedPageBinding`。
- `validateBindings(...)` —— 静态校验,返回 `BindingIssue[]`(如未知事件、未声明的 dataInput、孤儿 binding、跨画布不支持等),供宿主 UI 提示。
两者都不取数、不依赖运行时,适合在保存 / 加载文档时跑一遍。
---
## 7. 设计期 vs 运行期
binding 是否真触发由 `CanvasBindingMode` 控制,通过 `handle.setMode(...)` 切:
| mode | 行为 |
| --- | --- |
| `'off'` | 完全不 wire,宿主行为不变 |
| `'data-preview'` | 只 wire `dataSource` binding(取数预览),不接 event / lifecycle |
| `'runtime'` | 全量 wire:event + lifecycle + page + data |
会话级模式(`CanvasMode`:`design` / `preview` / `runtime` / `inspect`)到 binding 模式有一份默认映射,用 `bindingModeForCanvasMode(canvasMode)` 取:
```ts
import { bindingModeForCanvasMode } from '@hy-bricks/canvas'
bindingModeForCanvasMode('design') // → 'off'(设计期默认不取数,防误触)
bindingModeForCanvasMode('preview') // → 'runtime'
bindingModeForCanvasMode('runtime') // → 'runtime'
```
即:**设计期默认不取数**,预览 / 运行期才真打后端。宿主可以用 `setMode` 显式覆盖(比如在设计器里临时切到 `'data-preview'` 看真数据)。切模式会保留已取到的 cache,要强制重取走 `dataStore.invalidate(sourceId)`。
---
## 相关
- [属性面板暴露机制](/guide/property-panel-binding) —— binding 字段如何在属性面板里配
- [组件间事件](/guide/component-events) —— event / lifecycle binding 与 `setDataInput` 取数案例
- [Canvas Types](/api/canvas-types) —— 完整类型定义
- [CanvasHandle](/api/canvas-handle) —— mount-ready 信号 / `getCachedBySourceId` 等
========================================================================
# 设计器接入
URL: /guide/designer-integration
------------------------------------------------------------------------
# 设计器接入
设计器接入的核心是两条线:
1. 页面结构由 `v-model` 控制。
2. 所有修改尽量走 `CanvasHandle`。
## 最小接入
```vue
```
## 属性面板怎么做
SDK 不渲染属性面板。宿主自己拿选中实例和组件 contract 渲染表单。
推荐流程:
```ts
watch(
() => handle.value?.selectedInstances.value,
(instances) => {
primary.value = instances?.[0] ?? null
},
)
```
改布局:
```ts
handle.value?.updateLayoutBox(instanceId, {
widthMode: 'percent',
width: 100,
})
```
改 props:
```ts
handle.value?.dispatch({
type: 'updateInstance',
instanceId,
patch: {
props: nextProps,
},
})
```
## 组件库拖入
宿主负责组件库 UI。拖入画布时,宿主把组件信息转换成 `addInstance` action。
```ts
const point = handle.clientToCanvasPoint({
x: event.clientX,
y: event.clientY,
})
handle.dispatch({
type: 'addInstance',
instance: {
instanceId,
componentId,
componentVersionKey,
layoutBox: {
x: point.x,
y: point.y,
width: 320,
height: 180,
widthMode: 'px',
heightMode: 'px',
overflow: 'hidden',
},
},
})
```
## 右键菜单怎么做
SDK 提供命中能力和命令入口,宿主自己渲染菜单。
```ts
const hit = handle.getInstanceAtClientPoint({
x: event.clientX,
y: event.clientY,
})
if (hit) {
handle.dispatch({ type: 'select', ids: [hit.instanceId] })
openMenu(event.clientX, event.clientY)
}
```
菜单项建议统一调用:
```ts
handle.executeCommand('duplicateSelection')
handle.executeCommand('deleteSelection')
handle.executeCommand('bringToFront')
handle.executeCommand('lockSelection')
```
## 设计态点击组件内部
如果需要在设计期间点击组件内部按钮,建议分清模式:
- `design`:默认点选 / 拖拽 / resize。
- `preview`:允许组件内部交互。
- `inspect`:可选中,但不拖不改。
如果业务需要“设计态也能点内部控件”,应该通过工具模式或局部开关处理,不要让组件内部事件永久穿透选择层。
========================================================================
# 自由分割布局(free-split)
URL: /guide/free-split-layout
------------------------------------------------------------------------
# 自由分割布局(free-split)
`free-split` 是 0.5.0 新增的容器布局模式:把一个容器内部切成一棵**递归分割树**,每个格子(leaf)放一个组件。支持在画布上拖分隔条调比例、切分、合并、删除、移动/交换,以及把组件拖进格子。
> 一句话区分:`split` 是一层左右/上下按比例切;`free-split` 是**可任意嵌套**的分割树(左切完再上下切),每格一个组件、可视化增删改。
## 数据模型
```ts
interface FreeSplitContainerLayout {
mode: 'free-split'
root: SplitNode // 分割树根(可以是单个 leaf,也可以是 branch)
structureLocked?: boolean // 锁结构:只能填换内容,不能切/合/删
gap?: number // 格子间距 px,默认 0
}
type SplitNode = SplitBranch | SplitLeaf
interface SplitBranch { // 内部节点:沿 dir 把空间切成 children
type: 'split'
nodeId: string // 容器内唯一
dir: 'row' | 'col' // row=左右切,col=上下切
children: SplitNode[] // ≥2
sizes: NodeSize[] // 跟 children 一一对应
}
interface SplitLeaf { // 叶子 = 一格
type: 'leaf'
leafId: string // 容器内唯一
instanceId: string | null // 指向容器的一个子实例;null=空格
locked?: boolean // 该格结构锁
}
interface NodeSize {
mode: 'ratio' | 'px' // ratio=按权重分剩余;px=固定像素
value: number // ratio>0;px≥0
min?: number; max?: number
}
```
约束(SDK 与后端同口径校验):`leafId` / `nodeId` 容器内唯一(共用命名空间)、`instanceId` 一个实例只在一个叶、`NodeSize` 合法、树深 ≤ 8。非法树渲染时**降级平铺**(不崩)。
## 在工作台里用
选中一个容器 → 「容器内布局」面板 mode 选 **free-split**(空容器才可切;容器已有子组件时该项置灰,先清空)。切完是一个空格,然后:
| 操作 | 手势 |
| --- | --- |
| **切分** | 格子右上角 `⬌`(竖切=左右)/ `⬍`(横切=上下),或从格子边缘往里拖 |
| **调比例(resize)** | 拖两格之间的分隔条 |
| **合并** | 双击分隔条(相邻一侧为空格时),或 hover 分隔条出现的合并按钮 |
| **删除分区** | 格子右上角 `✕`(内容随之清除,相邻格吸收空间) |
| **移动 / 交换** | 拖格子右上角 `⠿` 手柄到另一格:目标空=移动过去,目标有内容=两格交换 |
| **填内容** | 从组件面板把组件拖进空格 |
每个操作都是**一次 undo**;锁结构(`structureLocked` / `leaf.locked`)时,切/合/删/move 入口自动隐藏,但填换内容仍可用。
## 宿主接入(重点)
free-split 的**渲染**和**编辑**是分开的两件事,接入时分别处理:
### 1. 渲染 —— data-driven,零 opt-in
只要页面文档里有合法的 free-split 容器,`HyperCardPageRenderer` 就会**自动按分割树渲染**,宿主**不需要任何开关**:
```vue
```
没有 free-split 数据的页面渲染**完全不变**(老宿主升级到 0.5.0 零行为变化)。
### 2. 编辑 —— 受 per-canvas gate 控
要让用户在你的设计器里**创建/编辑** free-split(出分隔条/热区 overlay、切分/合并/resize/拖填),给 `HyperCardCanvasDesigner` 传 `:free-split-enabled="true"`:
```vue
```
不传(或传 `false`)→ 仍然**渲染**已有 free-split,但不暴露编辑能力(等价只读)。
### 3. 后端 —— 存储校验必须放行 free-split
宿主自己的页面保存接口要让 `containerLayout.mode='free-split'` 通过,并校验分割树(与 SDK 同口径:id 唯一、NodeSize、深度 ≤ 8)。否则保存会被拒(常见表现:`/versions` 返回 400 `mode must be one of 'none'|'free'|...`)。宿主后端需实现等价的分割树 / NodeSize 校验(id 唯一、ratio/px 合法、深度 ≤ 8)。
> ⚠️ 这一步最容易漏:业务宿主用各自的后端,SDK 这边放行不代表你的后端放行。
### 4. drop 灌内容 —— 必须走 `handle.freeSplit.fill`
free-split 容器的子实例**不能用普通 `addInstance` 直插**(会被守门拒绝)。自定义 drop 流程时:命中坑用 `handle.getFreeSplitDropTarget(point)`,空叶用 `handle.freeSplit.fill` 填:
```ts
const target = handle.getFreeSplitDropTarget({ x, y })
if (target) {
// 只填空叶;满叶按需走「叶内容自身的嵌套 outlet」普通 slot-add,或忽略
handle.freeSplit.fill(target.containerId, target.leafId, newInstance)
}
```
## handle API
```ts
handle.freeSplit.fill(containerId, leafId, instance) // 填实例进空叶(ctx 纠正归属)
handle.freeSplit.clear(containerId, leafId) // 清空叶(级联删子树)
handle.freeSplit.swap(containerId, leafA, leafB) // 对调两叶(移动/交换)
handle.freeSplit.resize(containerId, nodeId, sizes) // 改某 branch 比例
handle.freeSplit.split(containerId, leafId, edge, share) // 切叶 → {leafId, nodeId}
handle.freeSplit.merge(containerId, leafId) // 合并(删空叶,相邻吸收)
handle.getFreeSplitDropTarget(point, root?) // DOM 命中坑 → {containerId, leafId, leafEl}
```
选中走并行通道(leaf/divider 不混进普通 `selectedIds`):`handle.selectFreeSplitLeaf` / `selectFreeSplitDivider` / `clearFreeSplitSelection` / `handle.freeSplitSelection`。
## 从 0.4.x 升级到 0.5.0
- **不用 free-split 的页面/宿主**:零改动,升依赖即可(渲染 data-driven 保证非 free-split 页零变化)。
- **要用 free-split**:① 设计器传 `:free-split-enabled="true"` ② 后端放行 free-split 校验。
- **BREAKING**:`HyperCardPageRenderer` 移除了 `freeSplitEnabled` prop(渲染改 data-driven,不再受其控)。如果你之前给 **Renderer** 传过这个 prop,删掉即可(渲染照常)。`HyperCardCanvasDesigner` 的 `freeSplitEnabled` prop **保留**(它控编辑)。
## 进一步阅读
- [LayoutBox 布局模型](/concepts/layout-system) — containerLayout 各模式总览
- [设计器接入](/guide/designer-integration)
========================================================================
# SDK 包边界
URL: /guide/package-boundary
------------------------------------------------------------------------
# SDK 包边界
HyperCard 现在按三个 npm 包拆分。最重要的原则是:SDK 负责“拿数据渲染和编辑”,宿主负责“业务数据、权限、接口和产品工作台”。
## 总览
| 包 | 负责 | 不负责 |
| --- | --- | --- |
| `@hy-bricks/core` | 运行时、libs 注入、组件编译辅助、多画布 registry | 组件库查询、权限、业务数据请求 |
| `@hy-bricks/editor` | 源码编辑、预览、受控 `v-model`、历史/Diff 入口事件 | fetch 历史版本、发布权限、后端 schema |
| `@hy-bricks/canvas` | 页面渲染、画布设计器、LayoutBox、选择/拖拽/resize、命令栈 | 组件列表 UI、属性面板 UI、业务编排 UI |
## core
`core` 是运行时底座。它的核心是 `createHyperCard()`。
宿主可以注入任意库:
```ts
app.use(createHyperCard({
libs: {
echarts,
http,
formatMoney,
},
}))
```
组件源码里只认 `__HYPERCARD__` 这个运行时上下文。
## editor
`editor` 是组件源码编辑器。它要保持薄:
- source 由宿主传入。
- 修改通过 `v-model` 回传。
- “看改动”“历史版本”“发布”只 emit 事件。
- 真正保存、比较、回滚、鉴权都由宿主做。
这条边界很重要。否则编辑器会被某个业务后端 schema 绑死。
## canvas
`canvas` 是页面渲染和设计核心。
它提供:
- `HyperCardPageRenderer`
- `HyperCardCanvasDesigner`
- `CanvasHandle`
- `LayoutBox`
- `containerLayout`
- 选择、拖拽、resize、命令栈、实例树、辅助线、网格、资源解析等能力
但它不内置:
- 组件库面板
- 属性面板
- 右键菜单 UI
- 页面库 UI
- AI 命令栏 UI
这些都由宿主项目自己组合。SDK 给 API 和规则。
## 宿主必须负责什么
- 用户能不能看见某份业务数据。
- 调哪个业务接口拿数据。
- 组件库怎么分组、搜索、上架。
- 页面版本怎么保存、审核、发布。
- 数据错误怎么提示。
- 业务主题、国际化、审计日志。
## 一句话
HyperCard SDK 是“画布引擎 + 组件运行时 + 源码编辑器”,不是完整业务后台。
========================================================================
# 属性面板暴露机制
URL: /guide/property-panel-binding
------------------------------------------------------------------------
# 属性面板暴露机制
> 组件如何把自己的字段暴露给设计器属性面板,让最终用户在面板里改值。
---
## 1. 5 个平台扩展字段
组件源码 JS 部分 = Vue Options + 5 个平台扩展字段:
| 字段 | 用途 |
|---|---|
| `name` | `{ value?: string; CN?: string }` — 中英文名 |
| `method` | 对外暴露方法(被 `runtime.call` 调) |
| `attribute` | 属性字典 `Record`(key → 说明)— **属性面板表单项源** |
| `custom` | 自定义字段(平台扩展,含 `dataInput: true` 数据绑定目标) |
| `style` | 样式字典 |
源:`@hy-bricks/core/parseSource.ts`(acorn 静态解析 export default)
---
## 2. attribute vs custom
```js
// attribute — 简单字段,字典 { key: '说明' }
attribute: {
text: '按钮文本',
variant: '样式 primary/default',
disabled: '是否禁用',
}
// custom — 复杂字段,带元数据
custom: {
seriesData: {
label: '系列数据',
dataInput: true, // ★ 标记可被 binding 灌入
value: [], // 初始值
},
}
```
**简单字段用 attribute,复杂字段(需绑数据 / 自定义编辑器)用 custom**。
---
## 3. 普通组件范例:MyButton
JS:
```js
export default {
name: { value: 'MyButton', CN: '我的按钮' },
attribute: {
text: '按钮文本',
variant: '样式 primary/default/dashed',
disabled: '是否禁用',
},
data() {
return { text: '点击', variant: 'default', disabled: false }
},
methods: {
onClick() {
this.$emit('click')
},
},
}
```
HTML:
```html
```
宿主属性面板拿到 `ComponentMeta.attribute`:
```ts
const meta = parseComponentSource(jsSource).meta
console.log(meta.attribute)
// { text: '按钮文本', variant: '样式 primary/default/dashed', disabled: '是否禁用' }
```
按字段类型推断渲染表单(shadcn-vue Input / Switch / Select)。
---
## 4. 数据绑定字段(custom.dataInput)
```js
custom: {
chartData: {
label: '图表数据',
dataInput: true,
value: [],
},
}
```
用户在属性面板看到 `chartData(数据源)` 选项 → 选已注册 dataSource → 写 `PageDocument.bindings`:
```ts
{
id: 'b1',
source: { kind: 'dataSource', sourceId: 'sales-2024' },
target: { kind: 'instanceDataInput', instanceId: '...', key: 'chartData' },
}
```
运行时 SDK wire binding → fetch → `setDataInput('chartData', data)` → 组件 `$watch` 触发。
---
## 5. 完整数据流
```
组件源码 (JS)
↓ parseComponentSource
ComponentMeta { attribute, customDecl, ... }
↓ host 拼属性面板
属性面板渲染表单(Input / Select / 数据源绑定按钮 / ...)
↓ 用户改值
PageInstance.props[] = newValue
↓ Vue reactivity
组件实例字段更新
↓ 组件 mounted / $watch 监听
触发组件内 setOption / refresh / ...
```
---
## 6. 属性面板 UI 在哪
**SDK 不提供完整属性面板 UI**,只给契约(`ComponentMeta`)+ binding 协议。
属性面板渲染完全在**宿主侧**,由各宿主应用自行实现。
UI 库建议:组件设计器 + 在线 IDE 一律用 shadcn-vue + Tailwind,保持视觉一致。
---
## 7. 自定义编辑器(复杂场景)
简单字段用默认表单组件;复杂字段(echarts series / 颜色 / 富文本)需自定义 editor:
```js
custom: {
options: {
label: 'ECharts Option',
type: 'json-object',
editor: 'echarts-option', // host 约定:用哪个 editor 组件
value: {},
},
}
```
`custom` 是 `Record` 完全开放,host 自己约定额外元数据(`editor` / `schema` / `placeholder` ...)。
当前平台**没有官方** `attribute.editor` 协议,host 自己约定。
---
## 8. 相关
- [`/recipes/echarts`](/recipes/echarts) — echarts 集成示例
- [`/api/canvas-types`](/api/canvas-types) — `PageBinding` / `BindingSource` / `BindingTarget` 字段表
========================================================================
# 快速开始
URL: /guide/quick-start
------------------------------------------------------------------------
# 快速开始
这一页只讲“宿主项目怎么把 HyperCard SDK 跑起来”。业务后台、权限、组件库管理都不是 SDK 自己做。
## 安装
```bash
pnpm add @hy-bricks/core @hy-bricks/canvas @hy-bricks/editor @hy-bricks/devtools
```
真实接入分三步走:注入 / 渲染 / 数据对接,下面逐步说明。
## 样式怎么生效
SDK 的 SFC `