Skip to content

布局投影

布局系统把 root.children 投影到内容流、页面 chrome 和浮层,并保证每个 root 节点只渲染一次。它表达页面意图,不替业务物料定义 flex 或 grid 几何。

先看三种结果

贯穿项目同时注册了普通公告、固定页头和浮动操作。浮动操作的完整定义如下:

ts
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排序行为
flowcontent region默认进入 content sort scope
chrome指定页面边缘默认不参与正文排序
layerfloat layer默认不参与正文排序

未声明 layout 的节点按 flow/content 处理。非 content flow region 默认不参与排序,除非显式声明自己的 sortScope

固定页面 chrome

固定页头通过 reserve 告诉画布内容应避让多少空间:

ts
layout: {
  placement: {
    kind: 'chrome',
    edge: 'block-start',
    position: 'fixed',
    reserve: { mode: 'size', size: 48 },
    avoidContent: true,
  },
}

position 可以是 fixedstickyflowreserve.mode 的选择标准如下:

  • 尺寸固定且跨端一致时使用 size
  • Web 设计态需要读取实际 DOM 尺寸时使用 measure,并可提供首帧 fallback size。
  • chrome 允许覆盖正文时使用 noneavoidContent: 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:

ts
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