# 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; } ``` --- ## 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 树架构: ```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) | 其他非相关元素表现 | | :--- | :--- | :--- | :--- | | **普通选中** | 实线蓝色边框
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` 的组件,直接进入该容器(等同于鼠标双击)。