c3846da8ae
- 新增设计器静态依赖分析方案,阐述基于 AST 的代码片段解析与依赖图谱构建 - 新增实体生命周期引擎实现规范,定义引擎驱动的事件分发与交互触发策略 - 新增悬浮组件架构设计规范,明确底层数据统一与上层视图分离的核心原则 - 新增应用运行模式架构规范,严格区分设计态、预览态与运行态的边界 - 新增设计器选中与分组交互规范,定义选中态、激活态及下钻交互的行为矩阵
9.7 KiB
9.7 KiB
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)。
- 场景 1:如果
-
Enter(深入键):- 如果当前仅选中了一个
isContainer: true的组件,直接进入该容器(等同于鼠标双击)。
- 如果当前仅选中了一个