布局投影
布局系统把 root.children 投影到内容流、页面 chrome 和浮层,并保证每个 root 节点只渲染一次。它表达页面意图,不替业务物料定义 flex 或 grid 几何。
先看三种结果
贯穿项目同时注册了普通公告、固定页头和浮动操作。浮动操作的完整定义如下:
import type { DesignerWidgetMeta, WidgetDefinition } from '@dragcraft/designer'
import { defineComponent, h } from 'vue'
export const FloatingActionWidget = defineComponent({
name: 'GuideFloatingActionWidget',
props: {
label: { type: String, default: '咨询' },
},
setup(props) {
return () => h('button', {
type: 'button',
class: 'guide-floating-action',
}, props.label)
},
})
export const floatingActionWidgetDefinition: WidgetDefinition<DesignerWidgetMeta> = {
meta: {
type: 'floating-action',
title: '浮动操作',
group: 'marketing',
defaultProps: { label: '咨询' },
defaultLayout: {
placement: {
kind: 'layer',
mode: 'framework',
anchor: { block: 'end', inline: 'end' },
offset: { blockEnd: 16, inlineEnd: 16 },
},
},
formSchema: {
sections: [{
title: '浮动操作',
fields: [{
key: 'label',
label: '按钮文字',
component: 'Input',
rules: [{ required: true, message: '按钮文字不能为空' }],
}],
}],
},
material: {
description: '固定在页面内容上方的操作入口',
tags: ['营销', '浮层'],
},
},
component: FloatingActionWidget,
}defaultLayout 让新建的浮动操作默认进入 layer。初始 Schema 仍显式保存实例 layout,使页面数据不依赖创建过程。
| placement | 默认 surface | 排序行为 |
|---|---|---|
flow | content region | 默认进入 content sort scope |
chrome | 指定页面边缘 | 默认不参与正文排序 |
layer | float layer | 默认不参与正文排序 |
未声明 layout 的节点按 flow/content 处理。非 content flow region 默认不参与排序,除非显式声明自己的 sortScope。
固定页面 chrome
固定页头通过 reserve 告诉画布内容应避让多少空间:
layout: {
placement: {
kind: 'chrome',
edge: 'block-start',
position: 'fixed',
reserve: { mode: 'size', size: 48 },
avoidContent: true,
},
}position 可以是 fixed、sticky 或 flow。reserve.mode 的选择标准如下:
- 尺寸固定且跨端一致时使用
size。 - Web 设计态需要读取实际 DOM 尺寸时使用
measure,并可提供首帧 fallback size。 - chrome 允许覆盖正文时使用
none或avoidContent: false。
Device Frame 的系统状态栏与 Schema chrome 是两层结构。前者包围整个 Canvas Surface,后者属于业务页面。
定位浮层
framework mode 适合按 anchor 和 offset 定位的按钮、角标和提示。self mode 给物料完整 layer 坐标系,适合复杂吸附或多个浮层协同。
设计态和示例运行时都会把安全区与 chrome inset 保留给 layer。业务组件不能通过提高工作台 z-index 把自己移出页面坐标系。
控制顺序和可见性
layout.order 改变同一 surface 中的视觉顺序,不改变节点所有权。visible 可以是布尔值或读取只读 Schema 的 predicate。
设计态仍会绘制不可见节点的半透明轮廓,方便选中和恢复;生产运行时应跳过该节点。predicate 必须是确定性的,不能在读取过程中修改 Schema 或发出网络请求。
在生产运行时重建投影
Vue 参考运行时独立解析注册表默认值、实例覆盖、可见性、顺序和固定 inset:
import type { DesignerSchema, SchemaNode } from '@dragcraft/designer'
import type { RuntimeNodeLayout, RuntimeRegistry } from './registry'
export type RuntimeLayoutEdge = 'block-start' | 'block-end' | 'inline-start' | 'inline-end'
export type RuntimePlacement
= | { kind: 'flow', region: string }
| {
kind: 'chrome'
edge: RuntimeLayoutEdge
position: 'fixed' | 'sticky' | 'flow'
reserve: { mode: 'measure' | 'size' | 'none', size?: string | number }
avoidContent: boolean
}
| {
kind: 'layer'
layer: string
mode: 'framework' | 'self'
anchor: { block: 'start' | 'center' | 'end', inline: 'start' | 'center' | 'end' }
offset?: {
blockStart?: string | number
blockEnd?: string | number
inlineStart?: string | number
inlineEnd?: string | number
}
}
export interface RuntimeLayoutEntry {
node: SchemaNode
arrayIndex: number
order: number
visible: boolean
placement: RuntimePlacement
}
export interface RuntimeLayoutPlan {
flow: Map<string, RuntimeLayoutEntry[]>
chrome: RuntimeLayoutEntry[]
layers: Map<string, RuntimeLayoutEntry[]>
insets: Record<RuntimeLayoutEdge, string>
}
const edges: RuntimeLayoutEdge[] = ['block-start', 'block-end', 'inline-start', 'inline-end']
function resolvePlacement(layout: RuntimeNodeLayout): RuntimePlacement {
const placement = layout.placement
if (!placement || placement.kind === 'flow') {
return {
kind: 'flow',
region: placement?.region ?? 'content',
}
}
if (placement.kind === 'chrome') {
return {
kind: 'chrome',
edge: placement.edge,
position: placement.position ?? 'fixed',
reserve: {
mode: placement.reserve?.mode ?? 'measure',
size: placement.reserve?.size,
},
avoidContent: placement.avoidContent ?? true,
}
}
const anchor = placement.anchor ?? { block: 'end', inline: 'end' }
return {
kind: 'layer',
layer: placement.layer ?? 'float',
mode: placement.mode ?? (placement.anchor ? 'framework' : 'self'),
anchor: {
block: anchor.block ?? 'end',
inline: anchor.inline ?? 'end',
},
offset: placement.offset,
}
}
function resolveEntry(
node: SchemaNode,
arrayIndex: number,
schema: DesignerSchema,
registry: RuntimeRegistry,
): RuntimeLayoutEntry {
const layout: RuntimeNodeLayout = {
...(registry[node.type]?.defaultLayout ?? {}),
...(node.layout ?? {}),
}
const rawVisible = layout.visible ?? true
const visible = typeof rawVisible === 'function'
? rawVisible({ node, schema })
: rawVisible
return {
node,
arrayIndex,
order: layout.order ?? arrayIndex,
visible,
placement: resolvePlacement(layout),
}
}
function pushEntry(
target: Map<string, RuntimeLayoutEntry[]>,
key: string,
entry: RuntimeLayoutEntry,
): void {
const current = target.get(key)
if (current)
current.push(entry)
else
target.set(key, [entry])
}
function toCssLength(value: string | number | undefined): string | null {
if (typeof value === 'number')
return `${value}px`
return value ?? null
}
function createInsets(chrome: RuntimeLayoutEntry[]): Record<RuntimeLayoutEdge, string> {
const contributions = new Map<RuntimeLayoutEdge, string[]>(edges.map(edge => [edge, []]))
for (const entry of chrome) {
if (entry.placement.kind !== 'chrome'
|| entry.placement.position !== 'fixed'
|| !entry.placement.avoidContent
|| entry.placement.reserve.mode === 'none') {
continue
}
const size = toCssLength(entry.placement.reserve.size)
if (size)
contributions.get(entry.placement.edge)?.push(size)
}
return Object.fromEntries(edges.map((edge) => {
const values = contributions.get(edge) ?? []
return [edge, values.length > 1 ? `calc(${values.join(' + ')})` : (values[0] ?? '0px')]
})) as Record<RuntimeLayoutEdge, string>
}
export function createRuntimeLayoutPlan(
schema: DesignerSchema,
registry: RuntimeRegistry,
): RuntimeLayoutPlan {
const flow = new Map<string, RuntimeLayoutEntry[]>()
const chrome: RuntimeLayoutEntry[] = []
const layers = new Map<string, RuntimeLayoutEntry[]>()
for (const [arrayIndex, node] of (schema.root.children ?? []).entries()) {
const entry = resolveEntry(node, arrayIndex, schema, registry)
if (!entry.visible)
continue
if (entry.placement.kind === 'flow')
pushEntry(flow, entry.placement.region, entry)
else if (entry.placement.kind === 'chrome')
chrome.push(entry)
else
pushEntry(layers, entry.placement.layer, entry)
}
const sortEntries = (entries: RuntimeLayoutEntry[]) => entries.sort(
(left, right) => left.order - right.order || left.arrayIndex - right.arrayIndex,
)
for (const entries of flow.values())
sortEntries(entries)
for (const entries of layers.values())
sortEntries(entries)
sortEntries(chrome)
return { flow, chrome, layers, insets: createInsets(chrome) }
}
export function createFrameworkLayerStyle(
placement: Extract<RuntimePlacement, { kind: 'layer' }>,
): Record<string, string> {
if (placement.mode === 'self')
return { inset: '0' }
const style: Record<string, string> = {}
const offset = placement.offset ?? {}
const blockOffset = placement.anchor.block === 'start'
? offset.blockStart
: offset.blockEnd
const inlineOffset = placement.anchor.inline === 'start'
? offset.inlineStart
: offset.inlineEnd
if (placement.anchor.block === 'start') {
style.top = toCssLength(blockOffset) ?? '0px'
}
else if (placement.anchor.block === 'end') {
style.bottom = toCssLength(blockOffset) ?? '0px'
}
else {
style.top = '50%'
style.transform = 'translateY(-50%)'
}
if (placement.anchor.inline === 'start') {
style.left = toCssLength(inlineOffset) ?? '0px'
}
else if (placement.anchor.inline === 'end') {
style.right = toCssLength(inlineOffset) ?? '0px'
}
else {
style.left = '50%'
style.transform = style.transform
? `${style.transform} translateX(-50%)`
: 'translateX(-50%)'
}
return style
}这份代码没有导入内部 createLayoutPlan()。生产运行时需要为目标平台维护自己的投影,并对支持的 placement、样式 DSL 和 fallback 策略负责。
IMPORTANT
页面 layout 只作用于 root.children。容器 region 中的子节点由容器组件排列,不再进入页面 flow/chrome/layer 投影。
需要让组件拥有子节点时,继续阅读 容器与 region。内部投影规则可查阅 布局系统 Architecture Map。