Files
datalive-design/docs/设计器选中与分组交互规范.md
T
skycurtain c3846da8ae docs: 新增 DatAlive 设计器核心架构设计文档
- 新增设计器静态依赖分析方案,阐述基于 AST 的代码片段解析与依赖图谱构建
- 新增实体生命周期引擎实现规范,定义引擎驱动的事件分发与交互触发策略
- 新增悬浮组件架构设计规范,明确底层数据统一与上层视图分离的核心原则
- 新增应用运行模式架构规范,严格区分设计态、预览态与运行态的边界
- 新增设计器选中与分组交互规范,定义选中态、激活态及下钻交互的行为矩阵
2026-04-10 23:03:45 +08:00

9.7 KiB
Raw Blame History

DatAlive 设计器选中与分组交互规范 (Editor Selection & Drill-down Strategy)

本文档定义了 DatAlive 设计器中核心的“选中(Selection)”与“下钻(Drill-down)”交互逻辑,旨在解决多层级组件嵌套下的精准选中难题。该规范参考了 Figma 等主流设计工具的交互模式,并严格适配 DatAlive 的底层 Schema 架构。

1. 核心概念 (Core Concepts)

为了实现自然流畅的深层嵌套组件操作,我们必须在编辑器运行时的内存状态中区分两个维度的“选中”概念:

1.1 选中态 (Selection State)

  • 含义:当前用户正在操作的目标组件。
  • 表现:显示实线边框、8个缩放控制点(Resize Handles)、右侧属性面板显示该组件属性。
  • 操作:此时按 Delete 删除该组件,按方向键移动该组件,或拖拽控制点改变其 layout 属性。
  • 数据结构selectedIds: string[](支持多选)。

1.2 激活态 / 下钻态 (Activation State / Drill-down Context)

  • 含义:当前用户“进入”了某个容器组件(如 Group, Card, Modal)的内部上下文。
  • 表现:该容器本身不再被选中(无控制点),但显示虚线边框(或高亮背景),提示用户“你正在编辑这个容器内部的内容”。该容器外部的元素通常会变暗或不可响应点击(Focus Mode 专注模式)。
  • 操作:此时点击该容器内的任何子元素,将直接选中子元素,而不再选中该容器本身。
  • 数据结构activeContainerId: string | null
    • null: 默认状态,处在当前 Page 的画布根层级。
    • string: 处在某个具体容器组件的内部。

2. 状态定义 (Store Definition)

前端状态管理(如 Zustand/Redux)在设计态(AppMode === 'design')应维护以下瞬时字段(这些字段绝对不存入持久化的 Schema 数据库中):

interface EditorSelectionState {
  // 当前选中的组件 ID 列表
  selectedIds: string[];
  
  // 当前激活的容器组件 ID(下钻上下文)
  // null 表示未激活任何深层容器,处于 Root Page Context
  activeContainerId: string | null;
  
  // 【架构约束】:为实现 O(1) 的层级查找,
  // 引擎在加载 Schema 树 (Page.components) 时,必须在内存中构建一个扁平化的辅助字典,
  // 动态推导出每个组件的 parentId(持久化 Schema 中坚决不存 parentId)。
  // componentNodeMap: Record<string, { id: string, parentId: string | null, type: string }>;
}

3. 交互行为矩阵 (Interaction Matrix)

3.1 单击行为 (Click)

核心规则:始终在 activeContainerId 定义的上下文中,寻找最高层级的子节点(冒泡拦截)。

场景 当前状态 动作 结果状态 逻辑说明
基础选中 active: null 点击 Button A (根节点) sel: ['Button A']
active: null
最简单的选中。
选中容器 active: null 点击 Button B (属于 Group 1) sel: ['Group 1']
active: null
点击容器内元素,自动冒泡选中其最外层容器。
组内选中 active: 'Group 1' 点击 Button B (属于 Group 1) sel: ['Button B']
active: 'Group 1'
因为已激活 Group 1,点击其子元素直接选中子元素。
深层冒泡 active: 'Group 1' 点击 Button C (属于 Group 2,且 Group 2 在 Group 1 内) sel: ['Group 2']
active: 'Group 1'
冒泡到当前激活层级(Group 1)的直接子节点(Group 2)。
退出下钻 active: 'Group 1' 点击 Button A (根节点,在 Group 1 外) sel: ['Button A']
active: null
点击目标不在当前激活容器内,自动重置 activeContainerId 为 null,并选中目标。
空白重置 任意 点击画布空白处 sel: []
active: null
清空所有状态。

3.2 双击行为 (Double Click)

核心规则:进入(Drill-down)当前选中的容器组件内部。

场景 当前状态 动作 结果状态 逻辑说明
进入容器 sel: ['Group 1'] 双击 Group 1 sel: []
active: 'Group 1'
关键交互:进入 Group 1 内部。此时 Group 1 变为容器上下文。
连续下钻 sel: ['Group 2']
active: 'Group 1'
双击 Group 2 sel: []
active: 'Group 2'
在 Group 1 内部继续下钻进入 Group 2。
文本编辑 sel: ['Button'] 双击 Button active: 不变 【架构约束】:只有 ComponentRegistry[type].isContainer === true 的组件才允许双击下钻。原子组件双击通常触发“文本行内编辑”或忽略。

3.3 修饰键直选 (Cmd/Ctrl + Click)

核心规则:穿透所有嵌套容器,直接选中鼠标下方最深层的原子组件。

场景 当前状态 动作 结果状态 逻辑说明
穿透选中 active: null Cmd + 点击 Button C (在 Group 2 中) sel: ['Button C']
active: 'Group 2'
直接选中 Button C。为了方便后续操作,建议自动将 activeContainerId 设置为 Button C 的动态 parentId(即 Group 2)。

4. 算法实现逻辑 (Implementation Logic)

以下伪代码描述了画布引擎在接收到底层 DOM 点击事件时的核心处理流程,完美适配了 DatAlive 的无 parentId 纯净 Schema 树架构:

/**
 * 处理画布上的组件点击事件
 * @param targetId - 鼠标直接点击到的最深层 DOM 对应的组件 ID
 * @param isDoubleClick - 是否为双击事件
 * @param isCmdPressed - 是否按住 Cmd/Ctrl 键
 */
function handleComponentClick(targetId: string, isDoubleClick: boolean, isCmdPressed: boolean) {
  const { 
    componentNodeMap, // 引擎在内存中扁平化生成的辅助字典 (包含动态 parentId)
    activeContainerId, 
    setActiveContainerId, 
    setSelectedIds 
  } = useEditorStore.getState();
  
  const targetNode = componentNodeMap[targetId];
  if (!targetNode) return;

  // 1. 处理 Cmd/Ctrl 直选(穿透所有层级)
  if (isCmdPressed) {
    setSelectedIds([targetId]);
    // 体验优化:直选后,自动激活其直接父级容器,方便后续点击旁边的兄弟元素
    setActiveContainerId(targetNode.parentId || null); 
    return;
  }

  // 2. 处理双击下钻 (Drill-down)
  if (isDoubleClick) {
    // 【架构防呆】:必须去物料元数据注册表中查询该组件是否为合法的容器!
    // 坚决不能写死 targetNode.type === 'Group'
    const meta = ComponentRegistry[targetNode.type];
    if (meta?.isContainer) {
      setActiveContainerId(targetId);
      setSelectedIds([]); // 清空选中,等待用户在组内进行下一步点击
    }
    return;
  }

  // 3. 处理普通单击(向上冒泡拦截逻辑)
  let currentId: string | null = targetId;
  let candidateId = targetId;

  // 沿着辅助字典的 parentId 向上遍历,寻找在当前激活上下文中的正确选中目标
  while (currentId) {
    const node = componentNodeMap[currentId];
    const parentId = node.parentId;

    // 关键判断 A:如果当前节点的父级就是 activeContainerId
    // 说明当前节点就是我们要选中的目标 (因为它刚好在当前“激活结界”的第一层)
    if (parentId === activeContainerId) {
      candidateId = currentId;
      break;
    }

    // 关键判断 B:如果父级是 null (说明遍历到了 Root Page)
    // 且当前没有任何容器被激活,目标就是这个最外层的根组件
    if (parentId === null) {
      candidateId = currentId;
      break;
    }

    // 继续向上冒泡
    currentId = parentId;
  }

  // 4. 跨层级逃逸检测(用户点击了 activeContainer 外部的其他组件)
  // 引擎利用预计算的 nodePath 或向上追溯,判断算出来的 candidateId 是否根本不是 activeContainer 的后代
  if (activeContainerId && !isDescendant(componentNodeMap, activeContainerId, candidateId)) {
    // 强制退出当前的下钻状态,重置为画布根层级
    setActiveContainerId(null);
    // 在根层级的上下文中,重新计算一次单击冒泡逻辑
    return handleComponentClick(targetId, false, false); 
  }

  // 5. 最终应用选中状态,触发属性面板和控制点 UI 的重绘
  setSelectedIds([candidateId]);
}

5. 视觉反馈规范 (Visual Feedback)

为了让实施人员清晰感知当前的层级上下文,画布引擎必须提供极其明确的视觉反馈:

状态 选中组件表现 激活容器表现 (Active Container) 其他非相关元素表现
普通选中 实线蓝色边框
8个白色方块控制点
可拖拽移动
N/A 正常显示
下钻编辑中 实线蓝色边框
8个白色方块控制点
可拖拽移动
粗虚线灰色边框
无控制点(不可拖拽)
左上角显示层级面包屑 (e.g. "Group A > Card B")
半透明 (Opacity 0.6)
屏蔽鼠标事件 (Focus Mode)
悬停 (Hover) 蓝色细边框 (Outline)
不显示控制点
N/A 正常显示

6. 键盘快捷键辅助 (Shortcuts)

在复杂的层级树中,提供高效的键盘导航是专业设计器的标配:

  • Esc (逃逸键):

    • 场景 1:如果 selectedIds 有值,清空选中(selectedIds = [])。
    • 场景 2:如果 selectedIds 为空,但 activeContainerId 不为空(处于下钻态),则向上跳出一级(将 activeContainerId 设为其 parentId)。
  • Enter (深入键):

    • 如果当前仅选中了一个 isContainer: true 的组件,直接进入该容器(等同于鼠标双击)。