docs: 新增 DatAlive 设计器核心架构设计文档

- 新增设计器静态依赖分析方案,阐述基于 AST 的代码片段解析与依赖图谱构建
- 新增实体生命周期引擎实现规范,定义引擎驱动的事件分发与交互触发策略
- 新增悬浮组件架构设计规范,明确底层数据统一与上层视图分离的核心原则
- 新增应用运行模式架构规范,严格区分设计态、预览态与运行态的边界
- 新增设计器选中与分组交互规范,定义选中态、激活态及下钻交互的行为矩阵
This commit is contained in:
2026-04-10 23:03:45 +08:00
parent 12b9ab3f4b
commit c3846da8ae
5 changed files with 496 additions and 0 deletions
@@ -0,0 +1,185 @@
# 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 数据库中):
```typescript
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']`<br>`active: null` | 最简单的选中。 |
| **选中容器** | `active: null` | 点击 Button B (属于 Group 1) | `sel: ['Group 1']`<br>`active: null` | 点击容器内元素,自动冒泡选中其最外层容器。 |
| **组内选中** | `active: 'Group 1'` | 点击 Button B (属于 Group 1) | `sel: ['Button B']`<br>`active: 'Group 1'` | 因为已激活 Group 1,点击其子元素直接选中子元素。 |
| **深层冒泡** | `active: 'Group 1'` | 点击 Button C (属于 Group 2,且 Group 2 在 Group 1 内) | `sel: ['Group 2']`<br>`active: 'Group 1'` | 冒泡到当前激活层级(Group 1)的直接子节点(Group 2)。 |
| **退出下钻** | `active: 'Group 1'` | 点击 Button A (根节点,在 Group 1 外) | `sel: ['Button A']`<br>`active: null` | 点击目标不在当前激活容器内,自动重置 `activeContainerId` 为 null,并选中目标。 |
| **空白重置** | 任意 | 点击画布空白处 | `sel: []`<br>`active: null` | 清空所有状态。 |
### 3.2 双击行为 (Double Click)
**核心规则**:进入(Drill-down)当前选中的容器组件内部。
| 场景 | 当前状态 | 动作 | 结果状态 | 逻辑说明 |
| :--- | :--- | :--- | :--- | :--- |
| **进入容器** | `sel: ['Group 1']` | 双击 Group 1 | `sel: []` <br>`active: 'Group 1'` | **关键交互**:进入 Group 1 内部。此时 Group 1 变为容器上下文。 |
| **连续下钻** | `sel: ['Group 2']`<br>`active: 'Group 1'` | 双击 Group 2 | `sel: []`<br>`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']`<br>`active: 'Group 2'` | 直接选中 Button C。为了方便后续操作,建议自动将 `activeContainerId` 设置为 Button C 的动态 `parentId`(即 Group 2)。 |
---
## 4. 算法实现逻辑 (Implementation Logic)
以下伪代码描述了画布引擎在接收到底层 DOM 点击事件时的核心处理流程,完美适配了 DatAlive 的无 `parentId` 纯净 Schema 树架构:
```typescript
/**
* 处理画布上的组件点击事件
* @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) | 其他非相关元素表现 |
| :--- | :--- | :--- | :--- |
| **普通选中** | 实线蓝色边框<br>8个白色方块控制点<br>可拖拽移动 | N/A | 正常显示 |
| **下钻编辑中** | 实线蓝色边框<br>8个白色方块控制点<br>可拖拽移动 | **粗虚线灰色边框**<br>无控制点(不可拖拽)<br>左上角显示层级面包屑 (e.g. "Group A > Card B") | **半透明 (Opacity 0.6)**<br>屏蔽鼠标事件 (Focus Mode) |
| **悬停 (Hover)** | 蓝色细边框 (Outline)<br>不显示控制点 | N/A | 正常显示 |
---
## 6. 键盘快捷键辅助 (Shortcuts)
在复杂的层级树中,提供高效的键盘导航是专业设计器的标配:
* **`Esc` (逃逸键)**:
* 场景 1:如果 `selectedIds` 有值,清空选中(`selectedIds = []`)。
* 场景 2:如果 `selectedIds` 为空,但 `activeContainerId` 不为空(处于下钻态),则**向上跳出一级**(将 `activeContainerId` 设为其 `parentId`)。
* **`Enter` (深入键)**:
* 如果当前仅选中了一个 `isContainer: true` 的组件,直接进入该容器(等同于鼠标双击)。