# 扣盯小子 — 全文文档(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 `