[
  {
    "path": ".cursor/skills/create-keybind/SKILL.md",
    "content": "---\nname: create-keybind\ndescription: 指导如何在 Project Graph 项目中创建新的快捷键。当用户需要添加新的快捷键、修改快捷键绑定或需要了解快捷键系统的实现方式时使用此技能。\n---\n\n# 创建新的快捷键功能\n\n本技能指导如何在 Project Graph 项目中创建新的快捷键。\n\n## 创建快捷键的步骤\n\n创建新快捷键需要完成以下 4 个步骤：\n\n### 1. 在 shortcutKeysRegister.tsx 中注册快捷键\n\n在 `app/src/core/service/controlService/shortcutKeysEngine/shortcutKeysRegister.tsx` 文件的 `allKeyBinds` 数组中添加新的快捷键定义。\n\n**快捷键定义结构：**\n\n```typescript\n{\n  id: \"uniqueKeybindId\",           // 唯一标识符，使用驼峰命名\n  defaultKey: \"A-S-m\",              // 默认快捷键组合\n  onPress: (project?: Project) => void,  // 按下时的回调函数\n  onRelease?: (project?: Project) => void, // 松开时的回调函数（可选）\n  isGlobal?: boolean,               // 是否为全局快捷键（可选，默认 false）\n  isUI?: boolean,                   // 是否为 UI 级别快捷键（可选，默认 false）\n  defaultEnabled?: boolean,         // 默认是否启用（可选，默认 true）\n}\n```\n\n**快捷键键位格式：**\n\n- `C-` = Ctrl (Windows/Linux) 或 Command (macOS)\n- `A-` = Alt (Windows/Linux) 或 Option (macOS)\n- `S-` = Shift\n- `M-` = Meta (macOS 上等同于 Command)\n- `F11`, `F12` 等 = 功能键\n- `arrowup`, `arrowdown`, `arrowleft`, `arrowright` = 方向键\n- `home`, `end`, `pageup`, `pagedown` = 导航键\n- `space`, `enter`, `escape` = 特殊键\n- 普通字母直接写，如 `m`, `t`, `k` 等\n- 多个按键用空格分隔，如 `\"t t t\"` 表示连续按三次 t\n\n**注意：** Mac 系统会自动将 `C-` 和 `M-` 互换，所以不需要手动处理平台差异。\n\n**示例：**\n\n```typescript\n{\n  id: \"setWindowToMiniSize\",\n  defaultKey: \"A-S-m\",  // Alt+Shift+M\n  onPress: async () => {\n    const window = getCurrentWindow();\n    // 执行操作\n    await window.setSize(new LogicalSize(width, height));\n  },\n  isUI: true,  // UI 级别快捷键，不需要项目上下文\n},\n```\n\n**快捷键类型说明：**\n\n- **项目级快捷键（默认）**：需要项目上下文，`onPress` 会接收 `project` 参数\n- **UI 级别快捷键（`isUI: true`）**：不需要项目上下文，可以在没有打开项目时使用\n- **全局快捷键（`isGlobal: true`）**：使用 Tauri 全局快捷键系统，即使应用不在焦点也能触发\n\n**使用 Tauri API 时的类型处理：**\n\n如果快捷键需要使用 Tauri 窗口 API（如 `setSize`），需要导入正确的类型：\n\n```typescript\nimport { getCurrentWindow } from \"@tauri-apps/api/window\";\nimport { LogicalSize } from \"@tauri-apps/api/dpi\"; // 或 PhysicalSize\n\n// 使用 LogicalSize（推荐，会自动处理 DPI 缩放）\nawait window.setSize(new LogicalSize(width, height));\n\n// 或使用 PhysicalSize（物理像素）\nawait window.setSize(new PhysicalSize(width, height));\n```\n\n### 2. 将快捷键添加到分组中\n\n在 `app/src/sub/SettingsWindow/keybinds.tsx` 文件的 `shortcutKeysGroups` 数组中，将新快捷键的 `id` 添加到相应的分组数组中。\n\n**分组结构：**\n\n```typescript\nexport const shortcutKeysGroups: ShortcutKeysGroup[] = [\n  {\n    title: \"basic\",              // 分组标识符（对应翻译文件中的 key）\n    icon: <Keyboard />,          // 分组图标\n    keys: [                      // 该分组包含的快捷键 ID 列表\n      \"saveFile\",\n      \"openFile\",\n      \"undo\",\n      \"redo\",\n      // ...\n    ],\n  },\n  {\n    title: \"ui\",                 // UI 控制分组\n    icon: <AppWindow />,\n    keys: [\n      \"closeAllSubWindows\",\n      \"toggleFullscreen\",\n      \"setWindowToMiniSize\",     // 添加新快捷键\n      // ...\n    ],\n  },\n  // ... 其他分组\n];\n```\n\n**可用分组：**\n\n- `basic` - 基础快捷键（撤销、重做、保存、打开等）\n- `camera` - 摄像机控制（移动、缩放、重置视野等）\n- `app` - 应用控制（切换项目、切换模式等）\n- `ui` - UI 控制（关闭窗口、全屏、窗口大小等）\n- `draw` - 涂鸦相关\n- `select` - 切换选择\n- `moveEntity` - 移动实体\n- `generateTextNodeInTree` - 生长节点\n- `generateTextNodeRoundedSelectedNode` - 在选中节点周围生成节点\n- `aboutTextNode` - 关于文本节点（分割、合并、创建等）\n- `section` - Section 框相关\n- `edge` - 连线相关\n- `color` - 颜色相关\n- `node` - 节点相关\n\n**分组选择指南：**\n\n- **UI 控制（`ui`）**：窗口管理、界面切换、全屏、窗口大小等\n- **基础快捷键（`basic`）**：文件操作、编辑操作（撤销、重做、复制、粘贴等）\n- **摄像机控制（`camera`）**：视野移动、缩放、重置等\n- **应用控制（`app`）**：项目切换、模式切换等\n- **文本节点相关（`aboutTextNode`）**：节点创建、分割、合并、编辑等\n- **其他**：根据功能特性选择最合适的分组\n\n**注意：** 如果快捷键不属于任何现有分组，可以：\n\n1. 添加到最接近的现有分组\n2. 创建新的分组（需要同时更新翻译文件）\n\n### 3. 添加翻译文本\n\n在所有语言文件中添加翻译：\n\n- `app/src/locales/zh_CN.yml` - 简体中文\n- `app/src/locales/zh_TW.yml` - 繁体中文\n- `app/src/locales/en.yml` - 英文\n- `app/src/locales/zh_TWC.yml` - 繁体中文（台湾）\n- `app/src/locales/id.yml` - 印度尼西亚语\n\n**翻译结构：**\n\n在 `keyBinds` 部分添加：\n\n```yaml\nkeyBinds:\n  keybindId:\n    title: \"快捷键标题\"\n    description: |\n      快捷键的详细描述\n      可以多行\n      说明快捷键的功能和使用场景\n```\n\n**示例：**\n\n```yaml\nkeyBinds:\n  setWindowToMiniSize:\n    title: 设置窗口为迷你大小\n    description: |\n      将窗口大小设置为设置中配置的迷你窗口宽度和高度。\n```\n\n**翻译文件位置：**\n\n- 简体中文：`app/src/locales/zh_CN.yml`\n- 繁体中文：`app/src/locales/zh_TW.yml`\n- 繁体中文（台湾）：`app/src/locales/zh_TWC.yml`\n- 英文：`app/src/locales/en.yml`\n- 印度尼西亚语：`app/src/locales/id.yml`\n\n**注意：**\n\n- 翻译键名（`keybindId`）必须与快捷键定义中的 `id` 完全一致\n- 所有语言文件都需要添加翻译，否则会显示默认值或键名\n\n### 4. 更新分组翻译（如果需要创建新分组）\n\n如果创建了新的快捷键分组，需要在所有语言文件的 `keyBindsGroup` 部分添加分组翻译：\n\n```yaml\nkeyBindsGroup:\n  newGroupName:\n    title: \"新分组标题\"\n    description: |\n      分组的详细描述\n      说明该分组包含哪些类型的快捷键\n```\n\n**示例：**\n\n```yaml\nkeyBindsGroup:\n  ui:\n    title: UI控制\n    description: |\n      用于控制UI的一些功能\n```\n\n## 快捷键类型详解\n\n### 项目级快捷键（默认）\n\n项目级快捷键需要项目上下文，`onPress` 函数会接收 `project` 参数：\n\n```typescript\n{\n  id: \"myKeybind\",\n  defaultKey: \"C-k\",\n  onPress: (project) => {\n    if (!project) {\n      toast.warning(\"请先打开工程文件\");\n      return;\n    }\n    // 使用 project 进行操作\n    project.stageManager.doSomething();\n  },\n}\n```\n\n### UI 级别快捷键\n\nUI 级别快捷键不需要项目上下文，可以在没有打开项目时使用：\n\n```typescript\n{\n  id: \"myUIKeybind\",\n  defaultKey: \"A-S-m\",\n  onPress: async () => {\n    // 不需要 project 参数\n    const window = getCurrentWindow();\n    await window.setSize(new LogicalSize(300, 300));\n  },\n  isUI: true,  // 标记为 UI 级别\n}\n```\n\n### 全局快捷键\n\n全局快捷键使用 Tauri 全局快捷键系统，即使应用不在焦点也能触发：\n\n```typescript\n{\n  id: \"myGlobalKeybind\",\n  defaultKey: \"CommandOrControl+Shift+G\",\n  onPress: () => {\n    // 全局快捷键逻辑\n  },\n  isGlobal: true,  // 标记为全局快捷键\n}\n```\n\n**注意：** 全局快捷键的键位格式与普通快捷键不同，使用 `CommandOrControl+Shift+G` 格式。\n\n## 访问快捷键配置\n\n在代码中访问快捷键配置：\n\n```typescript\nimport { KeyBindsUI } from \"@/core/service/controlService/shortcutKeysEngine/KeyBindsUI\";\n\n// 获取快捷键配置\nconst config = await KeyBindsUI.get(\"keybindId\");\n\n// 修改快捷键\nawait KeyBindsUI.changeOneUIKeyBind(\"keybindId\", \"new-key-combination\");\n\n// 重置所有快捷键\nawait KeyBindsUI.resetAllKeyBinds();\n```\n\n## 注意事项\n\n1. **快捷键 ID 命名规范：** 使用驼峰命名法（camelCase），如 `setWindowToMiniSize`\n2. **唯一性：** 快捷键 ID 必须唯一，不能与现有快捷键重复\n3. **默认键位：** 选择不冲突的默认键位组合\n4. **类型安全：** TypeScript 会自动推断类型，确保类型一致性\n5. **翻译键名：** 翻译文件中的键名必须与快捷键的 `id` 完全一致\n6. **分组必须：** 所有快捷键都必须添加到 `shortcutKeysGroups` 中的相应分组，否则不会在设置页面中显示\n7. **分组选择：** 根据快捷键的功能特性选择合适的分组，保持设置页面的逻辑清晰\n8. **Tauri API 类型：** 使用窗口 API 时，需要使用 `LogicalSize` 或 `PhysicalSize` 类型，不能直接使用普通对象\n9. **Mac 兼容性：** Mac 系统会自动将 `C-` 和 `M-` 互换，无需手动处理\n10. **UI vs 项目级：** 根据快捷键是否需要项目上下文选择合适的类型\n\n## 完整示例\n\n假设要添加一个\"设置窗口为迷你大小\"的快捷键：\n\n**1. shortcutKeysRegister.tsx - 注册快捷键：**\n\n```typescript\nimport { getCurrentWindow } from \"@tauri-apps/api/window\";\nimport { LogicalSize } from \"@tauri-apps/api/dpi\";\nimport { Settings } from \"@/core/service/Settings\";\n\nexport const allKeyBinds: KeyBindItem[] = [\n  // ... 其他快捷键\n  {\n    id: \"setWindowToMiniSize\",\n    defaultKey: \"A-S-m\",\n    onPress: async () => {\n      const window = getCurrentWindow();\n      // 如果当前是最大化状态，先取消最大化\n      if (await window.isMaximized()) {\n        await window.unmaximize();\n        store.set(isWindowMaxsizedAtom, false);\n      }\n      // 如果当前是全屏状态，先退出全屏\n      if (await window.isFullscreen()) {\n        await window.setFullscreen(false);\n      }\n      // 设置窗口大小为设置中的迷你窗口大小\n      const width = Settings.windowCollapsingWidth;\n      const height = Settings.windowCollapsingHeight;\n      await window.setSize(new LogicalSize(width, height));\n    },\n    isUI: true,\n  },\n  // ... 其他快捷键\n];\n```\n\n**2. keybinds.tsx - 添加到分组：**\n\n```typescript\nexport const shortcutKeysGroups: ShortcutKeysGroup[] = [\n  // ... 其他分组\n  {\n    title: \"ui\",\n    icon: <AppWindow />,\n    keys: [\n      \"closeAllSubWindows\",\n      \"toggleFullscreen\",\n      \"setWindowToMiniSize\",  // 添加到 UI 控制分组\n      // ...\n    ],\n  },\n  // ... 其他分组\n];\n```\n\n**3. zh_CN.yml - 添加翻译：**\n\n```yaml\nkeyBinds:\n  setWindowToMiniSize:\n    title: 设置窗口为迷你大小\n    description: |\n      将窗口大小设置为设置中配置的迷你窗口宽度和高度。\n```\n\n**4. 其他语言文件也需要添加相应翻译**\n\n## 快捷键键位格式参考\n\n**修饰键：**\n\n- `C-` = Ctrl/Command\n- `A-` = Alt/Option\n- `S-` = Shift\n- `M-` = Meta (macOS)\n\n**特殊键：**\n\n- `F1` - `F12` = 功能键\n- `arrowup`, `arrowdown`, `arrowleft`, `arrowright` = 方向键\n- `home`, `end`, `pageup`, `pagedown` = 导航键\n- `space`, `enter`, `escape`, `tab`, `backspace`, `delete` = 特殊键\n\n**组合示例：**\n\n- `\"C-s\"` = Ctrl+S\n- `\"A-S-m\"` = Alt+Shift+M\n- `\"C-A-S-t\"` = Ctrl+Alt+Shift+T\n- `\"F11\"` = F11\n- `\"C-F11\"` = Ctrl+F11\n- `\"t t t\"` = 连续按三次 T\n- `\"arrowup\"` = 上方向键\n- `\"S-arrowup\"` = Shift+上方向键\n\n## 快捷键设置页面\n\n快捷键添加到分组后，会在设置页面的\"快捷键绑定\"标签页中自动显示：\n\n1. 用户可以在设置页面查看所有快捷键\n2. 用户可以自定义快捷键键位\n3. 用户可以启用/禁用快捷键\n4. 用户可以重置快捷键为默认值\n\n快捷键会自动保存到 `keybinds2.json` 文件中，并在应用重启后恢复。\n"
  },
  {
    "path": ".cursor/skills/create-setting-item/SKILL.md",
    "content": "---\nname: create-setting-item\ndescription: 指导如何在 Project Graph 项目中创建新的设置项。当用户需要添加新的设置项、配置选项或需要了解设置系统的实现方式时使用此技能。\n---\n\n# 创建新的设置项功能\n\n本技能指导如何在 Project Graph 项目中创建新的设置项。\n\n## 创建设置项的步骤\n\n创建新设置项需要完成以下 5 个步骤：\n\n### 1. 在 Settings.tsx 中添加 Schema 定义\n\n在 `app/src/core/service/Settings.tsx` 文件的 `settingsSchema` 对象中添加新的设置项定义。\n\n**支持的 Zod 类型：**\n\n- `z.boolean().default(false)` - 布尔值开关\n- `z.number().min(x).max(y).default(z)` - 数字（可添加 `.int()` 限制为整数）\n- `z.string().default(\"\")` - 字符串\n- `z.union([z.literal(\"option1\"), z.literal(\"option2\")]).default(\"option1\")` - 枚举选择\n- `z.tuple([z.number(), z.number(), z.number(), z.number()]).default([0,0,0,0])` - 元组（如颜色 RGBA）\n\n**示例：**\n\n```typescript\n// 布尔值设置\nenableNewFeature: z.boolean().default(false),\n\n// 数字范围设置（带滑块）\nnewSliderValue: z.number().min(0).max(100).int().default(50),\n\n// 枚举选择设置\nnewMode: z.union([z.literal(\"mode1\"), z.literal(\"mode2\")]).default(\"mode1\"),\n```\n\n### 2. 在 SettingsIcons.tsx 中添加图标\n\n在 `app/src/core/service/SettingsIcons.tsx` 文件的 `settingsIcons` 对象中添加对应的图标。\n\n**步骤：**\n\n1. 从 `lucide-react` 导入合适的图标组件\n2. 在 `settingsIcons` 对象中添加映射：`settingKey: IconComponent`\n\n**示例：**\n\n```typescript\nimport { NewIcon } from \"lucide-react\";\n\nexport const settingsIcons = {\n  // ... 其他设置项\n  enableNewFeature: NewIcon,\n};\n```\n\n### 3. 添加翻译文本\n\n在所有语言文件中添加翻译：\n\n- `app/src/locales/zh_CN.yml` - 简体中文\n- `app/src/locales/zh_TW.yml` - 繁体中文\n- `app/src/locales/en.yml` - 英文\n- `app/src/locales/zh_TWC.yml` - 接地气繁体中文\n- `app/src/locales/id.yml` - 印度尼西亚语\n\n**翻译结构：**\n\n```yaml\nsettings:\n  settingKey:\n    title: \"设置项标题\"\n    description: |\n      设置项的详细描述\n      可以多行\n    options: # 仅枚举类型需要\n      option1: \"选项1显示文本\"\n      option2: \"选项2显示文本\"\n```\n\n**示例：**\n\n```yaml\nsettings:\n  enableNewFeature:\n    title: \"启用新功能\"\n    description: |\n      开启后将启用新功能特性。\n      此功能可能会影响性能。\n  newMode:\n    title: \"新模式\"\n    description: \"选择新的模式选项\"\n    options:\n      mode1: \"模式一\"\n      mode2: \"模式二\"\n```\n\n### 4. 将设置项添加到分组中\n\n在 `app/src/sub/SettingsWindow/settings.tsx` 文件的 `categories` 对象中，将新设置项的键名添加到相应的分组数组中。\n\n**分组结构：**\n\n```typescript\nconst categories = {\n  visual: {           // 一级分类：视觉\n    basic: [...],     // 二级分组：基础\n    background: [...], // 二级分组：背景\n    node: [...],      // 二级分组：节点样式\n    // ...\n  },\n  automation: {       // 一级分类：自动化\n    autoNamer: [...],\n    autoSave: [...],\n    // ...\n  },\n  control: {         // 一级分类：控制\n    mouse: [...],\n    cameraMove: [...],\n    // ...\n  },\n  performance: {     // 一级分类：性能\n    memory: [...],\n    cpu: [...],\n    // ...\n  },\n  ai: {              // 一级分类：AI\n    api: [...],\n  },\n};\n```\n\n**添加设置项到分组：**\n\n```typescript\nconst categories = {\n  visual: {\n    basic: [\n      \"language\",\n      \"isClassroomMode\",\n      \"enableNewFeature\", // 添加新设置项\n      // ...\n    ],\n  },\n  // ...\n};\n```\n\n**分组选择指南：**\n\n- **visual（视觉）**：界面显示、主题、背景、节点样式、连线样式等\n  - `basic`: 基础视觉设置\n  - `background`: 背景相关设置\n  - `node`: 节点样式设置\n  - `edge`: 连线样式设置\n  - `section`: Section 框的样式设置\n  - `entityDetails`: 实体详情面板设置\n  - `debug`: 调试相关设置\n  - `miniWindow`: 迷你窗口设置\n  - `experimental`: 实验性视觉功能\n- **automation（自动化）**：自动保存、自动备份、自动命名等\n  - `autoNamer`: 自动命名相关\n  - `autoSave`: 自动保存相关\n  - `autoBackup`: 自动备份相关\n  - `autoImport`: 自动导入相关\n- **control（控制）**：鼠标、键盘、触摸板、相机控制等\n  - `mouse`: 鼠标相关设置\n  - `touchpad`: 触摸板设置\n  - `cameraMove`: 相机移动设置\n  - `cameraZoom`: 相机缩放设置\n  - `objectSelect`: 对象选择设置\n  - `textNode`: 文本节点编辑设置\n  - `edge`: 连线操作设置\n  - `generateNode`: 节点生成设置\n  - `gamepad`: 游戏手柄设置\n- **performance（性能）**：内存、CPU、渲染性能相关\n  - `memory`: 内存相关设置\n  - `cpu`: CPU 相关设置\n  - `render`: 渲染相关设置\n  - `experimental`: 实验性性能功能\n- **ai（AI）**：AI 相关设置\n  - `api`: AI API 配置\n\n**注意：** 如果设置项不属于任何现有分组，可以：\n\n1. 添加到最接近的现有分组\n2. 在相应分类下创建新的分组（需要同时更新翻译文件中的分类结构）\n\n### 5. 在设置页面中使用 SettingField 组件\n\n设置项添加到分组后，会在设置页面的相应分组中自动显示。如果需要手动渲染或添加额外内容，可以使用 `SettingField` 组件：\n\n**基本用法：**\n\n```tsx\nimport { SettingField } from \"@/components/ui/field\";\n\n<SettingField settingKey=\"enableNewFeature\" />;\n```\n\n**带额外内容的用法：**\n\n```tsx\n<SettingField settingKey=\"enableNewFeature\" extra={<Button>额外按钮</Button>} />\n```\n\n**注意：** 大多数情况下，只需要将设置项添加到 `categories` 中即可，设置页面会自动渲染。只有在需要特殊布局或额外功能时才需要手动使用 `SettingField` 组件。\n\n## SettingField 组件的自动类型推断\n\n`SettingField` 组件会根据 schema 定义自动渲染对应的 UI 控件：\n\n- **字符串类型** → `Input` 输入框\n- **数字类型（有 min/max）** → `Slider` 滑块 + `Input` 数字输入框\n- **数字类型（无范围）** → `Input` 数字输入框\n- **布尔类型** → `Switch` 开关\n- **枚举类型（Union）** → `Select` 下拉选择框\n\n## 访问设置值\n\n在代码中访问设置值：\n\n```typescript\nimport { Settings } from \"@/core/service/Settings\";\n\n// 读取设置值\nconst value = Settings.enableNewFeature;\n\n// 修改设置值\nSettings.enableNewFeature = true;\n\n// 监听设置变化（返回取消监听的函数）\nconst unsubscribe = Settings.watch(\"enableNewFeature\", (newValue) => {\n  console.log(\"设置已更改:\", newValue);\n});\n\n// React Hook 方式（在组件中使用）\nconst [value, setValue] = Settings.use(\"enableNewFeature\");\n```\n\n## 注意事项\n\n1. **设置项键名命名规范：** 使用驼峰命名法（camelCase），如 `enableNewFeature`\n2. **默认值：** 所有设置项都必须提供默认值（`.default()`）\n3. **类型安全：** TypeScript 会自动推断类型，确保类型一致性\n4. **持久化：** 设置值会自动保存到 `settings.json` 文件中\n5. **翻译键名：** 翻译文件中的键名必须与设置项的键名完全一致\n6. **图标可选：** 如果不需要图标，可以不在 `settingsIcons` 中添加，组件会使用 Fragment\n7. **分组必须：** 所有设置项都必须添加到 `categories` 对象中的相应分组，否则不会在设置页面中显示\n8. **分组选择：** 根据设置项的功能特性选择合适的分类和分组，保持设置页面的逻辑清晰\n\n## 完整示例\n\n假设要添加一个\"启用暗色模式\"的设置项：\n\n**1. Settings.tsx:**\n\n```typescript\nexport const settingsSchema = z.object({\n  // ... 其他设置项\n  enableDarkMode: z.boolean().default(false),\n});\n```\n\n**2. SettingsIcons.tsx:**\n\n```typescript\nimport { Moon } from \"lucide-react\";\n\nexport const settingsIcons = {\n  // ... 其他设置项\n  enableDarkMode: Moon,\n};\n```\n\n**3. zh_CN.yml:**\n\n```yaml\nsettings:\n  enableDarkMode:\n    title: \"启用暗色模式\"\n    description: \"开启后将使用暗色主题界面\"\n```\n\n**4. settings.tsx - 添加到分组：**\n\n```typescript\nconst categories = {\n  visual: {\n    basic: [\n      \"language\",\n      \"isClassroomMode\",\n      \"enableDarkMode\", // 添加到基础视觉设置分组\n      // ...\n    ],\n    // ...\n  },\n  // ...\n};\n```\n\n**5. 设置项会自动显示：**\n设置项添加到 `categories` 后，会在设置页面的\"视觉 > 基础\"分组中自动显示，无需手动使用 `SettingField` 组件。\n\n## 快捷设置栏支持\n\n如果希望设置项出现在快捷设置栏（Quick Settings Toolbar）中，需要：\n\n1. 确保设置项已正确创建（完成上述 4 步）\n2. 快捷设置栏会自动显示所有布尔类型的设置项\n3. 可以通过 `QuickSettingsManager.addQuickSetting()` 手动添加非布尔类型的设置项\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# These are supported funding model platforms\n\ngithub: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]\npatreon: # Replace with a single Patreon username\nopen_collective: # Replace with a single Open Collective username\nko_fi: # Replace with a single Ko-fi username\ntidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel\ncommunity_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry\nliberapay: # Replace with a single Liberapay username\nissuehunt: # Replace with a single IssueHunt username\nlfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry\npolar: # Replace with a single Polar username\nbuy_me_a_coffee: # Replace with a single Buy Me a Coffee username\nthanks_dev: # Replace with a single thanks.dev username\ncustom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']\n  - 2y.nz/pgdonate\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "#blank_issues_enabled: false\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/no-issues.yml",
    "content": "name: 不要创建 Issue，请前往 Disscussions！\ndescription: 不要创建issue\nbody:\n  - type: checkboxes\n    attributes:\n      label: 不要创建 Issue，请前往 Disscussions\n      description: 鉴于大量低质量和重复的issue，从2025年7月开始，用户不可以自行创建issue\n      options:\n        - label: 我希望这个issue被删除\n          required: true\n        - label: \"[点这里前往Disscussions](https://github.com/graphif/project-graph/discussions/new/choose)\"\n          required: true\n"
  },
  {
    "path": ".github/copilot-instructions.md",
    "content": "## Project Background\n\nGithub Repository: `graphif/project-graph`\n\nProject Graph is a desktop application designed to visualize and manage complex project structures. It allows users to create, edit, and visualize project graphs, making it easier to understand relationships and dependencies within projects.\n\n## Coding guidelines\n\n- Prioritize code correctness and clarity. Speed and efficiency are secondary priorities unless otherwise specified.\n- Do not write organizational or comments that summarize the code. Comments should only be written in order to explain \"why\" the code is written in some way in the case there is a reason that is tricky / non-obvious.\n- Prefer implementing functionality in existing files unless it is a new logical component. Avoid creating many small files.\n- Never silently discard errors with `catch {}` or `catch (e) { console.error(e) }` on fallible operations. Always handle errors appropriately:\n  - Don't catch errors, let the calling function to handle them\n  - If the error should be ignored, show a dialog instead of logging to console. User cannot see logs in the console.\n  - Ensure errors propagate to the top of DOM (eg. `window`), so `ErrorHandler` component can catch it and show an user-friendly dialog\n  - Example: avoid `try { something() } catch (e) { console.error(e) }` - use `something()` instead\n- Always use `something.tsx` instead of a single `index.tsx` in a directory.\n\n## Tech-stack\n\n- React (TypeScript) + Tauri (Rust)\n- Vite + pnpm (monorepo) + turborepo\n- Canvas 2D\n- shadcn/ui + Tailwind CSS + self-developed sub-window system\n- Jotai\n\n## Structure\n\n### Tauri Application, and Frontend\n\n- Frontend Vite project: `/app`\n- Rust Project: `/app/src-tauri`\n\n### Fumadocs\n\n- Next.js Project: `/docs`\n- Content: `/docs/content/docs`\n\n### Open-source Libraries\n\nThey are all in `/packages` directory, and are used in the frontend.\n\n## Trade time for space\n\nTrade time for space, meaning that you should use more **storage** (not memory!) to reduce computation time\n\n## RFCs\n\nThe `tasks` the user refers to are **RFCs**. These RFCs are usually tracked in the repository’s Issues and their titles begin with `RFC:`.\n\nA user **cannot** take on tasks that are irrelevant to their own system. For example, a Linux user **cannot** complete a task that exists only on macOS.\n\nWhen the user asks which tasks remain unfinished, you must\n\n- assign **a unique number** to every task\n- sort the list by a combined score of **importance × implementation difficulty**,\n  so the user can easily tell you to tick the finished items in the corresponding RFCs.\n\n## Commit Message\n\nUse conventional commits\n"
  },
  {
    "path": ".github/scripts/enable-sourcemap.mjs",
    "content": "import { readFileSync, writeFileSync } from \"fs\";\n\nconst VITE_CONFIG_PATH = \"app/vite.config.ts\";\n\nconst conf = readFileSync(VITE_CONFIG_PATH);\nconst updated = conf.toString().replace(\"sourcemap: false\", \"sourcemap: true\");\n\nwriteFileSync(VITE_CONFIG_PATH, updated);\n\nconsole.log(updated);\n"
  },
  {
    "path": ".github/scripts/generate-changelog.mjs",
    "content": "/* eslint-disable */\nimport { execSync } from \"child_process\";\n\n// 获取最近一次发布的标签\nconst lastRelease = execSync(\n  \"git for-each-ref --sort=-creatordate --format='%(refname:short)' \\\"refs/tags/v*\\\" | head -n 1\",\n)\n  .toString()\n  .trim();\n\n// 获取 Git 提交记录\nconst commits = execSync(`git log ${lastRelease}.. --pretty=format:\"%s\" --reverse`).toString().trim();\n\n/**\n * 生成降级版本的changelog（直接使用commit标题）\n * @param {string} commits - commit标题列表，用换行分隔\n * @returns {string} 格式化的changelog\n */\nfunction generateFallbackChangelog(commits) {\n  if (!commits || commits.trim() === \"\") {\n    return \"## 更新内容\\n\\n本次更新暂无变更记录。\";\n  }\n\n  const commitList = commits\n    .split(\"\\n\")\n    .filter((line) => line.trim() !== \"\")\n    .map((commit) => `- ${commit}`)\n    .join(\"\\n\");\n\n  return `## 更新内容\n\n> ⚠️ 注意：由于AI总结更新的内容服务不可用，以下内容为直接提取的commit记录\n\n${commitList}\n`;\n}\n\n// 定义提示信息\nconst prompt = `\n你是一个专业的软件文档撰写助手，负责将开发团队提供的commit历史记录转换为用户友好的更新日志（Changelog）。用户会提供git历史记录信息。\n你的任务是生成一篇清晰、简洁的Changelog，面向最终用户（非技术人员），避免使用技术术语，专注于用户能直接感知的变更。请遵循以下规则：\n\n1. **理解commit类型**：\n  - \\`feat\\` / \\`feature\\`：新功能，归类为“新功能”。\n  - \\`fix\\` / \\`hotfix\\`：问题修复，归类为“问题修复”。\n  - \\`docs\\`：文档或内容更新，归类为“文档和内容更新”。\n  - \\`chore\\`：界面优化、配置调整或内部改进，归类为“改进和优化”（重点描述用户可见的变化）。\n  - 其他类型根据描述推断，确保归类合理。\n\n2. **组织Changelog结构**：\n  - 按类别分组commit（如“问题修复”、“文档和内容更新”、“改进和优化”等），每个类别使用子标题。\n  - 每个类别下用项目符号列表描述变更，语言口语化、正面积极（例如：用“修复了...问题”而非“修复bug”）。\n  - 如果多个commit相似，可合并描述以提高可读性。\n\n3. **输出格式**：\n  - 使用中文。\n  - 保持段落清晰，无需编号，但使用标题和项目符号。\n  - 开头不要有 \\`## 更新日志\\`，直接开始更新内容。\n  - 从二级标题开始（例如：\\`## 新功能\\`）。\n\n请直接输出Changelog内容，无需额外解释。\n\n## Example\n\n\\`\\`\\`\n## 新功能\n\n增加穿透点击功能，全局快捷键Alt+2可以开关窗口穿透，穿透点击开启后，副作用是会自动将透明度设置为0.2且自动打开窗口置顶\n标签面板可以正常打开了，且增加标签管理器UI分裂功能，每一个标签都能分裂成一个可拖拽与改变大小的独立子窗口，且点击能够对准对应的节点\nrua时，可以输入自定义连接符，如果输入了换行符作为连接，则rua出来的节点的换行策略会自动变为手动调整宽度模式\n增加手动保存时是否自动清理历史记录的设置项\n完成自动保存功能\n完成自动备份功能\n\n## 操作优化\n\n修复最后一个实体跳出框时，框附带移动到实体附近的bug\nsection中最后一个节点跳出框时，自动变为文本节点\n\n## 视觉/交互优化\n\nalt跳入框时，显示框会变大多少的虚线边缘\n右键菜单中增加文本节点妙操作\n开启穿透点击时，自动半透明窗口\n给子窗口增加shadow\n按住ctrl或者shift框选加选或者叉选时，增加视觉提示\n给特效界面增加提示\n\n## Bug修复\n\n修复涂鸦后没有记录历史的问题\n防止一开始启动软件视野缩放过大\n暂时修复详细信息报错问题\n\\`\\`\\`\n`;\n\n// 发送请求到 API\ntry {\n  const response = await fetch(\n    \"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-lite:generateContent?key=\" +\n      process.env.GEMINI_API_KEY,\n    {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/json\",\n      },\n      body: JSON.stringify({\n        system_instruction: {\n          parts: [\n            {\n              text: prompt,\n            },\n          ],\n        },\n        contents: [\n          {\n            parts: [\n              {\n                text: commits,\n              },\n            ],\n          },\n        ],\n      }),\n    },\n  );\n\n  if (!response.ok) {\n    throw new Error(`API request failed with status ${response.status}`);\n  }\n\n  const data = await response.json();\n  const changelog = data.candidates[0].content.parts[0].text;\n  const finalChangelog = `以下内容为AI根据git历史自动生成总结，不保证完全准确\n\n${changelog}\n`;\n  console.log(finalChangelog);\n} catch (err) {\n  console.error(\"AI生成changelog失败，使用降级方案:\", err.message);\n  \n  // 降级方案：直接输出commit列表\n  const fallbackChangelog = generateFallbackChangelog(commits);\n  console.log(fallbackChangelog);\n}\n"
  },
  {
    "path": ".github/scripts/generate-pkgbuild.mjs",
    "content": "/* eslint-disable */\n\nimport { writeFileSync } from \"fs\";\n\n// 命令行参数：\n// node ./generate-pkgbuild.mjs <pkgname> <pkgver> <sha256sums>\nconst pkgname = process.argv[2];\nconst pkgver = process.argv[3];\nconst sha256sums = process.argv[4];\n\nif (!pkgname || !pkgver || !sha256sums) {\n  console.error(\"Usage: node generate-pkgbuild.mjs <pkgname> <pkgver> <sha256sums>\");\n  process.exit(1);\n}\n\nconst conflicts =\n  pkgname === \"project-graph-nightly-bin\"\n    ? [\"project-graph-bin\", \"project-graph-git\"]\n    : [\"project-graph-nightly-bin\", \"project-graph-git\"];\nconst source =\n  pkgname === \"project-graph-nightly-bin\"\n    ? `https://github.com/LiRenTech/project-graph/releases/download/nightly/Project.Graph_0.0.0-nightly.${pkgver.slice(1)}_amd64.deb`\n    : `https://github.com/LiRenTech/project-graph/releases/download/v${pkgver}/Project.Graph_${pkgver}_amd64.deb`;\n\nconst PKGBUILD = `# Maintainer: zty012 <me@zty012.de>\npkgname=${pkgname}\npkgver=${pkgver.replaceAll(\"-\", \".\")}\npkgrel=1\npkgdesc=\"A simple tool to create topology diagrams.\"\narch=('x86_64')\nurl=\"https://github.com/LiRenTech/project-graph\"\nlicense=('mit')\ndepends=('cairo' 'desktop-file-utils' 'gdk-pixbuf2' 'glib2' 'gtk3' 'hicolor-icon-theme' 'libsoup' 'pango' 'webkit2gtk')\noptions=('!strip' '!emptydirs')\nprovides=('project-graph')\nconflicts=(${conflicts.map((x) => `'${x}'`).join(\" \")})\ninstall=${pkgname}.install\nsource_x86_64=('${source}')\nsha256sums_x86_64=('${sha256sums}')\npackage() {\n  # Extract package data\n  tar -xz -f data.tar.gz -C \"\\${pkgdir}\"\n}\n`;\n\nconsole.log(\"===== PKGBUILD =====\");\nconsole.log(PKGBUILD);\nwriteFileSync(\"./PKGBUILD\", PKGBUILD);\n\nconst SRCINFO = `pkgbase = ${pkgname}\n\\tpkgdesc = A simple tool to create topology diagrams.\n\\tpkgver = ${pkgver.replaceAll(\"-\", \".\")}\n\\tpkgrel = 1\n\\turl = https://github.com/LiRenTech/project-graph\n\\tinstall = ${pkgname}.install\n\\tarch = x86_64\n\\tlicense = mit\n\\tdepends = cairo\n\\tdepends = desktop-file-utils\n\\tdepends = gdk-pixbuf2\n\\tdepends = glib2\n\\tdepends = gtk3\n\\tdepends = hicolor-icon-theme\n\\tdepends = libsoup\n\\tdepends = pango\n\\tdepends = webkit2gtk\n\\tprovides = project-graph\n${conflicts.map((x) => `\\tconflicts = ${x}`).join(\"\\n\")}\n\\toptions = !strip\n\\toptions = !emptydirs\n\\tsource_x86_64 = ${source}\n\\tsha256sums_x86_64 = ${sha256sums}\n\npkgname = ${pkgname}`;\n\nconsole.log(\"===== .SRCINFO =====\");\nconsole.log(SRCINFO);\nwriteFileSync(\"./.SRCINFO\", SRCINFO);\n"
  },
  {
    "path": ".github/scripts/set-tauri-features.mjs",
    "content": "/* eslint-disable */\n\nimport { readFileSync, writeFileSync } from \"fs\";\n\nconst features = process.argv[2].split(\",\");\n\nconst CARGO_TOML_PATH = \"app/src-tauri/Cargo.toml\";\n\nconst conf = readFileSync(CARGO_TOML_PATH);\nconst updated = conf\n  .toString()\n  .replace(\n    /tauri = { version = \"(.*)\", features = \\[\"(.*)\"\\] }/,\n    `tauri = { version = \"$1\", features = [\"macos-private-api\", \"${features.join('\", \"')}\"] }`,\n  );\n\nwriteFileSync(CARGO_TOML_PATH, updated);\n\nconsole.log(updated);\n"
  },
  {
    "path": ".github/scripts/set-version.mjs",
    "content": "/* eslint-disable */\n\nimport { readFileSync, writeFileSync } from \"fs\";\n\nconst version = process.argv[2];\n\nconst TAURI_CONF_PATH = \"app/src-tauri/tauri.conf.json\";\n\nconst conf = JSON.parse(readFileSync(TAURI_CONF_PATH));\nconf.version = version;\n\nwriteFileSync(TAURI_CONF_PATH, JSON.stringify(conf, null, 2));\n\nconsole.log(conf);\n"
  },
  {
    "path": ".github/workflows/nightly.yml",
    "content": "name: \"Nightly\"\nrun-name: \"Nightly ${{ github.run_number }}\"\n\non:\n  schedule:\n    - cron: \"0 0 * * *\"\n  workflow_dispatch:\n\njobs:\n  build:\n    permissions:\n      contents: write\n    uses: ./.github/workflows/publish.yml\n    with:\n      android_key_alias: \"upload\"\n      android_key_path: \"upload.jks\"\n      app_version: 0.0.0-nightly.${{ github.run_number }}\n      app_version_android: 0.0.${{ github.run_number }}\n      aur_version: r${{ github.run_number }}\n      aur_key_algorithm: \"ed25519\"\n      aur_package_name: \"project-graph-nightly-bin\"\n      delete_release: true\n      prerelease: true\n      release_name: Nightly ${{ inputs.version }}\n      release_tag: nightly\n      task_build: build:ci\n      # task_build_android: build\n      include_devtools: true\n    secrets:\n      ANDROID_KEYSTORE: ${{ secrets.ANDROID_RELEASE_KEYSTORE }}\n      ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_PASSWORD }}\n      AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}\n      TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}\n      TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}\n      GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}\n"
  },
  {
    "path": ".github/workflows/publish.yml",
    "content": "name: Publish\n\non:\n  workflow_call:\n    inputs:\n      app_version:\n        type: string\n        description: The version of the app.\n        required: false\n        default: \"0.0.0\"\n      app_version_android:\n        type: string\n        description: The version of the app for Android, CANNOT be `0.0.0`\n        required: true\n      release_name:\n        type: string\n        description: The name of the release.\n        required: false\n        default: Release\n      release_tag:\n        type: string\n        description: The tag to use for the release.\n        required: true\n      prerelease:\n        type: boolean\n        description: Whether to make the release a prerelease.\n        required: false\n        default: false\n      delete_release:\n        type: boolean\n        description: Whether to delete the release.\n        required: false\n        default: true\n      android_key_path:\n        type: string\n        description: The path to the Android key file, relative to app_root\n        required: false\n        default: \"upload.jks\"\n      aur_package_name:\n        type: string\n        description: The name of the AUR package.\n        required: false\n        default: \"\"\n      aur_key_algorithm:\n        type: string\n        description: The algorithm to use for the AUR key.\n        required: false\n        default: \"ed25519\"\n      aur_version:\n        type: string\n        description: The version of the AUR package.\n        required: true\n      task_build:\n        type: string\n        description: The task to run for building the app.\n        required: false\n        default: \"tauri:build\"\n      task_build_android:\n        type: string\n        description: The task to run for building the app for Android.\n        required: false\n        default: \"tauri:build:android\"\n      android_key_alias:\n        type: string\n        description: The alias of the Android key.\n        required: false\n        default: \"upload\"\n      include_devtools:\n        type: boolean\n        description: Whether to include devtools in the build.\n        required: false\n        default: false\n    secrets:\n      TAURI_SIGNING_PRIVATE_KEY:\n        description: Sign app binaries for updater support.\n        required: false\n      TAURI_SIGNING_PRIVATE_KEY_PASSWORD:\n        description: Password for the signing key.\n        required: false\n      ANDROID_KEYSTORE:\n        description: Base64 of `jks` file for APK signing.\n        required: false\n      ANDROID_KEYSTORE_PASSWORD:\n        description: Password for the keystore.\n        required: false\n      BUILD_ENV:\n        description: \"Environment variables to pass to `tauri build`. Format: `key1=value1\\\\nkey2=value2\\\\n...`.\"\n        required: false\n      AUR_SSH_PRIVATE_KEY:\n        description: \"SSH private key for AUR publishing.\"\n        required: false\n      GEMINI_API_KEY:\n        description: Authenticate Google Gemini to generate changelog.\n        required: false\n\njobs:\n  create-release:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n    env:\n      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n      - name: Setup Node\n        uses: actions/setup-node@v4\n        with:\n          node-version: 24\n      - name: Install dependencies\n        run: npm i -g node-fetch\n      - name: Delete release\n        if: inputs.delete_release == true\n        run: |\n          gh release delete ${{ inputs.release_tag }} --yes --cleanup-tag || true\n          sleep 1\n      - name: Create release\n        env:\n          tag: ${{ inputs.release_tag }}\n          name: ${{ inputs.release_name }}\n          branch: ${{ github.ref_name }}\n        run: |\n          notes=$(GEMINI_API_KEY=\"${{ secrets.GEMINI_API_KEY }}\" node ./.github/scripts/generate-changelog.mjs)\n          gh release create \"$tag\" --target \"$branch\" --title \"$name\" --notes \"$notes\" ${{ inputs.prerelease == true && '--prerelease' || '' }}\n\n  publish-tauri:\n    needs: [create-release]\n    outputs:\n      hash: ${{ steps.sha256sum.outputs.hash }}\n    permissions:\n      contents: write\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - platform: \"ubuntu-22.04\"\n            dist: \"src-tauri/target/release/bundle/**/*.{deb,AppImage,rpm,sig}\"\n          - platform: \"windows-latest\"\n            dist: \"src-tauri/target/release/bundle/nsis\"\n          - platform: \"macos-latest\"\n            args: \"--target universal-apple-darwin\"\n            rust_targets: \"aarch64-apple-darwin,x86_64-apple-darwin\"\n            dist: \"src-tauri/target/universal-apple-darwin/release/bundle/**/*.{dmg,tar.gz,sig}\"\n\n    runs-on: ${{ matrix.platform }}\n    steps:\n      - uses: actions/checkout@v4\n\n      #region Prepare environment\n      - name: Linux - Install dependencies\n        if: matrix.platform == 'ubuntu-22.04'\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libudev-dev\n      - uses: pnpm/action-setup@v4\n        id: pnpm\n        name: Install pnpm\n        with:\n          run_install: false\n      - name: Install Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: 24\n          cache: \"pnpm\"\n      - name: Install dependencies\n        run: pnpm install --no-frozen-lockfile\n      - name: Install Rust\n        uses: dtolnay/rust-toolchain@stable\n        with:\n          targets: ${{ matrix.rust_targets }}\n      - name: Rust cache\n        uses: swatinem/rust-cache@v2\n        with:\n          workspaces: \"app/src-tauri -> target\"\n      #endregion\n\n      #region Android - Prepare environment\n      - name: Android - Setup Java\n        if: matrix.android\n        uses: actions/setup-java@v4\n        with:\n          distribution: \"zulu\"\n          java-version: \"17\"\n      - name: Android - Setup Android SDK\n        if: matrix.android\n        uses: android-actions/setup-android@v3\n      - name: Android - Setup Android NDK\n        if: matrix.android\n        run: sdkmanager \"ndk;27.0.11902837\"\n      - name: Android - Setup Android APK Signing\n        if: matrix.android\n        run: |\n          cd app/src-tauri/gen/android\n          cat > keystore.properties <<EOF\n          password=${{ secrets.ANDROID_RELEASE_PASSWORD }}\n          keyAlias=${{ inputs.android_key_alias }}\n          storeFile=${{ inputs.android_key_path }}\n          EOF\n          echo \"${{ secrets.ANDROID_RELEASE_KEYSTORE }}\" | base64 --decode > app/${{ inputs.android_key_path }}\n      #endregion\n      #region Edit version\n      - name: Set app version\n        if: ${{ !matrix.android && !matrix.variant }}\n        run: |\n          node ./.github/scripts/set-version.mjs ${{ inputs.app_version }}\n      - name: Set app version when variant exists\n        if: ${{ !matrix.android && matrix.variant }}\n        run: |\n          node ./.github/scripts/set-version.mjs ${{ inputs.app_version }}-${{ matrix.variant }}\n      - name: Enable DevTools\n        if: ${{ inputs.include_devtools }}\n        run: |\n          node ./.github/scripts/set-tauri-features.mjs devtools\n          node ./.github/scripts/enable-sourcemap.mjs\n      - name: Android - Set app version\n        if: matrix.android\n        run: |\n          node ./.github/scripts/set-version.mjs ${{ inputs.app_version_android }}\n      - name: Variant - win7\n        if: matrix.variant == 'win7'\n        run: |\n          (Get-Content ./app/src-tauri/tauri.conf.json).Replace('downloadBootstrapper', 'embedBootstrapper') | Set-Content ./app/src-tauri/tauri.conf.json\n          cat ./app/src-tauri/tauri.conf.json\n      #endregion\n      #region Build\n      - name: Write BUILD_ENV to .env file\n        if: matrix.variant != 'foss'\n        run: |\n          echo \"${{ secrets.BUILD_ENV }}\" > app/.env\n      - name: Build\n        run: |\n          pnpm run ${{ matrix.android && inputs.task_build_android || inputs.task_build }}\n        env:\n          TAURI_BUILD_ARGS: ${{ matrix.args }}\n          NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/27.0.11902837\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}\n          TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}\n\n      - name: MacOS - Rename app.tar.gz\n        if: matrix.platform == 'macos-latest'\n        run: |\n          cd app/src-tauri/target/*/release/bundle/macos\n          mv *.app.tar.gz \"Project Graph_${{ inputs.app_version }}_universal.app.tar.gz\"\n          mv *.app.tar.gz.sig \"Project Graph_${{ inputs.app_version }}_universal.app.tar.gz.sig\"\n      - name: Linux - Rename rpm\n        if: matrix.platform == 'ubuntu-22.04'\n        run: |\n          cd app/src-tauri/target/release/bundle/rpm\n          mv *.rpm \"Project Graph_${{ inputs.app_version }}_amd64.rpm\"\n      - name: Linux / MacOS - Upload\n        if: matrix.platform != 'windows-latest'\n        run: |\n          gh release upload ${{ inputs.release_tag }} app/${{ matrix.dist }} --clobber\n        env:\n          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n      - name: Windows - Upload\n        if: matrix.platform == 'windows-latest'\n        run: |\n          gh release upload ${{ inputs.release_tag }} (Get-Item .\\app\\${{ matrix.dist }}\\*.exe).FullName --clobber\n          gh release upload ${{ inputs.release_tag }} (Get-Item .\\app\\${{ matrix.dist }}\\*.sig).FullName --clobber\n        env:\n          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n      - id: sha256sum\n        name: Linux - Calculate SHA256 hash for AUR package\n        if: matrix.platform == 'ubuntu-22.04'\n        run: |\n          cd app/src-tauri/target/release/bundle/deb\n          sha256sum ./*.deb | awk '{print $1}' > sha256sum.txt\n          echo \"hash=$(cat sha256sum.txt)\" >> \"$GITHUB_OUTPUT\"\n      #endregion\n\n  # publish-tauri-linux-arm:\n  #   runs-on: ubuntu-22.04\n  #   needs: [create-release, build-frontend]\n  #   permissions:\n  #     contents: write\n\n  #   strategy:\n  #     matrix:\n  #       arch: [aarch64]\n  #       include:\n  #         - arch: aarch64\n  #           cpu: cortex-a72\n  #           base_image: https://dietpi.com/downloads/images/DietPi_RPi5-ARMv8-Bookworm.img.xz\n  #           deb: arm64\n  #           rpm: aarch64\n  #           appimage: aarch64\n  #         # - arch: armv7l\n  #         #   cpu: cortex-a53\n  #         #   deb: armhfp\n  #         #   rpm: arm\n  #         #   appimage: armhf\n  #         #   base_image: https://dietpi.com/downloads/images/DietPi_RPi-ARMv7-Bookworm.img.xz\n\n  #   steps:\n  #     - uses: actions/checkout@v3\n\n  #     - name: Cache rust build artifacts\n  #       uses: Swatinem/rust-cache@v2\n  #       with:\n  #         workspaces: src-tauri\n  #         cache-on-failure: true\n\n  #     - name: Build app\n  #       uses: pguyot/arm-runner-action@v2.6.5\n  #       with:\n  #         base_image: ${{ matrix.base_image }}\n  #         cpu: ${{ matrix.cpu }}\n  #         bind_mount_repository: true\n  #         image_additional_mb: 10240\n  #         optimize_image: no\n  #         #exit_on_fail: no\n  #         commands: |\n  #           # Prevent Rust from complaining about $HOME not matching eid home\n  #           export HOME=/root\n\n  #           # Workaround to CI worker being stuck on Updating crates.io index\n  #           export CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse\n\n  #           # Install setup prerequisites\n  #           apt-get update -y --allow-releaseinfo-change\n  #           apt-get autoremove -y\n  #           apt-get install -y --no-install-recommends --no-install-suggests curl libwebkit2gtk-4.1-dev build-essential libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf libfuse2 file\n  #           curl https://sh.rustup.rs -sSf | sh -s -- -y\n  #           . \"$HOME/.cargo/env\"\n\n  #           # Install Node.js\n  #           curl -fsSL https://deb.nodesource.com/setup_lts.x | bash\n  #           apt-get install -y nodejs\n\n  #           # Install frontend dependencies\n  #           npm i -g pnpm\n  #           pnpm i\n\n  #           # Build the application\n  #           pnpm tauri:build\n\n  #     - name: Upload\n  #       run: |\n  #         gh release upload ${{ inputs.release_tag }} app/src-tauri/target/release/bundle/*.{deb,rpm,AppImage} --clobber\n  #       env:\n  #         GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n  bump-aur-version:\n    needs: publish-tauri\n    runs-on: ubuntu-latest\n    if: inputs.aur_package_name != ''\n    steps:\n      - uses: actions/checkout@v4\n      - name: Setup Node\n        uses: actions/setup-node@v4\n        with:\n          node-version: 24\n      - name: Setup AUR private key\n        run: |\n          mkdir -p ~/.ssh\n          echo \"${{ secrets.AUR_SSH_PRIVATE_KEY }}\" > ~/.ssh/id_${{ inputs.aur_key_algorithm }}\n          chmod 600 ~/.ssh/id_${{ inputs.aur_key_algorithm }}\n          ssh-keyscan -t \"${{ inputs.aur_key_algorithm }}\" aur.archlinux.org >> ~/.ssh/known_hosts\n      - name: Clone AUR repository\n        run: git clone ssh://aur@aur.archlinux.org/${{ inputs.aur_package_name }}.git ./aurpackage\n      - name: Update version in PKGBUILD and .SRCINFO\n        env:\n          repo: ${{ github.repository }}\n          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        run: |\n          cd aurpackage\n          node ../.github/scripts/generate-pkgbuild.mjs ${{ inputs.aur_package_name }} ${{ inputs.aur_version }} ${{ needs.publish-tauri.outputs.hash }}\n      - name: Commit and push changes\n        run: |\n          cd aurpackage\n          git config user.name \"github-actions[bot]\"\n          git config user.email \"41898282+github-actions[bot]@users.noreply.github.com\"\n          git add PKGBUILD .SRCINFO\n          git commit -m \"Bump version to ${{ inputs.app_version }}\"\n          git push origin master\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: \"Release\"\nrun-name: \"v${{ inputs.version }}\"\n\non:\n  workflow_dispatch:\n    inputs:\n      version:\n        description: \"应用版本 (x.y.z)\"\n        required: true\n      prerelease:\n        type: boolean\n        description: \"Is pre-release\"\n        required: false\n        default: false\n      delete_release:\n        type: boolean\n        description: \"删除以前的release\"\n        required: false\n        default: false\n\njobs:\n  build:\n    permissions:\n      contents: write\n    uses: ./.github/workflows/publish.yml\n    with:\n      android_key_alias: \"upload\"\n      android_key_path: \"upload.jks\"\n      app_version: ${{ inputs.version }}\n      app_version_android: ${{ inputs.version }}\n      aur_version: ${{ inputs.version }}\n      aur_key_algorithm: \"ed25519\"\n      aur_package_name: \"project-graph-bin\"\n      delete_release: ${{ inputs.delete_release }}\n      prerelease: ${{ inputs.prerelease }}\n      release_name: \"v${{ inputs.version }}\"\n      release_tag: \"v${{ inputs.version }}\"\n      task_build: build:ci\n      # task_build_android: \"tauri:build:android\"\n    secrets:\n      ANDROID_KEYSTORE: ${{ secrets.ANDROID_RELEASE_KEYSTORE }}\n      ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_PASSWORD }}\n      AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}\n      BUILD_ENV: |\n        LR_API_BASE_URL=${{ secrets.ENV_API_BASE_URL }}\n        LR_GITHUB_CLIENT_SECRET=${{ secrets.ENV_GITHUB_CLIENT_SECRET }}\n      TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}\n      TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}\n      GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}\n"
  },
  {
    "path": ".github/workflows/render-docs-svg.yml",
    "content": "name: Render files in ./docs-pg to SVG\n\non:\n  push:\n    branches:\n      - master\n    paths:\n      - \"docs-pg/**\"\n  workflow_dispatch:\n\njobs:\n  render-svg:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n    steps:\n      - uses: actions/checkout@v2\n      - name: Install Project Graph\n        run: |\n          gh release download -p \"*.deb\" -O project-graph.deb --clobber\n          sudo apt-get update\n          sudo apt-get install -y ./project-graph.deb\n          rm -rf ./project-graph.deb\n        env:\n          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n      - name: Install xvfb\n        run: sudo apt-get install -y xvfb\n      - name: Render SVG files\n        run: |\n          cd docs-pg\n          for file in *.json; do\n            echo \"Rendering $file\"\n            xvfb-run -a project-graph \"$file\" -o \"${file%.json}.svg\"\n          done\n      - name: Commit files\n        run: |\n          git add .\n          git config --local user.email \"41898282+github-actions[bot]@users.noreply.github.com\"\n          git config --local user.name \"github-actions[bot]\"\n          git commit -a -m \"📝 Render SVG files\"\n          git pull\n      - name: Push changes\n        uses: ad-m/github-push-action@master\n        with:\n          github_token: ${{ secrets.GITHUB_TOKEN }}\n          branch: ${{ github.ref }}\n"
  },
  {
    "path": ".gitignore",
    "content": "node_modules\ndist\n.env\n.idea\nstorybook-static\n.swc\n\ndocs/src/.vitepress/dist\ndocs/src/.vitepress/cache\ndocs/src/.vitepress/.temp\n# pg早期的备份文件\n*.backup\n# 自动保存导致产生的文件\napp/src-tauri/Project Graph\nbackup_projectGraphTips\n*.backup.json\n# 省略文件夹\nbackup_*\n\n.turbo\n\n# mac下开发需要忽略\n.DS_Store\n\n\n\n.nx/cache\n.nx/workspace-data\n.cursor/rules/nx-rules.mdc\n.github/instructions/nx.instructions.md"
  },
  {
    "path": ".husky/pre-commit",
    "content": "pnpm lint-staged\n"
  },
  {
    "path": ".lintstagedrc",
    "content": "{\n  \"*.{ts,tsx}\": [\"eslint --fix\"],\n  \"*.{json,md,yaml,yml,ts,tsx}\": [\"prettier --write\"]\n}\n"
  },
  {
    "path": ".prettierignore",
    "content": "#app/src-tauri\n\n# 防止翻译总是被格式化\napp/src/locales/*.yml\n\n# 这个基本不会被手动修改\nrouter.ts\nvite-env.d.ts\n\n# prettier不支持mdx3.0\n# *.mdx\n\ndist\n.next\nout\n\nLICENSE\n\napp/src-tauri/gen\napp/src-tauri/target\napp/src/css/theme.pcss"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"plugins\": [\"prettier-plugin-tailwindcss\"],\n  \"printWidth\": 120\n}\n"
  },
  {
    "path": ".trae/documents/plan_20251223_165257.md",
    "content": "1. **添加upgradeNAnyToNLatest函数**：\n   - 在ProjectUpgrader namespace中添加upgradeNAnyToNLatest函数\n\n   - 该函数用于升级N版本的prg文件到最新版本\n\n   - 内部调用convertN1toN2等转换函数\n\n2. **添加convertN1toN2转换函数**：\n   - 该函数用于将N1版本升级到N2版本\n\n   - 为LineEdge添加lineType属性，默认值为'solid'\n\n   - 确保升级逻辑正确处理所有实体\n\n3. **在prg文件中增加metadata.msgpack**：\n   - 修改保存prg文件的逻辑，添加metadata.msgpack\n\n   - metadata.msgpack中包含dataVersion和dataVersionType字段\n\n   - dataVersion初始值为2（N2版本），dataVersionType为'N'\n\n   - 在升级时更新dataVersion字段\n\n4. **更新相关调用**：\n   - 确保在保存和加载prg文件时正确处理metadata\n\n   - 在升级过程中更新dataVersion字段\n\n5. **测试和验证**：\n   - 确保升级逻辑正确\n\n   - 确保metadata.msgpack被正确添加到prg文件中\n\n   - 确保lineType属性被正确处理\n"
  },
  {
    "path": ".trae/documents/plan_20260101_170503.md",
    "content": "1. **修改现有的重置按钮**\n\n   * 添加确认弹窗，用户确认后才执行重置\n\n   * 更新按钮文本，添加提醒信息\n\n   * 确保重置时同时重置快捷键值和启用状态\n\n2. **添加两个新的重置按钮**\n\n   * 只重置启用状态的按钮\n\n   * 只重置快捷键值的按钮\n\n   * 同样添加确认弹窗\n\n   * 更新按钮显示，添加明确的功能说明\n\n3. **实现重置功能的细分**\n\n   * 修改`KeyBindsUI`命名空间，添加两个新的重置方法：\n\n     * `resetAllKeyBindsEnabledState()`：仅重置启用状态\n\n     * `resetAllKeyBindsValues()`：仅重置快捷键值\n\n   * 确保现有`resetAllKeyBinds()`方法同时重置两者\n\n4. **更新UI界面**\n\n   * 重新布局重置按钮区域\n\n   * 使用清晰的文本和图标区分三个重置按钮\n\n   * 添加适当的样式和间距\n\n5. **测试功能**\n\n   * 测试所有三个重置按钮的功能\n\n   * 验证确认弹窗的显示和交互\n\n   * 检查重置后的数据状态是否正确\n\n**预期效果**：\n\n* 快捷键设置页面左侧分类栏底部会显示三个重置按钮\n\n* 每个按钮都有明确的功能说明和提醒\n\n* 点击任何重置按钮都会先显示确认弹窗\n\n* 重置后页面数据会立即更新\n\n* 用户可以根据需要选择不同的重置选项\n\n"
  },
  {
    "path": ".trae/documents/为LineEdge添加虚线形态属性.md",
    "content": "1. 在LineEdge类中添加线条类型属性：\n   - 在LineEdge.tsx中添加`@serializable`装饰的`lineType`属性，使用字符串枚举类型\n\n   - 支持的值：'solid'（实线）和'dashed'（虚线），后续可扩展\n\n   - 在构造函数中初始化该属性为'solid'\n\n   - 更新相关类型定义\n\n2. 更新渲染逻辑：\n   - 在StraightEdgeRenderer.tsx中修改renderNormalState和renderShiftingState方法，根据lineType属性选择调用renderSolidLine或renderDashedLine\n\n   - 确保其他渲染器（如SymmetryCurveEdgeRenderer和VerticalPolyEdgeRenderer）也支持虚线渲染\n\n3. 更新序列化和反序列化：\n   - 确保lineType属性能够被正确序列化和反序列化\n\n   - 在convertVAnyToN1函数中，为LineEdge添加lineType属性，默认值为'solid'\n\n4. 测试和验证：\n   - 确保新属性能够被正确保存和加载\n\n   - 测试实线和虚线的渲染效果\n\n   - 确保现有功能不受影响\n"
  },
  {
    "path": ".trae/documents/为RecentFilesWindow添加独立的隐私模式功能.md",
    "content": "## 计划概述\n\n为 RecentFilesWindow 页面添加独立的隐私模式开关，当开启时对文件夹名和文件名进行凯撒移位加密显示。\n\n## 实施步骤\n\n1. **添加本地隐私模式状态**\n   - 在组件中添加 `isLocalPrivacyMode` 状态\n   - 添加切换按钮到工具栏\n\n2. **创建凯撒移位加密函数**\n   - 复用现有的 `replaceTextWhenProtect` 函数，但强制使用凯撒模式\n   - 或者创建专用的文件名加密函数\n\n3. **修改显示逻辑**\n   - 在平铺视图中对文件名应用加密\n   - 在嵌套视图的文件夹名和文件名上应用加密\n   - 只影响显示，不改变实际数据\n\n4. **UI优化**\n   - 添加隐私模式切换按钮，图标使用遮罩或眼睛图标\n   - 按钮状态反映当前是否开启隐私模式\n\n## 技术要点\n\n- 使用凯撒移位加密（字符后移一位）\n- 复用现有的加密逻辑\n- 只在前端渲染层面做改变，不影响数据存储\n- 支持中英文字符的合理加密处理\n"
  },
  {
    "path": ".trae/documents/优化Tab键和反斜杠键创建节点的字体大小.md",
    "content": "# 优化Tab键和反斜杠键创建节点的字体大小\n\n## 一、问题分析\n\n当前Tab键和反斜杠键创建节点时，新节点的字体大小总是使用默认值（fontScaleLevel = 0），而没有考虑兄弟节点的字体大小。根据需求，新创建的节点应该：\n\n1. 查看同方向兄弟节点的字体大小\n2. 如果兄弟节点大小一致，新节点使用相同大小\n3. 如果兄弟节点大小不一致，使用父节点大小。\n4. 只考虑同方向的兄弟节点（上、下、左、右四个方向）\n\n## 二、实现思路\n\n1. **获取同方向兄弟节点**：根据节点的出边方向，将子节点分为四个方向组\n2. **检查兄弟节点字体大小一致性**：遍历同方向兄弟节点，检查它们的fontScaleLevel是否相同\n3. **设置新节点字体大小**：如果兄弟节点大小一致，新节点使用相同大小；否则使用父节点大小或默认值\n4. **修改两个创建节点的方法**：`onDeepGenerateNode`（Tab键）和`onBroadGenerateNode`（反斜杠键）\n\n## 三、关键修改点\n\n### 1. 修改 `onDeepGenerateNode` 方法（Tab键深度创建节点）\n\n**文件路径**：`/Volumes/移动固态1/project-graph-1/app/src/core/service/controlService/keyboardOnlyEngine/keyboardOnlyTreeEngine.tsx`\n\n**修改内容**：\n\n- 在创建新节点之前，获取父节点的出边\n\n- 根据预方向过滤出同方向的兄弟节点\n\n- 检查这些兄弟节点的字体大小是否一致\n\n- 为新节点设置合适的fontScaleLevel\n\n### 2. 修改 `onBroadGenerateNode` 方法（反斜杠键广度创建节点）\n\n**文件路径**：`/Volumes/移动固态1/project-graph-1/app/src/core/service/controlService/keyboardOnlyEngine/keyboardOnlyTreeEngine.tsx`\n\n**修改内容**：\n\n- 在创建新节点之前，获取父节点的出边\n\n- 根据预方向过滤出同方向的兄弟节点\n\n- 检查这些兄弟节点的字体大小是否一致\n\n- 为新节点设置合适的fontScaleLevel\n\n### 3. 辅助函数（可选）\n\n可以考虑添加一个辅助函数，用于获取同方向兄弟节点并检查字体大小一致性，以避免代码重复。\n\n## 四、实现步骤\n\n1. **在** **`onDeepGenerateNode`** **方法中**：\n   - 获取父节点的出边\n\n   - 根据预方向过滤出同方向的兄弟节点\n\n   - 检查兄弟节点的字体大小一致性\n\n   - 为新节点设置合适的fontScaleLevel\n\n2. **在** **`onBroadGenerateNode`** **方法中**：\n   - 获取父节点的出边\n\n   - 根据预方向过滤出同方向的兄弟节点\n\n   - 检查兄弟节点的字体大小一致性\n\n   - 为新节点设置合适的fontScaleLevel\n\n3. **测试验证**：\n   - 创建树形结构，为不同方向的子节点设置不同的字体大小\n\n   - 使用Tab键和反斜杠键创建新节点\n\n   - 验证新节点的字体大小是否符合预期\n\n## 五、预期效果\n\n1. 当使用Tab键或反斜杠键创建新节点时，新节点的字体大小会与同方向兄弟节点保持一致\n2. 如果没有同方向兄弟节点，新节点使用父节点的字体大小\n3. 只考虑同方向的兄弟节点，不同方向的节点不影响\n4. 保持原有功能不变，只优化字体大小设置\n\n## 六、注意事项\n\n1. 确保代码兼容性，不破坏现有功能\n2. 处理边界情况，如没有兄弟节点、兄弟节点字体大小不一致等\n3. 保持代码风格一致，遵循现有代码的设计模式\n4. 考虑性能影响，避免不必要的计算\n"
  },
  {
    "path": ".trae/documents/优化嫁接操作与添加反向操作.md",
    "content": "# 优化嫁接操作与添加反向操作\n\n## 1. 优化嫁接操作（insertNodeToTree）\n\n**问题**：当前嫁接操作创建的新连线总是使用默认的中心点连接，没有保持原连线的方向。\n\n**解决方案**：\n\n- 修改 `TextNodeSmartTools.insertNodeToTree` 方法\n- 保存原碰撞连线的 `sourceRectangleRate` 和 `targetRectangleRate` 属性\n- 在创建新连线时使用这些属性，保持原连线的方向\n- 对于向右的连线，确保新连线从源头右侧发出，目标左侧接收\n\n## 2. 实现反向操作（removeNodeFromTree）\n\n**功能**：将选中的节点从树中移除，并重新连接其前后节点。\n\n**实现步骤**：\n\n- 在 `TextNodeSmartTools` 命名空间中添加 `removeNodeFromTree` 方法\n- 逻辑：\n  1. 检查选中节点数量（必须为1）\n  2. 找到选中节点的所有入边和出边\n  3. 删除这些边\n  4. 将入边的源节点直接连接到出边的目标节点\n  5. 删除选中的节点\n  6. 记录操作历史\n\n## 3. 添加快捷键\n\n**快捷键设置**：\n\n- 嫁接操作：`1 e`\n- 摘除操作：`1 r`\n\n**实现**：在 `shortcutKeysRegister.tsx` 中添加两个新的快捷键注册：\n\n- 嫁接：`id: \"graftNodeToTree\", defaultKey: \"1 e\"`\n- 摘除：`id: \"removeNodeFromTree\", defaultKey: \"1 r\"`\n\n## 4. 新增快捷键分类\n\n**分类设置**：\n\n- 名称：\"node\"（节点相关）\n- 图标：`<Network />`\n- 包含快捷键：`[\"graftNodeToTree\", \"removeNodeFromTree\"]`\n\n**实现**：在 `keybinds.tsx` 的 `shortcutKeysGroups` 数组中添加新分类。\n\n## 5. 文件修改清单\n\n1. `textNodeSmartTools.tsx`：\n   - 优化 `insertNodeToTree` 方法\n   - 添加 `removeNodeFromTree` 方法\n\n2. `shortcutKeysRegister.tsx`：\n   - 注册两个新快捷键\n\n3. `keybinds.tsx`：\n   - 添加新的快捷键分类\n\n4. `locales/zh_CN.yml`（可选）：\n   - 添加新快捷键的国际化标题和描述\n\n## 6. 预期效果\n\n- 嫁接操作：保持原连线方向，特别是向右的连线会从源头右侧发出，目标左侧接收\n- 摘除操作：快速将节点从树中移除，自动重新连接前后节点\n- 快捷键：方便的 \"1 e\" 和 \"1 r\" 组合键\n- 快捷键分类：更清晰的快捷键管理，新分类 \"节点相关\" 包含这两个操作\n"
  },
  {
    "path": ".trae/documents/优化文本节点渲染判断逻辑.md",
    "content": "### 问题分析\n\n当前文本节点的文字渲染判断是基于固定的摄像机缩放阈值 `Settings.ignoreTextNodeTextRenderLessThanCameraScale`，当摄像机缩放比例小于这个阈值时，所有文本节点的文字都不会被渲染。这导致大字体的文本节点在宏观视野下更容易被看不见，因为它们的文字可能在摄像机缩放比例还比较大的时候就已经被隐藏了。\n\n### 解决方案\n\n1. 将设置项 `ignoreTextNodeTextRenderLessThanCameraScale` 重命名为 `ignoreTextNodeTextRenderLessThanFontSize`，以更准确地反映其功能\n2. 改为基于实际渲染字体大小的动态判断：根据文本节点的字体大小和当前摄像机缩放比例，计算出实际渲染的字体大小，然后判断这个实际字体大小是否大于设置的最小可见字体大小，只有当实际字体大小大于这个值时，才渲染文字\n\n### 实现方案\n\n#### 1. 重命名设置项\n\n**1.1 Settings.tsx**\n\n- 将 `ignoreTextNodeTextRenderLessThanCameraScale` 重命名为 `ignoreTextNodeTextRenderLessThanFontSize`\n- 设置合理的默认值和范围：`z.number().min(1).max(15).default(10)`\n\n**1.2 SettingsIcons.tsx**\n\n- 更新相关图标配置，将 `ignoreTextNodeTextRenderLessThanCameraScale` 改为 `ignoreTextNodeTextRenderLessThanFontSize`\n\n**1.3 语言文件**\n\n- 在所有语言文件中，将 `ignoreTextNodeTextRenderLessThanCameraScale` 重命名为 `ignoreTextNodeTextRenderLessThanFontSize`\n- 更新描述，说明它现在表示最小可见字体大小（像素）\n\n**1.4 settings.tsx**\n\n- 更新设置项，将 `ignoreTextNodeTextRenderLessThanCameraScale` 改为 `ignoreTextNodeTextRenderLessThanFontSize`\n\n#### 2. 修改渲染逻辑\n\n**2.1 TextNodeRenderer.tsx**\n\n- **第25行**：将基于摄像机缩放比例的透明背景判断改为基于实际渲染字体大小\n- **第52行**：将基于摄像机缩放比例的文字渲染判断改为基于实际渲染字体大小\n- 计算实际渲染字体大小：`const renderedFontSize = node.getFontSize() * this.project.camera.currentScale`\n- 使用新的设置项：`renderedFontSize < Settings.ignoreTextNodeTextRenderLessThanFontSize`\n\n**2.2 EntityRenderer.tsx**\n\n- 修改使用 `ignoreTextNodeTextRenderLessThanCameraScale` 的渲染逻辑，改为使用 `ignoreTextNodeTextRenderLessThanFontSize` 并基于实际渲染字体大小\n\n**2.3 SectionRenderer.tsx**\n\n- 修改使用 `ignoreTextNodeTextRenderLessThanCameraScale` 的渲染逻辑，改为使用 `ignoreTextNodeTextRenderLessThanFontSize` 并基于实际渲染字体大小\n\n**2.4 MultiTargetUndirectedEdgeRenderer.tsx**\n\n- 修改使用 `ignoreTextNodeTextRenderLessThanCameraScale` 的渲染逻辑，改为使用 `ignoreTextNodeTextRenderLessThanFontSize` 并基于实际渲染字体大小\n\n### 预期效果\n\n- 大字体的文本节点在宏观视野下会比小字体的文本节点更晚消失\n- 所有文本节点的文字在视觉上达到相同的清晰度阈值时才会消失\n- 提高了宏观视野下的可读性，用户可以在更远的距离看到重要的大字体文本\n- 优化了渲染性能，只渲染在当前缩放级别下可见的文字\n- 设置项范围合理，用户可以在1-15像素之间调整最小可见字体大小\n\n### 实施步骤\n\n1. 在 `Settings.tsx` 中重命名设置项并设置范围：`z.number().min(1).max(15).default(10)`\n2. 更新 `SettingsIcons.tsx` 中的配置\n3. 更新所有语言文件中的设置项名称和描述\n4. 更新 `settings.tsx` 中的设置项\n5. 修改 `TextNodeRenderer.tsx` 中的渲染逻辑\n6. 修改其他相关文件中的渲染逻辑\n7. 测试不同字体大小的文本节点在不同摄像机缩放级别下的渲染效果\n8. 验证设置调整是否生效\n"
  },
  {
    "path": ".trae/documents/修复Ctrl+T快捷键只能触发一个功能的问题.md",
    "content": "## 问题分析\n\n1. **现象**：用户按下Ctrl+T快捷键时，只有一个功能被触发，而不是三个绑定了该快捷键的功能都被触发\n2. **原因**：在`KeyBindsUI.tsx`的`check`函数中，当第一个匹配的快捷键执行后，会立即调用`userEventQueue.clear()`清空事件队列，导致后续绑定了相同快捷键的功能无法匹配到事件\n3. **涉及文件**：\n   - `app/src/core/service/controlService/shortcutKeysEngine/KeyBindsUI.tsx` - 快捷键处理核心逻辑\n   - `app/src/core/service/controlService/shortcutKeysEngine/shortcutKeysRegister.tsx` - 快捷键注册定义\n\n## 解决方案\n\n修改`KeyBindsUI.tsx`中的`check`函数，调整事件队列清空的时机，确保所有匹配的快捷键都能被执行：\n\n1. 移除在单个快捷键执行后立即清空队列的逻辑\n2. 收集所有匹配的快捷键，执行完所有匹配的快捷键后再清空队列\n3. 或者保持队列清空逻辑，但确保每个快捷键都能检查到匹配的事件\n\n## 具体修改步骤\n\n1. 打开`KeyBindsUI.tsx`文件\n2. 修改`check`函数，将`userEventQueue.clear()`移到所有快捷键检查完成之后\n3. 确保所有匹配的快捷键都能被执行\n4. 测试修改效果，确认按下Ctrl+T时三个功能都能被触发\n\n## 预期效果\n\n- 按下Ctrl+T时，会依次执行：\n  - `folderSection` - 切换/折叠章节\n  - `reverseEdges` - 反转选中的边\n  - `reverseSelectedNodeEdge` - 反转选中节点的边\n- 所有绑定了相同快捷键的功能都能正常触发\n- 不影响其他快捷键的正常工作\n"
  },
  {
    "path": ".trae/documents/修复引用块转换时连线悬空问题.md",
    "content": "## 问题分析\n\n当文本节点转换为引用块时，如果该文本节点已经有连线连接，这些连线会变成\"架空\"状态，无法删除。\n\n### 根本原因\n\n1. `changeTextNodeToReferenceBlock` 函数直接调用 `StageManager.delete(selectedNode)` 删除原文本节点\n2. `StageManager.delete()` 只是简单地从数组中移除对象，没有处理关联的连线\n3. 虽然 `DeleteManager` 有 `deleteEntityAfterClearAssociation` 方法来清理节点删除后的连线，但该方法只在 `DeleteManager.deleteEntities()` 中被调用\n4. 转换过程中创建了新的引用块节点，但没有处理原节点的连线关系\n\n## 解决方案\n\n修改 `changeTextNodeToReferenceBlock` 函数，在转换节点时正确处理连线关系：\n\n1. 在删除原文本节点前，获取所有与该节点相关的连线\n2. 创建新的引用块节点\n3. 将原节点的连线更新为连接到新的引用块节点\n4. 最后删除原文本节点\n\n## 实施步骤\n\n1. 查看 `changeTextNodeToReferenceBlock` 函数的完整实现\n2. 修改该函数，添加连线处理逻辑\n3. 测试修复效果\n\n## 预期结果\n\n- 文本节点转换为引用块时，原节点的连线会自动转移到新的引用块节点\n- 不会出现\"架空\"的连线\n- 转换后的引用块节点可以正常与其他节点连接\n\n## 代码修改点\n\n- `/Users/littlefean/Desktop/Projects/project-graph/app/src/core/service/dataManageService/textNodeSmartTools.tsx`：修改 `changeTextNodeToReferenceBlock` 函数\n\n## 具体修改思路\n\n1. 在删除原节点前，遍历所有关联，找出与原节点相关的连线\n2. 创建新的引用块节点\n3. 更新这些连线的源/目标为新的引用块节点\n4. 删除原节点\n\n这样可以确保连线关系被正确转移，不会出现悬空的连线。\n"
  },
  {
    "path": ".trae/documents/全局快捷键重构方案.md",
    "content": "## 计划概述\n\n为 RecentFilesWindow 页面添加独立的隐私模式开关，当开启时对文件夹名和文件名进行凯撒移位加密显示。\n\n## 实施步骤\n\n1. **添加本地隐私模式状态**\n   - 在组件中添加 `isLocalPrivacyMode` 状态\n\n   - 添加切换按钮到工具栏\n\n2. **创建凯撒移位加密函数**\n   - 复用现有的 `replaceTextWhenProtect` 函数，但强制使用凯撒模式\n\n   - 或者创建专用的文件名加密函数\n\n3. **修改显示逻辑**\n   - 在平铺视图中对文件名应用加密\n\n   - 在嵌套视图的文件夹名和文件名上应用加密\n\n   - 只影响显示，不改变实际数据\n\n4. **UI优化**\n   - 添加隐私模式切换按钮，图标使用遮罩或眼睛图标\n\n   - 按钮状态反映当前是否开启隐私模式\n\n## 技术要点\n\n- 使用凯撒移位加密（字符后移一位）\n\n- 复用现有的加密逻辑\n\n- 只在前端渲染层面做改变，不影响数据存储\n\n- 支持中英文字符的合理加密处理\n"
  },
  {
    "path": ".trae/documents/在快捷键设置页面添加重置所有快捷键按钮.md",
    "content": "1. **修改`keybinds.tsx`文件**，在左侧快捷键分类栏底部添加分割线和重置所有快捷键按钮\n   - 在`SidebarMenu`组件中，所有分类项之后添加一个分割线\n\n   - 分割线使用合适的样式，与现有UI设计保持一致\n\n   - 在分割线下方添加重置按钮\n\n   - 按钮使用合适的图标和文字\n\n   - 绑定点击事件处理函数\n\n2. **实现重置按钮点击事件**\n   - 调用`KeyBindsUI.resetAllKeyBinds()`方法重置所有快捷键\n\n   - 更新页面数据状态\n\n   - 显示重置成功提示\n\n3. **更新UI界面**\n   - 确保按钮样式与现有分类项一致\n\n   - 按钮位置在分类栏最底部，分割线下方\n\n   - 添加适当的视觉区分\n\n4. **测试功能**\n   - 点击重置按钮，验证所有快捷键是否恢复默认值\n\n   - 检查页面显示是否更新\n\n   - 验证提示信息是否正确显示\n\n**预期效果**：\n\n- 快捷键设置页面左侧分类栏底部会显示一个分割线\n\n- 分割线下方显示\"重置所有快捷键\"按钮\n\n- 点击按钮后，所有快捷键将恢复为默认值\n\n- 页面会更新显示，同时显示重置成功的提示信息\n\n- 重置功能与顶部导航栏的重置按钮功能一致\n"
  },
  {
    "path": ".trae/documents/实现 Section 的 isHidden 属性功能.md",
    "content": "# 实现 Section 的 isHidden 属性功能\n\n## 功能需求\n\n1. **isHidden 属性**：控制 section 内部细节的隐藏状态\n2. **移动限制**：隐藏后内部物体不能移动（包括跳跃式移动、普通拖拽、ctrl 拖拽）\n3. **删除限制**：隐藏后内部物体不能删除（包括劈砍删除和 del 删除）\n4. **跳跃式移动限制**：内部物体不能跳出去，外部物体不能跳进来\n5. **操作方式**：右键菜单添加\"隐藏 Section 内部细节\"项\n6. **渲染形态**：隐藏的 section 框显示斜着的线性阴影状态\n7. **详细注释**：为 isHidden 属性添加详细注释\n\n## 实现步骤\n\n### 1. 修改 Section.tsx\n\n- 为 isHidden 属性添加详细注释，说明其功能是隐藏内部细节并实现内部锁定效果\n- 确保 isHidden 属性在构造函数中正确初始化\n\n### 2. 修改右键菜单\n\n- 在 `context-menu-content.tsx` 中添加\"隐藏 Section 内部细节\"菜单项\n- 实现菜单项的点击逻辑，切换 section 的 isHidden 状态\n\n### 3. 修改移动控制\n\n- **跳跃式移动**：在 `ControllerEntityLayerMoving.tsx` 和 `StageEntityMoveManager.tsx` 中添加 isHidden 检查\n- **普通移动**：在 `ControllerEntityClickSelectAndMove.tsx` 中添加 isHidden 检查\n- **移动限制逻辑**：检查实体是否在 isHidden 为 true 的 section 内\n\n### 4. 修改删除控制\n\n- **劈砍删除**：在 `ControllerCutting.tsx` 中添加 isHidden 检查\n- **del 删除**：在 `StageManager.tsx` 的 deleteSelectedStageObjects 方法中添加 isHidden 检查\n\n### 5. 修改渲染逻辑\n\n- 在 `SectionRenderer.tsx` 中添加隐藏状态的特殊渲染逻辑\n- 为隐藏的 section 框添加斜着的线性阴影效果\n- 确保隐藏状态下的 section 框有清晰的视觉标识\n\n### 6. 测试验证\n\n- 测试右键菜单的隐藏功能\n- 测试隐藏后内部物体的移动限制\n- 测试隐藏后内部物体的删除限制\n- 测试跳跃式移动的限制\n- 测试隐藏 section 的斜线性阴影渲染效果\n\n## 技术要点\n\n- 使用现有的 isHidden 属性，无需添加新属性\n- 确保所有移动和删除操作都检查实体是否在隐藏的 section 内\n- 为隐藏的 section 添加斜着的线性阴影效果作为视觉标识\n- 添加详细的代码注释，便于其他开发者理解\n"
  },
  {
    "path": ".trae/documents/实现Section框大标题相机缩放阈值控制.md",
    "content": "1. **添加设置项**：在`Settings.tsx`的`settingsSchema`中添加新的设置项`sectionBigTitleCameraScaleThreshold`，类型为number，范围0.01到1，步长0.01，默认值0.5\n2. **添加图标**：在`SettingsIcons.tsx`中为新设置项添加合适的图标\n3. **添加翻译**：在中文翻译文件`zh_CN.yml`中添加新设置项的标题和描述\n4. **修改渲染逻辑**：\n   - 在`SectionRenderer.tsx`的`renderBigCoveredTitle`和`renderTopTitle`方法中，添加相机缩放阈值判断\n   - 在`EntityRenderer.tsx`的`renderAllSectionsBigTitle`方法中，添加相机缩放阈值判断\n5. **确保设置项显示**：确认新设置项在设置界面中正确显示\n"
  },
  {
    "path": ".trae/documents/实现关闭软件前的未保存文件警告.md",
    "content": "## 问题分析\n\n当用户点击关闭软件按钮时，系统会遍历所有项目并调用 `closeProject` 函数，对每个未保存的项目弹出保存询问。然而，这种方式存在风险：保存操作是异步的，可能在保存完成前软件就已关闭，导致文件损坏或丢失。\n\n## 解决方案\n\n在关闭窗口前，先检查是否有未保存的项目，如果有则弹出一个统一的警告对话框，告知用户有未保存文件，建议先手动保存，避免自动保存可能出现的问题。\n\n## 实现步骤\n\n1. **修改关闭窗口事件处理逻辑**：\n   - 在 `App.tsx` 的 `onCloseRequested` 事件处理中，添加未保存项目检查\n   - 如果存在未保存项目，弹出统一警告对话框\n   - 根据用户选择决定是否继续关闭流程\n\n2. **检查未保存项目**：\n   - 遍历所有项目，检查其状态是否为 `ProjectState.Unsaved` 或 `ProjectState.Stashed`\n   - 统计未保存项目数量\n\n3. **弹出警告对话框**：\n   - 使用现有的 `Dialog` 组件创建警告对话框\n   - 显示未保存项目数量和警告信息\n   - 提供 \"继续关闭\" 和 \"取消\" 两个选项\n\n4. **处理用户选择**：\n   - 如果用户选择 \"继续关闭\"，则执行原有的关闭流程\n   - 如果用户选择 \"取消\"，则中止关闭流程\n\n## 核心修改点\n\n- 文件：`/Volumes/移动固态1/project-graph-1/app/src/App.tsx`\n- 函数：`onCloseRequested` 事件处理函数（第262-280行）\n- 新增逻辑：未保存项目检查和统一警告对话框\n\n## 预期效果\n\n- 当用户关闭软件时，如果有未保存文件，会先看到一个统一的警告\n- 用户可以选择继续关闭（执行原有保存流程）或取消关闭（手动保存文件）\n- 减少文件保存不完整的风险，提高用户数据安全性\n- 增强用户体验，让用户更清楚当前的文件状态\n"
  },
  {
    "path": ".trae/documents/实现图片节点拖拽缩放功能.md",
    "content": "# 实现图片节点拖拽缩放功能\n\n## 1. 实现目标\n\n- 选中图片节点后，在右下角显示缩放控制点\n- 拖拽控制点可等比例缩放图片\n- 保持现有的等比例缩放特性\n- 性能优化：只在选中时显示控制点\n\n## 2. 实现步骤\n\n### 步骤1：让ImageNode实现ResizeAble接口\n\n- 修改`ImageNode.tsx`，让其实现`ResizeAble`接口\n- 添加`getResizeHandleRect()`方法，返回右下角缩放控制点矩形\n- 添加`resizeHandle()`方法，根据拖拽距离计算新缩放比例\n\n### 步骤2：修改EntityRenderer渲染缩放控制点\n\n- 修改`EntityRenderer.tsx`中的`renderImageNode`方法\n- 选中状态下，调用`shapeRenderer.renderRect()`绘制缩放控制点\n- 控制点位置：右下角，大小随相机缩放\n\n### 步骤3：确保ControllerEntityResize支持ImageNode\n\n- 检查`ControllerEntityResize.tsx`是否能处理ImageNode\n- 确保`utilsControl.tsx`中`isClickedResizeRect`能正确检测ImageNode的缩放控制点\n\n### 步骤4：移除旧的提示文字\n\n- 在`EntityRenderer.tsx`的`renderImageNode`方法中，移除\"ctrl+滚轮缩放大小\"提示\n- 保持Ctrl+滚轮缩放功能不变，增加拖拽缩放作为补充\n\n### 步骤5：性能优化\n\n- 复用现有的渲染逻辑，避免额外性能开销\n- 只在节点选中且相机缩放足够大时绘制控制点\n- 使用与TextNode相同的缩放控制点样式，保持一致性\n\n## 3. 技术细节\n\n### 缩放控制点设计\n\n- 位置：图片右下角\n- 大小：固定像素大小，随相机缩放\n- 样式：与TextNode缩放控制点一致\n- 颜色：使用`stageStyleManager.currentStyle.CollideBoxSelected`\n\n### 缩放逻辑\n\n- 保持等比例缩放，根据拖拽距离计算新的缩放比例\n- 缩放范围：0.1 - 10（与现有逻辑一致）\n- 拖拽时实时更新碰撞箱\n\n### 交互流程\n\n1. 选中图片节点\n2. 右下角显示缩放控制点\n3. 鼠标悬停控制点时，鼠标样式变为缩放样式\n4. 拖拽控制点，图片等比例缩放\n5. 释放鼠标，完成缩放\n\n## 4. 代码修改点\n\n1. **`ImageNode.tsx`**：实现ResizeAble接口，添加缩放相关方法\n2. **`EntityRenderer.tsx`**：渲染缩放控制点，移除旧提示\n3. **`ControllerEntityResize.tsx`**：确保支持ImageNode（如果需要）\n4. **`utilsControl.tsx`**：确保能检测ImageNode的缩放控制点（如果需要）\n\n## 5. 预期效果\n\n- 选中图片节点后，右下角显示缩放控制点\n- 拖拽控制点可流畅地等比例缩放图片\n- 保持现有Ctrl+滚轮缩放功能不变\n- 性能良好，不影响其他操作\n- 与现有UI风格保持一致\n"
  },
  {
    "path": ".trae/documents/实现引用块节点的精准连线定位.md",
    "content": "### 实现引用块节点的精准连线定位\n\n#### 问题分析\n\n图片节点在连线时有一个特性：当鼠标拖拽连线时，会根据鼠标位置精准定位连线的箭头位置，无论是从图片节点发出还是连接到图片节点，都能精准定位到图片内部的具体位置。现在需要让引用块节点也具有相同的特性。\n\n#### 实现方案\n\n需要修改以下几个文件：\n\n1. **ControllerNodeConnection.tsx**\n   - 在 `onMouseDown` 方法中添加对 `ReferenceBlockNode` 的支持，记录起始点在引用块上的精确位置\n   - 在 `mouseUp` 方法中添加对 `ReferenceBlockNode` 的支持，记录结束点在引用块上的精确位置\n   - 在 `dragMultiConnect` 方法中添加对 `ReferenceBlockNode` 的支持，使用精确位置计算连线起点和终点\n\n2. **Edge.tsx**\n   - 在 `bodyLine` getter 方法中添加对 `ReferenceBlockNode` 的支持，当连线连接到引用块节点且使用精确位置时，直接使用内部位置\n\n3. **SymmetryCurveEdgeRenderer.tsx**\n   - 在 `renderNormalState` 方法中添加对 `ReferenceBlockNode` 的支持，检查是否是引用块节点的精确位置，并使用相应的法线向量计算方式\n\n#### 实现思路\n\n1. 在处理图片节点的地方，同时添加对引用块节点的检查\n2. 复用现有的精确位置计算逻辑\n3. 确保引用块节点在连线时能像图片节点一样精准定位\n\n#### 预期效果\n\n引用块节点在进行连线操作时，无论是作为连线的起点还是终点，都能根据鼠标位置精准定位连线的箭头位置，就像图片节点一样。\n"
  },
  {
    "path": ".trae/documents/实现快捷键开关功能.md",
    "content": "# 实现快捷键开关功能\n\n## 核心需求\n\n- 为每个快捷键添加开关功能，支持自定义默认状态\n- 在设置页面的快捷键页面中显示开关控件\n- 支持开关状态的持久化存储\n- 修改相关代码以支持开关状态的触发控制\n\n## 实现步骤\n\n### 1. 修改快捷键定义接口\n\n- 修改 `shortcutKeysRegister.tsx` 中的 `KeyBindItem` 接口，添加 `defaultEnabled: boolean` 属性\n- 为所有现有快捷键添加默认 `defaultEnabled: true`\n- 支持后续修改特定快捷键的默认启用状态\n\n### 2. 设计持久化存储方案\n\n- **方案选择**：使用现有 `keybinds2.json` 文件，扩展数据结构\n- **数据结构设计**：\n  ```json\n  {\n    \"shortcutId\": {\n      \"key\": \"C-z\",\n      \"isEnabled\": true\n    }\n  }\n  ```\n- **向后兼容**：未设置的快捷键使用 `defaultEnabled` 值\n\n### 3. 扩展快捷键管理系统\n\n- 修改 `KeyBinds.tsx` 和 `KeyBindsUI.tsx`，添加开关状态管理\n- 更新 `create` 方法，支持初始化开关状态\n- 添加 `toggleEnabled` 方法，支持切换开关状态\n- 修改 `get` 和 `set` 方法，支持获取和设置完整的快捷键配置\n\n### 4. 修改快捷键触发逻辑\n\n- 在 `KeyBinds.tsx` 的 `check` 方法中，添加开关状态检查\n- 在 `KeyBindsUI.tsx` 的 `check` 方法中，添加开关状态检查\n- 只有 `isEnabled` 为 `true` 的快捷键才会被触发\n\n### 5. 更新设置页面组件\n\n- 修改 `keybinds.tsx`，在每个快捷键项中添加开关控件\n- 引入 `Switch` 组件（假设已有或从UI库中引入）\n- 添加开关状态的保存和加载逻辑\n- 支持实时更新开关状态\n\n### 6. 修改快捷键组件\n\n- 更新 `KeyBind` 组件，支持显示和操作开关状态\n- 在组件中集成开关控件\n- 支持开关状态的回调通知\n\n## 文件修改清单\n\n1. `shortcutKeysRegister.tsx` - 扩展快捷键定义，添加 `defaultEnabled` 属性\n2. `KeyBinds.tsx` - 支持项目级快捷键开关管理\n3. `KeyBindsUI.tsx` - 支持UI级快捷键开关管理\n4. `keybinds.tsx` - 添加设置页面开关控件\n5. `KeyBind.tsx` - 更新快捷键组件，集成开关功能\n\n## 技术要点\n\n- 使用现有的Tauri Store进行持久化存储\n- 采用扩展数据结构而非新增文件，简化管理\n- 保持向后兼容性，旧配置自动迁移\n- 实时更新开关状态，无需重启应用\n- 支持自定义默认启用状态\n\n## 预期效果\n\n- 每个快捷键都有独立的开关控制\n- 设置页面直观显示所有快捷键的开关状态\n- 关闭的快捷键不会响应键盘事件\n- 开关状态持久化保存，重启应用后保持\n- 支持通过修改 `defaultEnabled` 调整默认启用状态\n\n## 风险评估\n\n- 需确保所有现有快捷键都正确添加 `defaultEnabled` 属性\n- 需测试快捷键触发逻辑，确保关闭状态的快捷键不会被触发\n- 需确保设置页面的开关控件正常工作\n- 需确保数据结构扩展后与现有代码兼容\n\n## 测试计划\n\n1. 验证所有快捷键默认开启\n2. 测试单个快捷键开关功能\n3. 测试多个快捷键批量开关\n4. 测试开关状态持久化\n5. 测试关闭状态的快捷键是否被正确忽略\n6. 测试设置页面开关控件的交互体验\n7. 测试修改 `defaultEnabled` 后新配置的效果\n\n## 优化建议\n\n- 考虑添加\"全选\"和\"反选\"功能，方便批量管理快捷键开关\n- 考虑添加快捷键分组开关，方便按组管理\n- 考虑添加重置开关状态到默认值的功能\n\n## 实现细节\n\n- 使用 `isEnabled` 表示当前启用状态，`defaultEnabled` 表示默认启用状态\n- 存储时只保存与默认值不同的配置，节省存储空间\n- 加载时优先使用存储的配置，否则使用默认值\n- 开关状态与快捷键键位独立管理，允许单独修改\n"
  },
  {
    "path": ".trae/documents/实现搜索范围选项.md",
    "content": "# 实现搜索范围选项\n\n## 需求分析\n\n需要在搜索功能中添加两个搜索范围选项：\n\n1. 只搜索所有选中的内容（选中的节点）\n2. 只搜索所有选中内容的外接矩形范围内的所有实体\n\n## 实现思路\n\n1. **修改搜索引擎核心**\n   - 在`ContentSearch`类中添加搜索范围枚举和相关属性\n   - 修改`startSearch`方法，根据搜索范围过滤搜索对象\n   - 添加获取选中对象外接矩形的方法\n\n2. **增强搜索面板UI**\n   - 在`FindWindow`中添加搜索范围选择控件\n   - 实现UI与搜索引擎的状态同步\n   - 添加相应的图标和提示\n\n3. **实现搜索范围逻辑**\n   - 实现\"只搜索选中内容\"逻辑：过滤出选中的对象进行搜索\n   - 实现\"搜索外接矩形范围\"逻辑：计算选中对象的外接矩形，然后搜索该范围内的所有对象\n\n## 实现步骤\n\n1. **修改`contentSearchEngine.tsx`**\n   - 添加`SearchScope`枚举类型\n   - 在`ContentSearch`类中添加`searchScope`属性\n   - 修改`startSearch`方法，根据搜索范围过滤搜索对象\n   - 添加`getSelectedObjectsBounds`方法，计算选中对象的外接矩形\n   - 添加`isObjectInBounds`方法，判断对象是否在指定范围内\n\n2. **修改`FindWindow.tsx`**\n   - 添加搜索范围状态管理\n   - 在UI中添加搜索范围选择按钮\n   - 实现搜索范围切换逻辑\n   - 同步UI状态到搜索引擎\n\n3. **测试验证**\n   - 测试不同搜索范围下的搜索结果\n   - 验证UI控件的交互和状态显示\n   - 确保原有功能不受影响\n\n## 文件修改\n\n- `app/src/core/service/dataManageService/contentSearchEngine/contentSearchEngine.tsx`\n- `app/src/sub/FindWindow.tsx`\n"
  },
  {
    "path": ".trae/documents/实现背景图片功能和背景管理器.md",
    "content": "# 实现背景图片功能和背景管理器\n\n## 1. 为ImageNode添加背景图片属性\n\n- 在ImageNode类中添加`isBackground`属性，用于标记图片是否为背景图片\n- 确保该属性可序列化，以便保存到项目文件中\n\n## 2. 修改选择和删除逻辑\n\n- 修改StageManager中的选择相关方法（如`findEntityByLocation`、`findImageNodeByLocation`等），跳过背景图片\n- 修改删除相关方法（如`deleteEntities`、`deleteSelectedStageObjects`等），跳过背景图片\n- 修改框选逻辑，确保背景图片不被框选选中\n\n## 3. 添加右键菜单项\n\n- 在context-menu-content.tsx中为图片节点添加\"转化为背景图片\"菜单项\n- 为背景图片添加\"取消背景化\"菜单项\n- 实现对应的处理函数\n\n## 4. 创建背景管理器窗口\n\n- 创建BackgroundManagerWindow组件\n- 实现显示所有背景化图片的功能\n- 实现单独取消背景化的功能\n\n## 5. 在右侧工具栏添加背景管理器入口\n\n- 在右侧工具栏中添加背景管理器的按钮\n- 实现点击按钮打开背景管理器窗口的功能\n\n## 6. 测试和优化\n\n- 测试背景图片的选择、删除和背景化功能\n- 测试背景管理器的功能\n- 优化用户体验\n\n## 技术要点\n\n- 确保背景图片在渲染时位于底层\n- 确保背景图片不响应鼠标事件\n- 确保背景图片的状态正确保存和加载\n- 确保背景管理器能够正确显示和管理所有背景图片\n"
  },
  {
    "path": ".trae/documents/引用块节点精准连线定位功能问题记录.md",
    "content": "# 引用块节点精准连线定位功能问题记录\n\n## 问题现象\n\n在实现引用块节点（ReferenceBlockNode）的精准连线定位功能时，应用在启动时出现窗口无法弹出的问题。\n\n## 问题原因\n\n经过分析，问题是由于循环依赖导致的。当我们在以下文件中导入ReferenceBlockNode类时，形成了循环依赖：\n\n1. `ControllerNodeConnection.tsx`\n2. `Edge.tsx`\n3. `SymmetryCurveEdgeRenderer.tsx`\n\n循环依赖导致应用在启动时无法正确加载模块，从而导致窗口无法弹出。\n\n## 解决方法\n\n1. **移除ReferenceBlockNode的导入语句**：在所有相关文件中移除ReferenceBlockNode的导入语句，避免循环依赖。\n\n2. **使用构造函数名称检查替代直接类型检查**：在需要检查对象是否为ReferenceBlockNode类型的地方，使用`constructor.name === 'ReferenceBlockNode'`来替代直接的`instanceof`检查。\n\n3. **具体修改文件**：\n   - `ControllerNodeConnection.tsx`：移除导入，使用构造函数名称检查来记录起始点和结束点的精确位置\n   - `Edge.tsx`：移除导入，使用构造函数名称检查来处理引用块节点的精确位置\n   - `SymmetryCurveEdgeRenderer.tsx`：移除导入，使用构造函数名称检查来处理引用块节点的法线向量计算\n\n4. **修复后的效果**：应用可以正常启动，窗口能够弹出，同时引用块节点可以实现精准连线定位功能。\n\n## 代码修改示例\n\n```typescript\n// 修复前\nif (clickedConnectableEntity instanceof ImageNode || clickedConnectableEntity instanceof ReferenceBlockNode) {\n  // 处理逻辑\n}\n\n// 修复后\nif (\n  clickedConnectableEntity instanceof ImageNode ||\n  clickedConnectableEntity.constructor.name === \"ReferenceBlockNode\"\n) {\n  // 处理逻辑\n}\n```\n\n## 总结\n\n通过移除循环依赖，使用构造函数名称检查替代直接类型检查，我们成功解决了窗口无法弹出的问题，同时实现了引用块节点的精准连线定位功能。这种方法可以避免循环依赖问题，同时保持代码的可读性和可维护性。\n"
  },
  {
    "path": ".trae/documents/文本节点自定义文字大小功能设计方案.md",
    "content": "# 文本节点自定义文字大小功能设计方案\n\n## 一、需求分析\n\n1. **功能需求**：\n   - 为文本节点添加自定义文字大小功能\n   - 支持通过快捷键（放大/缩小）调整字体大小\n   - 采用指数形式缩放（例如：2 → 4 → 8 → 16 → 32）\n\n2. **技术约束**：\n   - 当前所有文本节点共享 `Renderer.FONT_SIZE = 32` 常量\n   - 渲染时字体大小会乘以相机缩放比例\n   - 已有完整的快捷键系统\n\n## 二、方案对比\n\n### 方案1：存储缩放级别（整数）\n\n**设计**：\n\n- 在 `TextNode` 类中添加 `fontScaleLevel` 字段，类型为整数\n- 基准值为0，对应默认字体大小（32px）\n- 计算公式：`finalFontSize = Renderer.FONT_SIZE * Math.pow(2, fontScaleLevel)`\n- 快捷键操作：每次按放大键 `fontScaleLevel++`，按缩小键 `fontScaleLevel--`\n\n**优点**：\n\n- 存储值范围小，占用空间少\n- 缩放逻辑清晰，符合用户期望的指数增长\n- 便于实现快捷键操作（只需增减整数）\n- 支持正负值，可放大可缩小\n\n**缺点**：\n\n- 需要额外计算才能得到实际像素值\n- 无法直接表示非2的幂次的字体大小\n\n### 方案2：直接存储像素值\n\n**设计**：\n\n- 在 `TextNode` 类中添加 `fontSize` 字段，类型为数值\n- 默认值为 `Renderer.FONT_SIZE`（32px）\n- 快捷键操作：根据当前值计算指数缩放后的新值\n\n**优点**：\n\n- 直接存储最终使用的像素值，无需计算\n- 支持任意字体大小，灵活性更高\n- 直观易懂，便于调试\n\n**缺点**：\n\n- 存储值范围大，占用空间多\n- 快捷键操作需要复杂计算（需要记录缩放次数或反向计算当前缩放级别）\n- 缩放逻辑不直观，用户难以预测下一次缩放后的大小\n\n## 三、推荐方案\n\n### 推荐方案：存储缩放级别（方案1的改进版）\n\n**设计**：\n\n1. **字段设计**：\n   - 在 `TextNode` 类中添加 `fontScale` 字段，类型为数值（支持小数）\n   - 默认值为1.0，对应默认字体大小\n   - 计算公式：`finalFontSize = Renderer.FONT_SIZE * fontScale`\n\n2. **缩放逻辑**：\n   - 快捷键放大：`fontScale *= 2`\n   - 快捷键缩小：`fontScale /= 2`\n   - 支持直接设置特定缩放比例（如1.5、0.5等）\n\n3. **实现细节**：\n   - 添加 `increaseFontSize()` 和 `decreaseFontSize()` 方法\n   - 在快捷键注册中添加对应绑定\n   - 更新渲染逻辑，使用节点自身的 `fontScale` 计算最终字体大小\n   - 确保新字段支持序列化和反序列化\n\n**优势**：\n\n- 结合了方案1和方案2的优点\n- 缩放逻辑清晰，符合指数增长预期\n- 支持精确调整字体大小\n- 存储空间合理\n- 便于实现快捷键操作\n\n## 四、实现步骤\n\n1. **修改 `TextNode` 类**：\n   - 添加 `@serializable` 装饰的 `fontScale` 字段\n   - 添加字体大小调整方法\n\n2. **更新渲染逻辑**：\n   - 在 `TextNodeRenderer` 中使用节点的 `fontScale` 计算字体大小\n   - 更新 `getMultiLineTextSize` 等相关方法\n\n3. **添加快捷键支持**：\n   - 在 `shortcutKeysRegister.tsx` 中注册放大/缩小快捷键\n   - 实现快捷键回调函数\n\n4. **数据升级**：\n   - 在 `ProjectUpgrader.tsx` 中添加升级逻辑，为旧节点设置默认 `fontScale`\n\n5. **测试验证**：\n   - 测试新建节点的默认字体大小\n   - 测试快捷键放大缩小功能\n   - 测试不同缩放级别的节点渲染效果\n   - 测试数据保存和加载功能\n\n## 五、预期效果\n\n- 新创建的文本节点使用默认字体大小（32px）\n- 选中文本节点后，按放大键（如 `+`）字体大小翻倍\n- 按缩小键（如 `-`）字体大小减半\n- 不同节点可以有不同的字体大小\n- 保存和加载后字体大小保持不变\n\n## 六、扩展考虑\n\n- 未来可添加字体大小输入框，支持直接输入像素值\n- 可添加字体大小范围限制（最小/最大）\n- 可支持批量调整多个节点的字体大小\n\n该方案既满足了用户期望的指数缩放效果，又保持了良好的灵活性和可扩展性。\n"
  },
  {
    "path": ".trae/documents/添加大标题遮罩透明度设置.md",
    "content": "1. **添加设置项**：在 `Settings.tsx` 中添加一个新的设置项 `sectionBigTitleOpacity`，类型为 `z.number().min(0).max(1).default(0.5)`，用于控制大标题遮罩的透明度\n\n2. **更新图标映射**：在 `SettingsIcons.tsx` 中为 `sectionBigTitleOpacity` 添加合适的图标，建议使用 `Blend` 图标，与其他透明度相关设置保持一致\n\n3. **添加翻译**：在三个翻译文件中添加对应的标题和描述：\n   - `zh_CN.yml`：标题为\"框的缩略大标题透明度\"，描述为\"控制半透明覆盖大标题的透明度，取值范围0-1\"\n\n   - `zh_TW.yml`：标题为\"框的縮略大標題透明度\"，描述为\"控制半透明覆蓋大標題的透明度，取值範圍0-1\"\n\n   - `en.yml`：标题为\"Section Big Title Opacity\"，描述为\"Control the opacity of the semi-transparent cover big title, range 0-1\"\n\n4. **修改渲染逻辑**：在 `SectionRenderer.tsx` 中，将硬编码的 `0.5` 透明度替换为使用 `Settings.sectionBigTitleOpacity` 值，确保设置项生效\n\n5. **测试验证**：确保新设置项能够正确保存和应用，并且在广视野下能够看到大标题遮罩透明度的变化\n"
  },
  {
    "path": ".trae/documents/添加快捷键冲突检测和提醒功能.md",
    "content": "# 快捷键冲突检测和提醒功能实现计划\n\n## 1. 功能需求分析\n\n- 在快捷键设置页面中，为每个快捷键添加冲突检测\n- 当快捷键重复时，显示警告色提示并说明与多少个快捷键冲突\n- 点击提示可以查看具体与哪一个快捷键冲突\n- 没有冲突时不显示任何内容，不占用额外视觉空间\n\n## 2. 实现方案\n\n### 2.1 添加冲突检测函数\n\n- 在 `KeyBindsPage` 组件中添加 `detectKeyConflicts` 函数\n- 该函数接收一个快捷键值，返回与该快捷键冲突的所有快捷键信息\n- 冲突检测逻辑：检查所有启用的快捷键，找出具有相同键值的项\n\n### 2.2 修改快捷键设置项渲染\n\n- 修改 `renderKeyFields` 函数，为每个快捷键设置项添加冲突检测\n- 当检测到冲突时，在 `Field` 组件下方添加冲突提示元素\n- 冲突提示元素使用警告色样式，显示冲突数量\n\n### 2.3 添加冲突详情对话框\n\n- 添加一个对话框组件，用于显示冲突的快捷键详情\n- 当用户点击冲突提示时，打开对话框并列出所有冲突的快捷键\n- 对话框中显示冲突快捷键的名称、当前键值和所属分组\n\n### 2.4 优化用户体验\n\n- 实时检测：当用户修改快捷键时，立即更新冲突状态\n- 视觉反馈：使用明显的警告色和图标，让用户快速识别冲突\n- 交互便捷：点击提示即可查看详情，无需额外操作\n\n## 3. 技术实现要点\n\n### 3.1 冲突检测逻辑\n\n```typescript\nconst detectKeyConflicts = (targetKey: string, targetId: string) => {\n  return data.filter((item) => item.key === targetKey && item.id !== targetId && item.isEnabled);\n};\n```\n\n### 3.2 冲突提示组件\n\n- 使用 `Badge` 或自定义元素显示冲突数量\n- 添加点击事件处理函数，打开详情对话框\n- 应用适当的样式，确保警告效果明显\n\n### 3.3 对话框实现\n\n- 使用现有的 `Dialog` 组件\n- 动态生成冲突列表，显示每个冲突快捷键的详细信息\n- 添加关闭按钮，确保用户可以方便地关闭对话框\n\n## 4. 具体修改文件\n\n- `/Volumes/移动固态1/project-graph-1/app/src/sub/SettingsWindow/keybinds.tsx`：主修改文件，添加冲突检测和提醒功能\n\n## 5. 预期效果\n\n- 用户在设置快捷键时，能够实时看到冲突提示\n- 点击提示可以查看具体冲突的快捷键，便于调整\n- 界面简洁，没有冲突时不显示额外元素，保持原有布局美观\n"
  },
  {
    "path": ".trae/documents/粘贴bug崩溃报告.txt",
    "content": "-------------------------------------\nTranslated Report (Full Report Below)\n-------------------------------------\nProcess:             project-graph [68049]\nPath:                /Applications/Project Graph.app/Contents/MacOS/project-graph\nIdentifier:          liren.project-graph\nVersion:             2.9.13 (2.9.13)\nCode Type:           ARM-64 (Native)\nRole:                Foreground\nParent Process:      launchd [1]\nCoalition:           liren.project-graph [1342]\nUser ID:             501\n\nDate/Time:           2026-02-13 15:56:44.8734 +0800\nLaunch Time:         2026-02-13 09:40:05.9876 +0800\nHardware Model:      Mac16,12\nOS Version:          macOS 26.1 (25B78)\nRelease Type:        User\n\nCrash Reporter Key:  B6C57097-5B06-A3FA-7F07-E57289B4D2B6\nIncident Identifier: 2CD58038-FC0A-4005-9C57-8CF12FA876D5\n\nSleep/Wake UUID:       5946CB7E-C783-40E2-ABF2-93A7C8E35F2C\n\nTime Awake Since Boot: 240000 seconds\nTime Since Wake:       23429 seconds\n\nSystem Integrity Protection: enabled\n\nTriggered by Thread: 0  main, Dispatch Queue: com.apple.main-thread\n\nException Type:    EXC_BAD_ACCESS (SIGSEGV)\nException Subtype: KERN_INVALID_ADDRESS at 0x003aea405a1445a0\nException Codes:   0x0000000000000001, 0x003aea405a1445a0\n\nTermination Reason:  Namespace SIGNAL, Code 11, Segmentation fault: 11\nTerminating Process: exc handler [68049]\n\n\nVM Region Info: 0x6a405a1445a0 is not in any region.  \n      REGION TYPE                    START - END         [ VSIZE] PRT/MAX SHRMOD  REGION DETAIL\n      UNUSED SPACE AT START\n--->  \n      UNUSED SPACE AT END\n\nThread 0 Crashed:: main Dispatch queue: com.apple.main-thread\n0   libobjc.A.dylib               \t       0x18848d820 objc_msgSend + 32\n1   AppKit                        \t       0x18cdd812c -[NSPasteboard _updateTypeCacheIfNeeded] + 1064\n2   AppKit                        \t       0x18d776820 -[NSPasteboard _typesAtIndex:combinesItems:] + 36\n3   WebCore                       \t       0x1af2ccaec WebCore::PlatformPasteboard::informationForItemAtIndex(unsigned long, long long) + 172\n4   WebCore                       \t       0x1aefb36b4 WebCore::PlatformPasteboard::allPasteboardItemInfo(long long) + 204\n5   WebKit                        \t       0x1b250b57c WebKit::WebPasteboardProxy::grantAccessToCurrentData(WebKit::WebProcessProxy&, WTF::String const&, WTF::CompletionHandler<void ()>&&) + 92\n6   WebKit                        \t       0x1b24b97a4 WebKit::WebPageProxy::grantAccessToCurrentPasteboardData(WTF::String const&, WTF::CompletionHandler<void ()>&&, std::__1::optional<WTF::ObjectIdentifierGeneric<WebCore::FrameIdentifierType, WTF::ObjectIdentifierMainThreadAccessTraits<unsigned long long>, unsigned long long>>) + 120\n7   WebKit                        \t       0x1b267eeac WebKit::WebPageProxy::willPerformPasteCommand(WebCore::DOMPasteAccessCategory, WTF::CompletionHandler<void ()>&&, std::__1::optional<WTF::ObjectIdentifierGeneric<WebCore::FrameIdentifierType, WTF::ObjectIdentifierMainThreadAccessTraits<unsigned long long>, unsigned long long>>) + 96\n8   WebKit                        \t       0x1b27e0990 WebKit::WebPageProxy::executeEditCommand(WTF::String const&, WTF::String const&) + 352\n9   WebKit                        \t       0x1b2693628 WebKit::WebViewImpl::executeEditCommandForSelector(objc_selector*, WTF::String const&) + 56\n10  WebKit                        \t       0x1b23b7c28 -[WKWebView(WKImplementationMac) paste:] + 44\n11  AppKit                        \t       0x18d852104 -[NSApplication(NSResponder) sendAction:to:from:] + 560\n12  AppKit                        \t       0x18d6bc344 -[NSMenuItem _corePerformAction:] + 540\n13  AppKit                        \t       0x18d880e7c _NSMenuPerformActionWithHighlighting + 160\n14  AppKit                        \t       0x18d6a3424 -[NSMenu _performKeyEquivalentForItemAtIndex:] + 172\n15  AppKit                        \t       0x18d6a3068 -[NSMenu performKeyEquivalent:] + 356\n16  AppKit                        \t       0x18d850a9c routeKeyEquivalent + 444\n17  AppKit                        \t       0x18d84ecd0 -[NSApplication(NSEventRouting) sendEvent:] + 1844\n18  project-graph                 \t       0x104f4df94 0x104b58000 + 4153236\n19  WebKit                        \t       0x1b269d1a8 WebKit::WebViewImpl::doneWithKeyEvent(NSEvent*, bool) + 168\n20  WebKit                        \t       0x1b1dc409c WebKit::PageClientImpl::doneWithKeyEvent(WebKit::NativeWebKeyboardEvent const&, bool) + 56\n21  WebKit                        \t       0x1b2808228 WebKit::WebPageProxy::didReceiveEvent(IPC::Connection*, WebKit::WebEventType, bool, std::__1::optional<WebCore::RemoteUserInputEventData>&&) + 1168\n22  WebKit                        \t       0x1b2293088 WebKit::WebPageProxy::didReceiveMessage(IPC::Connection&, IPC::Decoder&) + 7124\n23  WebKit                        \t       0x1b2ea8dd8 IPC::MessageReceiverMap::dispatchMessage(IPC::Connection&, IPC::Decoder&) + 264\n24  WebKit                        \t       0x1b28878b0 WebKit::WebProcessProxy::dispatchMessage(IPC::Connection&, IPC::Decoder&) + 40\n25  WebKit                        \t       0x1b22b2614 WebKit::WebProcessProxy::didReceiveMessage(IPC::Connection&, IPC::Decoder&) + 1620\n26  WebKit                        \t       0x1b2e824dc IPC::Connection::dispatchMessage(WTF::UniqueRef<IPC::Decoder>) + 300\n27  WebKit                        \t       0x1b2e82a0c IPC::Connection::dispatchIncomingMessages() + 536\n28  JavaScriptCore                \t       0x1a9e971d8 WTF::RunLoop::performWork() + 552\n29  JavaScriptCore                \t       0x1a9e98bd0 WTF::RunLoop::performWork(void*) + 36\n30  CoreFoundation                \t       0x1889789e8 __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ + 28\n31  CoreFoundation                \t       0x18897897c __CFRunLoopDoSource0 + 172\n32  CoreFoundation                \t       0x1889786e8 __CFRunLoopDoSources0 + 232\n33  CoreFoundation                \t       0x188977378 __CFRunLoopRun + 820\n34  CoreFoundation                \t       0x188a3135c _CFRunLoopRunSpecificWithOptions + 532\n35  HIToolbox                     \t       0x195434768 RunCurrentEventLoopInMode + 316\n36  HIToolbox                     \t       0x195437a90 ReceiveNextEventCommon + 488\n37  HIToolbox                     \t       0x1955c1308 _BlockUntilNextEventMatchingListInMode + 48\n38  AppKit                        \t       0x18d2883c0 _DPSBlockUntilNextEventMatchingListInMode + 236\n39  AppKit                        \t       0x18cd81e34 _DPSNextEvent + 588\n40  AppKit                        \t       0x18d84ff44 -[NSApplication(NSEventRouting) _nextEventMatchingEventMask:untilDate:inMode:dequeue:] + 688\n41  AppKit                        \t       0x18d84fc50 -[NSApplication(NSEventRouting) nextEventMatchingMask:untilDate:inMode:dequeue:] + 72\n42  AppKit                        \t       0x18cd7a780 -[NSApplication run] + 368\n43  project-graph                 \t       0x104cb392c 0x104b58000 + 1423660\n44  project-graph                 \t       0x104cb37b4 0x104b58000 + 1423284\n45  project-graph                 \t       0x104cb37a8 0x104b58000 + 1423272\n46  project-graph                 \t       0x104cb364c 0x104b58000 + 1422924\n47  project-graph                 \t       0x104cb072c 0x104b58000 + 1410860\n48  project-graph                 \t       0x104b59010 0x104b58000 + 4112\n49  project-graph                 \t       0x104b59350 0x104b58000 + 4944\n50  dyld                          \t       0x188511d54 start + 7184\n\nThread 1:: com.apple.NSEventThread\n0   libsystem_kernel.dylib        \t       0x188896c34 mach_msg2_trap + 8\n1   libsystem_kernel.dylib        \t       0x1888a9028 mach_msg2_internal + 76\n2   libsystem_kernel.dylib        \t       0x18889f98c mach_msg_overwrite + 484\n3   libsystem_kernel.dylib        \t       0x188896fb4 mach_msg + 24\n4   CoreFoundation                \t       0x188978b90 __CFRunLoopServiceMachPort + 160\n5   CoreFoundation                \t       0x1889774e8 __CFRunLoopRun + 1188\n6   CoreFoundation                \t       0x188a3135c _CFRunLoopRunSpecificWithOptions + 532\n7   AppKit                        \t       0x18ce11cb4 _NSEventThread + 184\n8   libsystem_pthread.dylib       \t       0x1888d9c08 _pthread_start + 136\n9   libsystem_pthread.dylib       \t       0x1888d4ba8 thread_start + 8\n\nThread 2:\n0   libsystem_kernel.dylib        \t       0x18889a4f8 __psynch_cvwait + 8\n1   libsystem_pthread.dylib       \t       0x1888da0dc _pthread_cond_wait + 984\n2   project-graph                 \t       0x104fc9404 0x104b58000 + 4658180\n3   project-graph                 \t       0x105023e50 0x104b58000 + 5029456\n4   project-graph                 \t       0x105023b8c 0x104b58000 + 5028748\n5   project-graph                 \t       0x104e22e8c 0x104b58000 + 2928268\n6   project-graph                 \t       0x104e22bc0 0x104b58000 + 2927552\n7   project-graph                 \t       0x104f379b4 0x104b58000 + 4061620\n8   libsystem_pthread.dylib       \t       0x1888d9c08 _pthread_start + 136\n9   libsystem_pthread.dylib       \t       0x1888d4ba8 thread_start + 8\n\nThread 3:\n0   libsystem_kernel.dylib        \t       0x18889a4f8 __psynch_cvwait + 8\n1   libsystem_pthread.dylib       \t       0x1888da0dc _pthread_cond_wait + 984\n2   project-graph                 \t       0x104fc9404 0x104b58000 + 4658180\n3   project-graph                 \t       0x105023e50 0x104b58000 + 5029456\n4   project-graph                 \t       0x105023b8c 0x104b58000 + 5028748\n5   project-graph                 \t       0x104e22e8c 0x104b58000 + 2928268\n6   project-graph                 \t       0x104e22bc0 0x104b58000 + 2927552\n7   project-graph                 \t       0x104f379b4 0x104b58000 + 4061620\n8   libsystem_pthread.dylib       \t       0x1888d9c08 _pthread_start + 136\n9   libsystem_pthread.dylib       \t       0x1888d4ba8 thread_start + 8\n\nThread 4:\n0   libsystem_kernel.dylib        \t       0x18889a4f8 __psynch_cvwait + 8\n1   libsystem_pthread.dylib       \t       0x1888da0dc _pthread_cond_wait + 984\n2   project-graph                 \t       0x104fc9404 0x104b58000 + 4658180\n3   project-graph                 \t       0x105023e50 0x104b58000 + 5029456\n4   project-graph                 \t       0x105023b8c 0x104b58000 + 5028748\n5   project-graph                 \t       0x104e22e8c 0x104b58000 + 2928268\n6   project-graph                 \t       0x104e22bc0 0x104b58000 + 2927552\n7   project-graph                 \t       0x104f379b4 0x104b58000 + 4061620\n8   libsystem_pthread.dylib       \t       0x1888d9c08 _pthread_start + 136\n9   libsystem_pthread.dylib       \t       0x1888d4ba8 thread_start + 8\n\nThread 5:\n0   libsystem_kernel.dylib        \t       0x18889a4f8 __psynch_cvwait + 8\n1   libsystem_pthread.dylib       \t       0x1888da0dc _pthread_cond_wait + 984\n2   project-graph                 \t       0x104fc9404 0x104b58000 + 4658180\n3   project-graph                 \t       0x105023e50 0x104b58000 + 5029456\n4   project-graph                 \t       0x105023b8c 0x104b58000 + 5028748\n5   project-graph                 \t       0x104e22e8c 0x104b58000 + 2928268\n6   project-graph                 \t       0x104e22bc0 0x104b58000 + 2927552\n7   project-graph                 \t       0x104f379b4 0x104b58000 + 4061620\n8   libsystem_pthread.dylib       \t       0x1888d9c08 _pthread_start + 136\n9   libsystem_pthread.dylib       \t       0x1888d4ba8 thread_start + 8\n\nThread 6:\n0   libsystem_kernel.dylib        \t       0x18889a4f8 __psynch_cvwait + 8\n1   libsystem_pthread.dylib       \t       0x1888da0dc _pthread_cond_wait + 984\n2   project-graph                 \t       0x104fc9404 0x104b58000 + 4658180\n3   project-graph                 \t       0x105023e50 0x104b58000 + 5029456\n4   project-graph                 \t       0x105023b8c 0x104b58000 + 5028748\n5   project-graph                 \t       0x104e22e8c 0x104b58000 + 2928268\n6   project-graph                 \t       0x104e22bc0 0x104b58000 + 2927552\n7   project-graph                 \t       0x104f379b4 0x104b58000 + 4061620\n8   libsystem_pthread.dylib       \t       0x1888d9c08 _pthread_start + 136\n9   libsystem_pthread.dylib       \t       0x1888d4ba8 thread_start + 8\n\nThread 7:\n0   libsystem_kernel.dylib        \t       0x18889a4f8 __psynch_cvwait + 8\n1   libsystem_pthread.dylib       \t       0x1888da0dc _pthread_cond_wait + 984\n2   project-graph                 \t       0x104fc9404 0x104b58000 + 4658180\n3   project-graph                 \t       0x105023e50 0x104b58000 + 5029456\n4   project-graph                 \t       0x105023b8c 0x104b58000 + 5028748\n5   project-graph                 \t       0x104e22e8c 0x104b58000 + 2928268\n6   project-graph                 \t       0x104e22bc0 0x104b58000 + 2927552\n7   project-graph                 \t       0x104f379b4 0x104b58000 + 4061620\n8   libsystem_pthread.dylib       \t       0x1888d9c08 _pthread_start + 136\n9   libsystem_pthread.dylib       \t       0x1888d4ba8 thread_start + 8\n\nThread 8:\n0   libsystem_kernel.dylib        \t       0x18889a4f8 __psynch_cvwait + 8\n1   libsystem_pthread.dylib       \t       0x1888da0dc _pthread_cond_wait + 984\n2   project-graph                 \t       0x104fc9404 0x104b58000 + 4658180\n3   project-graph                 \t       0x105023e50 0x104b58000 + 5029456\n4   project-graph                 \t       0x105023b8c 0x104b58000 + 5028748\n5   project-graph                 \t       0x104e22e8c 0x104b58000 + 2928268\n6   project-graph                 \t       0x104e22bc0 0x104b58000 + 2927552\n7   project-graph                 \t       0x104f379b4 0x104b58000 + 4061620\n8   libsystem_pthread.dylib       \t       0x1888d9c08 _pthread_start + 136\n9   libsystem_pthread.dylib       \t       0x1888d4ba8 thread_start + 8\n\nThread 9:\n0   libsystem_kernel.dylib        \t       0x18889a4f8 __psynch_cvwait + 8\n1   libsystem_pthread.dylib       \t       0x1888da0dc _pthread_cond_wait + 984\n2   project-graph                 \t       0x104fc9404 0x104b58000 + 4658180\n3   project-graph                 \t       0x105023e50 0x104b58000 + 5029456\n4   project-graph                 \t       0x105023b8c 0x104b58000 + 5028748\n5   project-graph                 \t       0x104e22e8c 0x104b58000 + 2928268\n6   project-graph                 \t       0x104e22bc0 0x104b58000 + 2927552\n7   project-graph                 \t       0x104f379b4 0x104b58000 + 4061620\n8   libsystem_pthread.dylib       \t       0x1888d9c08 _pthread_start + 136\n9   libsystem_pthread.dylib       \t       0x1888d4ba8 thread_start + 8\n\nThread 10:\n0   libsystem_kernel.dylib        \t       0x18889a4f8 __psynch_cvwait + 8\n1   libsystem_pthread.dylib       \t       0x1888da0dc _pthread_cond_wait + 984\n2   project-graph                 \t       0x104fc9404 0x104b58000 + 4658180\n3   project-graph                 \t       0x105023e50 0x104b58000 + 5029456\n4   project-graph                 \t       0x105023b8c 0x104b58000 + 5028748\n5   project-graph                 \t       0x104e22e8c 0x104b58000 + 2928268\n6   project-graph                 \t       0x104e22bc0 0x104b58000 + 2927552\n7   project-graph                 \t       0x104f379b4 0x104b58000 + 4061620\n8   libsystem_pthread.dylib       \t       0x1888d9c08 _pthread_start + 136\n9   libsystem_pthread.dylib       \t       0x1888d4ba8 thread_start + 8\n\nThread 11:\n0   libsystem_kernel.dylib        \t       0x18889a4f8 __psynch_cvwait + 8\n1   libsystem_pthread.dylib       \t       0x1888da0dc _pthread_cond_wait + 984\n2   project-graph                 \t       0x104fc9404 0x104b58000 + 4658180\n3   project-graph                 \t       0x105023e50 0x104b58000 + 5029456\n4   project-graph                 \t       0x105023b8c 0x104b58000 + 5028748\n5   project-graph                 \t       0x104e22e8c 0x104b58000 + 2928268\n6   project-graph                 \t       0x104e22bc0 0x104b58000 + 2927552\n7   project-graph                 \t       0x104f379b4 0x104b58000 + 4061620\n8   libsystem_pthread.dylib       \t       0x1888d9c08 _pthread_start + 136\n9   libsystem_pthread.dylib       \t       0x1888d4ba8 thread_start + 8\n\nThread 12:: WebCore: Scrolling\n0   libsystem_kernel.dylib        \t       0x188896c34 mach_msg2_trap + 8\n1   libsystem_kernel.dylib        \t       0x1888a9028 mach_msg2_internal + 76\n2   libsystem_kernel.dylib        \t       0x18889f98c mach_msg_overwrite + 484\n3   libsystem_kernel.dylib        \t       0x188896fb4 mach_msg + 24\n4   CoreFoundation                \t       0x188978b90 __CFRunLoopServiceMachPort + 160\n5   CoreFoundation                \t       0x1889774e8 __CFRunLoopRun + 1188\n6   CoreFoundation                \t       0x188a3135c _CFRunLoopRunSpecificWithOptions + 532\n7   CoreFoundation                \t       0x1889caa30 CFRunLoopRun + 64\n8   JavaScriptCore                \t       0x1a9e98198 WTF::Detail::CallableWrapper<WTF::RunLoop::create(WTF::ASCIILiteral, WTF::ThreadType, WTF::Thread::QOS)::$_0, void>::call() + 244\n9   JavaScriptCore                \t       0x1a9edae5c WTF::Thread::entryPoint(WTF::Thread::NewThreadContext*) + 260\n10  JavaScriptCore                \t       0x1a9ca5490 WTF::wtfThreadEntryPoint(void*) + 16\n11  libsystem_pthread.dylib       \t       0x1888d9c08 _pthread_start + 136\n12  libsystem_pthread.dylib       \t       0x1888d4ba8 thread_start + 8\n\nThread 13:: Log work queue\n0   libsystem_kernel.dylib        \t       0x188896bb0 semaphore_wait_trap + 8\n1   WebKit                        \t       0x1b2eae6b4 IPC::StreamConnectionWorkQueue::startProcessingThread()::$_0::operator()() + 44\n2   JavaScriptCore                \t       0x1a9edae5c WTF::Thread::entryPoint(WTF::Thread::NewThreadContext*) + 260\n3   JavaScriptCore                \t       0x1a9ca5490 WTF::wtfThreadEntryPoint(void*) + 16\n4   libsystem_pthread.dylib       \t       0x1888d9c08 _pthread_start + 136\n5   libsystem_pthread.dylib       \t       0x1888d4ba8 thread_start + 8\n\nThread 14:: tokio-runtime-worker\n0   libsystem_kernel.dylib        \t       0x18889a4f8 __psynch_cvwait + 8\n1   libsystem_pthread.dylib       \t       0x1888da0dc _pthread_cond_wait + 984\n2   project-graph                 \t       0x104fc9404 0x104b58000 + 4658180\n3   project-graph                 \t       0x104fc8830 0x104b58000 + 4655152\n4   project-graph                 \t       0x104fce4ec 0x104b58000 + 4678892\n5   project-graph                 \t       0x104fcb0a4 0x104b58000 + 4665508\n6   project-graph                 \t       0x104fcaef8 0x104b58000 + 4665080\n7   project-graph                 \t       0x104f379b4 0x104b58000 + 4061620\n8   libsystem_pthread.dylib       \t       0x1888d9c08 _pthread_start + 136\n9   libsystem_pthread.dylib       \t       0x1888d4ba8 thread_start + 8\n\nThread 15:: tokio-runtime-worker\n0   libsystem_kernel.dylib        \t       0x18889cf30 kevent + 8\n1   project-graph                 \t       0x104fca0a8 0x104b58000 + 4661416\n2   project-graph                 \t       0x104fc95c4 0x104b58000 + 4658628\n3   project-graph                 \t       0x104fc8780 0x104b58000 + 4654976\n4   project-graph                 \t       0x104fce4ec 0x104b58000 + 4678892\n5   project-graph                 \t       0x104fcb0a4 0x104b58000 + 4665508\n6   project-graph                 \t       0x104fcaef8 0x104b58000 + 4665080\n7   project-graph                 \t       0x104f379b4 0x104b58000 + 4061620\n8   libsystem_pthread.dylib       \t       0x1888d9c08 _pthread_start + 136\n9   libsystem_pthread.dylib       \t       0x1888d4ba8 thread_start + 8\n\nThread 16:: tokio-runtime-worker\n0   libsystem_kernel.dylib        \t       0x18889a4f8 __psynch_cvwait + 8\n1   libsystem_pthread.dylib       \t       0x1888da0dc _pthread_cond_wait + 984\n2   project-graph                 \t       0x104fc9404 0x104b58000 + 4658180\n3   project-graph                 \t       0x104fc8830 0x104b58000 + 4655152\n4   project-graph                 \t       0x104fce4ec 0x104b58000 + 4678892\n5   project-graph                 \t       0x104fcb0a4 0x104b58000 + 4665508\n6   project-graph                 \t       0x104fcaef8 0x104b58000 + 4665080\n7   project-graph                 \t       0x104f379b4 0x104b58000 + 4061620\n8   libsystem_pthread.dylib       \t       0x1888d9c08 _pthread_start + 136\n9   libsystem_pthread.dylib       \t       0x1888d4ba8 thread_start + 8\n\nThread 17:: tokio-runtime-worker\n0   libsystem_kernel.dylib        \t       0x18889a4f8 __psynch_cvwait + 8\n1   libsystem_pthread.dylib       \t       0x1888da0dc _pthread_cond_wait + 984\n2   project-graph                 \t       0x104fc9404 0x104b58000 + 4658180\n3   project-graph                 \t       0x104fc8830 0x104b58000 + 4655152\n4   project-graph                 \t       0x104fce4ec 0x104b58000 + 4678892\n5   project-graph                 \t       0x104fcb0a4 0x104b58000 + 4665508\n6   project-graph                 \t       0x104fcaef8 0x104b58000 + 4665080\n7   project-graph                 \t       0x104f379b4 0x104b58000 + 4061620\n8   libsystem_pthread.dylib       \t       0x1888d9c08 _pthread_start + 136\n9   libsystem_pthread.dylib       \t       0x1888d4ba8 thread_start + 8\n\nThread 18:: tokio-runtime-worker\n0   libsystem_kernel.dylib        \t       0x18889a4f8 __psynch_cvwait + 8\n1   libsystem_pthread.dylib       \t       0x1888da0dc _pthread_cond_wait + 984\n2   project-graph                 \t       0x104fc9404 0x104b58000 + 4658180\n3   project-graph                 \t       0x104fc8830 0x104b58000 + 4655152\n4   project-graph                 \t       0x104fce4ec 0x104b58000 + 4678892\n5   project-graph                 \t       0x104fcb0a4 0x104b58000 + 4665508\n6   project-graph                 \t       0x104fcaef8 0x104b58000 + 4665080\n7   project-graph                 \t       0x104f379b4 0x104b58000 + 4061620\n8   libsystem_pthread.dylib       \t       0x1888d9c08 _pthread_start + 136\n9   libsystem_pthread.dylib       \t       0x1888d4ba8 thread_start + 8\n\nThread 19:: tokio-runtime-worker\n0   libsystem_kernel.dylib        \t       0x18889a4f8 __psynch_cvwait + 8\n1   libsystem_pthread.dylib       \t       0x1888da0dc _pthread_cond_wait + 984\n2   project-graph                 \t       0x104fc9404 0x104b58000 + 4658180\n3   project-graph                 \t       0x104fc8830 0x104b58000 + 4655152\n4   project-graph                 \t       0x104fce4ec 0x104b58000 + 4678892\n5   project-graph                 \t       0x104fcb0a4 0x104b58000 + 4665508\n6   project-graph                 \t       0x104fcaef8 0x104b58000 + 4665080\n7   project-graph                 \t       0x104f379b4 0x104b58000 + 4061620\n8   libsystem_pthread.dylib       \t       0x1888d9c08 _pthread_start + 136\n9   libsystem_pthread.dylib       \t       0x1888d4ba8 thread_start + 8\n\nThread 20:: tokio-runtime-worker\n0   libsystem_kernel.dylib        \t       0x18889a4f8 __psynch_cvwait + 8\n1   libsystem_pthread.dylib       \t       0x1888da0dc _pthread_cond_wait + 984\n2   project-graph                 \t       0x104fc9404 0x104b58000 + 4658180\n3   project-graph                 \t       0x104fc8830 0x104b58000 + 4655152\n4   project-graph                 \t       0x104fce4ec 0x104b58000 + 4678892\n5   project-graph                 \t       0x104fcb0a4 0x104b58000 + 4665508\n6   project-graph                 \t       0x104fcaef8 0x104b58000 + 4665080\n7   project-graph                 \t       0x104f379b4 0x104b58000 + 4061620\n8   libsystem_pthread.dylib       \t       0x1888d9c08 _pthread_start + 136\n9   libsystem_pthread.dylib       \t       0x1888d4ba8 thread_start + 8\n\nThread 21:: tokio-runtime-worker\n0   libsystem_kernel.dylib        \t       0x18889a4f8 __psynch_cvwait + 8\n1   libsystem_pthread.dylib       \t       0x1888da0dc _pthread_cond_wait + 984\n2   project-graph                 \t       0x104fc9404 0x104b58000 + 4658180\n3   project-graph                 \t       0x104fc8830 0x104b58000 + 4655152\n4   project-graph                 \t       0x104fce4ec 0x104b58000 + 4678892\n5   project-graph                 \t       0x104fcb0a4 0x104b58000 + 4665508\n6   project-graph                 \t       0x104fcaef8 0x104b58000 + 4665080\n7   project-graph                 \t       0x104f379b4 0x104b58000 + 4061620\n8   libsystem_pthread.dylib       \t       0x1888d9c08 _pthread_start + 136\n9   libsystem_pthread.dylib       \t       0x1888d4ba8 thread_start + 8\n\nThread 22:: tokio-runtime-worker Dispatch queue: Client CFPasteboard-Apple CFPasteboard general\n0   libsystem_kernel.dylib        \t       0x188896b2c _kernelrpc_mach_port_construct_trap + 8\n1   libsystem_kernel.dylib        \t       0x188897eb8 mach_port_construct + 40\n2   libxpc.dylib                  \t       0x1885dd05c _xpc_try_mach_port_construct + 64\n3   libxpc.dylib                  \t       0x1885dd0a8 _xpc_mach_port_construct + 28\n4   libxpc.dylib                  \t       0x1885b5544 _xpc_mach_port_allocate + 36\n5   libxpc.dylib                  \t       0x1885b9bf0 xpc_connection_send_message_with_reply + 160\n6   CoreFoundation                \t       0x188a25778 -[_CFPasteboardEntry requestDataForPasteboard:generation:immediatelyAvailableResult:] + 1520\n7   CoreFoundation                \t       0x18897587c __CFPasteboardCopyData_block_invoke + 152\n8   CoreFoundation                \t       0x188a26824 ____CFPasteboardPerformOnQueue_block_invoke + 292\n9   libdispatch.dylib             \t       0x188721e1c _dispatch_block_sync_invoke + 240\n10  libdispatch.dylib             \t       0x188736ac4 _dispatch_client_callout + 16\n11  libdispatch.dylib             \t       0x18872c940 _dispatch_lane_barrier_sync_invoke_and_complete + 56\n12  libdispatch.dylib             \t       0x188723138 _dispatch_sync_block_with_privdata + 452\n13  CoreFoundation                \t       0x188975074 CFPasteboardCopyData + 652\n14  AppKit                        \t       0x18d776ad4 -[NSPasteboard _dataWithoutConversionForTypeIdentifier:index:securityScoped:] + 432\n15  AppKit                        \t       0x18d77734c -[NSPasteboard _dataForType:index:usesPboardTypes:combinesItems:securityScoped:] + 272\n16  AppKit                        \t       0x18dc4fea0 -[NSPasteboardItem __dataForType:async:completionHandler:] + 316\n17  AppKit                        \t       0x18dc4ffac -[NSPasteboardItem stringForType:] + 28\n18  project-graph                 \t       0x104ddc3f0 0x104b58000 + 2638832\n19  project-graph                 \t       0x104ddca18 0x104b58000 + 2640408\n20  project-graph                 \t       0x104fc8c0c 0x104b58000 + 4656140\n21  project-graph                 \t       0x104fce078 0x104b58000 + 4677752\n22  project-graph                 \t       0x104fcb0a4 0x104b58000 + 4665508\n23  project-graph                 \t       0x104fcaef8 0x104b58000 + 4665080\n24  project-graph                 \t       0x104f379b4 0x104b58000 + 4061620\n25  libsystem_pthread.dylib       \t       0x1888d9c08 _pthread_start + 136\n26  libsystem_pthread.dylib       \t       0x1888d4ba8 thread_start + 8\n\nThread 23:: tokio-runtime-worker\n0   libsystem_kernel.dylib        \t       0x18889a4f8 __psynch_cvwait + 8\n1   libsystem_pthread.dylib       \t       0x1888da0dc _pthread_cond_wait + 984\n2   project-graph                 \t       0x104fc9404 0x104b58000 + 4658180\n3   project-graph                 \t       0x104fc8830 0x104b58000 + 4655152\n4   project-graph                 \t       0x104fce4ec 0x104b58000 + 4678892\n5   project-graph                 \t       0x104fcb0a4 0x104b58000 + 4665508\n6   project-graph                 \t       0x104fcaef8 0x104b58000 + 4665080\n7   project-graph                 \t       0x104f379b4 0x104b58000 + 4061620\n8   libsystem_pthread.dylib       \t       0x1888d9c08 _pthread_start + 136\n9   libsystem_pthread.dylib       \t       0x1888d4ba8 thread_start + 8\n\nThread 24:: JavaScriptCore libpas scavenger\n0   libsystem_kernel.dylib        \t       0x18889a4f8 __psynch_cvwait + 8\n1   libsystem_pthread.dylib       \t       0x1888da0dc _pthread_cond_wait + 984\n2   JavaScriptCore                \t       0x1ab59be78 scavenger_thread_main + 1440\n3   libsystem_pthread.dylib       \t       0x1888d9c08 _pthread_start + 136\n4   libsystem_pthread.dylib       \t       0x1888d4ba8 thread_start + 8\n\nThread 25:: CVDisplayLink\n0   libsystem_kernel.dylib        \t       0x18889a4f8 __psynch_cvwait + 8\n1   libsystem_pthread.dylib       \t       0x1888da108 _pthread_cond_wait + 1028\n2   CoreVideo                     \t       0x192ee9b3c CVDisplayLink::waitUntil(unsigned long long) + 336\n3   CoreVideo                     \t       0x192ee8c24 CVDisplayLink::runIOThread() + 500\n4   libsystem_pthread.dylib       \t       0x1888d9c08 _pthread_start + 136\n5   libsystem_pthread.dylib       \t       0x1888d4ba8 thread_start + 8\n\nThread 26:\n\nThread 27:\n\nThread 28:\n\nThread 29:\n\n\nThread 0 crashed with ARM Thread State (64-bit):\n    x0: 0x000000080c0228b0   x1: 0x00000002034a58e8   x2: 0x000000016b2a1320   x3: 0x000000016b2a13a0\n    x4: 0x0000000000000010   x5: 0x0000000000000000   x6: 0xffffffffbfc007ff   x7: 0xfffff0003ffff800\n    x8: 0x3d2d25f44434003c   x9: 0x0000000202feace8  x10: 0x6ae100080c0228b0  x11: 0x000000000000007f\n   x12: 0x0000000000000031  x13: 0x000000080dc25b00  x14: 0x4d3aea405a144595  x15: 0x003aea405a144590\n   x16: 0x003aea405a144590  x17: 0x00000001f6895ca0  x18: 0x0000000000000000  x19: 0x000000080d0d03c0\n   x20: 0x0000000000000001  x21: 0x0000000000000000  x22: 0x000000080c0228b0  x23: 0x000000080c0228b0\n   x24: 0x000000080c0223d0  x25: 0x0000000000000001  x26: 0x00000001f6900978  x27: 0x0000000000000001\n   x28: 0x0000000100000002   fp: 0x000000016b2a1480   lr: 0x000000018cdd812c\n    sp: 0x000000016b2a12f0   pc: 0x000000018848d820 cpsr: 0x20000000\n   far: 0x003aea405a1445a0  esr: 0x92000004 (Data Abort) byte read Translation fault\n\nBinary Images:\n       0x104b58000 -        0x1053dffff liren.project-graph (2.9.13) <946c435c-f6bb-3969-9092-d73ba4a59315> /Applications/Project Graph.app/Contents/MacOS/project-graph\n       0x1171b4000 -        0x1171bffff libobjc-trampolines.dylib (*) <f8bd9069-8c4f-37ea-af9a-2b1060f54e4f> /usr/lib/libobjc-trampolines.dylib\n       0x1191b8000 -        0x1199dffff com.apple.AGXMetalG16G-B0 (341.11) <a22549f3-d4f5-3b88-af18-e06837f0d352> /System/Library/Extensions/AGXMetalG16G_B0.bundle/Contents/MacOS/AGXMetalG16G_B0\n       0x118760000 -        0x1187c3fff com.apple.AppleMetalOpenGLRenderer (1.0) <7fba6cd5-06ae-37aa-aa67-580c920ea69d> /System/Library/Extensions/AppleMetalOpenGLRenderer.bundle/Contents/MacOS/AppleMetalOpenGLRenderer\n       0x188484000 -        0x1884d748b libobjc.A.dylib (*) <5a0aab4e-0c1a-3680-82c9-43dc4007a6e7> /usr/lib/libobjc.A.dylib\n       0x18cd62000 -        0x18e48eb9f com.apple.AppKit (6.9) <3c0949bb-e361-369a-80b7-17440eb09e98> /System/Library/Frameworks/AppKit.framework/Versions/C/AppKit\n       0x1adf91000 -        0x1b16a45df com.apple.WebCore (21622) <ccd2dfa6-ae82-311f-b824-a9aad0a6f12e> /System/Library/Frameworks/WebKit.framework/Versions/A/Frameworks/WebCore.framework/Versions/A/WebCore\n       0x1b1d8c000 -        0x1b32a68bf com.apple.WebKit (21622) <3b55482a-efe2-35a7-b1c9-3f41a823a30b> /System/Library/Frameworks/WebKit.framework/Versions/A/WebKit\n       0x1a9c9e000 -        0x1ab7bce7f com.apple.JavaScriptCore (21622) <c79071c9-db50-3264-a316-94abd0d3b9a9> /System/Library/Frameworks/JavaScriptCore.framework/Versions/A/JavaScriptCore\n       0x188919000 -        0x188e5fabf com.apple.CoreFoundation (6.9) <3c4a3add-9e48-33da-82ee-80520e6cbe3b> /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation\n       0x195373000 -        0x1956757ff com.apple.HIToolbox (2.1.1) <9ab64c08-0685-3a0d-9a7e-83e7a1e9ebb4> /System/Library/Frameworks/Carbon.framework/Versions/A/Frameworks/HIToolbox.framework/Versions/A/HIToolbox\n       0x188509000 -        0x1885a7f63 dyld (*) <b50f5a1a-be81-3068-92e1-3554f2be478a> /usr/lib/dyld\n               0x0 - 0xffffffffffffffff ??? (*) <00000000-0000-0000-0000-000000000000> ???\n       0x188896000 -        0x1888d249f libsystem_kernel.dylib (*) <9fe7c84d-0c2b-363f-bee5-6fd76d67a227> /usr/lib/system/libsystem_kernel.dylib\n       0x1888d3000 -        0x1888dfabb libsystem_pthread.dylib (*) <e95973b8-824c-361e-adf4-390747c40897> /usr/lib/system/libsystem_pthread.dylib\n       0x1885ac000 -        0x188600f7f libxpc.dylib (*) <8346be50-de08-3606-9fb6-9a352975661d> /usr/lib/system/libxpc.dylib\n       0x18871b000 -        0x188761e9f libdispatch.dylib (*) <8fb392ae-401f-399a-96ae-41531cf91162> /usr/lib/system/libdispatch.dylib\n       0x192ee6000 -        0x192f69fff com.apple.CoreVideo (1.8) <d8605842-8c6c-36d7-820d-2132d91e0c06> /System/Library/Frameworks/CoreVideo.framework/Versions/A/CoreVideo\n\nExternal Modification Summary:\n  Calls made by other processes targeting this process:\n    task_for_pid: 0\n    thread_create: 0\n    thread_set_state: 0\n  Calls made by this process:\n    task_for_pid: 0\n    thread_create: 0\n    thread_set_state: 0\n  Calls made by all processes on this machine:\n    task_for_pid: 0\n    thread_create: 0\n    thread_set_state: 0\n\nVM Region Summary:\nReadOnly portion of Libraries: Total=1.8G resident=0K(0%) swapped_out_or_unallocated=1.8G(100%)\nWritable regions: Total=4.7G written=1841K(0%) resident=945K(0%) swapped_out=896K(0%) unallocated=4.7G(100%)\n\n                                VIRTUAL   REGION \nREGION TYPE                        SIZE    COUNT (non-coalesced) \n===========                     =======  ======= \nActivity Tracing                   256K        1 \nColorSync                           32K        2 \nCoreAnimation                      912K       57 \nCoreGraphics                        64K        4 \nCoreUI image data                  240K        2 \nFoundation                          48K        2 \nKernel Alloc Once                   32K        1 \nMALLOC                           268.9M       32 \nMALLOC guard page                 3120K        4 \nSTACK GUARD                        464K       29 \nStack                             53.4M       30 \nStack Guard                       56.0M        1 \nVM_ALLOCATE                       4880K       41 \nVM_ALLOCATE (reserved)             4.0G       21         reserved VM address space (unallocated)\nWebKit Malloc                    400.2M        9 \n__AUTH                            5853K      652 \n__AUTH_CONST                      88.8M     1037 \n__CTF                               824        1 \n__DATA                            30.0M      988 \n__DATA_CONST                      33.2M     1046 \n__DATA_DIRTY                      8836K      898 \n__FONT_DATA                        2352        1 \n__GLSLBUILTINS                    5174K        1 \n__INFO_FILTER                         8        1 \n__LINKEDIT                       594.2M        5 \n__OBJC_RO                         78.3M        1 \n__OBJC_RW                         2567K        1 \n__TEXT                             1.2G     1069 \n__TPRO_CONST                       128K        2 \nmapped file                      600.6M       75 \npage table in kernel               945K        1 \nshared memory                      912K       15 \n===========                     =======  ======= \nTOTAL                              7.4G     6030 \nTOTAL, minus reserved VM space     3.4G     6030 \n\n\n-----------\nFull Report\n-----------\n\n{\"app_name\":\"project-graph\",\"timestamp\":\"2026-02-13 15:56:49.00 +0800\",\"app_version\":\"2.9.13\",\"slice_uuid\":\"946c435c-f6bb-3969-9092-d73ba4a59315\",\"build_version\":\"2.9.13\",\"platform\":1,\"bundleID\":\"liren.project-graph\",\"share_with_app_devs\":0,\"is_first_party\":0,\"bug_type\":\"309\",\"os_version\":\"macOS 26.1 (25B78)\",\"roots_installed\":0,\"name\":\"project-graph\",\"incident_id\":\"2CD58038-FC0A-4005-9C57-8CF12FA876D5\"}\n{\n  \"uptime\" : 240000,\n  \"procRole\" : \"Foreground\",\n  \"version\" : 2,\n  \"userID\" : 501,\n  \"deployVersion\" : 210,\n  \"modelCode\" : \"Mac16,12\",\n  \"coalitionID\" : 1342,\n  \"osVersion\" : {\n    \"train\" : \"macOS 26.1\",\n    \"build\" : \"25B78\",\n    \"releaseType\" : \"User\"\n  },\n  \"captureTime\" : \"2026-02-13 15:56:44.8734 +0800\",\n  \"codeSigningMonitor\" : 2,\n  \"incident\" : \"2CD58038-FC0A-4005-9C57-8CF12FA876D5\",\n  \"pid\" : 68049,\n  \"translated\" : false,\n  \"cpuType\" : \"ARM-64\",\n  \"roots_installed\" : 0,\n  \"bug_type\" : \"309\",\n  \"procLaunch\" : \"2026-02-13 09:40:05.9876 +0800\",\n  \"procStartAbsTime\" : 5425199234391,\n  \"procExitAbsTime\" : 5967573695900,\n  \"procName\" : \"project-graph\",\n  \"procPath\" : \"\\/Applications\\/Project Graph.app\\/Contents\\/MacOS\\/project-graph\",\n  \"bundleInfo\" : {\"CFBundleShortVersionString\":\"2.9.13\",\"CFBundleVersion\":\"2.9.13\",\"CFBundleIdentifier\":\"liren.project-graph\"},\n  \"storeInfo\" : {\"deviceIdentifierForVendor\":\"78F3C8D6-5DAC-5F0A-85D3-F7225CCAD22B\",\"thirdParty\":true},\n  \"parentProc\" : \"launchd\",\n  \"parentPid\" : 1,\n  \"coalitionName\" : \"liren.project-graph\",\n  \"crashReporterKey\" : \"B6C57097-5B06-A3FA-7F07-E57289B4D2B6\",\n  \"appleIntelligenceStatus\" : {\"reasons\":[\"regionIneligible\",\"countryBillingIneligible\",\"countryLocationIneligible\"],\"state\":\"unavailable\"},\n  \"developerMode\" : 1,\n  \"codeSigningID\" : \"project_graph-908b4450466f1c40\",\n  \"codeSigningTeamID\" : \"\",\n  \"codeSigningFlags\" : 570556929,\n  \"codeSigningValidationCategory\" : 10,\n  \"codeSigningTrustLevel\" : 4294967295,\n  \"codeSigningAuxiliaryInfo\" : 0,\n  \"instructionByteStream\" : {\"beforePC\":\"HyAD1R8gA9UfAADx7QMAVA4AQPnQzX2S6gMAqipc7fJQGcHa7wMQqg==\",\"atPC\":\"CgpA+Uv9cNNKvUCSLBxByowBCwpNEQyLsSX\\/qD8BAeuBAABUSgEByg==\"},\n  \"bootSessionUUID\" : \"56C16D5F-59C7-4085-A8AE-12AE71F4376E\",\n  \"wakeTime\" : 23429,\n  \"sleepWakeUUID\" : \"5946CB7E-C783-40E2-ABF2-93A7C8E35F2C\",\n  \"sip\" : \"enabled\",\n  \"vmRegionInfo\" : \"0x6a405a1445a0 is not in any region.  \\n      REGION TYPE                    START - END         [ VSIZE] PRT\\/MAX SHRMOD  REGION DETAIL\\n      UNUSED SPACE AT START\\n--->  \\n      UNUSED SPACE AT END\",\n  \"exception\" : {\"codes\":\"0x0000000000000001, 0x003aea405a1445a0\",\"rawCodes\":[1,16583110759302560],\"type\":\"EXC_BAD_ACCESS\",\"signal\":\"SIGSEGV\",\"subtype\":\"KERN_INVALID_ADDRESS at 0x003aea405a1445a0\"},\n  \"termination\" : {\"flags\":0,\"code\":11,\"namespace\":\"SIGNAL\",\"indicator\":\"Segmentation fault: 11\",\"byProc\":\"exc handler\",\"byPid\":68049},\n  \"vmregioninfo\" : \"0x6a405a1445a0 is not in any region.  \\n      REGION TYPE                    START - END         [ VSIZE] PRT\\/MAX SHRMOD  REGION DETAIL\\n      UNUSED SPACE AT START\\n--->  \\n      UNUSED SPACE AT END\",\n  \"extMods\" : {\"caller\":{\"thread_create\":0,\"thread_set_state\":0,\"task_for_pid\":0},\"system\":{\"thread_create\":0,\"thread_set_state\":0,\"task_for_pid\":0},\"targeted\":{\"thread_create\":0,\"thread_set_state\":0,\"task_for_pid\":0},\"warnings\":0},\n  \"faultingThread\" : 0,\n  \"threads\" : [{\"threadState\":{\"x\":[{\"value\":34561206448},{\"value\":8645138664,\"objc-selector\":\"countByEnumeratingWithState:objects:count:\"},{\"value\":6092886816},{\"value\":6092886944},{\"value\":16},{\"value\":0},{\"value\":18446744072631617535},{\"value\":18446726482597246976},{\"value\":4408221341312090172},{\"value\":8640179432,\"objc-selector\":\"retain\"},{\"value\":7701436872341465264},{\"value\":127},{\"value\":49},{\"value\":34590579456},{\"value\":5565017851679753621},{\"value\":16583110759302544},{\"value\":16583110759302544},{\"value\":8431164576},{\"value\":0},{\"value\":34578695104},{\"value\":1},{\"value\":0},{\"value\":34561206448},{\"value\":34561206448},{\"value\":34561205200},{\"value\":1},{\"value\":8431602040},{\"value\":1},{\"value\":4294967298}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":6658294060},\"cpsr\":{\"value\":536870912},\"fp\":{\"value\":6092887168},\"sp\":{\"value\":6092886768},\"esr\":{\"value\":2449473540,\"description\":\"(Data Abort) byte read Translation fault\"},\"pc\":{\"value\":6581442592,\"matchesCrashFrame\":1},\"far\":{\"value\":16583110759302560}},\"id\":6246490,\"triggered\":true,\"name\":\"main\",\"queue\":\"com.apple.main-thread\",\"frames\":[{\"imageOffset\":38944,\"symbol\":\"objc_msgSend\",\"symbolLocation\":32,\"imageIndex\":4},{\"imageOffset\":483628,\"symbol\":\"-[NSPasteboard _updateTypeCacheIfNeeded]\",\"symbolLocation\":1064,\"imageIndex\":5},{\"imageOffset\":10569760,\"symbol\":\"-[NSPasteboard _typesAtIndex:combinesItems:]\",\"symbolLocation\":36,\"imageIndex\":5},{\"imageOffset\":20167404,\"symbol\":\"WebCore::PlatformPasteboard::informationForItemAtIndex(unsigned long, long long)\",\"symbolLocation\":172,\"imageIndex\":6},{\"imageOffset\":16918196,\"symbol\":\"WebCore::PlatformPasteboard::allPasteboardItemInfo(long long)\",\"symbolLocation\":204,\"imageIndex\":6},{\"imageOffset\":7861628,\"symbol\":\"WebKit::WebPasteboardProxy::grantAccessToCurrentData(WebKit::WebProcessProxy&, WTF::String const&, WTF::CompletionHandler<void ()>&&)\",\"symbolLocation\":92,\"imageIndex\":7},{\"imageOffset\":7526308,\"symbol\":\"WebKit::WebPageProxy::grantAccessToCurrentPasteboardData(WTF::String const&, WTF::CompletionHandler<void ()>&&, std::__1::optional<WTF::ObjectIdentifierGeneric<WebCore::FrameIdentifierType, WTF::ObjectIdentifierMainThreadAccessTraits<unsigned long long>, unsigned long long>>)\",\"symbolLocation\":120,\"imageIndex\":7},{\"imageOffset\":9383596,\"symbol\":\"WebKit::WebPageProxy::willPerformPasteCommand(WebCore::DOMPasteAccessCategory, WTF::CompletionHandler<void ()>&&, std::__1::optional<WTF::ObjectIdentifierGeneric<WebCore::FrameIdentifierType, WTF::ObjectIdentifierMainThreadAccessTraits<unsigned long long>, unsigned long long>>)\",\"symbolLocation\":96,\"imageIndex\":7},{\"imageOffset\":10832272,\"symbol\":\"WebKit::WebPageProxy::executeEditCommand(WTF::String const&, WTF::String const&)\",\"symbolLocation\":352,\"imageIndex\":7},{\"imageOffset\":9467432,\"symbol\":\"WebKit::WebViewImpl::executeEditCommandForSelector(objc_selector*, WTF::String const&)\",\"symbolLocation\":56,\"imageIndex\":7},{\"imageOffset\":6470696,\"symbol\":\"-[WKWebView(WKImplementationMac) paste:]\",\"symbolLocation\":44,\"imageIndex\":7},{\"imageOffset\":11469060,\"symbol\":\"-[NSApplication(NSResponder) sendAction:to:from:]\",\"symbolLocation\":560,\"imageIndex\":5},{\"imageOffset\":9806660,\"symbol\":\"-[NSMenuItem _corePerformAction:]\",\"symbolLocation\":540,\"imageIndex\":5},{\"imageOffset\":11660924,\"symbol\":\"_NSMenuPerformActionWithHighlighting\",\"symbolLocation\":160,\"imageIndex\":5},{\"imageOffset\":9704484,\"symbol\":\"-[NSMenu _performKeyEquivalentForItemAtIndex:]\",\"symbolLocation\":172,\"imageIndex\":5},{\"imageOffset\":9703528,\"symbol\":\"-[NSMenu performKeyEquivalent:]\",\"symbolLocation\":356,\"imageIndex\":5},{\"imageOffset\":11463324,\"symbol\":\"routeKeyEquivalent\",\"symbolLocation\":444,\"imageIndex\":5},{\"imageOffset\":11455696,\"symbol\":\"-[NSApplication(NSEventRouting) sendEvent:]\",\"symbolLocation\":1844,\"imageIndex\":5},{\"imageOffset\":4153236,\"imageIndex\":0},{\"imageOffset\":9507240,\"symbol\":\"WebKit::WebViewImpl::doneWithKeyEvent(NSEvent*, bool)\",\"symbolLocation\":168,\"imageIndex\":7},{\"imageOffset\":229532,\"symbol\":\"WebKit::PageClientImpl::doneWithKeyEvent(WebKit::NativeWebKeyboardEvent const&, bool)\",\"symbolLocation\":56,\"imageIndex\":7},{\"imageOffset\":10994216,\"symbol\":\"WebKit::WebPageProxy::didReceiveEvent(IPC::Connection*, WebKit::WebEventType, bool, std::__1::optional<WebCore::RemoteUserInputEventData>&&)\",\"symbolLocation\":1168,\"imageIndex\":7},{\"imageOffset\":5271688,\"symbol\":\"WebKit::WebPageProxy::didReceiveMessage(IPC::Connection&, IPC::Decoder&)\",\"symbolLocation\":7124,\"imageIndex\":7},{\"imageOffset\":17944024,\"symbol\":\"IPC::MessageReceiverMap::dispatchMessage(IPC::Connection&, IPC::Decoder&)\",\"symbolLocation\":264,\"imageIndex\":7},{\"imageOffset\":11516080,\"symbol\":\"WebKit::WebProcessProxy::dispatchMessage(IPC::Connection&, IPC::Decoder&)\",\"symbolLocation\":40,\"imageIndex\":7},{\"imageOffset\":5400084,\"symbol\":\"WebKit::WebProcessProxy::didReceiveMessage(IPC::Connection&, IPC::Decoder&)\",\"symbolLocation\":1620,\"imageIndex\":7},{\"imageOffset\":17786076,\"symbol\":\"IPC::Connection::dispatchMessage(WTF::UniqueRef<IPC::Decoder>)\",\"symbolLocation\":300,\"imageIndex\":7},{\"imageOffset\":17787404,\"symbol\":\"IPC::Connection::dispatchIncomingMessages()\",\"symbolLocation\":536,\"imageIndex\":7},{\"imageOffset\":2068952,\"symbol\":\"WTF::RunLoop::performWork()\",\"symbolLocation\":552,\"imageIndex\":8},{\"imageOffset\":2075600,\"symbol\":\"WTF::RunLoop::performWork(void*)\",\"symbolLocation\":36,\"imageIndex\":8},{\"imageOffset\":391656,\"symbol\":\"__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__\",\"symbolLocation\":28,\"imageIndex\":9},{\"imageOffset\":391548,\"symbol\":\"__CFRunLoopDoSource0\",\"symbolLocation\":172,\"imageIndex\":9},{\"imageOffset\":390888,\"symbol\":\"__CFRunLoopDoSources0\",\"symbolLocation\":232,\"imageIndex\":9},{\"imageOffset\":385912,\"symbol\":\"__CFRunLoopRun\",\"symbolLocation\":820,\"imageIndex\":9},{\"imageOffset\":1147740,\"symbol\":\"_CFRunLoopRunSpecificWithOptions\",\"symbolLocation\":532,\"imageIndex\":9},{\"imageOffset\":792424,\"symbol\":\"RunCurrentEventLoopInMode\",\"symbolLocation\":316,\"imageIndex\":10},{\"imageOffset\":805520,\"symbol\":\"ReceiveNextEventCommon\",\"symbolLocation\":488,\"imageIndex\":10},{\"imageOffset\":2417416,\"symbol\":\"_BlockUntilNextEventMatchingListInMode\",\"symbolLocation\":48,\"imageIndex\":10},{\"imageOffset\":5399488,\"symbol\":\"_DPSBlockUntilNextEventMatchingListInMode\",\"symbolLocation\":236,\"imageIndex\":5},{\"imageOffset\":130612,\"symbol\":\"_DPSNextEvent\",\"symbolLocation\":588,\"imageIndex\":5},{\"imageOffset\":11460420,\"symbol\":\"-[NSApplication(NSEventRouting) _nextEventMatchingEventMask:untilDate:inMode:dequeue:]\",\"symbolLocation\":688,\"imageIndex\":5},{\"imageOffset\":11459664,\"symbol\":\"-[NSApplication(NSEventRouting) nextEventMatchingMask:untilDate:inMode:dequeue:]\",\"symbolLocation\":72,\"imageIndex\":5},{\"imageOffset\":100224,\"symbol\":\"-[NSApplication run]\",\"symbolLocation\":368,\"imageIndex\":5},{\"imageOffset\":1423660,\"imageIndex\":0},{\"imageOffset\":1423284,\"imageIndex\":0},{\"imageOffset\":1423272,\"imageIndex\":0},{\"imageOffset\":1422924,\"imageIndex\":0},{\"imageOffset\":1410860,\"imageIndex\":0},{\"imageOffset\":4112,\"imageIndex\":0},{\"imageOffset\":4944,\"imageIndex\":0},{\"imageOffset\":36180,\"symbol\":\"start\",\"symbolLocation\":7184,\"imageIndex\":11}]},{\"id\":6246504,\"name\":\"com.apple.NSEventThread\",\"threadState\":{\"x\":[{\"value\":268451845},{\"value\":21592279046},{\"value\":8589934592},{\"value\":98968931401728},{\"value\":0},{\"value\":98968931401728},{\"value\":2},{\"value\":4294967295},{\"value\":0},{\"value\":17179869184},{\"value\":0},{\"value\":2},{\"value\":0},{\"value\":0},{\"value\":23043},{\"value\":0},{\"value\":18446744073709551569},{\"value\":8431213296},{\"value\":0},{\"value\":4294967295},{\"value\":2},{\"value\":98968931401728},{\"value\":0},{\"value\":98968931401728},{\"value\":6095184008},{\"value\":8589934592},{\"value\":21592279046},{\"value\":18446744073709550527},{\"value\":4412409862}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":6585749544},\"cpsr\":{\"value\":0},\"fp\":{\"value\":6095183856},\"sp\":{\"value\":6095183776},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585674804},\"far\":{\"value\":0}},\"frames\":[{\"imageOffset\":3124,\"symbol\":\"mach_msg2_trap\",\"symbolLocation\":8,\"imageIndex\":13},{\"imageOffset\":77864,\"symbol\":\"mach_msg2_internal\",\"symbolLocation\":76,\"imageIndex\":13},{\"imageOffset\":39308,\"symbol\":\"mach_msg_overwrite\",\"symbolLocation\":484,\"imageIndex\":13},{\"imageOffset\":4020,\"symbol\":\"mach_msg\",\"symbolLocation\":24,\"imageIndex\":13},{\"imageOffset\":392080,\"symbol\":\"__CFRunLoopServiceMachPort\",\"symbolLocation\":160,\"imageIndex\":9},{\"imageOffset\":386280,\"symbol\":\"__CFRunLoopRun\",\"symbolLocation\":1188,\"imageIndex\":9},{\"imageOffset\":1147740,\"symbol\":\"_CFRunLoopRunSpecificWithOptions\",\"symbolLocation\":532,\"imageIndex\":9},{\"imageOffset\":720052,\"symbol\":\"_NSEventThread\",\"symbolLocation\":184,\"imageIndex\":5},{\"imageOffset\":27656,\"symbol\":\"_pthread_start\",\"symbolLocation\":136,\"imageIndex\":14},{\"imageOffset\":7080,\"symbol\":\"thread_start\",\"symbolLocation\":8,\"imageIndex\":14}]},{\"id\":6246508,\"frames\":[{\"imageOffset\":17656,\"symbol\":\"__psynch_cvwait\",\"symbolLocation\":8,\"imageIndex\":13},{\"imageOffset\":28892,\"symbol\":\"_pthread_cond_wait\",\"symbolLocation\":984,\"imageIndex\":14},{\"imageOffset\":4658180,\"imageIndex\":0},{\"imageOffset\":5029456,\"imageIndex\":0},{\"imageOffset\":5028748,\"imageIndex\":0},{\"imageOffset\":2928268,\"imageIndex\":0},{\"imageOffset\":2927552,\"imageIndex\":0},{\"imageOffset\":4061620,\"imageIndex\":0},{\"imageOffset\":27656,\"symbol\":\"_pthread_start\",\"symbolLocation\":136,\"imageIndex\":14},{\"imageOffset\":7080,\"symbol\":\"thread_start\",\"symbolLocation\":8,\"imageIndex\":14}],\"threadState\":{\"x\":[{\"value\":260},{\"value\":0},{\"value\":256},{\"value\":0},{\"value\":0},{\"value\":160},{\"value\":0},{\"value\":0},{\"value\":6097332792},{\"value\":0},{\"value\":0},{\"value\":2},{\"value\":2},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":305},{\"value\":8431211416},{\"value\":0},{\"value\":34570813760},{\"value\":34574244080},{\"value\":6097334496},{\"value\":0},{\"value\":0},{\"value\":256},{\"value\":257},{\"value\":512},{\"value\":0},{\"value\":4389625392}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":6585950428},\"cpsr\":{\"value\":1610612736},\"fp\":{\"value\":6097332912},\"sp\":{\"value\":6097332768},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585689336},\"far\":{\"value\":0}}},{\"id\":6246509,\"frames\":[{\"imageOffset\":17656,\"symbol\":\"__psynch_cvwait\",\"symbolLocation\":8,\"imageIndex\":13},{\"imageOffset\":28892,\"symbol\":\"_pthread_cond_wait\",\"symbolLocation\":984,\"imageIndex\":14},{\"imageOffset\":4658180,\"imageIndex\":0},{\"imageOffset\":5029456,\"imageIndex\":0},{\"imageOffset\":5028748,\"imageIndex\":0},{\"imageOffset\":2928268,\"imageIndex\":0},{\"imageOffset\":2927552,\"imageIndex\":0},{\"imageOffset\":4061620,\"imageIndex\":0},{\"imageOffset\":27656,\"symbol\":\"_pthread_start\",\"symbolLocation\":136,\"imageIndex\":14},{\"imageOffset\":7080,\"symbol\":\"thread_start\",\"symbolLocation\":8,\"imageIndex\":14}],\"threadState\":{\"x\":[{\"value\":260},{\"value\":0},{\"value\":256},{\"value\":0},{\"value\":0},{\"value\":160},{\"value\":0},{\"value\":0},{\"value\":6099479096},{\"value\":0},{\"value\":0},{\"value\":2},{\"value\":2},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":305},{\"value\":8431211416},{\"value\":0},{\"value\":34570813952},{\"value\":34574244224},{\"value\":6099480800},{\"value\":0},{\"value\":0},{\"value\":256},{\"value\":257},{\"value\":512},{\"value\":3},{\"value\":4389625392}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":6585950428},\"cpsr\":{\"value\":1610612736},\"fp\":{\"value\":6099479216},\"sp\":{\"value\":6099479072},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585689336},\"far\":{\"value\":0}}},{\"id\":6246510,\"frames\":[{\"imageOffset\":17656,\"symbol\":\"__psynch_cvwait\",\"symbolLocation\":8,\"imageIndex\":13},{\"imageOffset\":28892,\"symbol\":\"_pthread_cond_wait\",\"symbolLocation\":984,\"imageIndex\":14},{\"imageOffset\":4658180,\"imageIndex\":0},{\"imageOffset\":5029456,\"imageIndex\":0},{\"imageOffset\":5028748,\"imageIndex\":0},{\"imageOffset\":2928268,\"imageIndex\":0},{\"imageOffset\":2927552,\"imageIndex\":0},{\"imageOffset\":4061620,\"imageIndex\":0},{\"imageOffset\":27656,\"symbol\":\"_pthread_start\",\"symbolLocation\":136,\"imageIndex\":14},{\"imageOffset\":7080,\"symbol\":\"thread_start\",\"symbolLocation\":8,\"imageIndex\":14}],\"threadState\":{\"x\":[{\"value\":260},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":160},{\"value\":0},{\"value\":0},{\"value\":6101625400},{\"value\":0},{\"value\":0},{\"value\":2},{\"value\":2},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":305},{\"value\":8431211416},{\"value\":0},{\"value\":34570814016},{\"value\":34575026144},{\"value\":6101627104},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":1},{\"value\":256},{\"value\":2},{\"value\":4389625392}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":6585950428},\"cpsr\":{\"value\":1610612736},\"fp\":{\"value\":6101625520},\"sp\":{\"value\":6101625376},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585689336},\"far\":{\"value\":0}}},{\"id\":6246511,\"frames\":[{\"imageOffset\":17656,\"symbol\":\"__psynch_cvwait\",\"symbolLocation\":8,\"imageIndex\":13},{\"imageOffset\":28892,\"symbol\":\"_pthread_cond_wait\",\"symbolLocation\":984,\"imageIndex\":14},{\"imageOffset\":4658180,\"imageIndex\":0},{\"imageOffset\":5029456,\"imageIndex\":0},{\"imageOffset\":5028748,\"imageIndex\":0},{\"imageOffset\":2928268,\"imageIndex\":0},{\"imageOffset\":2927552,\"imageIndex\":0},{\"imageOffset\":4061620,\"imageIndex\":0},{\"imageOffset\":27656,\"symbol\":\"_pthread_start\",\"symbolLocation\":136,\"imageIndex\":14},{\"imageOffset\":7080,\"symbol\":\"thread_start\",\"symbolLocation\":8,\"imageIndex\":14}],\"threadState\":{\"x\":[{\"value\":260},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":160},{\"value\":0},{\"value\":0},{\"value\":6103771704},{\"value\":0},{\"value\":0},{\"value\":2},{\"value\":2},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":305},{\"value\":8431211416},{\"value\":0},{\"value\":34570814080},{\"value\":34575025664},{\"value\":6103773408},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":1},{\"value\":256},{\"value\":5},{\"value\":4389625392}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":6585950428},\"cpsr\":{\"value\":1610612736},\"fp\":{\"value\":6103771824},\"sp\":{\"value\":6103771680},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585689336},\"far\":{\"value\":0}}},{\"id\":6246512,\"frames\":[{\"imageOffset\":17656,\"symbol\":\"__psynch_cvwait\",\"symbolLocation\":8,\"imageIndex\":13},{\"imageOffset\":28892,\"symbol\":\"_pthread_cond_wait\",\"symbolLocation\":984,\"imageIndex\":14},{\"imageOffset\":4658180,\"imageIndex\":0},{\"imageOffset\":5029456,\"imageIndex\":0},{\"imageOffset\":5028748,\"imageIndex\":0},{\"imageOffset\":2928268,\"imageIndex\":0},{\"imageOffset\":2927552,\"imageIndex\":0},{\"imageOffset\":4061620,\"imageIndex\":0},{\"imageOffset\":27656,\"symbol\":\"_pthread_start\",\"symbolLocation\":136,\"imageIndex\":14},{\"imageOffset\":7080,\"symbol\":\"thread_start\",\"symbolLocation\":8,\"imageIndex\":14}],\"threadState\":{\"x\":[{\"value\":260},{\"value\":0},{\"value\":512},{\"value\":0},{\"value\":0},{\"value\":160},{\"value\":0},{\"value\":0},{\"value\":6105918008},{\"value\":0},{\"value\":512},{\"value\":2199023256066},{\"value\":2199023256066},{\"value\":512},{\"value\":0},{\"value\":2199023256064},{\"value\":305},{\"value\":8431211416},{\"value\":0},{\"value\":34570949440},{\"value\":34574242928},{\"value\":6105919712},{\"value\":0},{\"value\":0},{\"value\":512},{\"value\":513},{\"value\":768},{\"value\":3},{\"value\":4389625392}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":6585950428},\"cpsr\":{\"value\":1610612736},\"fp\":{\"value\":6105918128},\"sp\":{\"value\":6105917984},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585689336},\"far\":{\"value\":0}}},{\"id\":6246513,\"frames\":[{\"imageOffset\":17656,\"symbol\":\"__psynch_cvwait\",\"symbolLocation\":8,\"imageIndex\":13},{\"imageOffset\":28892,\"symbol\":\"_pthread_cond_wait\",\"symbolLocation\":984,\"imageIndex\":14},{\"imageOffset\":4658180,\"imageIndex\":0},{\"imageOffset\":5029456,\"imageIndex\":0},{\"imageOffset\":5028748,\"imageIndex\":0},{\"imageOffset\":2928268,\"imageIndex\":0},{\"imageOffset\":2927552,\"imageIndex\":0},{\"imageOffset\":4061620,\"imageIndex\":0},{\"imageOffset\":27656,\"symbol\":\"_pthread_start\",\"symbolLocation\":136,\"imageIndex\":14},{\"imageOffset\":7080,\"symbol\":\"thread_start\",\"symbolLocation\":8,\"imageIndex\":14}],\"threadState\":{\"x\":[{\"value\":260},{\"value\":0},{\"value\":256},{\"value\":0},{\"value\":0},{\"value\":160},{\"value\":0},{\"value\":0},{\"value\":6108064312},{\"value\":0},{\"value\":0},{\"value\":2},{\"value\":2},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":305},{\"value\":8431211416},{\"value\":0},{\"value\":34570814144},{\"value\":34575025808},{\"value\":6108066016},{\"value\":0},{\"value\":0},{\"value\":256},{\"value\":257},{\"value\":512},{\"value\":8},{\"value\":4389625392}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":6585950428},\"cpsr\":{\"value\":1610612736},\"fp\":{\"value\":6108064432},\"sp\":{\"value\":6108064288},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585689336},\"far\":{\"value\":0}}},{\"id\":6246514,\"frames\":[{\"imageOffset\":17656,\"symbol\":\"__psynch_cvwait\",\"symbolLocation\":8,\"imageIndex\":13},{\"imageOffset\":28892,\"symbol\":\"_pthread_cond_wait\",\"symbolLocation\":984,\"imageIndex\":14},{\"imageOffset\":4658180,\"imageIndex\":0},{\"imageOffset\":5029456,\"imageIndex\":0},{\"imageOffset\":5028748,\"imageIndex\":0},{\"imageOffset\":2928268,\"imageIndex\":0},{\"imageOffset\":2927552,\"imageIndex\":0},{\"imageOffset\":4061620,\"imageIndex\":0},{\"imageOffset\":27656,\"symbol\":\"_pthread_start\",\"symbolLocation\":136,\"imageIndex\":14},{\"imageOffset\":7080,\"symbol\":\"thread_start\",\"symbolLocation\":8,\"imageIndex\":14}],\"threadState\":{\"x\":[{\"value\":260},{\"value\":0},{\"value\":256},{\"value\":0},{\"value\":0},{\"value\":160},{\"value\":0},{\"value\":0},{\"value\":6110210616},{\"value\":0},{\"value\":256},{\"value\":1099511628034},{\"value\":1099511628034},{\"value\":256},{\"value\":0},{\"value\":1099511628032},{\"value\":305},{\"value\":8431211416},{\"value\":0},{\"value\":34570814208},{\"value\":34575026096},{\"value\":6110212320},{\"value\":0},{\"value\":0},{\"value\":256},{\"value\":257},{\"value\":512},{\"value\":1},{\"value\":4389625392}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":6585950428},\"cpsr\":{\"value\":1610612736},\"fp\":{\"value\":6110210736},\"sp\":{\"value\":6110210592},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585689336},\"far\":{\"value\":0}}},{\"id\":6246515,\"frames\":[{\"imageOffset\":17656,\"symbol\":\"__psynch_cvwait\",\"symbolLocation\":8,\"imageIndex\":13},{\"imageOffset\":28892,\"symbol\":\"_pthread_cond_wait\",\"symbolLocation\":984,\"imageIndex\":14},{\"imageOffset\":4658180,\"imageIndex\":0},{\"imageOffset\":5029456,\"imageIndex\":0},{\"imageOffset\":5028748,\"imageIndex\":0},{\"imageOffset\":2928268,\"imageIndex\":0},{\"imageOffset\":2927552,\"imageIndex\":0},{\"imageOffset\":4061620,\"imageIndex\":0},{\"imageOffset\":27656,\"symbol\":\"_pthread_start\",\"symbolLocation\":136,\"imageIndex\":14},{\"imageOffset\":7080,\"symbol\":\"thread_start\",\"symbolLocation\":8,\"imageIndex\":14}],\"threadState\":{\"x\":[{\"value\":260},{\"value\":0},{\"value\":256},{\"value\":0},{\"value\":0},{\"value\":160},{\"value\":0},{\"value\":0},{\"value\":6112356920},{\"value\":0},{\"value\":0},{\"value\":2},{\"value\":2},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":305},{\"value\":8431211416},{\"value\":0},{\"value\":34570814272},{\"value\":34575025856},{\"value\":6112358624},{\"value\":0},{\"value\":0},{\"value\":256},{\"value\":257},{\"value\":512},{\"value\":2},{\"value\":4389625392}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":6585950428},\"cpsr\":{\"value\":1610612736},\"fp\":{\"value\":6112357040},\"sp\":{\"value\":6112356896},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585689336},\"far\":{\"value\":0}}},{\"id\":6246516,\"frames\":[{\"imageOffset\":17656,\"symbol\":\"__psynch_cvwait\",\"symbolLocation\":8,\"imageIndex\":13},{\"imageOffset\":28892,\"symbol\":\"_pthread_cond_wait\",\"symbolLocation\":984,\"imageIndex\":14},{\"imageOffset\":4658180,\"imageIndex\":0},{\"imageOffset\":5029456,\"imageIndex\":0},{\"imageOffset\":5028748,\"imageIndex\":0},{\"imageOffset\":2928268,\"imageIndex\":0},{\"imageOffset\":2927552,\"imageIndex\":0},{\"imageOffset\":4061620,\"imageIndex\":0},{\"imageOffset\":27656,\"symbol\":\"_pthread_start\",\"symbolLocation\":136,\"imageIndex\":14},{\"imageOffset\":7080,\"symbol\":\"thread_start\",\"symbolLocation\":8,\"imageIndex\":14}],\"threadState\":{\"x\":[{\"value\":260},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":160},{\"value\":0},{\"value\":0},{\"value\":6114503224},{\"value\":0},{\"value\":0},{\"value\":2},{\"value\":2},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":305},{\"value\":8431211416},{\"value\":0},{\"value\":34570814336},{\"value\":34575026192},{\"value\":6114504928},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":1},{\"value\":256},{\"value\":5},{\"value\":4389625392}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":6585950428},\"cpsr\":{\"value\":1610612736},\"fp\":{\"value\":6114503344},\"sp\":{\"value\":6114503200},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585689336},\"far\":{\"value\":0}}},{\"id\":6246517,\"frames\":[{\"imageOffset\":17656,\"symbol\":\"__psynch_cvwait\",\"symbolLocation\":8,\"imageIndex\":13},{\"imageOffset\":28892,\"symbol\":\"_pthread_cond_wait\",\"symbolLocation\":984,\"imageIndex\":14},{\"imageOffset\":4658180,\"imageIndex\":0},{\"imageOffset\":5029456,\"imageIndex\":0},{\"imageOffset\":5028748,\"imageIndex\":0},{\"imageOffset\":2928268,\"imageIndex\":0},{\"imageOffset\":2927552,\"imageIndex\":0},{\"imageOffset\":4061620,\"imageIndex\":0},{\"imageOffset\":27656,\"symbol\":\"_pthread_start\",\"symbolLocation\":136,\"imageIndex\":14},{\"imageOffset\":7080,\"symbol\":\"thread_start\",\"symbolLocation\":8,\"imageIndex\":14}],\"threadState\":{\"x\":[{\"value\":260},{\"value\":0},{\"value\":256},{\"value\":0},{\"value\":0},{\"value\":160},{\"value\":0},{\"value\":0},{\"value\":6116649528},{\"value\":0},{\"value\":1024},{\"value\":4398046512130},{\"value\":4398046512130},{\"value\":1024},{\"value\":0},{\"value\":4398046512128},{\"value\":305},{\"value\":8431211416},{\"value\":0},{\"value\":34570814400},{\"value\":34575025952},{\"value\":6116651232},{\"value\":0},{\"value\":0},{\"value\":256},{\"value\":257},{\"value\":512},{\"value\":8},{\"value\":4389625392}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":6585950428},\"cpsr\":{\"value\":1610612736},\"fp\":{\"value\":6116649648},\"sp\":{\"value\":6116649504},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585689336},\"far\":{\"value\":0}}},{\"id\":6246544,\"name\":\"WebCore: Scrolling\",\"threadState\":{\"x\":[{\"value\":268451845},{\"value\":21592279046},{\"value\":8589934592},{\"value\":329866373234688},{\"value\":0},{\"value\":329866373234688},{\"value\":2},{\"value\":4294967295},{\"value\":0},{\"value\":17179869184},{\"value\":0},{\"value\":2},{\"value\":0},{\"value\":0},{\"value\":76803},{\"value\":0},{\"value\":18446744073709551569},{\"value\":8431213296},{\"value\":0},{\"value\":4294967295},{\"value\":2},{\"value\":329866373234688},{\"value\":0},{\"value\":329866373234688},{\"value\":6118940616},{\"value\":8589934592},{\"value\":21592279046},{\"value\":18446744073709550527},{\"value\":4412409862}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":6585749544},\"cpsr\":{\"value\":0},\"fp\":{\"value\":6118940464},\"sp\":{\"value\":6118940384},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585674804},\"far\":{\"value\":0}},\"frames\":[{\"imageOffset\":3124,\"symbol\":\"mach_msg2_trap\",\"symbolLocation\":8,\"imageIndex\":13},{\"imageOffset\":77864,\"symbol\":\"mach_msg2_internal\",\"symbolLocation\":76,\"imageIndex\":13},{\"imageOffset\":39308,\"symbol\":\"mach_msg_overwrite\",\"symbolLocation\":484,\"imageIndex\":13},{\"imageOffset\":4020,\"symbol\":\"mach_msg\",\"symbolLocation\":24,\"imageIndex\":13},{\"imageOffset\":392080,\"symbol\":\"__CFRunLoopServiceMachPort\",\"symbolLocation\":160,\"imageIndex\":9},{\"imageOffset\":386280,\"symbol\":\"__CFRunLoopRun\",\"symbolLocation\":1188,\"imageIndex\":9},{\"imageOffset\":1147740,\"symbol\":\"_CFRunLoopRunSpecificWithOptions\",\"symbolLocation\":532,\"imageIndex\":9},{\"imageOffset\":727600,\"symbol\":\"CFRunLoopRun\",\"symbolLocation\":64,\"imageIndex\":9},{\"imageOffset\":2072984,\"symbol\":\"WTF::Detail::CallableWrapper<WTF::RunLoop::create(WTF::ASCIILiteral, WTF::ThreadType, WTF::Thread::QOS)::$_0, void>::call()\",\"symbolLocation\":244,\"imageIndex\":8},{\"imageOffset\":2346588,\"symbol\":\"WTF::Thread::entryPoint(WTF::Thread::NewThreadContext*)\",\"symbolLocation\":260,\"imageIndex\":8},{\"imageOffset\":29840,\"symbol\":\"WTF::wtfThreadEntryPoint(void*)\",\"symbolLocation\":16,\"imageIndex\":8},{\"imageOffset\":27656,\"symbol\":\"_pthread_start\",\"symbolLocation\":136,\"imageIndex\":14},{\"imageOffset\":7080,\"symbol\":\"thread_start\",\"symbolLocation\":8,\"imageIndex\":14}]},{\"id\":6246578,\"name\":\"Log work queue\",\"threadState\":{\"x\":[{\"value\":14},{\"value\":4917346528},{\"value\":0},{\"value\":6124099888},{\"value\":8373513120,\"symbolLocation\":0,\"symbol\":\"_os_log_current_test_callback\"},{\"value\":20},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":1498952},{\"value\":8408172592,\"symbolLocation\":0,\"symbol\":\"OBJC_CLASS_$_OS_os_log\"},{\"value\":8408172592,\"symbolLocation\":0,\"symbol\":\"OBJC_CLASS_$_OS_os_log\"},{\"value\":18446744073709551580},{\"value\":8431215768},{\"value\":0},{\"value\":4915725184},{\"value\":4915725224},{\"value\":6124105728},{\"value\":0},{\"value\":0},{\"value\":4915725312},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":7296706228},\"cpsr\":{\"value\":2147483648},\"fp\":{\"value\":6124105552},\"sp\":{\"value\":6124105536},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585674672},\"far\":{\"value\":0}},\"frames\":[{\"imageOffset\":2992,\"symbol\":\"semaphore_wait_trap\",\"symbolLocation\":8,\"imageIndex\":13},{\"imageOffset\":17966772,\"symbol\":\"IPC::StreamConnectionWorkQueue::startProcessingThread()::$_0::operator()()\",\"symbolLocation\":44,\"imageIndex\":7},{\"imageOffset\":2346588,\"symbol\":\"WTF::Thread::entryPoint(WTF::Thread::NewThreadContext*)\",\"symbolLocation\":260,\"imageIndex\":8},{\"imageOffset\":29840,\"symbol\":\"WTF::wtfThreadEntryPoint(void*)\",\"symbolLocation\":16,\"imageIndex\":8},{\"imageOffset\":27656,\"symbol\":\"_pthread_start\",\"symbolLocation\":136,\"imageIndex\":14},{\"imageOffset\":7080,\"symbol\":\"thread_start\",\"symbolLocation\":8,\"imageIndex\":14}]},{\"id\":6246619,\"name\":\"tokio-runtime-worker\",\"threadState\":{\"x\":[{\"value\":260},{\"value\":0},{\"value\":768},{\"value\":0},{\"value\":0},{\"value\":160},{\"value\":0},{\"value\":0},{\"value\":6126250792},{\"value\":0},{\"value\":0},{\"value\":2},{\"value\":2},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":305},{\"value\":8431211416},{\"value\":0},{\"value\":34563165440},{\"value\":34563271600},{\"value\":6126252256},{\"value\":0},{\"value\":0},{\"value\":768},{\"value\":769},{\"value\":1024},{\"value\":3},{\"value\":34562640160}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":6585950428},\"cpsr\":{\"value\":1610612736},\"fp\":{\"value\":6126250912},\"sp\":{\"value\":6126250768},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585689336},\"far\":{\"value\":0}},\"frames\":[{\"imageOffset\":17656,\"symbol\":\"__psynch_cvwait\",\"symbolLocation\":8,\"imageIndex\":13},{\"imageOffset\":28892,\"symbol\":\"_pthread_cond_wait\",\"symbolLocation\":984,\"imageIndex\":14},{\"imageOffset\":4658180,\"imageIndex\":0},{\"imageOffset\":4655152,\"imageIndex\":0},{\"imageOffset\":4678892,\"imageIndex\":0},{\"imageOffset\":4665508,\"imageIndex\":0},{\"imageOffset\":4665080,\"imageIndex\":0},{\"imageOffset\":4061620,\"imageIndex\":0},{\"imageOffset\":27656,\"symbol\":\"_pthread_start\",\"symbolLocation\":136,\"imageIndex\":14},{\"imageOffset\":7080,\"symbol\":\"thread_start\",\"symbolLocation\":8,\"imageIndex\":14}]},{\"id\":6246620,\"name\":\"tokio-runtime-worker\",\"threadState\":{\"x\":[{\"value\":4},{\"value\":0},{\"value\":0},{\"value\":34590130176},{\"value\":1024},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":6128397048},{\"value\":0},{\"value\":2},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":4},{\"value\":363},{\"value\":8431213136},{\"value\":0},{\"value\":34589889304},{\"value\":34590130176},{\"value\":1000000000},{\"value\":34571335216},{\"value\":0},{\"value\":1000000000},{\"value\":34586341936},{\"value\":0},{\"value\":8},{\"value\":34562644000}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":4378632360},\"cpsr\":{\"value\":1610612736},\"fp\":{\"value\":6128397152},\"sp\":{\"value\":6128397008},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585700144},\"far\":{\"value\":0}},\"frames\":[{\"imageOffset\":28464,\"symbol\":\"kevent\",\"symbolLocation\":8,\"imageIndex\":13},{\"imageOffset\":4661416,\"imageIndex\":0},{\"imageOffset\":4658628,\"imageIndex\":0},{\"imageOffset\":4654976,\"imageIndex\":0},{\"imageOffset\":4678892,\"imageIndex\":0},{\"imageOffset\":4665508,\"imageIndex\":0},{\"imageOffset\":4665080,\"imageIndex\":0},{\"imageOffset\":4061620,\"imageIndex\":0},{\"imageOffset\":27656,\"symbol\":\"_pthread_start\",\"symbolLocation\":136,\"imageIndex\":14},{\"imageOffset\":7080,\"symbol\":\"thread_start\",\"symbolLocation\":8,\"imageIndex\":14}]},{\"id\":6246621,\"name\":\"tokio-runtime-worker\",\"threadState\":{\"x\":[{\"value\":260},{\"value\":0},{\"value\":504320},{\"value\":0},{\"value\":0},{\"value\":160},{\"value\":0},{\"value\":0},{\"value\":6130543400},{\"value\":0},{\"value\":0},{\"value\":2},{\"value\":2},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":305},{\"value\":8431211416},{\"value\":0},{\"value\":34563172928},{\"value\":34563270736},{\"value\":6130544864},{\"value\":0},{\"value\":0},{\"value\":504320},{\"value\":504321},{\"value\":504576},{\"value\":9},{\"value\":34562643616}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":6585950428},\"cpsr\":{\"value\":1610612736},\"fp\":{\"value\":6130543520},\"sp\":{\"value\":6130543376},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585689336},\"far\":{\"value\":0}},\"frames\":[{\"imageOffset\":17656,\"symbol\":\"__psynch_cvwait\",\"symbolLocation\":8,\"imageIndex\":13},{\"imageOffset\":28892,\"symbol\":\"_pthread_cond_wait\",\"symbolLocation\":984,\"imageIndex\":14},{\"imageOffset\":4658180,\"imageIndex\":0},{\"imageOffset\":4655152,\"imageIndex\":0},{\"imageOffset\":4678892,\"imageIndex\":0},{\"imageOffset\":4665508,\"imageIndex\":0},{\"imageOffset\":4665080,\"imageIndex\":0},{\"imageOffset\":4061620,\"imageIndex\":0},{\"imageOffset\":27656,\"symbol\":\"_pthread_start\",\"symbolLocation\":136,\"imageIndex\":14},{\"imageOffset\":7080,\"symbol\":\"thread_start\",\"symbolLocation\":8,\"imageIndex\":14}]},{\"id\":6246622,\"name\":\"tokio-runtime-worker\",\"threadState\":{\"x\":[{\"value\":260},{\"value\":0},{\"value\":2816},{\"value\":0},{\"value\":0},{\"value\":160},{\"value\":0},{\"value\":0},{\"value\":6132689704},{\"value\":0},{\"value\":0},{\"value\":2},{\"value\":2},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":305},{\"value\":8431211416},{\"value\":0},{\"value\":34570828544},{\"value\":34575026240},{\"value\":6132691168},{\"value\":0},{\"value\":0},{\"value\":2816},{\"value\":2817},{\"value\":3072},{\"value\":9},{\"value\":34562642464}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":6585950428},\"cpsr\":{\"value\":1610612736},\"fp\":{\"value\":6132689824},\"sp\":{\"value\":6132689680},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585689336},\"far\":{\"value\":0}},\"frames\":[{\"imageOffset\":17656,\"symbol\":\"__psynch_cvwait\",\"symbolLocation\":8,\"imageIndex\":13},{\"imageOffset\":28892,\"symbol\":\"_pthread_cond_wait\",\"symbolLocation\":984,\"imageIndex\":14},{\"imageOffset\":4658180,\"imageIndex\":0},{\"imageOffset\":4655152,\"imageIndex\":0},{\"imageOffset\":4678892,\"imageIndex\":0},{\"imageOffset\":4665508,\"imageIndex\":0},{\"imageOffset\":4665080,\"imageIndex\":0},{\"imageOffset\":4061620,\"imageIndex\":0},{\"imageOffset\":27656,\"symbol\":\"_pthread_start\",\"symbolLocation\":136,\"imageIndex\":14},{\"imageOffset\":7080,\"symbol\":\"thread_start\",\"symbolLocation\":8,\"imageIndex\":14}]},{\"id\":6246623,\"name\":\"tokio-runtime-worker\",\"threadState\":{\"x\":[{\"value\":260},{\"value\":0},{\"value\":113664},{\"value\":0},{\"value\":0},{\"value\":160},{\"value\":0},{\"value\":0},{\"value\":6134836008},{\"value\":0},{\"value\":0},{\"value\":2},{\"value\":2},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":305},{\"value\":8431211416},{\"value\":0},{\"value\":34570828480},{\"value\":34575027056},{\"value\":6134837472},{\"value\":0},{\"value\":0},{\"value\":113664},{\"value\":113665},{\"value\":113920},{\"value\":5},{\"value\":34562644768}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":6585950428},\"cpsr\":{\"value\":1610612736},\"fp\":{\"value\":6134836128},\"sp\":{\"value\":6134835984},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585689336},\"far\":{\"value\":0}},\"frames\":[{\"imageOffset\":17656,\"symbol\":\"__psynch_cvwait\",\"symbolLocation\":8,\"imageIndex\":13},{\"imageOffset\":28892,\"symbol\":\"_pthread_cond_wait\",\"symbolLocation\":984,\"imageIndex\":14},{\"imageOffset\":4658180,\"imageIndex\":0},{\"imageOffset\":4655152,\"imageIndex\":0},{\"imageOffset\":4678892,\"imageIndex\":0},{\"imageOffset\":4665508,\"imageIndex\":0},{\"imageOffset\":4665080,\"imageIndex\":0},{\"imageOffset\":4061620,\"imageIndex\":0},{\"imageOffset\":27656,\"symbol\":\"_pthread_start\",\"symbolLocation\":136,\"imageIndex\":14},{\"imageOffset\":7080,\"symbol\":\"thread_start\",\"symbolLocation\":8,\"imageIndex\":14}]},{\"id\":6246624,\"name\":\"tokio-runtime-worker\",\"threadState\":{\"x\":[{\"value\":260},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":160},{\"value\":0},{\"value\":0},{\"value\":6136982312},{\"value\":0},{\"value\":0},{\"value\":2},{\"value\":2},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":305},{\"value\":8431211416},{\"value\":0},{\"value\":34570827520},{\"value\":34575027728},{\"value\":6136983776},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":1},{\"value\":256},{\"value\":1},{\"value\":34562645152}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":6585950428},\"cpsr\":{\"value\":1610612736},\"fp\":{\"value\":6136982432},\"sp\":{\"value\":6136982288},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585689336},\"far\":{\"value\":0}},\"frames\":[{\"imageOffset\":17656,\"symbol\":\"__psynch_cvwait\",\"symbolLocation\":8,\"imageIndex\":13},{\"imageOffset\":28892,\"symbol\":\"_pthread_cond_wait\",\"symbolLocation\":984,\"imageIndex\":14},{\"imageOffset\":4658180,\"imageIndex\":0},{\"imageOffset\":4655152,\"imageIndex\":0},{\"imageOffset\":4678892,\"imageIndex\":0},{\"imageOffset\":4665508,\"imageIndex\":0},{\"imageOffset\":4665080,\"imageIndex\":0},{\"imageOffset\":4061620,\"imageIndex\":0},{\"imageOffset\":27656,\"symbol\":\"_pthread_start\",\"symbolLocation\":136,\"imageIndex\":14},{\"imageOffset\":7080,\"symbol\":\"thread_start\",\"symbolLocation\":8,\"imageIndex\":14}]},{\"id\":6246625,\"name\":\"tokio-runtime-worker\",\"threadState\":{\"x\":[{\"value\":260},{\"value\":0},{\"value\":625408},{\"value\":0},{\"value\":0},{\"value\":160},{\"value\":0},{\"value\":0},{\"value\":6139128616},{\"value\":0},{\"value\":0},{\"value\":2},{\"value\":2},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":305},{\"value\":8431211416},{\"value\":0},{\"value\":34563165568},{\"value\":34563271696},{\"value\":6139130080},{\"value\":0},{\"value\":0},{\"value\":625408},{\"value\":625409},{\"value\":625664},{\"value\":6},{\"value\":34562645536}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":6585950428},\"cpsr\":{\"value\":1610612736},\"fp\":{\"value\":6139128736},\"sp\":{\"value\":6139128592},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585689336},\"far\":{\"value\":0}},\"frames\":[{\"imageOffset\":17656,\"symbol\":\"__psynch_cvwait\",\"symbolLocation\":8,\"imageIndex\":13},{\"imageOffset\":28892,\"symbol\":\"_pthread_cond_wait\",\"symbolLocation\":984,\"imageIndex\":14},{\"imageOffset\":4658180,\"imageIndex\":0},{\"imageOffset\":4655152,\"imageIndex\":0},{\"imageOffset\":4678892,\"imageIndex\":0},{\"imageOffset\":4665508,\"imageIndex\":0},{\"imageOffset\":4665080,\"imageIndex\":0},{\"imageOffset\":4061620,\"imageIndex\":0},{\"imageOffset\":27656,\"symbol\":\"_pthread_start\",\"symbolLocation\":136,\"imageIndex\":14},{\"imageOffset\":7080,\"symbol\":\"thread_start\",\"symbolLocation\":8,\"imageIndex\":14}]},{\"id\":6246626,\"name\":\"tokio-runtime-worker\",\"threadState\":{\"x\":[{\"value\":260},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":160},{\"value\":0},{\"value\":0},{\"value\":6141274920},{\"value\":0},{\"value\":0},{\"value\":2},{\"value\":2},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":305},{\"value\":8431211416},{\"value\":0},{\"value\":34570826496},{\"value\":34575025712},{\"value\":6141276384},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":1},{\"value\":256},{\"value\":0},{\"value\":34562645920}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":6585950428},\"cpsr\":{\"value\":1610612736},\"fp\":{\"value\":6141275040},\"sp\":{\"value\":6141274896},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585689336},\"far\":{\"value\":0}},\"frames\":[{\"imageOffset\":17656,\"symbol\":\"__psynch_cvwait\",\"symbolLocation\":8,\"imageIndex\":13},{\"imageOffset\":28892,\"symbol\":\"_pthread_cond_wait\",\"symbolLocation\":984,\"imageIndex\":14},{\"imageOffset\":4658180,\"imageIndex\":0},{\"imageOffset\":4655152,\"imageIndex\":0},{\"imageOffset\":4678892,\"imageIndex\":0},{\"imageOffset\":4665508,\"imageIndex\":0},{\"imageOffset\":4665080,\"imageIndex\":0},{\"imageOffset\":4061620,\"imageIndex\":0},{\"imageOffset\":27656,\"symbol\":\"_pthread_start\",\"symbolLocation\":136,\"imageIndex\":14},{\"imageOffset\":7080,\"symbol\":\"thread_start\",\"symbolLocation\":8,\"imageIndex\":14}]},{\"threadState\":{\"x\":[{\"value\":0},{\"value\":6143414984},{\"value\":0},{\"value\":6143414956},{\"value\":0},{\"value\":0},{\"value\":18446744072631617535},{\"value\":18446726482597246976},{\"value\":0},{\"value\":8408210216,\"symbolLocation\":0,\"symbol\":\"_current_pid\"},{\"value\":2},{\"value\":1099511627776},{\"value\":4294967293},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":18446744073709551592},{\"value\":8431213360},{\"value\":0},{\"value\":6143414956},{\"value\":0},{\"value\":6143414984},{\"value\":515},{\"value\":8408210344,\"symbolLocation\":0,\"symbol\":\"mach_task_self_\"},{\"value\":34561206448},{\"value\":687865929},{\"value\":34561200832},{\"value\":34561209952},{\"value\":34591780288}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":6585679544},\"cpsr\":{\"value\":536870912},\"fp\":{\"value\":6143414864},\"sp\":{\"value\":6143414832},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585674540},\"far\":{\"value\":0}},\"id\":6246627,\"name\":\"tokio-runtime-worker\",\"queue\":\"Client CFPasteboard-Apple CFPasteboard general\",\"frames\":[{\"imageOffset\":2860,\"symbol\":\"_kernelrpc_mach_port_construct_trap\",\"symbolLocation\":8,\"imageIndex\":13},{\"imageOffset\":7864,\"symbol\":\"mach_port_construct\",\"symbolLocation\":40,\"imageIndex\":13},{\"imageOffset\":200796,\"symbol\":\"_xpc_try_mach_port_construct\",\"symbolLocation\":64,\"imageIndex\":15},{\"imageOffset\":200872,\"symbol\":\"_xpc_mach_port_construct\",\"symbolLocation\":28,\"imageIndex\":15},{\"imageOffset\":38212,\"symbol\":\"_xpc_mach_port_allocate\",\"symbolLocation\":36,\"imageIndex\":15},{\"imageOffset\":56304,\"symbol\":\"xpc_connection_send_message_with_reply\",\"symbolLocation\":160,\"imageIndex\":15},{\"imageOffset\":1099640,\"symbol\":\"-[_CFPasteboardEntry requestDataForPasteboard:generation:immediatelyAvailableResult:]\",\"symbolLocation\":1520,\"imageIndex\":9},{\"imageOffset\":379004,\"symbol\":\"__CFPasteboardCopyData_block_invoke\",\"symbolLocation\":152,\"imageIndex\":9},{\"imageOffset\":1103908,\"symbol\":\"____CFPasteboardPerformOnQueue_block_invoke\",\"symbolLocation\":292,\"imageIndex\":9},{\"imageOffset\":28188,\"symbol\":\"_dispatch_block_sync_invoke\",\"symbolLocation\":240,\"imageIndex\":16},{\"imageOffset\":113348,\"symbol\":\"_dispatch_client_callout\",\"symbolLocation\":16,\"imageIndex\":16},{\"imageOffset\":72000,\"symbol\":\"_dispatch_lane_barrier_sync_invoke_and_complete\",\"symbolLocation\":56,\"imageIndex\":16},{\"imageOffset\":33080,\"symbol\":\"_dispatch_sync_block_with_privdata\",\"symbolLocation\":452,\"imageIndex\":16},{\"imageOffset\":376948,\"symbol\":\"CFPasteboardCopyData\",\"symbolLocation\":652,\"imageIndex\":9},{\"imageOffset\":10570452,\"symbol\":\"-[NSPasteboard _dataWithoutConversionForTypeIdentifier:index:securityScoped:]\",\"symbolLocation\":432,\"imageIndex\":5},{\"imageOffset\":10572620,\"symbol\":\"-[NSPasteboard _dataForType:index:usesPboardTypes:combinesItems:securityScoped:]\",\"symbolLocation\":272,\"imageIndex\":5},{\"imageOffset\":15654560,\"symbol\":\"-[NSPasteboardItem __dataForType:async:completionHandler:]\",\"symbolLocation\":316,\"imageIndex\":5},{\"imageOffset\":15654828,\"symbol\":\"-[NSPasteboardItem stringForType:]\",\"symbolLocation\":28,\"imageIndex\":5},{\"imageOffset\":2638832,\"imageIndex\":0},{\"imageOffset\":2640408,\"imageIndex\":0},{\"imageOffset\":4656140,\"imageIndex\":0},{\"imageOffset\":4677752,\"imageIndex\":0},{\"imageOffset\":4665508,\"imageIndex\":0},{\"imageOffset\":4665080,\"imageIndex\":0},{\"imageOffset\":4061620,\"imageIndex\":0},{\"imageOffset\":27656,\"symbol\":\"_pthread_start\",\"symbolLocation\":136,\"imageIndex\":14},{\"imageOffset\":7080,\"symbol\":\"thread_start\",\"symbolLocation\":8,\"imageIndex\":14}]},{\"id\":6246628,\"name\":\"tokio-runtime-worker\",\"threadState\":{\"x\":[{\"value\":260},{\"value\":0},{\"value\":723456},{\"value\":0},{\"value\":0},{\"value\":160},{\"value\":0},{\"value\":0},{\"value\":6145567528},{\"value\":0},{\"value\":0},{\"value\":2},{\"value\":2},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":305},{\"value\":8431211416},{\"value\":0},{\"value\":34563165504},{\"value\":34563271744},{\"value\":6145568992},{\"value\":0},{\"value\":0},{\"value\":723456},{\"value\":723457},{\"value\":723712},{\"value\":7},{\"value\":34562646688}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":6585950428},\"cpsr\":{\"value\":1610612736},\"fp\":{\"value\":6145567648},\"sp\":{\"value\":6145567504},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585689336},\"far\":{\"value\":0}},\"frames\":[{\"imageOffset\":17656,\"symbol\":\"__psynch_cvwait\",\"symbolLocation\":8,\"imageIndex\":13},{\"imageOffset\":28892,\"symbol\":\"_pthread_cond_wait\",\"symbolLocation\":984,\"imageIndex\":14},{\"imageOffset\":4658180,\"imageIndex\":0},{\"imageOffset\":4655152,\"imageIndex\":0},{\"imageOffset\":4678892,\"imageIndex\":0},{\"imageOffset\":4665508,\"imageIndex\":0},{\"imageOffset\":4665080,\"imageIndex\":0},{\"imageOffset\":4061620,\"imageIndex\":0},{\"imageOffset\":27656,\"symbol\":\"_pthread_start\",\"symbolLocation\":136,\"imageIndex\":14},{\"imageOffset\":7080,\"symbol\":\"thread_start\",\"symbolLocation\":8,\"imageIndex\":14}]},{\"id\":6548446,\"name\":\"JavaScriptCore libpas scavenger\",\"threadState\":{\"x\":[{\"value\":260},{\"value\":0},{\"value\":21455104},{\"value\":0},{\"value\":0},{\"value\":160},{\"value\":0},{\"value\":100000000},{\"value\":6093467304},{\"value\":0},{\"value\":256},{\"value\":1099511628034},{\"value\":1099511628034},{\"value\":256},{\"value\":0},{\"value\":1099511628032},{\"value\":305},{\"value\":8431211416},{\"value\":0},{\"value\":4764985408},{\"value\":4764985472},{\"value\":6093467872},{\"value\":100000000},{\"value\":0},{\"value\":21455104},{\"value\":44261633},{\"value\":44261888},{\"value\":8381325312,\"symbolLocation\":2744,\"symbol\":\"WTF::RefLogSingleton::s_buffer\"},{\"value\":8410828800,\"symbolLocation\":1816,\"symbol\":\"bmalloc_common_primitive_heap_support\"}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":6585950428},\"cpsr\":{\"value\":1610612736},\"fp\":{\"value\":6093467424},\"sp\":{\"value\":6093467280},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585689336},\"far\":{\"value\":0}},\"frames\":[{\"imageOffset\":17656,\"symbol\":\"__psynch_cvwait\",\"symbolLocation\":8,\"imageIndex\":13},{\"imageOffset\":28892,\"symbol\":\"_pthread_cond_wait\",\"symbolLocation\":984,\"imageIndex\":14},{\"imageOffset\":26205816,\"symbol\":\"scavenger_thread_main\",\"symbolLocation\":1440,\"imageIndex\":8},{\"imageOffset\":27656,\"symbol\":\"_pthread_start\",\"symbolLocation\":136,\"imageIndex\":14},{\"imageOffset\":7080,\"symbol\":\"thread_start\",\"symbolLocation\":8,\"imageIndex\":14}]},{\"id\":6770305,\"name\":\"CVDisplayLink\",\"threadState\":{\"x\":[{\"value\":260},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":65704},{\"value\":0},{\"value\":15638083},{\"value\":270710273},{\"value\":0},{\"value\":0},{\"value\":2},{\"value\":2},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":305},{\"value\":8431211416},{\"value\":0},{\"value\":34574594104},{\"value\":34574594168},{\"value\":1},{\"value\":15638083},{\"value\":0},{\"value\":0},{\"value\":270710273},{\"value\":270710528},{\"value\":5967573841783},{\"value\":0}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":6585950472},\"cpsr\":{\"value\":2684354560},\"fp\":{\"value\":6094040496},\"sp\":{\"value\":6094040352},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585689336},\"far\":{\"value\":0}},\"frames\":[{\"imageOffset\":17656,\"symbol\":\"__psynch_cvwait\",\"symbolLocation\":8,\"imageIndex\":13},{\"imageOffset\":28936,\"symbol\":\"_pthread_cond_wait\",\"symbolLocation\":1028,\"imageIndex\":14},{\"imageOffset\":15164,\"symbol\":\"CVDisplayLink::waitUntil(unsigned long long)\",\"symbolLocation\":336,\"imageIndex\":17},{\"imageOffset\":11300,\"symbol\":\"CVDisplayLink::runIOThread()\",\"symbolLocation\":500,\"imageIndex\":17},{\"imageOffset\":27656,\"symbol\":\"_pthread_start\",\"symbolLocation\":136,\"imageIndex\":14},{\"imageOffset\":7080,\"symbol\":\"thread_start\",\"symbolLocation\":8,\"imageIndex\":14}]},{\"id\":6835598,\"frames\":[],\"threadState\":{\"x\":[{\"value\":6118371328},{\"value\":124343},{\"value\":6117834752},{\"value\":0},{\"value\":409604},{\"value\":18446744073709551615},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":0},\"cpsr\":{\"value\":0},\"fp\":{\"value\":0},\"sp\":{\"value\":6118371328},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585928596},\"far\":{\"value\":0}}},{\"id\":6837987,\"frames\":[],\"threadState\":{\"x\":[{\"value\":6094614528},{\"value\":37483},{\"value\":6094077952},{\"value\":0},{\"value\":409604},{\"value\":18446744073709551615},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":0},\"cpsr\":{\"value\":0},\"fp\":{\"value\":0},\"sp\":{\"value\":6094614528},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585928596},\"far\":{\"value\":0}}},{\"id\":6837989,\"frames\":[],\"threadState\":{\"x\":[{\"value\":6117797888},{\"value\":103203},{\"value\":6117261312},{\"value\":0},{\"value\":409604},{\"value\":18446744073709551615},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":0},\"cpsr\":{\"value\":0},\"fp\":{\"value\":0},\"sp\":{\"value\":6117797888},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585928596},\"far\":{\"value\":0}}},{\"id\":6838119,\"frames\":[],\"threadState\":{\"x\":[{\"value\":6119518208},{\"value\":106279},{\"value\":6118981632},{\"value\":0},{\"value\":409604},{\"value\":18446744073709551615},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0},{\"value\":0}],\"flavor\":\"ARM_THREAD_STATE64\",\"lr\":{\"value\":0},\"cpsr\":{\"value\":0},\"fp\":{\"value\":0},\"sp\":{\"value\":6119518208},\"esr\":{\"value\":1442840704,\"description\":\"(Syscall)\"},\"pc\":{\"value\":6585928596},\"far\":{\"value\":0}}}],\n  \"usedImages\" : [\n  {\n    \"source\" : \"P\",\n    \"arch\" : \"arm64\",\n    \"base\" : 4373970944,\n    \"CFBundleShortVersionString\" : \"2.9.13\",\n    \"CFBundleIdentifier\" : \"liren.project-graph\",\n    \"size\" : 8945664,\n    \"uuid\" : \"946c435c-f6bb-3969-9092-d73ba4a59315\",\n    \"path\" : \"\\/Applications\\/Project Graph.app\\/Contents\\/MacOS\\/project-graph\",\n    \"name\" : \"project-graph\",\n    \"CFBundleVersion\" : \"2.9.13\"\n  },\n  {\n    \"source\" : \"P\",\n    \"arch\" : \"arm64e\",\n    \"base\" : 4682629120,\n    \"size\" : 49152,\n    \"uuid\" : \"f8bd9069-8c4f-37ea-af9a-2b1060f54e4f\",\n    \"path\" : \"\\/usr\\/lib\\/libobjc-trampolines.dylib\",\n    \"name\" : \"libobjc-trampolines.dylib\"\n  },\n  {\n    \"source\" : \"P\",\n    \"arch\" : \"arm64e\",\n    \"base\" : 4716199936,\n    \"CFBundleShortVersionString\" : \"341.11\",\n    \"CFBundleIdentifier\" : \"com.apple.AGXMetalG16G-B0\",\n    \"size\" : 8552448,\n    \"uuid\" : \"a22549f3-d4f5-3b88-af18-e06837f0d352\",\n    \"path\" : \"\\/System\\/Library\\/Extensions\\/AGXMetalG16G_B0.bundle\\/Contents\\/MacOS\\/AGXMetalG16G_B0\",\n    \"name\" : \"AGXMetalG16G_B0\",\n    \"CFBundleVersion\" : \"341.11\"\n  },\n  {\n    \"source\" : \"P\",\n    \"arch\" : \"arm64e\",\n    \"base\" : 4705353728,\n    \"CFBundleShortVersionString\" : \"1.0\",\n    \"CFBundleIdentifier\" : \"com.apple.AppleMetalOpenGLRenderer\",\n    \"size\" : 409600,\n    \"uuid\" : \"7fba6cd5-06ae-37aa-aa67-580c920ea69d\",\n    \"path\" : \"\\/System\\/Library\\/Extensions\\/AppleMetalOpenGLRenderer.bundle\\/Contents\\/MacOS\\/AppleMetalOpenGLRenderer\",\n    \"name\" : \"AppleMetalOpenGLRenderer\",\n    \"CFBundleVersion\" : \"1\"\n  },\n  {\n    \"source\" : \"P\",\n    \"arch\" : \"arm64e\",\n    \"base\" : 6581403648,\n    \"size\" : 341132,\n    \"uuid\" : \"5a0aab4e-0c1a-3680-82c9-43dc4007a6e7\",\n    \"path\" : \"\\/usr\\/lib\\/libobjc.A.dylib\",\n    \"name\" : \"libobjc.A.dylib\"\n  },\n  {\n    \"source\" : \"P\",\n    \"arch\" : \"arm64e\",\n    \"base\" : 6657810432,\n    \"CFBundleShortVersionString\" : \"6.9\",\n    \"CFBundleIdentifier\" : \"com.apple.AppKit\",\n    \"size\" : 24300448,\n    \"uuid\" : \"3c0949bb-e361-369a-80b7-17440eb09e98\",\n    \"path\" : \"\\/System\\/Library\\/Frameworks\\/AppKit.framework\\/Versions\\/C\\/AppKit\",\n    \"name\" : \"AppKit\",\n    \"CFBundleVersion\" : \"2685.20.119\"\n  },\n  {\n    \"source\" : \"P\",\n    \"arch\" : \"arm64e\",\n    \"base\" : 7213748224,\n    \"CFBundleShortVersionString\" : \"21622\",\n    \"CFBundleIdentifier\" : \"com.apple.WebCore\",\n    \"size\" : 57751008,\n    \"uuid\" : \"ccd2dfa6-ae82-311f-b824-a9aad0a6f12e\",\n    \"path\" : \"\\/System\\/Library\\/Frameworks\\/WebKit.framework\\/Versions\\/A\\/Frameworks\\/WebCore.framework\\/Versions\\/A\\/WebCore\",\n    \"name\" : \"WebCore\",\n    \"CFBundleVersion\" : \"21622.2.11.11.9\"\n  },\n  {\n    \"source\" : \"P\",\n    \"arch\" : \"arm64e\",\n    \"base\" : 7278739456,\n    \"CFBundleShortVersionString\" : \"21622\",\n    \"CFBundleIdentifier\" : \"com.apple.WebKit\",\n    \"size\" : 22128832,\n    \"uuid\" : \"3b55482a-efe2-35a7-b1c9-3f41a823a30b\",\n    \"path\" : \"\\/System\\/Library\\/Frameworks\\/WebKit.framework\\/Versions\\/A\\/WebKit\",\n    \"name\" : \"WebKit\",\n    \"CFBundleVersion\" : \"21622.2.11.11.9\"\n  },\n  {\n    \"source\" : \"P\",\n    \"arch\" : \"arm64e\",\n    \"base\" : 7143546880,\n    \"CFBundleShortVersionString\" : \"21622\",\n    \"CFBundleIdentifier\" : \"com.apple.JavaScriptCore\",\n    \"size\" : 28438144,\n    \"uuid\" : \"c79071c9-db50-3264-a316-94abd0d3b9a9\",\n    \"path\" : \"\\/System\\/Library\\/Frameworks\\/JavaScriptCore.framework\\/Versions\\/A\\/JavaScriptCore\",\n    \"name\" : \"JavaScriptCore\",\n    \"CFBundleVersion\" : \"21622.2.11.11.9\"\n  },\n  {\n    \"source\" : \"P\",\n    \"arch\" : \"arm64e\",\n    \"base\" : 6586208256,\n    \"CFBundleShortVersionString\" : \"6.9\",\n    \"CFBundleIdentifier\" : \"com.apple.CoreFoundation\",\n    \"size\" : 5532352,\n    \"uuid\" : \"3c4a3add-9e48-33da-82ee-80520e6cbe3b\",\n    \"path\" : \"\\/System\\/Library\\/Frameworks\\/CoreFoundation.framework\\/Versions\\/A\\/CoreFoundation\",\n    \"name\" : \"CoreFoundation\",\n    \"CFBundleVersion\" : \"4109.1.401\"\n  },\n  {\n    \"source\" : \"P\",\n    \"arch\" : \"arm64e\",\n    \"base\" : 6798389248,\n    \"CFBundleShortVersionString\" : \"2.1.1\",\n    \"CFBundleIdentifier\" : \"com.apple.HIToolbox\",\n    \"size\" : 3155968,\n    \"uuid\" : \"9ab64c08-0685-3a0d-9a7e-83e7a1e9ebb4\",\n    \"path\" : \"\\/System\\/Library\\/Frameworks\\/Carbon.framework\\/Versions\\/A\\/Frameworks\\/HIToolbox.framework\\/Versions\\/A\\/HIToolbox\",\n    \"name\" : \"HIToolbox\"\n  },\n  {\n    \"source\" : \"P\",\n    \"arch\" : \"arm64e\",\n    \"base\" : 6581948416,\n    \"size\" : 651108,\n    \"uuid\" : \"b50f5a1a-be81-3068-92e1-3554f2be478a\",\n    \"path\" : \"\\/usr\\/lib\\/dyld\",\n    \"name\" : \"dyld\"\n  },\n  {\n    \"size\" : 0,\n    \"source\" : \"A\",\n    \"base\" : 0,\n    \"uuid\" : \"00000000-0000-0000-0000-000000000000\"\n  },\n  {\n    \"source\" : \"P\",\n    \"arch\" : \"arm64e\",\n    \"base\" : 6585671680,\n    \"size\" : 246944,\n    \"uuid\" : \"9fe7c84d-0c2b-363f-bee5-6fd76d67a227\",\n    \"path\" : \"\\/usr\\/lib\\/system\\/libsystem_kernel.dylib\",\n    \"name\" : \"libsystem_kernel.dylib\"\n  },\n  {\n    \"source\" : \"P\",\n    \"arch\" : \"arm64e\",\n    \"base\" : 6585921536,\n    \"size\" : 51900,\n    \"uuid\" : \"e95973b8-824c-361e-adf4-390747c40897\",\n    \"path\" : \"\\/usr\\/lib\\/system\\/libsystem_pthread.dylib\",\n    \"name\" : \"libsystem_pthread.dylib\"\n  },\n  {\n    \"source\" : \"P\",\n    \"arch\" : \"arm64e\",\n    \"base\" : 6582616064,\n    \"size\" : 348032,\n    \"uuid\" : \"8346be50-de08-3606-9fb6-9a352975661d\",\n    \"path\" : \"\\/usr\\/lib\\/system\\/libxpc.dylib\",\n    \"name\" : \"libxpc.dylib\"\n  },\n  {\n    \"source\" : \"P\",\n    \"arch\" : \"arm64e\",\n    \"base\" : 6584119296,\n    \"size\" : 290464,\n    \"uuid\" : \"8fb392ae-401f-399a-96ae-41531cf91162\",\n    \"path\" : \"\\/usr\\/lib\\/system\\/libdispatch.dylib\",\n    \"name\" : \"libdispatch.dylib\"\n  },\n  {\n    \"source\" : \"P\",\n    \"arch\" : \"arm64e\",\n    \"base\" : 6760062976,\n    \"CFBundleShortVersionString\" : \"1.8\",\n    \"CFBundleIdentifier\" : \"com.apple.CoreVideo\",\n    \"size\" : 540672,\n    \"uuid\" : \"d8605842-8c6c-36d7-820d-2132d91e0c06\",\n    \"path\" : \"\\/System\\/Library\\/Frameworks\\/CoreVideo.framework\\/Versions\\/A\\/CoreVideo\",\n    \"name\" : \"CoreVideo\",\n    \"CFBundleVersion\" : \"726.2\"\n  }\n],\n  \"sharedCache\" : {\n  \"base\" : 6580862976,\n  \"size\" : 5609635840,\n  \"uuid\" : \"b69ff43c-dbfd-3fb1-b4fe-a8fe32ea1062\"\n},\n  \"vmSummary\" : \"ReadOnly portion of Libraries: Total=1.8G resident=0K(0%) swapped_out_or_unallocated=1.8G(100%)\\nWritable regions: Total=4.7G written=1841K(0%) resident=945K(0%) swapped_out=896K(0%) unallocated=4.7G(100%)\\n\\n                                VIRTUAL   REGION \\nREGION TYPE                        SIZE    COUNT (non-coalesced) \\n===========                     =======  ======= \\nActivity Tracing                   256K        1 \\nColorSync                           32K        2 \\nCoreAnimation                      912K       57 \\nCoreGraphics                        64K        4 \\nCoreUI image data                  240K        2 \\nFoundation                          48K        2 \\nKernel Alloc Once                   32K        1 \\nMALLOC                           268.9M       32 \\nMALLOC guard page                 3120K        4 \\nSTACK GUARD                        464K       29 \\nStack                             53.4M       30 \\nStack Guard                       56.0M        1 \\nVM_ALLOCATE                       4880K       41 \\nVM_ALLOCATE (reserved)             4.0G       21         reserved VM address space (unallocated)\\nWebKit Malloc                    400.2M        9 \\n__AUTH                            5853K      652 \\n__AUTH_CONST                      88.8M     1037 \\n__CTF                               824        1 \\n__DATA                            30.0M      988 \\n__DATA_CONST                      33.2M     1046 \\n__DATA_DIRTY                      8836K      898 \\n__FONT_DATA                        2352        1 \\n__GLSLBUILTINS                    5174K        1 \\n__INFO_FILTER                         8        1 \\n__LINKEDIT                       594.2M        5 \\n__OBJC_RO                         78.3M        1 \\n__OBJC_RW                         2567K        1 \\n__TEXT                             1.2G     1069 \\n__TPRO_CONST                       128K        2 \\nmapped file                      600.6M       75 \\npage table in kernel               945K        1 \\nshared memory                      912K       15 \\n===========                     =======  ======= \\nTOTAL                              7.4G     6030 \\nTOTAL, minus reserved VM space     3.4G     6030 \\n\",\n  \"legacyInfo\" : {\n  \"threadTriggered\" : {\n    \"name\" : \"main\",\n    \"queue\" : \"com.apple.main-thread\"\n  }\n},\n  \"logWritingSignature\" : \"be58563517ee84a143ff0fa4d7387166d5414db0\",\n  \"trialInfo\" : {\n  \"rollouts\" : [\n    {\n      \"rolloutId\" : \"64628732bf2f5257dedc8988\",\n      \"factorPackIds\" : [\n\n      ],\n      \"deploymentId\" : 240000001\n    },\n    {\n      \"rolloutId\" : \"6246d6a916a70b047e454124\",\n      \"factorPackIds\" : [\n\n      ],\n      \"deploymentId\" : 240000010\n    }\n  ],\n  \"experiments\" : [\n\n  ]\n}\n}\n\nModel: Mac16,12, BootROM 13822.41.1, proc 10:4:6 processors, 24 GB, SMC \nGraphics: Apple M4, Apple M4, Built-In\nDisplay: Mi Monitor, 3840 x 2160 (2160p/4K UHD 1 - Ultra High Definition), Main, MirrorOff, Online\nDisplay: Color LCD, 2560 x 1664 Retina, MirrorOff, Online\nMemory Module: LPDDR5, Hynix\nAirPort: spairport_wireless_card_type_wifi (0x14E4, 0x4388), wl0: Oct  3 2025 00:48:50 version 23.41.7.0.41.51.200 FWID 01-8b09c4e0\nIO80211_driverkit-1530.16 \"IO80211_driverkit-1530.16\" Oct 10 2025 22:56:35\nAirPort: \nBluetooth: Version (null), 0 services, 0 devices, 0 incoming serial ports\nNetwork Service: Wi-Fi, AirPort, en0\nThunderbolt Bus: MacBook Air, Apple Inc.\nThunderbolt Bus: MacBook Air, Apple Inc.\n"
  },
  {
    "path": ".trae/skills/create-keybind/SKILL.md",
    "content": "---\nname: create-keybind\ndescription: 指导如何在 Project Graph 项目中创建新的快捷键。当用户需要添加新的快捷键、修改快捷键绑定或需要了解快捷键系统的实现方式时使用此技能。\n---\n\n# 创建新的快捷键功能\n\n本技能指导如何在 Project Graph 项目中创建新的快捷键。\n\n## 创建快捷键的步骤\n\n创建新快捷键需要完成以下 4 个步骤：\n\n### 1. 在 shortcutKeysRegister.tsx 中注册快捷键\n\n在 `app/src/core/service/controlService/shortcutKeysEngine/shortcutKeysRegister.tsx` 文件的 `allKeyBinds` 数组中添加新的快捷键定义。\n\n**快捷键定义结构：**\n\n```typescript\n{\n  id: \"uniqueKeybindId\",           // 唯一标识符，使用驼峰命名\n  defaultKey: \"A-S-m\",              // 默认快捷键组合\n  onPress: (project?: Project) => void,  // 按下时的回调函数\n  onRelease?: (project?: Project) => void, // 松开时的回调函数（可选）\n  isGlobal?: boolean,               // 是否为全局快捷键（可选，默认 false）\n  isUI?: boolean,                   // 是否为 UI 级别快捷键（可选，默认 false）\n  defaultEnabled?: boolean,         // 默认是否启用（可选，默认 true）\n}\n```\n\n**快捷键键位格式：**\n\n- `C-` = Ctrl (Windows/Linux) 或 Command (macOS)\n- `A-` = Alt (Windows/Linux) 或 Option (macOS)\n- `S-` = Shift\n- `M-` = Meta (macOS 上等同于 Command)\n- `F11`, `F12` 等 = 功能键\n- `arrowup`, `arrowdown`, `arrowleft`, `arrowright` = 方向键\n- `home`, `end`, `pageup`, `pagedown` = 导航键\n- `space`, `enter`, `escape` = 特殊键\n- 普通字母直接写，如 `m`, `t`, `k` 等\n- 多个按键用空格分隔，如 `\"t t t\"` 表示连续按三次 t\n\n**注意：** Mac 系统会自动将 `C-` 和 `M-` 互换，所以不需要手动处理平台差异。\n\n**示例：**\n\n```typescript\n{\n  id: \"setWindowToMiniSize\",\n  defaultKey: \"A-S-m\",  // Alt+Shift+M\n  onPress: async () => {\n    const window = getCurrentWindow();\n    // 执行操作\n    await window.setSize(new LogicalSize(width, height));\n  },\n  isUI: true,  // UI 级别快捷键，不需要项目上下文\n},\n```\n\n**快捷键类型说明：**\n\n- **项目级快捷键（默认）**：需要项目上下文，`onPress` 会接收 `project` 参数\n- **UI 级别快捷键（`isUI: true`）**：不需要项目上下文，可以在没有打开项目时使用\n- **全局快捷键（`isGlobal: true`）**：使用 Tauri 全局快捷键系统，即使应用不在焦点也能触发\n\n**使用 Tauri API 时的类型处理：**\n\n如果快捷键需要使用 Tauri 窗口 API（如 `setSize`），需要导入正确的类型：\n\n```typescript\nimport { getCurrentWindow } from \"@tauri-apps/api/window\";\nimport { LogicalSize } from \"@tauri-apps/api/dpi\"; // 或 PhysicalSize\n\n// 使用 LogicalSize（推荐，会自动处理 DPI 缩放）\nawait window.setSize(new LogicalSize(width, height));\n\n// 或使用 PhysicalSize（物理像素）\nawait window.setSize(new PhysicalSize(width, height));\n```\n\n### 2. 将快捷键添加到分组中\n\n在 `app/src/sub/SettingsWindow/keybinds.tsx` 文件的 `shortcutKeysGroups` 数组中，将新快捷键的 `id` 添加到相应的分组数组中。\n\n**分组结构：**\n\n```typescript\nexport const shortcutKeysGroups: ShortcutKeysGroup[] = [\n  {\n    title: \"basic\",              // 分组标识符（对应翻译文件中的 key）\n    icon: <Keyboard />,          // 分组图标\n    keys: [                      // 该分组包含的快捷键 ID 列表\n      \"saveFile\",\n      \"openFile\",\n      \"undo\",\n      \"redo\",\n      // ...\n    ],\n  },\n  {\n    title: \"ui\",                 // UI 控制分组\n    icon: <AppWindow />,\n    keys: [\n      \"closeAllSubWindows\",\n      \"toggleFullscreen\",\n      \"setWindowToMiniSize\",     // 添加新快捷键\n      // ...\n    ],\n  },\n  // ... 其他分组\n];\n```\n\n**可用分组：**\n\n- `basic` - 基础快捷键（撤销、重做、保存、打开等）\n- `camera` - 摄像机控制（移动、缩放、重置视野等）\n- `app` - 应用控制（切换项目、切换模式等）\n- `ui` - UI 控制（关闭窗口、全屏、窗口大小等）\n- `draw` - 涂鸦相关\n- `select` - 切换选择\n- `moveEntity` - 移动实体\n- `generateTextNodeInTree` - 生长节点\n- `generateTextNodeRoundedSelectedNode` - 在选中节点周围生成节点\n- `aboutTextNode` - 关于文本节点（分割、合并、创建等）\n- `section` - Section 框相关\n- `edge` - 连线相关\n- `color` - 颜色相关\n- `node` - 节点相关\n\n**分组选择指南：**\n\n- **UI 控制（`ui`）**：窗口管理、界面切换、全屏、窗口大小等\n- **基础快捷键（`basic`）**：文件操作、编辑操作（撤销、重做、复制、粘贴等）\n- **摄像机控制（`camera`）**：视野移动、缩放、重置等\n- **应用控制（`app`）**：项目切换、模式切换等\n- **文本节点相关（`aboutTextNode`）**：节点创建、分割、合并、编辑等\n- **其他**：根据功能特性选择最合适的分组\n\n**注意：** 如果快捷键不属于任何现有分组，可以：\n\n1. 添加到最接近的现有分组\n2. 创建新的分组（需要同时更新翻译文件）\n\n### 3. 添加翻译文本\n\n在所有语言文件中添加翻译：\n\n- `app/src/locales/zh_CN.yml` - 简体中文\n- `app/src/locales/zh_TW.yml` - 繁体中文\n- `app/src/locales/en.yml` - 英文\n- `app/src/locales/zh_TWC.yml` - 繁体中文（台湾）\n\n**翻译结构：**\n\n在 `keyBinds` 部分添加：\n\n```yaml\nkeyBinds:\n  keybindId:\n    title: \"快捷键标题\"\n    description: |\n      快捷键的详细描述\n      可以多行\n      说明快捷键的功能和使用场景\n```\n\n**示例：**\n\n```yaml\nkeyBinds:\n  setWindowToMiniSize:\n    title: 设置窗口为迷你大小\n    description: |\n      将窗口大小设置为设置中配置的迷你窗口宽度和高度。\n```\n\n**翻译文件位置：**\n\n- 简体中文：`app/src/locales/zh_CN.yml`\n- 繁体中文：`app/src/locales/zh_TW.yml`\n- 繁体中文（台湾）：`app/src/locales/zh_TWC.yml`\n- 英文：`app/src/locales/en.yml`\n\n**注意：**\n\n- 翻译键名（`keybindId`）必须与快捷键定义中的 `id` 完全一致\n- 所有语言文件都需要添加翻译，否则会显示默认值或键名\n\n### 4. 更新分组翻译（如果需要创建新分组）\n\n如果创建了新的快捷键分组，需要在所有语言文件的 `keyBindsGroup` 部分添加分组翻译：\n\n```yaml\nkeyBindsGroup:\n  newGroupName:\n    title: \"新分组标题\"\n    description: |\n      分组的详细描述\n      说明该分组包含哪些类型的快捷键\n```\n\n**示例：**\n\n```yaml\nkeyBindsGroup:\n  ui:\n    title: UI控制\n    description: |\n      用于控制UI的一些功能\n```\n\n## 快捷键类型详解\n\n### 项目级快捷键（默认）\n\n项目级快捷键需要项目上下文，`onPress` 函数会接收 `project` 参数：\n\n```typescript\n{\n  id: \"myKeybind\",\n  defaultKey: \"C-k\",\n  onPress: (project) => {\n    if (!project) {\n      toast.warning(\"请先打开工程文件\");\n      return;\n    }\n    // 使用 project 进行操作\n    project.stageManager.doSomething();\n  },\n}\n```\n\n### UI 级别快捷键\n\nUI 级别快捷键不需要项目上下文，可以在没有打开项目时使用：\n\n```typescript\n{\n  id: \"myUIKeybind\",\n  defaultKey: \"A-S-m\",\n  onPress: async () => {\n    // 不需要 project 参数\n    const window = getCurrentWindow();\n    await window.setSize(new LogicalSize(300, 300));\n  },\n  isUI: true,  // 标记为 UI 级别\n}\n```\n\n### 全局快捷键\n\n全局快捷键使用 Tauri 全局快捷键系统，即使应用不在焦点也能触发：\n\n```typescript\n{\n  id: \"myGlobalKeybind\",\n  defaultKey: \"CommandOrControl+Shift+G\",\n  onPress: () => {\n    // 全局快捷键逻辑\n  },\n  isGlobal: true,  // 标记为全局快捷键\n}\n```\n\n**注意：** 全局快捷键的键位格式与普通快捷键不同，使用 `CommandOrControl+Shift+G` 格式。\n\n## 访问快捷键配置\n\n在代码中访问快捷键配置：\n\n```typescript\nimport { KeyBindsUI } from \"@/core/service/controlService/shortcutKeysEngine/KeyBindsUI\";\n\n// 获取快捷键配置\nconst config = await KeyBindsUI.get(\"keybindId\");\n\n// 修改快捷键\nawait KeyBindsUI.changeOneUIKeyBind(\"keybindId\", \"new-key-combination\");\n\n// 重置所有快捷键\nawait KeyBindsUI.resetAllKeyBinds();\n```\n\n## 注意事项\n\n1. **快捷键 ID 命名规范：** 使用驼峰命名法（camelCase），如 `setWindowToMiniSize`\n2. **唯一性：** 快捷键 ID 必须唯一，不能与现有快捷键重复\n3. **默认键位：** 选择不冲突的默认键位组合\n4. **类型安全：** TypeScript 会自动推断类型，确保类型一致性\n5. **翻译键名：** 翻译文件中的键名必须与快捷键的 `id` 完全一致\n6. **分组必须：** 所有快捷键都必须添加到 `shortcutKeysGroups` 中的相应分组，否则不会在设置页面中显示\n7. **分组选择：** 根据快捷键的功能特性选择合适的分组，保持设置页面的逻辑清晰\n8. **Tauri API 类型：** 使用窗口 API 时，需要使用 `LogicalSize` 或 `PhysicalSize` 类型，不能直接使用普通对象\n9. **Mac 兼容性：** Mac 系统会自动将 `C-` 和 `M-` 互换，无需手动处理\n10. **UI vs 项目级：** 根据快捷键是否需要项目上下文选择合适的类型\n\n## 完整示例\n\n假设要添加一个\"设置窗口为迷你大小\"的快捷键：\n\n**1. shortcutKeysRegister.tsx - 注册快捷键：**\n\n```typescript\nimport { getCurrentWindow } from \"@tauri-apps/api/window\";\nimport { LogicalSize } from \"@tauri-apps/api/dpi\";\nimport { Settings } from \"@/core/service/Settings\";\n\nexport const allKeyBinds: KeyBindItem[] = [\n  // ... 其他快捷键\n  {\n    id: \"setWindowToMiniSize\",\n    defaultKey: \"A-S-m\",\n    onPress: async () => {\n      const window = getCurrentWindow();\n      // 如果当前是最大化状态，先取消最大化\n      if (await window.isMaximized()) {\n        await window.unmaximize();\n        store.set(isWindowMaxsizedAtom, false);\n      }\n      // 如果当前是全屏状态，先退出全屏\n      if (await window.isFullscreen()) {\n        await window.setFullscreen(false);\n      }\n      // 设置窗口大小为设置中的迷你窗口大小\n      const width = Settings.windowCollapsingWidth;\n      const height = Settings.windowCollapsingHeight;\n      await window.setSize(new LogicalSize(width, height));\n    },\n    isUI: true,\n  },\n  // ... 其他快捷键\n];\n```\n\n**2. keybinds.tsx - 添加到分组：**\n\n```typescript\nexport const shortcutKeysGroups: ShortcutKeysGroup[] = [\n  // ... 其他分组\n  {\n    title: \"ui\",\n    icon: <AppWindow />,\n    keys: [\n      \"closeAllSubWindows\",\n      \"toggleFullscreen\",\n      \"setWindowToMiniSize\",  // 添加到 UI 控制分组\n      // ...\n    ],\n  },\n  // ... 其他分组\n];\n```\n\n**3. zh_CN.yml - 添加翻译：**\n\n```yaml\nkeyBinds:\n  setWindowToMiniSize:\n    title: 设置窗口为迷你大小\n    description: |\n      将窗口大小设置为设置中配置的迷你窗口宽度和高度。\n```\n\n**4. 其他语言文件也需要添加相应翻译**\n\n## 快捷键键位格式参考\n\n**修饰键：**\n\n- `C-` = Ctrl/Command\n- `A-` = Alt/Option\n- `S-` = Shift\n- `M-` = Meta (macOS)\n\n**特殊键：**\n\n- `F1` - `F12` = 功能键\n- `arrowup`, `arrowdown`, `arrowleft`, `arrowright` = 方向键\n- `home`, `end`, `pageup`, `pagedown` = 导航键\n- `space`, `enter`, `escape`, `tab`, `backspace`, `delete` = 特殊键\n\n**组合示例：**\n\n- `\"C-s\"` = Ctrl+S\n- `\"A-S-m\"` = Alt+Shift+M\n- `\"C-A-S-t\"` = Ctrl+Alt+Shift+T\n- `\"F11\"` = F11\n- `\"C-F11\"` = Ctrl+F11\n- `\"t t t\"` = 连续按三次 T\n- `\"arrowup\"` = 上方向键\n- `\"S-arrowup\"` = Shift+上方向键\n\n## 快捷键设置页面\n\n快捷键添加到分组后，会在设置页面的\"快捷键绑定\"标签页中自动显示：\n\n1. 用户可以在设置页面查看所有快捷键\n2. 用户可以自定义快捷键键位\n3. 用户可以启用/禁用快捷键\n4. 用户可以重置快捷键为默认值\n\n快捷键会自动保存到 `keybinds2.json` 文件中，并在应用重启后恢复。\n"
  },
  {
    "path": ".trae/skills/create-setting-item/SKILL.md",
    "content": "---\nname: create-setting-item\ndescription: 指导如何在 Project Graph 项目中创建新的设置项。当用户需要添加新的设置项、配置选项或需要了解设置系统的实现方式时使用此技能。\n---\n\n# 创建新的设置项功能\n\n本技能指导如何在 Project Graph 项目中创建新的设置项。\n\n## 创建设置项的步骤\n\n创建新设置项需要完成以下 5 个步骤：\n\n### 1. 在 Settings.tsx 中添加 Schema 定义\n\n在 `app/src/core/service/Settings.tsx` 文件的 `settingsSchema` 对象中添加新的设置项定义。\n\n**支持的 Zod 类型：**\n\n- `z.boolean().default(false)` - 布尔值开关\n- `z.number().min(x).max(y).default(z)` - 数字（可添加 `.int()` 限制为整数）\n- `z.string().default(\"\")` - 字符串\n- `z.union([z.literal(\"option1\"), z.literal(\"option2\")]).default(\"option1\")` - 枚举选择\n- `z.tuple([z.number(), z.number(), z.number(), z.number()]).default([0,0,0,0])` - 元组（如颜色 RGBA）\n\n**示例：**\n\n```typescript\n// 布尔值设置\nenableNewFeature: z.boolean().default(false),\n\n// 数字范围设置（带滑块）\nnewSliderValue: z.number().min(0).max(100).int().default(50),\n\n// 枚举选择设置\nnewMode: z.union([z.literal(\"mode1\"), z.literal(\"mode2\")]).default(\"mode1\"),\n```\n\n### 2. 在 SettingsIcons.tsx 中添加图标\n\n在 `app/src/core/service/SettingsIcons.tsx` 文件的 `settingsIcons` 对象中添加对应的图标。\n\n**步骤：**\n\n1. 从 `lucide-react` 导入合适的图标组件\n2. 在 `settingsIcons` 对象中添加映射：`settingKey: IconComponent`\n\n**示例：**\n\n```typescript\nimport { NewIcon } from \"lucide-react\";\n\nexport const settingsIcons = {\n  // ... 其他设置项\n  enableNewFeature: NewIcon,\n};\n```\n\n### 3. 添加翻译文本\n\n在所有语言文件中添加翻译：\n\n- `app/src/locales/zh_CN.yml` - 简体中文\n- `app/src/locales/zh_TW.yml` - 繁体中文\n- `app/src/locales/en.yml` - 英文\n- `app/src/locales/zh_TWC.yml` - 接地气繁体中文\n\n**翻译结构：**\n\n```yaml\nsettings:\n  settingKey:\n    title: \"设置项标题\"\n    description: |\n      设置项的详细描述\n      可以多行\n    options: # 仅枚举类型需要\n      option1: \"选项1显示文本\"\n      option2: \"选项2显示文本\"\n```\n\n**示例：**\n\n```yaml\nsettings:\n  enableNewFeature:\n    title: \"启用新功能\"\n    description: |\n      开启后将启用新功能特性。\n      此功能可能会影响性能。\n  newMode:\n    title: \"新模式\"\n    description: \"选择新的模式选项\"\n    options:\n      mode1: \"模式一\"\n      mode2: \"模式二\"\n```\n\n### 4. 将设置项添加到分组中\n\n在 `app/src/sub/SettingsWindow/settings.tsx` 文件的 `categories` 对象中，将新设置项的键名添加到相应的分组数组中。\n\n**分组结构：**\n\n```typescript\nconst categories = {\n  visual: {           // 一级分类：视觉\n    basic: [...],     // 二级分组：基础\n    background: [...], // 二级分组：背景\n    node: [...],      // 二级分组：节点样式\n    // ...\n  },\n  automation: {       // 一级分类：自动化\n    autoNamer: [...],\n    autoSave: [...],\n    // ...\n  },\n  control: {         // 一级分类：控制\n    mouse: [...],\n    cameraMove: [...],\n    // ...\n  },\n  performance: {     // 一级分类：性能\n    memory: [...],\n    cpu: [...],\n    // ...\n  },\n  ai: {              // 一级分类：AI\n    api: [...],\n  },\n};\n```\n\n**添加设置项到分组：**\n\n```typescript\nconst categories = {\n  visual: {\n    basic: [\n      \"language\",\n      \"isClassroomMode\",\n      \"enableNewFeature\", // 添加新设置项\n      // ...\n    ],\n  },\n  // ...\n};\n```\n\n**分组选择指南：**\n\n- **visual（视觉）**：界面显示、主题、背景、节点样式、连线样式等\n  - `basic`: 基础视觉设置\n  - `background`: 背景相关设置\n  - `node`: 节点样式设置\n  - `edge`: 连线样式设置\n  - `section`: Section 框的样式设置\n  - `entityDetails`: 实体详情面板设置\n  - `debug`: 调试相关设置\n  - `miniWindow`: 迷你窗口设置\n  - `experimental`: 实验性视觉功能\n- **automation（自动化）**：自动保存、自动备份、自动命名等\n  - `autoNamer`: 自动命名相关\n  - `autoSave`: 自动保存相关\n  - `autoBackup`: 自动备份相关\n  - `autoImport`: 自动导入相关\n- **control（控制）**：鼠标、键盘、触摸板、相机控制等\n  - `mouse`: 鼠标相关设置\n  - `touchpad`: 触摸板设置\n  - `cameraMove`: 相机移动设置\n  - `cameraZoom`: 相机缩放设置\n  - `objectSelect`: 对象选择设置\n  - `textNode`: 文本节点编辑设置\n  - `edge`: 连线操作设置\n  - `generateNode`: 节点生成设置\n  - `gamepad`: 游戏手柄设置\n- **performance（性能）**：内存、CPU、渲染性能相关\n  - `memory`: 内存相关设置\n  - `cpu`: CPU 相关设置\n  - `render`: 渲染相关设置\n  - `experimental`: 实验性性能功能\n- **ai（AI）**：AI 相关设置\n  - `api`: AI API 配置\n\n**注意：** 如果设置项不属于任何现有分组，可以：\n\n1. 添加到最接近的现有分组\n2. 在相应分类下创建新的分组（需要同时更新翻译文件中的分类结构）\n\n### 5. 在设置页面中使用 SettingField 组件\n\n设置项添加到分组后，会在设置页面的相应分组中自动显示。如果需要手动渲染或添加额外内容，可以使用 `SettingField` 组件：\n\n**基本用法：**\n\n```tsx\nimport { SettingField } from \"@/components/ui/field\";\n\n<SettingField settingKey=\"enableNewFeature\" />;\n```\n\n**带额外内容的用法：**\n\n```tsx\n<SettingField settingKey=\"enableNewFeature\" extra={<Button>额外按钮</Button>} />\n```\n\n**注意：** 大多数情况下，只需要将设置项添加到 `categories` 中即可，设置页面会自动渲染。只有在需要特殊布局或额外功能时才需要手动使用 `SettingField` 组件。\n\n## SettingField 组件的自动类型推断\n\n`SettingField` 组件会根据 schema 定义自动渲染对应的 UI 控件：\n\n- **字符串类型** → `Input` 输入框\n- **数字类型（有 min/max）** → `Slider` 滑块 + `Input` 数字输入框\n- **数字类型（无范围）** → `Input` 数字输入框\n- **布尔类型** → `Switch` 开关\n- **枚举类型（Union）** → `Select` 下拉选择框\n\n## 访问设置值\n\n在代码中访问设置值：\n\n```typescript\nimport { Settings } from \"@/core/service/Settings\";\n\n// 读取设置值\nconst value = Settings.enableNewFeature;\n\n// 修改设置值\nSettings.enableNewFeature = true;\n\n// 监听设置变化（返回取消监听的函数）\nconst unsubscribe = Settings.watch(\"enableNewFeature\", (newValue) => {\n  console.log(\"设置已更改:\", newValue);\n});\n\n// React Hook 方式（在组件中使用）\nconst [value, setValue] = Settings.use(\"enableNewFeature\");\n```\n\n## 注意事项\n\n1. **设置项键名命名规范：** 使用驼峰命名法（camelCase），如 `enableNewFeature`\n2. **默认值：** 所有设置项都必须提供默认值（`.default()`）\n3. **类型安全：** TypeScript 会自动推断类型，确保类型一致性\n4. **持久化：** 设置值会自动保存到 `settings.json` 文件中\n5. **翻译键名：** 翻译文件中的键名必须与设置项的键名完全一致\n6. **图标可选：** 如果不需要图标，可以不在 `settingsIcons` 中添加，组件会使用 Fragment\n7. **分组必须：** 所有设置项都必须添加到 `categories` 对象中的相应分组，否则不会在设置页面中显示\n8. **分组选择：** 根据设置项的功能特性选择合适的分类和分组，保持设置页面的逻辑清晰\n\n## 完整示例\n\n假设要添加一个\"启用暗色模式\"的设置项：\n\n**1. Settings.tsx:**\n\n```typescript\nexport const settingsSchema = z.object({\n  // ... 其他设置项\n  enableDarkMode: z.boolean().default(false),\n});\n```\n\n**2. SettingsIcons.tsx:**\n\n```typescript\nimport { Moon } from \"lucide-react\";\n\nexport const settingsIcons = {\n  // ... 其他设置项\n  enableDarkMode: Moon,\n};\n```\n\n**3. zh_CN.yml:**\n\n```yaml\nsettings:\n  enableDarkMode:\n    title: \"启用暗色模式\"\n    description: \"开启后将使用暗色主题界面\"\n```\n\n**4. settings.tsx - 添加到分组：**\n\n```typescript\nconst categories = {\n  visual: {\n    basic: [\n      \"language\",\n      \"isClassroomMode\",\n      \"enableDarkMode\", // 添加到基础视觉设置分组\n      // ...\n    ],\n    // ...\n  },\n  // ...\n};\n```\n\n**5. 设置项会自动显示：**\n设置项添加到 `categories` 后，会在设置页面的\"视觉 > 基础\"分组中自动显示，无需手动使用 `SettingField` 组件。\n\n## 快捷设置栏支持\n\n如果希望设置项出现在快捷设置栏（Quick Settings Toolbar）中，需要：\n\n1. 确保设置项已正确创建（完成上述 4 步）\n2. 快捷设置栏会自动显示所有布尔类型的设置项\n3. 可以通过 `QuickSettingsManager.addQuickSetting()` 手动添加非布尔类型的设置项\n"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\"tauri-apps.tauri-vscode\", \"dbaeumer.vscode-eslint\", \"esbenp.prettier-vscode\"]\n}\n"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n  // Use IntelliSense to learn about possible attributes.\n  // Hover to view descriptions of existing attributes.\n  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387\n  \"version\": \"0.2.0\",\n  \"configurations\": [\n    {\n      \"type\": \"lldb\",\n      \"request\": \"launch\",\n      \"name\": \"Tauri Development Debug\",\n      \"cargo\": {\n        \"args\": [\"build\", \"--manifest-path=./src-tauri/Cargo.toml\", \"--no-default-features\"],\n        \"cwd\": \"${workspaceFolder}/app\"\n      },\n      \"cwd\": \"${workspaceFolder}/app\",\n      // task for the `beforeDevCommand` if used, must be configured in `.vscode/tasks.json`\n      \"preLaunchTask\": \"ui:dev\"\n    },\n    {\n      \"type\": \"lldb\",\n      \"request\": \"launch\",\n      \"name\": \"Tauri Production Debug\",\n      \"cargo\": {\n        \"args\": [\"build\", \"--release\", \"--manifest-path=./src-tauri/Cargo.toml\"],\n        \"cwd\": \"${workspaceFolder}/app\"\n      },\n      \"cwd\": \"${workspaceFolder}/app\",\n      // task for the `beforeBuildCommand` if used, must be configured in `.vscode/tasks.json`\n      \"preLaunchTask\": \"ui:build\"\n    }\n  ]\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"i18n-ally.localesPaths\": [\"src/locales\"],\n  \"i18n-ally.keystyle\": \"nested\",\n  \"eslint.options\": {\n    \"overrideConfigFile\": \"./eslint.config.js\"\n  },\n  \"vue.server.includeLanguages\": [\"vue\"],\n  \"rust-analyzer.linkedProjects\": [\"app/src-tauri/Cargo.toml\"],\n  \"files.associations\": {\n    \"*.pg-theme\": \"yaml\"\n  },\n  \"todo-tree.tree.disableCompactFolders\": false,\n  \"todo-tree.tree.scanMode\": \"workspace only\",\n  \"typescript.tsdk\": \"app/node_modules/typescript/lib\"\n}\n"
  },
  {
    "path": ".vscode/tasks.json",
    "content": "{\n  // See https://go.microsoft.com/fwlink/?LinkId=733558\n  // for the documentation about the tasks.json format\n  \"version\": \"2.0.0\",\n  \"tasks\": [\n    {\n      \"label\": \"ui:dev\",\n      \"type\": \"shell\",\n      // `dev` keeps running in the background\n      // ideally you should also configure a `problemMatcher`\n      // see https://code.visualstudio.com/docs/editor/tasks#_can-a-background-task-be-used-as-a-prelaunchtask-in-launchjson\n      \"isBackground\": true,\n      // change this to your `beforeDevCommand`:\n      \"command\": \"pnpm\",\n      \"args\": [\"dev\"]\n    },\n    {\n      \"label\": \"ui:build\",\n      \"type\": \"shell\",\n      // change this to your `beforeBuildCommand`:\n      \"command\": \"pnpm\",\n      \"args\": [\"build\"]\n    }\n  ]\n}\n"
  },
  {
    "path": ".zed/settings.json",
    "content": "// Folder-specific settings\n//\n// For a full list of overridable settings, and general information on folder-specific settings,\n// see the documentation: https://zed.dev/docs/configuring-zed#settings-files\n{\n  \"file_types\": {\n    \"YAML\": [\"*.pg-theme\"]\n  },\n  \"lsp\": {\n    \"vtsls\": {\n      \"settings\": {\n        \"typescript\": {\n          \"tsdk\": \"app/node_modules/typescript/lib\"\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participation in our\ncommunity a harassment-free experience for everyone, regardless of age, body\nsize, visible or invisible disability, ethnicity, sex characteristics, gender\nidentity and expression, level of experience, education, socio-economic status,\nnationality, personal appearance, race, religion, or sexual identity\nand orientation.\n\nWe pledge to act and interact in ways that contribute to an open, welcoming,\ndiverse, inclusive, and healthy community.\n\n## Our Standards\n\nExamples of behavior that contributes to a positive environment for our\ncommunity include:\n\n- Demonstrating empathy and kindness toward other people\n- Being respectful of differing opinions, viewpoints, and experiences\n- Giving and gracefully accepting constructive feedback\n- Accepting responsibility and apologizing to those affected by our mistakes,\n  and learning from the experience\n- Focusing on what is best not just for us as individuals, but for the\n  overall community\n\nExamples of unacceptable behavior include:\n\n- The use of sexualized language or imagery, and sexual attention or\n  advances of any kind\n- Trolling, insulting or derogatory comments, and personal or political attacks\n- Public or private harassment\n- Publishing others' private information, such as a physical or email\n  address, without their explicit permission\n- Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n## Enforcement Responsibilities\n\nCommunity leaders are responsible for clarifying and enforcing our standards of\nacceptable behavior and will take appropriate and fair corrective action in\nresponse to any behavior that they deem inappropriate, threatening, offensive,\nor harmful.\n\nCommunity leaders have the right and responsibility to remove, edit, or reject\ncomments, commits, code, wiki edits, issues, and other contributions that are\nnot aligned to this Code of Conduct, and will communicate reasons for moderation\ndecisions when appropriate.\n\n## Scope\n\nThis Code of Conduct applies within all community spaces, and also applies when\nan individual is officially representing the community in public spaces.\nExamples of representing our community include using an official e-mail address,\nposting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported to the community leaders responsible for enforcement at\nsupport@project-graph.top.\nAll complaints will be reviewed and investigated promptly and fairly.\n\nAll community leaders are obligated to respect the privacy and security of the\nreporter of any incident.\n\n## Enforcement Guidelines\n\nCommunity leaders will follow these Community Impact Guidelines in determining\nthe consequences for any action they deem in violation of this Code of Conduct:\n\n### 1. Correction\n\n**Community Impact**: Use of inappropriate language or other behavior deemed\nunprofessional or unwelcome in the community.\n\n**Consequence**: A private, written warning from community leaders, providing\nclarity around the nature of the violation and an explanation of why the\nbehavior was inappropriate. A public apology may be requested.\n\n### 2. Warning\n\n**Community Impact**: A violation through a single incident or series\nof actions.\n\n**Consequence**: A warning with consequences for continued behavior. No\ninteraction with the people involved, including unsolicited interaction with\nthose enforcing the Code of Conduct, for a specified period of time. This\nincludes avoiding interactions in community spaces as well as external channels\nlike social media. Violating these terms may lead to a temporary or\npermanent ban.\n\n### 3. Temporary Ban\n\n**Community Impact**: A serious violation of community standards, including\nsustained inappropriate behavior.\n\n**Consequence**: A temporary ban from any sort of interaction or public\ncommunication with the community for a specified period of time. No public or\nprivate interaction with the people involved, including unsolicited interaction\nwith those enforcing the Code of Conduct, is allowed during this period.\nViolating these terms may lead to a permanent ban.\n\n### 4. Permanent Ban\n\n**Community Impact**: Demonstrating a pattern of violation of community\nstandards, including sustained inappropriate behavior, harassment of an\nindividual, or aggression toward or disparagement of classes of individuals.\n\n**Consequence**: A permanent ban from any sort of public interaction within\nthe community.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage],\nversion 2.0, available at\nhttps://www.contributor-covenant.org/version/2/0/code_of_conduct.html.\n\nCommunity Impact Guidelines were inspired by [Mozilla's code of conduct\nenforcement ladder](https://github.com/mozilla/diversity).\n\n[homepage]: https://www.contributor-covenant.org\n\nFor answers to common questions about this code of conduct, see the FAQ at\nhttps://www.contributor-covenant.org/faq. Translations are available at\nhttps://www.contributor-covenant.org/translations.\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "Please refer to [Contributing](https://project-graph.top/docs/contribute)\n"
  },
  {
    "path": "TODO.md",
    "content": ""
  },
  {
    "path": "app/.browserslistrc",
    "content": "Chrome>=111, Edge>=111, Android>=111, Safari>=16.4"
  },
  {
    "path": "app/LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Use with the GNU Affero General Public License.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    <program>  Copyright (C) <year>  <name of author>\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n<https://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<https://www.gnu.org/licenses/why-not-lgpl.html>.\n"
  },
  {
    "path": "app/README.md",
    "content": "<div align=\"center\">\n\n<img src=\"app/src/assets/logo-animated.svg\" height=\"150\" alt=\"banner\">\n<h1>Project Graph</h1>\n\n</div>\n\n![License](https://img.shields.io/badge/License-MIT%20and%20GPL%203.0-green.svg)\n[![website](https://img.shields.io/badge/website-project--graph.top-purple)](https://project-graph.top)\n\n---\n\nProject Graph 是一款专注于快速绘制节点图的桌面工具，旨在帮助用户高效地创建项目拓扑图和进行头脑风暴。用户可以通过简单的拖拽操作来创建和连接节点，从而轻松构建复杂的图结构。该工具还具备层级清晰的结构，能够直观地呈现项目关系或数据关系。\n\n## 功能亮点\n\n- 🚀 基于 [Pixi.js](https://pixijs.com/) 构建，性能卓越，支持大规模节点图的绘制\n- 🎨 快速简单的操作方法\n- 💻 跨平台支持，适用于 Windows、macOS 和 Linux\n- 📁 特有的文件格式 `.prg` (Media Type: `application/vnd.project-graph`)，便于存储和分享\n\n## 社区\n\n加入社群: https://project-graph.top/docs/app/community\n\n贡献代码: https://project-graph.top/docs/contribute\n\n## 鸣谢\n\n![contributors](https://contrib.rocks/image?repo=LiRenTech/project-graph)\n\n所有捐赠的用户（[见此处](https://github.com/graphif/project-graph/blob/master/app/src/sub/SettingsWindow/credits.tsx#L15)）\n\n### 服务器提供商\n\n[慕乐云](https://muleyun.com/aff/HLONILNH)\n\n[YXVM](https://yxvm.com/)\n\n[ZMTO](https://console.zmto.com/?affid=1574)\n\n![ZMTO Logo](https://console.zmto.com/templates/2019/dist/images/logo_white.svg)\n\n[DartNode](https://dartnode.com)\n\n[![Powered by DartNode](https://dartnode.com/branding/DN-Open-Source-sm.png)](https://dartnode.com \"Powered by DartNode - Free VPS for Open Source\")\n"
  },
  {
    "path": "app/components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"new-york\",\n  \"rsc\": false,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"\",\n    \"css\": \"src/css/index.css\",\n    \"baseColor\": \"neutral\",\n    \"cssVariables\": true,\n    \"prefix\": \"\"\n  },\n  \"aliases\": {\n    \"components\": \"@/components\",\n    \"utils\": \"@/utils/cn\",\n    \"ui\": \"@/components/ui\",\n    \"lib\": \"@/utils\",\n    \"hooks\": \"@/hooks\"\n  },\n  \"iconLibrary\": \"lucide\"\n}\n"
  },
  {
    "path": "app/index.html",
    "content": "<!doctype html>\n<html>\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\" />\n    <title>Project Graph</title>\n  </head>\n  <body class=\"bg-transparent\">\n    <div id=\"root\" class=\"h-screen\"></div>\n    <script type=\"module\">\n      // 此处configureSerializer必须在所有代码的最开头\n      // 否则@serializable装饰器无法获取正确的类名\n      import { configureSerializer } from \"@graphif/serializer\";\n      import { getOriginalNameOf } from \"virtual:original-class-name\";\n      configureSerializer(getOriginalNameOf);\n    </script>\n    <script type=\"module\" src=\"/src/main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "app/package.json",
    "content": "{\n  \"name\": \"@graphif/project-graph\",\n  \"private\": true,\n  \"version\": \"0.0.0\",\n  \"type\": \"module\",\n  \"license\": \"GPL-3.0-only\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"type-check\": \"tsc --noEmit\",\n    \"preview\": \"vite preview\",\n    \"tauri\": \"tauri\",\n    \"tauri:dev\": \"tauri dev\",\n    \"tauri:build\": \"node ./scripts/tauri_build.js\",\n    \"sync-locales\": \"tsx ./scripts/sync-locales.ts\"\n  },\n  \"dependencies\": {\n    \"@ariakit/react\": \"^0.4.18\",\n    \"@emoji-mart/data\": \"1.2.1\",\n    \"@graphif/data-structures\": \"workspace:*\",\n    \"@graphif/serializer\": \"workspace:*\",\n    \"@graphif/shapes\": \"workspace:*\",\n    \"@headlessui/react\": \"^2.2.4\",\n    \"@modyfi/vite-plugin-yaml\": \"^1.1.1\",\n    \"@msgpack/msgpack\": \"^3.1.2\",\n    \"@octokit/rest\": \"^22.0.0\",\n    \"@platejs/ai\": \"^49.2.14\",\n    \"@platejs/basic-nodes\": \"^49.0.0\",\n    \"@platejs/basic-styles\": \"^49.0.0\",\n    \"@platejs/callout\": \"^49.0.0\",\n    \"@platejs/caption\": \"^49.0.0\",\n    \"@platejs/code-block\": \"^49.0.0\",\n    \"@platejs/combobox\": \"^49.0.0\",\n    \"@platejs/comment\": \"^49.0.0\",\n    \"@platejs/core\": \"^49.2.12\",\n    \"@platejs/date\": \"^49.0.2\",\n    \"@platejs/dnd\": \"^49.2.10\",\n    \"@platejs/emoji\": \"^49.0.0\",\n    \"@platejs/floating\": \"^49.0.0\",\n    \"@platejs/indent\": \"^49.0.0\",\n    \"@platejs/layout\": \"^49.2.1\",\n    \"@platejs/link\": \"^49.1.1\",\n    \"@platejs/list\": \"^49.2.0\",\n    \"@platejs/markdown\": \"^49.2.14\",\n    \"@platejs/math\": \"^49.0.0\",\n    \"@platejs/media\": \"^49.0.0\",\n    \"@platejs/mention\": \"^49.0.0\",\n    \"@platejs/resizable\": \"^49.0.0\",\n    \"@platejs/selection\": \"^49.2.4\",\n    \"@platejs/slate\": \"^49.2.4\",\n    \"@platejs/suggestion\": \"^49.0.0\",\n    \"@platejs/table\": \"^49.1.13\",\n    \"@platejs/toc\": \"^49.0.0\",\n    \"@platejs/toggle\": \"^49.0.0\",\n    \"@platejs/utils\": \"^49.2.12\",\n    \"@radix-ui/react-alert-dialog\": \"^1.1.15\",\n    \"@radix-ui/react-avatar\": \"^1.1.10\",\n    \"@radix-ui/react-checkbox\": \"^1.3.3\",\n    \"@radix-ui/react-collapsible\": \"^1.1.11\",\n    \"@radix-ui/react-context-menu\": \"^2.2.15\",\n    \"@radix-ui/react-dialog\": \"^1.1.14\",\n    \"@radix-ui/react-dropdown-menu\": \"^2.1.16\",\n    \"@radix-ui/react-menu\": \"^2.1.15\",\n    \"@radix-ui/react-menubar\": \"^1.1.15\",\n    \"@radix-ui/react-popover\": \"^1.1.15\",\n    \"@radix-ui/react-popper\": \"^1.2.7\",\n    \"@radix-ui/react-primitive\": \"^2.1.3\",\n    \"@radix-ui/react-progress\": \"^1.1.7\",\n    \"@radix-ui/react-roving-focus\": \"^1.1.11\",\n    \"@radix-ui/react-select\": \"^2.2.5\",\n    \"@radix-ui/react-separator\": \"^1.1.7\",\n    \"@radix-ui/react-slider\": \"^1.3.5\",\n    \"@radix-ui/react-slot\": \"^1.2.3\",\n    \"@radix-ui/react-switch\": \"^1.2.5\",\n    \"@radix-ui/react-tabs\": \"^1.1.12\",\n    \"@radix-ui/react-toggle-group\": \"^1.1.11\",\n    \"@radix-ui/react-toolbar\": \"^1.1.11\",\n    \"@radix-ui/react-tooltip\": \"^1.2.7\",\n    \"@react-scan/vite-plugin-react-scan\": \"^0.1.8\",\n    \"@swc-jotai/react-refresh\": \"^0.3.0\",\n    \"@tailwindcss/vite\": \"^4.1.11\",\n    \"@tauri-apps/api\": \"^2.6.0\",\n    \"@tauri-apps/cli\": \"^2.6.2\",\n    \"@tauri-apps/plugin-cli\": \"~2.4.0\",\n    \"@tauri-apps/plugin-clipboard-manager\": \"^2.3.0\",\n    \"@tauri-apps/plugin-dialog\": \"^2.3.0\",\n    \"@tauri-apps/plugin-fs\": \"~2.4.0\",\n    \"@tauri-apps/plugin-global-shortcut\": \"^2.3.0\",\n    \"@tauri-apps/plugin-http\": \"^2.5.0\",\n    \"@tauri-apps/plugin-os\": \"^2.3.0\",\n    \"@tauri-apps/plugin-process\": \"~2.3.0\",\n    \"@tauri-apps/plugin-shell\": \"2.3.0\",\n    \"@tauri-apps/plugin-store\": \"^2.3.0\",\n    \"@tauri-apps/plugin-updater\": \"~2.9.0\",\n    \"@tauri-apps/plugin-window-state\": \"^2.3.0\",\n    \"@types/lodash\": \"^4.17.20\",\n    \"@types/markdown-it\": \"^14.1.2\",\n    \"@types/md5\": \"^2.3.5\",\n    \"@types/react\": \"^19.1.8\",\n    \"@types/react-dom\": \"^19.1.6\",\n    \"@types/uuid\": \"^10.0.0\",\n    \"@udecode/cn\": \"^49.0.15\",\n    \"@udecode/react-hotkeys\": \"^37.0.0\",\n    \"@udecode/react-utils\": \"^49.0.15\",\n    \"@udecode/utils\": \"^47.2.7\",\n    \"@vitejs/plugin-react-oxc\": \"^0.2.2\",\n    \"@zip.js/zip.js\": \"^2.7.63\",\n    \"bcrypt\": \"^6.0.0\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"cmdk\": \"^1.1.1\",\n    \"date-fns\": \"^4.1.0\",\n    \"decimal.js\": \"^10.5.0\",\n    \"driver.js\": \"^1.3.6\",\n    \"events\": \"^3.3.0\",\n    \"fuse.js\": \"^7.1.0\",\n    \"html2canvas-pro\": \"^1.5.11\",\n    \"i18next\": \"^25.3.1\",\n    \"jotai\": \"catalog:\",\n    \"jsdom\": \"^26.1.0\",\n    \"jsondiffpatch\": \"^0.7.3\",\n    \"lodash\": \"^4.17.21\",\n    \"lowlight\": \"^3.3.0\",\n    \"lucide-react\": \"^0.525.0\",\n    \"md5\": \"^2.3.0\",\n    \"mime\": \"^4.0.7\",\n    \"motion\": \"^12.23.22\",\n    \"next-themes\": \"^0.4.6\",\n    \"openai\": \"^5.8.2\",\n    \"pdf-lib\": \"^1.17.1\",\n    \"platejs\": \"^49.2.12\",\n    \"react\": \"catalog:\",\n    \"react-day-picker\": \"^9.9.0\",\n    \"react-dom\": \"catalog:\",\n    \"react-i18next\": \"^15.6.0\",\n    \"react-lite-youtube-embed\": \"^2.5.3\",\n    \"react-player\": \"3.3.1\",\n    \"react-scan\": \"^0.4.3\",\n    \"react-textarea-autosize\": \"^8.5.9\",\n    \"reflect-metadata\": \"^0.2.2\",\n    \"rehype-katex\": \"^7.0.1\",\n    \"rehype-react\": \"^8.0.0\",\n    \"remark\": \"^15.0.1\",\n    \"remark-breaks\": \"^4.0.0\",\n    \"remark-gfm\": \"^4.0.1\",\n    \"remark-math\": \"^6.0.0\",\n    \"remark-parse\": \"^11.0.0\",\n    \"remark-rehype\": \"^11.1.2\",\n    \"sonner\": \"^2.0.7\",\n    \"tailwind-merge\": \"^3.3.1\",\n    \"tailwind-scrollbar-hide\": \"^4.0.0\",\n    \"tailwindcss\": \"^4.1.11\",\n    \"tauri-plugin-system-info-api\": \"^2.0.10\",\n    \"tw-animate-css\": \"^1.3.6\",\n    \"typescript\": \"catalog:\",\n    \"unified\": \"^11.0.5\",\n    \"unplugin-operator-overload\": \"^1.0.0\",\n    \"unplugin-original-class-name\": \"^1.0.0\",\n    \"use-file-picker\": \"2.1.2\",\n    \"uuid\": \"^11.1.0\",\n    \"valibot\": \"^1.1.0\",\n    \"vconsole\": \"^3.15.1\",\n    \"vditor\": \"^3.11.1\",\n    \"vite\": \"^7.0.2\",\n    \"vite-plugin-svgr\": \"^4.3.0\",\n    \"vitest\": \"3.2.4\",\n    \"vscode-uri\": \"^3.1.0\",\n    \"yaml\": \"^2.8.0\",\n    \"zod\": \"^3.25.74\"\n  },\n  \"devDependencies\": {\n    \"opencc-js\": \"^1.0.5\",\n    \"tsx\": \"^4.21.0\"\n  }\n}\n"
  },
  {
    "path": "app/scripts/sync-locales.ts",
    "content": "import * as OpenCC from \"opencc-js\";\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\nconst zhCNPath = path.resolve(__dirname, \"../src/locales/zh_CN.yml\");\nconst zhTWPath = path.resolve(__dirname, \"../src/locales/zh_TW.yml\");\n\nconst converter = OpenCC.Converter({ from: \"cn\", to: \"tw\" });\n\nfunction convert() {\n  try {\n    if (!fs.existsSync(zhCNPath)) {\n      console.error(`Source file not found: ${zhCNPath}`);\n      return;\n    }\n\n    const content = fs.readFileSync(zhCNPath, \"utf-8\");\n    const converted = converter(content);\n\n    fs.writeFileSync(zhTWPath, converted, \"utf-8\");\n    console.log(`Successfully updated zh_TW.yml from zh_CN.yml`);\n  } catch (error) {\n    console.error(`Error during conversion:`, error);\n  }\n}\n\nconvert();\n"
  },
  {
    "path": "app/scripts/tauri_build.js",
    "content": "/* eslint-disable */\nimport { spawn } from \"child_process\";\n\n// 从环境变量获取参数并分割成数组\nconst tauriBuildArgs = process.env.TAURI_BUILD_ARGS ? process.env.TAURI_BUILD_ARGS.split(\" \") : [];\n\n// 构造完整命令参数\nconst args = [\"tauri\", \"build\", ...tauriBuildArgs];\n\nconst pnpmBin = process.env.npm_execpath;\n\nlet child;\n\n// 使用 spawn 执行命令\nif (pnpmBin.endsWith(\"js\")) {\n  child = spawn(\"node\", [pnpmBin, ...args], {\n    stdio: \"inherit\",\n  });\n} else {\n  child = spawn(pnpmBin, args, {\n    stdio: \"inherit\",\n  });\n}\n\n// 处理退出\nchild.on(\"exit\", (code) => {\n  process.exit(code || 0);\n});\n"
  },
  {
    "path": "app/splash.html",
    "content": "<!doctype html>\n<html>\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\" />\n    <title>Project Graph Startup</title>\n    <style>\n      @import \"tailwindcss\";\n    </style>\n  </head>\n  <body class=\"bg-transparent\">\n    <div id=\"root\" class=\"h-screen bg-black text-white\">loading...</div>\n    <script type=\"module\">\n      import { getCurrentWindow } from \"@tauri-apps/api/window\";\n      getCurrentWindow().show();\n    </script>\n  </body>\n</html>\n"
  },
  {
    "path": "app/src/App.tsx",
    "content": "import MyContextMenuContent from \"@/components/context-menu-content\";\nimport RenderSubWindows from \"@/components/render-sub-windows\";\nimport { Button } from \"@/components/ui/button\";\nimport { ContextMenu, ContextMenuTrigger } from \"@/components/ui/context-menu\";\nimport { Dialog } from \"@/components/ui/dialog\";\nimport Welcome from \"@/components/welcome-page\";\nimport { Project, ProjectState } from \"@/core/Project\";\nimport { GlobalMenu } from \"@/core/service/GlobalMenu\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { Telemetry } from \"@/core/service/Telemetry\";\nimport { Themes } from \"@/core/service/Themes\";\nimport { globalShortcutManager } from \"@/core/service/controlService/shortcutKeysEngine/GlobalShortcutManager\";\nimport {\n  activeProjectAtom,\n  isClassroomModeAtom,\n  isClickThroughEnabledAtom,\n  isWindowAlwaysOnTopAtom,\n  isWindowMaxsizedAtom,\n  projectsAtom,\n} from \"@/state\";\nimport { getVersion } from \"@tauri-apps/api/app\";\nimport { getAllWindows, getCurrentWindow } from \"@tauri-apps/api/window\";\nimport { arch, platform, version } from \"@tauri-apps/plugin-os\";\nimport { restoreStateCurrent, saveWindowState, StateFlags } from \"@tauri-apps/plugin-window-state\";\nimport { useAtom } from \"jotai\";\nimport { ChevronsLeftRight, Copy, Minus, Pin, PinOff, Square, X } from \"lucide-react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport { cpuInfo } from \"tauri-plugin-system-info-api\";\nimport { DragFileIntoStageEngine } from \"./core/service/dataManageService/dragFileIntoStageEngine/dragFileIntoStageEngine\";\nimport { cn } from \"./utils/cn\";\nimport { isMac, isWindows } from \"./utils/platform\";\nimport { KeyBindsUI } from \"./core/service/controlService/shortcutKeysEngine/KeyBindsUI\";\nimport { checkAndFixShortcutStorage } from \"./core/service/controlService/shortcutKeysEngine/ShortcutKeyFixer\";\nimport { ProjectTabs } from \"./ProjectTabs\";\nimport { DropWindowCover } from \"./DropWindowCover\";\nimport ToolbarContent from \"./components/toolbar-content\";\nimport RightToolbar from \"./components/right-toolbar\";\nimport ThemeModeSwitch from \"@/components/theme-mode-switch\";\n\nexport default function App() {\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  const [_, _setMaximized] = useAtom(isWindowMaxsizedAtom);\n\n  const [projects, setProjects] = useAtom(projectsAtom);\n  const [activeProject, setActiveProject] = useAtom(activeProjectAtom);\n  const canvasWrapperRef = useRef<HTMLDivElement>(null);\n  // const [isWide, setIsWide] = useState(false);\n  const [telemetryEventSent, setTelemetryEventSent] = useState(false);\n  const [dropMouseLocation, setDropMouseLocation] = useState<\"top\" | \"middle\" | \"bottom\" | \"notInWindowZone\">(\n    \"notInWindowZone\",\n  );\n  const [ignoreMouseEvents, setIgnoreMouseEvents] = useState(false);\n  const [isClassroomMode, setIsClassroomMode] = useAtom(isClassroomModeAtom);\n  const [showQuickSettingsToolbar, setShowQuickSettingsToolbar] = useState(Settings.showQuickSettingsToolbar);\n  const [windowBackgroundAlpha, setWindowBackgroundAlpha] = useState(Settings.windowBackgroundAlpha);\n\n  const contextMenuTriggerRef = useRef<HTMLDivElement>(null);\n\n  // const { t } = useTranslation(\"app\");\n\n  useEffect(() => {\n    // 先修复老用户的快捷键缓存问题（F11快捷键）\n    (async () => {\n      await checkAndFixShortcutStorage();\n    })();\n    // 注册UI级别快捷键\n    KeyBindsUI.registerAllUIKeyBinds();\n    KeyBindsUI.uiStartListen();\n\n    // 修复鼠标拖出窗口后触发上下文菜单的问题\n    window.addEventListener(\"contextmenu\", (event) => {\n      if (\n        event.clientX < 0 ||\n        event.clientX > window.innerWidth ||\n        event.clientY < 0 ||\n        event.clientY > window.innerHeight\n      )\n        event.preventDefault();\n    });\n\n    // 全局错误处理\n    window.addEventListener(\"error\", (event) => {\n      Telemetry.event(\"未知错误\", String(event.error));\n    });\n\n    // 监听主题样式切换\n    Settings.watch(\"theme\", (value) => {\n      Themes.applyThemeById(value);\n    });\n\n    // 监听主题模式切换\n    Settings.watch(\"themeMode\", (value) => {\n      const targetTheme = value === \"light\" ? Settings.lightTheme : Settings.darkTheme;\n      if (Settings.theme !== targetTheme) {\n        Settings.theme = targetTheme;\n      }\n    });\n    Settings.watch(\"lightTheme\", (value) => {\n      if (Settings.themeMode === \"light\" && Settings.theme !== value) {\n        Settings.theme = value;\n      }\n    });\n    Settings.watch(\"darkTheme\", (value) => {\n      if (Settings.themeMode === \"dark\" && Settings.theme !== value) {\n        Settings.theme = value;\n      }\n    });\n\n    // 监听快捷设置工具栏显示设置\n    const unwatchShowQuickSettingsToolbar = Settings.watch(\"showQuickSettingsToolbar\", (value) => {\n      setShowQuickSettingsToolbar(value);\n    });\n\n    // 监听窗口背景不透明度\n    const unwatchWindowBackgroundAlpha = Settings.watch(\"windowBackgroundAlpha\", (value) => {\n      setWindowBackgroundAlpha(value);\n    });\n\n    // 恢复窗口位置大小\n    restoreStateCurrent(StateFlags.SIZE | StateFlags.POSITION | StateFlags.MAXIMIZED);\n\n    // setIsWide(window.innerWidth / window.innerHeight > 1.8);\n\n    const unlisten1 = getCurrentWindow().onResized(() => {\n      if (!isOnResizedDisabled.current) {\n        isMaximizedWorkaround();\n      }\n      // setIsWide(window.innerWidth / window.innerHeight > 1.8);\n    });\n\n    if (!telemetryEventSent) {\n      setTelemetryEventSent(true);\n      (async () => {\n        const cpu = await cpuInfo();\n        await Telemetry.event(\"启动应用\", {\n          version: await getVersion(),\n          os: platform(),\n          arch: arch(),\n          osVersion: version(),\n          cpu: cpu.cpus[0].brand,\n          cpuCount: cpu.cpu_count,\n        });\n      })();\n    }\n\n    // 加载完成了，显示窗口\n    getCurrentWindow().show();\n    // 关闭splash\n    getAllWindows().then((windows) => {\n      const splash = windows.find((w) => w.label === \"splash\");\n      if (splash) {\n        splash.close();\n      }\n    });\n\n    // 初始化全局快捷键管理\n    globalShortcutManager.init();\n\n    return () => {\n      unlisten1?.then((f) => f());\n      KeyBindsUI.uiStopListen();\n      // 清理全局快捷键资源\n      unwatchShowQuickSettingsToolbar();\n      unwatchWindowBackgroundAlpha();\n      globalShortcutManager.dispose();\n    };\n  }, []);\n\n  useEffect(() => {\n    setIsClassroomMode(Settings.isClassroomMode);\n  }, [Settings.isClassroomMode]);\n\n  // https://github.com/tauri-apps/tauri/issues/5812\n  const isOnResizedDisabled = useRef(false);\n  function isMaximizedWorkaround() {\n    isOnResizedDisabled.current = true;\n    getCurrentWindow()\n      .isMaximized()\n      .then((isMaximized) => {\n        isOnResizedDisabled.current = false;\n        // your stuff\n        _setMaximized(isMaximized);\n      });\n  }\n\n  useEffect(() => {\n    if (!canvasWrapperRef.current) return;\n    if (!activeProject) return;\n    activeProject.canvas.mount(canvasWrapperRef.current);\n    activeProject.loop();\n    projects.filter((p) => p.uri.toString() !== activeProject.uri.toString()).forEach((p) => p.pause());\n    activeProject.canvas.element.addEventListener(\"pointerdown\", () => {\n      setIgnoreMouseEvents(true);\n    });\n    activeProject.canvas.element.addEventListener(\"pointerup\", () => {\n      setIgnoreMouseEvents(false);\n    });\n    const unlisten2 = getCurrentWindow().onDragDropEvent(async (event) => {\n      // Mac 上 event.payload.position 为逻辑像素，而 outerSize() 返回物理像素，\n      // 需用 scaleFactor 换算；Windows 上两者单位一致，不需要换算。\n      const size = await getCurrentWindow().outerSize();\n      const logicalHeight = isMac ? size.height / (await getCurrentWindow().scaleFactor()) : size.height;\n      if (event.payload.type === \"over\") {\n        if (event.payload.position.y <= logicalHeight / 3) {\n          setDropMouseLocation(\"top\");\n        } else if (event.payload.position.y <= (logicalHeight / 3) * 2) {\n          setDropMouseLocation(\"middle\");\n        } else {\n          setDropMouseLocation(\"bottom\");\n        }\n      } else if (event.payload.type === \"leave\") {\n        setDropMouseLocation(\"notInWindowZone\");\n      } else if (event.payload.type === \"drop\") {\n        setDropMouseLocation(\"notInWindowZone\");\n        if (event.payload.position.y <= logicalHeight / 3) {\n          DragFileIntoStageEngine.handleDrop(activeProject, event.payload.paths);\n        } else if (event.payload.position.y <= (logicalHeight / 3) * 2) {\n          DragFileIntoStageEngine.handleDropFileRelativePath(activeProject, event.payload.paths);\n        } else {\n          DragFileIntoStageEngine.handleDropFileAbsolutePath(activeProject, event.payload.paths);\n        }\n      }\n    });\n    return () => {\n      unlisten2?.then((f) => f());\n    };\n  }, [activeProject]);\n\n  /**\n   * 首次启动时显示欢迎页面\n   */\n  // const navigate = useNavigate();\n  // useEffect(() => {\n  //   if (LastLaunch.isFirstLaunch) {\n  //     navigate(\"/welcome\");\n  //   }\n  // }, []);\n\n  useEffect(() => {\n    let unlisten1: () => void;\n    /**\n     * 关闭窗口时的事件监听\n     */\n    getCurrentWindow()\n      .onCloseRequested(async (e) => {\n        e.preventDefault();\n\n        // 检查是否有未保存的项目\n        const unsavedProjects = projects.filter(\n          (project) => project.state === ProjectState.Unsaved || project.state === ProjectState.Stashed,\n        );\n\n        if (unsavedProjects.length > 0) {\n          // 弹出警告对话框\n          const response = await Dialog.buttons(\n            \"检测到未保存文件\",\n            `当前有 ${unsavedProjects.length} 个未保存的文件。直接关闭可能有文件被清空的风险，建议先手动保存文件。`,\n            [\n              { id: \"cancel\", label: \"取消\", variant: \"ghost\" },\n              { id: \"continue\", label: \"继续关闭\", variant: \"destructive\" },\n            ],\n          );\n\n          if (response === \"cancel\") {\n            // 用户选择取消关闭，返回\n            return;\n          }\n          // 用户选择继续关闭，执行原有关闭流程\n        }\n\n        try {\n          for (const project of projects) {\n            console.log(\"尝试关闭\", project);\n            await closeProject(project);\n          }\n        } catch {\n          Telemetry.event(\"关闭应用提示是否保存文件选择了取消\");\n          return;\n        }\n        Telemetry.event(\"关闭应用\");\n        // 保存窗口位置\n        await saveWindowState(StateFlags.SIZE | StateFlags.POSITION | StateFlags.MAXIMIZED);\n        await getCurrentWindow().destroy();\n      })\n      .then((it) => {\n        unlisten1 = it;\n      });\n\n    for (const project of projects) {\n      project.on(\"state-change\", () => {\n        // 强制重新渲染一次\n        setProjects([...projects]);\n      });\n      project.on(\"contextmenu\", ({ x, y }) => {\n        contextMenuTriggerRef.current?.dispatchEvent(\n          new MouseEvent(\"contextmenu\", {\n            bubbles: true,\n            clientX: x,\n            clientY: y,\n          }),\n        );\n        setProjects([...projects]);\n      });\n    }\n\n    return () => {\n      unlisten1?.();\n      for (const project of projects) {\n        project.removeAllListeners(\"state-change\");\n        project.removeAllListeners(\"contextmenu\");\n      }\n    };\n  }, [projects.length]);\n\n  const closeProject = async (project: Project) => {\n    if (project.state === ProjectState.Stashed) {\n      toast(\"文件还没有保存，但已经暂存，在“最近打开的文件”中可恢复文件\");\n    } else if (project.state === ProjectState.Unsaved) {\n      // 切换到这个文件\n      setActiveProject(project);\n      const response = await Dialog.buttons(\"是否保存更改？\", decodeURI(project.uri.toString()), [\n        { id: \"cancel\", label: \"取消\", variant: \"ghost\" },\n        { id: \"discard\", label: \"不保存\", variant: \"destructive\" },\n        { id: \"save\", label: \"保存\" },\n      ]);\n      if (response === \"save\") {\n        await project.save();\n      } else if (response === \"cancel\") {\n        throw new Error(\"取消操作\");\n      }\n    }\n    await project.dispose();\n    setProjects((projects) => {\n      const result = projects.filter((p) => p.uri.toString() !== project.uri.toString());\n      // 如果删除了当前标签页，就切换到下一个标签页\n      if (activeProject?.uri.toString() === project.uri.toString() && result.length > 0) {\n        const activeProjectIndex = projects.findIndex((p) => p.uri.toString() === activeProject?.uri.toString());\n        if (activeProjectIndex === projects.length - 1) {\n          // 关闭了最后一个标签页\n          setActiveProject(result[activeProjectIndex - 1]);\n        } else {\n          setActiveProject(result[activeProjectIndex]);\n        }\n      }\n      // 如果删除了唯一一个标签页，就显示欢迎页面\n      if (result.length === 0) {\n        setActiveProject(undefined);\n      }\n      return result;\n    });\n  };\n\n  const handleTabClick = useCallback((project: Project) => {\n    setActiveProject(project);\n  }, []);\n\n  const handleTabClose = useCallback(\n    async (project: Project) => {\n      await closeProject(project);\n    },\n    [closeProject],\n  );\n\n  return (\n    <>\n      {/* 这是一个底层的 div，用于在拖拽改变窗口大小时填充背景，防止窗口出现透明闪烁 */}\n      <div className=\"fixed inset-0 z-[-1] bg-[var(--stage-background)]\" style={{ opacity: windowBackgroundAlpha }} />\n      <div\n        className=\"relative flex h-full w-full flex-col overflow-clip rounded-lg sm:gap-2 sm:p-2\"\n        onContextMenu={(e) => e.preventDefault()}\n      >\n        {/* 菜单 | 标签页 | ...移动窗口区域... | 窗口控制按钮 */}\n        <div\n          className={cn(\n            \"z-10 flex h-4 items-center transition-all hover:opacity-100 sm:h-9 sm:gap-2\",\n            isClassroomMode && \"opacity-0\",\n            ignoreMouseEvents && \"pointer-events-none\",\n          )}\n        >\n          <div\n            className=\"hover:bg-primary/25 h-full min-w-6 cursor-grab transition-colors active:cursor-grabbing sm:hidden\"\n            data-tauri-drag-region\n          />\n          {isMac && <WindowButtons />}\n          <GlobalMenu />\n          <div\n            className=\"hover:bg-primary/25 h-full flex-1 cursor-grab transition-colors hover:*:opacity-100 active:cursor-grabbing sm:rounded-sm sm:hover:border\"\n            data-tauri-drag-region\n          />\n          <ThemeModeSwitch />\n          {!isMac && <WindowButtons />}\n        </div>\n\n        <ProjectTabs\n          projects={projects}\n          activeProject={activeProject}\n          onTabClick={handleTabClick}\n          onTabClose={handleTabClose}\n          isClassroomMode={isClassroomMode}\n          ignoreMouseEvents={ignoreMouseEvents}\n        />\n\n        {/* canvas */}\n        <div className=\"absolute inset-0 overflow-hidden\" ref={canvasWrapperRef}></div>\n\n        {/* 没有项目处于打开状态时，显示欢迎页面 */}\n        {projects.length === 0 && (\n          <div className=\"absolute inset-0 overflow-hidden *:h-full *:w-full\">\n            <Welcome />\n          </div>\n        )}\n\n        {/* 右键菜单 */}\n        <ContextMenu>\n          <ContextMenuTrigger>\n            <div ref={contextMenuTriggerRef} />\n          </ContextMenuTrigger>\n          <MyContextMenuContent />\n        </ContextMenu>\n\n        {/* ======= */}\n        {/* <ErrorHandler /> */}\n\n        {/* <PGCanvas /> */}\n\n        {/* <FloatingOutlet />\n      <RenderSubWindows /> */}\n\n        <RenderSubWindows />\n\n        {/* 底部工具栏 */}\n        {activeProject && <ToolbarContent />}\n\n        {/* 右侧工具栏 */}\n        {activeProject && showQuickSettingsToolbar && <RightToolbar />}\n\n        {/* 右上角关闭的触发角 */}\n        {isWindows && (\n          <div\n            className=\"absolute right-0 top-0 z-50 h-1 w-1 cursor-pointer rounded-bl-xl bg-red-600 transition-all hover:h-10 hover:w-10 hover:bg-yellow-500\"\n            onClick={() => getCurrentWindow().close()}\n          ></div>\n        )}\n        {dropMouseLocation !== \"notInWindowZone\" && (\n          <DropWindowCover dropMouseLocation={dropMouseLocation} isDraft={activeProject?.isDraft ?? false} />\n        )}\n      </div>\n    </>\n  );\n}\n\n/**\n * 窗口右上角的最小化，最大化，关闭等按钮\n */\nfunction WindowButtons() {\n  const [maximized] = useAtom(isWindowMaxsizedAtom);\n  const [isClickThroughEnabled] = useAtom(isClickThroughEnabledAtom);\n  const [isWindowAlwaysOnTop, setIsWindowAlwaysOnTop] = useAtom(isWindowAlwaysOnTopAtom);\n  const checkoutWindowsAlwaysTop = async () => {\n    const tauriWindow = getCurrentWindow();\n    if (isWindowAlwaysOnTop) {\n      setIsWindowAlwaysOnTop(false);\n      await tauriWindow.setAlwaysOnTop(false);\n    } else {\n      setIsWindowAlwaysOnTop(true);\n      await tauriWindow.setAlwaysOnTop(true);\n    }\n  };\n\n  return (\n    <div className=\"bg-background shadow-xs flex h-full items-center sm:rounded-md sm:border\">\n      {isClickThroughEnabled && <span className=\"text-destructive!\">Alt + 2关闭窗口穿透点击</span>}\n      {isMac ? (\n        <span className=\"flex *:flex *:size-3 sm:px-2 sm:*:m-1\">\n          <div\n            className=\"hidden cursor-pointer items-center justify-center rounded-full bg-red-400 text-red-800 hover:scale-110\"\n            onClick={() => getCurrentWindow().close()}\n          >\n            <X strokeWidth={3} size={10} />\n          </div>\n          <div\n            className=\"hidden cursor-pointer items-center justify-center rounded-full bg-yellow-400 text-yellow-800 hover:scale-110 sm:block\"\n            onClick={() => getCurrentWindow().minimize()}\n          >\n            <Minus strokeWidth={3} size={10} />\n          </div>\n          <div\n            className=\"hidden cursor-pointer items-center justify-center rounded-full bg-green-400 text-green-800 hover:scale-110 sm:block\"\n            onClick={() => {\n              getCurrentWindow()\n                .isFullscreen()\n                .then((res) => getCurrentWindow().setFullscreen(!res));\n            }}\n          >\n            <ChevronsLeftRight strokeWidth={3} size={10} className=\"rotate-45\" />\n          </div>\n          <div\n            className=\"cursor-pointer items-center justify-center rounded-full bg-blue-400 text-blue-800 hover:scale-110\"\n            onClick={async (e) => {\n              e.stopPropagation();\n              checkoutWindowsAlwaysTop();\n            }}\n          >\n            {isWindowAlwaysOnTop ? <Pin size={10} /> : <PinOff size={10} />}\n          </div>\n        </span>\n      ) : (\n        <span className=\"flex h-full flex-row sm:gap-1\">\n          {/* 钉住 */}\n          <Button\n            className=\"size-4 sm:size-9\"\n            variant=\"ghost\"\n            size=\"icon\"\n            onClick={async (e) => {\n              e.stopPropagation();\n              checkoutWindowsAlwaysTop();\n            }}\n          >\n            {isWindowAlwaysOnTop ? <Pin strokeWidth={3} /> : <PinOff strokeWidth={3} className=\"opacity-50\" />}\n          </Button>\n          {/* 最小化 */}\n          <Button\n            className=\"size-4 sm:size-9\"\n            variant=\"ghost\"\n            size=\"icon\"\n            onClick={() => getCurrentWindow().minimize()}\n          >\n            <Minus strokeWidth={3} />\n          </Button>\n          {/* 最大化/还原 */}\n          {maximized ? (\n            <Button\n              className=\"size-4 text-xs sm:size-9\"\n              variant=\"ghost\"\n              size=\"icon\"\n              onClick={() => getCurrentWindow().unmaximize()}\n            >\n              <Copy className=\"size-3\" strokeWidth={3} />\n            </Button>\n          ) : (\n            <Button\n              className=\"size-4 text-xs sm:size-9\"\n              variant=\"ghost\"\n              size=\"icon\"\n              onClick={() => getCurrentWindow().maximize()}\n            >\n              <Square className=\"size-3\" strokeWidth={4} />\n            </Button>\n          )}\n          {/* 关闭 */}\n          <Button\n            className=\"size-4 text-xs sm:size-9\"\n            variant=\"ghost\"\n            size=\"icon\"\n            onClick={() => getCurrentWindow().close()}\n          >\n            <X strokeWidth={3} />\n          </Button>\n        </span>\n      )}\n    </div>\n  );\n}\n\nexport function Catch() {\n  return <></>;\n}\n"
  },
  {
    "path": "app/src/DropWindowCover.tsx",
    "content": "import { cn } from \"./utils/cn\";\n\n/**\n * 拖拽鼠标进入舞台时，覆盖一个提示区域\n * 用于提示用户在不同位置释放有不同的效果\n */\nexport const DropWindowCover = ({\n  dropMouseLocation,\n  isDraft,\n}: {\n  dropMouseLocation: \"top\" | \"middle\" | \"bottom\" | \"notInWindowZone\";\n  isDraft: boolean;\n}) => {\n  //\n  return (\n    <div className=\"z-5 absolute left-0 top-0 flex h-screen w-full flex-col\">\n      <div\n        className={cn(\n          \"bg-card/80 flex flex-1 flex-col items-center justify-center text-xl\",\n          dropMouseLocation === \"top\" && \"text-destructive bg-transparent\",\n        )}\n      >\n        <p>拖拽到这里：追加到舞台</p>\n        <span className=\"text-sm\">\n          如果是图片文件（png/jpg/jpeg/webp），则追加到舞台，如果是prg工程文件，则打开标签页\n        </span>\n      </div>\n      <div\n        className={cn(\n          \"bg-card/80 flex flex-1 flex-col items-center justify-center text-xl\",\n          dropMouseLocation === \"middle\" && !isDraft && \"text-destructive bg-transparent\",\n          isDraft && \"cursor-not-allowed opacity-40\",\n        )}\n      >\n        <p>\n          拖拽到这里：以 <span className=\"text-3xl\">相对路径</span> 生成文本节点到舞台\n        </p>\n        {isDraft && <span className=\"text-sm\">（草稿文件无路径，无法使用相对路径）</span>}\n      </div>\n      <div\n        className={cn(\n          \"bg-card/80 flex flex-1 flex-col items-center justify-center text-xl\",\n          dropMouseLocation === \"bottom\" && \"text-destructive bg-transparent\",\n        )}\n      >\n        <p>\n          拖拽到这里：以 <span className=\"text-3xl\">绝对路径</span> 生成文本节点到舞台\n        </p>\n\n        <span className=\"text-sm\">\n          这样就可以构建外部文件链接，选中路径为内容的文本节点，直接调用系统默认方式打开此文件了\n        </span>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "app/src/ProjectTabs.tsx",
    "content": "import { memo, useCallback, useEffect, useRef, useState } from \"react\";\nimport { Project, ProjectState } from \"./core/Project\";\nimport { cn } from \"@udecode/cn\";\nimport { Button } from \"./components/ui/button\";\nimport { CircleAlert, CloudUpload, X } from \"lucide-react\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"./components/ui/tooltip\";\nimport { SoundService } from \"./core/service/feedbackService/SoundService\";\nimport { toast } from \"sonner\";\nimport { Settings } from \"./core/service/Settings\";\nimport { replaceTextWhenProtect } from \"./utils/font\";\n\n// 将 ProjectTabs 移出 App 组件，作为独立组件\nexport const ProjectTabs = memo(function ProjectTabs({\n  projects,\n  activeProject,\n  onTabClick,\n  onTabClose,\n  isClassroomMode,\n  ignoreMouseEvents,\n}: {\n  projects: Project[];\n  activeProject: Project | undefined;\n  onTabClick: (project: Project) => void;\n  onTabClose: (project: Project) => void;\n  isClassroomMode: boolean;\n  ignoreMouseEvents: boolean;\n}) {\n  const tabsContainerRef = useRef<HTMLDivElement>(null);\n  const scrollPositionRef = useRef(0);\n  const [protectingPrivacy, setProtectingPrivacy] = useState(Settings.protectingPrivacy);\n\n  useEffect(() => {\n    const unwatch = Settings.watch(\"protectingPrivacy\", setProtectingPrivacy);\n    return unwatch;\n  }, []);\n\n  // 保存滚动位置\n  const saveScrollPosition = useCallback(() => {\n    if (tabsContainerRef.current) {\n      scrollPositionRef.current = tabsContainerRef.current.scrollLeft;\n    }\n  }, []);\n\n  // 恢复滚动位置\n  const restoreScrollPosition = useCallback(() => {\n    if (tabsContainerRef.current) {\n      tabsContainerRef.current.scrollLeft = scrollPositionRef.current;\n    }\n  }, []);\n\n  // 处理标签点击\n  const handleTabClick = useCallback(\n    (project: Project) => {\n      saveScrollPosition();\n      onTabClick(project);\n      // 微任务中恢复滚动位置\n      Promise.resolve().then(restoreScrollPosition);\n    },\n    [onTabClick, saveScrollPosition, restoreScrollPosition],\n  );\n\n  // 处理标签关闭\n  const handleTabClose = useCallback(\n    async (project: Project, e: React.MouseEvent) => {\n      e.stopPropagation();\n      saveScrollPosition();\n      await onTabClose(project);\n      Promise.resolve().then(restoreScrollPosition);\n    },\n    [onTabClose, saveScrollPosition, restoreScrollPosition],\n  );\n\n  // 监听滚动\n  const handleScroll = useCallback(() => {\n    saveScrollPosition();\n  }, [saveScrollPosition]);\n\n  return (\n    <div\n      ref={tabsContainerRef}\n      className={cn(\n        \"scrollbar-hide z-10 flex h-4 overflow-x-auto whitespace-nowrap hover:opacity-100 sm:h-6 sm:gap-1\",\n        isClassroomMode && \"opacity-0\",\n        ignoreMouseEvents && \"pointer-events-none\",\n      )}\n      onScroll={handleScroll}\n    >\n      {projects.map((project) => (\n        <Button\n          key={project.uri.toString()}\n          className={cn(\n            \"hover:bg-primary/20 outline-inset h-full cursor-pointer rounded-none px-2 hover:opacity-100 sm:rounded-sm\",\n            activeProject?.uri.toString() === project.uri.toString() ? \"bg-primary/70\" : \"bg-accent opacity-70\",\n            project.isSaving && \"animate-pulse\",\n          )}\n          onMouseDown={(e) => {\n            if (e.button === 0) {\n              SoundService.play.mouseClickButton();\n              handleTabClick(project);\n            } else if (e.button === 1) {\n              e.preventDefault();\n              saveScrollPosition();\n              onTabClose(project);\n              Promise.resolve().then(restoreScrollPosition);\n              SoundService.play.cuttingLineRelease();\n            }\n          }}\n          onMouseEnter={() => {\n            SoundService.play.mouseEnterButton();\n          }}\n        >\n          <span className=\"text-xs\">\n            {(() => {\n              const name =\n                project.uri.scheme === \"draft\"\n                  ? `临时草稿 (${project.uri.path})`\n                  : project.uri.scheme === \"file\"\n                    ? project.uri.path.split(\"/\").pop()\n                    : project.uri.toString();\n              return protectingPrivacy ? replaceTextWhenProtect(name ?? \"\") : name;\n            })()}\n          </span>\n          <div\n            className=\"flex size-4 cursor-pointer items-center justify-center hover:opacity-100\"\n            onClick={(e) => {\n              if (project.isSaving) {\n                // 如果正在保存中，显示提示\n                toast.warning(\"正在保存中，请勿擅自做多余的操作\");\n                SoundService.play.cuttingLineRelease();\n              } else if (project.state === ProjectState.Unsaved) {\n                // 如果是未保存状态，根据项目类型执行不同操作\n                if (project.uri.scheme === \"draft\") {\n                  // 草稿文件，弹出对话框\n                  handleTabClose(project, e);\n                  SoundService.play.cuttingLineRelease();\n                } else {\n                  // 已有的文件，直接保存\n                  project.save();\n                  SoundService.play.cuttingLineRelease();\n                }\n              } else {\n                // 其他状态，执行关闭操作\n                handleTabClose(project, e);\n                SoundService.play.cuttingLineRelease();\n              }\n            }}\n          >\n            {project.isSaving ? (\n              <span className=\"grid size-3.5 animate-spin grid-cols-2\">\n                <span className=\"border-1 border-accent-foreground w-full animate-pulse rounded-full p-0.5\"></span>\n                <span className=\"border-1 border-accent-foreground w-full rounded-full p-0.5\"></span>\n                <span className=\"border-1 border-accent-foreground w-full rounded-full p-0.5\"></span>\n                <span className=\"border-1 border-accent-foreground w-full animate-pulse rounded-full p-0.5\"></span>\n              </span>\n            ) : project.state === ProjectState.Saved ? (\n              <X className=\"scale-75 opacity-75\" />\n            ) : project.state === ProjectState.Stashed ? (\n              <CloudUpload />\n            ) : (\n              <Tooltip>\n                {/* 醒目提醒用户，崩溃了丢了文件别怪开发者提醒不到位 */}\n                <TooltipTrigger>\n                  <CircleAlert className=\"*:text-destructive! text-destructive!\" />\n                </TooltipTrigger>\n                <TooltipContent>未保存！</TooltipContent>\n              </Tooltip>\n            )}\n          </div>\n        </Button>\n      ))}\n    </div>\n  );\n});\n"
  },
  {
    "path": "app/src/assets/versions.json",
    "content": "[\n  {\n    \"version\": \"0.0.0-dev\",\n    \"name\": \"筑梦空间\",\n    \"name_en\": \"Development Build\"\n  },\n  {\n    \"version\": \"0.0.0-nightly\",\n    \"name\": \"夜筑新梦\",\n    \"name_en\": \"Nightly Build\"\n  },\n  {\n    \"version\": \"1.0\",\n    \"name\": \"风起云涌之刻\",\n    \"name_en\": \"Whirlwind Ascension\"\n  },\n  {\n    \"version\": \"1.1\",\n    \"name\": \"地动山摇之时\",\n    \"name_en\": \"Momentous Rupture\"\n  },\n  {\n    \"version\": \"1.2\",\n    \"name\": \"2025年新纪元\",\n    \"name_en\": \"The Great River\"\n  },\n  {\n    \"version\": \"1.3\",\n    \"name\": \"层峦叠嶂之绘\",\n    \"name_en\": \"Layered Peaks\"\n  },\n  {\n    \"version\": \"1.4\",\n    \"name\": \"寰宇重塑之业\",\n    \"name_en\": \"Cosmic Reform\"\n  },\n  {\n    \"version\": \"1.5\",\n    \"name\": \"云程发轫之势\",\n    \"name_en\": \"Promising Start\"\n  },\n  {\n    \"version\": \"1.6\",\n    \"name\": \"贝塞流光之幕\",\n    \"name_en\": \"Bézier Luminescence\"\n  },\n  {\n    \"version\": \"1.7\",\n    \"name\": \"控弦绘影之织\",\n    \"name_en\": \"CR Precision Weave\"\n  },\n  {\n    \"version\": \"1.8\",\n    \"name\": \"视界纵横之窗\",\n    \"name_en\": \"Panoramic Weave\"\n  },\n  {\n    \"version\": \"2.0\",\n    \"name\": \"零钥\",\n    \"name_en\": \"Nullkey\"\n  }\n]\n"
  },
  {
    "path": "app/src/cli.tsx",
    "content": "import { writeStdout } from \"@/utils/otherApi\";\nimport { CliMatches } from \"@tauri-apps/plugin-cli\";\n\nexport async function runCli(matches: CliMatches) {\n  if (matches.args.help?.occurrences > 0) {\n    writeStdout(cliHelpText);\n    return;\n  }\n  if (matches.args.output?.occurrences > 0) {\n    const outputPath = matches.args.output?.value as string;\n    const outputFormat = outputPath.endsWith(\".svg\") || outputPath === \"-\" ? \"svg\" : \"\";\n    if (outputFormat === \"svg\") {\n      // const result = StageExportSvg.dumpStageToSVGString();\n      // if (outputPath === \"-\") {\n      //   writeStdout(result);\n      // } else {\n      //   await writeTextFile(outputPath, result);\n      // }\n    } else {\n      throw new Error(\"Invalid output format. Only SVG format is supported.\");\n    }\n  }\n}\nconst cliHelpText = `\n    ____               _           __  ______                 __\n   / __ \\\\_________    (_)__  _____/ \\\\/ ____/________ _____  / /_\n  / /_/ / ___/ __ \\\\  / / _ \\\\/ ___/ __\\\\/ __/ ___/ __ \\\\/ __ \\\\/ __ \\\\\n / ____/ /  / /_/ / / /  __/ /__/ /_/ \\\\/_/ / /  / /_/ / /_/ / / / /\n/_/   /_/   \\\\____/_/ /\\\\___/\\\\___/\\\\__\\\\/____/_/   \\\\__,_/ .___/_/ /_/\n                /___/                              /_/\n\nhttps://project-graph.top/zh/features/cli\n\n`;\n"
  },
  {
    "path": "app/src/components/context-menu-content.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport {\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuSub,\n  ContextMenuSubContent,\n  ContextMenuSubTrigger,\n} from \"@/components/ui/context-menu\";\nimport { Dialog } from \"@/components/ui/dialog\";\nimport { MouseLocation } from \"@/core/service/controlService/MouseLocation\";\nimport { ColorSmartTools } from \"@/core/service/dataManageService/colorSmartTools\";\nimport { ConnectNodeSmartTools } from \"@/core/service/dataManageService/connectNodeSmartTools\";\nimport { TextNodeSmartTools } from \"@/core/service/dataManageService/textNodeSmartTools\";\nimport { ColorManager } from \"@/core/service/feedbackService/ColorManager\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { Edge } from \"@/core/stage/stageObject/association/Edge\";\nimport { MultiTargetUndirectedEdge } from \"@/core/stage/stageObject/association/MutiTargetUndirectedEdge\";\nimport { ImageNode } from \"@/core/stage/stageObject/entity/ImageNode\";\nimport { ReferenceBlockNode } from \"@/core/stage/stageObject/entity/ReferenceBlockNode\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { activeProjectAtom, contextMenuTooltipWordsAtom } from \"@/state\";\nimport ColorPaletteWindow from \"@/sub/ColorPaletteWindow\";\nimport ColorWindow from \"@/sub/ColorWindow\";\nimport { Direction } from \"@/types/directions\";\nimport { parseEmacsKey } from \"@/utils/emacs\";\nimport { openBrowserOrFile } from \"@/utils/externalOpen\";\nimport { exportImagesToProjectDirectory } from \"@/utils/imageExport\";\nimport { Color, Vector } from \"@graphif/data-structures\";\nimport { Image as TauriImage } from \"@tauri-apps/api/image\";\nimport { writeImage } from \"@tauri-apps/plugin-clipboard-manager\";\nimport { useAtom } from \"jotai\";\nimport {\n  AlignCenterHorizontal,\n  AlignCenterVertical,\n  AlignEndHorizontal,\n  AlignEndVertical,\n  AlignHorizontalJustifyStart,\n  AlignHorizontalSpaceBetween,\n  AlignStartHorizontal,\n  AlignStartVertical,\n  AlignVerticalJustifyStart,\n  AlignVerticalSpaceBetween,\n  ArrowDownUp,\n  ArrowLeftFromLine,\n  ArrowLeftRight,\n  ArrowRightFromLine,\n  ArrowUpRight,\n  ArrowUpToLine,\n  Asterisk,\n  Box,\n  Check,\n  ChevronDown,\n  ChevronsRightLeft,\n  ChevronUp,\n  Clipboard,\n  Code,\n  Copy,\n  CornerUpRight,\n  Dot,\n  Ellipsis,\n  Equal,\n  ExternalLink,\n  GitPullRequestCreateArrow,\n  Grip,\n  Images,\n  LayoutDashboard,\n  LayoutPanelTop,\n  ListEnd,\n  Lock,\n  Maximize2,\n  Minimize2,\n  MoveDown,\n  MoveHorizontal,\n  MoveRight,\n  MoveUp,\n  MoveUpRight,\n  Network,\n  Package,\n  PaintBucket,\n  Palette,\n  Rabbit,\n  RefreshCcw,\n  RefreshCcwDot,\n  Repeat2,\n  Save,\n  Slash,\n  Spline,\n  SquareDashedBottomCode,\n  SquareDot,\n  SquareRoundCorner,\n  SquareSplitHorizontal,\n  SquareSquare,\n  SquaresUnite,\n  Sun,\n  SunDim,\n  TextSelect,\n  Trash,\n  Undo,\n  Workflow,\n} from \"lucide-react\";\nimport { ReactNode, useEffect, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport tailwindColors from \"tailwindcss/colors\";\nimport KeyTooltip from \"./key-tooltip\";\n\nconst Content = ContextMenuContent;\nconst Item = ContextMenuItem;\nconst Sub = ContextMenuSub;\nconst SubTrigger = ContextMenuSubTrigger;\nconst SubContent = ContextMenuSubContent;\n// const Separator = ContextMenuSeparator;\n\n/**\n * 右键菜单\n * @returns\n */\nexport default function MyContextMenuContent() {\n  const [p] = useAtom(activeProjectAtom);\n  const [contextMenuTooltipWords] = useAtom(contextMenuTooltipWordsAtom);\n  const { t } = useTranslation(\"contextMenu\");\n  if (!p) return <></>;\n\n  const isSelectedTreeRoots = () => {\n    const selectedEntities = p.stageManager.getSelectedEntities();\n    if (selectedEntities.length === 0) return false;\n    return selectedEntities.every((entity) => {\n      return entity instanceof ConnectableEntity && p.graphMethods.isTree(entity);\n    });\n  };\n\n  // 简化判断，只要选中了两个及以上的节点就显示按钮\n  const hasMultipleSelectedEntities = () => {\n    const selectedEntities = p.stageManager.getSelectedEntities();\n    return selectedEntities.length >= 2 && selectedEntities.every((entity) => entity instanceof ConnectableEntity);\n  };\n\n  return (\n    <Content>\n      {/* 第一行 Ctrl+c/v/x del */}\n      <Item className=\"bg-transparent! gap-0 p-0\">\n        <KeyTooltip keyId=\"copy\">\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            onClick={() => {\n              p.copyEngine.copy();\n            }}\n          >\n            <Copy />\n          </Button>\n        </KeyTooltip>\n        <KeyTooltip keyId=\"paste\">\n          <Button variant=\"ghost\" size=\"icon\" onClick={() => p.copyEngine.paste()}>\n            <Clipboard />\n          </Button>\n        </KeyTooltip>\n        {p.stageManager.getSelectedStageObjects().length > 0 && (\n          <KeyTooltip keyId=\"deleteSelectedStageObjects\">\n            <Button variant=\"ghost\" size=\"icon\" onClick={() => p.stageManager.deleteSelectedStageObjects()}>\n              <Trash className=\"text-destructive\" />\n            </Button>\n          </KeyTooltip>\n        )}\n        <KeyTooltip keyId=\"undo\">\n          <Button variant=\"ghost\" size=\"icon\" onClick={() => p.historyManager.undo()}>\n            <Undo />\n          </Button>\n        </KeyTooltip>\n\n        {/* 先不放cut，感觉不常用，可能还很容易出bug */}\n        {/* <KeyTooltip keyId=\"cut\">\n          <Button variant=\"ghost\" size=\"icon\" onClick={() => p.copyEngine.cut()}>\n            <Scissors />\n          </Button>\n        </KeyTooltip> */}\n      </Item>\n\n      {/* 对齐面板 */}\n      <Item className=\"bg-transparent! gap-0 p-0\">\n        {p.stageManager.getSelectedEntities().length >= 2 && (\n          <div className=\"grid grid-cols-3 grid-rows-3\">\n            <ContextMenuTooltip keyId=\"alignTop\">\n              <Button variant=\"ghost\" size=\"icon\" className=\"size-6\" onClick={() => p.layoutManager.alignTop()}>\n                <AlignStartHorizontal />\n              </Button>\n            </ContextMenuTooltip>\n            <ContextMenuTooltip keyId=\"alignTopToBottomNoSpace\">\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"size-6\"\n                onClick={() => p.layoutManager.alignTopToBottomNoSpace()}\n              >\n                <AlignVerticalJustifyStart />\n              </Button>\n            </ContextMenuTooltip>\n            <div />\n            <ContextMenuTooltip keyId=\"alignCenterHorizontal\">\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"size-6\"\n                onClick={() => p.layoutManager.alignCenterHorizontal()}\n              >\n                <AlignCenterHorizontal />\n              </Button>\n            </ContextMenuTooltip>\n\n            <ContextMenuTooltip keyId=\"alignVerticalSpaceBetween\">\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"size-6\"\n                onClick={() => p.layoutManager.alignVerticalSpaceBetween()}\n              >\n                <AlignVerticalSpaceBetween />\n              </Button>\n            </ContextMenuTooltip>\n            <ContextMenuTooltip keyId=\"layoutToSquare\">\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"size-6\"\n                onClick={() => p.layoutManager.layoutToSquare(p.stageManager.getSelectedEntities())}\n              >\n                <Grip />\n              </Button>\n            </ContextMenuTooltip>\n\n            <ContextMenuTooltip keyId=\"alignBottom\">\n              <Button variant=\"ghost\" size=\"icon\" className=\"size-6\" onClick={() => p.layoutManager.alignBottom()}>\n                <AlignEndHorizontal />\n              </Button>\n            </ContextMenuTooltip>\n            <ContextMenuTooltip keyId=\"layoutToTightSquare\">\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"size-6\"\n                onClick={() => p.layoutManager.layoutToTightSquare(p.stageManager.getSelectedEntities())}\n              >\n                <LayoutDashboard />\n              </Button>\n            </ContextMenuTooltip>\n            <div />\n          </div>\n        )}\n        {p.stageManager.getSelectedEntities().length >= 2 && (\n          <div className=\"grid grid-cols-3 grid-rows-3\">\n            <ContextMenuTooltip keyId=\"alignLeft\">\n              <Button variant=\"ghost\" size=\"icon\" className=\"size-6\" onClick={() => p.layoutManager.alignLeft()}>\n                <AlignStartVertical />\n              </Button>\n            </ContextMenuTooltip>\n            <ContextMenuTooltip keyId=\"alignCenterVertical\">\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"size-6\"\n                onClick={() => p.layoutManager.alignCenterVertical()}\n              >\n                <AlignCenterVertical />\n              </Button>\n            </ContextMenuTooltip>\n            <ContextMenuTooltip keyId=\"alignRight\">\n              <Button variant=\"ghost\" size=\"icon\" className=\"size-6\" onClick={() => p.layoutManager.alignRight()}>\n                <AlignEndVertical />\n              </Button>\n            </ContextMenuTooltip>\n            <ContextMenuTooltip keyId=\"alignLeftToRightNoSpace\">\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"size-6\"\n                onClick={() => p.layoutManager.alignLeftToRightNoSpace()}\n              >\n                <AlignHorizontalJustifyStart />\n              </Button>\n            </ContextMenuTooltip>\n\n            <ContextMenuTooltip keyId=\"alignHorizontalSpaceBetween\">\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"size-6\"\n                onClick={() => p.layoutManager.alignHorizontalSpaceBetween()}\n              >\n                <AlignHorizontalSpaceBetween />\n              </Button>\n            </ContextMenuTooltip>\n\n            <div />\n\n            <ContextMenuTooltip keyId=\"adjustSelectedTextNodeWidthMin\">\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"size-6\"\n                onClick={() => p.layoutManager.adjustSelectedTextNodeWidth(\"minWidth\")}\n              >\n                <ChevronsRightLeft />\n              </Button>\n            </ContextMenuTooltip>\n            <ContextMenuTooltip keyId=\"adjustSelectedTextNodeWidthAverage\">\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"size-6\"\n                onClick={() => p.layoutManager.adjustSelectedTextNodeWidth(\"average\")}\n              >\n                <MoveHorizontal />\n              </Button>\n            </ContextMenuTooltip>\n            <ContextMenuTooltip keyId=\"adjustSelectedTextNodeWidthMax\">\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"size-6\"\n                onClick={() => p.layoutManager.adjustSelectedTextNodeWidth(\"maxWidth\")}\n              >\n                <Code />\n              </Button>\n            </ContextMenuTooltip>\n          </div>\n        )}\n      </Item>\n      {/* 树形面板 */}\n      {isSelectedTreeRoots() && (\n        <Item className=\"bg-transparent! gap-0 p-0\">\n          <ContextMenuTooltip keyId=\"treeGraphAdjust\">\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              className=\"size-6\"\n              onClick={() =>\n                p.autoAlign.autoLayoutSelectedFastTreeMode(p.stageManager.getSelectedEntities()[0] as ConnectableEntity)\n              }\n            >\n              <Network className=\"-rotate-90\" />\n            </Button>\n          </ContextMenuTooltip>\n          <ContextMenuTooltip keyId=\"treeReverseX\">\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              className=\"size-6\"\n              onClick={() =>\n                p.autoLayoutFastTree.treeReverseX(p.stageManager.getSelectedEntities()[0] as ConnectableEntity)\n              }\n            >\n              <ArrowLeftRight />\n            </Button>\n          </ContextMenuTooltip>\n          <ContextMenuTooltip keyId=\"treeReverseY\">\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              className=\"size-6\"\n              onClick={() =>\n                p.autoLayoutFastTree.treeReverseY(p.stageManager.getSelectedEntities()[0] as ConnectableEntity)\n              }\n            >\n              <ArrowDownUp />\n            </Button>\n          </ContextMenuTooltip>\n          <ContextMenuTooltip keyId=\"textNodeTreeToSection\">\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              className=\"size-6\"\n              onClick={() => {\n                const textNodes = p.stageManager.getSelectedEntities().filter((it) => it instanceof TextNode);\n                for (const textNode of textNodes) {\n                  p.sectionPackManager.textNodeTreeToSection(textNode);\n                }\n              }}\n            >\n              <LayoutPanelTop />\n            </Button>\n          </ContextMenuTooltip>\n          <ContextMenuTooltip keyId=\"layoutToTightSquareDeep\">\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              className=\"size-6\"\n              onClick={() => p.layoutManager.layoutBySelected(p.layoutManager.layoutToTightSquare, true)}\n            >\n              <SquareSquare />\n            </Button>\n          </ContextMenuTooltip>\n        </Item>\n      )}\n\n      {/* DAG面板 */}\n      {hasMultipleSelectedEntities() && (\n        <Item className=\"bg-transparent! gap-0 p-0\">\n          <ContextMenuTooltip keyId=\"dagGraphAdjust\">\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              className=\"size-6\"\n              onClick={() => {\n                const selectedEntities = p.stageManager\n                  .getSelectedEntities()\n                  .filter((entity) => entity instanceof ConnectableEntity);\n                if (p.graphMethods.isDAGByNodes(selectedEntities)) {\n                  p.autoLayout.autoLayoutDAG(selectedEntities);\n                } else {\n                  toast.error(\"选中的节点不构成有向无环图（DAG）\");\n                }\n              }}\n            >\n              <Workflow />\n            </Button>\n          </ContextMenuTooltip>\n        </Item>\n      )}\n\n      <p className=\"pl-1 text-xs opacity-50\">{contextMenuTooltipWords || \"暂无提示\"}</p>\n\n      {/* 存在选中实体 */}\n      {p.stageManager.getSelectedStageObjects().length > 0 &&\n        p.stageManager.getSelectedStageObjects().some((it) => \"color\" in it) && (\n          <>\n            {/* 更改更简单的颜色 */}\n            <ColorLine />\n            {/* 更改更详细的颜色 */}\n            <Sub>\n              <SubTrigger>\n                <Palette />\n                {t(\"changeColor\")}\n              </SubTrigger>\n              <SubContent>\n                <Item onClick={() => p.stageObjectColorManager.setSelectedStageObjectColor(Color.Transparent)}>\n                  <Slash />\n                  {t(\"resetColor\")}\n                </Item>\n                <Item className=\"bg-transparent! grid grid-cols-11 gap-0\">\n                  {Object.values(tailwindColors)\n                    .filter((it) => typeof it !== \"string\")\n                    .slice(4)\n                    .flatMap((it) => Object.values(it).map(Color.fromCss))\n                    .map((color, index) => (\n                      <div\n                        key={index}\n                        className=\"hover:outline-accent-foreground size-4 -outline-offset-2 hover:outline-2\"\n                        style={{ backgroundColor: color.toString() }}\n                        onMouseEnter={() => p.stageObjectColorManager.setSelectedStageObjectColor(color)}\n                      />\n                    ))}\n                </Item>\n                <Item onClick={() => p.stageObjectColorManager.setSelectedStageObjectColor(new Color(11, 45, 14, 0))}>\n                  改为强制特殊透明色\n                </Item>\n                <Item\n                  onClick={() => {\n                    ColorWindow.open();\n                  }}\n                >\n                  打开调色板\n                </Item>\n                <Item\n                  onClick={() => {\n                    ColorPaletteWindow.open();\n                  }}\n                >\n                  打开舞台颜色分布表\n                </Item>\n              </SubContent>\n            </Sub>\n          </>\n        )}\n      {/* 存在两个及以上选中实体 */}\n      {p.stageManager.getSelectedEntities().length >= 2 && (\n        <>\n          <Item onClick={() => p.stageManager.packEntityToSectionBySelected()}>\n            <Box />\n            {t(\"packToSection\")}\n          </Item>\n          <Item\n            onClick={() => {\n              const selectedNodes = p.stageManager\n                .getSelectedEntities()\n                .filter((node) => node instanceof ConnectableEntity);\n              if (selectedNodes.length <= 1) {\n                toast.error(\"至少选择两个可连接节点\");\n                return;\n              }\n              const edge = MultiTargetUndirectedEdge.createFromSomeEntity(p, selectedNodes);\n              p.stageManager.add(edge);\n            }}\n          >\n            <Asterisk />\n            {t(\"createMTUEdgeLine\")}\n          </Item>\n          <Item\n            onClick={() => {\n              const selectedNodes = p.stageManager\n                .getSelectedEntities()\n                .filter((node) => node instanceof ConnectableEntity);\n              if (selectedNodes.length <= 1) {\n                toast.error(\"至少选择两个可连接节点\");\n                return;\n              }\n              const edge = MultiTargetUndirectedEdge.createFromSomeEntity(p, selectedNodes);\n              edge.renderType = \"convex\";\n              p.stageManager.add(edge);\n            }}\n          >\n            <SquareRoundCorner />\n            {t(\"createMTUEdgeConvex\")}\n          </Item>\n        </>\n      )}\n      {/* 没有选中实体，提示用户可以创建实体 */}\n      {p.stageManager.getSelectedStageObjects().length === 0 && (\n        <>\n          <Item\n            onClick={() =>\n              p.controllerUtils.addTextNodeByLocation(p.renderer.transformView2World(MouseLocation.vector()), true)\n            }\n          >\n            <TextSelect />\n            {t(\"createTextNode\")}\n          </Item>\n          <Item\n            onClick={() => p.controllerUtils.createConnectPoint(p.renderer.transformView2World(MouseLocation.vector()))}\n          >\n            <Dot />\n            {t(\"createConnectPoint\")}\n          </Item>\n        </>\n      )}\n      {/* 存在选中 TextNode */}\n      {p.stageManager.getSelectedEntities().filter((it) => it instanceof TextNode).length > 0 && (\n        <>\n          <Item\n            onClick={() => {\n              const selectedTextNodes = p.stageManager.getSelectedEntities().filter((it) => it instanceof TextNode);\n              for (const textNode of selectedTextNodes) {\n                textNode.increaseFontSize();\n              }\n              p.historyManager.recordStep();\n            }}\n          >\n            <Maximize2 />\n            放大字体\n          </Item>\n          <Item\n            onClick={() => {\n              const selectedTextNodes = p.stageManager.getSelectedEntities().filter((it) => it instanceof TextNode);\n              for (const textNode of selectedTextNodes) {\n                textNode.decreaseFontSize();\n              }\n              p.historyManager.recordStep();\n            }}\n          >\n            <Minimize2 />\n            缩小字体\n          </Item>\n          <Sub>\n            <SubTrigger>\n              <Rabbit />\n              文本节点 巧妙操作\n            </SubTrigger>\n            <SubContent>\n              <Item onClick={() => TextNodeSmartTools.ttt(p)}>\n                <ListEnd />\n                切换换行模式\n                <span className=\"text-xs opacity-50\">[t, t, t]</span>\n              </Item>\n              <Item onClick={() => TextNodeSmartTools.rua(p)}>\n                <SquaresUnite />\n                ruá成一个\n                <span className=\"text-xs opacity-50\">[r, u, a]</span>\n              </Item>\n              <Item onClick={() => TextNodeSmartTools.kei(p)}>\n                <SquareSplitHorizontal />\n                kēi成多个\n                <span className=\"text-xs opacity-50\">[k, e, i]</span>\n              </Item>\n              <Item onClick={() => TextNodeSmartTools.exchangeTextAndDetails(p)}>\n                <Repeat2 />\n                详略交换\n                <span className=\"text-xs opacity-50\">[e, e, e, e, e]</span>\n              </Item>\n              <Item onClick={() => TextNodeSmartTools.removeFirstCharFromSelectedTextNodes(p)}>\n                <ArrowLeftFromLine />\n                削头\n                <span className=\"text-xs opacity-50\">[ctrl+backspace]</span>\n              </Item>\n              <Item onClick={() => TextNodeSmartTools.removeLastCharFromSelectedTextNodes(p)}>\n                <ArrowRightFromLine />\n                剃尾\n                <span className=\"text-xs opacity-50\">[ctrl+delete]</span>\n              </Item>\n\n              <Item onClick={() => TextNodeSmartTools.okk(p)}>\n                <Check />\n                打勾勾\n                <span className=\"text-xs opacity-50\">[o, k, k]</span>\n              </Item>\n\n              <Item\n                onClick={() =>\n                  p.stageManager\n                    .getSelectedEntities()\n                    .filter((it) => it instanceof TextNode)\n                    .map((it) => p.sectionPackManager.targetTextNodeToSection(it, false, true))\n                }\n              >\n                <Package />\n                {t(\"convertToSection\")}\n                <span className=\"text-xs opacity-50\">[ctrl+shift+G]</span>\n              </Item>\n              <Sub>\n                <SubTrigger>\n                  <Network />\n                  连接相关\n                </SubTrigger>\n                <SubContent>\n                  <Item onClick={() => ConnectNodeSmartTools.insertNodeToTree(p)}>\n                    <GitPullRequestCreateArrow />\n                    嫁接到连线中\n                    <span className=\"text-xs opacity-50\">[q, e]</span>\n                  </Item>\n                  <Item onClick={() => ConnectNodeSmartTools.removeNodeFromTree(p)}>\n                    <ArrowLeftFromLine />\n                    从连线中摘除\n                    <span className=\"text-xs opacity-50\">[q, r]</span>\n                  </Item>\n                  <Item onClick={() => ConnectNodeSmartTools.connectDown(p)}>\n                    <MoveDown />\n                    向下连一串\n                    <span className=\"text-xs opacity-50\">[-, -, d, o, w, n]</span>\n                  </Item>\n                  <Item onClick={() => ConnectNodeSmartTools.connectRight(p)}>\n                    <MoveRight />\n                    向右连一串\n                    <span className=\"text-xs opacity-50\">[-, -, r, i, g, h, t]</span>\n                  </Item>\n                  <Item onClick={() => ConnectNodeSmartTools.connectAll(p)}>\n                    <Asterisk />\n                    全连接\n                    <span className=\"text-xs opacity-50\">[-, -, a, l, l]</span>\n                  </Item>\n                </SubContent>\n              </Sub>\n              <Sub>\n                <SubTrigger>\n                  <PaintBucket />\n                  颜色相关\n                </SubTrigger>\n                <SubContent>\n                  <Item onClick={() => ColorSmartTools.increaseBrightness(p)}>\n                    <Sun />\n                    增加亮度\n                    <span className=\"text-xs opacity-50\">[b, .]</span>\n                  </Item>\n                  <Item onClick={() => ColorSmartTools.decreaseBrightness(p)}>\n                    <SunDim />\n                    降低亮度\n                    <span className=\"text-xs opacity-50\">[b, ,]</span>\n                  </Item>\n                  <Item onClick={() => ColorSmartTools.changeColorHueUp(p)}>\n                    <ChevronUp />\n                    增加色相值\n                    <span className=\"text-xs opacity-50\">[Alt+Shift+⬆]</span>\n                  </Item>\n                  <Item onClick={() => ColorSmartTools.changeColorHueDown(p)}>\n                    <ChevronDown />\n                    降低色相值\n                    <span className=\"text-xs opacity-50\">[Alt+Shift+⬇]</span>\n                  </Item>\n                  <Item onClick={() => ColorSmartTools.changeColorHueMajorUp(p)}>\n                    <MoveUp />\n                    大幅度增加色相值\n                    <span className=\"text-xs opacity-50\">[Alt+Shift+Home]</span>\n                  </Item>\n                  <Item onClick={() => ColorSmartTools.changeColorHueMajorDown(p)}>\n                    <MoveDown />\n                    大幅度降低色相值\n                    <span className=\"text-xs opacity-50\">[Alt+Shift+End]</span>\n                  </Item>\n                </SubContent>\n              </Sub>\n              <Sub>\n                <SubTrigger>\n                  <Ellipsis />\n                  其他\n                </SubTrigger>\n                <SubContent>\n                  <Item onClick={() => TextNodeSmartTools.changeTextNodeToReferenceBlock(p)}>\n                    <SquareDashedBottomCode />\n                    将选中的文本节点转换为引用块\n                  </Item>\n                </SubContent>\n              </Sub>\n            </SubContent>\n          </Sub>\n\n          <Item onClick={() => openBrowserOrFile(p)}>\n            <ExternalLink />\n            将内容视为路径并打开\n          </Item>\n        </>\n      )}\n      {/* 存在选中 Section */}\n      {p.stageManager.getSelectedEntities().filter((it) => it instanceof Section).length > 0 && (\n        <>\n          <Item onClick={() => p.stageManager.sectionSwitchCollapse()}>\n            <Package />\n            {t(\"toggleSectionCollapse\")}\n          </Item>\n          <Item\n            onClick={() => {\n              const selectedSections = p.stageManager.getSelectedEntities().filter((it) => it instanceof Section);\n              for (const section of selectedSections) {\n                section.locked = !section.locked;\n                p.sectionRenderer.render(section);\n              }\n              // 记录历史步骤\n              p.historyManager.recordStep();\n            }}\n          >\n            <Lock />\n            锁定/解锁 section 框\n          </Item>\n        </>\n      )}\n      {/* 存在选中 引用块 */}\n      {p.stageManager.getSelectedEntities().filter((it) => it instanceof ReferenceBlockNode).length > 0 && (\n        <>\n          <Item\n            onClick={() => {\n              p.stageManager\n                .getSelectedEntities()\n                .filter((it) => it instanceof ReferenceBlockNode)\n                .filter((it) => it.isSelected)\n                .forEach((it) => {\n                  it.refresh();\n                });\n            }}\n          >\n            <RefreshCcwDot />\n            刷新引用块\n          </Item>\n          <Item\n            onClick={() => {\n              p.stageManager\n                .getSelectedEntities()\n                .filter((it) => it instanceof ReferenceBlockNode)\n                .filter((it) => it.isSelected)\n                .forEach((it) => {\n                  it.goToSource();\n                });\n            }}\n          >\n            <CornerUpRight />\n            进入该引用块所在的源头位置\n          </Item>\n        </>\n      )}\n      {/* 存在选中的 Edge */}\n      {p.stageManager.getSelectedAssociations().filter((it) => it instanceof Edge).length > 0 && (\n        <>\n          <Item\n            onClick={() => {\n              p.stageManager.switchEdgeToUndirectedEdge();\n              p.historyManager.recordStep();\n            }}\n          >\n            <Spline />\n            转换为无向边\n          </Item>\n          <Sub>\n            <SubTrigger>\n              <ArrowRightFromLine />\n              线条类型\n            </SubTrigger>\n            <SubContent>\n              <Item\n                onClick={() => {\n                  p.stageManager.setSelectedEdgeLineType(\"solid\");\n                  p.historyManager.recordStep();\n                }}\n              >\n                <Slash />\n                实线\n              </Item>\n              <Item\n                onClick={() => {\n                  p.stageManager.setSelectedEdgeLineType(\"dashed\");\n                  p.historyManager.recordStep();\n                }}\n              >\n                <Ellipsis />\n                虚线\n              </Item>\n              <Item\n                onClick={() => {\n                  p.stageManager.setSelectedEdgeLineType(\"double\");\n                  p.historyManager.recordStep();\n                }}\n              >\n                <Equal />\n                双实线\n              </Item>\n            </SubContent>\n          </Sub>\n          <Item className=\"bg-transparent! gap-0 p-0\">\n            <div className=\"grid grid-cols-3 grid-rows-3\">\n              <div></div>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"size-6\"\n                onClick={() => p.stageManager.changeSelectedEdgeConnectLocation(Direction.Up, true)}\n              >\n                <ArrowRightFromLine className=\"-rotate-90\" />\n              </Button>\n              <div></div>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"size-6\"\n                onClick={() => p.stageManager.changeSelectedEdgeConnectLocation(Direction.Left, true)}\n              >\n                <ArrowRightFromLine className=\"-rotate-180\" />\n              </Button>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"size-6\"\n                onClick={() => p.stageManager.changeSelectedEdgeConnectLocation(null, true)}\n              >\n                <SquareDot />\n              </Button>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"size-6\"\n                onClick={() => p.stageManager.changeSelectedEdgeConnectLocation(Direction.Right, true)}\n              >\n                <ArrowRightFromLine />\n              </Button>\n              <div></div>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"size-6\"\n                onClick={() => p.stageManager.changeSelectedEdgeConnectLocation(Direction.Down, true)}\n              >\n                <ArrowRightFromLine className=\"rotate-90\" />\n              </Button>\n              <div></div>\n            </div>\n            <div className=\"grid grid-cols-3 grid-rows-3\">\n              <div></div>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"size-6\"\n                onClick={() => p.stageManager.changeSelectedEdgeConnectLocation(Direction.Up)}\n              >\n                <ArrowUpToLine className=\"rotate-180\" />\n              </Button>\n              <div></div>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"size-6\"\n                onClick={() => p.stageManager.changeSelectedEdgeConnectLocation(Direction.Left)}\n              >\n                <ArrowUpToLine className=\"rotate-90\" />\n              </Button>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"size-6\"\n                onClick={() => p.stageManager.changeSelectedEdgeConnectLocation(null)}\n              >\n                <SquareDot />\n              </Button>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"size-6\"\n                onClick={() => p.stageManager.changeSelectedEdgeConnectLocation(Direction.Right)}\n              >\n                <ArrowUpToLine className=\"-rotate-90\" />\n              </Button>\n              <div></div>\n              <Button\n                variant=\"ghost\"\n                size=\"icon\"\n                className=\"size-6\"\n                onClick={() => p.stageManager.changeSelectedEdgeConnectLocation(Direction.Down)}\n              >\n                <ArrowUpToLine />\n              </Button>\n              <div></div>\n            </div>\n          </Item>\n        </>\n      )}\n\n      {/* 存在选中的 MTUEdge */}\n      {p.stageManager.getSelectedAssociations().filter((it) => it instanceof MultiTargetUndirectedEdge).length > 0 && (\n        <>\n          <Sub>\n            <SubTrigger>\n              <ArrowUpRight />\n              {t(\"switchMTUEdgeArrow\")}\n            </SubTrigger>\n            <SubContent>\n              <Item\n                onClick={() => {\n                  const selectedMTUEdges = p.stageManager\n                    .getSelectedAssociations()\n                    .filter((edge) => edge instanceof MultiTargetUndirectedEdge);\n                  for (const multi_target_undirected_edge of selectedMTUEdges) {\n                    multi_target_undirected_edge.arrow = \"outer\";\n                  }\n                  p.historyManager.recordStep();\n                }}\n              >\n                <Maximize2 />\n                {t(\"mtuEdgeArrowOuter\")}\n              </Item>\n              <Item\n                onClick={() => {\n                  const selectedMTUEdges = p.stageManager\n                    .getSelectedAssociations()\n                    .filter((edge) => edge instanceof MultiTargetUndirectedEdge);\n                  for (const multi_target_undirected_edge of selectedMTUEdges) {\n                    multi_target_undirected_edge.arrow = \"inner\";\n                  }\n                  p.historyManager.recordStep();\n                }}\n              >\n                <Minimize2 />\n                {t(\"mtuEdgeArrowInner\")}\n              </Item>\n              <Item\n                onClick={() => {\n                  const selectedMTUEdges = p.stageManager\n                    .getSelectedAssociations()\n                    .filter((edge) => edge instanceof MultiTargetUndirectedEdge);\n                  for (const multi_target_undirected_edge of selectedMTUEdges) {\n                    multi_target_undirected_edge.arrow = \"none\";\n                  }\n                  p.historyManager.recordStep();\n                }}\n              >\n                <Slash />\n                {t(\"mtuEdgeArrowNone\")}\n              </Item>\n            </SubContent>\n          </Sub>\n\n          <Item\n            onClick={() => {\n              const selectedMTUEdge = p.stageManager\n                .getSelectedAssociations()\n                .filter((edge) => edge instanceof MultiTargetUndirectedEdge);\n              for (const multi_target_undirected_edge of selectedMTUEdge) {\n                if (multi_target_undirected_edge.renderType === \"line\") {\n                  multi_target_undirected_edge.renderType = \"convex\";\n                } else if (multi_target_undirected_edge.renderType === \"convex\") {\n                  multi_target_undirected_edge.renderType = \"circle\";\n                } else if (multi_target_undirected_edge.renderType === \"circle\") {\n                  multi_target_undirected_edge.renderType = \"line\";\n                }\n              }\n              p.historyManager.recordStep();\n            }}\n          >\n            <RefreshCcw />\n            {t(\"switchMTUEdgeRenderType\")}\n          </Item>\n\n          <Item\n            onClick={() => {\n              // 重置所有选中无向边的端点位置到中心\n              const selectedMTUEdges = p.stageManager\n                .getSelectedAssociations()\n                .filter((edge) => edge instanceof MultiTargetUndirectedEdge);\n              for (const multi_target_undirected_edge of selectedMTUEdges) {\n                // 重置中心位置到中心\n                multi_target_undirected_edge.centerRate = Vector.same(0.5);\n                // 重置每个节点的连接点位置到中心\n                multi_target_undirected_edge.rectRates = multi_target_undirected_edge.associationList.map(() =>\n                  Vector.same(0.5),\n                );\n              }\n              p.historyManager.recordStep();\n            }}\n          >\n            <AlignCenterHorizontal />\n            重置端点位置到中心\n          </Item>\n\n          <Item\n            onClick={() => {\n              p.stageManager.switchUndirectedEdgeToEdge();\n              p.historyManager.recordStep();\n            }}\n          >\n            <MoveUpRight />\n            {t(\"convertToDirectedEdge\")}\n          </Item>\n        </>\n      )}\n\n      {/* 涂鸦模式增加修改画笔颜色 */}\n      {Settings.mouseLeftMode === \"draw\" && (\n        <Sub>\n          <SubTrigger>\n            <Palette />\n            改变画笔颜色\n          </SubTrigger>\n          <SubContent>\n            <Item onClick={() => (Settings.autoFillPenStrokeColor = Color.Transparent.toArray())}>\n              <Slash />\n              {t(\"resetColor\")}\n            </Item>\n            <Item className=\"bg-transparent! grid grid-cols-11 gap-0\">\n              {Object.values(tailwindColors)\n                .filter((it) => typeof it !== \"string\")\n                .flatMap((it) => Object.values(it).map(Color.fromCss))\n                .map((color, index) => (\n                  <div\n                    key={index}\n                    className=\"hover:outline-accent-foreground size-4 -outline-offset-2 hover:outline-2\"\n                    style={{ backgroundColor: color.toString() }}\n                    onMouseEnter={() => (Settings.autoFillPenStrokeColor = color.toArray())}\n                  />\n                ))}\n            </Item>\n          </SubContent>\n        </Sub>\n      )}\n\n      {/* 存在选中 ImageNode */}\n      {p.stageManager.getSelectedEntities().filter((it) => it instanceof ImageNode).length > 0 && (\n        <>\n          <Item\n            onClick={async () => {\n              // 获取所有选中的 ImageNode\n              const selectedImageNodes = p.stageManager\n                .getSelectedEntities()\n                .filter((it) => it instanceof ImageNode) as ImageNode[];\n\n              if (selectedImageNodes.length === 0) {\n                toast.error(\"请选中图片节点\");\n                return;\n              }\n\n              // 复制第一张图片到剪贴板（如果有多张图片，只复制第一张）\n              const imageNode = selectedImageNodes[0];\n              const blob = p.attachments.get(imageNode.attachmentId);\n              if (blob) {\n                try {\n                  const arrayBuffer = await blob.arrayBuffer();\n                  const tauriImage = await TauriImage.fromBytes(new Uint8Array(arrayBuffer));\n                  await writeImage(tauriImage);\n                  if (selectedImageNodes.length === 1) {\n                    toast.success(\"已将选中的图片复制到系统剪贴板\");\n                  } else {\n                    toast.success(`已将第1张图片复制到系统剪贴板（共${selectedImageNodes.length}张）`);\n                  }\n                } catch (error) {\n                  console.error(\"复制图片到剪贴板失败:\", error);\n                  toast.error(\"复制图片到剪贴板失败\");\n                }\n              } else {\n                toast.error(\"无法获取图片数据\");\n              }\n            }}\n          >\n            <Clipboard />\n            复制图片到系统剪贴板\n          </Item>\n          <Item\n            onClick={() => {\n              // 获取所有选中的 ImageNode\n              const selectedImageNodes = p.stageManager\n                .getSelectedEntities()\n                .filter((it) => it instanceof ImageNode) as ImageNode[];\n\n              if (selectedImageNodes.length === 0) {\n                toast.error(\"请选中图片节点\");\n                return;\n              }\n\n              // 对每张图片进行红蓝通道对调\n              for (const imageNode of selectedImageNodes) {\n                imageNode.swapRedBlueChannels();\n              }\n\n              // 记录历史步骤\n              p.historyManager.recordStep();\n\n              // 显示提示信息\n              if (selectedImageNodes.length === 1) {\n                toast.success(\"已对调图片的红蓝通道\");\n              } else {\n                toast.success(`已对调 ${selectedImageNodes.length} 张图片的红蓝通道`);\n              }\n            }}\n          >\n            <ArrowLeftRight />\n            对调图片红蓝通道\n          </Item>\n          <Item\n            onClick={() => {\n              // 获取所有选中的 ImageNode\n              const selectedImageNodes = p.stageManager\n                .getSelectedEntities()\n                .filter((it) => it instanceof ImageNode) as ImageNode[];\n\n              if (selectedImageNodes.length === 0) {\n                toast.error(\"请选中图片节点\");\n                return;\n              }\n\n              // 将选中的图片转化为背景图片\n              for (const imageNode of selectedImageNodes) {\n                imageNode.isBackground = true;\n              }\n\n              // 记录历史步骤\n              p.historyManager.recordStep();\n\n              // 显示提示信息\n              if (selectedImageNodes.length === 1) {\n                toast.success(\"已将图片转化为背景图片\");\n              } else {\n                toast.success(`已将 ${selectedImageNodes.length} 张图片转化为背景图片`);\n              }\n            }}\n          >\n            <Images />\n            转化为背景图片\n          </Item>\n          <Item\n            onClick={() => {\n              // 获取所有选中的 ImageNode\n              const selectedImageNodes = p.stageManager\n                .getSelectedEntities()\n                .filter((it) => it instanceof ImageNode) as ImageNode[];\n\n              if (selectedImageNodes.length === 0) {\n                toast.error(\"请选中图片节点\");\n                return;\n              }\n\n              // 取消背景化\n              for (const imageNode of selectedImageNodes) {\n                imageNode.isBackground = false;\n              }\n\n              // 记录历史步骤\n              p.historyManager.recordStep();\n\n              // 显示提示信息\n              if (selectedImageNodes.length === 1) {\n                toast.success(\"已取消图片的背景化\");\n              } else {\n                toast.success(`已取消 ${selectedImageNodes.length} 张图片的背景化`);\n              }\n            }}\n          >\n            <SquareSquare />\n            取消背景化\n          </Item>\n          <Item\n            onClick={async () => {\n              // 检查是否是草稿模式\n              if (p.isDraft) {\n                toast.error(\"请先保存项目后再导出图片\");\n                return;\n              }\n\n              // 获取所有选中的 ImageNode\n              const selectedImageNodes = p.stageManager\n                .getSelectedEntities()\n                .filter((it) => it instanceof ImageNode) as ImageNode[];\n\n              if (selectedImageNodes.length === 0) {\n                toast.error(\"请选中图片节点\");\n                return;\n              }\n\n              // 根据图片数量决定提示信息\n              const isBatch = selectedImageNodes.length > 1;\n              const promptMessage = isBatch\n                ? `请输入文件名（不含扩展名，将为 ${selectedImageNodes.length} 张图片添加数字后缀）`\n                : `请输入文件名（不含扩展名，将自动添加扩展名）`;\n\n              // 弹出输入框 - 只弹出一次\n              const fileName = await Dialog.input(\"另存图片\", promptMessage, {\n                placeholder: \"image\",\n              });\n\n              if (!fileName) {\n                return; // 用户取消\n              }\n\n              // 验证文件名是否合法\n              const invalidChars = /[/\\\\:*?\"<>|]/;\n              if (invalidChars.test(fileName)) {\n                toast.error('文件名包含非法字符：/ \\\\ : * ? \" < > |');\n                return;\n              }\n\n              // 调用工具函数导出图片\n              const { successCount, failedCount } = await exportImagesToProjectDirectory(\n                selectedImageNodes,\n                p.uri.fsPath,\n                p.attachments,\n                fileName,\n              );\n\n              // 显示结果提示\n              if (successCount > 0 && failedCount === 0) {\n                toast.success(`成功保存 ${successCount} 张图片`);\n              } else if (successCount > 0 && failedCount > 0) {\n                toast.warning(`成功保存 ${successCount} 张图片，${failedCount} 张失败`);\n              } else {\n                toast.error(`保存失败，请检查文件名或文件权限`);\n              }\n            }}\n          >\n            <Save />\n            另存图片到当前prg所在目录下\n          </Item>\n        </>\n      )}\n    </Content>\n  );\n}\n\nfunction ContextMenuTooltip({ keyId, children = <></> }: { keyId: string; children: ReactNode }) {\n  const [keySeq, setKeySeq] = useState<ReturnType<typeof parseEmacsKey>[number][]>();\n  const [activeProject] = useAtom(activeProjectAtom);\n\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  const [_, setContextMenuTooltipWords] = useAtom(contextMenuTooltipWordsAtom);\n  const { t } = useTranslation(\"keyBinds\");\n\n  useEffect(() => {\n    activeProject?.keyBinds.get(keyId)?.then((key) => {\n      if (key) {\n        const keyStr = typeof key === \"string\" ? key : key.key;\n        const parsed = parseEmacsKey(keyStr);\n        if (parsed.length > 0) {\n          setKeySeq(parsed);\n        } else {\n          setKeySeq(undefined);\n        }\n      } else {\n        setKeySeq(undefined);\n      }\n    });\n  }, [keyId, activeProject]);\n\n  const onMouseEnter = () => {\n    const title = t(`${keyId}.title`);\n    let keyTips = \"\";\n    if (keySeq) {\n      keyTips = keySeq\n        .map((seq) => {\n          let res = \"\";\n          if (seq.control) {\n            res += \"Ctrl+\";\n          }\n          if (seq.meta) {\n            res += \"Meta+\";\n          }\n          if (seq.shift) {\n            res += \"Shift+\";\n          }\n          if (seq.alt) {\n            res += \"Alt+\";\n          }\n          return res + seq.key.toUpperCase();\n        })\n        .join(\",\");\n    } else {\n      keyTips = \"未绑定快捷键\";\n    }\n    setContextMenuTooltipWords(`${title} [${keyTips}]`);\n  };\n\n  const onMouseLeave = () => {\n    setContextMenuTooltipWords(\"\");\n  };\n\n  return (\n    <span onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>\n      {children}\n    </span>\n  );\n}\n\nconst ColorLine: React.FC = () => {\n  const [currentColors, setCurrentColors] = useState<Color[]>([]);\n  const [project] = useAtom(activeProjectAtom);\n\n  useEffect(() => {\n    ColorManager.getUserEntityFillColors().then((colors) => {\n      setCurrentColors(colors);\n    });\n  }, []);\n\n  const handleChangeColor = (color: Color) => {\n    project?.stageObjectColorManager.setSelectedStageObjectColor(color);\n  };\n\n  return (\n    <div className=\"flex max-w-64 overflow-x-auto\">\n      {currentColors.map((color) => {\n        return (\n          <div\n            className=\"hover:outline-accent-foreground size-4 cursor-pointer -outline-offset-2 hover:outline-2\"\n            key={color.toString()}\n            style={{\n              backgroundColor: `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`,\n            }}\n            onClick={() => {\n              handleChangeColor(color);\n            }}\n          />\n        );\n      })}\n    </div>\n  );\n};\n"
  },
  {
    "path": "app/src/components/editor/editor-base-kit.tsx",
    "content": "import { BaseAlignKit } from \"./plugins/align-base-kit\";\nimport { BaseBasicBlocksKit } from \"./plugins/basic-blocks-base-kit\";\nimport { BaseBasicMarksKit } from \"./plugins/basic-marks-base-kit\";\nimport { BaseCalloutKit } from \"./plugins/callout-base-kit\";\nimport { BaseCodeBlockKit } from \"./plugins/code-block-base-kit\";\nimport { BaseColumnKit } from \"./plugins/column-base-kit\";\nimport { BaseCommentKit } from \"./plugins/comment-base-kit\";\nimport { BaseDateKit } from \"./plugins/date-base-kit\";\nimport { BaseFontKit } from \"./plugins/font-base-kit\";\nimport { BaseLineHeightKit } from \"./plugins/line-height-base-kit\";\nimport { BaseLinkKit } from \"./plugins/link-base-kit\";\nimport { BaseListKit } from \"./plugins/list-base-kit\";\nimport { MarkdownKit } from \"./plugins/markdown-kit\";\nimport { BaseMathKit } from \"./plugins/math-base-kit\";\nimport { BaseMediaKit } from \"./plugins/media-base-kit\";\nimport { BaseMentionKit } from \"./plugins/mention-base-kit\";\nimport { BaseSuggestionKit } from \"./plugins/suggestion-base-kit\";\nimport { BaseTableKit } from \"./plugins/table-base-kit\";\nimport { BaseTocKit } from \"./plugins/toc-base-kit\";\nimport { BaseToggleKit } from \"./plugins/toggle-base-kit\";\n\nexport const BaseEditorKit = [\n  ...BaseBasicBlocksKit,\n  ...BaseCodeBlockKit,\n  ...BaseTableKit,\n  ...BaseToggleKit,\n  ...BaseTocKit,\n  ...BaseMediaKit,\n  ...BaseCalloutKit,\n  ...BaseColumnKit,\n  ...BaseMathKit,\n  ...BaseDateKit,\n  ...BaseLinkKit,\n  ...BaseMentionKit,\n  ...BaseBasicMarksKit,\n  ...BaseFontKit,\n  ...BaseListKit,\n  ...BaseAlignKit,\n  ...BaseLineHeightKit,\n  ...BaseCommentKit,\n  ...BaseSuggestionKit,\n  ...MarkdownKit,\n];\n"
  },
  {
    "path": "app/src/components/editor/plugins/align-base-kit.tsx",
    "content": "import { BaseTextAlignPlugin } from \"@platejs/basic-styles\";\nimport { KEYS } from \"platejs\";\n\nexport const BaseAlignKit = [\n  BaseTextAlignPlugin.configure({\n    inject: {\n      nodeProps: {\n        defaultNodeValue: \"start\",\n        nodeKey: \"align\",\n        styleKey: \"textAlign\",\n        validNodeValues: [\"start\", \"left\", \"center\", \"right\", \"end\", \"justify\"],\n      },\n      targetPlugins: [...KEYS.heading, KEYS.p, KEYS.img, KEYS.mediaEmbed],\n    },\n  }),\n];\n"
  },
  {
    "path": "app/src/components/editor/plugins/basic-blocks-base-kit.tsx",
    "content": "import {\n  BaseBlockquotePlugin,\n  BaseH1Plugin,\n  BaseH2Plugin,\n  BaseH3Plugin,\n  BaseH4Plugin,\n  BaseH5Plugin,\n  BaseH6Plugin,\n  BaseHorizontalRulePlugin,\n} from \"@platejs/basic-nodes\";\nimport { BaseParagraphPlugin } from \"platejs\";\n\nimport { BlockquoteElementStatic } from \"@/components/ui/blockquote-node-static\";\nimport {\n  H1ElementStatic,\n  H2ElementStatic,\n  H3ElementStatic,\n  H4ElementStatic,\n  H5ElementStatic,\n  H6ElementStatic,\n} from \"@/components/ui/heading-node-static\";\nimport { HrElementStatic } from \"@/components/ui/hr-node-static\";\nimport { ParagraphElementStatic } from \"@/components/ui/paragraph-node-static\";\n\nexport const BaseBasicBlocksKit = [\n  BaseParagraphPlugin.withComponent(ParagraphElementStatic),\n  BaseH1Plugin.withComponent(H1ElementStatic),\n  BaseH2Plugin.withComponent(H2ElementStatic),\n  BaseH3Plugin.withComponent(H3ElementStatic),\n  BaseH4Plugin.withComponent(H4ElementStatic),\n  BaseH5Plugin.withComponent(H5ElementStatic),\n  BaseH6Plugin.withComponent(H6ElementStatic),\n  BaseBlockquotePlugin.withComponent(BlockquoteElementStatic),\n  BaseHorizontalRulePlugin.withComponent(HrElementStatic),\n];\n"
  },
  {
    "path": "app/src/components/editor/plugins/basic-blocks-kit.tsx",
    "content": "\"use client\";\n\nimport {\n  BlockquotePlugin,\n  H1Plugin,\n  H2Plugin,\n  H3Plugin,\n  H4Plugin,\n  H5Plugin,\n  H6Plugin,\n  HorizontalRulePlugin,\n} from \"@platejs/basic-nodes/react\";\nimport { ParagraphPlugin } from \"platejs/react\";\n\nimport { BlockquoteElement } from \"@/components/ui/blockquote-node\";\nimport { H1Element, H2Element, H3Element, H4Element, H5Element, H6Element } from \"@/components/ui/heading-node\";\nimport { HrElement } from \"@/components/ui/hr-node\";\nimport { ParagraphElement } from \"@/components/ui/paragraph-node\";\n\nexport const BasicBlocksKit = [\n  ParagraphPlugin.withComponent(ParagraphElement),\n  H1Plugin.configure({\n    node: {\n      component: H1Element,\n    },\n    rules: {\n      break: { empty: \"reset\" },\n    },\n    shortcuts: { toggle: { keys: \"mod+alt+1\" } },\n  }),\n  H2Plugin.configure({\n    node: {\n      component: H2Element,\n    },\n    rules: {\n      break: { empty: \"reset\" },\n    },\n    shortcuts: { toggle: { keys: \"mod+alt+2\" } },\n  }),\n  H3Plugin.configure({\n    node: {\n      component: H3Element,\n    },\n    rules: {\n      break: { empty: \"reset\" },\n    },\n    shortcuts: { toggle: { keys: \"mod+alt+3\" } },\n  }),\n  H4Plugin.configure({\n    node: {\n      component: H4Element,\n    },\n    rules: {\n      break: { empty: \"reset\" },\n    },\n    shortcuts: { toggle: { keys: \"mod+alt+4\" } },\n  }),\n  H5Plugin.configure({\n    node: {\n      component: H5Element,\n    },\n    rules: {\n      break: { empty: \"reset\" },\n    },\n    shortcuts: { toggle: { keys: \"mod+alt+5\" } },\n  }),\n  H6Plugin.configure({\n    node: {\n      component: H6Element,\n    },\n    rules: {\n      break: { empty: \"reset\" },\n    },\n    shortcuts: { toggle: { keys: \"mod+alt+6\" } },\n  }),\n  BlockquotePlugin.configure({\n    node: { component: BlockquoteElement },\n    shortcuts: { toggle: { keys: \"mod+shift+period\" } },\n  }),\n  HorizontalRulePlugin.withComponent(HrElement),\n];\n"
  },
  {
    "path": "app/src/components/editor/plugins/basic-marks-base-kit.tsx",
    "content": "import {\n  BaseBoldPlugin,\n  BaseCodePlugin,\n  BaseHighlightPlugin,\n  BaseItalicPlugin,\n  BaseKbdPlugin,\n  BaseStrikethroughPlugin,\n  BaseSubscriptPlugin,\n  BaseSuperscriptPlugin,\n  BaseUnderlinePlugin,\n} from \"@platejs/basic-nodes\";\n\nimport { CodeLeafStatic } from \"@/components/ui/code-node-static\";\nimport { HighlightLeafStatic } from \"@/components/ui/highlight-node-static\";\nimport { KbdLeafStatic } from \"@/components/ui/kbd-node-static\";\n\nexport const BaseBasicMarksKit = [\n  BaseBoldPlugin,\n  BaseItalicPlugin,\n  BaseUnderlinePlugin,\n  BaseCodePlugin.withComponent(CodeLeafStatic),\n  BaseStrikethroughPlugin,\n  BaseSubscriptPlugin,\n  BaseSuperscriptPlugin,\n  BaseHighlightPlugin.withComponent(HighlightLeafStatic),\n  BaseKbdPlugin.withComponent(KbdLeafStatic),\n];\n"
  },
  {
    "path": "app/src/components/editor/plugins/basic-marks-kit.tsx",
    "content": "\"use client\";\n\nimport {\n  BoldPlugin,\n  CodePlugin,\n  HighlightPlugin,\n  ItalicPlugin,\n  KbdPlugin,\n  StrikethroughPlugin,\n  SubscriptPlugin,\n  SuperscriptPlugin,\n  UnderlinePlugin,\n} from \"@platejs/basic-nodes/react\";\n\nimport { CodeLeaf } from \"@/components/ui/code-node\";\nimport { HighlightLeaf } from \"@/components/ui/highlight-node\";\nimport { KbdLeaf } from \"@/components/ui/kbd-node\";\n\nexport const BasicMarksKit = [\n  BoldPlugin,\n  ItalicPlugin,\n  UnderlinePlugin,\n  CodePlugin.configure({\n    node: { component: CodeLeaf },\n    shortcuts: { toggle: { keys: \"mod+e\" } },\n  }),\n  StrikethroughPlugin.configure({\n    shortcuts: { toggle: { keys: \"mod+shift+x\" } },\n  }),\n  SubscriptPlugin.configure({\n    shortcuts: { toggle: { keys: \"mod+comma\" } },\n  }),\n  SuperscriptPlugin.configure({\n    shortcuts: { toggle: { keys: \"mod+period\" } },\n  }),\n  HighlightPlugin.configure({\n    node: { component: HighlightLeaf },\n    shortcuts: { toggle: { keys: \"mod+shift+h\" } },\n  }),\n  KbdPlugin.withComponent(KbdLeaf),\n];\n"
  },
  {
    "path": "app/src/components/editor/plugins/callout-base-kit.tsx",
    "content": "import { BaseCalloutPlugin } from \"@platejs/callout\";\n\nimport { CalloutElementStatic } from \"@/components/ui/callout-node-static\";\n\nexport const BaseCalloutKit = [BaseCalloutPlugin.withComponent(CalloutElementStatic)];\n"
  },
  {
    "path": "app/src/components/editor/plugins/code-block-base-kit.tsx",
    "content": "import { BaseCodeBlockPlugin, BaseCodeLinePlugin, BaseCodeSyntaxPlugin } from \"@platejs/code-block\";\nimport { all, createLowlight } from \"lowlight\";\n\nimport {\n  CodeBlockElementStatic,\n  CodeLineElementStatic,\n  CodeSyntaxLeafStatic,\n} from \"@/components/ui/code-block-node-static\";\n\nconst lowlight = createLowlight(all);\n\nexport const BaseCodeBlockKit = [\n  BaseCodeBlockPlugin.configure({\n    node: { component: CodeBlockElementStatic },\n    options: { lowlight },\n  }),\n  BaseCodeLinePlugin.withComponent(CodeLineElementStatic),\n  BaseCodeSyntaxPlugin.withComponent(CodeSyntaxLeafStatic),\n];\n"
  },
  {
    "path": "app/src/components/editor/plugins/code-block-kit.tsx",
    "content": "\"use client\";\n\nimport { CodeBlockPlugin, CodeLinePlugin, CodeSyntaxPlugin } from \"@platejs/code-block/react\";\nimport { all, createLowlight } from \"lowlight\";\n\nimport { CodeBlockElement, CodeLineElement, CodeSyntaxLeaf } from \"@/components/ui/code-block-node\";\n\nconst lowlight = createLowlight(all);\n\nexport const CodeBlockKit = [\n  CodeBlockPlugin.configure({\n    node: { component: CodeBlockElement },\n    options: { lowlight },\n    shortcuts: { toggle: { keys: \"mod+alt+8\" } },\n  }),\n  CodeLinePlugin.withComponent(CodeLineElement),\n  CodeSyntaxPlugin.withComponent(CodeSyntaxLeaf),\n];\n"
  },
  {
    "path": "app/src/components/editor/plugins/column-base-kit.tsx",
    "content": "import { BaseColumnItemPlugin, BaseColumnPlugin } from \"@platejs/layout\";\n\nimport { ColumnElementStatic, ColumnGroupElementStatic } from \"@/components/ui/column-node-static\";\n\nexport const BaseColumnKit = [\n  BaseColumnPlugin.withComponent(ColumnGroupElementStatic),\n  BaseColumnItemPlugin.withComponent(ColumnElementStatic),\n];\n"
  },
  {
    "path": "app/src/components/editor/plugins/comment-base-kit.tsx",
    "content": "import { BaseCommentPlugin } from \"@platejs/comment\";\n\nimport { CommentLeafStatic } from \"@/components/ui/comment-node-static\";\n\nexport const BaseCommentKit = [BaseCommentPlugin.withComponent(CommentLeafStatic)];\n"
  },
  {
    "path": "app/src/components/editor/plugins/comment-kit.tsx",
    "content": "\"use client\";\n\nimport type { ExtendConfig, Path } from \"platejs\";\n\nimport { type BaseCommentConfig, BaseCommentPlugin, getDraftCommentKey } from \"@platejs/comment\";\nimport { isSlateString } from \"platejs\";\nimport { toTPlatePlugin } from \"platejs/react\";\n\nimport { CommentLeaf } from \"@/components/ui/comment-node\";\n\ntype CommentConfig = ExtendConfig<\n  BaseCommentConfig,\n  {\n    activeId: string | null;\n    commentingBlock: Path | null;\n    hoverId: string | null;\n    uniquePathMap: Map<string, Path>;\n  }\n>;\n\nexport const commentPlugin = toTPlatePlugin<CommentConfig>(BaseCommentPlugin, {\n  handlers: {\n    onClick: ({ api, event, setOption, type }) => {\n      let leaf = event.target as HTMLElement;\n      let isSet = false;\n\n      const unsetActiveSuggestion = () => {\n        setOption(\"activeId\", null);\n        isSet = true;\n      };\n\n      if (!isSlateString(leaf)) unsetActiveSuggestion();\n\n      while (leaf.parentElement) {\n        if (leaf.classList.contains(`slate-${type}`)) {\n          const commentsEntry = api.comment!.node();\n\n          if (!commentsEntry) {\n            unsetActiveSuggestion();\n\n            break;\n          }\n\n          const id = api.comment!.nodeId(commentsEntry[0]);\n\n          setOption(\"activeId\", id ?? null);\n          isSet = true;\n\n          break;\n        }\n\n        leaf = leaf.parentElement;\n      }\n\n      if (!isSet) unsetActiveSuggestion();\n    },\n  },\n  options: {\n    activeId: null,\n    commentingBlock: null,\n    hoverId: null,\n    uniquePathMap: new Map(),\n  },\n})\n  .extendTransforms(\n    ({\n      editor,\n      setOption,\n      tf: {\n        comment: { setDraft },\n      },\n    }) => ({\n      setDraft: () => {\n        if (editor.api.isCollapsed()) {\n          editor.tf.select(editor.api.block()![1]);\n        }\n\n        setDraft();\n\n        editor.tf.collapse();\n        setOption(\"activeId\", getDraftCommentKey());\n        setOption(\"commentingBlock\", editor.selection!.focus.path.slice(0, 1));\n      },\n    }),\n  )\n  .configure({\n    node: { component: CommentLeaf },\n    shortcuts: {\n      setDraft: { keys: \"mod+shift+m\" },\n    },\n  });\n\nexport const CommentKit = [commentPlugin];\n"
  },
  {
    "path": "app/src/components/editor/plugins/date-base-kit.tsx",
    "content": "import { BaseDatePlugin } from \"@platejs/date\";\n\nimport { DateElementStatic } from \"@/components/ui/date-node-static\";\n\nexport const BaseDateKit = [BaseDatePlugin.withComponent(DateElementStatic)];\n"
  },
  {
    "path": "app/src/components/editor/plugins/discussion-kit.tsx",
    "content": "\"use client\";\n\nimport type { TComment } from \"@/components/ui/comment\";\n\nimport { createPlatePlugin } from \"platejs/react\";\n\nimport { BlockDiscussion } from \"@/components/ui/block-discussion\";\n\nexport interface TDiscussion {\n  id: string;\n  comments: TComment[];\n  createdAt: Date;\n  isResolved: boolean;\n  userId: string;\n  documentContent?: string;\n}\n\nconst discussionsData: TDiscussion[] = [\n  {\n    id: \"discussion1\",\n    comments: [\n      {\n        id: \"comment1\",\n        contentRich: [\n          {\n            children: [\n              {\n                text: \"Comments are a great way to provide feedback and discuss changes.\",\n              },\n            ],\n            type: \"p\",\n          },\n        ],\n        createdAt: new Date(Date.now() - 600_000),\n        discussionId: \"discussion1\",\n        isEdited: false,\n        userId: \"charlie\",\n      },\n      {\n        id: \"comment2\",\n        contentRich: [\n          {\n            children: [\n              {\n                text: \"Agreed! The link to the docs makes it easy to learn more.\",\n              },\n            ],\n            type: \"p\",\n          },\n        ],\n        createdAt: new Date(Date.now() - 500_000),\n        discussionId: \"discussion1\",\n        isEdited: false,\n        userId: \"bob\",\n      },\n    ],\n    createdAt: new Date(),\n    documentContent: \"comments\",\n    isResolved: false,\n    userId: \"charlie\",\n  },\n  {\n    id: \"discussion2\",\n    comments: [\n      {\n        id: \"comment1\",\n        contentRich: [\n          {\n            children: [\n              {\n                text: \"Nice demonstration of overlapping annotations with both comments and suggestions!\",\n              },\n            ],\n            type: \"p\",\n          },\n        ],\n        createdAt: new Date(Date.now() - 300_000),\n        discussionId: \"discussion2\",\n        isEdited: false,\n        userId: \"bob\",\n      },\n      {\n        id: \"comment2\",\n        contentRich: [\n          {\n            children: [\n              {\n                text: \"This helps users understand how powerful the editor can be.\",\n              },\n            ],\n            type: \"p\",\n          },\n        ],\n        createdAt: new Date(Date.now() - 200_000),\n        discussionId: \"discussion2\",\n        isEdited: false,\n        userId: \"charlie\",\n      },\n    ],\n    createdAt: new Date(),\n    documentContent: \"overlapping\",\n    isResolved: false,\n    userId: \"bob\",\n  },\n];\n\nconst avatarUrl = (seed: string) => `https://api.dicebear.com/9.x/glass/svg?seed=${seed}`;\n\nconst usersData: Record<string, { id: string; avatarUrl: string; name: string; hue?: number }> = {\n  alice: {\n    id: \"alice\",\n    avatarUrl: avatarUrl(\"alice6\"),\n    name: \"Alice\",\n  },\n  bob: {\n    id: \"bob\",\n    avatarUrl: avatarUrl(\"bob4\"),\n    name: \"Bob\",\n  },\n  charlie: {\n    id: \"charlie\",\n    avatarUrl: avatarUrl(\"charlie2\"),\n    name: \"Charlie\",\n  },\n};\n\n// This plugin is purely UI. It's only used to store the discussions and users data\nexport const discussionPlugin = createPlatePlugin({\n  key: \"discussion\",\n  options: {\n    currentUserId: \"alice\",\n    discussions: discussionsData,\n    users: usersData,\n  },\n})\n  .configure({\n    render: { aboveNodes: BlockDiscussion },\n  })\n  .extendSelectors(({ getOption }) => ({\n    currentUser: () => getOption(\"users\")[getOption(\"currentUserId\")],\n    user: (id: string) => getOption(\"users\")[id],\n  }));\n\nexport const DiscussionKit = [discussionPlugin];\n"
  },
  {
    "path": "app/src/components/editor/plugins/fixed-toolbar-kit.tsx",
    "content": "\"use client\";\n\nimport { createPlatePlugin } from \"platejs/react\";\n\nimport { FixedToolbar } from \"@/components/ui/fixed-toolbar\";\nimport { FixedToolbarButtons } from \"@/components/ui/fixed-toolbar-buttons\";\n\nexport const FixedToolbarKit = [\n  createPlatePlugin({\n    key: \"fixed-toolbar\",\n    render: {\n      beforeEditable: () => (\n        <FixedToolbar>\n          <FixedToolbarButtons />\n          <div className=\"w-8\" data-pg-drag-region />\n        </FixedToolbar>\n      ),\n    },\n  }),\n];\n"
  },
  {
    "path": "app/src/components/editor/plugins/floating-toolbar-kit.tsx",
    "content": "\"use client\";\n\nimport { createPlatePlugin } from \"platejs/react\";\n\nimport { FloatingToolbar } from \"@/components/ui/floating-toolbar\";\nimport { FloatingToolbarButtons } from \"@/components/ui/floating-toolbar-buttons\";\n\nexport const FloatingToolbarKit = [\n  createPlatePlugin({\n    key: \"floating-toolbar\",\n    render: {\n      afterEditable: () => (\n        <FloatingToolbar>\n          <FloatingToolbarButtons />\n        </FloatingToolbar>\n      ),\n    },\n  }),\n];\n"
  },
  {
    "path": "app/src/components/editor/plugins/font-base-kit.tsx",
    "content": "import type { SlatePluginConfig } from \"platejs\";\n\nimport {\n  BaseFontBackgroundColorPlugin,\n  BaseFontColorPlugin,\n  BaseFontFamilyPlugin,\n  BaseFontSizePlugin,\n} from \"@platejs/basic-styles\";\nimport { KEYS } from \"platejs\";\n\nconst options = {\n  inject: { targetPlugins: [KEYS.p] },\n} satisfies SlatePluginConfig;\n\nexport const BaseFontKit = [\n  BaseFontColorPlugin.configure(options),\n  BaseFontBackgroundColorPlugin.configure(options),\n  BaseFontSizePlugin.configure(options),\n  BaseFontFamilyPlugin.configure(options),\n];\n"
  },
  {
    "path": "app/src/components/editor/plugins/font-kit.tsx",
    "content": "\"use client\";\n\nimport type { PlatePluginConfig } from \"platejs/react\";\n\nimport {\n  FontBackgroundColorPlugin,\n  FontColorPlugin,\n  FontFamilyPlugin,\n  FontSizePlugin,\n} from \"@platejs/basic-styles/react\";\nimport { KEYS } from \"platejs\";\n\nconst options = {\n  inject: { targetPlugins: [KEYS.p] },\n} satisfies PlatePluginConfig;\n\nexport const FontKit = [\n  FontColorPlugin.configure({\n    inject: {\n      ...options.inject,\n      nodeProps: {\n        defaultNodeValue: \"black\",\n      },\n    },\n  }),\n  FontBackgroundColorPlugin.configure(options),\n  FontSizePlugin.configure(options),\n  FontFamilyPlugin.configure(options),\n];\n"
  },
  {
    "path": "app/src/components/editor/plugins/indent-base-kit.tsx",
    "content": "import { BaseIndentPlugin } from \"@platejs/indent\";\nimport { KEYS } from \"platejs\";\n\nexport const BaseIndentKit = [\n  BaseIndentPlugin.configure({\n    inject: {\n      targetPlugins: [...KEYS.heading, KEYS.p, KEYS.blockquote, KEYS.codeBlock, KEYS.toggle],\n    },\n    options: {\n      offset: 24,\n    },\n  }),\n];\n"
  },
  {
    "path": "app/src/components/editor/plugins/indent-kit.tsx",
    "content": "\"use client\";\n\nimport { IndentPlugin } from \"@platejs/indent/react\";\nimport { KEYS } from \"platejs\";\n\nexport const IndentKit = [\n  IndentPlugin.configure({\n    inject: {\n      targetPlugins: [...KEYS.heading, KEYS.p, KEYS.blockquote, KEYS.codeBlock, KEYS.toggle, KEYS.img],\n    },\n    options: {\n      offset: 24,\n    },\n  }),\n];\n"
  },
  {
    "path": "app/src/components/editor/plugins/line-height-base-kit.tsx",
    "content": "import { BaseLineHeightPlugin } from \"@platejs/basic-styles\";\nimport { KEYS } from \"platejs\";\n\nexport const BaseLineHeightKit = [\n  BaseLineHeightPlugin.configure({\n    inject: {\n      nodeProps: {\n        defaultNodeValue: 1.5,\n        validNodeValues: [1, 1.2, 1.5, 2, 3],\n      },\n      targetPlugins: [...KEYS.heading, KEYS.p],\n    },\n  }),\n];\n"
  },
  {
    "path": "app/src/components/editor/plugins/link-base-kit.tsx",
    "content": "import { BaseLinkPlugin } from \"@platejs/link\";\n\nimport { LinkElementStatic } from \"@/components/ui/link-node-static\";\n\nexport const BaseLinkKit = [BaseLinkPlugin.withComponent(LinkElementStatic)];\n"
  },
  {
    "path": "app/src/components/editor/plugins/link-kit.tsx",
    "content": "\"use client\";\n\nimport { LinkPlugin } from \"@platejs/link/react\";\n\nimport { LinkElement } from \"@/components/ui/link-node\";\nimport { LinkFloatingToolbar } from \"@/components/ui/link-toolbar\";\n\nexport const LinkKit = [\n  LinkPlugin.configure({\n    render: {\n      node: LinkElement,\n      afterEditable: () => <LinkFloatingToolbar />,\n    },\n  }),\n];\n"
  },
  {
    "path": "app/src/components/editor/plugins/list-base-kit.tsx",
    "content": "import { BaseListPlugin } from \"@platejs/list\";\nimport { KEYS } from \"platejs\";\n\nimport { BaseIndentKit } from \"@/components/editor/plugins/indent-base-kit\";\nimport { BlockListStatic } from \"@/components/ui/block-list-static\";\n\nexport const BaseListKit = [\n  ...BaseIndentKit,\n  BaseListPlugin.configure({\n    inject: {\n      targetPlugins: [...KEYS.heading, KEYS.p, KEYS.blockquote, KEYS.codeBlock, KEYS.toggle],\n    },\n    render: {\n      belowNodes: BlockListStatic,\n    },\n  }),\n];\n"
  },
  {
    "path": "app/src/components/editor/plugins/list-kit.tsx",
    "content": "\"use client\";\n\nimport { ListPlugin } from \"@platejs/list/react\";\nimport { KEYS } from \"platejs\";\n\nimport { IndentKit } from \"@/components/editor/plugins/indent-kit\";\nimport { BlockList } from \"@/components/ui/block-list\";\n\nexport const ListKit = [\n  ...IndentKit,\n  ListPlugin.configure({\n    inject: {\n      targetPlugins: [...KEYS.heading, KEYS.p, KEYS.blockquote, KEYS.codeBlock, KEYS.toggle, KEYS.img],\n    },\n    render: {\n      belowNodes: BlockList,\n    },\n  }),\n];\n"
  },
  {
    "path": "app/src/components/editor/plugins/markdown-kit.tsx",
    "content": "import { MarkdownPlugin, remarkMdx, remarkMention } from \"@platejs/markdown\";\nimport { KEYS } from \"platejs\";\nimport remarkGfm from \"remark-gfm\";\nimport remarkMath from \"remark-math\";\n\nexport const MarkdownKit = [\n  MarkdownPlugin.configure({\n    options: {\n      disallowedNodes: [KEYS.suggestion],\n      remarkPlugins: [remarkMath, remarkGfm, remarkMdx, remarkMention],\n    },\n  }),\n];\n"
  },
  {
    "path": "app/src/components/editor/plugins/math-base-kit.tsx",
    "content": "import { BaseEquationPlugin, BaseInlineEquationPlugin } from \"@platejs/math\";\n\nimport { EquationElementStatic, InlineEquationElementStatic } from \"@/components/ui/equation-node-static\";\n\nexport const BaseMathKit = [\n  BaseInlineEquationPlugin.withComponent(InlineEquationElementStatic),\n  BaseEquationPlugin.withComponent(EquationElementStatic),\n];\n"
  },
  {
    "path": "app/src/components/editor/plugins/math-kit.tsx",
    "content": "\"use client\";\n\nimport { EquationPlugin, InlineEquationPlugin } from \"@platejs/math/react\";\n\nimport { EquationElement, InlineEquationElement } from \"@/components/ui/equation-node\";\n\nexport const MathKit = [\n  InlineEquationPlugin.withComponent(InlineEquationElement),\n  EquationPlugin.withComponent(EquationElement),\n];\n"
  },
  {
    "path": "app/src/components/editor/plugins/media-base-kit.tsx",
    "content": "import { BaseCaptionPlugin } from \"@platejs/caption\";\nimport {\n  BaseAudioPlugin,\n  BaseFilePlugin,\n  BaseImagePlugin,\n  BaseMediaEmbedPlugin,\n  BasePlaceholderPlugin,\n  BaseVideoPlugin,\n} from \"@platejs/media\";\nimport { KEYS } from \"platejs\";\n\nimport { AudioElementStatic } from \"@/components/ui/media-audio-node-static\";\nimport { FileElementStatic } from \"@/components/ui/media-file-node-static\";\nimport { ImageElementStatic } from \"@/components/ui/media-image-node-static\";\nimport { VideoElementStatic } from \"@/components/ui/media-video-node-static\";\n\nexport const BaseMediaKit = [\n  BaseImagePlugin.withComponent(ImageElementStatic),\n  BaseVideoPlugin.withComponent(VideoElementStatic),\n  BaseAudioPlugin.withComponent(AudioElementStatic),\n  BaseFilePlugin.withComponent(FileElementStatic),\n  BaseCaptionPlugin.configure({\n    options: {\n      query: {\n        allow: [KEYS.img, KEYS.video, KEYS.audio, KEYS.file, KEYS.mediaEmbed],\n      },\n    },\n  }),\n  BaseMediaEmbedPlugin,\n  BasePlaceholderPlugin,\n];\n"
  },
  {
    "path": "app/src/components/editor/plugins/mention-base-kit.tsx",
    "content": "import { BaseMentionPlugin } from \"@platejs/mention\";\n\nimport { MentionElementStatic } from \"@/components/ui/mention-node-static\";\n\nexport const BaseMentionKit = [BaseMentionPlugin.withComponent(MentionElementStatic)];\n"
  },
  {
    "path": "app/src/components/editor/plugins/suggestion-base-kit.tsx",
    "content": "import { BaseSuggestionPlugin } from \"@platejs/suggestion\";\n\nimport { SuggestionLeafStatic } from \"@/components/ui/suggestion-node-static\";\n\nexport const BaseSuggestionKit = [BaseSuggestionPlugin.withComponent(SuggestionLeafStatic)];\n"
  },
  {
    "path": "app/src/components/editor/plugins/suggestion-kit.tsx",
    "content": "\"use client\";\n\nimport { type BaseSuggestionConfig, BaseSuggestionPlugin } from \"@platejs/suggestion\";\nimport { type ExtendConfig, type Path, isSlateEditor, isSlateElement, isSlateString } from \"platejs\";\nimport { toTPlatePlugin } from \"platejs/react\";\n\nimport { BlockSuggestion } from \"@/components/ui/block-suggestion\";\nimport { SuggestionLeaf, SuggestionLineBreak } from \"@/components/ui/suggestion-node\";\n\nimport { discussionPlugin } from \"./discussion-kit\";\n\nexport type SuggestionConfig = ExtendConfig<\n  BaseSuggestionConfig,\n  {\n    activeId: string | null;\n    hoverId: string | null;\n    uniquePathMap: Map<string, Path>;\n  }\n>;\n\nexport const suggestionPlugin = toTPlatePlugin<SuggestionConfig>(BaseSuggestionPlugin, ({ editor }) => ({\n  options: {\n    activeId: null,\n    currentUserId: editor.getOption(discussionPlugin, \"currentUserId\"),\n    hoverId: null,\n    uniquePathMap: new Map(),\n  },\n})).configure({\n  handlers: {\n    // unset active suggestion when clicking outside of suggestion\n    onClick: ({ api, event, setOption, type }) => {\n      let leaf = event.target as HTMLElement;\n      let isSet = false;\n\n      const unsetActiveSuggestion = () => {\n        setOption(\"activeId\", null);\n        isSet = true;\n      };\n\n      if (!isSlateString(leaf)) unsetActiveSuggestion();\n\n      while (leaf.parentElement && !isSlateElement(leaf.parentElement) && !isSlateEditor(leaf.parentElement)) {\n        if (leaf.classList.contains(`slate-${type}`)) {\n          const suggestionEntry = api.suggestion!.node({ isText: true });\n\n          if (!suggestionEntry) {\n            unsetActiveSuggestion();\n\n            break;\n          }\n\n          const id = api.suggestion!.nodeId(suggestionEntry[0]);\n\n          setOption(\"activeId\", id ?? null);\n          isSet = true;\n\n          break;\n        }\n\n        leaf = leaf.parentElement;\n      }\n\n      if (!isSet) unsetActiveSuggestion();\n    },\n  },\n  render: {\n    belowNodes: SuggestionLineBreak as any,\n    node: SuggestionLeaf,\n    belowRootNodes: ({ api, element }) => {\n      if (!api.suggestion!.isBlockSuggestion(element)) {\n        return null;\n      }\n\n      return <BlockSuggestion element={element} />;\n    },\n  },\n});\n\nexport const SuggestionKit = [suggestionPlugin];\n"
  },
  {
    "path": "app/src/components/editor/plugins/table-base-kit.tsx",
    "content": "import { BaseTableCellHeaderPlugin, BaseTableCellPlugin, BaseTablePlugin, BaseTableRowPlugin } from \"@platejs/table\";\n\nimport {\n  TableCellElementStatic,\n  TableCellHeaderElementStatic,\n  TableElementStatic,\n  TableRowElementStatic,\n} from \"@/components/ui/table-node-static\";\n\nexport const BaseTableKit = [\n  BaseTablePlugin.withComponent(TableElementStatic),\n  BaseTableRowPlugin.withComponent(TableRowElementStatic),\n  BaseTableCellPlugin.withComponent(TableCellElementStatic),\n  BaseTableCellHeaderPlugin.withComponent(TableCellHeaderElementStatic),\n];\n"
  },
  {
    "path": "app/src/components/editor/plugins/table-kit.tsx",
    "content": "\"use client\";\n\nimport { TableCellHeaderPlugin, TableCellPlugin, TablePlugin, TableRowPlugin } from \"@platejs/table/react\";\n\nimport { TableCellElement, TableCellHeaderElement, TableElement, TableRowElement } from \"@/components/ui/table-node\";\n\nexport const TableKit = [\n  TablePlugin.withComponent(TableElement),\n  TableRowPlugin.withComponent(TableRowElement),\n  TableCellPlugin.withComponent(TableCellElement),\n  TableCellHeaderPlugin.withComponent(TableCellHeaderElement),\n];\n"
  },
  {
    "path": "app/src/components/editor/plugins/toc-base-kit.tsx",
    "content": "import { BaseTocPlugin } from \"@platejs/toc\";\n\nimport { TocElementStatic } from \"@/components/ui/toc-node-static\";\n\nexport const BaseTocKit = [BaseTocPlugin.withComponent(TocElementStatic)];\n"
  },
  {
    "path": "app/src/components/editor/plugins/toggle-base-kit.tsx",
    "content": "import { BaseTogglePlugin } from \"@platejs/toggle\";\n\nimport { ToggleElementStatic } from \"@/components/ui/toggle-node-static\";\n\nexport const BaseToggleKit = [BaseTogglePlugin.withComponent(ToggleElementStatic)];\n"
  },
  {
    "path": "app/src/components/editor/transforms.ts",
    "content": "\"use client\";\n\nimport type { PlateEditor } from \"platejs/react\";\n\nimport { insertCallout } from \"@platejs/callout\";\nimport { insertCodeBlock, toggleCodeBlock } from \"@platejs/code-block\";\nimport { insertDate } from \"@platejs/date\";\nimport { insertColumnGroup, toggleColumnGroup } from \"@platejs/layout\";\nimport { triggerFloatingLink } from \"@platejs/link/react\";\nimport { insertEquation, insertInlineEquation } from \"@platejs/math\";\nimport { insertAudioPlaceholder, insertFilePlaceholder, insertMedia, insertVideoPlaceholder } from \"@platejs/media\";\nimport { SuggestionPlugin } from \"@platejs/suggestion/react\";\nimport { TablePlugin } from \"@platejs/table/react\";\nimport { insertToc } from \"@platejs/toc\";\nimport { type NodeEntry, type Path, type TElement, KEYS, PathApi } from \"platejs\";\n\nconst ACTION_THREE_COLUMNS = \"action_three_columns\";\n\nconst insertList = (editor: PlateEditor, type: string) => {\n  editor.tf.insertNodes(\n    editor.api.create.block({\n      indent: 1,\n      listStyleType: type,\n    }),\n    { select: true },\n  );\n};\n\nconst insertBlockMap: Record<string, (editor: PlateEditor, type: string) => void> = {\n  [KEYS.listTodo]: insertList,\n  [KEYS.ol]: insertList,\n  [KEYS.ul]: insertList,\n  [ACTION_THREE_COLUMNS]: (editor) => insertColumnGroup(editor, { columns: 3, select: true }),\n  [KEYS.audio]: (editor) => insertAudioPlaceholder(editor, { select: true }),\n  [KEYS.callout]: (editor) => insertCallout(editor, { select: true }),\n  [KEYS.codeBlock]: (editor) => insertCodeBlock(editor, { select: true }),\n  [KEYS.equation]: (editor) => insertEquation(editor, { select: true }),\n  [KEYS.file]: (editor) => insertFilePlaceholder(editor, { select: true }),\n  [KEYS.img]: (editor) =>\n    insertMedia(editor, {\n      select: true,\n      type: KEYS.img,\n    }),\n  [KEYS.mediaEmbed]: (editor) =>\n    insertMedia(editor, {\n      select: true,\n      type: KEYS.mediaEmbed,\n    }),\n  [KEYS.table]: (editor) => editor.getTransforms(TablePlugin).insert.table({}, { select: true }),\n  [KEYS.toc]: (editor) => insertToc(editor, { select: true }),\n  [KEYS.video]: (editor) => insertVideoPlaceholder(editor, { select: true }),\n};\n\nconst insertInlineMap: Record<string, (editor: PlateEditor, type: string) => void> = {\n  [KEYS.date]: (editor) => insertDate(editor, { select: true }),\n  [KEYS.inlineEquation]: (editor) => insertInlineEquation(editor, \"\", { select: true }),\n  [KEYS.link]: (editor) => triggerFloatingLink(editor, { focused: true }),\n};\n\nexport const insertBlock = (editor: PlateEditor, type: string) => {\n  editor.tf.withoutNormalizing(() => {\n    const block = editor.api.block();\n\n    if (!block) return;\n    if (type in insertBlockMap) {\n      insertBlockMap[type](editor, type);\n    } else {\n      editor.tf.insertNodes(editor.api.create.block({ type }), {\n        at: PathApi.next(block[1]),\n        select: true,\n      });\n    }\n    if (getBlockType(block[0]) !== type) {\n      editor.getApi(SuggestionPlugin).suggestion.withoutSuggestions(() => {\n        editor.tf.removeNodes({ previousEmptyBlock: true });\n      });\n    }\n  });\n};\n\nexport const insertInlineElement = (editor: PlateEditor, type: string) => {\n  if (insertInlineMap[type]) {\n    insertInlineMap[type](editor, type);\n  }\n};\n\nconst setList = (editor: PlateEditor, type: string, entry: NodeEntry<TElement>) => {\n  editor.tf.setNodes(\n    editor.api.create.block({\n      indent: 1,\n      listStyleType: type,\n    }),\n    {\n      at: entry[1],\n    },\n  );\n};\n\nconst setBlockMap: Record<string, (editor: PlateEditor, type: string, entry: NodeEntry<TElement>) => void> = {\n  [KEYS.listTodo]: setList,\n  [KEYS.ol]: setList,\n  [KEYS.ul]: setList,\n  [ACTION_THREE_COLUMNS]: (editor) => toggleColumnGroup(editor, { columns: 3 }),\n  [KEYS.codeBlock]: (editor) => toggleCodeBlock(editor),\n};\n\nexport const setBlockType = (editor: PlateEditor, type: string, { at }: { at?: Path } = {}) => {\n  editor.tf.withoutNormalizing(() => {\n    const setEntry = (entry: NodeEntry<TElement>) => {\n      const [node, path] = entry;\n\n      if (node[KEYS.listType]) {\n        editor.tf.unsetNodes([KEYS.listType, \"indent\"], { at: path });\n      }\n      if (type in setBlockMap) {\n        return setBlockMap[type](editor, type, entry);\n      }\n      if (node.type !== type) {\n        editor.tf.setNodes({ type }, { at: path });\n      }\n    };\n\n    if (at) {\n      const entry = editor.api.node<TElement>(at);\n\n      if (entry) {\n        setEntry(entry);\n\n        return;\n      }\n    }\n\n    const entries = editor.api.blocks({ mode: \"lowest\" });\n\n    entries.forEach((entry) => setEntry(entry));\n  });\n};\n\nexport const getBlockType = (block: TElement) => {\n  if (block[KEYS.listType]) {\n    if (block[KEYS.listType] === KEYS.ol) {\n      return KEYS.ol;\n    } else if (block[KEYS.listType] === KEYS.listTodo) {\n      return KEYS.listTodo;\n    } else {\n      return KEYS.ul;\n    }\n  }\n\n  return block.type;\n};\n"
  },
  {
    "path": "app/src/components/key-tooltip.tsx",
    "content": "import { Tooltip, TooltipContent, TooltipTrigger } from \"@/components/ui/tooltip\";\nimport { activeProjectAtom } from \"@/state\";\nimport { parseEmacsKey } from \"@/utils/emacs\";\nimport { useAtom } from \"jotai\";\nimport { ReactNode, useEffect, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { RenderKey } from \"./ui/key-bind\";\n\nexport default function KeyTooltip({ keyId, children = <></> }: { keyId: string; children: ReactNode }) {\n  const [keySeq, setKeySeq] = useState<ReturnType<typeof parseEmacsKey>[number][]>();\n  const [activeProject] = useAtom(activeProjectAtom);\n  const { t } = useTranslation(\"keyBinds\");\n\n  useEffect(() => {\n    activeProject?.keyBinds.get(keyId)?.then((key) => {\n      if (key) {\n        const keyStr = typeof key === \"string\" ? key : key.key;\n        const parsed = parseEmacsKey(keyStr);\n        if (parsed.length > 0) {\n          setKeySeq(parsed);\n        } else {\n          setKeySeq(undefined);\n        }\n      } else {\n        setKeySeq(undefined);\n      }\n    });\n  }, [keyId, activeProject]);\n\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>{children}</TooltipTrigger>\n      {/* 给下面的组件加属性，sideOffset={12}，可以缓解右键菜单按钮比较密集的tooltip遮挡的问题 */}\n      <TooltipContent className=\"pointer-events-none flex gap-2\">\n        <span>{t(`${keyId}.title`)}</span>\n        <div className=\"flex\">\n          {keySeq ? keySeq.map((data, index) => <RenderKey key={index} data={data} />) : \"[未绑定快捷键]\"}\n        </div>\n      </TooltipContent>\n    </Tooltip>\n  );\n}\n"
  },
  {
    "path": "app/src/components/render-sub-windows.tsx",
    "content": "import { SimpleCard } from \"@/components/ui/card\";\nimport { SubWindow } from \"@/core/service/SubWindow\";\nimport { cn } from \"@/utils/cn\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { Transition } from \"@headlessui/react\";\nimport { X } from \"lucide-react\";\n\n/**\n * 这个组件中管理了所有的 子窗口\n * @returns\n */\nexport default function RenderSubWindows() {\n  const subWindows = SubWindow.use();\n\n  const onClickInner = (win: SubWindow.Window) => {\n    if (win.closeWhenClickInside) {\n      SubWindow.close(win.id);\n    }\n  };\n\n  return (\n    <div className=\"pointer-events-none fixed left-0 top-0 z-40 h-full w-full\">\n      {subWindows.map((win: SubWindow.Window) => (\n        // transition 组件可以让关闭流程更平滑\n        <Transition key={win.id} appear={true} show={!win.closing}>\n          <SimpleCard\n            data-pg-window-id={win.id}\n            style={{\n              top: win.rect.top + \"px\",\n              left: win.rect.left + \"px\",\n              zIndex: win.zIndex,\n              width: win.rect.width + \"px\",\n              height: win.rect.height + \"px\",\n            }}\n            className={cn(\n              \"pointer-events-auto absolute flex flex-col overflow-hidden outline-0 transition\",\n              \"data-closed:scale-90 data-closed:opacity-0\",\n            )}\n            onClick={() => onClickInner(win)}\n            onMouseDown={(e) => {\n              SubWindow.focus(win.id);\n              // 如果按到的元素的父元素都没有data-pg-drag-region属性，就不移动窗口\n              if (!(e.target as HTMLElement).closest(\"[data-pg-drag-region]\")) {\n                return;\n              }\n              const start = new Vector(e.clientX, e.clientY);\n              const onMouseUp = () => {\n                window.removeEventListener(\"mouseup\", onMouseUp);\n                window.removeEventListener(\"mousemove\", onMouseMove);\n              };\n              const onMouseMove = (e: MouseEvent) => {\n                const delta = new Vector(e.clientX, e.clientY).subtract(start);\n                const newRect = win.rect.translate(delta);\n                SubWindow.update(win.id, { rect: newRect });\n              };\n              window.addEventListener(\"mouseup\", onMouseUp);\n              window.addEventListener(\"mousemove\", onMouseMove);\n            }}\n            onTouchStart={(e) => {\n              SubWindow.focus(win.id);\n              if (e.touches.length > 1) return;\n              const touch = e.touches[0];\n              // 如果按到的元素的父元素都没有data-pg-drag-region属性，就不移动窗口\n              if (!(e.target as HTMLElement).closest(\"[data-pg-drag-region]\")) {\n                return;\n              }\n              const start = new Vector(touch.clientX, touch.clientY);\n              const onTouchEnd = () => {\n                window.removeEventListener(\"touchend\", onTouchEnd);\n                window.removeEventListener(\"touchmove\", onTouchMove);\n              };\n              const onTouchMove = (e: TouchEvent) => {\n                if (e.touches.length > 1) return;\n                const touch = e.touches[0];\n                const delta = new Vector(touch.clientX, touch.clientY).subtract(start);\n                const newRect = win.rect.translate(delta);\n                SubWindow.update(win.id, { rect: newRect });\n              };\n              window.addEventListener(\"touchend\", onTouchEnd);\n              window.addEventListener(\"touchmove\", onTouchMove);\n            }}\n          >\n            <div\n              className={cn(\n                \"flex p-1\",\n                win.titleBarOverlay && \"pointer-events-none absolute left-0 top-0 z-[100] w-full\",\n              )}\n            >\n              <div\n                className=\"hover:bg-sidebar-accent hover:outline-sidebar-ring flex-1 cursor-grab rounded-sm px-1 transition-all hover:outline hover:outline-dashed active:cursor-grabbing\"\n                data-pg-drag-region={win.titleBarOverlay ? undefined : \"\"}\n              >\n                {win.title}\n              </div>\n              {win.closable && (\n                <X\n                  className=\"pointer-events-auto\"\n                  onClick={() => {\n                    SubWindow.close(win.id);\n                  }}\n                />\n              )}\n            </div>\n            <div className=\"flex-1 overflow-auto\">\n              {win.children && win.children instanceof Object && \"props\" in win.children\n                ? {\n                    ...win.children,\n                    props: {\n                      ...(win.children.props || {}),\n                      winId: win.id,\n                    },\n                  }\n                : win.children}\n            </div>\n            {/* 添加一个可调整大小的边缘，这里以右下角为例 */}\n            <div\n              className=\"bg-sub-window-resize-bg hover:bg-foreground/50 absolute bottom-0 right-0 h-4 w-4 cursor-se-resize\"\n              onMouseDown={(e) => {\n                const start = new Vector(e.clientX, e.clientY);\n                const onMouseUp = () => {\n                  window.removeEventListener(\"mouseup\", onMouseUp);\n                  window.removeEventListener(\"mousemove\", onMouseMove);\n                };\n                const onMouseMove = (e: MouseEvent) => {\n                  const delta = new Vector(e.clientX, e.clientY).subtract(start);\n                  SubWindow.update(win.id, {\n                    rect: new Rectangle(win.rect.location, win.rect.size.add(delta)),\n                  });\n                };\n                window.addEventListener(\"mouseup\", onMouseUp);\n                window.addEventListener(\"mousemove\", onMouseMove);\n              }}\n              onTouchStart={(e) => {\n                if (e.touches.length > 1) return;\n                const touch = e.touches[0];\n                const start = new Vector(touch.clientX, touch.clientY);\n                const onTouchEnd = () => {\n                  window.removeEventListener(\"touchend\", onTouchEnd);\n                  window.removeEventListener(\"touchmove\", onTouchMove);\n                };\n                const onTouchMove = (e: TouchEvent) => {\n                  if (e.touches.length > 1) return;\n                  const touch = e.touches[0];\n                  const delta = new Vector(touch.clientX, touch.clientY).subtract(start);\n                  SubWindow.update(win.id, {\n                    rect: new Rectangle(win.rect.location, win.rect.size.add(delta)),\n                  });\n                };\n                window.addEventListener(\"touchend\", onTouchEnd);\n                window.addEventListener(\"touchmove\", onTouchMove);\n              }}\n            />\n            {/* 左下角 */}\n            <div\n              className=\"bg-sub-window-resize-bg hover:bg-foreground/50 absolute bottom-0 left-0 h-4 w-4 cursor-sw-resize\"\n              onMouseDown={(e) => {\n                const start = new Vector(e.clientX, e.clientY);\n                const onMouseUp = () => {\n                  window.removeEventListener(\"mouseup\", onMouseUp);\n                  window.removeEventListener(\"mousemove\", onMouseMove);\n                };\n                const onMouseMove = (e: MouseEvent) => {\n                  const delta = new Vector(e.clientX, e.clientY).subtract(start);\n                  SubWindow.update(win.id, {\n                    rect: new Rectangle(\n                      new Vector(win.rect.left + delta.x, win.rect.top),\n                      new Vector(win.rect.width - delta.x, win.rect.height + delta.y),\n                    ),\n                  });\n                };\n                window.addEventListener(\"mouseup\", onMouseUp);\n                window.addEventListener(\"mousemove\", onMouseMove);\n              }}\n              onTouchStart={(e) => {\n                if (e.touches.length > 1) return;\n                const touch = e.touches[0];\n                const start = new Vector(touch.clientX, touch.clientY);\n                const onTouchEnd = () => {\n                  window.removeEventListener(\"touchend\", onTouchEnd);\n                  window.removeEventListener(\"touchmove\", onTouchMove);\n                };\n                const onTouchMove = (e: TouchEvent) => {\n                  if (e.touches.length > 1) return;\n                  const touch = e.touches[0];\n                  const delta = new Vector(touch.clientX, touch.clientY).subtract(start);\n                  SubWindow.update(win.id, {\n                    rect: new Rectangle(\n                      new Vector(win.rect.left + delta.x, win.rect.top),\n                      new Vector(win.rect.width - delta.x, win.rect.height + delta.y),\n                    ),\n                  });\n                };\n                window.addEventListener(\"touchend\", onTouchEnd);\n                window.addEventListener(\"touchmove\", onTouchMove);\n              }}\n            />\n          </SimpleCard>\n        </Transition>\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/src/components/right-toolbar.tsx",
    "content": "import { Settings, settingsSchema } from \"@/core/service/Settings\";\nimport { QuickSettingsManager } from \"@/core/service/QuickSettingsManager\";\nimport { settingsIcons } from \"@/core/service/SettingsIcons\";\nimport { Button } from \"./ui/button\";\nimport { Switch } from \"./ui/switch\";\nimport { Toolbar } from \"./ui/toolbar\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"./ui/tooltip\";\nimport { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from \"./ui/dialog\";\nimport { useTranslation } from \"react-i18next\";\nimport { useEffect, useState } from \"react\";\nimport { cn } from \"@/utils/cn\";\nimport { Fragment } from \"react\";\nimport { useAtom } from \"jotai\";\nimport { isClassroomModeAtom } from \"@/state\";\n\n/**\n * 单个快捷设置项按钮\n */\nfunction QuickSettingButton({\n  settingKey,\n  isHovered,\n}: {\n  settingKey: keyof ReturnType<typeof settingsSchema._def.shape>;\n  isHovered: boolean;\n}) {\n  const { t } = useTranslation(\"settings\");\n  const [value, setValue] = useState<boolean>(Settings[settingKey] as boolean);\n  const [showDialog, setShowDialog] = useState(false);\n\n  useEffect(() => {\n    const unwatch = Settings.watch(settingKey, (newValue) => {\n      if (typeof newValue === \"boolean\") {\n        setValue(newValue);\n      }\n    });\n    return unwatch;\n  }, [settingKey]);\n\n  const handleToggle = () => {\n    const currentValue = Settings[settingKey];\n    if (typeof currentValue === \"boolean\") {\n      // @ts-expect-error 设置值\n      Settings[settingKey] = !currentValue;\n    }\n  };\n\n  const handleIconClick = () => {\n    setShowDialog(true);\n  };\n\n  const Icon = settingsIcons[settingKey as keyof typeof settingsIcons] ?? Fragment;\n  const title = t(`${settingKey}.title` as string);\n  const description = t(`${settingKey}.description` as string);\n\n  if (Icon === Fragment) return null;\n\n  return (\n    <>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <div className=\"flex items-center gap-2\">\n            <Button\n              className={cn(\"opacity-50 transition-opacity hover:opacity-100\", value && \"opacity-100\")}\n              variant=\"ghost\"\n              size=\"icon\"\n              onClick={handleIconClick}\n            >\n              <Icon />\n            </Button>\n            <div\n              className={cn(\n                \"overflow-hidden transition-all duration-200\",\n                isHovered ? \"w-10 opacity-100\" : \"w-0 opacity-0\",\n              )}\n            >\n              <Switch checked={value} onCheckedChange={handleToggle} />\n            </div>\n          </div>\n        </TooltipTrigger>\n        <TooltipContent side=\"left\">{title}</TooltipContent>\n      </Tooltip>\n\n      <Dialog open={showDialog} onOpenChange={setShowDialog}>\n        <DialogContent>\n          <DialogHeader>\n            <DialogTitle className=\"flex items-center gap-2\">\n              <Icon className=\"h-5 w-5\" />\n              {title}\n            </DialogTitle>\n            <DialogDescription className=\"pt-2\">{description}</DialogDescription>\n          </DialogHeader>\n        </DialogContent>\n      </Dialog>\n    </>\n  );\n}\n\n/**\n * 右侧工具栏\n * 显示用户自定义的快捷设置项开关\n */\nexport default function RightToolbar() {\n  const [quickSettings, setQuickSettings] = useState<QuickSettingsManager.QuickSettingItem[]>([]);\n  const [isHovered, setIsHovered] = useState(false);\n  const [isClassroomMode] = useAtom(isClassroomModeAtom);\n\n  const loadQuickSettings = async () => {\n    const items = await QuickSettingsManager.getQuickSettings();\n    setQuickSettings(items);\n  };\n\n  useEffect(() => {\n    // 加载快捷设置项列表\n    loadQuickSettings();\n\n    // 定期检查更新（每5秒）\n    const interval = setInterval(() => {\n      loadQuickSettings();\n    }, 5000);\n\n    // 监听窗口焦点事件，当窗口重新获得焦点时刷新\n    const handleFocus = () => {\n      loadQuickSettings();\n    };\n    window.addEventListener(\"focus\", handleFocus);\n\n    return () => {\n      clearInterval(interval);\n      window.removeEventListener(\"focus\", handleFocus);\n    };\n  }, []);\n\n  return (\n    <div\n      className={cn(\n        \"absolute right-2 top-1/2 flex -translate-y-1/2 transform flex-col items-center justify-center transition-all hover:opacity-100\",\n        isClassroomMode && \"opacity-0\",\n      )}\n    >\n      <Toolbar\n        className=\"bg-popover/95 supports-backdrop-blur:bg-popover/80 border-border/50 flex-col gap-0.5 rounded-lg border px-1 py-1.5 shadow-xl backdrop-blur-md\"\n        onMouseEnter={() => setIsHovered(true)}\n        onMouseLeave={() => setIsHovered(false)}\n      >\n        {quickSettings.map((item) => (\n          <QuickSettingButton key={item.settingKey as string} settingKey={item.settingKey} isHovered={isHovered} />\n        ))}\n      </Toolbar>\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/src/components/theme-mode-switch.tsx",
    "content": "import { Settings } from \"@/core/service/Settings\";\nimport { Switch } from \"./ui/switch\";\nimport { Sun, Moon } from \"lucide-react\";\nimport { cn } from \"@/utils/cn\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"./ui/tooltip\";\nimport { useTranslation } from \"react-i18next\";\n\nexport default function ThemeModeSwitch() {\n  const { t } = useTranslation(\"settings\");\n  const [themeMode] = Settings.use(\"themeMode\");\n  const isDark = themeMode === \"dark\";\n\n  const handleToggle = () => {\n    Settings.themeMode = isDark ? \"light\" : \"dark\";\n  };\n\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>\n        <div\n          className={cn(\n            \"bg-background shadow-xs outline-accent-foreground/10 relative h-6 w-11 cursor-pointer overflow-hidden rounded-full outline-1\",\n          )}\n        >\n          {/* Hidden default thumb */}\n          <Switch\n            checked={isDark}\n            onCheckedChange={handleToggle}\n            className=\"absolute inset-0 h-full w-full cursor-pointer opacity-0\"\n          />\n          {/* Custom thumb with icon */}\n          <span\n            className={cn(\n              \"pointer-events-none absolute left-0.5 top-0.5 flex h-5 w-5 items-center justify-center rounded-full transition-all duration-200\",\n              isDark && \"translate-x-5\",\n              isDark ? \"bg-neutral-300\" : \"bg-amber-200\",\n            )}\n          >\n            {isDark ? <Moon className=\"h-3 w-3 text-slate-800\" /> : <Sun className=\"h-3 w-3 text-amber-600\" />}\n          </span>\n        </div>\n      </TooltipTrigger>\n      <TooltipContent side=\"left\">{isDark ? t(\"themeMode.options.dark\") : t(\"themeMode.options.light\")}</TooltipContent>\n    </Tooltip>\n  );\n}\n"
  },
  {
    "path": "app/src/components/toolbar-content.tsx",
    "content": "import { Settings } from \"@/core/service/Settings\";\nimport { Button } from \"./ui/button\";\nimport { Toolbar } from \"./ui/toolbar\";\nimport { MousePointer, Pencil, Waypoints } from \"lucide-react\";\nimport { Tooltip } from \"./ui/tooltip\";\nimport { TooltipContent, TooltipTrigger } from \"@radix-ui/react-tooltip\";\nimport { useTranslation } from \"react-i18next\";\nimport { useEffect, useState } from \"react\";\nimport { cn } from \"@/utils/cn\";\nimport { ColorManager } from \"@/core/service/feedbackService/ColorManager\";\nimport { Color } from \"@graphif/data-structures\";\nimport { useAtom } from \"jotai\";\nimport { isClassroomModeAtom } from \"@/state\";\n\n/**\n * 底部工具栏\n * @returns\n */\nexport default function ToolbarContent() {\n  const { t } = useTranslation(\"keyBinds\");\n  const [isClassroomMode] = useAtom(isClassroomModeAtom);\n\n  const [leftMouseMode, setLeftMouseMode] = useState(Settings.mouseLeftMode);\n  useEffect(() => {\n    setLeftMouseMode(Settings.mouseLeftMode);\n  }, [Settings.mouseLeftMode]);\n\n  return (\n    <div\n      className={cn(\n        \"absolute bottom-0 left-1/2 flex -translate-x-1/2 transform flex-col items-center justify-center transition-all hover:opacity-100\",\n        isClassroomMode && \"opacity-0\",\n      )}\n    >\n      <Toolbar className=\"bg-popover/95 supports-backdrop-blur:bg-popover/80 border-border/50 rounded-t-lg border-t px-2 py-1.5 shadow-xl backdrop-blur-md\">\n        <Tooltip>\n          <TooltipTrigger>\n            <Button\n              className={cn(\"opacity-50\", leftMouseMode === \"selectAndMove\" && \"opacity-100\")}\n              variant=\"ghost\"\n              size=\"icon\"\n              onClick={() => {\n                setLeftMouseMode(\"connectAndCut\");\n                Settings.mouseLeftMode = \"selectAndMove\";\n              }}\n            >\n              <MousePointer />\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent>{t(\"checkoutLeftMouseToSelectAndMove.title\")}</TooltipContent>\n        </Tooltip>\n\n        <Tooltip>\n          <TooltipTrigger>\n            <Button\n              className={cn(\"opacity-50\", leftMouseMode === \"draw\" && \"opacity-100\")}\n              variant=\"ghost\"\n              size=\"icon\"\n              onClick={() => {\n                setLeftMouseMode(\"draw\");\n                Settings.mouseLeftMode = \"draw\";\n              }}\n            >\n              <Pencil />\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent>{t(\"checkoutLeftMouseToDrawing.title\")}</TooltipContent>\n        </Tooltip>\n\n        <Tooltip>\n          <TooltipTrigger>\n            <Button\n              className={cn(\"opacity-50\", leftMouseMode === \"connectAndCut\" && \"opacity-100\")}\n              variant=\"ghost\"\n              size=\"icon\"\n              onClick={() => {\n                setLeftMouseMode(\"connectAndCut\");\n                Settings.mouseLeftMode = \"connectAndCut\";\n              }}\n            >\n              <Waypoints />\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent>{t(\"checkoutLeftMouseToConnectAndCutting.title\")}</TooltipContent>\n        </Tooltip>\n      </Toolbar>\n      {leftMouseMode === \"draw\" && (\n        <div>\n          <DrawingColorLine />\n        </div>\n      )}\n    </div>\n  );\n}\n\nconst DrawingColorLine: React.FC = () => {\n  const [userColorList, setUserColorList] = useState<Color[]>([]);\n  const [currentDrawColor, setCurrentDrawColor] = useState<Color>(Color.Transparent);\n\n  useEffect(() => {\n    ColorManager.getUserEntityFillColors().then((colors) => {\n      setUserColorList(colors);\n    });\n    setCurrentDrawColor(new Color(...Settings.autoFillPenStrokeColor));\n  }, []);\n\n  const handleChangeColor = (color: Color) => {\n    Settings.autoFillPenStrokeColor = color.toArray();\n    setCurrentDrawColor(color.clone());\n  };\n\n  return (\n    <div className=\"flex max-w-64 overflow-x-auto\">\n      {userColorList.map((color) => {\n        return (\n          <div\n            className={cn(\n              \"outline-accent-foreground hover:outline-3 hover:-outline-offset-3 size-4 cursor-pointer\",\n              currentDrawColor.equals(color) && \"outline-2 -outline-offset-2\",\n            )}\n            key={color.toString()}\n            style={{\n              backgroundColor: `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`,\n            }}\n            onClick={() => {\n              handleChangeColor(color);\n            }}\n          />\n        );\n      })}\n    </div>\n  );\n};\n"
  },
  {
    "path": "app/src/components/ui/ai-node.tsx",
    "content": "\"use client\";\n\nimport { AIChatPlugin } from \"@platejs/ai/react\";\nimport { type PlateElementProps, type PlateTextProps, PlateElement, PlateText, usePluginOption } from \"platejs/react\";\n\nimport { cn } from \"@/utils/cn\";\n\nexport function AILeaf(props: PlateTextProps) {\n  const streaming = usePluginOption(AIChatPlugin, \"streaming\");\n  const streamingLeaf = props.editor.getApi(AIChatPlugin).aiChat.node({ streaming: true });\n\n  const isLast = streamingLeaf?.[0] === props.text;\n\n  return (\n    <PlateText\n      className={cn(\n        \"border-b-2 border-b-purple-100 bg-purple-50 text-purple-800\",\n        \"transition-all duration-200 ease-in-out\",\n        isLast &&\n          streaming &&\n          'after:bg-primary after:ml-1.5 after:inline-block after:h-3 after:w-3 after:rounded-full after:align-middle after:content-[\"\"]',\n      )}\n      {...props}\n    />\n  );\n}\n\nexport function AIAnchorElement(props: PlateElementProps) {\n  return (\n    <PlateElement {...props}>\n      <div className=\"h-[0.1px]\" />\n    </PlateElement>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/ai-toolbar-button.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\n\nimport { AIChatPlugin } from \"@platejs/ai/react\";\nimport { useEditorPlugin } from \"platejs/react\";\n\nimport { ToolbarButton } from \"./toolbar\";\n\nexport function AIToolbarButton(props: React.ComponentProps<typeof ToolbarButton>) {\n  const { api } = useEditorPlugin(AIChatPlugin);\n\n  return (\n    <ToolbarButton\n      {...props}\n      onClick={() => {\n        api.aiChat.show();\n      }}\n      onMouseDown={(e) => {\n        e.preventDefault();\n      }}\n    />\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/alert-dialog.tsx",
    "content": "import * as React from \"react\";\nimport * as AlertDialogPrimitive from \"@radix-ui/react-alert-dialog\";\n\nimport { cn } from \"@/utils/cn\";\nimport { buttonVariants } from \"@/components/ui/button\";\n\nfunction AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {\n  return <AlertDialogPrimitive.Root data-slot=\"alert-dialog\" {...props} />;\n}\n\nfunction AlertDialogTrigger({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {\n  return <AlertDialogPrimitive.Trigger data-slot=\"alert-dialog-trigger\" {...props} />;\n}\n\nfunction AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {\n  return <AlertDialogPrimitive.Portal data-slot=\"alert-dialog-portal\" {...props} />;\n}\n\nfunction AlertDialogOverlay({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {\n  return (\n    <AlertDialogPrimitive.Overlay\n      data-slot=\"alert-dialog-overlay\"\n      className={cn(\n        \"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction AlertDialogContent({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {\n  return (\n    <AlertDialogPortal>\n      <AlertDialogOverlay />\n      <AlertDialogPrimitive.Content\n        data-slot=\"alert-dialog-content\"\n        className={cn(\n          \"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg\",\n          className,\n        )}\n        {...props}\n      />\n    </AlertDialogPortal>\n  );\n}\n\nfunction AlertDialogHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"alert-dialog-header\"\n      className={cn(\"flex flex-col gap-2 text-center sm:text-left\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction AlertDialogFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"alert-dialog-footer\"\n      className={cn(\"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction AlertDialogTitle({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {\n  return (\n    <AlertDialogPrimitive.Title\n      data-slot=\"alert-dialog-title\"\n      className={cn(\"text-lg font-semibold\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction AlertDialogDescription({\n  className,\n  ...props\n}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {\n  return (\n    <AlertDialogPrimitive.Description\n      data-slot=\"alert-dialog-description\"\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction AlertDialogAction({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {\n  return <AlertDialogPrimitive.Action className={cn(buttonVariants(), className)} {...props} />;\n}\n\nfunction AlertDialogCancel({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {\n  return <AlertDialogPrimitive.Cancel className={cn(buttonVariants({ variant: \"outline\" }), className)} {...props} />;\n}\n\nexport {\n  AlertDialog,\n  AlertDialogPortal,\n  AlertDialogOverlay,\n  AlertDialogTrigger,\n  AlertDialogContent,\n  AlertDialogHeader,\n  AlertDialogFooter,\n  AlertDialogTitle,\n  AlertDialogDescription,\n  AlertDialogAction,\n  AlertDialogCancel,\n};\n"
  },
  {
    "path": "app/src/components/ui/alert.tsx",
    "content": "import * as React from \"react\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \"@/utils/cn\";\n\nconst alertVariants = cva(\n  \"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-card text-card-foreground\",\n        destructive:\n          \"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  },\n);\n\nfunction Alert({ className, variant, ...props }: React.ComponentProps<\"div\"> & VariantProps<typeof alertVariants>) {\n  return <div data-slot=\"alert\" role=\"alert\" className={cn(alertVariants({ variant }), className)} {...props} />;\n}\n\nfunction AlertTitle({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"alert-title\"\n      className={cn(\"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction AlertDescription({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"alert-description\"\n      className={cn(\n        \"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport { Alert, AlertTitle, AlertDescription };\n"
  },
  {
    "path": "app/src/components/ui/align-toolbar-button.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport type { Alignment } from \"@platejs/basic-styles\";\nimport type { DropdownMenuProps } from \"@radix-ui/react-dropdown-menu\";\n\nimport { TextAlignPlugin } from \"@platejs/basic-styles/react\";\nimport { AlignCenterIcon, AlignJustifyIcon, AlignLeftIcon, AlignRightIcon } from \"lucide-react\";\nimport { useEditorPlugin, useSelectionFragmentProp } from \"platejs/react\";\n\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\n\nimport { ToolbarButton } from \"./toolbar\";\n\nconst items = [\n  {\n    icon: AlignLeftIcon,\n    value: \"left\",\n  },\n  {\n    icon: AlignCenterIcon,\n    value: \"center\",\n  },\n  {\n    icon: AlignRightIcon,\n    value: \"right\",\n  },\n  {\n    icon: AlignJustifyIcon,\n    value: \"justify\",\n  },\n];\n\nexport function AlignToolbarButton(props: DropdownMenuProps) {\n  const { editor, tf } = useEditorPlugin(TextAlignPlugin);\n  const value =\n    useSelectionFragmentProp({\n      defaultValue: \"start\",\n      getProp: (node) => node.align,\n    }) ?? \"left\";\n\n  const [open, setOpen] = React.useState(false);\n  const IconValue = items.find((item) => item.value === value)?.icon ?? AlignLeftIcon;\n\n  return (\n    <DropdownMenu open={open} onOpenChange={setOpen} modal={false} {...props}>\n      <DropdownMenuTrigger asChild>\n        <ToolbarButton pressed={open} tooltip=\"Align\" isDropdown>\n          <IconValue />\n        </ToolbarButton>\n      </DropdownMenuTrigger>\n\n      <DropdownMenuContent className=\"min-w-0\" align=\"start\">\n        <DropdownMenuRadioGroup\n          value={value}\n          onValueChange={(value) => {\n            tf.textAlign.setNodes(value as Alignment);\n            editor.tf.focus();\n          }}\n        >\n          {items.map(({ icon: Icon, value: itemValue }) => (\n            <DropdownMenuRadioItem\n              key={itemValue}\n              className=\"data-[state=checked]:bg-accent *:first:[span]:hidden pl-2\"\n              value={itemValue}\n            >\n              <Icon />\n            </DropdownMenuRadioItem>\n          ))}\n        </DropdownMenuRadioGroup>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/avatar.tsx",
    "content": "import * as React from \"react\";\nimport * as AvatarPrimitive from \"@radix-ui/react-avatar\";\n\nimport { cn } from \"@/utils/cn\";\n\nfunction Avatar({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Root>) {\n  return (\n    <AvatarPrimitive.Root\n      data-slot=\"avatar\"\n      className={cn(\"relative flex size-8 shrink-0 overflow-hidden rounded-full\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {\n  return (\n    <AvatarPrimitive.Image data-slot=\"avatar-image\" className={cn(\"aspect-square size-full\", className)} {...props} />\n  );\n}\n\nfunction AvatarFallback({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {\n  return (\n    <AvatarPrimitive.Fallback\n      data-slot=\"avatar-fallback\"\n      className={cn(\"bg-muted flex size-full items-center justify-center rounded-full\", className)}\n      {...props}\n    />\n  );\n}\n\nexport { Avatar, AvatarImage, AvatarFallback };\n"
  },
  {
    "path": "app/src/components/ui/block-discussion.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\n\nimport type { PlateElementProps, RenderNodeWrapper } from \"platejs/react\";\n\nimport { getDraftCommentKey } from \"@platejs/comment\";\nimport { CommentPlugin } from \"@platejs/comment/react\";\nimport { SuggestionPlugin } from \"@platejs/suggestion/react\";\nimport { MessageSquareTextIcon, MessagesSquareIcon, PencilLineIcon } from \"lucide-react\";\nimport {\n  type AnyPluginConfig,\n  type NodeEntry,\n  type Path,\n  type TCommentText,\n  type TElement,\n  type TSuggestionText,\n  PathApi,\n  TextApi,\n} from \"platejs\";\nimport { useEditorPlugin, useEditorRef, usePluginOption } from \"platejs/react\";\n\nimport { commentPlugin } from \"@/components/editor/plugins/comment-kit\";\nimport { type TDiscussion, discussionPlugin } from \"@/components/editor/plugins/discussion-kit\";\nimport { suggestionPlugin } from \"@/components/editor/plugins/suggestion-kit\";\nimport { Button } from \"@/components/ui/button\";\nimport { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from \"@/components/ui/popover\";\n\nimport { BlockSuggestionCard, isResolvedSuggestion, useResolveSuggestion } from \"./block-suggestion\";\nimport { Comment, CommentCreateForm } from \"./comment\";\n\nexport const BlockDiscussion: RenderNodeWrapper<AnyPluginConfig> = (props) => {\n  const { editor, element } = props;\n\n  const commentsApi = editor.getApi(CommentPlugin).comment;\n  const blockPath = editor.api.findPath(element);\n\n  // avoid duplicate in table or column\n  if (!blockPath || blockPath.length > 1) return;\n\n  const draftCommentNode = commentsApi.node({ at: blockPath, isDraft: true });\n\n  const commentNodes = [...commentsApi.nodes({ at: blockPath })];\n\n  const suggestionNodes = [...editor.getApi(SuggestionPlugin).suggestion.nodes({ at: blockPath })];\n\n  if (commentNodes.length === 0 && suggestionNodes.length === 0 && !draftCommentNode) {\n    return;\n  }\n\n  return (props) => (\n    <BlockCommentContent\n      blockPath={blockPath}\n      commentNodes={commentNodes}\n      draftCommentNode={draftCommentNode}\n      suggestionNodes={suggestionNodes}\n      {...props}\n    />\n  );\n};\n\nconst BlockCommentContent = ({\n  blockPath,\n  children,\n  commentNodes,\n  draftCommentNode,\n  suggestionNodes,\n}: PlateElementProps & {\n  blockPath: Path;\n  commentNodes: NodeEntry<TCommentText>[];\n  draftCommentNode: NodeEntry<TCommentText> | undefined;\n  suggestionNodes: NodeEntry<TElement | TSuggestionText>[];\n}) => {\n  const editor = useEditorRef();\n  const resolvedSuggestions = useResolveSuggestion(suggestionNodes, blockPath);\n  const resolvedDiscussions = useResolvedDiscussion(commentNodes, blockPath);\n\n  const suggestionsCount = resolvedSuggestions.length;\n  const discussionsCount = resolvedDiscussions.length;\n  const totalCount = suggestionsCount + discussionsCount;\n\n  const activeSuggestionId = usePluginOption(suggestionPlugin, \"activeId\");\n  const activeSuggestion = activeSuggestionId && resolvedSuggestions.find((s) => s.suggestionId === activeSuggestionId);\n\n  const commentingBlock = usePluginOption(commentPlugin, \"commentingBlock\");\n  const activeCommentId = usePluginOption(commentPlugin, \"activeId\");\n  const isCommenting = activeCommentId === getDraftCommentKey();\n  const activeDiscussion = activeCommentId && resolvedDiscussions.find((d) => d.id === activeCommentId);\n\n  const noneActive = !activeSuggestion && !activeDiscussion;\n\n  const sortedMergedData = [...resolvedDiscussions, ...resolvedSuggestions].sort(\n    (a, b) => a.createdAt.getTime() - b.createdAt.getTime(),\n  );\n\n  const selected =\n    resolvedDiscussions.some((d) => d.id === activeCommentId) ||\n    resolvedSuggestions.some((s) => s.suggestionId === activeSuggestionId);\n\n  const [_open, setOpen] = React.useState(selected);\n\n  // in some cases, we may comment the multiple blocks\n  const commentingCurrent = !!commentingBlock && PathApi.equals(blockPath, commentingBlock);\n\n  const open = _open || selected || (isCommenting && !!draftCommentNode && commentingCurrent);\n\n  const anchorElement = React.useMemo(() => {\n    let activeNode: NodeEntry | undefined;\n\n    if (activeSuggestion) {\n      activeNode = suggestionNodes.find(\n        ([node]) =>\n          TextApi.isText(node) &&\n          editor.getApi(SuggestionPlugin).suggestion.nodeId(node) === activeSuggestion.suggestionId,\n      );\n    }\n\n    if (activeCommentId) {\n      if (activeCommentId === getDraftCommentKey()) {\n        activeNode = draftCommentNode;\n      } else {\n        activeNode = commentNodes.find(\n          ([node]) => editor.getApi(commentPlugin).comment.nodeId(node) === activeCommentId,\n        );\n      }\n    }\n\n    if (!activeNode) return null;\n\n    return editor.api.toDOMNode(activeNode[0])!;\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [open, activeSuggestion, activeCommentId, editor.api, suggestionNodes, draftCommentNode, commentNodes]);\n\n  if (suggestionsCount + resolvedDiscussions.length === 0 && !draftCommentNode)\n    return <div className=\"w-full\">{children}</div>;\n\n  return (\n    <div className=\"flex w-full justify-between\">\n      <Popover\n        open={open}\n        onOpenChange={(_open_) => {\n          if (!_open_ && isCommenting && draftCommentNode) {\n            editor.tf.unsetNodes(getDraftCommentKey(), {\n              at: [],\n              mode: \"lowest\",\n              match: (n) => n[getDraftCommentKey()],\n            });\n          }\n          setOpen(_open_);\n        }}\n      >\n        <div className=\"w-full\">{children}</div>\n        {anchorElement && <PopoverAnchor asChild className=\"w-full\" virtualRef={{ current: anchorElement }} />}\n\n        <PopoverContent\n          className=\"max-h-[min(50dvh,calc(-24px+var(--radix-popper-available-height)))] w-[380px] min-w-[130px] max-w-[calc(100vw-24px)] overflow-y-auto p-0 data-[state=closed]:opacity-0\"\n          onCloseAutoFocus={(e) => e.preventDefault()}\n          onOpenAutoFocus={(e) => e.preventDefault()}\n          align=\"center\"\n          side=\"bottom\"\n        >\n          {isCommenting ? (\n            <CommentCreateForm className=\"p-4\" focusOnMount />\n          ) : (\n            <React.Fragment>\n              {noneActive ? (\n                sortedMergedData.map((item, index) =>\n                  isResolvedSuggestion(item) ? (\n                    <BlockSuggestionCard\n                      key={item.suggestionId}\n                      idx={index}\n                      isLast={index === sortedMergedData.length - 1}\n                      suggestion={item}\n                    />\n                  ) : (\n                    <BlockComment key={item.id} discussion={item} isLast={index === sortedMergedData.length - 1} />\n                  ),\n                )\n              ) : (\n                <React.Fragment>\n                  {activeSuggestion && (\n                    <BlockSuggestionCard\n                      key={activeSuggestion.suggestionId}\n                      idx={0}\n                      isLast={true}\n                      suggestion={activeSuggestion}\n                    />\n                  )}\n\n                  {activeDiscussion && <BlockComment discussion={activeDiscussion} isLast={true} />}\n                </React.Fragment>\n              )}\n            </React.Fragment>\n          )}\n        </PopoverContent>\n\n        {totalCount > 0 && (\n          <div className=\"relative left-0 size-0 select-none\">\n            <PopoverTrigger asChild>\n              <Button\n                variant=\"ghost\"\n                className=\"text-muted-foreground/80 hover:text-muted-foreground/80 data-[active=true]:bg-muted ml-1 mt-1 flex h-6 gap-1 !px-1.5 py-0\"\n                data-active={open}\n                contentEditable={false}\n              >\n                {suggestionsCount > 0 && discussionsCount === 0 && <PencilLineIcon className=\"size-4 shrink-0\" />}\n\n                {suggestionsCount === 0 && discussionsCount > 0 && (\n                  <MessageSquareTextIcon className=\"size-4 shrink-0\" />\n                )}\n\n                {suggestionsCount > 0 && discussionsCount > 0 && <MessagesSquareIcon className=\"size-4 shrink-0\" />}\n\n                <span className=\"text-xs font-semibold\">{totalCount}</span>\n              </Button>\n            </PopoverTrigger>\n          </div>\n        )}\n      </Popover>\n    </div>\n  );\n};\n\nfunction BlockComment({ discussion, isLast }: { discussion: TDiscussion; isLast: boolean }) {\n  const [editingId, setEditingId] = React.useState<string | null>(null);\n\n  return (\n    <React.Fragment key={discussion.id}>\n      <div className=\"p-4\">\n        {discussion.comments.map((comment, index) => (\n          <Comment\n            key={comment.id ?? index}\n            comment={comment}\n            discussionLength={discussion.comments.length}\n            documentContent={discussion?.documentContent}\n            editingId={editingId}\n            index={index}\n            setEditingId={setEditingId}\n            showDocumentContent\n          />\n        ))}\n        <CommentCreateForm discussionId={discussion.id} />\n      </div>\n\n      {!isLast && <div className=\"bg-muted h-px w-full\" />}\n    </React.Fragment>\n  );\n}\n\nconst useResolvedDiscussion = (commentNodes: NodeEntry<TCommentText>[], blockPath: Path) => {\n  const { api, getOption, setOption } = useEditorPlugin(commentPlugin);\n\n  const discussions = usePluginOption(discussionPlugin, \"discussions\");\n\n  commentNodes.forEach(([node]) => {\n    const id = api.comment.nodeId(node);\n    const map = getOption(\"uniquePathMap\");\n\n    if (!id) return;\n\n    const previousPath = map.get(id);\n\n    // If there are no comment nodes in the corresponding path in the map, then update it.\n    if (PathApi.isPath(previousPath)) {\n      const nodes = api.comment.node({ id, at: previousPath });\n\n      if (!nodes) {\n        setOption(\"uniquePathMap\", new Map(map).set(id, blockPath));\n        return;\n      }\n\n      return;\n    }\n    // TODO: fix throw error\n    setOption(\"uniquePathMap\", new Map(map).set(id, blockPath));\n  });\n\n  const commentsIds = new Set(commentNodes.map(([node]) => api.comment.nodeId(node)).filter(Boolean));\n\n  const resolvedDiscussions = discussions\n    .map((d: TDiscussion) => ({\n      ...d,\n      createdAt: new Date(d.createdAt),\n    }))\n    .filter((item: TDiscussion) => {\n      /** If comment cross blocks just show it in the first block */\n      const commentsPathMap = getOption(\"uniquePathMap\");\n      const firstBlockPath = commentsPathMap.get(item.id);\n\n      if (!firstBlockPath) return false;\n      if (!PathApi.equals(firstBlockPath, blockPath)) return false;\n\n      return api.comment.has({ id: item.id }) && commentsIds.has(item.id) && !item.isResolved;\n    });\n\n  return resolvedDiscussions;\n};\n"
  },
  {
    "path": "app/src/components/ui/block-list-static.tsx",
    "content": "import * as React from \"react\";\n\nimport type { RenderStaticNodeWrapper, SlateRenderElementProps, TListElement } from \"platejs\";\n\nimport { isOrderedList } from \"@platejs/list\";\nimport { CheckIcon } from \"lucide-react\";\n\nimport { cn } from \"@/utils/cn\";\n\nconst config: Record<\n  string,\n  {\n    Li: React.FC<SlateRenderElementProps>;\n    Marker: React.FC<SlateRenderElementProps>;\n  }\n> = {\n  todo: {\n    Li: TodoLiStatic,\n    Marker: TodoMarkerStatic,\n  },\n};\n\nexport const BlockListStatic: RenderStaticNodeWrapper = (props) => {\n  if (!props.element.listStyleType) return;\n\n  return (props) => <List {...props} />;\n};\n\nfunction List(props: SlateRenderElementProps) {\n  const { listStart, listStyleType } = props.element as TListElement;\n  const { Li, Marker } = config[listStyleType] ?? {};\n  const List = isOrderedList(props.element) ? \"ol\" : \"ul\";\n\n  return (\n    <List className=\"relative m-0 p-0\" style={{ listStyleType }} start={listStart}>\n      {Marker && <Marker {...props} />}\n      {Li ? <Li {...props} /> : <li>{props.children}</li>}\n    </List>\n  );\n}\n\nfunction TodoMarkerStatic(props: SlateRenderElementProps) {\n  const checked = props.element.checked as boolean;\n\n  return (\n    <div contentEditable={false}>\n      <button\n        className={cn(\n          \"border-primary bg-background ring-offset-background focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground peer pointer-events-none absolute -left-6 top-1 size-4 shrink-0 rounded-sm border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2\",\n          props.className,\n        )}\n        data-state={checked ? \"checked\" : \"unchecked\"}\n        type=\"button\"\n      >\n        <div className={cn(\"flex items-center justify-center text-current\")}>\n          {checked && <CheckIcon className=\"size-4\" />}\n        </div>\n      </button>\n    </div>\n  );\n}\n\nfunction TodoLiStatic(props: SlateRenderElementProps) {\n  return (\n    <li className={cn(\"list-none\", (props.element.checked as boolean) && \"text-muted-foreground line-through\")}>\n      {props.children}\n    </li>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/block-list.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\n\nimport type { TListElement } from \"platejs\";\n\nimport { isOrderedList } from \"@platejs/list\";\nimport { useTodoListElement, useTodoListElementState } from \"@platejs/list/react\";\nimport { type PlateElementProps, type RenderNodeWrapper, useReadOnly } from \"platejs/react\";\n\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport { cn } from \"@/utils/cn\";\n\nconst config: Record<\n  string,\n  {\n    Li: React.FC<PlateElementProps>;\n    Marker: React.FC<PlateElementProps>;\n  }\n> = {\n  todo: {\n    Li: TodoLi,\n    Marker: TodoMarker,\n  },\n};\n\nexport const BlockList: RenderNodeWrapper = (props) => {\n  if (!props.element.listStyleType) return;\n\n  return (props) => <List {...props} />;\n};\n\nfunction List(props: PlateElementProps) {\n  const { listStart, listStyleType } = props.element as TListElement;\n  const { Li, Marker } = config[listStyleType] ?? {};\n  const List = isOrderedList(props.element) ? \"ol\" : \"ul\";\n\n  return (\n    <List className=\"relative m-0 p-0\" style={{ listStyleType }} start={listStart}>\n      {Marker && <Marker {...props} />}\n      {Li ? <Li {...props} /> : <li>{props.children}</li>}\n    </List>\n  );\n}\n\nfunction TodoMarker(props: PlateElementProps) {\n  const state = useTodoListElementState({ element: props.element });\n  const { checkboxProps } = useTodoListElement(state);\n  const readOnly = useReadOnly();\n\n  return (\n    <div contentEditable={false}>\n      <Checkbox className={cn(\"absolute -left-6 top-1\", readOnly && \"pointer-events-none\")} {...checkboxProps} />\n    </div>\n  );\n}\n\nfunction TodoLi(props: PlateElementProps) {\n  return (\n    <li className={cn(\"list-none\", (props.element.checked as boolean) && \"text-muted-foreground line-through\")}>\n      {props.children}\n    </li>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/block-selection.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport { DndPlugin } from \"@platejs/dnd\";\nimport { useBlockSelected } from \"@platejs/selection/react\";\nimport { cva } from \"class-variance-authority\";\nimport { type PlateElementProps, usePluginOption } from \"platejs/react\";\n\nexport const blockSelectionVariants = cva(\n  \"pointer-events-none absolute inset-0 z-1 bg-brand/[.13] transition-opacity\",\n  {\n    defaultVariants: {\n      active: true,\n    },\n    variants: {\n      active: {\n        false: \"opacity-0\",\n        true: \"opacity-100\",\n      },\n    },\n  },\n);\n\nexport function BlockSelection(props: PlateElementProps) {\n  const isBlockSelected = useBlockSelected();\n  const isDragging = usePluginOption(DndPlugin, \"isDragging\");\n\n  if (!isBlockSelected || props.plugin.key === \"tr\" || props.plugin.key === \"table\") return null;\n\n  return (\n    <div\n      className={blockSelectionVariants({\n        active: isBlockSelected && !isDragging,\n      })}\n      data-slot=\"block-selection\"\n    />\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/block-suggestion.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport type { TResolvedSuggestion } from \"@platejs/suggestion\";\n\nimport { acceptSuggestion, getSuggestionKey, keyId2SuggestionId, rejectSuggestion } from \"@platejs/suggestion\";\nimport { SuggestionPlugin } from \"@platejs/suggestion/react\";\nimport { CheckIcon, XIcon } from \"lucide-react\";\nimport {\n  type NodeEntry,\n  type Path,\n  type TElement,\n  type TSuggestionElement,\n  type TSuggestionText,\n  ElementApi,\n  KEYS,\n  PathApi,\n  TextApi,\n} from \"platejs\";\nimport { useEditorPlugin, usePluginOption } from \"platejs/react\";\n\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/utils/cn\";\nimport { type TDiscussion, discussionPlugin } from \"@/components/editor/plugins/discussion-kit\";\nimport { suggestionPlugin } from \"@/components/editor/plugins/suggestion-kit\";\n\nimport { type TComment, Comment, CommentCreateForm, formatCommentDate } from \"./comment\";\n\nexport interface ResolvedSuggestion extends TResolvedSuggestion {\n  comments: TComment[];\n}\n\nconst BLOCK_SUGGESTION = \"__block__\";\n\nconst TYPE_TEXT_MAP: Record<string, (node?: TElement) => string> = {\n  [KEYS.audio]: () => \"Audio\",\n  [KEYS.blockquote]: () => \"Blockquote\",\n  [KEYS.callout]: () => \"Callout\",\n  [KEYS.codeBlock]: () => \"Code Block\",\n  [KEYS.column]: () => \"Column\",\n  [KEYS.equation]: () => \"Equation\",\n  [KEYS.file]: () => \"File\",\n  [KEYS.h1]: () => `Heading 1`,\n  [KEYS.h2]: () => `Heading 2`,\n  [KEYS.h3]: () => `Heading 3`,\n  [KEYS.h4]: () => `Heading 4`,\n  [KEYS.h5]: () => `Heading 5`,\n  [KEYS.h6]: () => `Heading 6`,\n  [KEYS.hr]: () => \"Horizontal Rule\",\n  [KEYS.img]: () => \"Image\",\n  [KEYS.mediaEmbed]: () => \"Media\",\n  [KEYS.p]: (node) => {\n    if (node?.[KEYS.listType] === KEYS.listTodo) return \"Todo List\";\n    if (node?.[KEYS.listType] === KEYS.ol) return \"Ordered List\";\n    if (node?.[KEYS.listType] === KEYS.ul) return \"List\";\n\n    return \"Paragraph\";\n  },\n  [KEYS.table]: () => \"Table\",\n  [KEYS.toc]: () => \"Table of Contents\",\n  [KEYS.toggle]: () => \"Toggle\",\n  [KEYS.video]: () => \"Video\",\n};\n\nexport function BlockSuggestion({ element }: { element: TSuggestionElement }) {\n  const suggestionData = element.suggestion;\n\n  if (suggestionData?.isLineBreak) return null;\n\n  const isRemove = suggestionData?.type === \"remove\";\n\n  return (\n    <div\n      className={cn(\n        \"z-1 border-brand/[0.8] pointer-events-none absolute inset-0 border-2 transition-opacity\",\n        isRemove && \"border-gray-300\",\n      )}\n      contentEditable={false}\n    />\n  );\n}\n\nexport function BlockSuggestionCard({\n  idx,\n  isLast,\n  suggestion,\n}: {\n  idx: number;\n  isLast: boolean;\n  suggestion: ResolvedSuggestion;\n}) {\n  const { api, editor } = useEditorPlugin(SuggestionPlugin);\n\n  const userInfo = usePluginOption(discussionPlugin, \"user\", suggestion.userId);\n\n  const accept = (suggestion: ResolvedSuggestion) => {\n    api.suggestion.withoutSuggestions(() => {\n      acceptSuggestion(editor, suggestion);\n    });\n  };\n\n  const reject = (suggestion: ResolvedSuggestion) => {\n    api.suggestion.withoutSuggestions(() => {\n      rejectSuggestion(editor, suggestion);\n    });\n  };\n\n  const [hovering, setHovering] = React.useState(false);\n\n  const suggestionText2Array = (text: string) => {\n    if (text === BLOCK_SUGGESTION) return [\"line breaks\"];\n\n    return text.split(BLOCK_SUGGESTION).filter(Boolean);\n  };\n\n  const [editingId, setEditingId] = React.useState<string | null>(null);\n\n  return (\n    <div\n      key={`${suggestion.suggestionId}-${idx}`}\n      className=\"relative\"\n      onMouseEnter={() => setHovering(true)}\n      onMouseLeave={() => setHovering(false)}\n    >\n      <div className=\"flex flex-col p-4\">\n        <div className=\"relative flex items-center\">\n          {/* Replace to your own backend or refer to potion */}\n          <Avatar className=\"size-5\">\n            <AvatarImage alt={userInfo?.name} src={userInfo?.avatarUrl} />\n            <AvatarFallback>{userInfo?.name?.[0]}</AvatarFallback>\n          </Avatar>\n          <h4 className=\"mx-2 text-sm font-semibold leading-none\">{userInfo?.name}</h4>\n          <div className=\"text-muted-foreground/80 text-xs leading-none\">\n            <span className=\"mr-1\">{formatCommentDate(new Date(suggestion.createdAt))}</span>\n          </div>\n        </div>\n\n        <div className=\"relative mb-4 mt-1 pl-[32px]\">\n          <div className=\"flex flex-col gap-2\">\n            {suggestion.type === \"remove\" && (\n              <React.Fragment>\n                {suggestionText2Array(suggestion.text!).map((text, index) => (\n                  <div key={index} className=\"flex items-center gap-2\">\n                    <span className=\"text-muted-foreground text-sm\">Delete:</span>\n\n                    <span key={index} className=\"text-sm\">\n                      {text}\n                    </span>\n                  </div>\n                ))}\n              </React.Fragment>\n            )}\n\n            {suggestion.type === \"insert\" && (\n              <React.Fragment>\n                {suggestionText2Array(suggestion.newText!).map((text, index) => (\n                  <div key={index} className=\"flex items-center gap-2\">\n                    <span className=\"text-muted-foreground text-sm\">Add:</span>\n\n                    <span key={index} className=\"text-sm\">\n                      {text || \"line breaks\"}\n                    </span>\n                  </div>\n                ))}\n              </React.Fragment>\n            )}\n\n            {suggestion.type === \"replace\" && (\n              <div className=\"flex flex-col gap-2\">\n                {suggestionText2Array(suggestion.newText!).map((text, index) => (\n                  <React.Fragment key={index}>\n                    <div key={index} className=\"text-brand/80 flex items-start gap-2\">\n                      <span className=\"text-sm\">with:</span>\n                      <span className=\"text-sm\">{text || \"line breaks\"}</span>\n                    </div>\n                  </React.Fragment>\n                ))}\n\n                {suggestionText2Array(suggestion.text!).map((text, index) => (\n                  <React.Fragment key={index}>\n                    <div key={index} className=\"flex items-start gap-2\">\n                      <span className=\"text-muted-foreground text-sm\">{index === 0 ? \"Replace:\" : \"Delete:\"}</span>\n                      <span className=\"text-sm\">{text || \"line breaks\"}</span>\n                    </div>\n                  </React.Fragment>\n                ))}\n              </div>\n            )}\n\n            {suggestion.type === \"update\" && (\n              <div className=\"flex items-center gap-2\">\n                <span className=\"text-muted-foreground text-sm\">\n                  {Object.keys(suggestion.properties).map((key) => (\n                    <span key={key}>Un{key}</span>\n                  ))}\n\n                  {Object.keys(suggestion.newProperties).map((key) => (\n                    <span key={key}>{key.charAt(0).toUpperCase() + key.slice(1)}</span>\n                  ))}\n                </span>\n                <span className=\"text-sm\">{suggestion.newText}</span>\n              </div>\n            )}\n          </div>\n        </div>\n\n        {suggestion.comments.map((comment, index) => (\n          <Comment\n            key={comment.id ?? index}\n            comment={comment}\n            discussionLength={suggestion.comments.length}\n            documentContent=\"__suggestion__\"\n            editingId={editingId}\n            index={index}\n            setEditingId={setEditingId}\n          />\n        ))}\n\n        {hovering && (\n          <div className=\"absolute right-4 top-4 flex gap-2\">\n            <Button variant=\"ghost\" className=\"text-muted-foreground size-6 p-1\" onClick={() => accept(suggestion)}>\n              <CheckIcon className=\"size-4\" />\n            </Button>\n\n            <Button variant=\"ghost\" className=\"text-muted-foreground size-6 p-1\" onClick={() => reject(suggestion)}>\n              <XIcon className=\"size-4\" />\n            </Button>\n          </div>\n        )}\n\n        <CommentCreateForm discussionId={suggestion.suggestionId} />\n      </div>\n\n      {!isLast && <div className=\"bg-muted h-px w-full\" />}\n    </div>\n  );\n}\n\nexport const useResolveSuggestion = (suggestionNodes: NodeEntry<TElement | TSuggestionText>[], blockPath: Path) => {\n  const discussions = usePluginOption(discussionPlugin, \"discussions\");\n\n  const { api, editor, getOption, setOption } = useEditorPlugin(suggestionPlugin);\n\n  suggestionNodes.forEach(([node]) => {\n    const id = api.suggestion.nodeId(node);\n    const map = getOption(\"uniquePathMap\");\n\n    if (!id) return;\n\n    const previousPath = map.get(id);\n\n    // If there are no suggestion nodes in the corresponding path in the map, then update it.\n    if (PathApi.isPath(previousPath)) {\n      const nodes = api.suggestion.node({ id, at: previousPath, isText: true });\n      const parentNode = api.node(previousPath);\n      let lineBreakId: string | null = null;\n\n      if (parentNode && ElementApi.isElement(parentNode[0])) {\n        lineBreakId = api.suggestion.nodeId(parentNode[0]) ?? null;\n      }\n\n      if (!nodes && lineBreakId !== id) {\n        return setOption(\"uniquePathMap\", new Map(map).set(id, blockPath));\n      }\n\n      return;\n    }\n    setOption(\"uniquePathMap\", new Map(map).set(id, blockPath));\n  });\n\n  const resolvedSuggestion: ResolvedSuggestion[] = React.useMemo(() => {\n    const map = getOption(\"uniquePathMap\");\n\n    if (suggestionNodes.length === 0) return [];\n\n    const suggestionIds = new Set(\n      suggestionNodes\n        .flatMap(([node]) => {\n          if (TextApi.isText(node)) {\n            const dataList = api.suggestion.dataList(node);\n            const includeUpdate = dataList.some((data) => data.type === \"update\");\n\n            if (!includeUpdate) return api.suggestion.nodeId(node);\n\n            return dataList.filter((data) => data.type === \"update\").map((d) => d.id);\n          }\n          if (ElementApi.isElement(node)) {\n            return api.suggestion.nodeId(node);\n          }\n        })\n        .filter(Boolean),\n    );\n\n    const res: ResolvedSuggestion[] = [];\n\n    suggestionIds.forEach((id) => {\n      if (!id) return;\n\n      const path = map.get(id);\n\n      if (!path || !PathApi.isPath(path)) return;\n      if (!PathApi.equals(path, blockPath)) return;\n\n      const entries = [\n        ...editor.api.nodes<TElement | TSuggestionText>({\n          at: [],\n          mode: \"all\",\n          match: (n) => (n[KEYS.suggestion] && n[getSuggestionKey(id)]) || api.suggestion.nodeId(n as TElement) === id,\n        }),\n      ];\n\n      // move line break to the end\n      entries.sort(([, path1], [, path2]) => {\n        return PathApi.isChild(path1, path2) ? -1 : 1;\n      });\n\n      let newText = \"\";\n      let text = \"\";\n      let properties: any = {};\n      let newProperties: any = {};\n\n      // overlapping suggestion\n      entries.forEach(([node]) => {\n        if (TextApi.isText(node)) {\n          const dataList = api.suggestion.dataList(node);\n\n          dataList.forEach((data) => {\n            if (data.id !== id) return;\n\n            switch (data.type) {\n              case \"insert\": {\n                newText += node.text;\n\n                break;\n              }\n              case \"remove\": {\n                text += node.text;\n\n                break;\n              }\n              case \"update\": {\n                properties = {\n                  ...properties,\n                  ...data.properties,\n                };\n\n                newProperties = {\n                  ...newProperties,\n                  ...data.newProperties,\n                };\n\n                newText += node.text;\n\n                break;\n              }\n              // No default\n            }\n          });\n        } else {\n          const lineBreakData = api.suggestion.isBlockSuggestion(node) ? node.suggestion : undefined;\n\n          if (lineBreakData?.id !== keyId2SuggestionId(id)) return;\n          if (lineBreakData.type === \"insert\") {\n            newText += lineBreakData.isLineBreak ? BLOCK_SUGGESTION : BLOCK_SUGGESTION + TYPE_TEXT_MAP[node.type](node);\n          } else if (lineBreakData.type === \"remove\") {\n            text += lineBreakData.isLineBreak ? BLOCK_SUGGESTION : BLOCK_SUGGESTION + TYPE_TEXT_MAP[node.type](node);\n          }\n        }\n      });\n\n      if (entries.length === 0) return;\n\n      const nodeData = api.suggestion.suggestionData(entries[0][0]);\n\n      if (!nodeData) return;\n\n      // const comments = data?.discussions.find((d) => d.id === id)?.comments;\n      const comments = discussions.find((s: TDiscussion) => s.id === id)?.comments || [];\n      const createdAt = new Date(nodeData.createdAt);\n\n      const keyId = getSuggestionKey(id);\n\n      if (nodeData.type === \"update\") {\n        return res.push({\n          comments,\n          createdAt,\n          keyId,\n          newProperties,\n          newText,\n          properties,\n          suggestionId: keyId2SuggestionId(id),\n          type: \"update\",\n          userId: nodeData.userId,\n        });\n      }\n      if (newText.length > 0 && text.length > 0) {\n        return res.push({\n          comments,\n          createdAt,\n          keyId,\n          newText,\n          suggestionId: keyId2SuggestionId(id),\n          text,\n          type: \"replace\",\n          userId: nodeData.userId,\n        });\n      }\n      if (newText.length > 0) {\n        return res.push({\n          comments,\n          createdAt,\n          keyId,\n          newText,\n          suggestionId: keyId2SuggestionId(id),\n          type: \"insert\",\n          userId: nodeData.userId,\n        });\n      }\n      if (text.length > 0) {\n        return res.push({\n          comments,\n          createdAt,\n          keyId,\n          suggestionId: keyId2SuggestionId(id),\n          text,\n          type: \"remove\",\n          userId: nodeData.userId,\n        });\n      }\n    });\n\n    return res;\n  }, [api.suggestion, blockPath, discussions, editor.api, getOption, suggestionNodes]);\n\n  return resolvedSuggestion;\n};\n\nexport const isResolvedSuggestion = (\n  suggestion: ResolvedSuggestion | TDiscussion,\n): suggestion is ResolvedSuggestion => {\n  return \"suggestionId\" in suggestion;\n};\n"
  },
  {
    "path": "app/src/components/ui/blockquote-node-static.tsx",
    "content": "// @ts-nocheck\nimport { type SlateElementProps, SlateElement } from \"platejs\";\n\nexport function BlockquoteElementStatic(props: SlateElementProps) {\n  return <SlateElement as=\"blockquote\" className=\"my-1 border-l-2 pl-6 italic\" {...props} />;\n}\n"
  },
  {
    "path": "app/src/components/ui/blockquote-node.tsx",
    "content": "\"use client\";\n\nimport { type PlateElementProps, PlateElement } from \"platejs/react\";\n\nexport function BlockquoteElement(props: PlateElementProps) {\n  return <PlateElement as=\"blockquote\" className=\"my-1 border-l-2 pl-6 italic\" {...props} />;\n}\n"
  },
  {
    "path": "app/src/components/ui/button.tsx",
    "content": "import * as React from \"react\";\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \"@/utils/cn\";\n\nconst buttonVariants = cva(\n  \"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90\",\n        destructive:\n          \"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60\",\n        outline:\n          \"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50\",\n        secondary: \"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80\",\n        ghost: \"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50\",\n        link: \"text-primary underline-offset-4 hover:underline\",\n      },\n      size: {\n        default: \"h-9 px-4 py-2 has-[>svg]:px-3\",\n        sm: \"h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5\",\n        lg: \"h-10 rounded-md px-6 has-[>svg]:px-4\",\n        icon: \"size-9\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  },\n);\n\nfunction Button({\n  className,\n  variant,\n  size,\n  asChild = false,\n  ...props\n}: React.ComponentProps<\"button\"> &\n  VariantProps<typeof buttonVariants> & {\n    asChild?: boolean;\n  }) {\n  const Comp = asChild ? Slot : \"button\";\n\n  return <Comp data-slot=\"button\" className={cn(buttonVariants({ variant, size, className }))} {...props} />;\n}\n\nexport { Button, buttonVariants };\n"
  },
  {
    "path": "app/src/components/ui/calendar.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from \"lucide-react\";\nimport { DayButton, DayPicker, getDefaultClassNames } from \"react-day-picker\";\n\nimport { cn } from \"@/utils/cn\";\nimport { Button, buttonVariants } from \"@/components/ui/button\";\n\nfunction Calendar({\n  className,\n  classNames,\n  showOutsideDays = true,\n  captionLayout = \"label\",\n  buttonVariant = \"ghost\",\n  formatters,\n  components,\n  ...props\n}: React.ComponentProps<typeof DayPicker> & {\n  buttonVariant?: React.ComponentProps<typeof Button>[\"variant\"];\n}) {\n  const defaultClassNames = getDefaultClassNames();\n\n  return (\n    <DayPicker\n      showOutsideDays={showOutsideDays}\n      className={cn(\n        \"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent\",\n        String.raw`rtl:**:[.rdp-button\\_next>svg]:rotate-180`,\n        String.raw`rtl:**:[.rdp-button\\_previous>svg]:rotate-180`,\n        className,\n      )}\n      captionLayout={captionLayout}\n      formatters={{\n        formatMonthDropdown: (date) => date.toLocaleString(\"default\", { month: \"short\" }),\n        ...formatters,\n      }}\n      classNames={{\n        root: cn(\"w-fit\", defaultClassNames.root),\n        months: cn(\"flex gap-4 flex-col md:flex-row relative\", defaultClassNames.months),\n        month: cn(\"flex flex-col w-full gap-4\", defaultClassNames.month),\n        nav: cn(\"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between\", defaultClassNames.nav),\n        button_previous: cn(\n          buttonVariants({ variant: buttonVariant }),\n          \"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none\",\n          defaultClassNames.button_previous,\n        ),\n        button_next: cn(\n          buttonVariants({ variant: buttonVariant }),\n          \"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none\",\n          defaultClassNames.button_next,\n        ),\n        month_caption: cn(\n          \"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)\",\n          defaultClassNames.month_caption,\n        ),\n        dropdowns: cn(\n          \"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5\",\n          defaultClassNames.dropdowns,\n        ),\n        dropdown_root: cn(\n          \"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md\",\n          defaultClassNames.dropdown_root,\n        ),\n        dropdown: cn(\"absolute bg-popover inset-0 opacity-0\", defaultClassNames.dropdown),\n        caption_label: cn(\n          \"select-none font-medium\",\n          captionLayout === \"label\"\n            ? \"text-sm\"\n            : \"rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5\",\n          defaultClassNames.caption_label,\n        ),\n        table: \"w-full border-collapse\",\n        weekdays: cn(\"flex\", defaultClassNames.weekdays),\n        weekday: cn(\n          \"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none\",\n          defaultClassNames.weekday,\n        ),\n        week: cn(\"flex w-full mt-2\", defaultClassNames.week),\n        week_number_header: cn(\"select-none w-(--cell-size)\", defaultClassNames.week_number_header),\n        week_number: cn(\"text-[0.8rem] select-none text-muted-foreground\", defaultClassNames.week_number),\n        day: cn(\n          \"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none\",\n          defaultClassNames.day,\n        ),\n        range_start: cn(\"rounded-l-md bg-accent\", defaultClassNames.range_start),\n        range_middle: cn(\"rounded-none\", defaultClassNames.range_middle),\n        range_end: cn(\"rounded-r-md bg-accent\", defaultClassNames.range_end),\n        today: cn(\n          \"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none\",\n          defaultClassNames.today,\n        ),\n        outside: cn(\"text-muted-foreground aria-selected:text-muted-foreground\", defaultClassNames.outside),\n        disabled: cn(\"text-muted-foreground opacity-50\", defaultClassNames.disabled),\n        hidden: cn(\"invisible\", defaultClassNames.hidden),\n        ...classNames,\n      }}\n      components={{\n        Root: ({ className, rootRef, ...props }) => {\n          return <div data-slot=\"calendar\" ref={rootRef} className={cn(className)} {...props} />;\n        },\n        Chevron: ({ className, orientation, ...props }) => {\n          if (orientation === \"left\") {\n            return <ChevronLeftIcon className={cn(\"size-4\", className)} {...props} />;\n          }\n\n          if (orientation === \"right\") {\n            return <ChevronRightIcon className={cn(\"size-4\", className)} {...props} />;\n          }\n\n          return <ChevronDownIcon className={cn(\"size-4\", className)} {...props} />;\n        },\n        DayButton: CalendarDayButton,\n        WeekNumber: ({ children, ...props }) => {\n          return (\n            <td {...props}>\n              <div className=\"size-(--cell-size) flex items-center justify-center text-center\">{children}</div>\n            </td>\n          );\n        },\n        ...components,\n      }}\n      {...props}\n    />\n  );\n}\n\nfunction CalendarDayButton({ className, day, modifiers, ...props }: React.ComponentProps<typeof DayButton>) {\n  const defaultClassNames = getDefaultClassNames();\n\n  const ref = React.useRef<HTMLButtonElement>(null);\n  React.useEffect(() => {\n    if (modifiers.focused) ref.current?.focus();\n  }, [modifiers.focused]);\n\n  return (\n    <Button\n      ref={ref}\n      variant=\"ghost\"\n      size=\"icon\"\n      data-day={day.date.toLocaleDateString()}\n      data-selected-single={\n        modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle\n      }\n      data-range-start={modifiers.range_start}\n      data-range-end={modifiers.range_end}\n      data-range-middle={modifiers.range_middle}\n      className={cn(\n        \"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground min-w-(--cell-size) flex aspect-square size-auto w-full flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-start=true]:rounded-l-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70\",\n        defaultClassNames.day,\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport { Calendar, CalendarDayButton };\n"
  },
  {
    "path": "app/src/components/ui/callout-node-static.tsx",
    "content": "// @ts-nocheck\nimport type { SlateElementProps } from \"platejs\";\n\nimport { SlateElement } from \"platejs\";\n\nimport { cn } from \"@/utils/cn\";\n\nexport function CalloutElementStatic({ children, className, ...props }: SlateElementProps) {\n  return (\n    <SlateElement\n      className={cn(\"bg-muted my-1 flex rounded-sm p-4 pl-3\", className)}\n      style={{\n        backgroundColor: props.element.backgroundColor as any,\n      }}\n      {...props}\n    >\n      <div className=\"flex w-full gap-2 rounded-md\">\n        <div\n          className=\"size-6 select-none text-[18px]\"\n          style={{\n            fontFamily:\n              '\"Apple Color Emoji\", \"Segoe UI Emoji\", NotoColorEmoji, \"Noto Color Emoji\", \"Segoe UI Symbol\", \"Android Emoji\", EmojiSymbols',\n          }}\n        >\n          <span data-plate-prevent-deserialization>{(props.element.icon as any) || \"💡\"}</span>\n        </div>\n        <div className=\"w-full\">{children}</div>\n      </div>\n    </SlateElement>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/callout-node.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { useCalloutEmojiPicker } from \"@platejs/callout/react\";\nimport { useEmojiDropdownMenuState } from \"@platejs/emoji/react\";\nimport { PlateElement } from \"platejs/react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/utils/cn\";\n\nimport { EmojiPicker, EmojiPopover } from \"./emoji-toolbar-button\";\n\nexport function CalloutElement({\n  attributes,\n  children,\n  className,\n  ...props\n}: React.ComponentProps<typeof PlateElement>) {\n  const { emojiPickerState, isOpen, setIsOpen } = useEmojiDropdownMenuState({\n    closeOnSelect: true,\n  });\n\n  const { emojiToolbarDropdownProps, props: calloutProps } = useCalloutEmojiPicker({\n    isOpen,\n    setIsOpen,\n  });\n\n  return (\n    <PlateElement\n      className={cn(\"bg-muted my-1 flex rounded-sm p-4 pl-3\", className)}\n      style={{\n        backgroundColor: props.element.backgroundColor as any,\n      }}\n      attributes={{\n        ...attributes,\n        \"data-plate-open-context-menu\": true,\n      }}\n      {...props}\n    >\n      <div className=\"flex w-full gap-2 rounded-md\">\n        <EmojiPopover\n          {...emojiToolbarDropdownProps}\n          control={\n            <Button\n              variant=\"ghost\"\n              className=\"hover:bg-muted-foreground/15 size-6 select-none p-1 text-[18px]\"\n              style={{\n                fontFamily:\n                  '\"Apple Color Emoji\", \"Segoe UI Emoji\", NotoColorEmoji, \"Noto Color Emoji\", \"Segoe UI Symbol\", \"Android Emoji\", EmojiSymbols',\n              }}\n              contentEditable={false}\n            >\n              {(props.element.icon as any) || \"💡\"}\n            </Button>\n          }\n        >\n          <EmojiPicker {...emojiPickerState} {...calloutProps} />\n        </EmojiPopover>\n        <div className=\"w-full\">{children}</div>\n      </div>\n    </PlateElement>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/caption.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport type { VariantProps } from \"class-variance-authority\";\n\nimport {\n  Caption as CaptionPrimitive,\n  CaptionTextarea as CaptionTextareaPrimitive,\n  useCaptionButton,\n  useCaptionButtonState,\n} from \"@platejs/caption/react\";\nimport { createPrimitiveComponent } from \"@udecode/cn\";\nimport { cva } from \"class-variance-authority\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/utils/cn\";\n\nconst captionVariants = cva(\"max-w-full\", {\n  defaultVariants: {\n    align: \"center\",\n  },\n  variants: {\n    align: {\n      center: \"mx-auto\",\n      left: \"mr-auto\",\n      right: \"ml-auto\",\n    },\n  },\n});\n\nexport function Caption({\n  align,\n  className,\n  ...props\n}: React.ComponentProps<typeof CaptionPrimitive> & VariantProps<typeof captionVariants>) {\n  return <CaptionPrimitive {...props} className={cn(captionVariants({ align }), className)} />;\n}\n\nexport function CaptionTextarea(props: React.ComponentProps<typeof CaptionTextareaPrimitive>) {\n  return (\n    <CaptionTextareaPrimitive\n      {...props}\n      className={cn(\n        \"mt-2 w-full resize-none border-none bg-inherit p-0 font-[inherit] text-inherit\",\n        \"focus:outline-none focus:[&::placeholder]:opacity-0\",\n        \"text-center print:placeholder:text-transparent\",\n        props.className,\n      )}\n    />\n  );\n}\n\nexport const CaptionButton = createPrimitiveComponent(Button)({\n  propsHook: useCaptionButton,\n  stateHook: useCaptionButtonState,\n});\n"
  },
  {
    "path": "app/src/components/ui/card.tsx",
    "content": "import * as React from \"react\";\n\nimport { cn } from \"@/utils/cn\";\n\nfunction SimpleCard({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card\"\n      className={cn(\"bg-card text-card-foreground rounded-xl border shadow-sm\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction Card({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card\"\n      className={cn(\"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction CardHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-header\"\n      className={cn(\n        \"@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CardTitle({ className, ...props }: React.ComponentProps<\"div\">) {\n  return <div data-slot=\"card-title\" className={cn(\"font-semibold leading-none\", className)} {...props} />;\n}\n\nfunction CardDescription({ className, ...props }: React.ComponentProps<\"div\">) {\n  return <div data-slot=\"card-description\" className={cn(\"text-muted-foreground text-sm\", className)} {...props} />;\n}\n\nfunction CardAction({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"card-action\"\n      className={cn(\"col-start-2 row-span-2 row-start-1 self-start justify-self-end\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction CardContent({ className, ...props }: React.ComponentProps<\"div\">) {\n  return <div data-slot=\"card-content\" className={cn(\"px-6\", className)} {...props} />;\n}\n\nfunction CardFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div data-slot=\"card-footer\" className={cn(\"[.border-t]:pt-6 flex items-center px-6\", className)} {...props} />\n  );\n}\n\nexport { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, SimpleCard };\n"
  },
  {
    "path": "app/src/components/ui/checkbox.tsx",
    "content": "import * as React from \"react\";\nimport * as CheckboxPrimitive from \"@radix-ui/react-checkbox\";\nimport { CheckIcon } from \"lucide-react\";\n\nimport { cn } from \"@/utils/cn\";\n\nfunction Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {\n  return (\n    <CheckboxPrimitive.Root\n      data-slot=\"checkbox\"\n      className={cn(\n        \"border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-xs peer size-4 shrink-0 rounded-[4px] border outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50\",\n        className,\n      )}\n      {...props}\n    >\n      <CheckboxPrimitive.Indicator\n        data-slot=\"checkbox-indicator\"\n        className=\"flex items-center justify-center text-current transition-none\"\n      >\n        <CheckIcon className=\"size-3.5\" />\n      </CheckboxPrimitive.Indicator>\n    </CheckboxPrimitive.Root>\n  );\n}\n\nexport { Checkbox };\n"
  },
  {
    "path": "app/src/components/ui/code-block-node-static.tsx",
    "content": "// @ts-nocheck\nimport { type SlateElementProps, type SlateLeafProps, type TCodeBlockElement, SlateElement, SlateLeaf } from \"platejs\";\n\nexport function CodeBlockElementStatic(props: SlateElementProps<TCodeBlockElement>) {\n  return (\n    <SlateElement\n      className=\"**:[.hljs-addition]:bg-[#f0fff4] **:[.hljs-addition]:text-[#22863a] dark:**:[.hljs-addition]:bg-[#3c5743] dark:**:[.hljs-addition]:text-[#ceead5] **:[.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable]:text-[#005cc5] dark:**:[.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable]:text-[#6596cf] **:[.hljs-built\\\\\\\\_in,.hljs-symbol]:text-[#e36209] dark:**:[.hljs-built\\\\\\\\_in,.hljs-symbol]:text-[#c3854e] **:[.hljs-bullet]:text-[#735c0f] **:[.hljs-comment,.hljs-code,.hljs-formula]:text-[#6a737d] dark:**:[.hljs-comment,.hljs-code,.hljs-formula]:text-[#6a737d] **:[.hljs-deletion]:bg-[#ffeef0] **:[.hljs-deletion]:text-[#b31d28] dark:**:[.hljs-deletion]:bg-[#473235] dark:**:[.hljs-deletion]:text-[#e7c7cb] **:[.hljs-emphasis]:italic **:[.hljs-keyword,.hljs-doctag,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language\\\\\\\\_]:text-[#d73a49] dark:**:[.hljs-keyword,.hljs-doctag,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language\\\\\\\\_]:text-[#ee6960] **:[.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo]:text-[#22863a] dark:**:[.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo]:text-[#36a84f] **:[.hljs-regexp,.hljs-string,.hljs-meta_.hljs-string]:text-[#032f62] dark:**:[.hljs-regexp,.hljs-string,.hljs-meta_.hljs-string]:text-[#3593ff] **:[.hljs-section]:font-bold **:[.hljs-section]:text-[#005cc5] dark:**:[.hljs-section]:text-[#61a5f2] **:[.hljs-strong]:font-bold **:[.hljs-title,.hljs-title.class\\\\\\\\_,.hljs-title.class\\\\\\\\_.inherited\\\\\\\\_\\\\\\\\_,.hljs-title.function\\\\\\\\_]:text-[#6f42c1] dark:**:[.hljs-title,.hljs-title.class\\\\\\\\_,.hljs-title.class\\\\\\\\_.inherited\\\\\\\\_\\\\\\\\_,.hljs-title.function\\\\\\\\_]:text-[#a77bfa] py-1\"\n      {...props}\n    >\n      <div className=\"bg-muted/50 relative rounded-md\">\n        <pre className=\"overflow-x-auto p-8 pr-4 font-mono text-sm leading-[normal] [tab-size:2] print:break-inside-avoid\">\n          <code>{props.children}</code>\n        </pre>\n      </div>\n    </SlateElement>\n  );\n}\n\nexport function CodeLineElementStatic(props: SlateElementProps) {\n  return <SlateElement {...props} />;\n}\n\nexport function CodeSyntaxLeafStatic(props: SlateLeafProps) {\n  const tokenClassName = props.leaf.className as string;\n\n  return <SlateLeaf className={tokenClassName} {...props} />;\n}\n"
  },
  {
    "path": "app/src/components/ui/code-block-node.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { formatCodeBlock, isLangSupported } from \"@platejs/code-block\";\nimport { BracesIcon, Check, CheckIcon, CopyIcon } from \"lucide-react\";\nimport { type TCodeBlockElement, type TCodeSyntaxLeaf, NodeApi } from \"platejs\";\nimport { type PlateElementProps, type PlateLeafProps, PlateElement, PlateLeaf } from \"platejs/react\";\nimport { useEditorRef, useElement, useReadOnly } from \"platejs/react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from \"@/components/ui/command\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ui/popover\";\nimport { cn } from \"@/utils/cn\";\n\nexport function CodeBlockElement(props: PlateElementProps<TCodeBlockElement>) {\n  const { editor, element } = props;\n\n  return (\n    <PlateElement\n      className=\"**:[.hljs-addition]:bg-[#f0fff4] **:[.hljs-addition]:text-[#22863a] dark:**:[.hljs-addition]:bg-[#3c5743] dark:**:[.hljs-addition]:text-[#ceead5] **:[.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable]:text-[#005cc5] dark:**:[.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable]:text-[#6596cf] **:[.hljs-built\\\\\\\\_in,.hljs-symbol]:text-[#e36209] dark:**:[.hljs-built\\\\\\\\_in,.hljs-symbol]:text-[#c3854e] **:[.hljs-bullet]:text-[#735c0f] **:[.hljs-comment,.hljs-code,.hljs-formula]:text-[#6a737d] dark:**:[.hljs-comment,.hljs-code,.hljs-formula]:text-[#6a737d] **:[.hljs-deletion]:bg-[#ffeef0] **:[.hljs-deletion]:text-[#b31d28] dark:**:[.hljs-deletion]:bg-[#473235] dark:**:[.hljs-deletion]:text-[#e7c7cb] **:[.hljs-emphasis]:italic **:[.hljs-keyword,.hljs-doctag,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language\\\\\\\\_]:text-[#d73a49] dark:**:[.hljs-keyword,.hljs-doctag,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language\\\\\\\\_]:text-[#ee6960] **:[.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo]:text-[#22863a] dark:**:[.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo]:text-[#36a84f] **:[.hljs-regexp,.hljs-string,.hljs-meta_.hljs-string]:text-[#032f62] dark:**:[.hljs-regexp,.hljs-string,.hljs-meta_.hljs-string]:text-[#3593ff] **:[.hljs-section]:font-bold **:[.hljs-section]:text-[#005cc5] dark:**:[.hljs-section]:text-[#61a5f2] **:[.hljs-strong]:font-bold **:[.hljs-title,.hljs-title.class\\\\\\\\_,.hljs-title.class\\\\\\\\_.inherited\\\\\\\\_\\\\\\\\_,.hljs-title.function\\\\\\\\_]:text-[#6f42c1] dark:**:[.hljs-title,.hljs-title.class\\\\\\\\_,.hljs-title.class\\\\\\\\_.inherited\\\\\\\\_\\\\\\\\_,.hljs-title.function\\\\\\\\_]:text-[#a77bfa] py-1\"\n      {...props}\n    >\n      <div className=\"bg-muted/50 relative rounded-md\">\n        <pre className=\"overflow-x-auto p-8 pr-4 font-mono text-sm leading-[normal] [tab-size:2] print:break-inside-avoid\">\n          <code>{props.children}</code>\n        </pre>\n\n        <div className=\"absolute right-1 top-1 z-10 flex select-none gap-0.5\" contentEditable={false}>\n          {isLangSupported(element.lang) && (\n            <Button\n              size=\"icon\"\n              variant=\"ghost\"\n              className=\"size-6 text-xs\"\n              onClick={() => formatCodeBlock(editor, { element })}\n              title=\"Format code\"\n            >\n              <BracesIcon className=\"text-muted-foreground !size-3.5\" />\n            </Button>\n          )}\n\n          <CodeBlockCombobox />\n\n          <CopyButton\n            size=\"icon\"\n            variant=\"ghost\"\n            className=\"text-muted-foreground size-6 gap-1 text-xs\"\n            value={() => NodeApi.string(element)}\n          />\n        </div>\n      </div>\n    </PlateElement>\n  );\n}\n\nfunction CodeBlockCombobox() {\n  const [open, setOpen] = React.useState(false);\n  const readOnly = useReadOnly();\n  const editor = useEditorRef();\n  const element = useElement<TCodeBlockElement>();\n  const value = element.lang || \"plaintext\";\n  const [searchValue, setSearchValue] = React.useState(\"\");\n\n  const items = React.useMemo(\n    () =>\n      languages.filter((language) => !searchValue || language.label.toLowerCase().includes(searchValue.toLowerCase())),\n    [searchValue],\n  );\n\n  if (readOnly) return null;\n\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger asChild>\n        <Button\n          size=\"sm\"\n          variant=\"ghost\"\n          className=\"text-muted-foreground h-6 select-none justify-between gap-1 px-2 text-xs\"\n          aria-expanded={open}\n          role=\"combobox\"\n        >\n          {languages.find((language) => language.value === value)?.label ?? \"Plain Text\"}\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent className=\"w-[200px] p-0\" onCloseAutoFocus={() => setSearchValue(\"\")}>\n        <Command shouldFilter={false}>\n          <CommandInput\n            className=\"h-9\"\n            value={searchValue}\n            onValueChange={(value) => setSearchValue(value)}\n            placeholder=\"Search language...\"\n          />\n          <CommandEmpty>No language found.</CommandEmpty>\n\n          <CommandList className=\"h-[344px] overflow-y-auto\">\n            <CommandGroup>\n              {items.map((language) => (\n                <CommandItem\n                  key={language.label}\n                  className=\"cursor-pointer\"\n                  value={language.value}\n                  onSelect={(value) => {\n                    editor.tf.setNodes<TCodeBlockElement>({ lang: value }, { at: element });\n                    setSearchValue(value);\n                    setOpen(false);\n                  }}\n                >\n                  <Check className={cn(value === language.value ? \"opacity-100\" : \"opacity-0\")} />\n                  {language.label}\n                </CommandItem>\n              ))}\n            </CommandGroup>\n          </CommandList>\n        </Command>\n      </PopoverContent>\n    </Popover>\n  );\n}\n\nfunction CopyButton({\n  value,\n  ...props\n}: { value: (() => string) | string } & Omit<React.ComponentProps<typeof Button>, \"value\">) {\n  const [hasCopied, setHasCopied] = React.useState(false);\n\n  React.useEffect(() => {\n    setTimeout(() => {\n      setHasCopied(false);\n    }, 2000);\n  }, [hasCopied]);\n\n  return (\n    <Button\n      onClick={() => {\n        void navigator.clipboard.writeText(typeof value === \"function\" ? value() : value);\n        setHasCopied(true);\n      }}\n      {...props}\n    >\n      <span className=\"sr-only\">Copy</span>\n      {hasCopied ? <CheckIcon className=\"!size-3\" /> : <CopyIcon className=\"!size-3\" />}\n    </Button>\n  );\n}\n\nexport function CodeLineElement(props: PlateElementProps) {\n  return <PlateElement {...props} />;\n}\n\nexport function CodeSyntaxLeaf(props: PlateLeafProps<TCodeSyntaxLeaf>) {\n  const tokenClassName = props.leaf.className as string;\n\n  return <PlateLeaf className={tokenClassName} {...props} />;\n}\n\nconst languages: { label: string; value: string }[] = [\n  { label: \"Auto\", value: \"auto\" },\n  { label: \"Plain Text\", value: \"plaintext\" },\n  { label: \"ABAP\", value: \"abap\" },\n  { label: \"Agda\", value: \"agda\" },\n  { label: \"Arduino\", value: \"arduino\" },\n  { label: \"ASCII Art\", value: \"ascii\" },\n  { label: \"Assembly\", value: \"x86asm\" },\n  { label: \"Bash\", value: \"bash\" },\n  { label: \"BASIC\", value: \"basic\" },\n  { label: \"BNF\", value: \"bnf\" },\n  { label: \"C\", value: \"c\" },\n  { label: \"C#\", value: \"csharp\" },\n  { label: \"C++\", value: \"cpp\" },\n  { label: \"Clojure\", value: \"clojure\" },\n  { label: \"CoffeeScript\", value: \"coffeescript\" },\n  { label: \"Coq\", value: \"coq\" },\n  { label: \"CSS\", value: \"css\" },\n  { label: \"Dart\", value: \"dart\" },\n  { label: \"Dhall\", value: \"dhall\" },\n  { label: \"Diff\", value: \"diff\" },\n  { label: \"Docker\", value: \"dockerfile\" },\n  { label: \"EBNF\", value: \"ebnf\" },\n  { label: \"Elixir\", value: \"elixir\" },\n  { label: \"Elm\", value: \"elm\" },\n  { label: \"Erlang\", value: \"erlang\" },\n  { label: \"F#\", value: \"fsharp\" },\n  { label: \"Flow\", value: \"flow\" },\n  { label: \"Fortran\", value: \"fortran\" },\n  { label: \"Gherkin\", value: \"gherkin\" },\n  { label: \"GLSL\", value: \"glsl\" },\n  { label: \"Go\", value: \"go\" },\n  { label: \"GraphQL\", value: \"graphql\" },\n  { label: \"Groovy\", value: \"groovy\" },\n  { label: \"Haskell\", value: \"haskell\" },\n  { label: \"HCL\", value: \"hcl\" },\n  { label: \"HTML\", value: \"html\" },\n  { label: \"Idris\", value: \"idris\" },\n  { label: \"Java\", value: \"java\" },\n  { label: \"JavaScript\", value: \"javascript\" },\n  { label: \"JSON\", value: \"json\" },\n  { label: \"Julia\", value: \"julia\" },\n  { label: \"Kotlin\", value: \"kotlin\" },\n  { label: \"LaTeX\", value: \"latex\" },\n  { label: \"Less\", value: \"less\" },\n  { label: \"Lisp\", value: \"lisp\" },\n  { label: \"LiveScript\", value: \"livescript\" },\n  { label: \"LLVM IR\", value: \"llvm\" },\n  { label: \"Lua\", value: \"lua\" },\n  { label: \"Makefile\", value: \"makefile\" },\n  { label: \"Markdown\", value: \"markdown\" },\n  { label: \"Markup\", value: \"markup\" },\n  { label: \"MATLAB\", value: \"matlab\" },\n  { label: \"Mathematica\", value: \"mathematica\" },\n  { label: \"Mermaid\", value: \"mermaid\" },\n  { label: \"Nix\", value: \"nix\" },\n  { label: \"Notion Formula\", value: \"notion\" },\n  { label: \"Objective-C\", value: \"objectivec\" },\n  { label: \"OCaml\", value: \"ocaml\" },\n  { label: \"Pascal\", value: \"pascal\" },\n  { label: \"Perl\", value: \"perl\" },\n  { label: \"PHP\", value: \"php\" },\n  { label: \"PowerShell\", value: \"powershell\" },\n  { label: \"Prolog\", value: \"prolog\" },\n  { label: \"Protocol Buffers\", value: \"protobuf\" },\n  { label: \"PureScript\", value: \"purescript\" },\n  { label: \"Python\", value: \"python\" },\n  { label: \"R\", value: \"r\" },\n  { label: \"Racket\", value: \"racket\" },\n  { label: \"Reason\", value: \"reasonml\" },\n  { label: \"Ruby\", value: \"ruby\" },\n  { label: \"Rust\", value: \"rust\" },\n  { label: \"Sass\", value: \"scss\" },\n  { label: \"Scala\", value: \"scala\" },\n  { label: \"Scheme\", value: \"scheme\" },\n  { label: \"SCSS\", value: \"scss\" },\n  { label: \"Shell\", value: \"shell\" },\n  { label: \"Smalltalk\", value: \"smalltalk\" },\n  { label: \"Solidity\", value: \"solidity\" },\n  { label: \"SQL\", value: \"sql\" },\n  { label: \"Swift\", value: \"swift\" },\n  { label: \"TOML\", value: \"toml\" },\n  { label: \"TypeScript\", value: \"typescript\" },\n  { label: \"VB.Net\", value: \"vbnet\" },\n  { label: \"Verilog\", value: \"verilog\" },\n  { label: \"VHDL\", value: \"vhdl\" },\n  { label: \"Visual Basic\", value: \"vbnet\" },\n  { label: \"WebAssembly\", value: \"wasm\" },\n  { label: \"XML\", value: \"xml\" },\n  { label: \"YAML\", value: \"yaml\" },\n];\n"
  },
  {
    "path": "app/src/components/ui/code-node-static.tsx",
    "content": "// @ts-nocheck\nimport type { SlateLeafProps } from \"platejs\";\n\nimport { SlateLeaf } from \"platejs\";\n\nexport function CodeLeafStatic(props: SlateLeafProps) {\n  return (\n    <SlateLeaf\n      {...props}\n      as=\"code\"\n      className=\"bg-muted whitespace-pre-wrap rounded-md px-[0.3em] py-[0.2em] font-mono text-sm\"\n    >\n      {props.children}\n    </SlateLeaf>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/code-node.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport type { PlateLeafProps } from \"platejs/react\";\n\nimport { PlateLeaf } from \"platejs/react\";\n\nexport function CodeLeaf(props: PlateLeafProps) {\n  return (\n    <PlateLeaf\n      {...props}\n      as=\"code\"\n      className=\"bg-muted whitespace-pre-wrap rounded-md px-[0.3em] py-[0.2em] font-mono text-sm\"\n    >\n      {props.children}\n    </PlateLeaf>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/collapsible.tsx",
    "content": "import { cn } from \"@/utils/cn\";\nimport * as CollapsiblePrimitive from \"@radix-ui/react-collapsible\";\n\nfunction Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {\n  return <CollapsiblePrimitive.Root data-slot=\"collapsible\" {...props} />;\n}\n\nfunction CollapsibleTrigger({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {\n  return <CollapsiblePrimitive.CollapsibleTrigger data-slot=\"collapsible-trigger\" {...props} />;\n}\n\ntype CollapsibleContentProps = React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent> & {\n  animate?: boolean;\n};\n\nfunction CollapsibleContent({ className, animate = true, ...props }: CollapsibleContentProps) {\n  return (\n    <CollapsiblePrimitive.CollapsibleContent\n      data-slot=\"collapsible-content\"\n      className={cn(\n        animate &&\n          \"data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down overflow-hidden transition-all\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport { Collapsible, CollapsibleContent, CollapsibleTrigger };\n"
  },
  {
    "path": "app/src/components/ui/column-node-static.tsx",
    "content": "// @ts-nocheck\nimport type { SlateElementProps, TColumnElement } from \"platejs\";\n\nimport { SlateElement } from \"platejs\";\n\nexport function ColumnElementStatic(props: SlateElementProps<TColumnElement>) {\n  const { width } = props.element;\n\n  return (\n    <div className=\"group/column relative\" style={{ width: width ?? \"100%\" }}>\n      <SlateElement className=\"h-full px-2 pt-2 group-first/column:pl-0 group-last/column:pr-0\" {...props}>\n        <div className=\"relative h-full border border-transparent p-1.5\">{props.children}</div>\n      </SlateElement>\n    </div>\n  );\n}\n\nexport function ColumnGroupElementStatic(props: SlateElementProps) {\n  return (\n    <SlateElement className=\"mb-2\" {...props}>\n      <div className=\"flex size-full rounded\">{props.children}</div>\n    </SlateElement>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/column-node.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\n\nimport type { TColumnElement } from \"platejs\";\nimport type { PlateElementProps } from \"platejs/react\";\n\nimport { useDraggable, useDropLine } from \"@platejs/dnd\";\nimport { setColumns } from \"@platejs/layout\";\nimport { ResizableProvider } from \"@platejs/resizable\";\nimport { BlockSelectionPlugin } from \"@platejs/selection/react\";\nimport { useComposedRef } from \"@udecode/cn\";\nimport { GripHorizontal, type LucideProps, Trash2Icon } from \"lucide-react\";\nimport { PathApi } from \"platejs\";\nimport {\n  PlateElement,\n  useEditorRef,\n  useEditorSelector,\n  useElement,\n  useFocusedLast,\n  usePluginOption,\n  useReadOnly,\n  useRemoveNodeButton,\n  useSelected,\n  withHOC,\n} from \"platejs/react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Popover, PopoverAnchor, PopoverContent } from \"@/components/ui/popover\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from \"@/components/ui/tooltip\";\nimport { cn } from \"@/utils/cn\";\n\nexport const ColumnElement = withHOC(\n  ResizableProvider,\n  function ColumnElement(props: PlateElementProps<TColumnElement>) {\n    const { width } = props.element;\n    const readOnly = useReadOnly();\n    const isSelectionAreaVisible = usePluginOption(BlockSelectionPlugin, \"isSelectionAreaVisible\");\n\n    const { isDragging, previewRef, handleRef } = useDraggable({\n      element: props.element,\n      orientation: \"horizontal\",\n      type: \"column\",\n      canDropNode: ({ dragEntry, dropEntry }) =>\n        PathApi.equals(PathApi.parent(dragEntry[1]), PathApi.parent(dropEntry[1])),\n    });\n\n    return (\n      <div className=\"group/column relative\" style={{ width: width ?? \"100%\" }}>\n        {!readOnly && !isSelectionAreaVisible && (\n          <div\n            ref={handleRef}\n            className={cn(\n              \"absolute left-1/2 top-2 z-50 -translate-x-1/2 -translate-y-1/2\",\n              \"pointer-events-auto flex items-center\",\n              \"opacity-0 transition-opacity group-hover/column:opacity-100\",\n            )}\n          >\n            <ColumnDragHandle />\n          </div>\n        )}\n\n        <PlateElement\n          {...props}\n          ref={useComposedRef(props.ref, previewRef)}\n          className=\"h-full px-2 pt-2 group-first/column:pl-0 group-last/column:pr-0\"\n        >\n          <div\n            className={cn(\n              \"relative h-full border border-transparent p-1.5\",\n              !readOnly && \"border-border rounded-lg border-dashed\",\n              isDragging && \"opacity-50\",\n            )}\n          >\n            {props.children}\n\n            {!readOnly && !isSelectionAreaVisible && <DropLine />}\n          </div>\n        </PlateElement>\n      </div>\n    );\n  },\n);\n\nconst ColumnDragHandle = React.memo(function ColumnDragHandle() {\n  return (\n    <TooltipProvider>\n      <Tooltip>\n        <TooltipTrigger asChild>\n          <Button variant=\"ghost\" className=\"h-5 !px-1\">\n            <GripHorizontal\n              className=\"text-muted-foreground\"\n              onClick={(event) => {\n                event.stopPropagation();\n                event.preventDefault();\n              }}\n            />\n          </Button>\n        </TooltipTrigger>\n\n        <TooltipContent>Drag to move column</TooltipContent>\n      </Tooltip>\n    </TooltipProvider>\n  );\n});\n\nfunction DropLine() {\n  const { dropLine } = useDropLine({ orientation: \"horizontal\" });\n\n  if (!dropLine) return null;\n\n  return (\n    <div\n      className={cn(\n        \"slate-dropLine\",\n        \"bg-brand/50 absolute\",\n        dropLine === \"left\" && \"inset-y-0 left-[-10.5px] w-1 group-first/column:-left-1\",\n        dropLine === \"right\" && \"inset-y-0 right-[-11px] w-1 group-last/column:-right-1\",\n      )}\n    />\n  );\n}\n\nexport function ColumnGroupElement(props: PlateElementProps) {\n  return (\n    <PlateElement className=\"mb-2\" {...props}>\n      <ColumnFloatingToolbar>\n        <div className=\"flex size-full rounded\">{props.children}</div>\n      </ColumnFloatingToolbar>\n    </PlateElement>\n  );\n}\n\nfunction ColumnFloatingToolbar({ children }: React.PropsWithChildren) {\n  const editor = useEditorRef();\n  const readOnly = useReadOnly();\n  const element = useElement<TColumnElement>();\n  const { props: buttonProps } = useRemoveNodeButton({ element });\n  const selected = useSelected();\n  const isCollapsed = useEditorSelector((editor) => editor.api.isCollapsed(), []);\n  const isFocusedLast = useFocusedLast();\n\n  const open = isFocusedLast && !readOnly && selected && isCollapsed;\n\n  const onColumnChange = (widths: string[]) => {\n    setColumns(editor, {\n      at: element,\n      widths,\n    });\n  };\n\n  return (\n    <Popover open={open} modal={false}>\n      <PopoverAnchor>{children}</PopoverAnchor>\n      <PopoverContent\n        className=\"w-auto p-1\"\n        onOpenAutoFocus={(e) => e.preventDefault()}\n        align=\"center\"\n        side=\"top\"\n        sideOffset={10}\n      >\n        <div className=\"box-content flex h-8 items-center\">\n          <Button variant=\"ghost\" className=\"size-8\" onClick={() => onColumnChange([\"50%\", \"50%\"])}>\n            <DoubleColumnOutlined />\n          </Button>\n          <Button variant=\"ghost\" className=\"size-8\" onClick={() => onColumnChange([\"33%\", \"33%\", \"33%\"])}>\n            <ThreeColumnOutlined />\n          </Button>\n          <Button variant=\"ghost\" className=\"size-8\" onClick={() => onColumnChange([\"70%\", \"30%\"])}>\n            <RightSideDoubleColumnOutlined />\n          </Button>\n          <Button variant=\"ghost\" className=\"size-8\" onClick={() => onColumnChange([\"30%\", \"70%\"])}>\n            <LeftSideDoubleColumnOutlined />\n          </Button>\n          <Button variant=\"ghost\" className=\"size-8\" onClick={() => onColumnChange([\"25%\", \"50%\", \"25%\"])}>\n            <DoubleSideDoubleColumnOutlined />\n          </Button>\n\n          <Separator orientation=\"vertical\" className=\"mx-1 h-6\" />\n          <Button variant=\"ghost\" className=\"size-8\" {...buttonProps}>\n            <Trash2Icon />\n          </Button>\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n}\n\nconst DoubleColumnOutlined = (props: LucideProps) => (\n  <svg fill=\"none\" height=\"16\" viewBox=\"0 0 16 16\" width=\"16\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n    <path\n      clipRule=\"evenodd\"\n      d=\"M8.5 3H13V13H8.5V3ZM7.5 2H8.5H13C13.5523 2 14 2.44772 14 3V13C14 13.5523 13.5523 14 13 14H8.5H7.5H3C2.44772 14 2 13.5523 2 13V3C2 2.44772 2.44772 2 3 2H7.5ZM7.5 13H3L3 3H7.5V13Z\"\n      fill=\"currentColor\"\n      fillRule=\"evenodd\"\n    />\n  </svg>\n);\n\nconst ThreeColumnOutlined = (props: LucideProps) => (\n  <svg fill=\"none\" height=\"16\" viewBox=\"0 0 16 16\" width=\"16\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n    <path\n      clipRule=\"evenodd\"\n      d=\"M9.25 3H6.75V13H9.25V3ZM9.25 2H6.75H5.75H3C2.44772 2 2 2.44772 2 3V13C2 13.5523 2.44772 14 3 14H5.75H6.75H9.25H10.25H13C13.5523 14 14 13.5523 14 13V3C14 2.44772 13.5523 2 13 2H10.25H9.25ZM10.25 3V13H13V3H10.25ZM3 13H5.75V3H3L3 13Z\"\n      fill=\"currentColor\"\n      fillRule=\"evenodd\"\n    />\n  </svg>\n);\n\nconst RightSideDoubleColumnOutlined = (props: LucideProps) => (\n  <svg fill=\"none\" height=\"16\" viewBox=\"0 0 16 16\" width=\"16\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n    <path\n      clipRule=\"evenodd\"\n      d=\"M11.25 3H13V13H11.25V3ZM10.25 2H11.25H13C13.5523 2 14 2.44772 14 3V13C14 13.5523 13.5523 14 13 14H11.25H10.25H3C2.44772 14 2 13.5523 2 13V3C2 2.44772 2.44772 2 3 2H10.25ZM10.25 13H3L3 3H10.25V13Z\"\n      fill=\"currentColor\"\n      fillRule=\"evenodd\"\n    />\n  </svg>\n);\n\nconst LeftSideDoubleColumnOutlined = (props: LucideProps) => (\n  <svg fill=\"none\" height=\"16\" viewBox=\"0 0 16 16\" width=\"16\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n    <path\n      clipRule=\"evenodd\"\n      d=\"M5.75 3H13V13H5.75V3ZM4.75 2H5.75H13C13.5523 2 14 2.44772 14 3V13C14 13.5523 13.5523 14 13 14H5.75H4.75H3C2.44772 14 2 13.5523 2 13V3C2 2.44772 2.44772 2 3 2H4.75ZM4.75 13H3L3 3H4.75V13Z\"\n      fill=\"currentColor\"\n      fillRule=\"evenodd\"\n    />\n  </svg>\n);\n\nconst DoubleSideDoubleColumnOutlined = (props: LucideProps) => (\n  <svg fill=\"none\" height=\"16\" viewBox=\"0 0 16 16\" width=\"16\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n    <path\n      clipRule=\"evenodd\"\n      d=\"M10.25 3H5.75V13H10.25V3ZM10.25 2H5.75H4.75H3C2.44772 2 2 2.44772 2 3V13C2 13.5523 2.44772 14 3 14H4.75H5.75H10.25H11.25H13C13.5523 14 14 13.5523 14 13V3C14 2.44772 13.5523 2 13 2H11.25H10.25ZM11.25 3V13H13V3H11.25ZM3 13H4.75V3H3L3 13Z\"\n      fill=\"currentColor\"\n      fillRule=\"evenodd\"\n    />\n  </svg>\n);\n"
  },
  {
    "path": "app/src/components/ui/command.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Command as CommandPrimitive } from \"cmdk\";\nimport { SearchIcon } from \"lucide-react\";\n\nimport { cn } from \"@/utils/cn\";\nimport { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from \"@/components/ui/dialog\";\n\nfunction Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {\n  return (\n    <CommandPrimitive\n      data-slot=\"command\"\n      className={cn(\n        \"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CommandDialog({\n  title = \"Command Palette\",\n  description = \"Search for a command to run...\",\n  children,\n  className,\n  showCloseButton = true,\n  ...props\n}: React.ComponentProps<typeof Dialog> & {\n  title?: string;\n  description?: string;\n  className?: string;\n  showCloseButton?: boolean;\n}) {\n  return (\n    <Dialog {...props}>\n      <DialogHeader className=\"sr-only\">\n        <DialogTitle>{title}</DialogTitle>\n        <DialogDescription>{description}</DialogDescription>\n      </DialogHeader>\n      <DialogContent className={cn(\"overflow-hidden p-0\", className)} showCloseButton={showCloseButton}>\n        <Command className=\"[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5\">\n          {children}\n        </Command>\n      </DialogContent>\n    </Dialog>\n  );\n}\n\nfunction CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {\n  return (\n    <div data-slot=\"command-input-wrapper\" className=\"flex h-9 items-center gap-2 border-b px-3\">\n      <SearchIcon className=\"size-4 shrink-0 opacity-50\" />\n      <CommandPrimitive.Input\n        data-slot=\"command-input\"\n        className={cn(\n          \"placeholder:text-muted-foreground outline-hidden flex h-10 w-full rounded-md bg-transparent py-3 text-sm disabled:cursor-not-allowed disabled:opacity-50\",\n          className,\n        )}\n        {...props}\n      />\n    </div>\n  );\n}\n\nfunction CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {\n  return (\n    <CommandPrimitive.List\n      data-slot=\"command-list\"\n      className={cn(\"max-h-[300px] scroll-py-1 overflow-y-auto overflow-x-hidden\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {\n  return <CommandPrimitive.Empty data-slot=\"command-empty\" className=\"py-6 text-center text-sm\" {...props} />;\n}\n\nfunction CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) {\n  return (\n    <CommandPrimitive.Group\n      data-slot=\"command-group\"\n      className={cn(\n        \"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CommandSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) {\n  return (\n    <CommandPrimitive.Separator\n      data-slot=\"command-separator\"\n      className={cn(\"bg-border -mx-1 h-px\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {\n  return (\n    <CommandPrimitive.Item\n      data-slot=\"command-item\"\n      className={cn(\n        \"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction CommandShortcut({ className, ...props }: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot=\"command-shortcut\"\n      className={cn(\"text-muted-foreground ml-auto text-xs tracking-widest\", className)}\n      {...props}\n    />\n  );\n}\n\nexport {\n  Command,\n  CommandDialog,\n  CommandInput,\n  CommandList,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n  CommandShortcut,\n  CommandSeparator,\n};\n"
  },
  {
    "path": "app/src/components/ui/comment-node-static.tsx",
    "content": "// @ts-nocheck\nimport * as React from \"react\";\n\nimport type { SlateLeafProps, TCommentText } from \"platejs\";\n\nimport { SlateLeaf } from \"platejs\";\n\nexport function CommentLeafStatic(props: SlateLeafProps<TCommentText>) {\n  return (\n    <SlateLeaf {...props} className=\"border-b-highlight/35 bg-highlight/15 border-b-2\">\n      {props.children}\n    </SlateLeaf>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/comment-node.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\n\nimport type { TCommentText } from \"platejs\";\nimport type { PlateLeafProps } from \"platejs/react\";\n\nimport { getCommentCount } from \"@platejs/comment\";\nimport { PlateLeaf, useEditorPlugin, usePluginOption } from \"platejs/react\";\n\nimport { cn } from \"@/utils/cn\";\nimport { commentPlugin } from \"@/components/editor/plugins/comment-kit\";\n\nexport function CommentLeaf(props: PlateLeafProps<TCommentText>) {\n  const { children, leaf } = props;\n\n  const { api, setOption } = useEditorPlugin(commentPlugin);\n  const hoverId = usePluginOption(commentPlugin, \"hoverId\");\n  const activeId = usePluginOption(commentPlugin, \"activeId\");\n\n  const isOverlapping = getCommentCount(leaf) > 1;\n  const currentId = api.comment.nodeId(leaf);\n  const isActive = activeId === currentId;\n  const isHover = hoverId === currentId;\n\n  return (\n    <PlateLeaf\n      {...props}\n      className={cn(\n        \"border-b-highlight/[.36] bg-highlight/[.13] border-b-2 transition-colors duration-200\",\n        (isHover || isActive) && \"border-b-highlight bg-highlight/25\",\n        isOverlapping && \"border-b-highlight/[.7] bg-highlight/25 border-b-2\",\n        (isHover || isActive) && isOverlapping && \"border-b-highlight bg-highlight/45\",\n      )}\n      attributes={{\n        ...props.attributes,\n        onClick: () => setOption(\"activeId\", currentId ?? null),\n        onMouseEnter: () => setOption(\"hoverId\", currentId ?? null),\n        onMouseLeave: () => setOption(\"hoverId\", null),\n      }}\n    >\n      {children}\n    </PlateLeaf>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/comment-toolbar-button.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\n\nimport { MessageSquareTextIcon } from \"lucide-react\";\nimport { useEditorRef } from \"platejs/react\";\n\nimport { commentPlugin } from \"@/components/editor/plugins/comment-kit\";\n\nimport { ToolbarButton } from \"./toolbar\";\n\nexport function CommentToolbarButton() {\n  const editor = useEditorRef();\n\n  return (\n    <ToolbarButton\n      onClick={() => {\n        editor.getTransforms(commentPlugin).comment.setDraft();\n      }}\n      data-plate-prevent-overlay\n      tooltip=\"Comment\"\n    >\n      <MessageSquareTextIcon />\n    </ToolbarButton>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/comment.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\n\nimport type { CreatePlateEditorOptions } from \"platejs/react\";\n\nimport { getCommentKey, getDraftCommentKey } from \"@platejs/comment\";\nimport { CommentPlugin, useCommentId } from \"@platejs/comment/react\";\nimport { differenceInDays, differenceInHours, differenceInMinutes, format } from \"date-fns\";\nimport { ArrowUpIcon, CheckIcon, MoreHorizontalIcon, PencilIcon, TrashIcon, XIcon } from \"lucide-react\";\nimport { type Value, KEYS, nanoid, NodeApi } from \"platejs\";\nimport { Plate, useEditorPlugin, useEditorRef, usePlateEditor, usePluginOption } from \"platejs/react\";\n\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { cn } from \"@/utils/cn\";\nimport { BasicMarksKit } from \"@/components/editor/plugins/basic-marks-kit\";\nimport { type TDiscussion, discussionPlugin } from \"@/components/editor/plugins/discussion-kit\";\n\nimport { Editor, EditorContainer } from \"./editor\";\n\nexport interface TComment {\n  id: string;\n  contentRich: Value;\n  createdAt: Date;\n  discussionId: string;\n  isEdited: boolean;\n  userId: string;\n}\n\nexport function Comment(props: {\n  comment: TComment;\n  discussionLength: number;\n  editingId: string | null;\n  index: number;\n  setEditingId: React.Dispatch<React.SetStateAction<string | null>>;\n  documentContent?: string;\n  showDocumentContent?: boolean;\n  onEditorClick?: () => void;\n}) {\n  const {\n    comment,\n    discussionLength,\n    documentContent,\n    editingId,\n    index,\n    setEditingId,\n    showDocumentContent = false,\n    onEditorClick,\n  } = props;\n\n  const editor = useEditorRef();\n  const userInfo = usePluginOption(discussionPlugin, \"user\", comment.userId);\n  const currentUserId = usePluginOption(discussionPlugin, \"currentUserId\");\n\n  const resolveDiscussion = async (id: string) => {\n    const updatedDiscussions = editor.getOption(discussionPlugin, \"discussions\").map((discussion) => {\n      if (discussion.id === id) {\n        return { ...discussion, isResolved: true };\n      }\n      return discussion;\n    });\n    editor.setOption(discussionPlugin, \"discussions\", updatedDiscussions);\n  };\n\n  const removeDiscussion = async (id: string) => {\n    const updatedDiscussions = editor\n      .getOption(discussionPlugin, \"discussions\")\n      .filter((discussion) => discussion.id !== id);\n    editor.setOption(discussionPlugin, \"discussions\", updatedDiscussions);\n  };\n\n  const updateComment = async (input: { id: string; contentRich: Value; discussionId: string; isEdited: boolean }) => {\n    const updatedDiscussions = editor.getOption(discussionPlugin, \"discussions\").map((discussion) => {\n      if (discussion.id === input.discussionId) {\n        const updatedComments = discussion.comments.map((comment) => {\n          if (comment.id === input.id) {\n            return {\n              ...comment,\n              contentRich: input.contentRich,\n              isEdited: true,\n              updatedAt: new Date(),\n            };\n          }\n          return comment;\n        });\n        return { ...discussion, comments: updatedComments };\n      }\n      return discussion;\n    });\n    editor.setOption(discussionPlugin, \"discussions\", updatedDiscussions);\n  };\n\n  const { tf } = useEditorPlugin(CommentPlugin);\n\n  // Replace to your own backend or refer to potion\n  const isMyComment = currentUserId === comment.userId;\n\n  const initialValue = comment.contentRich;\n\n  const commentEditor = useCommentEditor(\n    {\n      id: comment.id,\n      value: initialValue,\n    },\n    [initialValue],\n  );\n\n  const onCancel = () => {\n    setEditingId(null);\n    commentEditor.tf.replaceNodes(initialValue, {\n      at: [],\n      children: true,\n    });\n  };\n\n  const onSave = () => {\n    void updateComment({\n      id: comment.id,\n      contentRich: commentEditor.children,\n      discussionId: comment.discussionId,\n      isEdited: true,\n    });\n    setEditingId(null);\n  };\n\n  const onResolveComment = () => {\n    void resolveDiscussion(comment.discussionId);\n    tf.comment.unsetMark({ id: comment.discussionId });\n  };\n\n  const isFirst = index === 0;\n  const isLast = index === discussionLength - 1;\n  const isEditing = editingId && editingId === comment.id;\n\n  const [hovering, setHovering] = React.useState(false);\n  const [dropdownOpen, setDropdownOpen] = React.useState(false);\n\n  return (\n    <div onMouseEnter={() => setHovering(true)} onMouseLeave={() => setHovering(false)}>\n      <div className=\"relative flex items-center\">\n        <Avatar className=\"size-5\">\n          <AvatarImage alt={userInfo?.name} src={userInfo?.avatarUrl} />\n          <AvatarFallback>{userInfo?.name?.[0]}</AvatarFallback>\n        </Avatar>\n        <h4 className=\"mx-2 text-sm font-semibold leading-none\">\n          {/* Replace to your own backend or refer to potion */}\n          {userInfo?.name}\n        </h4>\n\n        <div className=\"text-muted-foreground/80 text-xs leading-none\">\n          <span className=\"mr-1\">{formatCommentDate(new Date(comment.createdAt))}</span>\n          {comment.isEdited && <span>(edited)</span>}\n        </div>\n\n        {isMyComment && (hovering || dropdownOpen) && (\n          <div className=\"absolute right-0 top-0 flex space-x-1\">\n            {index === 0 && (\n              <Button\n                variant=\"ghost\"\n                className=\"text-muted-foreground h-6 p-1\"\n                onClick={onResolveComment}\n                type=\"button\"\n              >\n                <CheckIcon className=\"size-4\" />\n              </Button>\n            )}\n\n            <CommentMoreDropdown\n              onCloseAutoFocus={() => {\n                setTimeout(() => {\n                  commentEditor.tf.focus({ edge: \"endEditor\" });\n                }, 0);\n              }}\n              onRemoveComment={() => {\n                if (discussionLength === 1) {\n                  tf.comment.unsetMark({ id: comment.discussionId });\n                  void removeDiscussion(comment.discussionId);\n                }\n              }}\n              comment={comment}\n              dropdownOpen={dropdownOpen}\n              setDropdownOpen={setDropdownOpen}\n              setEditingId={setEditingId}\n            />\n          </div>\n        )}\n      </div>\n\n      {isFirst && showDocumentContent && (\n        <div className=\"text-subtle-foreground relative mt-1 flex pl-[32px] text-sm\">\n          {discussionLength > 1 && <div className=\"bg-muted absolute left-3 top-[5px] h-full w-0.5 shrink-0\" />}\n          <div className=\"bg-highlight my-px w-0.5 shrink-0\" />\n          {documentContent && <div className=\"ml-2\">{documentContent}</div>}\n        </div>\n      )}\n\n      <div className=\"relative my-1 pl-[26px]\">\n        {!isLast && <div className=\"bg-muted absolute left-3 top-0 h-full w-0.5 shrink-0\" />}\n        <Plate readOnly={!isEditing} editor={commentEditor}>\n          <EditorContainer variant=\"comment\">\n            <Editor variant=\"comment\" className=\"w-auto grow\" onClick={() => onEditorClick?.()} />\n\n            {isEditing && (\n              <div className=\"ml-auto flex shrink-0 gap-1\">\n                <Button\n                  size=\"icon\"\n                  variant=\"ghost\"\n                  className=\"size-[28px]\"\n                  onClick={(e: React.MouseEvent<HTMLButtonElement>) => {\n                    e.stopPropagation();\n                    void onCancel();\n                  }}\n                >\n                  <div className=\"bg-primary/40 flex size-5 shrink-0 items-center justify-center rounded-[50%]\">\n                    <XIcon className=\"text-background size-3 stroke-[3px]\" />\n                  </div>\n                </Button>\n\n                <Button\n                  size=\"icon\"\n                  variant=\"ghost\"\n                  onClick={(e: React.MouseEvent<HTMLButtonElement>) => {\n                    e.stopPropagation();\n                    void onSave();\n                  }}\n                >\n                  <div className=\"bg-brand flex size-5 shrink-0 items-center justify-center rounded-[50%]\">\n                    <CheckIcon className=\"text-background size-3 stroke-[3px]\" />\n                  </div>\n                </Button>\n              </div>\n            )}\n          </EditorContainer>\n        </Plate>\n      </div>\n    </div>\n  );\n}\n\nfunction CommentMoreDropdown(props: {\n  comment: TComment;\n  dropdownOpen: boolean;\n  setDropdownOpen: React.Dispatch<React.SetStateAction<boolean>>;\n  setEditingId: React.Dispatch<React.SetStateAction<string | null>>;\n  onCloseAutoFocus?: () => void;\n  onRemoveComment?: () => void;\n}) {\n  const { comment, dropdownOpen, setDropdownOpen, setEditingId, onCloseAutoFocus, onRemoveComment } = props;\n\n  const editor = useEditorRef();\n\n  const selectedEditCommentRef = React.useRef<boolean>(false);\n\n  const onDeleteComment = React.useCallback(() => {\n    if (!comment.id) return alert(\"You are operating too quickly, please try again later.\");\n\n    // Find and update the discussion\n    const updatedDiscussions = editor.getOption(discussionPlugin, \"discussions\").map((discussion) => {\n      if (discussion.id !== comment.discussionId) {\n        return discussion;\n      }\n\n      const commentIndex = discussion.comments.findIndex((c) => c.id === comment.id);\n      if (commentIndex === -1) {\n        return discussion;\n      }\n\n      return {\n        ...discussion,\n        comments: [...discussion.comments.slice(0, commentIndex), ...discussion.comments.slice(commentIndex + 1)],\n      };\n    });\n\n    // Save back to session storage\n    editor.setOption(discussionPlugin, \"discussions\", updatedDiscussions);\n    onRemoveComment?.();\n  }, [comment.discussionId, comment.id, editor, onRemoveComment]);\n\n  const onEditComment = React.useCallback(() => {\n    selectedEditCommentRef.current = true;\n\n    if (!comment.id) return alert(\"You are operating too quickly, please try again later.\");\n\n    setEditingId(comment.id);\n  }, [comment.id, setEditingId]);\n\n  return (\n    <DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen} modal={false}>\n      <DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>\n        <Button variant=\"ghost\" className={cn(\"text-muted-foreground h-6 p-1\")}>\n          <MoreHorizontalIcon className=\"size-4\" />\n        </Button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent\n        className=\"w-48\"\n        onCloseAutoFocus={(e) => {\n          if (selectedEditCommentRef.current) {\n            onCloseAutoFocus?.();\n            selectedEditCommentRef.current = false;\n          }\n\n          return e.preventDefault();\n        }}\n      >\n        <DropdownMenuGroup>\n          <DropdownMenuItem onClick={onEditComment}>\n            <PencilIcon className=\"size-4\" />\n            Edit comment\n          </DropdownMenuItem>\n          <DropdownMenuItem onClick={onDeleteComment}>\n            <TrashIcon className=\"size-4\" />\n            Delete comment\n          </DropdownMenuItem>\n        </DropdownMenuGroup>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n\nconst useCommentEditor = (options: Omit<CreatePlateEditorOptions, \"plugins\"> = {}, deps: any[] = []) => {\n  const commentEditor = usePlateEditor(\n    {\n      id: \"comment\",\n      plugins: BasicMarksKit,\n      value: [],\n      ...options,\n    },\n    deps,\n  );\n\n  return commentEditor;\n};\n\nexport function CommentCreateForm({\n  autoFocus = false,\n  className,\n  discussionId: discussionIdProp,\n  focusOnMount = false,\n}: {\n  autoFocus?: boolean;\n  className?: string;\n  discussionId?: string;\n  focusOnMount?: boolean;\n}) {\n  const discussions = usePluginOption(discussionPlugin, \"discussions\");\n\n  const editor = useEditorRef();\n  const commentId = useCommentId();\n  const discussionId = discussionIdProp ?? commentId;\n\n  const userInfo = usePluginOption(discussionPlugin, \"currentUser\");\n  const [commentValue, setCommentValue] = React.useState<Value | undefined>();\n  const commentContent = React.useMemo(\n    () => (commentValue ? NodeApi.string({ children: commentValue, type: KEYS.p }) : \"\"),\n    [commentValue],\n  );\n  const commentEditor = useCommentEditor();\n\n  React.useEffect(() => {\n    if (commentEditor && focusOnMount) {\n      commentEditor.tf.focus();\n    }\n  }, [commentEditor, focusOnMount]);\n\n  const onAddComment = React.useCallback(async () => {\n    if (!commentValue) return;\n\n    commentEditor.tf.reset();\n\n    if (discussionId) {\n      // Get existing discussion\n      const discussion = discussions.find((d) => d.id === discussionId);\n      if (!discussion) {\n        // Mock creating suggestion\n        const newDiscussion: TDiscussion = {\n          id: discussionId,\n          comments: [\n            {\n              id: nanoid(),\n              contentRich: commentValue,\n              createdAt: new Date(),\n              discussionId,\n              isEdited: false,\n              userId: editor.getOption(discussionPlugin, \"currentUserId\"),\n            },\n          ],\n          createdAt: new Date(),\n          isResolved: false,\n          userId: editor.getOption(discussionPlugin, \"currentUserId\"),\n        };\n\n        editor.setOption(discussionPlugin, \"discussions\", [...discussions, newDiscussion]);\n        return;\n      }\n\n      // Create reply comment\n      const comment: TComment = {\n        id: nanoid(),\n        contentRich: commentValue,\n        createdAt: new Date(),\n        discussionId,\n        isEdited: false,\n        userId: editor.getOption(discussionPlugin, \"currentUserId\"),\n      };\n\n      // Add reply to discussion comments\n      const updatedDiscussion = {\n        ...discussion,\n        comments: [...discussion.comments, comment],\n      };\n\n      // Filter out old discussion and add updated one\n      const updatedDiscussions = discussions.filter((d) => d.id !== discussionId).concat(updatedDiscussion);\n\n      editor.setOption(discussionPlugin, \"discussions\", updatedDiscussions);\n\n      return;\n    }\n\n    const commentsNodeEntry = editor.getApi(CommentPlugin).comment.nodes({ at: [], isDraft: true });\n\n    if (commentsNodeEntry.length === 0) return;\n\n    const documentContent = commentsNodeEntry.map(([node]) => node.text).join(\"\");\n\n    const _discussionId = nanoid();\n    // Mock creating new discussion\n    const newDiscussion: TDiscussion = {\n      id: _discussionId,\n      comments: [\n        {\n          id: nanoid(),\n          contentRich: commentValue,\n          createdAt: new Date(),\n          discussionId: _discussionId,\n          isEdited: false,\n          userId: editor.getOption(discussionPlugin, \"currentUserId\"),\n        },\n      ],\n      createdAt: new Date(),\n      documentContent,\n      isResolved: false,\n      userId: editor.getOption(discussionPlugin, \"currentUserId\"),\n    };\n\n    editor.setOption(discussionPlugin, \"discussions\", [...discussions, newDiscussion]);\n\n    const id = newDiscussion.id;\n\n    commentsNodeEntry.forEach(([, path]) => {\n      editor.tf.setNodes(\n        {\n          [getCommentKey(id)]: true,\n        },\n        { at: path, split: true },\n      );\n      editor.tf.unsetNodes([getDraftCommentKey()], { at: path });\n    });\n  }, [commentValue, commentEditor.tf, discussionId, editor, discussions]);\n\n  return (\n    <div className={cn(\"flex w-full\", className)}>\n      <div className=\"mr-1 mt-2 shrink-0\">\n        {/* Replace to your own backend or refer to potion */}\n        <Avatar className=\"size-5\">\n          <AvatarImage alt={userInfo?.name} src={userInfo?.avatarUrl} />\n          <AvatarFallback>{userInfo?.name?.[0]}</AvatarFallback>\n        </Avatar>\n      </div>\n\n      <div className=\"relative flex grow gap-2\">\n        <Plate\n          onChange={({ value }) => {\n            setCommentValue(value);\n          }}\n          editor={commentEditor}\n        >\n          <EditorContainer variant=\"comment\">\n            <Editor\n              variant=\"comment\"\n              className=\"min-h-[25px] grow pr-8 pt-0.5\"\n              onKeyDown={(e) => {\n                if (e.key === \"Enter\" && !e.shiftKey) {\n                  e.preventDefault();\n                  onAddComment();\n                }\n              }}\n              placeholder=\"Reply...\"\n              autoComplete=\"off\"\n              autoFocus={autoFocus}\n            />\n\n            <Button\n              size=\"icon\"\n              variant=\"ghost\"\n              className=\"absolute bottom-0.5 right-0.5 ml-auto size-6 shrink-0\"\n              disabled={commentContent.trim().length === 0}\n              onClick={(e) => {\n                e.stopPropagation();\n                onAddComment();\n              }}\n            >\n              <div className=\"flex size-6 items-center justify-center rounded-full\">\n                <ArrowUpIcon />\n              </div>\n            </Button>\n          </EditorContainer>\n        </Plate>\n      </div>\n    </div>\n  );\n}\n\nexport const formatCommentDate = (date: Date) => {\n  const now = new Date();\n  const diffMinutes = differenceInMinutes(now, date);\n  const diffHours = differenceInHours(now, date);\n  const diffDays = differenceInDays(now, date);\n\n  if (diffMinutes < 60) {\n    return `${diffMinutes}m`;\n  }\n  if (diffHours < 24) {\n    return `${diffHours}h`;\n  }\n  if (diffDays < 2) {\n    return `${diffDays}d`;\n  }\n\n  return format(date, \"MM/dd/yyyy\");\n};\n"
  },
  {
    "path": "app/src/components/ui/context-menu.tsx",
    "content": "import * as ContextMenuPrimitive from \"@radix-ui/react-context-menu\";\nimport { CheckIcon, ChevronRightIcon, CircleIcon } from \"lucide-react\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/utils/cn\";\n\nfunction ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {\n  return <ContextMenuPrimitive.Root data-slot=\"context-menu\" {...props} />;\n}\n\nfunction ContextMenuTrigger({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {\n  return <ContextMenuPrimitive.Trigger data-slot=\"context-menu-trigger\" {...props} />;\n}\n\nfunction ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {\n  return <ContextMenuPrimitive.Group data-slot=\"context-menu-group\" {...props} />;\n}\n\nfunction ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {\n  return <ContextMenuPrimitive.Portal data-slot=\"context-menu-portal\" {...props} />;\n}\n\nfunction ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {\n  return <ContextMenuPrimitive.Sub data-slot=\"context-menu-sub\" {...props} />;\n}\n\nfunction ContextMenuRadioGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {\n  return <ContextMenuPrimitive.RadioGroup data-slot=\"context-menu-radio-group\" {...props} />;\n}\n\nfunction ContextMenuSubTrigger({\n  className,\n  inset,\n  children,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {\n  inset?: boolean;\n}) {\n  return (\n    <ContextMenuPrimitive.SubTrigger\n      data-slot=\"context-menu-sub-trigger\"\n      data-inset={inset}\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n      <ChevronRightIcon className=\"ml-auto\" />\n    </ContextMenuPrimitive.SubTrigger>\n  );\n}\n\nfunction ContextMenuSubContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {\n  return (\n    <ContextMenuPrimitive.SubContent\n      data-slot=\"context-menu-sub-content\"\n      className={cn(\n        \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-context-menu-content-transform-origin) z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction ContextMenuContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {\n  return (\n    <ContextMenuPrimitive.Portal>\n      <ContextMenuPrimitive.Content\n        data-slot=\"context-menu-content\"\n        className={cn(\n          \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--radix-context-menu-content-available-height) origin-(--radix-context-menu-content-transform-origin) z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md\",\n          className,\n        )}\n        {...props}\n      />\n    </ContextMenuPrimitive.Portal>\n  );\n}\n\nfunction ContextMenuItem({\n  className,\n  inset,\n  variant = \"default\",\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {\n  inset?: boolean;\n  variant?: \"default\" | \"destructive\";\n}) {\n  return (\n    <ContextMenuPrimitive.Item\n      data-slot=\"context-menu-item\"\n      data-inset={inset}\n      data-variant={variant}\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction ContextMenuCheckboxItem({\n  className,\n  children,\n  checked,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {\n  return (\n    <ContextMenuPrimitive.CheckboxItem\n      data-slot=\"context-menu-checkbox-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className,\n      )}\n      checked={checked}\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <ContextMenuPrimitive.ItemIndicator>\n          <CheckIcon className=\"size-4\" />\n        </ContextMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </ContextMenuPrimitive.CheckboxItem>\n  );\n}\n\nfunction ContextMenuRadioItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {\n  return (\n    <ContextMenuPrimitive.RadioItem\n      data-slot=\"context-menu-radio-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className,\n      )}\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <ContextMenuPrimitive.ItemIndicator>\n          <CircleIcon className=\"size-2 fill-current\" />\n        </ContextMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </ContextMenuPrimitive.RadioItem>\n  );\n}\n\nfunction ContextMenuLabel({\n  className,\n  inset,\n  ...props\n}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {\n  inset?: boolean;\n}) {\n  return (\n    <ContextMenuPrimitive.Label\n      data-slot=\"context-menu-label\"\n      data-inset={inset}\n      className={cn(\"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction ContextMenuSeparator({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {\n  return (\n    <ContextMenuPrimitive.Separator\n      data-slot=\"context-menu-separator\"\n      className={cn(\"bg-border -mx-1 my-1 h-px\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction ContextMenuShortcut({ className, ...props }: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot=\"context-menu-shortcut\"\n      className={cn(\"text-muted-foreground ml-auto text-xs tracking-widest\", className)}\n      {...props}\n    />\n  );\n}\n\nexport {\n  ContextMenu,\n  ContextMenuCheckboxItem,\n  ContextMenuContent,\n  ContextMenuGroup,\n  ContextMenuItem,\n  ContextMenuLabel,\n  ContextMenuPortal,\n  ContextMenuRadioGroup,\n  ContextMenuRadioItem,\n  ContextMenuSeparator,\n  ContextMenuShortcut,\n  ContextMenuSub,\n  ContextMenuSubContent,\n  ContextMenuSubTrigger,\n  ContextMenuTrigger,\n};\n"
  },
  {
    "path": "app/src/components/ui/date-node-static.tsx",
    "content": "// @ts-nocheck\nimport * as React from \"react\";\n\nimport type { SlateElementProps, TDateElement } from \"platejs\";\n\nimport { SlateElement } from \"platejs\";\n\nexport function DateElementStatic(props: SlateElementProps<TDateElement>) {\n  const { element } = props;\n\n  return (\n    <SlateElement className=\"inline-block\" {...props}>\n      <span className=\"bg-muted text-muted-foreground w-fit rounded-sm px-1\">\n        {element.date ? (\n          (() => {\n            const today = new Date();\n            const elementDate = new Date(element.date);\n            const isToday =\n              elementDate.getDate() === today.getDate() &&\n              elementDate.getMonth() === today.getMonth() &&\n              elementDate.getFullYear() === today.getFullYear();\n\n            const isYesterday =\n              new Date(today.setDate(today.getDate() - 1)).toDateString() === elementDate.toDateString();\n            const isTomorrow =\n              new Date(today.setDate(today.getDate() + 2)).toDateString() === elementDate.toDateString();\n\n            if (isToday) return \"Today\";\n            if (isYesterday) return \"Yesterday\";\n            if (isTomorrow) return \"Tomorrow\";\n\n            return elementDate.toLocaleDateString(undefined, {\n              day: \"numeric\",\n              month: \"long\",\n              year: \"numeric\",\n            });\n          })()\n        ) : (\n          <span>Pick a date</span>\n        )}\n      </span>\n      {props.children}\n    </SlateElement>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/date-node.tsx",
    "content": "\"use client\";\n\nimport type { TDateElement } from \"platejs\";\nimport type { PlateElementProps } from \"platejs/react\";\n\nimport { PlateElement, useReadOnly } from \"platejs/react\";\n\nimport { Calendar } from \"@/components/ui/calendar\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ui/popover\";\nimport { cn } from \"@/utils/cn\";\n\nexport function DateElement(props: PlateElementProps<TDateElement>) {\n  const { editor, element } = props;\n\n  const readOnly = useReadOnly();\n\n  const trigger = (\n    <span\n      className={cn(\"bg-muted text-muted-foreground w-fit cursor-pointer rounded-sm px-1\")}\n      contentEditable={false}\n      draggable\n    >\n      {element.date ? (\n        (() => {\n          const today = new Date();\n          const elementDate = new Date(element.date);\n          const isToday =\n            elementDate.getDate() === today.getDate() &&\n            elementDate.getMonth() === today.getMonth() &&\n            elementDate.getFullYear() === today.getFullYear();\n\n          const isYesterday =\n            new Date(today.setDate(today.getDate() - 1)).toDateString() === elementDate.toDateString();\n          const isTomorrow = new Date(today.setDate(today.getDate() + 2)).toDateString() === elementDate.toDateString();\n\n          if (isToday) return \"Today\";\n          if (isYesterday) return \"Yesterday\";\n          if (isTomorrow) return \"Tomorrow\";\n\n          return elementDate.toLocaleDateString(undefined, {\n            day: \"numeric\",\n            month: \"long\",\n            year: \"numeric\",\n          });\n        })()\n      ) : (\n        <span>Pick a date</span>\n      )}\n    </span>\n  );\n\n  if (readOnly) {\n    return trigger;\n  }\n\n  return (\n    <PlateElement\n      {...props}\n      className=\"inline-block\"\n      attributes={{\n        ...props.attributes,\n        contentEditable: false,\n      }}\n    >\n      <Popover>\n        <PopoverTrigger asChild>{trigger}</PopoverTrigger>\n        <PopoverContent className=\"w-auto p-0\">\n          <Calendar\n            selected={new Date(element.date as string)}\n            onSelect={(date) => {\n              if (!date) return;\n\n              editor.tf.setNodes({ date: date.toDateString() }, { at: element });\n            }}\n            mode=\"single\"\n            initialFocus\n          />\n        </PopoverContent>\n      </Popover>\n      {props.children}\n    </PlateElement>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/dialog.tsx",
    "content": "import * as DialogPrimitive from \"@radix-ui/react-dialog\";\nimport { XIcon } from \"lucide-react\";\nimport * as React from \"react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { SubWindow } from \"@/core/service/SubWindow\";\nimport { cn } from \"@/utils/cn\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { writeText } from \"@tauri-apps/plugin-clipboard-manager\";\nimport { toast } from \"sonner\";\n\nfunction Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {\n  return <DialogPrimitive.Root data-slot=\"dialog\" {...props} />;\n}\n\nfunction DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {\n  return <DialogPrimitive.Trigger data-slot=\"dialog-trigger\" {...props} />;\n}\n\nfunction DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {\n  return <DialogPrimitive.Portal data-slot=\"dialog-portal\" {...props} />;\n}\n\nfunction DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {\n  return <DialogPrimitive.Close data-slot=\"dialog-close\" {...props} />;\n}\n\nfunction DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {\n  return (\n    <DialogPrimitive.Overlay\n      data-slot=\"dialog-overlay\"\n      className={cn(\n        \"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction DialogContent({\n  className,\n  children,\n  showCloseButton = true,\n  ...props\n}: React.ComponentProps<typeof DialogPrimitive.Content> & {\n  showCloseButton?: boolean;\n}) {\n  return (\n    <DialogPortal data-slot=\"dialog-portal\">\n      <DialogOverlay />\n      <DialogPrimitive.Content\n        data-slot=\"dialog-content\"\n        className={cn(\n          \"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg\",\n          className,\n        )}\n        {...props}\n      >\n        {children}\n        {showCloseButton && (\n          <DialogPrimitive.Close\n            data-slot=\"dialog-close\"\n            className=\"ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground rounded-xs focus:outline-hidden absolute right-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\"\n          >\n            <XIcon />\n            <span className=\"sr-only\">Close</span>\n          </DialogPrimitive.Close>\n        )}\n      </DialogPrimitive.Content>\n    </DialogPortal>\n  );\n}\n\nfunction DialogHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"dialog-header\"\n      className={cn(\"flex flex-col gap-2 text-center sm:text-left\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction DialogFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"dialog-footer\"\n      className={cn(\"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {\n  return (\n    <DialogPrimitive.Title\n      data-slot=\"dialog-title\"\n      className={cn(\"text-lg font-semibold leading-none\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {\n  return (\n    <DialogPrimitive.Description\n      data-slot=\"dialog-description\"\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  );\n}\n\nDialog.confirm = (title = \"你确定？\", description = \"\", { destructive = false } = {}): Promise<boolean> => {\n  return new Promise((resolve) => {\n    function Component({ winId }: { winId?: string }) {\n      const [open, setOpen] = React.useState(true);\n\n      return (\n        <Dialog open={open}>\n          <DialogContent showCloseButton={false}>\n            <DialogHeader>\n              <DialogTitle>{title}</DialogTitle>\n              <DialogDescription>{description}</DialogDescription>\n              <DialogFooter>\n                <Button\n                  variant=\"outline\"\n                  onClick={() => {\n                    resolve(false);\n                    setOpen(false);\n                    setTimeout(() => {\n                      SubWindow.close(winId!);\n                    }, 500);\n                  }}\n                >\n                  取消\n                </Button>\n                <Button\n                  variant={destructive ? \"destructive\" : \"default\"}\n                  onClick={() => {\n                    resolve(true);\n                    setOpen(false);\n                    setTimeout(() => {\n                      SubWindow.close(winId!);\n                    }, 500);\n                  }}\n                >\n                  确定\n                </Button>\n              </DialogFooter>\n            </DialogHeader>\n          </DialogContent>\n        </Dialog>\n      );\n    }\n\n    SubWindow.create({\n      titleBarOverlay: true,\n      closable: false,\n      rect: new Rectangle(Vector.same(100), Vector.same(-1)),\n      children: <Component />,\n    });\n  });\n};\n\nDialog.input = (\n  title = \"请输入文本\",\n  description = \"\",\n  { defaultValue = \"\", placeholder = \"...\", destructive = false, multiline = false } = {},\n): Promise<string | undefined> => {\n  return new Promise((resolve) => {\n    function Component({ winId }: { winId?: string }) {\n      const [open, setOpen] = React.useState(true);\n      const [value, setValue] = React.useState(defaultValue);\n      const InputComponent = multiline ? Textarea : Input;\n\n      return (\n        <Dialog open={open}>\n          <DialogContent showCloseButton={false}>\n            <DialogHeader>\n              <DialogTitle>{title}</DialogTitle>\n              <DialogDescription>{description}</DialogDescription>\n              <InputComponent\n                value={value}\n                onChange={(e) => setValue(e.target.value)}\n                placeholder={placeholder}\n                onKeyDown={(e) => {\n                  if (e.key === \"Enter\" && e.shiftKey) {\n                    e.preventDefault();\n                    e.stopPropagation();\n                    resolve(value);\n                    setOpen(false);\n                    setTimeout(() => {\n                      SubWindow.close(winId!);\n                    }, 500);\n                  } else if (e.key === \"Escape\") {\n                    e.preventDefault();\n                    e.stopPropagation();\n                    resolve(undefined);\n                    setOpen(false);\n                    setTimeout(() => {\n                      SubWindow.close(winId!);\n                    }, 500);\n                  }\n                }}\n              />\n              <DialogFooter>\n                <Button\n                  variant=\"outline\"\n                  onClick={() => {\n                    resolve(undefined);\n                    setOpen(false);\n                    setTimeout(() => {\n                      SubWindow.close(winId!);\n                    }, 500);\n                  }}\n                >\n                  取消（Esc）\n                </Button>\n                <Button\n                  variant={destructive ? \"destructive\" : \"default\"}\n                  onClick={() => {\n                    resolve(value);\n                    setOpen(false);\n                    setTimeout(() => {\n                      SubWindow.close(winId!);\n                    }, 500);\n                  }}\n                >\n                  确定（Shift+Enter）\n                </Button>\n              </DialogFooter>\n            </DialogHeader>\n          </DialogContent>\n        </Dialog>\n      );\n    }\n\n    SubWindow.create({\n      titleBarOverlay: true,\n      closable: false,\n      rect: new Rectangle(Vector.same(100), Vector.same(-1)),\n      children: <Component />,\n    });\n  });\n};\n\nDialog.buttons = <\n  const Buttons extends readonly {\n    id: string;\n    label: string;\n    variant?: Parameters<typeof Button>[number][\"variant\"];\n  }[],\n>(\n  title: string,\n  description: string,\n  buttons: Buttons,\n): Promise<Buttons[number][\"id\"]> => {\n  return new Promise((resolve) => {\n    function Component({ winId }: { winId?: string }) {\n      const [open, setOpen] = React.useState(true);\n\n      return (\n        <Dialog open={open}>\n          <DialogContent showCloseButton={false}>\n            <DialogHeader>\n              <DialogTitle>{title}</DialogTitle>\n              <DialogDescription>{description}</DialogDescription>\n              <DialogFooter>\n                {buttons.map(({ id, label, variant = \"default\" }) => (\n                  <Button\n                    key={id}\n                    variant={variant}\n                    onClick={() => {\n                      resolve(id);\n                      setOpen(false);\n                      setTimeout(() => {\n                        SubWindow.close(winId!);\n                      }, 500);\n                    }}\n                  >\n                    {label}\n                  </Button>\n                ))}\n              </DialogFooter>\n            </DialogHeader>\n          </DialogContent>\n        </Dialog>\n      );\n    }\n\n    SubWindow.create({\n      titleBarOverlay: true,\n      closable: false,\n      rect: new Rectangle(Vector.same(100), Vector.same(-1)),\n      children: <Component />,\n    });\n  });\n};\n\nDialog.copy = (title = \"导出成功\", description = \"\", value = \"\"): Promise<void> => {\n  return new Promise((resolve) => {\n    function Component({ winId }: { winId?: string }) {\n      const [open, setOpen] = React.useState(true);\n\n      return (\n        <Dialog open={open}>\n          <DialogContent showCloseButton={false}>\n            <DialogHeader>\n              <DialogTitle>{title}</DialogTitle>\n              <DialogDescription>{description}</DialogDescription>\n              <pre className=\"max-h-64 max-w-96 select-text overflow-y-auto rounded-md border p-2\">{value}</pre>\n              <DialogFooter>\n                <Button\n                  variant=\"outline\"\n                  onClick={async () => {\n                    await writeText(value);\n                    toast.success(\"已复制到剪贴板\");\n                  }}\n                >\n                  复制\n                </Button>\n                <Button\n                  onClick={() => {\n                    resolve();\n                    setOpen(false);\n                    setTimeout(() => {\n                      SubWindow.close(winId!);\n                    }, 500);\n                  }}\n                >\n                  确定\n                </Button>\n              </DialogFooter>\n            </DialogHeader>\n          </DialogContent>\n        </Dialog>\n      );\n    }\n\n    SubWindow.create({\n      titleBarOverlay: true,\n      closable: false,\n      rect: new Rectangle(Vector.same(100), Vector.same(-1)),\n      children: <Component />,\n    });\n  });\n};\n\nexport {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogOverlay,\n  DialogPortal,\n  DialogTitle,\n  DialogTrigger,\n};\n"
  },
  {
    "path": "app/src/components/ui/dropdown-menu.tsx",
    "content": "import * as React from \"react\";\nimport * as DropdownMenuPrimitive from \"@radix-ui/react-dropdown-menu\";\nimport { CheckIcon, ChevronRightIcon, CircleIcon } from \"lucide-react\";\n\nimport { cn } from \"@/utils/cn\";\n\nfunction DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {\n  return <DropdownMenuPrimitive.Root data-slot=\"dropdown-menu\" {...props} />;\n}\n\nfunction DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {\n  return <DropdownMenuPrimitive.Portal data-slot=\"dropdown-menu-portal\" {...props} />;\n}\n\nfunction DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {\n  return <DropdownMenuPrimitive.Trigger data-slot=\"dropdown-menu-trigger\" {...props} />;\n}\n\nfunction DropdownMenuContent({\n  className,\n  sideOffset = 4,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {\n  return (\n    <DropdownMenuPrimitive.Portal>\n      <DropdownMenuPrimitive.Content\n        data-slot=\"dropdown-menu-content\"\n        sideOffset={sideOffset}\n        className={cn(\n          \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--radix-dropdown-menu-content-available-height) origin-(--radix-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md\",\n          className,\n        )}\n        {...props}\n      />\n    </DropdownMenuPrimitive.Portal>\n  );\n}\n\nfunction DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {\n  return <DropdownMenuPrimitive.Group data-slot=\"dropdown-menu-group\" {...props} />;\n}\n\nfunction DropdownMenuItem({\n  className,\n  inset,\n  variant = \"default\",\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {\n  inset?: boolean;\n  variant?: \"default\" | \"destructive\";\n}) {\n  return (\n    <DropdownMenuPrimitive.Item\n      data-slot=\"dropdown-menu-item\"\n      data-inset={inset}\n      data-variant={variant}\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuCheckboxItem({\n  className,\n  children,\n  checked,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {\n  return (\n    <DropdownMenuPrimitive.CheckboxItem\n      data-slot=\"dropdown-menu-checkbox-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className,\n      )}\n      checked={checked}\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <DropdownMenuPrimitive.ItemIndicator>\n          <CheckIcon className=\"size-4\" />\n        </DropdownMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </DropdownMenuPrimitive.CheckboxItem>\n  );\n}\n\nfunction DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {\n  return <DropdownMenuPrimitive.RadioGroup data-slot=\"dropdown-menu-radio-group\" {...props} />;\n}\n\nfunction DropdownMenuRadioItem({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {\n  return (\n    <DropdownMenuPrimitive.RadioItem\n      data-slot=\"dropdown-menu-radio-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className,\n      )}\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <DropdownMenuPrimitive.ItemIndicator>\n          <CircleIcon className=\"size-2 fill-current\" />\n        </DropdownMenuPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </DropdownMenuPrimitive.RadioItem>\n  );\n}\n\nfunction DropdownMenuLabel({\n  className,\n  inset,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {\n  inset?: boolean;\n}) {\n  return (\n    <DropdownMenuPrimitive.Label\n      data-slot=\"dropdown-menu-label\"\n      data-inset={inset}\n      className={cn(\"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {\n  return (\n    <DropdownMenuPrimitive.Separator\n      data-slot=\"dropdown-menu-separator\"\n      className={cn(\"bg-border -mx-1 my-1 h-px\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuShortcut({ className, ...props }: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot=\"dropdown-menu-shortcut\"\n      className={cn(\"text-muted-foreground ml-auto text-xs tracking-widest\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {\n  return <DropdownMenuPrimitive.Sub data-slot=\"dropdown-menu-sub\" {...props} />;\n}\n\nfunction DropdownMenuSubTrigger({\n  className,\n  inset,\n  children,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {\n  inset?: boolean;\n}) {\n  return (\n    <DropdownMenuPrimitive.SubTrigger\n      data-slot=\"dropdown-menu-sub-trigger\"\n      data-inset={inset}\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground outline-hidden flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm data-[inset]:pl-8\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n      <ChevronRightIcon className=\"ml-auto size-4\" />\n    </DropdownMenuPrimitive.SubTrigger>\n  );\n}\n\nfunction DropdownMenuSubContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {\n  return (\n    <DropdownMenuPrimitive.SubContent\n      data-slot=\"dropdown-menu-sub-content\"\n      className={cn(\n        \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport {\n  DropdownMenu,\n  DropdownMenuPortal,\n  DropdownMenuTrigger,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuLabel,\n  DropdownMenuItem,\n  DropdownMenuCheckboxItem,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuSub,\n  DropdownMenuSubTrigger,\n  DropdownMenuSubContent,\n};\n"
  },
  {
    "path": "app/src/components/ui/editor-static.tsx",
    "content": "// @ts-nocheck\nimport * as React from \"react\";\n\nimport type { VariantProps } from \"class-variance-authority\";\n\nimport { cva } from \"class-variance-authority\";\nimport { type PlateStaticProps, PlateStatic } from \"platejs\";\n\nimport { cn } from \"@/utils/cn\";\n\nexport const editorVariants = cva(\n  cn(\n    \"group/editor\",\n    \"relative w-full cursor-text overflow-x-hidden break-words whitespace-pre-wrap select-text\",\n    \"rounded-md ring-offset-background focus-visible:outline-none\",\n    \"placeholder:text-muted-foreground/80 **:data-slate-placeholder:top-[auto_!important] **:data-slate-placeholder:text-muted-foreground/80 **:data-slate-placeholder:opacity-100!\",\n    \"[&_strong]:font-bold\",\n  ),\n  {\n    defaultVariants: {\n      variant: \"none\",\n    },\n    variants: {\n      disabled: {\n        true: \"cursor-not-allowed opacity-50\",\n      },\n      focused: {\n        true: \"ring-2 ring-ring ring-offset-2\",\n      },\n      variant: {\n        ai: \"w-full px-0 text-base md:text-sm\",\n        aiChat: \"max-h-[min(70vh,320px)] w-full max-w-[700px] overflow-y-auto px-5 py-3 text-base md:text-sm\",\n        default: \"size-full px-4 pt-4 pb-72 text-base lg:px-[max(64px,calc(50%-350px))]\",\n        demo: \"size-full px-4 pt-4 pb-72 text-base lg:px-[max(64px,calc(50%-350px))]\",\n        fullWidth: \"size-full px-4 pt-4 pb-72 text-base lg:px-24\",\n        none: \"\",\n        select: \"px-3 py-2 text-base data-readonly:w-fit\",\n      },\n    },\n  },\n);\n\nexport function EditorStatic({ className, variant, ...props }: PlateStaticProps & VariantProps<typeof editorVariants>) {\n  return <PlateStatic className={cn(editorVariants({ variant }), className)} {...props} />;\n}\n"
  },
  {
    "path": "app/src/components/ui/editor.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport type { VariantProps } from \"class-variance-authority\";\nimport type { PlateContentProps, PlateViewProps } from \"platejs/react\";\n\nimport { cva } from \"class-variance-authority\";\nimport { PlateContainer, PlateContent, PlateView } from \"platejs/react\";\n\nimport { cn } from \"@/utils/cn\";\n\nconst editorContainerVariants = cva(\n  \"relative w-full cursor-text overflow-y-auto caret-primary select-text selection:bg-brand/25 focus-visible:outline-none [&_.slate-selection-area]:z-50 [&_.slate-selection-area]:border [&_.slate-selection-area]:border-brand/25 [&_.slate-selection-area]:bg-brand/15\",\n  {\n    defaultVariants: {\n      variant: \"default\",\n    },\n    variants: {\n      variant: {\n        comment: cn(\n          \"flex flex-wrap justify-between gap-1 px-1 py-0.5 text-sm\",\n          \"rounded-md border-[1.5px] border-transparent bg-transparent\",\n          \"has-[[data-slate-editor]:focus]:border-brand/50 has-[[data-slate-editor]:focus]:ring-2 has-[[data-slate-editor]:focus]:ring-brand/30\",\n          \"has-aria-disabled:border-input has-aria-disabled:bg-muted\",\n        ),\n        default: \"h-full\",\n        demo: \"h-[650px]\",\n        select: cn(\n          \"group rounded-md border border-input ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2\",\n          \"has-data-readonly:w-fit has-data-readonly:cursor-default has-data-readonly:border-transparent has-data-readonly:focus-within:[box-shadow:none]\",\n        ),\n      },\n    },\n  },\n);\n\nexport function EditorContainer({\n  className,\n  variant,\n  ...props\n}: React.ComponentProps<\"div\"> & VariantProps<typeof editorContainerVariants>) {\n  return (\n    <PlateContainer\n      className={cn(\"ignore-click-outside/toolbar\", editorContainerVariants({ variant }), className)}\n      {...props}\n    />\n  );\n}\n\nconst editorVariants = cva(\n  cn(\n    \"group/editor\",\n    \"relative w-full cursor-text overflow-x-hidden break-words whitespace-pre-wrap select-text\",\n    \"rounded-md ring-offset-background focus-visible:outline-none\",\n    \"placeholder:text-muted-foreground/80 **:data-slate-placeholder:!top-1/2 **:data-slate-placeholder:-translate-y-1/2 **:data-slate-placeholder:text-muted-foreground/80 **:data-slate-placeholder:opacity-100!\",\n    \"[&_strong]:font-bold\",\n  ),\n  {\n    defaultVariants: {\n      variant: \"default\",\n    },\n    variants: {\n      disabled: {\n        true: \"cursor-not-allowed opacity-50\",\n      },\n      focused: {\n        true: \"ring-2 ring-ring ring-offset-2\",\n      },\n      variant: {\n        ai: \"w-full px-0 text-base md:text-sm\",\n        aiChat: \"max-h-[min(70vh,320px)] w-full max-w-[700px] overflow-y-auto px-3 py-2 text-base md:text-sm\",\n        comment: cn(\"rounded-none border-none bg-transparent text-sm\"),\n        default: \"size-full px-4 pt-4 pb-72 text-base lg:px-[max(64px,calc(50%-350px))]\",\n        demo: \"size-full px-4 pt-4 pb-72 text-base lg:px-[max(64px,calc(50%-350px))]\",\n        fullWidth: \"size-full px-4 pt-4 pb-72 text-base lg:px-24\",\n        // 详细信息面板⬇️\n        // 不能有 lg:px-xxx\n        nodeDetails: \"size-full px-2 pt-4 text-base\",\n        none: \"\",\n        select: \"px-3 py-2 text-base data-readonly:w-fit\",\n      },\n    },\n  },\n);\n\nexport type EditorProps = PlateContentProps & VariantProps<typeof editorVariants>;\n\nexport const Editor = React.forwardRef<HTMLDivElement, EditorProps>(\n  ({ className, disabled, focused, variant, ...props }, ref) => {\n    return (\n      <PlateContent\n        ref={ref}\n        className={cn(\n          editorVariants({\n            disabled,\n            focused,\n            variant,\n          }),\n          className,\n        )}\n        disabled={disabled}\n        disableDefaultStyles\n        {...props}\n      />\n    );\n  },\n);\n\nEditor.displayName = \"Editor\";\n\nexport function EditorView({ className, variant, ...props }: PlateViewProps & VariantProps<typeof editorVariants>) {\n  return <PlateView {...props} className={cn(editorVariants({ variant }), className)} />;\n}\n\nEditorView.displayName = \"EditorView\";\n"
  },
  {
    "path": "app/src/components/ui/emoji-node.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport type { PlateElementProps } from \"platejs/react\";\n\nimport { EmojiInlineIndexSearch, insertEmoji } from \"@platejs/emoji\";\nimport { EmojiPlugin } from \"@platejs/emoji/react\";\nimport { PlateElement, usePluginOption } from \"platejs/react\";\n\nimport { useDebounce } from \"@/hooks/use-debounce\";\n\nimport {\n  InlineCombobox,\n  InlineComboboxContent,\n  InlineComboboxEmpty,\n  InlineComboboxGroup,\n  InlineComboboxInput,\n  InlineComboboxItem,\n} from \"./inline-combobox\";\n\nexport function EmojiInputElement(props: PlateElementProps) {\n  const { children, editor, element } = props;\n  const data = usePluginOption(EmojiPlugin, \"data\")!;\n  const [value, setValue] = React.useState(\"\");\n  const debouncedValue = useDebounce(value, 100);\n  const isPending = value !== debouncedValue;\n\n  const filteredEmojis = React.useMemo(() => {\n    if (debouncedValue.trim().length === 0) return [];\n\n    return EmojiInlineIndexSearch.getInstance(data).search(debouncedValue.replace(/:$/, \"\")).get();\n  }, [data, debouncedValue]);\n\n  return (\n    <PlateElement as=\"span\" {...props}>\n      <InlineCombobox value={value} element={element} filter={false} setValue={setValue} trigger=\":\" hideWhenNoValue>\n        <InlineComboboxInput />\n\n        <InlineComboboxContent>\n          {!isPending && <InlineComboboxEmpty>No results</InlineComboboxEmpty>}\n\n          <InlineComboboxGroup>\n            {filteredEmojis.map((emoji) => (\n              <InlineComboboxItem key={emoji.id} value={emoji.name} onClick={() => insertEmoji(editor, emoji)}>\n                {emoji.skins[0].native} {emoji.name}\n              </InlineComboboxItem>\n            ))}\n          </InlineComboboxGroup>\n        </InlineComboboxContent>\n      </InlineCombobox>\n\n      {children}\n    </PlateElement>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/emoji-toolbar-button.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport type { Emoji } from \"@emoji-mart/data\";\n\nimport { type EmojiCategoryList, type EmojiIconList, type GridRow, EmojiSettings } from \"@platejs/emoji\";\nimport {\n  type EmojiDropdownMenuOptions,\n  type UseEmojiPickerType,\n  useEmojiDropdownMenuState,\n} from \"@platejs/emoji/react\";\nimport * as Popover from \"@radix-ui/react-popover\";\nimport {\n  AppleIcon,\n  ClockIcon,\n  CompassIcon,\n  FlagIcon,\n  LeafIcon,\n  LightbulbIcon,\n  MusicIcon,\n  SearchIcon,\n  SmileIcon,\n  StarIcon,\n  XIcon,\n} from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from \"@/components/ui/tooltip\";\nimport { cn } from \"@/utils/cn\";\nimport { ToolbarButton } from \"@/components/ui/toolbar\";\n\nexport function EmojiToolbarButton({\n  options,\n  ...props\n}: {\n  options?: EmojiDropdownMenuOptions;\n} & React.ComponentPropsWithoutRef<typeof ToolbarButton>) {\n  const { emojiPickerState, isOpen, setIsOpen } = useEmojiDropdownMenuState(options);\n\n  return (\n    <EmojiPopover\n      control={\n        <ToolbarButton pressed={isOpen} tooltip=\"Emoji\" isDropdown {...props}>\n          <SmileIcon />\n        </ToolbarButton>\n      }\n      isOpen={isOpen}\n      setIsOpen={setIsOpen}\n    >\n      <EmojiPicker {...emojiPickerState} isOpen={isOpen} setIsOpen={setIsOpen} settings={options?.settings} />\n    </EmojiPopover>\n  );\n}\n\nexport function EmojiPopover({\n  children,\n  control,\n  isOpen,\n  setIsOpen,\n}: {\n  children: React.ReactNode;\n  control: React.ReactNode;\n  isOpen: boolean;\n  setIsOpen: (open: boolean) => void;\n}) {\n  return (\n    <Popover.Root open={isOpen} onOpenChange={setIsOpen}>\n      <Popover.Trigger asChild>{control}</Popover.Trigger>\n\n      <Popover.Portal>\n        <Popover.Content className=\"z-100\">{children}</Popover.Content>\n      </Popover.Portal>\n    </Popover.Root>\n  );\n}\n\nexport function EmojiPicker({\n  clearSearch,\n  emoji,\n  emojiLibrary,\n  focusedCategory,\n  hasFound,\n  i18n,\n  icons = {\n    categories: emojiCategoryIcons,\n    search: emojiSearchIcons,\n  },\n  isSearching,\n  refs,\n  searchResult,\n  searchValue,\n  setSearch,\n  settings = EmojiSettings,\n  visibleCategories,\n  handleCategoryClick,\n  onMouseOver,\n  onSelectEmoji,\n}: Omit<UseEmojiPickerType, \"icons\"> & {\n  icons?: EmojiIconList<React.ReactElement>;\n}) {\n  return (\n    <div\n      className={cn(\"bg-popover text-popover-foreground flex flex-col rounded-xl\", \"h-[23rem] w-80 border shadow-md\")}\n    >\n      <EmojiPickerNavigation\n        onClick={handleCategoryClick}\n        emojiLibrary={emojiLibrary}\n        focusedCategory={focusedCategory}\n        i18n={i18n}\n        icons={icons}\n      />\n      <EmojiPickerSearchBar i18n={i18n} searchValue={searchValue} setSearch={setSearch}>\n        <EmojiPickerSearchAndClear clearSearch={clearSearch} i18n={i18n} searchValue={searchValue} />\n      </EmojiPickerSearchBar>\n      <EmojiPickerContent\n        onMouseOver={onMouseOver}\n        onSelectEmoji={onSelectEmoji}\n        emojiLibrary={emojiLibrary}\n        i18n={i18n}\n        isSearching={isSearching}\n        refs={refs}\n        searchResult={searchResult}\n        settings={settings}\n        visibleCategories={visibleCategories}\n      />\n      <EmojiPickerPreview emoji={emoji} hasFound={hasFound} i18n={i18n} isSearching={isSearching} />\n    </div>\n  );\n}\n\nconst EmojiButton = React.memo(function EmojiButton({\n  emoji,\n  index,\n  onMouseOver,\n  onSelect,\n}: {\n  emoji: Emoji;\n  index: number;\n  onMouseOver: (emoji?: Emoji) => void;\n  onSelect: (emoji: Emoji) => void;\n}) {\n  return (\n    <button\n      className=\"group relative flex size-9 cursor-pointer items-center justify-center border-none bg-transparent text-2xl leading-none\"\n      onClick={() => onSelect(emoji)}\n      onMouseEnter={() => onMouseOver(emoji)}\n      onMouseLeave={() => onMouseOver()}\n      aria-label={emoji.skins[0].native}\n      data-index={index}\n      tabIndex={-1}\n      type=\"button\"\n    >\n      <div className=\"absolute inset-0 rounded-full opacity-0 group-hover:opacity-100\" aria-hidden=\"true\" />\n      <span\n        className=\"relative\"\n        style={{\n          fontFamily:\n            '\"Apple Color Emoji\", \"Segoe UI Emoji\", NotoColorEmoji, \"Noto Color Emoji\", \"Segoe UI Symbol\", \"Android Emoji\", EmojiSymbols',\n        }}\n        data-emoji-set=\"native\"\n      >\n        {emoji.skins[0].native}\n      </span>\n    </button>\n  );\n});\n\nconst RowOfButtons = React.memo(function RowOfButtons({\n  emojiLibrary,\n  row,\n  onMouseOver,\n  onSelectEmoji,\n}: {\n  row: GridRow;\n} & Pick<UseEmojiPickerType, \"emojiLibrary\" | \"onMouseOver\" | \"onSelectEmoji\">) {\n  return (\n    <div key={row.id} className=\"flex\" data-index={row.id}>\n      {row.elements.map((emojiId, index) => (\n        <EmojiButton\n          key={emojiId}\n          onMouseOver={onMouseOver}\n          onSelect={onSelectEmoji}\n          emoji={emojiLibrary.getEmoji(emojiId)}\n          index={index}\n        />\n      ))}\n    </div>\n  );\n});\n\nfunction EmojiPickerContent({\n  emojiLibrary,\n  i18n,\n  isSearching = false,\n  refs,\n  searchResult,\n  settings = EmojiSettings,\n  visibleCategories,\n  onMouseOver,\n  onSelectEmoji,\n}: Pick<\n  UseEmojiPickerType,\n  | \"emojiLibrary\"\n  | \"i18n\"\n  | \"isSearching\"\n  | \"onMouseOver\"\n  | \"onSelectEmoji\"\n  | \"refs\"\n  | \"searchResult\"\n  | \"settings\"\n  | \"visibleCategories\"\n>) {\n  const getRowWidth = settings.perLine.value * settings.buttonSize.value;\n\n  const isCategoryVisible = React.useCallback(\n    (categoryId: any) => {\n      return visibleCategories.has(categoryId) ? visibleCategories.get(categoryId) : false;\n    },\n    [visibleCategories],\n  );\n\n  const EmojiList = React.useCallback(() => {\n    return emojiLibrary\n      .getGrid()\n      .sections()\n      .map(({ id: categoryId }) => {\n        const section = emojiLibrary.getGrid().section(categoryId);\n        const { buttonSize } = settings;\n\n        return (\n          <div key={categoryId} ref={section.root} style={{ width: getRowWidth }} data-id={categoryId}>\n            <div className=\"z-1 bg-popover/90 backdrop-blur-xs sticky -top-px p-1 py-2 text-sm font-semibold\">\n              {i18n.categories[categoryId]}\n            </div>\n            <div className=\"relative flex flex-wrap\" style={{ height: section.getRows().length * buttonSize.value }}>\n              {isCategoryVisible(categoryId) &&\n                section\n                  .getRows()\n                  .map((row: GridRow) => (\n                    <RowOfButtons\n                      key={row.id}\n                      onMouseOver={onMouseOver}\n                      onSelectEmoji={onSelectEmoji}\n                      emojiLibrary={emojiLibrary}\n                      row={row}\n                    />\n                  ))}\n            </div>\n          </div>\n        );\n      });\n  }, [emojiLibrary, getRowWidth, i18n.categories, isCategoryVisible, onSelectEmoji, onMouseOver, settings]);\n\n  const SearchList = React.useCallback(() => {\n    return (\n      <div style={{ width: getRowWidth }} data-id=\"search\">\n        <div className=\"z-1 bg-popover/90 text-card-foreground backdrop-blur-xs sticky -top-px p-1 py-2 text-sm font-semibold\">\n          {i18n.searchResult}\n        </div>\n        <div className=\"relative flex flex-wrap\">\n          {searchResult.map((emoji: Emoji, index: number) => (\n            <EmojiButton\n              key={emoji.id}\n              onMouseOver={onMouseOver}\n              onSelect={onSelectEmoji}\n              emoji={emojiLibrary.getEmoji(emoji.id)}\n              index={index}\n            />\n          ))}\n        </div>\n      </div>\n    );\n  }, [emojiLibrary, getRowWidth, i18n.searchResult, searchResult, onSelectEmoji, onMouseOver]);\n\n  return (\n    <div\n      ref={refs.current.contentRoot}\n      className={cn(\n        \"h-full min-h-[50%] overflow-y-auto overflow-x-hidden px-2\",\n        \"[&::-webkit-scrollbar]:w-4\",\n        \"[&::-webkit-scrollbar-button]:hidden [&::-webkit-scrollbar-button]:size-0\",\n        \"[&::-webkit-scrollbar-thumb]:bg-muted [&::-webkit-scrollbar-thumb]:hover:bg-muted-foreground/25 [&::-webkit-scrollbar-thumb]:min-h-11 [&::-webkit-scrollbar-thumb]:rounded-full\",\n        \"[&::-webkit-scrollbar-thumb]:border-popover [&::-webkit-scrollbar-thumb]:border-4 [&::-webkit-scrollbar-thumb]:border-solid [&::-webkit-scrollbar-thumb]:bg-clip-padding\",\n      )}\n      data-id=\"scroll\"\n    >\n      <div ref={refs.current.content} className=\"h-full\">\n        {isSearching ? SearchList() : EmojiList()}\n      </div>\n    </div>\n  );\n}\n\nfunction EmojiPickerSearchBar({\n  children,\n  i18n,\n  searchValue,\n  setSearch,\n}: {\n  children: React.ReactNode;\n} & Pick<UseEmojiPickerType, \"i18n\" | \"searchValue\" | \"setSearch\">) {\n  return (\n    <div className=\"flex items-center px-2\">\n      <div className=\"relative flex grow items-center\">\n        <input\n          className=\"bg-muted placeholder:text-muted-foreground block w-full appearance-none rounded-full border-0 px-10 py-2 text-sm outline-none focus-visible:outline-none\"\n          value={searchValue}\n          onChange={(event) => setSearch(event.target.value)}\n          placeholder={i18n.search}\n          aria-label=\"Search\"\n          autoComplete=\"off\"\n          type=\"text\"\n          autoFocus\n        />\n        {children}\n      </div>\n    </div>\n  );\n}\n\nfunction EmojiPickerSearchAndClear({\n  clearSearch,\n  i18n,\n  searchValue,\n}: Pick<UseEmojiPickerType, \"clearSearch\" | \"i18n\" | \"searchValue\">) {\n  return (\n    <div className=\"text-foreground flex items-center\">\n      <div\n        className={cn(\n          \"text-foreground absolute left-2.5 top-1/2 z-10 flex size-5 -translate-y-1/2 items-center justify-center\",\n        )}\n      >\n        {emojiSearchIcons.loupe}\n      </div>\n      {searchValue && (\n        <Button\n          size=\"icon\"\n          variant=\"ghost\"\n          className={cn(\n            \"text-popover-foreground absolute right-0.5 top-1/2 flex size-8 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full border-none bg-transparent hover:bg-transparent\",\n          )}\n          onClick={clearSearch}\n          title={i18n.clear}\n          aria-label=\"Clear\"\n          type=\"button\"\n        >\n          {emojiSearchIcons.delete}\n        </Button>\n      )}\n    </div>\n  );\n}\n\nfunction EmojiPreview({ emoji }: Pick<UseEmojiPickerType, \"emoji\">) {\n  return (\n    <div className=\"border-muted flex h-14 max-h-14 min-h-14 items-center border-t p-2\">\n      <div className=\"flex items-center justify-center text-2xl\">{emoji?.skins[0].native}</div>\n      <div className=\"overflow-hidden pl-2\">\n        <div className=\"truncate text-sm font-semibold\">{emoji?.name}</div>\n        <div className=\"truncate text-sm\">{`:${emoji?.id}:`}</div>\n      </div>\n    </div>\n  );\n}\n\nfunction NoEmoji({ i18n }: Pick<UseEmojiPickerType, \"i18n\">) {\n  return (\n    <div className=\"border-muted flex h-14 max-h-14 min-h-14 items-center border-t p-2\">\n      <div className=\"flex items-center justify-center text-2xl\">😢</div>\n      <div className=\"overflow-hidden pl-2\">\n        <div className=\"truncate text-sm font-bold\">{i18n.searchNoResultsTitle}</div>\n        <div className=\"truncate text-sm\">{i18n.searchNoResultsSubtitle}</div>\n      </div>\n    </div>\n  );\n}\n\nfunction PickAnEmoji({ i18n }: Pick<UseEmojiPickerType, \"i18n\">) {\n  return (\n    <div className=\"border-muted flex h-14 max-h-14 min-h-14 items-center border-t p-2\">\n      <div className=\"flex items-center justify-center text-2xl\">☝️</div>\n      <div className=\"overflow-hidden pl-2\">\n        <div className=\"truncate text-sm font-semibold\">{i18n.pick}</div>\n      </div>\n    </div>\n  );\n}\n\nfunction EmojiPickerPreview({\n  emoji,\n  hasFound = true,\n  i18n,\n  isSearching = false,\n  ...props\n}: Pick<UseEmojiPickerType, \"emoji\" | \"hasFound\" | \"i18n\" | \"isSearching\">) {\n  const showPickEmoji = !emoji && (!isSearching || hasFound);\n  const showNoEmoji = isSearching && !hasFound;\n  const showPreview = emoji && !showNoEmoji && !showNoEmoji;\n\n  return (\n    <>\n      {showPreview && <EmojiPreview emoji={emoji} {...props} />}\n      {showPickEmoji && <PickAnEmoji i18n={i18n} {...props} />}\n      {showNoEmoji && <NoEmoji i18n={i18n} {...props} />}\n    </>\n  );\n}\n\nfunction EmojiPickerNavigation({\n  emojiLibrary,\n  focusedCategory,\n  i18n,\n  icons,\n  onClick,\n}: {\n  onClick: (id: EmojiCategoryList) => void;\n} & Pick<UseEmojiPickerType, \"emojiLibrary\" | \"focusedCategory\" | \"i18n\" | \"icons\">) {\n  return (\n    <TooltipProvider delayDuration={500}>\n      <nav id=\"emoji-nav\" className=\"border-b-border mb-2.5 border-0 border-b border-solid p-1.5\">\n        <div className=\"relative flex items-center justify-evenly\">\n          {emojiLibrary\n            .getGrid()\n            .sections()\n            .map(({ id }) => (\n              <Tooltip key={id}>\n                <TooltipTrigger asChild>\n                  <Button\n                    size=\"sm\"\n                    variant=\"ghost\"\n                    className={cn(\n                      \"text-muted-foreground hover:bg-muted hover:text-muted-foreground h-fit rounded-full fill-current p-1.5\",\n                      id === focusedCategory && \"bg-accent text-accent-foreground pointer-events-none fill-current\",\n                    )}\n                    onClick={() => {\n                      onClick(id);\n                    }}\n                    aria-label={i18n.categories[id]}\n                    type=\"button\"\n                  >\n                    <span className=\"inline-flex size-5 items-center justify-center\">\n                      {icons.categories[id].outline}\n                    </span>\n                  </Button>\n                </TooltipTrigger>\n                <TooltipContent side=\"bottom\">{i18n.categories[id]}</TooltipContent>\n              </Tooltip>\n            ))}\n        </div>\n      </nav>\n    </TooltipProvider>\n  );\n}\n\nconst emojiCategoryIcons: Record<\n  EmojiCategoryList,\n  {\n    outline: React.ReactElement;\n    solid: React.ReactElement; // Needed to add another solid variant - outline will be used for now\n  }\n> = {\n  activity: {\n    outline: (\n      <svg\n        className=\"size-full\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"2\"\n        viewBox=\"0 0 24 24\"\n        xmlns=\"http://www.w3.org/2000/svg\"\n      >\n        <circle cx=\"12\" cy=\"12\" r=\"10\" />\n        <path d=\"M2.1 13.4A10.1 10.1 0 0 0 13.4 2.1\" />\n        <path d=\"m5 4.9 14 14.2\" />\n        <path d=\"M21.9 10.6a10.1 10.1 0 0 0-11.3 11.3\" />\n      </svg>\n    ),\n    solid: (\n      <svg\n        className=\"size-full\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"2\"\n        viewBox=\"0 0 24 24\"\n        xmlns=\"http://www.w3.org/2000/svg\"\n      >\n        <circle cx=\"12\" cy=\"12\" r=\"10\" />\n        <path d=\"M2.1 13.4A10.1 10.1 0 0 0 13.4 2.1\" />\n        <path d=\"m5 4.9 14 14.2\" />\n        <path d=\"M21.9 10.6a10.1 10.1 0 0 0-11.3 11.3\" />\n      </svg>\n    ),\n  },\n\n  custom: {\n    outline: <StarIcon className=\"size-full\" />,\n    solid: <StarIcon className=\"size-full\" />,\n  },\n\n  flags: {\n    outline: <FlagIcon className=\"size-full\" />,\n    solid: <FlagIcon className=\"size-full\" />,\n  },\n\n  foods: {\n    outline: <AppleIcon className=\"size-full\" />,\n    solid: <AppleIcon className=\"size-full\" />,\n  },\n\n  frequent: {\n    outline: <ClockIcon className=\"size-full\" />,\n    solid: <ClockIcon className=\"size-full\" />,\n  },\n\n  nature: {\n    outline: <LeafIcon className=\"size-full\" />,\n    solid: <LeafIcon className=\"size-full\" />,\n  },\n\n  objects: {\n    outline: <LightbulbIcon className=\"size-full\" />,\n    solid: <LightbulbIcon className=\"size-full\" />,\n  },\n\n  people: {\n    outline: <SmileIcon className=\"size-full\" />,\n    solid: <SmileIcon className=\"size-full\" />,\n  },\n\n  places: {\n    outline: <CompassIcon className=\"size-full\" />,\n    solid: <CompassIcon className=\"size-full\" />,\n  },\n\n  symbols: {\n    outline: <MusicIcon className=\"size-full\" />,\n    solid: <MusicIcon className=\"size-full\" />,\n  },\n};\n\nconst emojiSearchIcons = {\n  delete: <XIcon className=\"size-4 text-current\" />,\n  loupe: <SearchIcon className=\"size-4 text-current\" />,\n};\n"
  },
  {
    "path": "app/src/components/ui/equation-node-static.tsx",
    "content": "// @ts-nocheck\nimport * as React from \"react\";\n\nimport type { SlateElementProps, TEquationElement } from \"platejs\";\n\nimport { getEquationHtml } from \"@platejs/math\";\nimport { RadicalIcon } from \"lucide-react\";\nimport { SlateElement } from \"platejs\";\n\nimport { cn } from \"@/utils/cn\";\n\nexport function EquationElementStatic(props: SlateElementProps<TEquationElement>) {\n  const { element } = props;\n\n  const html = getEquationHtml({\n    element,\n    options: {\n      displayMode: true,\n      errorColor: \"#cc0000\",\n      fleqn: false,\n      leqno: false,\n      macros: { \"\\\\f\": \"#1f(#2)\" },\n      output: \"htmlAndMathml\",\n      strict: \"warn\",\n      throwOnError: false,\n      trust: false,\n    },\n  });\n\n  return (\n    <SlateElement className=\"my-1\" {...props}>\n      <div\n        className={cn(\n          \"hover:bg-primary/10 data-[selected=true]:bg-primary/10 group flex select-none items-center justify-center rounded-sm\",\n          element.texExpression.length === 0 ? \"bg-muted p-3 pr-9\" : \"px-2 py-1\",\n        )}\n      >\n        {element.texExpression.length > 0 ? (\n          <span\n            dangerouslySetInnerHTML={{\n              __html: html,\n            }}\n          />\n        ) : (\n          <div className=\"text-muted-foreground flex h-7 w-full items-center gap-2 whitespace-nowrap text-sm\">\n            <RadicalIcon className=\"text-muted-foreground/80 size-6\" />\n            <div>Add a Tex equation</div>\n          </div>\n        )}\n      </div>\n      {props.children}\n    </SlateElement>\n  );\n}\n\nexport function InlineEquationElementStatic(props: SlateElementProps<TEquationElement>) {\n  const html = getEquationHtml({\n    element: props.element,\n    options: {\n      displayMode: true,\n      errorColor: \"#cc0000\",\n      fleqn: false,\n      leqno: false,\n      macros: { \"\\\\f\": \"#1f(#2)\" },\n      output: \"htmlAndMathml\",\n      strict: \"warn\",\n      throwOnError: false,\n      trust: false,\n    },\n  });\n\n  return (\n    <SlateElement {...props} className=\"inline-block select-none rounded-sm [&_.katex-display]:my-0\">\n      <div\n        className={cn(\n          'after:z-1 after:absolute after:inset-0 after:-left-1 after:-top-0.5 after:h-[calc(100%)+4px] after:w-[calc(100%+8px)] after:rounded-sm after:content-[\"\"]',\n          \"h-6\",\n          props.element.texExpression.length === 0 && \"text-muted-foreground after:bg-neutral-500/10\",\n        )}\n      >\n        <span\n          className={cn(props.element.texExpression.length === 0 && \"hidden\", \"font-mono leading-none\")}\n          dangerouslySetInnerHTML={{ __html: html }}\n        />\n      </div>\n      {props.children}\n    </SlateElement>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/equation-node.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\nimport TextareaAutosize, { type TextareaAutosizeProps } from \"react-textarea-autosize\";\n\nimport type { TEquationElement } from \"platejs\";\nimport type { PlateElementProps } from \"platejs/react\";\n\nimport { useEquationElement, useEquationInput } from \"@platejs/math/react\";\nimport { BlockSelectionPlugin } from \"@platejs/selection/react\";\nimport { CornerDownLeftIcon, RadicalIcon } from \"lucide-react\";\nimport {\n  createPrimitiveComponent,\n  PlateElement,\n  useEditorRef,\n  useEditorSelector,\n  useElement,\n  useReadOnly,\n  useSelected,\n} from \"platejs/react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ui/popover\";\nimport { cn } from \"@/utils/cn\";\n\nexport function EquationElement(props: PlateElementProps<TEquationElement>) {\n  const selected = useSelected();\n  const [open, setOpen] = React.useState(selected);\n  const katexRef = React.useRef<HTMLDivElement | null>(null);\n\n  useEquationElement({\n    element: props.element,\n    katexRef: katexRef,\n    options: {\n      displayMode: true,\n      errorColor: \"#cc0000\",\n      fleqn: false,\n      leqno: false,\n      macros: { \"\\\\f\": \"#1f(#2)\" },\n      output: \"htmlAndMathml\",\n      strict: \"warn\",\n      throwOnError: false,\n      trust: false,\n    },\n  });\n\n  return (\n    <PlateElement className=\"my-1\" {...props}>\n      <Popover open={open} onOpenChange={setOpen} modal={false}>\n        <PopoverTrigger asChild>\n          <div\n            className={cn(\n              \"hover:bg-primary/10 data-[selected=true]:bg-primary/10 group flex cursor-pointer select-none items-center justify-center rounded-sm\",\n              props.element.texExpression.length === 0 ? \"bg-muted p-3 pr-9\" : \"px-2 py-1\",\n            )}\n            data-selected={selected}\n            contentEditable={false}\n            role=\"button\"\n          >\n            {props.element.texExpression.length > 0 ? (\n              <span ref={katexRef} />\n            ) : (\n              <div className=\"text-muted-foreground flex h-7 w-full items-center gap-2 whitespace-nowrap text-sm\">\n                <RadicalIcon className=\"text-muted-foreground/80 size-6\" />\n                <div>Add a Tex equation</div>\n              </div>\n            )}\n          </div>\n        </PopoverTrigger>\n\n        <EquationPopoverContent\n          open={open}\n          placeholder={`f(x) = \\\\begin{cases}\\n  x^2, &\\\\quad x > 0 \\\\\\\\\\n  0, &\\\\quad x = 0 \\\\\\\\\\n  -x^2, &\\\\quad x < 0\\n\\\\end{cases}`}\n          isInline={false}\n          setOpen={setOpen}\n        />\n      </Popover>\n\n      {props.children}\n    </PlateElement>\n  );\n}\n\nexport function InlineEquationElement(props: PlateElementProps<TEquationElement>) {\n  const element = props.element;\n  const katexRef = React.useRef<HTMLDivElement | null>(null);\n  const selected = useSelected();\n  const isCollapsed = useEditorSelector((editor) => editor.api.isCollapsed(), []);\n  const [open, setOpen] = React.useState(selected && isCollapsed);\n\n  React.useEffect(() => {\n    if (selected && isCollapsed) {\n      setOpen(true);\n    }\n  }, [selected, isCollapsed]);\n\n  useEquationElement({\n    element,\n    katexRef: katexRef,\n    options: {\n      displayMode: true,\n      errorColor: \"#cc0000\",\n      fleqn: false,\n      leqno: false,\n      macros: { \"\\\\f\": \"#1f(#2)\" },\n      output: \"htmlAndMathml\",\n      strict: \"warn\",\n      throwOnError: false,\n      trust: false,\n    },\n  });\n\n  return (\n    <PlateElement {...props} className={cn(\"[&_.katex-display]:my-0! mx-1 inline-block select-none rounded-sm\")}>\n      <Popover open={open} onOpenChange={setOpen} modal={false}>\n        <PopoverTrigger asChild>\n          <div\n            className={cn(\n              'after:z-1 after:absolute after:inset-0 after:-left-1 after:-top-0.5 after:h-[calc(100%)+4px] after:w-[calc(100%+8px)] after:rounded-sm after:content-[\"\"]',\n              \"h-6\",\n              ((element.texExpression.length > 0 && open) || selected) && \"after:bg-brand/15\",\n              element.texExpression.length === 0 && \"text-muted-foreground after:bg-neutral-500/10\",\n            )}\n            contentEditable={false}\n          >\n            <span\n              ref={katexRef}\n              className={cn(element.texExpression.length === 0 && \"hidden\", \"font-mono leading-none\")}\n            />\n            {element.texExpression.length === 0 && (\n              <span>\n                <RadicalIcon className=\"mr-1 inline-block h-[19px] w-4 py-[1.5px] align-text-bottom\" />\n                New equation\n              </span>\n            )}\n          </div>\n        </PopoverTrigger>\n\n        <EquationPopoverContent className=\"my-auto\" open={open} placeholder=\"E = mc^2\" setOpen={setOpen} isInline />\n      </Popover>\n\n      {props.children}\n    </PlateElement>\n  );\n}\n\nconst EquationInput = createPrimitiveComponent(TextareaAutosize)({\n  propsHook: useEquationInput,\n});\n\nconst EquationPopoverContent = ({\n  className,\n  isInline,\n  open,\n  setOpen,\n  ...props\n}: {\n  isInline: boolean;\n  open: boolean;\n  setOpen: (open: boolean) => void;\n} & TextareaAutosizeProps) => {\n  const editor = useEditorRef();\n  const readOnly = useReadOnly();\n  const element = useElement<TEquationElement>();\n\n  React.useEffect(() => {\n    if (isInline && open) {\n      setOpen(true);\n    }\n  }, [isInline, open, setOpen]);\n\n  if (readOnly) return null;\n\n  const onClose = () => {\n    setOpen(false);\n\n    if (isInline) {\n      editor.tf.select(element, { focus: true, next: true });\n    } else {\n      editor.getApi(BlockSelectionPlugin).blockSelection.set(element.id as string);\n    }\n  };\n\n  return (\n    <PopoverContent\n      className=\"flex gap-2\"\n      onEscapeKeyDown={(e) => {\n        e.preventDefault();\n      }}\n      contentEditable={false}\n    >\n      <EquationInput\n        className={cn(\"max-h-[50vh] grow resize-none p-2 text-sm\", className)}\n        state={{ isInline, open, onClose }}\n        autoFocus\n        {...props}\n      />\n\n      <Button variant=\"secondary\" className=\"px-3\" onClick={onClose}>\n        Done <CornerDownLeftIcon className=\"size-3.5\" />\n      </Button>\n    </PopoverContent>\n  );\n};\n"
  },
  {
    "path": "app/src/components/ui/equation-toolbar-button.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { insertInlineEquation } from \"@platejs/math\";\nimport { RadicalIcon } from \"lucide-react\";\nimport { useEditorRef } from \"platejs/react\";\n\nimport { ToolbarButton } from \"./toolbar\";\n\nexport function InlineEquationToolbarButton(props: React.ComponentProps<typeof ToolbarButton>) {\n  const editor = useEditorRef();\n\n  return (\n    <ToolbarButton\n      {...props}\n      onClick={() => {\n        insertInlineEquation(editor);\n      }}\n      tooltip=\"Mark as equation\"\n    >\n      <RadicalIcon />\n    </ToolbarButton>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/export-toolbar-button.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport type { DropdownMenuProps } from \"@radix-ui/react-dropdown-menu\";\n\nimport { MarkdownPlugin } from \"@platejs/markdown\";\nimport { ArrowDownToLineIcon } from \"lucide-react\";\nimport { createSlateEditor, serializeHtml } from \"platejs\";\nimport { useEditorRef } from \"platejs/react\";\n\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { BaseEditorKit } from \"@/components/editor/editor-base-kit\";\n\nimport { EditorStatic } from \"./editor-static\";\nimport { ToolbarButton } from \"./toolbar\";\n\nconst siteUrl = \"https://platejs.org\";\n\nexport function ExportToolbarButton(props: DropdownMenuProps) {\n  const editor = useEditorRef();\n  const [open, setOpen] = React.useState(false);\n\n  const getCanvas = async () => {\n    const { default: html2canvas } = await import(\"html2canvas-pro\");\n\n    const style = document.createElement(\"style\");\n    document.head.append(style);\n\n    const canvas = await html2canvas(editor.api.toDOMNode(editor)!, {\n      onclone: (document: Document) => {\n        const editorElement = document.querySelector('[contenteditable=\"true\"]');\n        if (editorElement) {\n          Array.from(editorElement.querySelectorAll(\"*\")).forEach((element) => {\n            const existingStyle = element.getAttribute(\"style\") || \"\";\n            element.setAttribute(\n              \"style\",\n              `${existingStyle}; font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif !important`,\n            );\n          });\n        }\n      },\n    });\n    style.remove();\n\n    return canvas;\n  };\n\n  const downloadFile = async (url: string, filename: string) => {\n    const response = await fetch(url);\n\n    const blob = await response.blob();\n    const blobUrl = window.URL.createObjectURL(blob);\n\n    const link = document.createElement(\"a\");\n    link.href = blobUrl;\n    link.download = filename;\n    document.body.append(link);\n    link.click();\n    link.remove();\n\n    // Clean up the blob URL\n    window.URL.revokeObjectURL(blobUrl);\n  };\n\n  const exportToPdf = async () => {\n    const canvas = await getCanvas();\n\n    const PDFLib = await import(\"pdf-lib\");\n    const pdfDoc = await PDFLib.PDFDocument.create();\n    const page = pdfDoc.addPage([canvas.width, canvas.height]);\n    const imageEmbed = await pdfDoc.embedPng(canvas.toDataURL(\"PNG\"));\n    const { height, width } = imageEmbed.scale(1);\n    page.drawImage(imageEmbed, {\n      height,\n      width,\n      x: 0,\n      y: 0,\n    });\n    const pdfBase64 = await pdfDoc.saveAsBase64({ dataUri: true });\n\n    await downloadFile(pdfBase64, \"plate.pdf\");\n  };\n\n  const exportToImage = async () => {\n    const canvas = await getCanvas();\n    await downloadFile(canvas.toDataURL(\"image/png\"), \"plate.png\");\n  };\n\n  const exportToHtml = async () => {\n    const editorStatic = createSlateEditor({\n      plugins: BaseEditorKit,\n      value: editor.children,\n    });\n\n    const editorHtml = await serializeHtml(editorStatic, {\n      editorComponent: EditorStatic,\n      props: { style: { padding: \"0 calc(50% - 350px)\", paddingBottom: \"\" } },\n    });\n\n    const tailwindCss = `<link rel=\"stylesheet\" href=\"${siteUrl}/tailwind.css\">`;\n    const katexCss = `<link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/katex@0.16.18/dist/katex.css\" integrity=\"sha384-9PvLvaiSKCPkFKB1ZsEoTjgnJn+O3KvEwtsz37/XrkYft3DTk2gHdYvd9oWgW3tV\" crossorigin=\"anonymous\">`;\n\n    const html = `<!DOCTYPE html>\n    <html lang=\"en\">\n      <head>\n        <meta charset=\"utf-8\" />\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n        <meta name=\"color-scheme\" content=\"light dark\" />\n        <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n        <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin />\n        <link\n          href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400..700&family=JetBrains+Mono:wght@400..700&display=swap\"\n          rel=\"stylesheet\"\n        />\n        ${tailwindCss}\n        ${katexCss}\n        <style>\n          :root {\n            --font-sans: 'Inter', 'Inter Fallback';\n            --font-mono: 'JetBrains Mono', 'JetBrains Mono Fallback';\n          }\n        </style>\n      </head>\n      <body>\n        ${editorHtml}\n      </body>\n    </html>`;\n\n    const url = `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;\n\n    await downloadFile(url, \"plate.html\");\n  };\n\n  const exportToMarkdown = async () => {\n    const md = editor.getApi(MarkdownPlugin).markdown.serialize();\n    const url = `data:text/markdown;charset=utf-8,${encodeURIComponent(md)}`;\n    await downloadFile(url, \"plate.md\");\n  };\n\n  return (\n    <DropdownMenu open={open} onOpenChange={setOpen} modal={false} {...props}>\n      <DropdownMenuTrigger asChild>\n        <ToolbarButton pressed={open} tooltip=\"Export\" isDropdown>\n          <ArrowDownToLineIcon className=\"size-4\" />\n        </ToolbarButton>\n      </DropdownMenuTrigger>\n\n      <DropdownMenuContent align=\"start\">\n        <DropdownMenuGroup>\n          <DropdownMenuItem onSelect={exportToHtml}>Export as HTML</DropdownMenuItem>\n          <DropdownMenuItem onSelect={exportToPdf}>Export as PDF</DropdownMenuItem>\n          <DropdownMenuItem onSelect={exportToImage}>Export as Image</DropdownMenuItem>\n          <DropdownMenuItem onSelect={exportToMarkdown}>Export as Markdown</DropdownMenuItem>\n        </DropdownMenuGroup>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/field.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@/components/ui/select\";\nimport { Slider } from \"@/components/ui/slider\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { Settings, settingsSchema } from \"@/core/service/Settings\";\nimport { settingsIcons } from \"@/core/service/SettingsIcons\";\nimport { Telemetry } from \"@/core/service/Telemetry\";\nimport { cn } from \"@/utils/cn\";\nimport _ from \"lodash\";\nimport { ChevronRight, RotateCw } from \"lucide-react\";\nimport React, { CSSProperties, Fragment, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\n\nexport function SettingField({ settingKey, extra = <></> }: { settingKey: keyof Settings; extra?: React.ReactNode }) {\n  const [value, setValue] = React.useState<any>(Settings[settingKey]);\n  const { t, i18n } = useTranslation(\"settings\");\n  const schema = settingsSchema.shape[settingKey];\n\n  React.useEffect(() => {\n    if (value !== Settings[settingKey]) {\n      // @ts-expect-error 不知道为什么Settings[settingKey]可能是never\n      Settings[settingKey] = value;\n      postTelemetryEvent();\n    }\n\n    if (settingKey === \"language\") {\n      i18n.changeLanguage(value);\n    }\n  }, [value]);\n\n  const postTelemetryEvent = _.debounce(() => {\n    if (settingKey === \"aiApiKey\") return;\n    Telemetry.event(\"修改设置\", {\n      key: settingKey,\n      value,\n    });\n  }, 1000);\n\n  // @ts-expect-error fuck ts\n  const Icon = settingsIcons[settingKey] ?? Fragment;\n\n  return (\n    <Field\n      title={t(`${settingKey}.title`)}\n      description={t(`${settingKey}.description`, { defaultValue: \"\" })}\n      icon={<Icon />}\n      className=\"border-accent not-hover:rounded-none hover:bg-accent border-b transition\"\n    >\n      <RotateCw\n        className=\"text-panel-details-text h-4 w-4 cursor-pointer opacity-0 hover:rotate-180 group-hover/field:opacity-100\"\n        onClick={() => setValue(schema._def.defaultValue)}\n      />\n      {extra}\n      {schema._def.innerType._def.typeName === \"ZodString\" ? (\n        <Input value={value} onChange={(e) => setValue(e.target.value)} className=\"w-64\" />\n      ) : schema._def.innerType._def.typeName === \"ZodNumber\" &&\n        schema._def.innerType._def.checks.find((it) => it.kind === \"min\") &&\n        schema._def.innerType._def.checks.find((it) => it.kind === \"max\") ? (\n        <>\n          <Slider\n            value={[value]}\n            onValueChange={([v]) => setValue(v)}\n            min={schema._def.innerType._def.checks.find((it) => it.kind === \"min\")?.value ?? 0}\n            max={schema._def.innerType._def.checks.find((it) => it.kind === \"max\")?.value ?? 1}\n            step={\n              schema._def.innerType._def.checks.find((it) => it.kind === \"int\")\n                ? 1\n                : (schema._def.innerType._def.checks.find((it) => it.kind === \"multipleOf\")?.value ?? 0.01)\n            }\n            className=\"w-48\"\n          />\n          <Input\n            value={value}\n            onChange={(e) => setValue(parseFloat(e.target.value))}\n            type=\"number\"\n            min={schema._def.innerType._def.checks.find((it) => it.kind === \"min\")?.value ?? 0}\n            max={schema._def.innerType._def.checks.find((it) => it.kind === \"max\")?.value ?? 1}\n            step={\n              schema._def.innerType._def.checks.find((it) => it.kind === \"int\")\n                ? 1\n                : (schema._def.innerType._def.checks.find((it) => it.kind === \"multipleOf\")?.value ?? 0.01)\n            }\n            className=\"w-24\"\n          />\n        </>\n      ) : schema._def.innerType._def.typeName === \"ZodNumber\" ? (\n        <Input value={value} onChange={(e) => setValue(e.target.valueAsNumber)} type=\"number\" className=\"w-32\" />\n      ) : schema._def.innerType._def.typeName === \"ZodBoolean\" ? (\n        <Switch checked={value} onCheckedChange={setValue} />\n      ) : schema._def.innerType._def.typeName === \"ZodUnion\" ? (\n        <Select value={value} onValueChange={setValue}>\n          <SelectTrigger>\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            {schema._def.innerType._def.options.map(({ _def: { value: it } }) => (\n              <SelectItem key={it} value={it}>\n                {t(`${settingKey}.options.${it}`)}\n              </SelectItem>\n            ))}\n          </SelectContent>\n        </Select>\n      ) : (\n        <>unknown type</>\n      )}\n    </Field>\n  );\n}\nexport function ButtonField({\n  title,\n  description = \"\",\n  label = \"\",\n  disabled = false,\n  onClick = () => {},\n  icon = <></>,\n}: {\n  title: string;\n  description?: string;\n  label?: string;\n  disabled?: boolean;\n  onClick?: () => void;\n  icon?: React.ReactNode;\n}) {\n  return (\n    <Field title={title} description={description} icon={icon}>\n      <Button disabled={disabled} onClick={onClick}>\n        {label}\n      </Button>\n    </Field>\n  );\n}\n\nconst fieldColors = {\n  default: \"hover:bg-field-group-hover-bg\",\n  celebrate: \"border-2 border-green-500/20 hover:bg-green-500/25\",\n  danger: \"border-2 border-red-500/20 hover:bg-red-500/25\",\n  warning: \"border-2 border-yellow-500/20 hover:bg-yellow-500/25\",\n  thinking: \"border-2 border-blue-500/20 hover:bg-blue-500/25\",\n  imaging: \"border-2 border-purple-500/20 hover:bg-purple-500/25\",\n};\n\n/**\n * 每一个设置段\n * @param param0\n * @returns\n */\nexport function Field({\n  title = \"\",\n  description = \"\",\n  children = <></>,\n  extra = <></>,\n  color = \"default\",\n  icon = <></>,\n  className = \"\",\n  style = {},\n  onClick = () => {},\n}: {\n  title?: string;\n  description?: string;\n  children?: React.ReactNode;\n  extra?: React.ReactNode;\n  color?: \"default\" | \"celebrate\" | \"danger\" | \"warning\" | \"thinking\" | \"imaging\";\n  icon?: React.ReactNode;\n  className?: string;\n  style?: CSSProperties;\n  onClick?: () => void;\n}) {\n  return (\n    <div\n      className={cn(\"group/field flex w-full flex-col items-start gap-2 rounded-xl p-4\", fieldColors[color], className)}\n      style={style}\n      onClick={onClick}\n    >\n      <div className=\"flex w-full items-center justify-between gap-2\">\n        <div className=\"flex items-center gap-2\">\n          <span>{icon}</span>\n          <div className=\"flex flex-col\">\n            <span>{title}</span>\n            <span className=\"text-panel-details-text text-xs font-light opacity-60\">\n              {description.split(\"\\n\").map((dd, ii) => (\n                <p key={ii} className=\"text-xs\">\n                  {dd}\n                </p>\n              ))}\n            </span>\n          </div>\n        </div>\n        <div className=\"flex-1\"></div>\n        {children}\n      </div>\n      {extra}\n    </div>\n  );\n}\n\n/**\n * 用于给各种设置项提供一个分类组\n * @param param0\n * @returns\n */\nexport function FieldGroup({\n  title = \"\",\n  icon = null,\n  children = null,\n  className = \"\",\n  description = \"\",\n}: {\n  title?: string;\n  icon?: React.ReactNode;\n  children?: React.ReactNode;\n  className?: string;\n  description?: string;\n}) {\n  const [isOpen, setIsOpen] = useState(false);\n  const [height, setHeight] = useState<number | string>(0);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const innerRef = useRef<HTMLDivElement>(null);\n  const [isAnimating, setAnimating] = useState(false);\n  const [shouldMount, setShouldMount] = useState(isOpen);\n\n  useEffect(() => {\n    const el = innerRef.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => {\n      if (isOpen) setHeight(el.offsetHeight);\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [isOpen]);\n\n  useLayoutEffect(() => {\n    if (!isOpen) {\n      setHeight(0);\n      return;\n    }\n\n    requestAnimationFrame(() => {\n      const el = innerRef.current;\n      if (el) setHeight(el.offsetHeight);\n    });\n  }, [isOpen]);\n\n  const handleToggle = () => {\n    setAnimating(true);\n    const next = !isOpen;\n    setIsOpen(next);\n\n    if (next) {\n      setShouldMount(true); // 展开：立即挂载\n    } else {\n      setTimeout(() => {\n        // 折叠：动画后再卸载\n        setShouldMount(false);\n      }, 250);\n    }\n    setTimeout(() => setAnimating(false), 500);\n  };\n\n  return (\n    <div className={cn(\"flex w-full flex-col gap-2\", className)}>\n      <div\n        className=\"text-settings-text my-2 flex cursor-pointer items-center gap-2 pl-4 pt-4 text-sm opacity-60 hover:opacity-100\"\n        onClick={handleToggle}\n      >\n        <span>{icon}</span>\n        <span>{title}</span>\n        <ChevronRight className={cn(isOpen && \"rotate-90\")} />\n      </div>\n\n      {description && isOpen && <div className=\"text-panel-details-text pl-4 text-xs\">{description}</div>}\n\n      <div ref={contentRef} className=\"overflow-hidden rounded-xl transition-all\" style={{ height }}>\n        {shouldMount && (\n          <div\n            ref={innerRef}\n            className={cn(\"transition-all\", !isOpen && !isAnimating && \"pointer-events-none opacity-0\")}\n          >\n            <div className=\"bg-field-group-bg group/field-group\">{children}</div>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/file-chooser.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { isWeb } from \"@/utils/platform\";\nimport { open } from \"@tauri-apps/plugin-dialog\";\nimport { FolderOpen } from \"lucide-react\";\n\nexport default function FileChooser({\n  kind,\n  value = \"\",\n  onChange = () => {},\n}: {\n  kind: \"file\" | \"directory\";\n  value?: string;\n  onChange?: (value: string) => void;\n}) {\n  return isWeb ? (\n    <div>网页版暂不支持文件选择</div>\n  ) : (\n    <div className=\"flex items-center gap-2\">\n      <Input type=\"text\" value={value} onChange={(e) => onChange(e.target.value)} />\n      <Button\n        onClick={() => {\n          open({\n            directory: kind === \"directory\",\n            multiple: false,\n          }).then((result) => {\n            onChange(result ?? \"\");\n          });\n        }}\n      >\n        {<FolderOpen />}\n      </Button>\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/fixed-toolbar-buttons.tsx",
    "content": "\"use client\";\n\nimport {\n  ArrowUpToLineIcon,\n  BaselineIcon,\n  BoldIcon,\n  Code2Icon,\n  ItalicIcon,\n  PaintBucketIcon,\n  StrikethroughIcon,\n  UnderlineIcon,\n  Hand,\n} from \"lucide-react\";\nimport { KEYS } from \"platejs\";\nimport { useEditorReadOnly } from \"platejs/react\";\n\nimport { AlignToolbarButton } from \"./align-toolbar-button\";\nimport { ExportToolbarButton } from \"./export-toolbar-button\";\nimport { FontColorToolbarButton } from \"./font-color-toolbar-button\";\n// import { RedoToolbarButton, UndoToolbarButton } from \"./history-toolbar-button\";\nimport { ImportToolbarButton } from \"./import-toolbar-button\";\nimport { InsertToolbarButton } from \"./insert-toolbar-button\";\nimport { LinkToolbarButton } from \"./link-toolbar-button\";\nimport { BulletedListToolbarButton, NumberedListToolbarButton, TodoListToolbarButton } from \"./list-toolbar-button\";\nimport { MarkToolbarButton } from \"./mark-toolbar-button\";\nimport { MoreToolbarButton } from \"./more-toolbar-button\";\nimport { TableToolbarButton } from \"./table-toolbar-button\";\nimport { ToggleToolbarButton } from \"./toggle-toolbar-button\";\nimport { ToolbarGroup } from \"./toolbar\";\nimport { TurnIntoToolbarButton } from \"./turn-into-toolbar-button\";\nimport { FontSizeToolbarButton } from \"./font-size-toolbar-button\";\n\nexport function FixedToolbarButtons() {\n  const readOnly = useEditorReadOnly();\n\n  return (\n    <div className=\"scrollbar-hide flex flex-1 overflow-x-auto\">\n      <div\n        className=\"bg-accent flex min-w-8 items-center justify-center rounded-lg opacity-50 transition-colors hover:cursor-grab hover:opacity-100 active:cursor-grabbing\"\n        data-pg-drag-region\n      >\n        <Hand className=\"pointer-events-none\" />\n      </div>\n      {!readOnly && (\n        <>\n          {/* <ToolbarGroup>\n            <UndoToolbarButton />\n            <RedoToolbarButton />\n          </ToolbarGroup> */}\n\n          {/*<ToolbarGroup>\n            <AIToolbarButton tooltip=\"AI commands\">\n              <WandSparklesIcon />\n            </AIToolbarButton>\n          </ToolbarGroup>*/}\n\n          <ToolbarGroup>\n            <ExportToolbarButton>\n              <ArrowUpToLineIcon />\n            </ExportToolbarButton>\n\n            <ImportToolbarButton />\n          </ToolbarGroup>\n\n          <ToolbarGroup>\n            <InsertToolbarButton />\n            <TurnIntoToolbarButton />\n            <FontSizeToolbarButton />\n          </ToolbarGroup>\n\n          <ToolbarGroup>\n            <MarkToolbarButton nodeType={KEYS.bold} tooltip=\"Bold (⌘+B)\">\n              <BoldIcon />\n            </MarkToolbarButton>\n\n            <MarkToolbarButton nodeType={KEYS.italic} tooltip=\"Italic (⌘+I)\">\n              <ItalicIcon />\n            </MarkToolbarButton>\n\n            <MarkToolbarButton nodeType={KEYS.underline} tooltip=\"Underline (⌘+U)\">\n              <UnderlineIcon />\n            </MarkToolbarButton>\n\n            <MarkToolbarButton nodeType={KEYS.strikethrough} tooltip=\"Strikethrough (⌘+⇧+M)\">\n              <StrikethroughIcon />\n            </MarkToolbarButton>\n\n            <MarkToolbarButton nodeType={KEYS.code} tooltip=\"Code (⌘+E)\">\n              <Code2Icon />\n            </MarkToolbarButton>\n\n            <FontColorToolbarButton nodeType={KEYS.color} tooltip=\"Text color\">\n              <BaselineIcon />\n            </FontColorToolbarButton>\n\n            <FontColorToolbarButton nodeType={KEYS.backgroundColor} tooltip=\"Background color\">\n              <PaintBucketIcon />\n            </FontColorToolbarButton>\n          </ToolbarGroup>\n\n          <ToolbarGroup>\n            <AlignToolbarButton />\n\n            <NumberedListToolbarButton />\n            <BulletedListToolbarButton />\n            <TodoListToolbarButton />\n            <ToggleToolbarButton />\n          </ToolbarGroup>\n\n          <ToolbarGroup>\n            <LinkToolbarButton />\n            <TableToolbarButton />\n            {/*<EmojiToolbarButton />*/}\n          </ToolbarGroup>\n\n          <ToolbarGroup>\n            <MoreToolbarButton />\n          </ToolbarGroup>\n        </>\n      )}\n\n      <div className=\"grow hover:cursor-grab active:cursor-grabbing\" data-pg-drag-region />\n\n      {/*<ToolbarGroup>\n        <ModeToolbarButton />\n      </ToolbarGroup>*/}\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/fixed-toolbar.tsx",
    "content": "\"use client\";\n\nimport { cn } from \"@/utils/cn\";\n\nimport { Toolbar } from \"./toolbar\";\n\nexport function FixedToolbar(props: React.ComponentProps<typeof Toolbar>) {\n  return (\n    <Toolbar\n      {...props}\n      className={cn(\n        \"scrollbar-hide border-b-border bg-background/95 supports-backdrop-blur:bg-background/60 sticky left-0 top-0 z-50 flex w-full justify-between overflow-x-auto rounded-t-lg border-b p-1 backdrop-blur-sm\",\n        props.className,\n      )}\n    />\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/floating-toolbar-buttons.tsx",
    "content": "\"use client\";\n\nimport { BoldIcon, Code2Icon, ItalicIcon, StrikethroughIcon, UnderlineIcon } from \"lucide-react\";\nimport { KEYS } from \"platejs\";\nimport { useEditorReadOnly } from \"platejs/react\";\n\nimport { InlineEquationToolbarButton } from \"./equation-toolbar-button\";\nimport { FontSizeToolbarButton } from \"./font-size-toolbar-button\";\nimport { LinkToolbarButton } from \"./link-toolbar-button\";\nimport { MarkToolbarButton } from \"./mark-toolbar-button\";\nimport { ToolbarGroup } from \"./toolbar\";\nimport { TurnIntoToolbarButton } from \"./turn-into-toolbar-button\";\n\nexport function FloatingToolbarButtons() {\n  const readOnly = useEditorReadOnly();\n\n  return (\n    <>\n      {!readOnly && (\n        <>\n          {/*<ToolbarGroup>\n            <AIToolbarButton tooltip=\"AI commands\">\n              <WandSparklesIcon />\n              Ask AI\n            </AIToolbarButton>\n          </ToolbarGroup>*/}\n\n          <ToolbarGroup>\n            <TurnIntoToolbarButton />\n            <FontSizeToolbarButton />\n\n            <MarkToolbarButton nodeType={KEYS.bold} tooltip=\"Bold (⌘+B)\">\n              <BoldIcon />\n            </MarkToolbarButton>\n\n            <MarkToolbarButton nodeType={KEYS.italic} tooltip=\"Italic (⌘+I)\">\n              <ItalicIcon />\n            </MarkToolbarButton>\n\n            <MarkToolbarButton nodeType={KEYS.underline} tooltip=\"Underline (⌘+U)\">\n              <UnderlineIcon />\n            </MarkToolbarButton>\n\n            <MarkToolbarButton nodeType={KEYS.strikethrough} tooltip=\"Strikethrough (⌘+⇧+M)\">\n              <StrikethroughIcon />\n            </MarkToolbarButton>\n\n            <MarkToolbarButton nodeType={KEYS.code} tooltip=\"Code (⌘+E)\">\n              <Code2Icon />\n            </MarkToolbarButton>\n\n            <InlineEquationToolbarButton />\n\n            <LinkToolbarButton />\n          </ToolbarGroup>\n        </>\n      )}\n\n      {/*<ToolbarGroup>\n        <CommentToolbarButton />\n        <SuggestionToolbarButton />\n\n        {!readOnly && <MoreToolbarButton />}\n      </ToolbarGroup>*/}\n    </>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/floating-toolbar.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\n\nimport {\n  type FloatingToolbarState,\n  flip,\n  offset,\n  useFloatingToolbar,\n  useFloatingToolbarState,\n} from \"@platejs/floating\";\nimport { useComposedRef } from \"@udecode/cn\";\nimport { KEYS } from \"platejs\";\nimport { useEditorId, useEventEditorValue, usePluginOption } from \"platejs/react\";\n\nimport { cn } from \"@/utils/cn\";\n\nimport { Toolbar } from \"./toolbar\";\n\nexport function FloatingToolbar({\n  children,\n  className,\n  state,\n  ...props\n}: React.ComponentProps<typeof Toolbar> & {\n  state?: FloatingToolbarState;\n}) {\n  const editorId = useEditorId();\n  const focusedEditorId = useEventEditorValue(\"focus\");\n  const isFloatingLinkOpen = !!usePluginOption({ key: KEYS.link }, \"mode\");\n  const isAIChatOpen = usePluginOption({ key: KEYS.aiChat }, \"open\");\n\n  const floatingToolbarState = useFloatingToolbarState({\n    editorId,\n    focusedEditorId,\n    hideToolbar: isFloatingLinkOpen || isAIChatOpen,\n    ...state,\n    floatingOptions: {\n      middleware: [\n        offset(12),\n        flip({\n          fallbackPlacements: [\"top-start\", \"top-end\", \"bottom-start\", \"bottom-end\"],\n          padding: 12,\n        }),\n      ],\n      placement: \"top\",\n      ...state?.floatingOptions,\n    },\n  });\n\n  const { clickOutsideRef, hidden, props: rootProps, ref: floatingRef } = useFloatingToolbar(floatingToolbarState);\n\n  const ref = useComposedRef<HTMLDivElement>(props.ref, floatingRef);\n\n  if (hidden) return null;\n\n  return (\n    <div ref={clickOutsideRef}>\n      <Toolbar\n        {...props}\n        {...rootProps}\n        ref={ref}\n        className={cn(\n          \"scrollbar-hide bg-popover absolute z-50 overflow-x-auto whitespace-nowrap rounded-md border p-1 opacity-100 shadow-md print:hidden\",\n          \"max-w-[80vw]\",\n          className,\n        )}\n      >\n        {children}\n      </Toolbar>\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/font-color-toolbar-button.tsx",
    "content": "\"use client\";\n\nimport React from \"react\";\n\nimport type { DropdownMenuItemProps, DropdownMenuProps } from \"@radix-ui/react-dropdown-menu\";\n\nimport { useComposedRef } from \"@udecode/cn\";\nimport debounce from \"lodash/debounce.js\";\nimport { EraserIcon, PlusIcon } from \"lucide-react\";\nimport { useEditorRef, useEditorSelector } from \"platejs/react\";\n\nimport { buttonVariants } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from \"@/components/ui/tooltip\";\nimport { cn } from \"@/utils/cn\";\n\nimport { ToolbarButton, ToolbarMenuGroup } from \"./toolbar\";\n\nexport function FontColorToolbarButton({\n  children,\n  nodeType,\n  tooltip,\n}: {\n  nodeType: string;\n  tooltip?: string;\n} & DropdownMenuProps) {\n  const editor = useEditorRef();\n\n  const selectionDefined = useEditorSelector((editor) => !!editor.selection, []);\n\n  const color = useEditorSelector((editor) => editor.api.mark(nodeType) as string, [nodeType]);\n\n  const [selectedColor, setSelectedColor] = React.useState<string>();\n  const [open, setOpen] = React.useState(false);\n\n  const onToggle = React.useCallback(\n    (value = !open) => {\n      setOpen(value);\n    },\n    [open, setOpen],\n  );\n\n  const updateColor = React.useCallback(\n    (value: string) => {\n      if (editor.selection) {\n        setSelectedColor(value);\n\n        editor.tf.select(editor.selection);\n        editor.tf.focus();\n\n        editor.tf.addMarks({ [nodeType]: value });\n      }\n    },\n    [editor, nodeType],\n  );\n\n  const updateColorAndClose = React.useCallback(\n    (value: string) => {\n      updateColor(value);\n      onToggle();\n    },\n    [onToggle, updateColor],\n  );\n\n  const clearColor = React.useCallback(() => {\n    if (editor.selection) {\n      editor.tf.select(editor.selection);\n      editor.tf.focus();\n\n      if (selectedColor) {\n        editor.tf.removeMarks(nodeType);\n      }\n\n      onToggle();\n    }\n  }, [editor, selectedColor, onToggle, nodeType]);\n\n  React.useEffect(() => {\n    if (selectionDefined) {\n      setSelectedColor(color);\n    }\n  }, [color, selectionDefined]);\n\n  return (\n    <DropdownMenu\n      open={open}\n      onOpenChange={(value) => {\n        setOpen(value);\n      }}\n      modal={false}\n    >\n      <DropdownMenuTrigger asChild>\n        <ToolbarButton pressed={open} tooltip={tooltip}>\n          {children}\n        </ToolbarButton>\n      </DropdownMenuTrigger>\n\n      <DropdownMenuContent align=\"start\">\n        <ColorPicker\n          color={selectedColor || color}\n          clearColor={clearColor}\n          colors={DEFAULT_COLORS}\n          customColors={DEFAULT_CUSTOM_COLORS}\n          updateColor={updateColorAndClose}\n          updateCustomColor={updateColor}\n        />\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n\nfunction PureColorPicker({\n  className,\n  clearColor,\n  color,\n  colors,\n  customColors,\n  updateColor,\n  updateCustomColor,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  colors: TColor[];\n  customColors: TColor[];\n  clearColor: () => void;\n  updateColor: (color: string) => void;\n  updateCustomColor: (color: string) => void;\n  color?: string;\n}) {\n  return (\n    <div className={cn(\"flex flex-col\", className)} {...props}>\n      <ToolbarMenuGroup label=\"Custom Colors\">\n        <ColorCustom\n          color={color}\n          className=\"px-2\"\n          colors={colors}\n          customColors={customColors}\n          updateColor={updateColor}\n          updateCustomColor={updateCustomColor}\n        />\n      </ToolbarMenuGroup>\n      <ToolbarMenuGroup label=\"Default Colors\">\n        <ColorDropdownMenuItems color={color} className=\"px-2\" colors={colors} updateColor={updateColor} />\n      </ToolbarMenuGroup>\n      {color && (\n        <ToolbarMenuGroup>\n          <DropdownMenuItem className=\"p-2\" onClick={clearColor}>\n            <EraserIcon />\n            <span>Clear</span>\n          </DropdownMenuItem>\n        </ToolbarMenuGroup>\n      )}\n    </div>\n  );\n}\n\nconst ColorPicker = React.memo(\n  PureColorPicker,\n  (prev, next) => prev.color === next.color && prev.colors === next.colors && prev.customColors === next.customColors,\n);\n\nfunction ColorCustom({\n  className,\n  color,\n  colors,\n  customColors,\n  updateColor,\n  updateCustomColor,\n  ...props\n}: {\n  colors: TColor[];\n  customColors: TColor[];\n  updateColor: (color: string) => void;\n  updateCustomColor: (color: string) => void;\n  color?: string;\n} & React.ComponentPropsWithoutRef<\"div\">) {\n  const [customColor, setCustomColor] = React.useState<string>();\n  const [value, setValue] = React.useState<string>(color || \"#000000\");\n\n  React.useEffect(() => {\n    if (!color || customColors.some((c) => c.value === color) || colors.some((c) => c.value === color)) {\n      return;\n    }\n\n    setCustomColor(color);\n  }, [color, colors, customColors]);\n\n  const computedColors = React.useMemo(\n    () =>\n      customColor\n        ? [\n            ...customColors,\n            {\n              isBrightColor: false,\n              name: \"\",\n              value: customColor,\n            },\n          ]\n        : customColors,\n    [customColor, customColors],\n  );\n\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  const updateCustomColorDebounced = React.useCallback(debounce(updateCustomColor, 100), [updateCustomColor]);\n\n  return (\n    <div className={cn(\"relative flex flex-col gap-4\", className)} {...props}>\n      <ColorDropdownMenuItems color={color} colors={computedColors} updateColor={updateColor}>\n        <ColorInput\n          value={value}\n          onChange={(e) => {\n            setValue(e.target.value);\n            updateCustomColorDebounced(e.target.value);\n          }}\n        >\n          <DropdownMenuItem\n            className={cn(\n              buttonVariants({\n                size: \"icon\",\n                variant: \"outline\",\n              }),\n              \"absolute bottom-2 right-2 top-1 flex size-8 items-center justify-center rounded-full\",\n            )}\n            onSelect={(e) => {\n              e.preventDefault();\n            }}\n          >\n            <span className=\"sr-only\">Custom</span>\n            <PlusIcon />\n          </DropdownMenuItem>\n        </ColorInput>\n      </ColorDropdownMenuItems>\n    </div>\n  );\n}\n\nfunction ColorInput({ children, className, value = \"#000000\", ...props }: React.ComponentProps<\"input\">) {\n  const inputRef = React.useRef<HTMLInputElement | null>(null);\n\n  return (\n    <div className=\"flex flex-col items-center\">\n      {React.Children.map(children, (child) => {\n        if (!child) return child;\n\n        return React.cloneElement(\n          child as React.ReactElement<{\n            onClick: () => void;\n          }>,\n          {\n            onClick: () => inputRef.current?.click(),\n          },\n        );\n      })}\n      <input\n        {...props}\n        ref={useComposedRef(props.ref, inputRef)}\n        className={cn(\"size-0 overflow-hidden border-0 p-0\", className)}\n        value={value}\n        type=\"color\"\n      />\n    </div>\n  );\n}\n\ntype TColor = {\n  isBrightColor: boolean;\n  name: string;\n  value: string;\n};\n\nfunction ColorDropdownMenuItem({\n  className,\n  isBrightColor,\n  isSelected,\n  name,\n  updateColor,\n  value,\n  ...props\n}: {\n  isBrightColor: boolean;\n  isSelected: boolean;\n  value: string;\n  updateColor: (color: string) => void;\n  name?: string;\n} & DropdownMenuItemProps) {\n  const content = (\n    <DropdownMenuItem\n      className={cn(\n        buttonVariants({\n          size: \"icon\",\n          variant: \"outline\",\n        }),\n        \"border-muted my-1 flex size-6 items-center justify-center rounded-full border border-solid p-0 transition-all hover:scale-125\",\n        !isBrightColor && \"border-transparent\",\n        isSelected && \"border-primary border-2\",\n        className,\n      )}\n      style={{ backgroundColor: value }}\n      onSelect={(e) => {\n        e.preventDefault();\n        updateColor(value);\n      }}\n      {...props}\n    />\n  );\n\n  return name ? (\n    <Tooltip>\n      <TooltipTrigger>{content}</TooltipTrigger>\n      <TooltipContent className=\"mb-1 capitalize\">{name}</TooltipContent>\n    </Tooltip>\n  ) : (\n    content\n  );\n}\n\nexport function ColorDropdownMenuItems({\n  className,\n  color,\n  colors,\n  updateColor,\n  ...props\n}: {\n  colors: TColor[];\n  updateColor: (color: string) => void;\n  color?: string;\n} & React.ComponentProps<\"div\">) {\n  return (\n    <div className={cn(\"grid grid-cols-[repeat(10,1fr)] place-items-center gap-x-1\", className)} {...props}>\n      <TooltipProvider>\n        {colors.map(({ isBrightColor, name, value }) => (\n          <ColorDropdownMenuItem\n            name={name}\n            key={name ?? value}\n            value={value}\n            isBrightColor={isBrightColor}\n            isSelected={color === value}\n            updateColor={updateColor}\n          />\n        ))}\n        {props.children}\n      </TooltipProvider>\n    </div>\n  );\n}\n\nexport const DEFAULT_COLORS = [\n  {\n    isBrightColor: false,\n    name: \"black\",\n    value: \"#000000\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark grey 4\",\n    value: \"#434343\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark grey 3\",\n    value: \"#666666\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark grey 2\",\n    value: \"#999999\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark grey 1\",\n    value: \"#B7B7B7\",\n  },\n  {\n    isBrightColor: false,\n    name: \"grey\",\n    value: \"#CCCCCC\",\n  },\n  {\n    isBrightColor: false,\n    name: \"light grey 1\",\n    value: \"#D9D9D9\",\n  },\n  {\n    isBrightColor: true,\n    name: \"light grey 2\",\n    value: \"#EFEFEF\",\n  },\n  {\n    isBrightColor: true,\n    name: \"light grey 3\",\n    value: \"#F3F3F3\",\n  },\n  {\n    isBrightColor: true,\n    name: \"white\",\n    value: \"#FFFFFF\",\n  },\n  {\n    isBrightColor: false,\n    name: \"red berry\",\n    value: \"#980100\",\n  },\n  {\n    isBrightColor: false,\n    name: \"red\",\n    value: \"#FE0000\",\n  },\n  {\n    isBrightColor: false,\n    name: \"orange\",\n    value: \"#FE9900\",\n  },\n  {\n    isBrightColor: true,\n    name: \"yellow\",\n    value: \"#FEFF00\",\n  },\n  {\n    isBrightColor: false,\n    name: \"green\",\n    value: \"#00FF00\",\n  },\n  {\n    isBrightColor: false,\n    name: \"cyan\",\n    value: \"#00FFFF\",\n  },\n  {\n    isBrightColor: false,\n    name: \"cornflower blue\",\n    value: \"#4B85E8\",\n  },\n  {\n    isBrightColor: false,\n    name: \"blue\",\n    value: \"#1300FF\",\n  },\n  {\n    isBrightColor: false,\n    name: \"purple\",\n    value: \"#9900FF\",\n  },\n  {\n    isBrightColor: false,\n    name: \"magenta\",\n    value: \"#FF00FF\",\n  },\n\n  {\n    isBrightColor: false,\n    name: \"light red berry 3\",\n    value: \"#E6B8AF\",\n  },\n  {\n    isBrightColor: false,\n    name: \"light red 3\",\n    value: \"#F4CCCC\",\n  },\n  {\n    isBrightColor: true,\n    name: \"light orange 3\",\n    value: \"#FCE4CD\",\n  },\n  {\n    isBrightColor: true,\n    name: \"light yellow 3\",\n    value: \"#FFF2CC\",\n  },\n  {\n    isBrightColor: true,\n    name: \"light green 3\",\n    value: \"#D9EAD3\",\n  },\n  {\n    isBrightColor: false,\n    name: \"light cyan 3\",\n    value: \"#D0DFE3\",\n  },\n  {\n    isBrightColor: false,\n    name: \"light cornflower blue 3\",\n    value: \"#C9DAF8\",\n  },\n  {\n    isBrightColor: true,\n    name: \"light blue 3\",\n    value: \"#CFE1F3\",\n  },\n  {\n    isBrightColor: true,\n    name: \"light purple 3\",\n    value: \"#D9D2E9\",\n  },\n  {\n    isBrightColor: true,\n    name: \"light magenta 3\",\n    value: \"#EAD1DB\",\n  },\n\n  {\n    isBrightColor: false,\n    name: \"light red berry 2\",\n    value: \"#DC7E6B\",\n  },\n  {\n    isBrightColor: false,\n    name: \"light red 2\",\n    value: \"#EA9999\",\n  },\n  {\n    isBrightColor: false,\n    name: \"light orange 2\",\n    value: \"#F9CB9C\",\n  },\n  {\n    isBrightColor: true,\n    name: \"light yellow 2\",\n    value: \"#FFE598\",\n  },\n  {\n    isBrightColor: false,\n    name: \"light green 2\",\n    value: \"#B7D6A8\",\n  },\n  {\n    isBrightColor: false,\n    name: \"light cyan 2\",\n    value: \"#A1C4C9\",\n  },\n  {\n    isBrightColor: false,\n    name: \"light cornflower blue 2\",\n    value: \"#A4C2F4\",\n  },\n  {\n    isBrightColor: false,\n    name: \"light blue 2\",\n    value: \"#9FC5E8\",\n  },\n  {\n    isBrightColor: false,\n    name: \"light purple 2\",\n    value: \"#B5A7D5\",\n  },\n  {\n    isBrightColor: false,\n    name: \"light magenta 2\",\n    value: \"#D5A6BD\",\n  },\n\n  {\n    isBrightColor: false,\n    name: \"light red berry 1\",\n    value: \"#CC4125\",\n  },\n  {\n    isBrightColor: false,\n    name: \"light red 1\",\n    value: \"#E06666\",\n  },\n  {\n    isBrightColor: false,\n    name: \"light orange 1\",\n    value: \"#F6B26B\",\n  },\n  {\n    isBrightColor: false,\n    name: \"light yellow 1\",\n    value: \"#FFD966\",\n  },\n  {\n    isBrightColor: false,\n    name: \"light green 1\",\n    value: \"#93C47D\",\n  },\n  {\n    isBrightColor: false,\n    name: \"light cyan 1\",\n    value: \"#76A5AE\",\n  },\n  {\n    isBrightColor: false,\n    name: \"light cornflower blue 1\",\n    value: \"#6C9EEB\",\n  },\n  {\n    isBrightColor: false,\n    name: \"light blue 1\",\n    value: \"#6FA8DC\",\n  },\n  {\n    isBrightColor: false,\n    name: \"light purple 1\",\n    value: \"#8D7CC3\",\n  },\n  {\n    isBrightColor: false,\n    name: \"light magenta 1\",\n    value: \"#C27BA0\",\n  },\n\n  {\n    isBrightColor: false,\n    name: \"dark red berry 1\",\n    value: \"#A61B00\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark red 1\",\n    value: \"#CC0000\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark orange 1\",\n    value: \"#E59138\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark yellow 1\",\n    value: \"#F1C231\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark green 1\",\n    value: \"#6AA74F\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark cyan 1\",\n    value: \"#45818E\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark cornflower blue 1\",\n    value: \"#3B78D8\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark blue 1\",\n    value: \"#3E84C6\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark purple 1\",\n    value: \"#664EA6\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark magenta 1\",\n    value: \"#A64D78\",\n  },\n\n  {\n    isBrightColor: false,\n    name: \"dark red berry 2\",\n    value: \"#84200D\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark red 2\",\n    value: \"#990001\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark orange 2\",\n    value: \"#B45F05\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark yellow 2\",\n    value: \"#BF9002\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark green 2\",\n    value: \"#38761D\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark cyan 2\",\n    value: \"#124F5C\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark cornflower blue 2\",\n    value: \"#1155CB\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark blue 2\",\n    value: \"#0C5394\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark purple 2\",\n    value: \"#351C75\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark magenta 2\",\n    value: \"#741B47\",\n  },\n\n  {\n    isBrightColor: false,\n    name: \"dark red berry 3\",\n    value: \"#5B0F00\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark red 3\",\n    value: \"#660000\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark orange 3\",\n    value: \"#783F04\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark yellow 3\",\n    value: \"#7E6000\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark green 3\",\n    value: \"#274E12\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark cyan 3\",\n    value: \"#0D343D\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark cornflower blue 3\",\n    value: \"#1B4487\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark blue 3\",\n    value: \"#083763\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark purple 3\",\n    value: \"#1F124D\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark magenta 3\",\n    value: \"#4C1130\",\n  },\n];\n\nconst DEFAULT_CUSTOM_COLORS = [\n  {\n    isBrightColor: false,\n    name: \"dark orange 3\",\n    value: \"#783F04\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark grey 3\",\n    value: \"#666666\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark grey 2\",\n    value: \"#999999\",\n  },\n  {\n    isBrightColor: false,\n    name: \"light cornflower blue 1\",\n    value: \"#6C9EEB\",\n  },\n  {\n    isBrightColor: false,\n    name: \"dark magenta 3\",\n    value: \"#4C1130\",\n  },\n];\n"
  },
  {
    "path": "app/src/components/ui/font-size-toolbar-button.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\n\nimport type { TElement } from \"platejs\";\n\nimport { toUnitLess } from \"@platejs/basic-styles\";\nimport { FontSizePlugin } from \"@platejs/basic-styles/react\";\nimport { Minus, Plus } from \"lucide-react\";\nimport { KEYS } from \"platejs\";\nimport { useEditorPlugin, useEditorSelector } from \"platejs/react\";\n\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ui/popover\";\nimport { cn } from \"@/utils/cn\";\n\nimport { ToolbarButton } from \"./toolbar\";\n\nconst DEFAULT_FONT_SIZE = \"16\";\n\nconst FONT_SIZE_MAP = {\n  h1: \"36\",\n  h2: \"24\",\n  h3: \"20\",\n} as const;\n\nconst FONT_SIZES = [\"8\", \"9\", \"10\", \"12\", \"14\", \"16\", \"18\", \"24\", \"30\", \"36\", \"48\", \"60\", \"72\", \"96\"] as const;\n\nexport function FontSizeToolbarButton() {\n  const [inputValue, setInputValue] = React.useState(DEFAULT_FONT_SIZE);\n  const [isFocused, setIsFocused] = React.useState(false);\n  const { editor, tf } = useEditorPlugin(FontSizePlugin);\n\n  const cursorFontSize = useEditorSelector((editor) => {\n    const fontSize = editor.api.marks()?.[KEYS.fontSize];\n\n    if (fontSize) {\n      return toUnitLess(fontSize as string);\n    }\n\n    const [block] = editor.api.block<TElement>() || [];\n\n    if (!block?.type) return DEFAULT_FONT_SIZE;\n\n    return block.type in FONT_SIZE_MAP ? FONT_SIZE_MAP[block.type as keyof typeof FONT_SIZE_MAP] : DEFAULT_FONT_SIZE;\n  }, []);\n\n  const handleInputChange = () => {\n    const newSize = toUnitLess(inputValue);\n\n    if (Number.parseInt(newSize) < 1 || Number.parseInt(newSize) > 100) {\n      editor.tf.focus();\n\n      return;\n    }\n    if (newSize !== toUnitLess(cursorFontSize)) {\n      tf.fontSize.addMark(`${newSize}px`);\n    }\n\n    editor.tf.focus();\n  };\n\n  const handleFontSizeChange = (delta: number) => {\n    const newSize = Number(displayValue) + delta;\n    tf.fontSize.addMark(`${newSize}px`);\n    editor.tf.focus();\n  };\n\n  const displayValue = isFocused ? inputValue : cursorFontSize;\n\n  return (\n    <div className=\"bg-muted/60 flex h-7 items-center gap-1 rounded-md p-0\">\n      <ToolbarButton onClick={() => handleFontSizeChange(-1)}>\n        <Minus />\n      </ToolbarButton>\n\n      <Popover open={isFocused} modal={false}>\n        <PopoverTrigger asChild>\n          <input\n            className={cn(\"hover:bg-muted h-full w-10 shrink-0 bg-transparent px-1 text-center text-sm\")}\n            value={displayValue}\n            onBlur={() => {\n              setIsFocused(false);\n              handleInputChange();\n            }}\n            onChange={(e) => setInputValue(e.target.value)}\n            onFocus={() => {\n              setIsFocused(true);\n              setInputValue(toUnitLess(cursorFontSize));\n            }}\n            onKeyDown={(e) => {\n              if (e.key === \"Enter\") {\n                e.preventDefault();\n                handleInputChange();\n              }\n            }}\n            data-plate-focus=\"true\"\n            type=\"text\"\n          />\n        </PopoverTrigger>\n        <PopoverContent className=\"w-10 px-px py-1\" onOpenAutoFocus={(e) => e.preventDefault()}>\n          {FONT_SIZES.map((size) => (\n            <button\n              key={size}\n              className={cn(\n                \"hover:bg-accent data-[highlighted=true]:bg-accent flex h-8 w-full items-center justify-center text-sm\",\n              )}\n              onClick={() => {\n                tf.fontSize.addMark(`${size}px`);\n                setIsFocused(false);\n              }}\n              data-highlighted={size === displayValue}\n              type=\"button\"\n            >\n              {size}\n            </button>\n          ))}\n        </PopoverContent>\n      </Popover>\n\n      <ToolbarButton onClick={() => handleFontSizeChange(1)}>\n        <Plus />\n      </ToolbarButton>\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/heading-node-static.tsx",
    "content": "import * as React from \"react\";\n\nimport type { SlateElementProps } from \"platejs\";\n\nimport { type VariantProps, cva } from \"class-variance-authority\";\nimport { SlateElement } from \"platejs\";\n\nconst headingVariants = cva(\"relative mb-1\", {\n  variants: {\n    variant: {\n      h1: \"mt-[1.6em] pb-1 font-heading text-4xl font-bold\",\n      h2: \"mt-[1.4em] pb-px font-heading text-2xl font-semibold tracking-tight\",\n      h3: \"mt-[1em] pb-px font-heading text-xl font-semibold tracking-tight\",\n      h4: \"mt-[0.75em] font-heading text-lg font-semibold tracking-tight\",\n      h5: \"mt-[0.75em] text-lg font-semibold tracking-tight\",\n      h6: \"mt-[0.75em] text-base font-semibold tracking-tight\",\n    },\n  },\n});\n\nexport function HeadingElementStatic({\n  variant = \"h1\",\n  ...props\n}: SlateElementProps & VariantProps<typeof headingVariants>) {\n  return (\n    <SlateElement as={variant!} className={headingVariants({ variant })} {...props}>\n      {props.children}\n    </SlateElement>\n  );\n}\n\nexport function H1ElementStatic(props: SlateElementProps) {\n  return <HeadingElementStatic variant=\"h1\" {...props} />;\n}\n\nexport function H2ElementStatic(props: React.ComponentProps<typeof HeadingElementStatic>) {\n  return <HeadingElementStatic variant=\"h2\" {...props} />;\n}\n\nexport function H3ElementStatic(props: React.ComponentProps<typeof HeadingElementStatic>) {\n  return <HeadingElementStatic variant=\"h3\" {...props} />;\n}\n\nexport function H4ElementStatic(props: React.ComponentProps<typeof HeadingElementStatic>) {\n  return <HeadingElementStatic variant=\"h4\" {...props} />;\n}\n\nexport function H5ElementStatic(props: React.ComponentProps<typeof HeadingElementStatic>) {\n  return <HeadingElementStatic variant=\"h5\" {...props} />;\n}\n\nexport function H6ElementStatic(props: React.ComponentProps<typeof HeadingElementStatic>) {\n  return <HeadingElementStatic variant=\"h6\" {...props} />;\n}\n"
  },
  {
    "path": "app/src/components/ui/heading-node.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\n\nimport type { PlateElementProps } from \"platejs/react\";\n\nimport { type VariantProps, cva } from \"class-variance-authority\";\nimport { PlateElement } from \"platejs/react\";\n\nconst headingVariants = cva(\"relative mb-1\", {\n  variants: {\n    variant: {\n      h1: \"mt-[1.6em] pb-1 font-heading text-4xl font-bold\",\n      h2: \"mt-[1.4em] pb-px font-heading text-2xl font-semibold tracking-tight\",\n      h3: \"mt-[1em] pb-px font-heading text-xl font-semibold tracking-tight\",\n      h4: \"mt-[0.75em] font-heading text-lg font-semibold tracking-tight\",\n      h5: \"mt-[0.75em] text-lg font-semibold tracking-tight\",\n      h6: \"mt-[0.75em] text-base font-semibold tracking-tight\",\n    },\n  },\n});\n\nexport function HeadingElement({ variant = \"h1\", ...props }: PlateElementProps & VariantProps<typeof headingVariants>) {\n  return (\n    <PlateElement as={variant!} className={headingVariants({ variant })} {...props}>\n      {props.children}\n    </PlateElement>\n  );\n}\n\nexport function H1Element(props: PlateElementProps) {\n  return <HeadingElement variant=\"h1\" {...props} />;\n}\n\nexport function H2Element(props: PlateElementProps) {\n  return <HeadingElement variant=\"h2\" {...props} />;\n}\n\nexport function H3Element(props: PlateElementProps) {\n  return <HeadingElement variant=\"h3\" {...props} />;\n}\n\nexport function H4Element(props: PlateElementProps) {\n  return <HeadingElement variant=\"h4\" {...props} />;\n}\n\nexport function H5Element(props: PlateElementProps) {\n  return <HeadingElement variant=\"h5\" {...props} />;\n}\n\nexport function H6Element(props: PlateElementProps) {\n  return <HeadingElement variant=\"h6\" {...props} />;\n}\n"
  },
  {
    "path": "app/src/components/ui/highlight-node-static.tsx",
    "content": "// @ts-nocheck\nimport * as React from \"react\";\n\nimport type { SlateLeafProps } from \"platejs\";\n\nimport { SlateLeaf } from \"platejs\";\n\nexport function HighlightLeafStatic(props: SlateLeafProps) {\n  return (\n    <SlateLeaf {...props} as=\"mark\" className=\"bg-highlight/30 text-inherit\">\n      {props.children}\n    </SlateLeaf>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/highlight-node.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\n\nimport type { PlateLeafProps } from \"platejs/react\";\n\nimport { PlateLeaf } from \"platejs/react\";\n\nexport function HighlightLeaf(props: PlateLeafProps) {\n  return (\n    <PlateLeaf {...props} as=\"mark\" className=\"bg-highlight/30 text-inherit\">\n      {props.children}\n    </PlateLeaf>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/history-toolbar-button.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\n\nimport { Redo2Icon, Undo2Icon } from \"lucide-react\";\nimport { useEditorRef, useEditorSelector } from \"platejs/react\";\n\nimport { ToolbarButton } from \"./toolbar\";\n\nexport function RedoToolbarButton(props: React.ComponentProps<typeof ToolbarButton>) {\n  const editor = useEditorRef();\n  const disabled = useEditorSelector((editor) => editor.history.redos.length === 0, []);\n\n  return (\n    <ToolbarButton\n      {...props}\n      disabled={disabled}\n      onClick={() => editor.redo()}\n      onMouseDown={(e) => e.preventDefault()}\n      tooltip=\"Redo\"\n    >\n      <Redo2Icon />\n    </ToolbarButton>\n  );\n}\n\nexport function UndoToolbarButton(props: React.ComponentProps<typeof ToolbarButton>) {\n  const editor = useEditorRef();\n  const disabled = useEditorSelector((editor) => editor.history.undos.length === 0, []);\n\n  return (\n    <ToolbarButton\n      {...props}\n      disabled={disabled}\n      onClick={() => editor.undo()}\n      onMouseDown={(e) => e.preventDefault()}\n      tooltip=\"Undo\"\n    >\n      <Undo2Icon />\n    </ToolbarButton>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/hr-node-static.tsx",
    "content": "// @ts-nocheck\nimport * as React from \"react\";\n\nimport type { SlateElementProps } from \"platejs\";\n\nimport { SlateElement } from \"platejs\";\n\nimport { cn } from \"@/utils/cn\";\n\nexport function HrElementStatic(props: SlateElementProps) {\n  return (\n    <SlateElement {...props}>\n      <div className=\"cursor-text py-6\" contentEditable={false}>\n        <hr className={cn(\"bg-muted h-0.5 rounded-sm border-none bg-clip-content\")} />\n      </div>\n      {props.children}\n    </SlateElement>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/hr-node.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\n\nimport type { PlateElementProps } from \"platejs/react\";\n\nimport { PlateElement, useFocused, useReadOnly, useSelected } from \"platejs/react\";\n\nimport { cn } from \"@/utils/cn\";\n\nexport function HrElement(props: PlateElementProps) {\n  const readOnly = useReadOnly();\n  const selected = useSelected();\n  const focused = useFocused();\n\n  return (\n    <PlateElement {...props}>\n      <div className=\"py-6\" contentEditable={false}>\n        <hr\n          className={cn(\n            \"bg-muted h-0.5 rounded-sm border-none bg-clip-content\",\n            selected && focused && \"ring-ring ring-2 ring-offset-2\",\n            !readOnly && \"cursor-pointer\",\n          )}\n        />\n      </div>\n      {props.children}\n    </PlateElement>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/import-toolbar-button.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport type { DropdownMenuProps } from \"@radix-ui/react-dropdown-menu\";\n\nimport { MarkdownPlugin } from \"@platejs/markdown\";\nimport { ArrowUpToLineIcon } from \"lucide-react\";\nimport { getEditorDOMFromHtmlString } from \"platejs\";\nimport { useEditorRef } from \"platejs/react\";\nimport { useFilePicker } from \"use-file-picker\";\n\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\n\nimport { ToolbarButton } from \"./toolbar\";\n\ntype ImportType = \"html\" | \"markdown\";\n\nexport function ImportToolbarButton(props: DropdownMenuProps) {\n  const editor = useEditorRef();\n  const [open, setOpen] = React.useState(false);\n\n  const getFileNodes = (text: string, type: ImportType) => {\n    if (type === \"html\") {\n      const editorNode = getEditorDOMFromHtmlString(text);\n      const nodes = editor.api.html.deserialize({\n        element: editorNode,\n      });\n\n      return nodes;\n    }\n\n    if (type === \"markdown\") {\n      return editor.getApi(MarkdownPlugin).markdown.deserialize(text);\n    }\n\n    return [];\n  };\n\n  const { openFilePicker: openMdFilePicker } = useFilePicker({\n    accept: [\".md\", \".mdx\"],\n    multiple: false,\n    onFilesSelected: async ({ plainFiles }) => {\n      const text = await plainFiles[0].text();\n\n      const nodes = getFileNodes(text, \"markdown\");\n\n      editor.tf.insertNodes(nodes);\n    },\n  });\n\n  const { openFilePicker: openHtmlFilePicker } = useFilePicker({\n    accept: [\"text/html\"],\n    multiple: false,\n    onFilesSelected: async ({ plainFiles }) => {\n      const text = await plainFiles[0].text();\n\n      const nodes = getFileNodes(text, \"html\");\n\n      editor.tf.insertNodes(nodes);\n    },\n  });\n\n  return (\n    <DropdownMenu open={open} onOpenChange={setOpen} modal={false} {...props}>\n      <DropdownMenuTrigger asChild>\n        <ToolbarButton pressed={open} tooltip=\"Import\" isDropdown>\n          <ArrowUpToLineIcon className=\"size-4\" />\n        </ToolbarButton>\n      </DropdownMenuTrigger>\n\n      <DropdownMenuContent align=\"start\">\n        <DropdownMenuGroup>\n          <DropdownMenuItem\n            onSelect={() => {\n              openHtmlFilePicker();\n            }}\n          >\n            Import from HTML\n          </DropdownMenuItem>\n\n          <DropdownMenuItem\n            onSelect={() => {\n              openMdFilePicker();\n            }}\n          >\n            Import from Markdown\n          </DropdownMenuItem>\n        </DropdownMenuGroup>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/indent-toolbar-button.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { useIndentButton, useOutdentButton } from \"@platejs/indent/react\";\nimport { IndentIcon, OutdentIcon } from \"lucide-react\";\n\nimport { ToolbarButton } from \"./toolbar\";\n\nexport function IndentToolbarButton(props: React.ComponentProps<typeof ToolbarButton>) {\n  const { props: buttonProps } = useIndentButton();\n\n  return (\n    <ToolbarButton {...props} {...buttonProps} tooltip=\"Indent\">\n      <IndentIcon />\n    </ToolbarButton>\n  );\n}\n\nexport function OutdentToolbarButton(props: React.ComponentProps<typeof ToolbarButton>) {\n  const { props: buttonProps } = useOutdentButton();\n\n  return (\n    <ToolbarButton {...props} {...buttonProps} tooltip=\"Outdent\">\n      <OutdentIcon />\n    </ToolbarButton>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/inline-combobox.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\n\nimport type { Point, TElement } from \"platejs\";\n\nimport {\n  type ComboboxItemProps,\n  Combobox,\n  ComboboxGroup,\n  ComboboxGroupLabel,\n  ComboboxItem,\n  ComboboxPopover,\n  ComboboxProvider,\n  ComboboxRow,\n  Portal,\n  useComboboxContext,\n  useComboboxStore,\n} from \"@ariakit/react\";\nimport { filterWords } from \"@platejs/combobox\";\nimport { type UseComboboxInputResult, useComboboxInput, useHTMLInputCursorState } from \"@platejs/combobox/react\";\nimport { cva } from \"class-variance-authority\";\nimport { useComposedRef, useEditorRef } from \"platejs/react\";\n\nimport { cn } from \"@/utils/cn\";\n\ntype FilterFn = (\n  item: { value: string; group?: string; keywords?: string[]; label?: string },\n  search: string,\n) => boolean;\n\ninterface InlineComboboxContextValue {\n  filter: FilterFn | false;\n  inputProps: UseComboboxInputResult[\"props\"];\n  inputRef: React.RefObject<HTMLInputElement | null>;\n  removeInput: UseComboboxInputResult[\"removeInput\"];\n  showTrigger: boolean;\n  trigger: string;\n  setHasEmpty: (hasEmpty: boolean) => void;\n}\n\nconst InlineComboboxContext = React.createContext<InlineComboboxContextValue>(\n  null as unknown as InlineComboboxContextValue,\n);\n\nconst defaultFilter: FilterFn = ({ group, keywords = [], label, value }, search) => {\n  const uniqueTerms = new Set([value, ...keywords, group, label].filter(Boolean));\n\n  return Array.from(uniqueTerms).some((keyword) => filterWords(keyword!, search));\n};\n\ninterface InlineComboboxProps {\n  children: React.ReactNode;\n  element: TElement;\n  trigger: string;\n  filter?: FilterFn | false;\n  hideWhenNoValue?: boolean;\n  showTrigger?: boolean;\n  value?: string;\n  setValue?: (value: string) => void;\n}\n\nconst InlineCombobox = ({\n  children,\n  element,\n  filter = defaultFilter,\n  hideWhenNoValue = false,\n  setValue: setValueProp,\n  showTrigger = true,\n  trigger,\n  value: valueProp,\n}: InlineComboboxProps) => {\n  const editor = useEditorRef();\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const cursorState = useHTMLInputCursorState(inputRef);\n\n  const [valueState, setValueState] = React.useState(\"\");\n  const hasValueProp = valueProp !== undefined;\n  const value = hasValueProp ? valueProp : valueState;\n\n  const setValue = React.useCallback(\n    (newValue: string) => {\n      setValueProp?.(newValue);\n\n      if (!hasValueProp) {\n        setValueState(newValue);\n      }\n    },\n    [setValueProp, hasValueProp],\n  );\n\n  /**\n   * Track the point just before the input element so we know where to\n   * insertText if the combobox closes due to a selection change.\n   */\n  const insertPoint = React.useRef<Point | null>(null);\n\n  React.useEffect(() => {\n    const path = editor.api.findPath(element);\n\n    if (!path) return;\n\n    const point = editor.api.before(path);\n\n    if (!point) return;\n\n    const pointRef = editor.api.pointRef(point);\n    insertPoint.current = pointRef.current;\n\n    return () => {\n      pointRef.unref();\n    };\n  }, [editor, element]);\n\n  const { props: inputProps, removeInput } = useComboboxInput({\n    cancelInputOnBlur: true,\n    cursorState,\n    ref: inputRef,\n    onCancelInput: (cause) => {\n      if (cause !== \"backspace\") {\n        editor.tf.insertText(trigger + value, {\n          at: insertPoint?.current ?? undefined,\n        });\n      }\n      if (cause === \"arrowLeft\" || cause === \"arrowRight\") {\n        editor.tf.move({\n          distance: 1,\n          reverse: cause === \"arrowLeft\",\n        });\n      }\n    },\n  });\n\n  const [hasEmpty, setHasEmpty] = React.useState(false);\n\n  const contextValue: InlineComboboxContextValue = React.useMemo(\n    () => ({\n      filter,\n      inputProps,\n      inputRef,\n      removeInput,\n      setHasEmpty,\n      showTrigger,\n      trigger,\n    }),\n    [trigger, showTrigger, filter, inputRef, inputProps, removeInput, setHasEmpty],\n  );\n\n  const store = useComboboxStore({\n    // open: ,\n    setValue: (newValue) => React.startTransition(() => setValue(newValue)),\n  });\n\n  const items = store.useState(\"items\");\n\n  /**\n   * If there is no active ID and the list of items changes, select the first\n   * item.\n   */\n  React.useEffect(() => {\n    if (!store.getState().activeId) {\n      store.setActiveId(store.first());\n    }\n  }, [items, store]);\n\n  return (\n    <span contentEditable={false}>\n      <ComboboxProvider open={(items.length > 0 || hasEmpty) && (!hideWhenNoValue || value.length > 0)} store={store}>\n        <InlineComboboxContext.Provider value={contextValue}>{children}</InlineComboboxContext.Provider>\n      </ComboboxProvider>\n    </span>\n  );\n};\n\nconst InlineComboboxInput = React.forwardRef<HTMLInputElement, React.HTMLAttributes<HTMLInputElement>>(\n  ({ className, ...props }, propRef) => {\n    const { inputProps, inputRef: contextRef, showTrigger, trigger } = React.useContext(InlineComboboxContext);\n\n    const store = useComboboxContext()!;\n    const value = store.useState(\"value\");\n\n    const ref = useComposedRef(propRef, contextRef);\n\n    /**\n     * To create an auto-resizing input, we render a visually hidden span\n     * containing the input value and position the input element on top of it.\n     * This works well for all cases except when input exceeds the width of the\n     * container.\n     */\n\n    return (\n      <>\n        {showTrigger && trigger}\n\n        <span className=\"relative min-h-[1lh]\">\n          <span className=\"invisible overflow-hidden text-nowrap\" aria-hidden=\"true\">\n            {value || \"\\u200B\"}\n          </span>\n\n          <Combobox\n            ref={ref}\n            className={cn(\"absolute left-0 top-0 size-full bg-transparent outline-none\", className)}\n            value={value}\n            autoSelect\n            {...inputProps}\n            {...props}\n          />\n        </span>\n      </>\n    );\n  },\n);\n\nInlineComboboxInput.displayName = \"InlineComboboxInput\";\n\nconst InlineComboboxContent: typeof ComboboxPopover = ({ className, ...props }) => {\n  // Portal prevents CSS from leaking into popover\n  return (\n    <Portal>\n      <ComboboxPopover\n        className={cn(\"z-500 bg-popover max-h-[288px] w-[300px] overflow-y-auto rounded-md shadow-md\", className)}\n        {...props}\n      />\n    </Portal>\n  );\n};\n\nconst comboboxItemVariants = cva(\n  \"relative mx-1 flex h-[28px] items-center rounded-sm px-2 text-sm text-foreground outline-none select-none [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0\",\n  {\n    defaultVariants: {\n      interactive: true,\n    },\n    variants: {\n      interactive: {\n        false: \"\",\n        true: \"cursor-pointer transition-colors hover:bg-accent hover:text-accent-foreground data-[active-item=true]:bg-accent data-[active-item=true]:text-accent-foreground\",\n      },\n    },\n  },\n);\n\nconst InlineComboboxItem = ({\n  className,\n  focusEditor = true,\n  group,\n  keywords,\n  label,\n  onClick,\n  ...props\n}: {\n  focusEditor?: boolean;\n  group?: string;\n  keywords?: string[];\n  label?: string;\n} & ComboboxItemProps &\n  Required<Pick<ComboboxItemProps, \"value\">>) => {\n  const { value } = props;\n\n  const { filter, removeInput } = React.useContext(InlineComboboxContext);\n\n  const store = useComboboxContext()!;\n\n  // Optimization: Do not subscribe to value if filter is false\n  const search = filter && store.useState(\"value\");\n\n  const visible = React.useMemo(\n    () => !filter || filter({ group, keywords, label, value }, search as string),\n    [filter, group, keywords, label, value, search],\n  );\n\n  if (!visible) return null;\n\n  return (\n    <ComboboxItem\n      className={cn(comboboxItemVariants(), className)}\n      onClick={(event) => {\n        removeInput(focusEditor);\n        onClick?.(event);\n      }}\n      {...props}\n    />\n  );\n};\n\nconst InlineComboboxEmpty = ({ children, className }: React.HTMLAttributes<HTMLDivElement>) => {\n  const { setHasEmpty } = React.useContext(InlineComboboxContext);\n  const store = useComboboxContext()!;\n  const items = store.useState(\"items\");\n\n  React.useEffect(() => {\n    setHasEmpty(true);\n\n    return () => {\n      setHasEmpty(false);\n    };\n  }, [setHasEmpty]);\n\n  if (items.length > 0) return null;\n\n  return <div className={cn(comboboxItemVariants({ interactive: false }), className)}>{children}</div>;\n};\n\nconst InlineComboboxRow = ComboboxRow;\n\nfunction InlineComboboxGroup({ className, ...props }: React.ComponentProps<typeof ComboboxGroup>) {\n  return (\n    <ComboboxGroup\n      {...props}\n      className={cn(\"not-last:border-b hidden py-1.5 [&:has([role=option])]:block\", className)}\n    />\n  );\n}\n\nfunction InlineComboboxGroupLabel({ className, ...props }: React.ComponentProps<typeof ComboboxGroupLabel>) {\n  return (\n    <ComboboxGroupLabel\n      {...props}\n      className={cn(\"text-muted-foreground mb-2 mt-1.5 px-3 text-xs font-medium\", className)}\n    />\n  );\n}\n\nexport {\n  InlineCombobox,\n  InlineComboboxContent,\n  InlineComboboxEmpty,\n  InlineComboboxGroup,\n  InlineComboboxGroupLabel,\n  InlineComboboxInput,\n  InlineComboboxItem,\n  InlineComboboxRow,\n};\n"
  },
  {
    "path": "app/src/components/ui/input.tsx",
    "content": "import * as React from \"react\";\n\nimport { cn } from \"@/utils/cn\";\n\nfunction Input({ className, type, ...props }: React.ComponentProps<\"input\">) {\n  return (\n    <input\n      type={type}\n      data-slot=\"input\"\n      className={cn(\n        \"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base outline-none transition-[color,box-shadow] file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n        \"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]\",\n        \"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport { Input };\n"
  },
  {
    "path": "app/src/components/ui/insert-toolbar-button.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport type { DropdownMenuProps } from \"@radix-ui/react-dropdown-menu\";\n\nimport {\n  CalendarIcon,\n  ChevronRightIcon,\n  Columns3Icon,\n  FileCodeIcon,\n  FilmIcon,\n  Heading1Icon,\n  Heading2Icon,\n  Heading3Icon,\n  ImageIcon,\n  Link2Icon,\n  ListIcon,\n  ListOrderedIcon,\n  MinusIcon,\n  PilcrowIcon,\n  PlusIcon,\n  QuoteIcon,\n  RadicalIcon,\n  SquareIcon,\n  TableIcon,\n  TableOfContentsIcon,\n} from \"lucide-react\";\nimport { KEYS } from \"platejs\";\nimport { type PlateEditor, useEditorRef } from \"platejs/react\";\n\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { insertBlock, insertInlineElement } from \"@/components/editor/transforms\";\n\nimport { ToolbarButton, ToolbarMenuGroup } from \"./toolbar\";\n\ntype Group = {\n  group: string;\n  items: Item[];\n};\n\ninterface Item {\n  icon: React.ReactNode;\n  value: string;\n  onSelect: (editor: PlateEditor, value: string) => void;\n  focusEditor?: boolean;\n  label?: string;\n}\n\nconst groups: Group[] = [\n  {\n    group: \"Basic blocks\",\n    items: [\n      {\n        icon: <PilcrowIcon />,\n        label: \"Paragraph\",\n        value: KEYS.p,\n      },\n      {\n        icon: <Heading1Icon />,\n        label: \"Heading 1\",\n        value: \"h1\",\n      },\n      {\n        icon: <Heading2Icon />,\n        label: \"Heading 2\",\n        value: \"h2\",\n      },\n      {\n        icon: <Heading3Icon />,\n        label: \"Heading 3\",\n        value: \"h3\",\n      },\n      {\n        icon: <TableIcon />,\n        label: \"Table\",\n        value: KEYS.table,\n      },\n      {\n        icon: <FileCodeIcon />,\n        label: \"Code\",\n        value: KEYS.codeBlock,\n      },\n      {\n        icon: <QuoteIcon />,\n        label: \"Quote\",\n        value: KEYS.blockquote,\n      },\n      {\n        icon: <MinusIcon />,\n        label: \"Divider\",\n        value: KEYS.hr,\n      },\n    ].map((item) => ({\n      ...item,\n      onSelect: (editor, value) => {\n        insertBlock(editor, value);\n      },\n    })),\n  },\n  {\n    group: \"Lists\",\n    items: [\n      {\n        icon: <ListIcon />,\n        label: \"Bulleted list\",\n        value: KEYS.ul,\n      },\n      {\n        icon: <ListOrderedIcon />,\n        label: \"Numbered list\",\n        value: KEYS.ol,\n      },\n      {\n        icon: <SquareIcon />,\n        label: \"To-do list\",\n        value: KEYS.listTodo,\n      },\n      {\n        icon: <ChevronRightIcon />,\n        label: \"Toggle list\",\n        value: KEYS.toggle,\n      },\n    ].map((item) => ({\n      ...item,\n      onSelect: (editor, value) => {\n        insertBlock(editor, value);\n      },\n    })),\n  },\n  {\n    group: \"Media\",\n    items: [\n      {\n        icon: <ImageIcon />,\n        label: \"Image\",\n        value: KEYS.img,\n      },\n      {\n        icon: <FilmIcon />,\n        label: \"Embed\",\n        value: KEYS.mediaEmbed,\n      },\n    ].map((item) => ({\n      ...item,\n      onSelect: (editor, value) => {\n        insertBlock(editor, value);\n      },\n    })),\n  },\n  {\n    group: \"Advanced blocks\",\n    items: [\n      {\n        icon: <TableOfContentsIcon />,\n        label: \"Table of contents\",\n        value: KEYS.toc,\n      },\n      {\n        icon: <Columns3Icon />,\n        label: \"3 columns\",\n        value: \"action_three_columns\",\n      },\n      {\n        focusEditor: false,\n        icon: <RadicalIcon />,\n        label: \"Equation\",\n        value: KEYS.equation,\n      },\n    ].map((item) => ({\n      ...item,\n      onSelect: (editor, value) => {\n        insertBlock(editor, value);\n      },\n    })),\n  },\n  {\n    group: \"Inline\",\n    items: [\n      {\n        icon: <Link2Icon />,\n        label: \"Link\",\n        value: KEYS.link,\n      },\n      {\n        focusEditor: true,\n        icon: <CalendarIcon />,\n        label: \"Date\",\n        value: KEYS.date,\n      },\n      {\n        focusEditor: false,\n        icon: <RadicalIcon />,\n        label: \"Inline Equation\",\n        value: KEYS.inlineEquation,\n      },\n    ].map((item) => ({\n      ...item,\n      onSelect: (editor, value) => {\n        insertInlineElement(editor, value);\n      },\n    })),\n  },\n];\n\nexport function InsertToolbarButton(props: DropdownMenuProps) {\n  const editor = useEditorRef();\n  const [open, setOpen] = React.useState(false);\n\n  return (\n    <DropdownMenu open={open} onOpenChange={setOpen} modal={false} {...props}>\n      <DropdownMenuTrigger asChild>\n        <ToolbarButton pressed={open} tooltip=\"Insert\" isDropdown>\n          <PlusIcon />\n        </ToolbarButton>\n      </DropdownMenuTrigger>\n\n      <DropdownMenuContent className=\"flex max-h-[500px] min-w-0 flex-col overflow-y-auto\" align=\"start\">\n        {groups.map(({ group, items: nestedItems }) => (\n          <ToolbarMenuGroup key={group} label={group}>\n            {nestedItems.map(({ icon, label, value, onSelect }) => (\n              <DropdownMenuItem\n                key={value}\n                className=\"min-w-[180px]\"\n                onSelect={() => {\n                  onSelect(editor, value);\n                  editor.tf.focus();\n                }}\n              >\n                {icon}\n                {label}\n              </DropdownMenuItem>\n            ))}\n          </ToolbarMenuGroup>\n        ))}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/kbd-node-static.tsx",
    "content": "// @ts-nocheck\nimport * as React from \"react\";\n\nimport type { SlateLeafProps } from \"platejs\";\n\nimport { SlateLeaf } from \"platejs\";\n\nexport function KbdLeafStatic(props: SlateLeafProps) {\n  return (\n    <SlateLeaf\n      {...props}\n      as=\"kbd\"\n      className=\"border-border bg-muted rounded border px-1.5 py-0.5 font-mono text-sm shadow-[rgba(255,_255,_255,_0.1)_0px_0.5px_0px_0px_inset,_rgb(248,_249,_250)_0px_1px_5px_0px_inset,_rgb(193,_200,_205)_0px_0px_0px_0.5px,_rgb(193,_200,_205)_0px_2px_1px_-1px,_rgb(193,_200,_205)_0px_1px_0px_0px] dark:shadow-[rgba(255,_255,_255,_0.1)_0px_0.5px_0px_0px_inset,_rgb(26,_29,_30)_0px_1px_5px_0px_inset,_rgb(76,_81,_85)_0px_0px_0px_0.5px,_rgb(76,_81,_85)_0px_2px_1px_-1px,_rgb(76,_81,_85)_0px_1px_0px_0px]\"\n    >\n      {props.children}\n    </SlateLeaf>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/kbd-node.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\n\nimport type { PlateLeafProps } from \"platejs/react\";\n\nimport { PlateLeaf } from \"platejs/react\";\n\nexport function KbdLeaf(props: PlateLeafProps) {\n  return (\n    <PlateLeaf\n      {...props}\n      as=\"kbd\"\n      className=\"border-border bg-muted rounded border px-1.5 py-0.5 font-mono text-sm shadow-[rgba(255,_255,_255,_0.1)_0px_0.5px_0px_0px_inset,_rgb(248,_249,_250)_0px_1px_5px_0px_inset,_rgb(193,_200,_205)_0px_0px_0px_0.5px,_rgb(193,_200,_205)_0px_2px_1px_-1px,_rgb(193,_200,_205)_0px_1px_0px_0px] dark:shadow-[rgba(255,_255,_255,_0.1)_0px_0.5px_0px_0px_inset,_rgb(26,_29,_30)_0px_1px_5px_0px_inset,_rgb(76,_81,_85)_0px_0px_0px_0.5px,_rgb(76,_81,_85)_0px_2px_1px_-1px,_rgb(76,_81,_85)_0px_1px_0px_0px]\"\n    >\n      {props.children}\n    </PlateLeaf>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/key-bind.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { formatEmacsKey, parseEmacsKey } from \"@/utils/emacs\";\nimport { isLinux, isMac, isWindows } from \"@/utils/platform\";\nimport { Check, Delete } from \"lucide-react\";\nimport { useCallback, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\n\n/**\n * 绑定快捷键的组件\n * 非受控！！\n */\nexport default function KeyBind({\n  defaultValue = \"\",\n  onChange = () => {},\n}: {\n  defaultValue?: string;\n  onChange?: (value: string) => void;\n}) {\n  const [choosing, setChoosing] = useState(false);\n  const [value, setValue] = useState(defaultValue);\n  const { t } = useTranslation(\"keyBinds\");\n\n  const handleKeyDown = useCallback((event: KeyboardEvent) => {\n    event.preventDefault();\n    if ([\"Control\", \"Alt\", \"Shift\", \"Meta\"].includes(event.key)) return;\n    setValue((prev) => prev + \" \" + formatEmacsKey(event));\n  }, []);\n\n  const handleMouseDown = useCallback((event: MouseEvent) => {\n    event.preventDefault();\n    event.stopPropagation();\n    if (event.button !== 0) {\n      setValue((prev) => prev + \" \" + formatEmacsKey(event));\n    }\n  }, []);\n\n  const handleMouseUp = useCallback((event: MouseEvent) => {\n    event.preventDefault();\n    event.stopPropagation();\n  }, []);\n\n  const handleWheel = useCallback((event: WheelEvent) => {\n    event.preventDefault();\n    event.stopPropagation();\n    setValue((prev) => prev + \" \" + formatEmacsKey(event));\n  }, []);\n\n  const startInput = useCallback(() => {\n    document.addEventListener(\"keydown\", handleKeyDown);\n    document.addEventListener(\"mousedown\", handleMouseDown);\n    document.addEventListener(\"mouseup\", handleMouseUp);\n    document.addEventListener(\"wheel\", handleWheel);\n    setChoosing(true);\n    setValue(\"\");\n  }, [handleKeyDown, handleMouseDown, handleMouseUp, handleWheel]);\n\n  const endInput = useCallback(() => {\n    document.removeEventListener(\"keydown\", handleKeyDown);\n    document.removeEventListener(\"mousedown\", handleMouseDown);\n    document.removeEventListener(\"mouseup\", handleMouseUp);\n    document.removeEventListener(\"wheel\", handleWheel);\n    setChoosing(false);\n    onChange(value.trim());\n  }, [handleKeyDown, handleMouseDown, handleMouseUp, handleWheel, value, onChange]);\n\n  return (\n    <>\n      <Button onClick={startInput} variant={choosing ? \"outline\" : \"default\"} className=\"gap-0\">\n        {value ? parseEmacsKey(value.trim()).map((key, index) => <RenderKey key={index} data={key} />) : t(\"none\")}\n      </Button>\n      {choosing && (\n        <>\n          <Button\n            onClick={() => {\n              setValue((v) => v.trim().split(\" \").slice(0, -1).join(\" \"));\n            }}\n            size=\"icon\"\n          >\n            <Delete />\n          </Button>\n          <Button onClick={endInput} size=\"icon\">\n            <Check />\n          </Button>\n        </>\n      )}\n    </>\n  );\n}\n\nexport function RenderKey({ data }: { data: ReturnType<typeof parseEmacsKey>[number] }) {\n  let keyShow = data.key;\n  if (data.key === \"arrowup\") {\n    keyShow = \"↑\";\n  } else if (data.key === \"arrowdown\") {\n    keyShow = \"↓\";\n  } else if (data.key === \"arrowleft\") {\n    keyShow = \"←\";\n  } else if (data.key === \"arrowright\") {\n    keyShow = \"→\";\n  }\n  return (\n    <span className=\"not-first:before:content-[',_'] flex gap-1 font-bold\">\n      <Modifiers modifiers={data} />\n      {data.key.startsWith(\"<\") ? <MouseButton key_={data.key} /> : keyShow}\n    </span>\n  );\n}\n\nexport function Modifiers({\n  modifiers,\n}: {\n  modifiers: {\n    control: boolean;\n    alt: boolean;\n    shift: boolean;\n    meta: boolean;\n  };\n}) {\n  const mods = [];\n\n  if (modifiers.control) {\n    if (isMac) {\n      mods.push(\"⌃\");\n    } else if (isWindows) {\n      mods.push(\"Ctrl\");\n    } else if (isLinux) {\n      mods.push(\"Ctrl\");\n    } else {\n      mods.push(\"control\");\n    }\n  }\n  if (modifiers.alt) {\n    if (isMac) {\n      mods.push(\"⌥\");\n    } else if (isWindows) {\n      mods.push(\"Alt\");\n    } else if (isLinux) {\n      mods.push(\"Alt\");\n    } else {\n      mods.push(\"alt\");\n    }\n  }\n  if (modifiers.shift) {\n    if (isMac) {\n      mods.push(\"⇧\");\n    } else if (isWindows) {\n      mods.push(\"Shift\");\n    } else if (isLinux) {\n      mods.push(\"Shift\");\n    } else {\n      mods.push(\"shift\");\n    }\n  }\n  if (modifiers.meta) {\n    if (isMac) {\n      mods.push(\"⌘\");\n    } else if (isWindows) {\n      mods.push(\"❖\");\n    } else if (isLinux) {\n      mods.push(\"Super\");\n    } else {\n      mods.push(\"meta\");\n    }\n  }\n  return mods.map((modifier, index) => (\n    <span className=\"bg-card text-foreground rounded-sm px-1 font-semibold\" key={index}>\n      {modifier}\n    </span>\n  ));\n}\n\nexport function MouseButton({ key_ }: { key_: string }) {\n  const button = key_.slice(1, -1);\n\n  return <span>{button === \"MWU\" ? \"鼠标滚轮向上\" : button === \"MWD\" ? \"鼠标滚轮向下\" : `鼠标按键${button}`}</span>;\n}\n"
  },
  {
    "path": "app/src/components/ui/line-height-toolbar-button.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport type { DropdownMenuProps } from \"@radix-ui/react-dropdown-menu\";\n\nimport { LineHeightPlugin } from \"@platejs/basic-styles/react\";\nimport { DropdownMenuItemIndicator } from \"@radix-ui/react-dropdown-menu\";\nimport { CheckIcon, WrapText } from \"lucide-react\";\nimport { useEditorRef, useSelectionFragmentProp } from \"platejs/react\";\n\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\n\nimport { ToolbarButton } from \"./toolbar\";\n\nexport function LineHeightToolbarButton(props: DropdownMenuProps) {\n  const editor = useEditorRef();\n  const { defaultNodeValue, validNodeValues: values = [] } = editor.getInjectProps(LineHeightPlugin);\n\n  const value = useSelectionFragmentProp({\n    defaultValue: defaultNodeValue,\n    getProp: (node) => node.lineHeight,\n  });\n\n  const [open, setOpen] = React.useState(false);\n\n  return (\n    <DropdownMenu open={open} onOpenChange={setOpen} modal={false} {...props}>\n      <DropdownMenuTrigger asChild>\n        <ToolbarButton pressed={open} tooltip=\"Line height\" isDropdown>\n          <WrapText />\n        </ToolbarButton>\n      </DropdownMenuTrigger>\n\n      <DropdownMenuContent className=\"min-w-0\" align=\"start\">\n        <DropdownMenuRadioGroup\n          value={value}\n          onValueChange={(newValue) => {\n            editor.getTransforms(LineHeightPlugin).lineHeight.setNodes(Number(newValue));\n            editor.tf.focus();\n          }}\n        >\n          {values.map((value) => (\n            <DropdownMenuRadioItem key={value} className=\"*:first:[span]:hidden min-w-[180px] pl-2\" value={value}>\n              <span className=\"pointer-events-none absolute right-2 flex size-3.5 items-center justify-center\">\n                <DropdownMenuItemIndicator>\n                  <CheckIcon />\n                </DropdownMenuItemIndicator>\n              </span>\n              {value}\n            </DropdownMenuRadioItem>\n          ))}\n        </DropdownMenuRadioGroup>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/link-node-static.tsx",
    "content": "// @ts-nocheck\nimport * as React from \"react\";\n\nimport type { SlateElementProps, TLinkElement } from \"platejs\";\n\nimport { getLinkAttributes } from \"@platejs/link\";\nimport { SlateElement } from \"platejs\";\n\nexport function LinkElementStatic(props: SlateElementProps<TLinkElement>) {\n  return (\n    <SlateElement\n      {...props}\n      as=\"a\"\n      className=\"text-primary decoration-primary font-medium underline underline-offset-4\"\n      attributes={{\n        ...props.attributes,\n        ...getLinkAttributes(props.editor, props.element),\n      }}\n    >\n      {props.children}\n    </SlateElement>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/link-node.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\n\nimport type { TLinkElement } from \"platejs\";\nimport type { PlateElementProps } from \"platejs/react\";\n\nimport { getLinkAttributes } from \"@platejs/link\";\nimport { PlateElement } from \"platejs/react\";\n\nexport function LinkElement(props: PlateElementProps<TLinkElement>) {\n  return (\n    <PlateElement\n      {...props}\n      as=\"a\"\n      className=\"text-primary decoration-primary font-medium underline underline-offset-4\"\n      attributes={{\n        ...props.attributes,\n        ...getLinkAttributes(props.editor, props.element),\n        onMouseOver: (e) => {\n          e.stopPropagation();\n        },\n      }}\n    >\n      {props.children}\n    </PlateElement>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/link-toolbar-button.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { useLinkToolbarButton, useLinkToolbarButtonState } from \"@platejs/link/react\";\nimport { Link } from \"lucide-react\";\n\nimport { ToolbarButton } from \"./toolbar\";\n\nexport function LinkToolbarButton(props: React.ComponentProps<typeof ToolbarButton>) {\n  const state = useLinkToolbarButtonState();\n  const { props: buttonProps } = useLinkToolbarButton(state);\n\n  return (\n    <ToolbarButton {...props} {...buttonProps} data-plate-focus tooltip=\"Link\">\n      <Link />\n    </ToolbarButton>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/link-toolbar.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\n\nimport type { TLinkElement } from \"platejs\";\n\nimport { type UseVirtualFloatingOptions, flip, offset } from \"@platejs/floating\";\nimport { getLinkAttributes } from \"@platejs/link\";\nimport {\n  type LinkFloatingToolbarState,\n  FloatingLinkUrlInput,\n  useFloatingLinkEdit,\n  useFloatingLinkEditState,\n  useFloatingLinkInsert,\n  useFloatingLinkInsertState,\n} from \"@platejs/link/react\";\nimport { cva } from \"class-variance-authority\";\nimport { ExternalLink, Link, Text, Unlink } from \"lucide-react\";\nimport { KEYS } from \"platejs\";\nimport { useEditorRef, useEditorSelection, useFormInputProps, usePluginOption } from \"platejs/react\";\n\nimport { buttonVariants } from \"@/components/ui/button\";\nimport { Separator } from \"@/components/ui/separator\";\n\nconst popoverVariants = cva(\n  \"z-50 w-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-hidden\",\n);\n\nconst inputVariants = cva(\n  \"flex h-[28px] w-full rounded-md border-none bg-transparent px-1.5 py-1 text-base placeholder:text-muted-foreground focus-visible:ring-transparent focus-visible:outline-none md:text-sm\",\n);\n\nexport function LinkFloatingToolbar({ state }: { state?: LinkFloatingToolbarState }) {\n  const activeCommentId = usePluginOption({ key: KEYS.comment }, \"activeId\");\n  const activeSuggestionId = usePluginOption({ key: KEYS.suggestion }, \"activeId\");\n\n  const floatingOptions: UseVirtualFloatingOptions = React.useMemo(() => {\n    return {\n      middleware: [\n        offset(8),\n        flip({\n          fallbackPlacements: [\"bottom-end\", \"top-start\", \"top-end\"],\n          padding: 12,\n        }),\n      ],\n      placement: activeSuggestionId || activeCommentId ? \"top-start\" : \"bottom-start\",\n    };\n  }, [activeCommentId, activeSuggestionId]);\n\n  const insertState = useFloatingLinkInsertState({\n    ...state,\n    floatingOptions: {\n      ...floatingOptions,\n      ...state?.floatingOptions,\n    },\n  });\n  const { hidden, props: insertProps, ref: insertRef, textInputProps } = useFloatingLinkInsert(insertState);\n\n  const editState = useFloatingLinkEditState({\n    ...state,\n    floatingOptions: {\n      ...floatingOptions,\n      ...state?.floatingOptions,\n    },\n  });\n  const { editButtonProps, props: editProps, ref: editRef, unlinkButtonProps } = useFloatingLinkEdit(editState);\n  const inputProps = useFormInputProps({\n    preventDefaultOnEnterKeydown: true,\n  });\n\n  if (hidden) return null;\n\n  const input = (\n    <div className=\"flex w-[330px] flex-col\" {...inputProps}>\n      <div className=\"flex items-center\">\n        <div className=\"text-muted-foreground flex items-center pl-2 pr-1\">\n          <Link className=\"size-4\" />\n        </div>\n\n        <FloatingLinkUrlInput className={inputVariants()} placeholder=\"Paste link\" data-plate-focus />\n      </div>\n      <Separator className=\"my-1\" />\n      <div className=\"flex items-center\">\n        <div className=\"text-muted-foreground flex items-center pl-2 pr-1\">\n          <Text className=\"size-4\" />\n        </div>\n        <input className={inputVariants()} placeholder=\"Text to display\" data-plate-focus {...textInputProps} />\n      </div>\n    </div>\n  );\n\n  const editContent = editState.isEditing ? (\n    input\n  ) : (\n    <div className=\"box-content flex items-center\">\n      <button className={buttonVariants({ size: \"sm\", variant: \"ghost\" })} type=\"button\" {...editButtonProps}>\n        Edit link\n      </button>\n\n      <Separator orientation=\"vertical\" />\n\n      <LinkOpenButton />\n\n      <Separator orientation=\"vertical\" />\n\n      <button\n        className={buttonVariants({\n          size: \"sm\",\n          variant: \"ghost\",\n        })}\n        type=\"button\"\n        {...unlinkButtonProps}\n      >\n        <Unlink width={18} />\n      </button>\n    </div>\n  );\n\n  return (\n    <>\n      <div ref={insertRef} className={popoverVariants()} {...insertProps}>\n        {input}\n      </div>\n\n      <div ref={editRef} className={popoverVariants()} {...editProps}>\n        {editContent}\n      </div>\n    </>\n  );\n}\n\nfunction LinkOpenButton() {\n  const editor = useEditorRef();\n  const selection = useEditorSelection();\n\n  const attributes = React.useMemo(\n    () => {\n      const entry = editor.api.node<TLinkElement>({\n        match: { type: editor.getType(KEYS.link) },\n      });\n      if (!entry) {\n        return {};\n      }\n      const [element] = entry;\n      return getLinkAttributes(editor, element);\n    },\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [editor, selection],\n  );\n\n  return (\n    <a\n      {...attributes}\n      className={buttonVariants({\n        size: \"sm\",\n        variant: \"ghost\",\n      })}\n      onMouseOver={(e) => {\n        e.stopPropagation();\n      }}\n      aria-label=\"Open link in a new tab\"\n      target=\"_blank\"\n      onClick={(e) => {\n        e.stopPropagation();\n        e.preventDefault();\n        if (attributes.href) {\n          open(attributes.href);\n        }\n      }}\n    >\n      <ExternalLink width={18} />\n    </a>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/list-toolbar-button.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { ListStyleType, someList, toggleList } from \"@platejs/list\";\nimport { useIndentTodoToolBarButton, useIndentTodoToolBarButtonState } from \"@platejs/list/react\";\nimport { List, ListOrdered, ListTodoIcon } from \"lucide-react\";\nimport { useEditorRef, useEditorSelector } from \"platejs/react\";\n\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\n\nimport { ToolbarButton, ToolbarSplitButton, ToolbarSplitButtonPrimary, ToolbarSplitButtonSecondary } from \"./toolbar\";\n\nexport function BulletedListToolbarButton() {\n  const editor = useEditorRef();\n  const [open, setOpen] = React.useState(false);\n\n  const pressed = useEditorSelector(\n    (editor) => someList(editor, [ListStyleType.Disc, ListStyleType.Circle, ListStyleType.Square]),\n    [],\n  );\n\n  return (\n    <ToolbarSplitButton pressed={open}>\n      <ToolbarSplitButtonPrimary\n        className=\"data-[state=on]:bg-accent data-[state=on]:text-accent-foreground\"\n        onClick={() => {\n          toggleList(editor, {\n            listStyleType: ListStyleType.Disc,\n          });\n        }}\n        data-state={pressed ? \"on\" : \"off\"}\n      >\n        <List className=\"size-4\" />\n      </ToolbarSplitButtonPrimary>\n\n      <DropdownMenu open={open} onOpenChange={setOpen} modal={false}>\n        <DropdownMenuTrigger asChild>\n          <ToolbarSplitButtonSecondary />\n        </DropdownMenuTrigger>\n\n        <DropdownMenuContent align=\"start\" alignOffset={-32}>\n          <DropdownMenuGroup>\n            <DropdownMenuItem\n              onClick={() =>\n                toggleList(editor, {\n                  listStyleType: ListStyleType.Disc,\n                })\n              }\n            >\n              <div className=\"flex items-center gap-2\">\n                <div className=\"size-2 rounded-full border border-current bg-current\" />\n                Default\n              </div>\n            </DropdownMenuItem>\n            <DropdownMenuItem\n              onClick={() =>\n                toggleList(editor, {\n                  listStyleType: ListStyleType.Circle,\n                })\n              }\n            >\n              <div className=\"flex items-center gap-2\">\n                <div className=\"size-2 rounded-full border border-current\" />\n                Circle\n              </div>\n            </DropdownMenuItem>\n            <DropdownMenuItem\n              onClick={() =>\n                toggleList(editor, {\n                  listStyleType: ListStyleType.Square,\n                })\n              }\n            >\n              <div className=\"flex items-center gap-2\">\n                <div className=\"size-2 border border-current bg-current\" />\n                Square\n              </div>\n            </DropdownMenuItem>\n          </DropdownMenuGroup>\n        </DropdownMenuContent>\n      </DropdownMenu>\n    </ToolbarSplitButton>\n  );\n}\n\nexport function NumberedListToolbarButton() {\n  const editor = useEditorRef();\n  const [open, setOpen] = React.useState(false);\n\n  const pressed = useEditorSelector(\n    (editor) =>\n      someList(editor, [\n        ListStyleType.Decimal,\n        ListStyleType.LowerAlpha,\n        ListStyleType.UpperAlpha,\n        ListStyleType.LowerRoman,\n        ListStyleType.UpperRoman,\n      ]),\n    [],\n  );\n\n  return (\n    <ToolbarSplitButton pressed={open}>\n      <ToolbarSplitButtonPrimary\n        className=\"data-[state=on]:bg-accent data-[state=on]:text-accent-foreground\"\n        onClick={() =>\n          toggleList(editor, {\n            listStyleType: ListStyleType.Decimal,\n          })\n        }\n        data-state={pressed ? \"on\" : \"off\"}\n      >\n        <ListOrdered className=\"size-4\" />\n      </ToolbarSplitButtonPrimary>\n\n      <DropdownMenu open={open} onOpenChange={setOpen} modal={false}>\n        <DropdownMenuTrigger asChild>\n          <ToolbarSplitButtonSecondary />\n        </DropdownMenuTrigger>\n\n        <DropdownMenuContent align=\"start\" alignOffset={-32}>\n          <DropdownMenuGroup>\n            <DropdownMenuItem\n              onSelect={() =>\n                toggleList(editor, {\n                  listStyleType: ListStyleType.Decimal,\n                })\n              }\n            >\n              Decimal (1, 2, 3)\n            </DropdownMenuItem>\n            <DropdownMenuItem\n              onSelect={() =>\n                toggleList(editor, {\n                  listStyleType: ListStyleType.LowerAlpha,\n                })\n              }\n            >\n              Lower Alpha (a, b, c)\n            </DropdownMenuItem>\n            <DropdownMenuItem\n              onSelect={() =>\n                toggleList(editor, {\n                  listStyleType: ListStyleType.UpperAlpha,\n                })\n              }\n            >\n              Upper Alpha (A, B, C)\n            </DropdownMenuItem>\n            <DropdownMenuItem\n              onSelect={() =>\n                toggleList(editor, {\n                  listStyleType: ListStyleType.LowerRoman,\n                })\n              }\n            >\n              Lower Roman (i, ii, iii)\n            </DropdownMenuItem>\n            <DropdownMenuItem\n              onSelect={() =>\n                toggleList(editor, {\n                  listStyleType: ListStyleType.UpperRoman,\n                })\n              }\n            >\n              Upper Roman (I, II, III)\n            </DropdownMenuItem>\n          </DropdownMenuGroup>\n        </DropdownMenuContent>\n      </DropdownMenu>\n    </ToolbarSplitButton>\n  );\n}\n\nexport function TodoListToolbarButton(props: React.ComponentProps<typeof ToolbarButton>) {\n  const state = useIndentTodoToolBarButtonState({ nodeType: \"todo\" });\n  const { props: buttonProps } = useIndentTodoToolBarButton(state);\n\n  return (\n    <ToolbarButton {...props} {...buttonProps} tooltip=\"Todo\">\n      <ListTodoIcon />\n    </ToolbarButton>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/mark-toolbar-button.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { useMarkToolbarButton, useMarkToolbarButtonState } from \"platejs/react\";\n\nimport { ToolbarButton } from \"./toolbar\";\n\nexport function MarkToolbarButton({\n  clear,\n  nodeType,\n  ...props\n}: React.ComponentProps<typeof ToolbarButton> & {\n  nodeType: string;\n  clear?: string[] | string;\n}) {\n  const state = useMarkToolbarButtonState({ clear, nodeType });\n  const { props: buttonProps } = useMarkToolbarButton(state);\n\n  return <ToolbarButton {...props} {...buttonProps} />;\n}\n"
  },
  {
    "path": "app/src/components/ui/markdown.tsx",
    "content": "import \"@/css/markdown.css\";\nimport { cn } from \"@/utils/cn\";\nimport { useEffect, useState } from \"react\";\nimport production from \"react/jsx-runtime\";\nimport rehypeReact from \"rehype-react\";\nimport remarkBreaks from \"remark-breaks\";\nimport remarkGfm from \"remark-gfm\";\nimport remarkParse from \"remark-parse\";\nimport remarkRehype from \"remark-rehype\";\nimport { unified } from \"unified\";\n\nexport default function Markdown({ source, className = \"\" }: { source: string; className?: string }) {\n  const [content, setContent] = useState(<>loading</>);\n\n  useEffect(() => {\n    const processor = unified()\n      .use(remarkParse)\n      .use(remarkGfm)\n      .use(remarkBreaks)\n      .use(remarkRehype)\n      .use(rehypeReact, production);\n    processor.process(source).then((data: any) => {\n      setContent(data.result);\n    });\n  }, [source]);\n\n  return <div className={cn(className, \"markdown-body\")}>{content}</div>;\n}\n"
  },
  {
    "path": "app/src/components/ui/media-audio-node-static.tsx",
    "content": "// @ts-nocheck\nimport * as React from \"react\";\n\nimport type { SlateElementProps, TAudioElement } from \"platejs\";\n\nimport { SlateElement } from \"platejs\";\n\nexport function AudioElementStatic(props: SlateElementProps<TAudioElement>) {\n  return (\n    <SlateElement {...props} className=\"mb-1\">\n      <figure className=\"group relative cursor-default\">\n        <div className=\"h-16\">\n          <audio className=\"size-full\" src={props.element.url} controls />\n        </div>\n      </figure>\n      {props.children}\n    </SlateElement>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/media-audio-node.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\n\nimport type { TAudioElement } from \"platejs\";\nimport type { PlateElementProps } from \"platejs/react\";\n\nimport { useMediaState } from \"@platejs/media/react\";\nimport { ResizableProvider } from \"@platejs/resizable\";\nimport { PlateElement, withHOC } from \"platejs/react\";\n\nimport { Caption, CaptionTextarea } from \"./caption\";\n\nexport const AudioElement = withHOC(ResizableProvider, function AudioElement(props: PlateElementProps<TAudioElement>) {\n  const { align = \"center\", readOnly, unsafeUrl } = useMediaState();\n\n  return (\n    <PlateElement {...props} className=\"mb-1\">\n      <figure className=\"group relative cursor-default\" contentEditable={false}>\n        <div className=\"h-16\">\n          <audio className=\"size-full\" src={unsafeUrl} controls />\n        </div>\n\n        <Caption style={{ width: \"100%\" }} align={align}>\n          <CaptionTextarea className=\"h-20\" readOnly={readOnly} placeholder=\"Write a caption...\" />\n        </Caption>\n      </figure>\n      {props.children}\n    </PlateElement>\n  );\n});\n"
  },
  {
    "path": "app/src/components/ui/media-file-node-static.tsx",
    "content": "// @ts-nocheck\nimport * as React from \"react\";\n\nimport type { SlateElementProps, TFileElement } from \"platejs\";\n\nimport { FileUp } from \"lucide-react\";\nimport { SlateElement } from \"platejs\";\n\nexport function FileElementStatic(props: SlateElementProps<TFileElement>) {\n  const { name, url } = props.element;\n\n  return (\n    <SlateElement className=\"my-px rounded-sm\" {...props}>\n      <a\n        className=\"hover:bg-muted group relative m-0 flex cursor-pointer items-center rounded px-0.5 py-[3px]\"\n        contentEditable={false}\n        download={name}\n        href={url}\n        rel=\"noopener noreferrer\"\n        role=\"button\"\n        target=\"_blank\"\n      >\n        <div className=\"flex items-center gap-1 p-1\">\n          <FileUp className=\"size-5\" />\n          <div>{name}</div>\n        </div>\n      </a>\n      {props.children}\n    </SlateElement>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/media-file-node.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\n\nimport type { TFileElement } from \"platejs\";\nimport type { PlateElementProps } from \"platejs/react\";\n\nimport { useMediaState } from \"@platejs/media/react\";\nimport { ResizableProvider } from \"@platejs/resizable\";\nimport { FileUp } from \"lucide-react\";\nimport { PlateElement, useReadOnly, withHOC } from \"platejs/react\";\n\nimport { Caption, CaptionTextarea } from \"./caption\";\n\nexport const FileElement = withHOC(ResizableProvider, function FileElement(props: PlateElementProps<TFileElement>) {\n  const readOnly = useReadOnly();\n  const { name, unsafeUrl } = useMediaState();\n\n  return (\n    <PlateElement className=\"my-px rounded-sm\" {...props}>\n      <a\n        className=\"hover:bg-muted group relative m-0 flex cursor-pointer items-center rounded px-0.5 py-[3px]\"\n        contentEditable={false}\n        download={name}\n        href={unsafeUrl}\n        rel=\"noopener noreferrer\"\n        role=\"button\"\n        target=\"_blank\"\n      >\n        <div className=\"flex items-center gap-1 p-1\">\n          <FileUp className=\"size-5\" />\n          <div>{name}</div>\n        </div>\n\n        <Caption align=\"left\">\n          <CaptionTextarea className=\"text-left\" readOnly={readOnly} placeholder=\"Write a caption...\" />\n        </Caption>\n      </a>\n      {props.children}\n    </PlateElement>\n  );\n});\n"
  },
  {
    "path": "app/src/components/ui/media-image-node-static.tsx",
    "content": "// @ts-nocheck\nimport * as React from \"react\";\n\nimport type { SlateElementProps, TCaptionProps, TImageElement, TResizableProps } from \"platejs\";\n\nimport { NodeApi, SlateElement } from \"platejs\";\n\nimport { cn } from \"@/utils/cn\";\n\nexport function ImageElementStatic(props: SlateElementProps<TImageElement & TCaptionProps & TResizableProps>) {\n  const { align = \"center\", caption, url, width } = props.element;\n\n  return (\n    <SlateElement {...props} className=\"py-2.5\">\n      <figure className=\"group relative m-0 inline-block\" style={{ width }}>\n        <div className=\"relative min-w-[92px] max-w-full\" style={{ textAlign: align }}>\n          <img\n            className={cn(\"w-full max-w-full cursor-default object-cover px-0\", \"rounded-sm\")}\n            alt={(props.attributes as any).alt}\n            src={url}\n          />\n          {caption && (\n            <figcaption className=\"mx-auto mt-2 h-[24px] max-w-full\">{NodeApi.string(caption[0])}</figcaption>\n          )}\n        </div>\n      </figure>\n      {props.children}\n    </SlateElement>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/media-image-node.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\n\nimport type { TImageElement } from \"platejs\";\nimport type { PlateElementProps } from \"platejs/react\";\n\nimport { useDraggable } from \"@platejs/dnd\";\nimport { Image, ImagePlugin, useMediaState } from \"@platejs/media/react\";\nimport { ResizableProvider, useResizableValue } from \"@platejs/resizable\";\nimport { PlateElement, withHOC } from \"platejs/react\";\n\nimport { cn } from \"@/utils/cn\";\n\nimport { Caption, CaptionTextarea } from \"./caption\";\nimport { MediaToolbar } from \"./media-toolbar\";\nimport { mediaResizeHandleVariants, Resizable, ResizeHandle } from \"./resize-handle\";\n\nexport const ImageElement = withHOC(ResizableProvider, function ImageElement(props: PlateElementProps<TImageElement>) {\n  const { align = \"center\", focused, readOnly, selected } = useMediaState();\n  const width = useResizableValue(\"width\");\n\n  const { isDragging, handleRef } = useDraggable({\n    element: props.element,\n  });\n\n  return (\n    <MediaToolbar plugin={ImagePlugin}>\n      <PlateElement {...props} className=\"py-2.5\">\n        <figure className=\"group relative m-0\" contentEditable={false}>\n          <Resizable\n            align={align}\n            options={{\n              align,\n              readOnly,\n            }}\n          >\n            <ResizeHandle\n              className={mediaResizeHandleVariants({ direction: \"left\" })}\n              options={{ direction: \"left\" }}\n            />\n            <Image\n              ref={handleRef}\n              className={cn(\n                \"block w-full max-w-full cursor-pointer object-cover px-0\",\n                \"rounded-sm\",\n                focused && selected && \"ring-ring ring-2 ring-offset-2\",\n                isDragging && \"opacity-50\",\n              )}\n              alt={props.attributes.alt as string | undefined}\n            />\n            <ResizeHandle\n              className={mediaResizeHandleVariants({\n                direction: \"right\",\n              })}\n              options={{ direction: \"right\" }}\n            />\n          </Resizable>\n\n          <Caption style={{ width }} align={align}>\n            <CaptionTextarea\n              readOnly={readOnly}\n              onFocus={(e) => {\n                e.preventDefault();\n              }}\n              placeholder=\"Write a caption...\"\n            />\n          </Caption>\n        </figure>\n\n        {props.children}\n      </PlateElement>\n    </MediaToolbar>\n  );\n});\n"
  },
  {
    "path": "app/src/components/ui/media-toolbar-button.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\n\nimport type { DropdownMenuProps } from \"@radix-ui/react-dropdown-menu\";\n\nimport { PlaceholderPlugin } from \"@platejs/media/react\";\nimport { AudioLinesIcon, FileUpIcon, FilmIcon, ImageIcon, LinkIcon } from \"lucide-react\";\nimport { isUrl, KEYS } from \"platejs\";\nimport { useEditorRef } from \"platejs/react\";\nimport { toast } from \"sonner\";\nimport { useFilePicker } from \"use-file-picker\";\n\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Input } from \"@/components/ui/input\";\n\nimport { ToolbarSplitButton, ToolbarSplitButtonPrimary, ToolbarSplitButtonSecondary } from \"./toolbar\";\n\nconst MEDIA_CONFIG: Record<\n  string,\n  {\n    accept: string[];\n    icon: React.ReactNode;\n    title: string;\n    tooltip: string;\n  }\n> = {\n  [KEYS.audio]: {\n    accept: [\"audio/*\"],\n    icon: <AudioLinesIcon className=\"size-4\" />,\n    title: \"Insert Audio\",\n    tooltip: \"Audio\",\n  },\n  [KEYS.file]: {\n    accept: [\"*\"],\n    icon: <FileUpIcon className=\"size-4\" />,\n    title: \"Insert File\",\n    tooltip: \"File\",\n  },\n  [KEYS.img]: {\n    accept: [\"image/*\"],\n    icon: <ImageIcon className=\"size-4\" />,\n    title: \"Insert Image\",\n    tooltip: \"Image\",\n  },\n  [KEYS.video]: {\n    accept: [\"video/*\"],\n    icon: <FilmIcon className=\"size-4\" />,\n    title: \"Insert Video\",\n    tooltip: \"Video\",\n  },\n};\n\nexport function MediaToolbarButton({ nodeType, ...props }: DropdownMenuProps & { nodeType: string }) {\n  const currentConfig = MEDIA_CONFIG[nodeType];\n\n  const editor = useEditorRef();\n  const [open, setOpen] = React.useState(false);\n  const [dialogOpen, setDialogOpen] = React.useState(false);\n\n  const { openFilePicker } = useFilePicker({\n    accept: currentConfig.accept,\n    multiple: true,\n    onFilesSelected: ({ plainFiles: updatedFiles }) => {\n      editor.getTransforms(PlaceholderPlugin).insert.media(updatedFiles);\n    },\n  });\n\n  return (\n    <>\n      <ToolbarSplitButton\n        onClick={() => {\n          openFilePicker();\n        }}\n        onKeyDown={(e) => {\n          if (e.key === \"ArrowDown\") {\n            e.preventDefault();\n            setOpen(true);\n          }\n        }}\n        pressed={open}\n      >\n        <ToolbarSplitButtonPrimary>{currentConfig.icon}</ToolbarSplitButtonPrimary>\n\n        <DropdownMenu open={open} onOpenChange={setOpen} modal={false} {...props}>\n          <DropdownMenuTrigger asChild>\n            <ToolbarSplitButtonSecondary />\n          </DropdownMenuTrigger>\n\n          <DropdownMenuContent onClick={(e) => e.stopPropagation()} align=\"start\" alignOffset={-32}>\n            <DropdownMenuGroup>\n              <DropdownMenuItem onSelect={() => openFilePicker()}>\n                {currentConfig.icon}\n                Upload from computer\n              </DropdownMenuItem>\n              <DropdownMenuItem onSelect={() => setDialogOpen(true)}>\n                <LinkIcon />\n                Insert via URL\n              </DropdownMenuItem>\n            </DropdownMenuGroup>\n          </DropdownMenuContent>\n        </DropdownMenu>\n      </ToolbarSplitButton>\n\n      <AlertDialog\n        open={dialogOpen}\n        onOpenChange={(value) => {\n          setDialogOpen(value);\n        }}\n      >\n        <AlertDialogContent className=\"gap-6\">\n          <MediaUrlDialogContent currentConfig={currentConfig} nodeType={nodeType} setOpen={setDialogOpen} />\n        </AlertDialogContent>\n      </AlertDialog>\n    </>\n  );\n}\n\nfunction MediaUrlDialogContent({\n  currentConfig,\n  nodeType,\n  setOpen,\n}: {\n  currentConfig: (typeof MEDIA_CONFIG)[string];\n  nodeType: string;\n  setOpen: (value: boolean) => void;\n}) {\n  const editor = useEditorRef();\n  const [url, setUrl] = React.useState(\"\");\n\n  const embedMedia = React.useCallback(() => {\n    if (!isUrl(url)) return toast.error(\"Invalid URL\");\n\n    setOpen(false);\n    editor.tf.insertNodes({\n      children: [{ text: \"\" }],\n      name: nodeType === KEYS.file ? url.split(\"/\").pop() : undefined,\n      type: nodeType,\n      url,\n    });\n  }, [url, editor, nodeType, setOpen]);\n\n  return (\n    <>\n      <AlertDialogHeader>\n        <AlertDialogTitle>{currentConfig.title}</AlertDialogTitle>\n      </AlertDialogHeader>\n\n      <AlertDialogDescription className=\"group relative w-full\">\n        <label\n          className=\"text-muted-foreground/70 group-focus-within:text-foreground has-[+input:not(:placeholder-shown)]:text-foreground absolute top-1/2 block -translate-y-1/2 cursor-text px-1 text-sm transition-all group-focus-within:pointer-events-none group-focus-within:top-0 group-focus-within:cursor-default group-focus-within:text-xs group-focus-within:font-medium has-[+input:not(:placeholder-shown)]:pointer-events-none has-[+input:not(:placeholder-shown)]:top-0 has-[+input:not(:placeholder-shown)]:cursor-default has-[+input:not(:placeholder-shown)]:text-xs has-[+input:not(:placeholder-shown)]:font-medium\"\n          htmlFor=\"url\"\n        >\n          <span className=\"bg-background inline-flex px-2\">URL</span>\n        </label>\n        <Input\n          id=\"url\"\n          className=\"w-full\"\n          value={url}\n          onChange={(e) => setUrl(e.target.value)}\n          onKeyDown={(e) => {\n            if (e.key === \"Enter\") embedMedia();\n          }}\n          placeholder=\"\"\n          type=\"url\"\n          autoFocus\n        />\n      </AlertDialogDescription>\n\n      <AlertDialogFooter>\n        <AlertDialogCancel>Cancel</AlertDialogCancel>\n        <AlertDialogAction\n          onClick={(e) => {\n            e.preventDefault();\n            embedMedia();\n          }}\n        >\n          Accept\n        </AlertDialogAction>\n      </AlertDialogFooter>\n    </>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/media-toolbar.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\n\nimport type { WithRequiredKey } from \"platejs\";\n\nimport {\n  FloatingMedia as FloatingMediaPrimitive,\n  FloatingMediaStore,\n  useFloatingMediaValue,\n  useImagePreviewValue,\n} from \"@platejs/media/react\";\nimport { cva } from \"class-variance-authority\";\nimport { Link, Trash2Icon } from \"lucide-react\";\nimport {\n  useEditorRef,\n  useEditorSelector,\n  useElement,\n  useFocusedLast,\n  useReadOnly,\n  useRemoveNodeButton,\n  useSelected,\n} from \"platejs/react\";\n\nimport { Button, buttonVariants } from \"@/components/ui/button\";\nimport { Popover, PopoverAnchor, PopoverContent } from \"@/components/ui/popover\";\nimport { Separator } from \"@/components/ui/separator\";\n\nimport { CaptionButton } from \"./caption\";\n\nconst inputVariants = cva(\n  \"flex h-[28px] w-full rounded-md border-none bg-transparent px-1.5 py-1 text-base placeholder:text-muted-foreground focus-visible:ring-transparent focus-visible:outline-none md:text-sm\",\n);\n\nexport function MediaToolbar({ children, plugin }: { children: React.ReactNode; plugin: WithRequiredKey }) {\n  const editor = useEditorRef();\n  const readOnly = useReadOnly();\n  const selected = useSelected();\n  const isFocusedLast = useFocusedLast();\n  const selectionCollapsed = useEditorSelector((editor) => !editor.api.isExpanded(), []);\n  const isImagePreviewOpen = useImagePreviewValue(\"isOpen\", editor.id);\n  const open = isFocusedLast && !readOnly && selected && selectionCollapsed && !isImagePreviewOpen;\n  const isEditing = useFloatingMediaValue(\"isEditing\");\n\n  React.useEffect(() => {\n    if (!open && isEditing) {\n      FloatingMediaStore.set(\"isEditing\", false);\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [open]);\n\n  const element = useElement();\n  const { props: buttonProps } = useRemoveNodeButton({ element });\n\n  return (\n    <Popover open={open} modal={false}>\n      <PopoverAnchor>{children}</PopoverAnchor>\n\n      <PopoverContent className=\"w-auto p-1\" onOpenAutoFocus={(e) => e.preventDefault()}>\n        {isEditing ? (\n          <div className=\"flex w-[330px] flex-col\">\n            <div className=\"flex items-center\">\n              <div className=\"text-muted-foreground flex items-center pl-2 pr-1\">\n                <Link className=\"size-4\" />\n              </div>\n\n              <FloatingMediaPrimitive.UrlInput\n                className={inputVariants()}\n                placeholder=\"Paste the embed link...\"\n                options={{ plugin }}\n              />\n            </div>\n          </div>\n        ) : (\n          <div className=\"box-content flex items-center\">\n            <FloatingMediaPrimitive.EditButton className={buttonVariants({ size: \"sm\", variant: \"ghost\" })}>\n              Edit link\n            </FloatingMediaPrimitive.EditButton>\n\n            <CaptionButton size=\"sm\" variant=\"ghost\">\n              Caption\n            </CaptionButton>\n\n            <Separator orientation=\"vertical\" className=\"mx-1 h-6\" />\n\n            <Button size=\"sm\" variant=\"ghost\" {...buttonProps}>\n              <Trash2Icon />\n            </Button>\n          </div>\n        )}\n      </PopoverContent>\n    </Popover>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/media-video-node-static.tsx",
    "content": "// @ts-nocheck\nimport * as React from \"react\";\n\nimport type { SlateElementProps, TCaptionElement, TResizableProps, TVideoElement } from \"platejs\";\n\nimport { NodeApi, SlateElement } from \"platejs\";\n\nexport function VideoElementStatic(props: SlateElementProps<TVideoElement & TCaptionElement & TResizableProps>) {\n  const { align = \"center\", caption, url, width } = props.element;\n\n  return (\n    <SlateElement className=\"py-2.5\" {...props}>\n      <div style={{ textAlign: align }}>\n        <figure className=\"group relative m-0 inline-block cursor-default\" style={{ width }}>\n          <video className=\"w-full max-w-full rounded-sm object-cover px-0\" src={url} controls />\n          {caption && <figcaption>{NodeApi.string(caption[0])}</figcaption>}\n        </figure>\n      </div>\n      {props.children}\n    </SlateElement>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/media-video-node.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\nimport LiteYouTubeEmbed from \"react-lite-youtube-embed\";\nimport ReactPlayer from \"react-player\";\n\nimport type { TResizableProps, TVideoElement } from \"platejs\";\nimport type { PlateElementProps } from \"platejs/react\";\n\nimport { useDraggable } from \"@platejs/dnd\";\nimport { parseTwitterUrl, parseVideoUrl } from \"@platejs/media\";\nimport { useMediaState } from \"@platejs/media/react\";\nimport { ResizableProvider, useResizableValue } from \"@platejs/resizable\";\nimport { PlateElement, useEditorMounted, withHOC } from \"platejs/react\";\n\nimport { cn } from \"@/utils/cn\";\n\nimport { Caption, CaptionTextarea } from \"./caption\";\nimport { mediaResizeHandleVariants, Resizable, ResizeHandle } from \"./resize-handle\";\n\nexport const VideoElement = withHOC(\n  ResizableProvider,\n  function VideoElement(props: PlateElementProps<TVideoElement & TResizableProps>) {\n    const {\n      align = \"center\",\n      embed,\n      isUpload,\n      isYoutube,\n      readOnly,\n      unsafeUrl,\n    } = useMediaState({\n      urlParsers: [parseTwitterUrl, parseVideoUrl],\n    });\n    const width = useResizableValue(\"width\");\n\n    const isEditorMounted = useEditorMounted();\n\n    const isTweet = true;\n\n    const { isDragging, handleRef } = useDraggable({\n      element: props.element,\n    });\n\n    return (\n      <PlateElement className=\"py-2.5\" {...props}>\n        <figure className=\"relative m-0 cursor-default\" contentEditable={false}>\n          <Resizable\n            className={cn(isDragging && \"opacity-50\")}\n            align={align}\n            options={{\n              align,\n              maxWidth: isTweet ? 550 : \"100%\",\n              minWidth: isTweet ? 300 : 100,\n              readOnly,\n            }}\n          >\n            <div className=\"group/media\">\n              <ResizeHandle\n                className={mediaResizeHandleVariants({ direction: \"left\" })}\n                options={{ direction: \"left\" }}\n              />\n\n              <ResizeHandle\n                className={mediaResizeHandleVariants({ direction: \"right\" })}\n                options={{ direction: \"right\" }}\n              />\n\n              {!isUpload && isYoutube && (\n                <div ref={handleRef}>\n                  <LiteYouTubeEmbed\n                    id={embed!.id!}\n                    title=\"youtube\"\n                    wrapperClass={cn(\n                      \"aspect-video rounded-sm\",\n                      \"relative block cursor-pointer bg-black bg-cover bg-center [contain:content]\",\n                      \"[&.lyt-activated]:before:absolute [&.lyt-activated]:before:top-0 [&.lyt-activated]:before:h-[60px] [&.lyt-activated]:before:w-full [&.lyt-activated]:before:bg-top [&.lyt-activated]:before:bg-repeat-x [&.lyt-activated]:before:pb-[50px] [&.lyt-activated]:before:[transition:all_0.2s_cubic-bezier(0,_0,_0.2,_1)]\",\n                      \"[&.lyt-activated]:before:bg-[url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAADGCAYAAAAT+OqFAAAAdklEQVQoz42QQQ7AIAgEF/T/D+kbq/RWAlnQyyazA4aoAB4FsBSA/bFjuF1EOL7VbrIrBuusmrt4ZZORfb6ehbWdnRHEIiITaEUKa5EJqUakRSaEYBJSCY2dEstQY7AuxahwXFrvZmWl2rh4JZ07z9dLtesfNj5q0FU3A5ObbwAAAABJRU5ErkJggg==)]\",\n                      'after:block after:pb-[var(--aspect-ratio)] after:content-[\"\"]',\n                      \"[&_>_iframe]:absolute [&_>_iframe]:top-0 [&_>_iframe]:left-0 [&_>_iframe]:size-full\",\n                      \"[&_>_.lty-playbtn]:z-1 [&_>_.lty-playbtn]:h-[46px] [&_>_.lty-playbtn]:w-[70px] [&_>_.lty-playbtn]:rounded-[14%] [&_>_.lty-playbtn]:bg-[#212121] [&_>_.lty-playbtn]:opacity-80 [&_>_.lty-playbtn]:[transition:all_0.2s_cubic-bezier(0,_0,_0.2,_1)]\",\n                      \"[&:hover_>_.lty-playbtn]:bg-[red] [&:hover_>_.lty-playbtn]:opacity-100\",\n                      '[&_>_.lty-playbtn]:before:border-y-[11px] [&_>_.lty-playbtn]:before:border-r-0 [&_>_.lty-playbtn]:before:border-l-[19px] [&_>_.lty-playbtn]:before:border-[transparent_transparent_transparent_#fff] [&_>_.lty-playbtn]:before:content-[\"\"]',\n                      \"[&_>_.lty-playbtn]:absolute [&_>_.lty-playbtn]:top-1/2 [&_>_.lty-playbtn]:left-1/2 [&_>_.lty-playbtn]:[transform:translate3d(-50%,-50%,0)]\",\n                      \"[&_>_.lty-playbtn]:before:absolute [&_>_.lty-playbtn]:before:top-1/2 [&_>_.lty-playbtn]:before:left-1/2 [&_>_.lty-playbtn]:before:[transform:translate3d(-50%,-50%,0)]\",\n                      \"[&.lyt-activated]:cursor-[unset]\",\n                      \"[&.lyt-activated]:before:pointer-events-none [&.lyt-activated]:before:opacity-0\",\n                      \"[&.lyt-activated_>_.lty-playbtn]:pointer-events-none [&.lyt-activated_>_.lty-playbtn]:opacity-0!\",\n                    )}\n                  />\n                </div>\n              )}\n\n              {isUpload && isEditorMounted && (\n                <div ref={handleRef}>\n                  <ReactPlayer height=\"100%\" src={unsafeUrl} width=\"100%\" controls />\n                </div>\n              )}\n            </div>\n          </Resizable>\n\n          <Caption style={{ width }} align={align}>\n            <CaptionTextarea readOnly={readOnly} placeholder=\"Write a caption...\" />\n          </Caption>\n        </figure>\n        {props.children}\n      </PlateElement>\n    );\n  },\n);\n"
  },
  {
    "path": "app/src/components/ui/mention-node-static.tsx",
    "content": "import * as React from \"react\";\n\nimport type { SlateElementProps, TMentionElement } from \"platejs\";\n\nimport { KEYS, SlateElement } from \"platejs\";\n\nimport { cn } from \"@/utils/cn\";\n\nexport function MentionElementStatic(\n  props: SlateElementProps<TMentionElement> & {\n    prefix?: string;\n  },\n) {\n  const { prefix } = props;\n  const element = props.element;\n\n  return (\n    <SlateElement\n      {...props}\n      className={cn(\n        \"bg-muted inline-block rounded-md px-1.5 py-0.5 align-baseline text-sm font-medium\",\n        element.children[0][KEYS.bold] === true && \"font-bold\",\n        element.children[0][KEYS.italic] === true && \"italic\",\n        element.children[0][KEYS.underline] === true && \"underline\",\n      )}\n      attributes={{\n        ...props.attributes,\n        \"data-slate-value\": element.value,\n      }}\n    >\n      <React.Fragment>\n        {props.children}\n        {prefix}\n        {element.value}\n      </React.Fragment>\n    </SlateElement>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/mention-node.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport type { TComboboxInputElement, TMentionElement } from \"platejs\";\nimport type { PlateElementProps } from \"platejs/react\";\n\nimport { getMentionOnSelectItem } from \"@platejs/mention\";\nimport { IS_APPLE, KEYS } from \"platejs\";\nimport { PlateElement, useFocused, useReadOnly, useSelected } from \"platejs/react\";\n\nimport { cn } from \"@/utils/cn\";\nimport { useMounted } from \"@/hooks/use-mounted\";\n\nimport {\n  InlineCombobox,\n  InlineComboboxContent,\n  InlineComboboxEmpty,\n  InlineComboboxGroup,\n  InlineComboboxInput,\n  InlineComboboxItem,\n} from \"./inline-combobox\";\n\nexport function MentionElement(\n  props: PlateElementProps<TMentionElement> & {\n    prefix?: string;\n  },\n) {\n  const element = props.element;\n\n  const selected = useSelected();\n  const focused = useFocused();\n  const mounted = useMounted();\n  const readOnly = useReadOnly();\n\n  return (\n    <PlateElement\n      {...props}\n      className={cn(\n        \"bg-muted inline-block rounded-md px-1.5 py-0.5 align-baseline text-sm font-medium\",\n        !readOnly && \"cursor-pointer\",\n        selected && focused && \"ring-ring ring-2\",\n        element.children[0][KEYS.bold] === true && \"font-bold\",\n        element.children[0][KEYS.italic] === true && \"italic\",\n        element.children[0][KEYS.underline] === true && \"underline\",\n      )}\n      attributes={{\n        ...props.attributes,\n        contentEditable: false,\n        \"data-slate-value\": element.value,\n        draggable: true,\n      }}\n    >\n      {mounted && IS_APPLE ? (\n        // Mac OS IME https://github.com/ianstormtaylor/slate/issues/3490\n        <React.Fragment>\n          {props.children}\n          {props.prefix}\n          {element.value}\n        </React.Fragment>\n      ) : (\n        // Others like Android https://github.com/ianstormtaylor/slate/pull/5360\n        <React.Fragment>\n          {props.prefix}\n          {element.value}\n          {props.children}\n        </React.Fragment>\n      )}\n    </PlateElement>\n  );\n}\n\nconst onSelectItem = getMentionOnSelectItem();\n\nexport function MentionInputElement(props: PlateElementProps<TComboboxInputElement>) {\n  const { editor, element } = props;\n  const [search, setSearch] = React.useState(\"\");\n\n  return (\n    <PlateElement {...props} as=\"span\">\n      <InlineCombobox value={search} element={element} setValue={setSearch} showTrigger={false} trigger=\"@\">\n        <span className=\"bg-muted ring-ring inline-block rounded-md px-1.5 py-0.5 align-baseline text-sm focus-within:ring-2\">\n          <InlineComboboxInput />\n        </span>\n\n        <InlineComboboxContent className=\"my-1.5\">\n          <InlineComboboxEmpty>No results</InlineComboboxEmpty>\n\n          <InlineComboboxGroup>\n            {MENTIONABLES.map((item) => (\n              <InlineComboboxItem key={item.key} value={item.text} onClick={() => onSelectItem(editor, item, search)}>\n                {item.text}\n              </InlineComboboxItem>\n            ))}\n          </InlineComboboxGroup>\n        </InlineComboboxContent>\n      </InlineCombobox>\n\n      {props.children}\n    </PlateElement>\n  );\n}\n\nconst MENTIONABLES = [\n  { key: \"0\", text: \"Aayla Secura\" },\n  { key: \"1\", text: \"Adi Gallia\" },\n  {\n    key: \"2\",\n    text: \"Admiral Dodd Rancit\",\n  },\n  {\n    key: \"3\",\n    text: \"Admiral Firmus Piett\",\n  },\n  {\n    key: \"4\",\n    text: \"Admiral Gial Ackbar\",\n  },\n  { key: \"5\", text: \"Admiral Ozzel\" },\n  { key: \"6\", text: \"Admiral Raddus\" },\n  {\n    key: \"7\",\n    text: \"Admiral Terrinald Screed\",\n  },\n  { key: \"8\", text: \"Admiral Trench\" },\n  {\n    key: \"9\",\n    text: \"Admiral U.O. Statura\",\n  },\n  { key: \"10\", text: \"Agen Kolar\" },\n  { key: \"11\", text: \"Agent Kallus\" },\n  {\n    key: \"12\",\n    text: \"Aiolin and Morit Astarte\",\n  },\n  { key: \"13\", text: \"Aks Moe\" },\n  { key: \"14\", text: \"Almec\" },\n  { key: \"15\", text: \"Alton Kastle\" },\n  { key: \"16\", text: \"Amee\" },\n  { key: \"17\", text: \"AP-5\" },\n  { key: \"18\", text: \"Armitage Hux\" },\n  { key: \"19\", text: \"Artoo\" },\n  { key: \"20\", text: \"Arvel Crynyd\" },\n  { key: \"21\", text: \"Asajj Ventress\" },\n  { key: \"22\", text: \"Aurra Sing\" },\n  { key: \"23\", text: \"AZI-3\" },\n  { key: \"24\", text: \"Bala-Tik\" },\n  { key: \"25\", text: \"Barada\" },\n  { key: \"26\", text: \"Bargwill Tomder\" },\n  { key: \"27\", text: \"Baron Papanoida\" },\n  { key: \"28\", text: \"Barriss Offee\" },\n  { key: \"29\", text: \"Baze Malbus\" },\n  { key: \"30\", text: \"Bazine Netal\" },\n  { key: \"31\", text: \"BB-8\" },\n  { key: \"32\", text: \"BB-9E\" },\n  { key: \"33\", text: \"Ben Quadinaros\" },\n  { key: \"34\", text: \"Berch Teller\" },\n  { key: \"35\", text: \"Beru Lars\" },\n  { key: \"36\", text: \"Bib Fortuna\" },\n  {\n    key: \"37\",\n    text: \"Biggs Darklighter\",\n  },\n  { key: \"38\", text: \"Black Krrsantan\" },\n  { key: \"39\", text: \"Bo-Katan Kryze\" },\n  { key: \"40\", text: \"Boba Fett\" },\n  { key: \"41\", text: \"Bobbajo\" },\n  { key: \"42\", text: \"Bodhi Rook\" },\n  { key: \"43\", text: \"Borvo the Hutt\" },\n  { key: \"44\", text: \"Boss Nass\" },\n  { key: \"45\", text: \"Bossk\" },\n  {\n    key: \"46\",\n    text: \"Breha Antilles-Organa\",\n  },\n  { key: \"47\", text: \"Bren Derlin\" },\n  { key: \"48\", text: \"Brendol Hux\" },\n  { key: \"49\", text: \"BT-1\" },\n];\n"
  },
  {
    "path": "app/src/components/ui/menubar.tsx",
    "content": "import * as MenubarPrimitive from \"@radix-ui/react-menubar\";\nimport { CheckIcon, ChevronRightIcon, CircleIcon } from \"lucide-react\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/utils/cn\";\nimport { SoundService } from \"@/core/service/feedbackService/SoundService\";\n\nfunction Menubar({ className, ...props }: React.ComponentProps<typeof MenubarPrimitive.Root>) {\n  return (\n    <MenubarPrimitive.Root\n      data-slot=\"menubar\"\n      className={cn(\n        \"bg-background shadow-xs flex h-full items-center sm:gap-1 sm:rounded-md sm:border sm:p-1\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction MenubarMenu({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Menu>) {\n  return <MenubarPrimitive.Menu data-slot=\"menubar-menu\" {...props} />;\n}\n\nfunction MenubarGroup({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Group>) {\n  return <MenubarPrimitive.Group data-slot=\"menubar-group\" {...props} />;\n}\n\nfunction MenubarPortal({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Portal>) {\n  return <MenubarPrimitive.Portal data-slot=\"menubar-portal\" {...props} />;\n}\n\nfunction MenubarRadioGroup({ ...props }: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {\n  return <MenubarPrimitive.RadioGroup data-slot=\"menubar-radio-group\" {...props} />;\n}\n\nfunction MenubarTrigger({ className, ...props }: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {\n  return (\n    <MenubarPrimitive.Trigger\n      data-slot=\"menubar-trigger\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground outline-hidden hover:bg-accent hover:text-accent-foreground flex select-none items-center rounded-sm py-1 text-sm font-medium data-[disabled]:opacity-50 sm:px-2 [&_svg]:size-4 sm:[&_svg]:mr-1\",\n        className,\n      )}\n      onMouseEnter={() => {\n        SoundService.play.mouseEnterButton();\n      }}\n      onMouseDown={() => {\n        SoundService.play.mouseClickButton();\n      }}\n      {...props}\n    />\n  );\n}\n\nfunction MenubarContent({\n  className,\n  align = \"start\",\n  alignOffset = -4,\n  sideOffset = 8,\n  ...props\n}: React.ComponentProps<typeof MenubarPrimitive.Content>) {\n  return (\n    <MenubarPortal>\n      <MenubarPrimitive.Content\n        data-slot=\"menubar-content\"\n        align={align}\n        alignOffset={alignOffset}\n        sideOffset={sideOffset}\n        className={cn(\n          \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-menubar-content-transform-origin) z-50 min-w-[12rem] overflow-hidden rounded-md border p-1 shadow-md\",\n          className,\n        )}\n        {...props}\n      />\n    </MenubarPortal>\n  );\n}\n\nfunction MenubarItem({\n  className,\n  inset,\n  variant = \"default\",\n  ...props\n}: React.ComponentProps<typeof MenubarPrimitive.Item> & {\n  inset?: boolean;\n  variant?: \"default\" | \"destructive\";\n}) {\n  return (\n    <MenubarPrimitive.Item\n      data-slot=\"menubar-item\"\n      data-inset={inset}\n      data-variant={variant}\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className,\n      )}\n      onMouseEnter={() => {\n        SoundService.play.mouseEnterButton();\n      }}\n      onMouseDown={() => {\n        SoundService.play.mouseClickButton();\n      }}\n      {...props}\n    />\n  );\n}\n\nfunction MenubarCheckboxItem({\n  className,\n  children,\n  checked,\n  ...props\n}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {\n  return (\n    <MenubarPrimitive.CheckboxItem\n      data-slot=\"menubar-checkbox-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground rounded-xs outline-hidden relative flex cursor-default select-none items-center gap-2 py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className,\n      )}\n      checked={checked}\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <MenubarPrimitive.ItemIndicator>\n          <CheckIcon className=\"size-4\" />\n        </MenubarPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </MenubarPrimitive.CheckboxItem>\n  );\n}\n\nfunction MenubarRadioItem({ className, children, ...props }: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {\n  return (\n    <MenubarPrimitive.RadioItem\n      data-slot=\"menubar-radio-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground rounded-xs outline-hidden relative flex cursor-default select-none items-center gap-2 py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className,\n      )}\n      {...props}\n    >\n      <span className=\"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center\">\n        <MenubarPrimitive.ItemIndicator>\n          <CircleIcon className=\"size-2 fill-current\" />\n        </MenubarPrimitive.ItemIndicator>\n      </span>\n      {children}\n    </MenubarPrimitive.RadioItem>\n  );\n}\n\nfunction MenubarLabel({\n  className,\n  inset,\n  ...props\n}: React.ComponentProps<typeof MenubarPrimitive.Label> & {\n  inset?: boolean;\n}) {\n  return (\n    <MenubarPrimitive.Label\n      data-slot=\"menubar-label\"\n      data-inset={inset}\n      className={cn(\"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction MenubarSeparator({ className, ...props }: React.ComponentProps<typeof MenubarPrimitive.Separator>) {\n  return (\n    <MenubarPrimitive.Separator\n      data-slot=\"menubar-separator\"\n      className={cn(\"bg-border -mx-1 my-1 h-px\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction MenubarShortcut({ className, ...props }: React.ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot=\"menubar-shortcut\"\n      className={cn(\"text-muted-foreground ml-auto text-xs tracking-widest\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction MenubarSub({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Sub>) {\n  return <MenubarPrimitive.Sub data-slot=\"menubar-sub\" {...props} />;\n}\n\nfunction MenubarSubTrigger({\n  className,\n  inset,\n  children,\n  ...props\n}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {\n  inset?: boolean;\n}) {\n  return (\n    <MenubarPrimitive.SubTrigger\n      data-slot=\"menubar-sub-trigger\"\n      data-inset={inset}\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className,\n      )}\n      onMouseEnter={() => {\n        SoundService.play.mouseEnterButton();\n      }}\n      onMouseDown={() => {\n        SoundService.play.mouseClickButton();\n      }}\n      {...props}\n    >\n      {children}\n      <ChevronRightIcon className=\"ml-auto h-4 w-4\" />\n    </MenubarPrimitive.SubTrigger>\n  );\n}\n\nfunction MenubarSubContent({ className, ...props }: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {\n  return (\n    <MenubarPrimitive.SubContent\n      data-slot=\"menubar-sub-content\"\n      className={cn(\n        \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-menubar-content-transform-origin) z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport {\n  Menubar,\n  MenubarCheckboxItem,\n  MenubarContent,\n  MenubarGroup,\n  MenubarItem,\n  MenubarLabel,\n  MenubarMenu,\n  MenubarPortal,\n  MenubarRadioGroup,\n  MenubarRadioItem,\n  MenubarSeparator,\n  MenubarShortcut,\n  MenubarSub,\n  MenubarSubContent,\n  MenubarSubTrigger,\n  MenubarTrigger,\n};\n"
  },
  {
    "path": "app/src/components/ui/mode-toolbar-button.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { SuggestionPlugin } from \"@platejs/suggestion/react\";\nimport { type DropdownMenuProps, DropdownMenuItemIndicator } from \"@radix-ui/react-dropdown-menu\";\nimport { CheckIcon, EyeIcon, PenIcon } from \"lucide-react\";\nimport { useEditorRef, usePlateState, usePluginOption } from \"platejs/react\";\n\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\n\nimport { ToolbarButton } from \"./toolbar\";\n\nexport function ModeToolbarButton(props: DropdownMenuProps) {\n  const editor = useEditorRef();\n  const [readOnly, setReadOnly] = usePlateState(\"readOnly\");\n  const [open, setOpen] = React.useState(false);\n\n  const isSuggesting = usePluginOption(SuggestionPlugin, \"isSuggesting\");\n\n  let value = \"editing\";\n\n  if (readOnly) value = \"viewing\";\n\n  if (isSuggesting) value = \"suggestion\";\n\n  const item: Record<string, { icon: React.ReactNode; label: string }> = {\n    editing: {\n      icon: <PenIcon />,\n      label: \"Editing\",\n    },\n    // suggestion: {\n    //   icon: <PencilLineIcon />,\n    //   label: 'Suggestion',\n    // },\n    viewing: {\n      icon: <EyeIcon />,\n      label: \"Viewing\",\n    },\n  };\n\n  return (\n    <DropdownMenu open={open} onOpenChange={setOpen} modal={false} {...props}>\n      <DropdownMenuTrigger asChild>\n        <ToolbarButton pressed={open} tooltip=\"Editing mode\" isDropdown>\n          {item[value].icon}\n          <span className=\"hidden lg:inline\">{item[value].label}</span>\n        </ToolbarButton>\n      </DropdownMenuTrigger>\n\n      <DropdownMenuContent className=\"min-w-[180px]\" align=\"start\">\n        <DropdownMenuRadioGroup\n          value={value}\n          onValueChange={(newValue) => {\n            if (newValue === \"viewing\") {\n              setReadOnly(true);\n\n              return;\n            } else {\n              setReadOnly(false);\n            }\n\n            if (newValue === \"suggestion\") {\n              editor.setOption(SuggestionPlugin, \"isSuggesting\", true);\n\n              return;\n            } else {\n              editor.setOption(SuggestionPlugin, \"isSuggesting\", false);\n            }\n\n            if (newValue === \"editing\") {\n              editor.tf.focus();\n\n              return;\n            }\n          }}\n        >\n          <DropdownMenuRadioItem className=\"*:first:[span]:hidden *:[svg]:text-muted-foreground pl-2\" value=\"editing\">\n            <Indicator />\n            {item.editing.icon}\n            {item.editing.label}\n          </DropdownMenuRadioItem>\n\n          <DropdownMenuRadioItem className=\"*:first:[span]:hidden *:[svg]:text-muted-foreground pl-2\" value=\"viewing\">\n            <Indicator />\n            {item.viewing.icon}\n            {item.viewing.label}\n          </DropdownMenuRadioItem>\n\n          {/*<DropdownMenuRadioItem\n            className=\"*:first:[span]:hidden *:[svg]:text-muted-foreground pl-2\"\n            value=\"suggestion\"\n          >\n            <Indicator />\n            {item.suggestion.icon}\n            {item.suggestion.label}\n          </DropdownMenuRadioItem>*/}\n        </DropdownMenuRadioGroup>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n\nfunction Indicator() {\n  return (\n    <span className=\"pointer-events-none absolute right-2 flex size-3.5 items-center justify-center\">\n      <DropdownMenuItemIndicator>\n        <CheckIcon />\n      </DropdownMenuItemIndicator>\n    </span>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/more-toolbar-button.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport type { DropdownMenuProps } from \"@radix-ui/react-dropdown-menu\";\n\nimport { KeyboardIcon, MoreHorizontalIcon, SubscriptIcon, SuperscriptIcon } from \"lucide-react\";\nimport { KEYS } from \"platejs\";\nimport { useEditorRef } from \"platejs/react\";\n\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\n\nimport { ToolbarButton } from \"./toolbar\";\n\nexport function MoreToolbarButton(props: DropdownMenuProps) {\n  const editor = useEditorRef();\n  const [open, setOpen] = React.useState(false);\n\n  return (\n    <DropdownMenu open={open} onOpenChange={setOpen} modal={false} {...props}>\n      <DropdownMenuTrigger asChild>\n        <ToolbarButton pressed={open} tooltip=\"Insert\">\n          <MoreHorizontalIcon />\n        </ToolbarButton>\n      </DropdownMenuTrigger>\n\n      <DropdownMenuContent\n        className=\"ignore-click-outside/toolbar flex max-h-[500px] min-w-[180px] flex-col overflow-y-auto\"\n        align=\"start\"\n      >\n        <DropdownMenuGroup>\n          <DropdownMenuItem\n            onSelect={() => {\n              editor.tf.toggleMark(KEYS.kbd);\n              editor.tf.collapse({ edge: \"end\" });\n              editor.tf.focus();\n            }}\n          >\n            <KeyboardIcon />\n            Keyboard input\n          </DropdownMenuItem>\n\n          <DropdownMenuItem\n            onSelect={() => {\n              editor.tf.toggleMark(KEYS.sup, {\n                remove: KEYS.sub,\n              });\n              editor.tf.focus();\n            }}\n          >\n            <SuperscriptIcon />\n            Superscript\n            {/* (⌘+,) */}\n          </DropdownMenuItem>\n          <DropdownMenuItem\n            onSelect={() => {\n              editor.tf.toggleMark(KEYS.sub, {\n                remove: KEYS.sup,\n              });\n              editor.tf.focus();\n            }}\n          >\n            <SubscriptIcon />\n            Subscript\n            {/* (⌘+.) */}\n          </DropdownMenuItem>\n        </DropdownMenuGroup>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/paragraph-node-static.tsx",
    "content": "// @ts-nocheck\nimport * as React from \"react\";\n\nimport type { SlateElementProps } from \"platejs\";\n\nimport { SlateElement } from \"platejs\";\n\nimport { cn } from \"@/utils/cn\";\n\nexport function ParagraphElementStatic(props: SlateElementProps) {\n  return (\n    <SlateElement {...props} className={cn(\"m-0 px-0 py-1\")}>\n      {props.children}\n    </SlateElement>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/paragraph-node.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\n\nimport type { PlateElementProps } from \"platejs/react\";\n\nimport { PlateElement } from \"platejs/react\";\n\nimport { cn } from \"@/utils/cn\";\n\nexport function ParagraphElement(props: PlateElementProps) {\n  return (\n    <PlateElement {...props} className={cn(\"m-0 px-0 py-1\")}>\n      {props.children}\n    </PlateElement>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/popover.tsx",
    "content": "\"use client\";\n\nimport * as PopoverPrimitive from \"@radix-ui/react-popover\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/utils/cn\";\nimport { Button } from \"./button\";\n\nfunction Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {\n  return <PopoverPrimitive.Root data-slot=\"popover\" {...props} />;\n}\n\nfunction PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {\n  return <PopoverPrimitive.Trigger data-slot=\"popover-trigger\" {...props} />;\n}\n\nfunction PopoverContent({\n  className,\n  align = \"center\",\n  sideOffset = 4,\n  ...props\n}: React.ComponentProps<typeof PopoverPrimitive.Content>) {\n  return (\n    <PopoverPrimitive.Portal>\n      <PopoverPrimitive.Content\n        data-slot=\"popover-content\"\n        align={align}\n        sideOffset={sideOffset}\n        className={cn(\n          \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-popover-content-transform-origin) outline-hidden z-50 w-72 rounded-md border p-4 shadow-md\",\n          className,\n        )}\n        {...props}\n      />\n    </PopoverPrimitive.Portal>\n  );\n}\n\nfunction PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {\n  return <PopoverPrimitive.Anchor data-slot=\"popover-anchor\" {...props} />;\n}\n\nPopover.Confirm = ({\n  title = \"\",\n  description = \"\",\n  onConfirm = () => {},\n  destructive = false,\n  children = <></>,\n}: {\n  title?: string;\n  description?: string;\n  onConfirm?: () => void;\n  destructive?: boolean;\n  children: React.ReactNode;\n}) => {\n  const [open, setOpen] = React.useState(false);\n\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger asChild>{children}</PopoverTrigger>\n      <PopoverContent className=\"w-64\">\n        <div className=\"mb-2\">{title}</div>\n        <div className=\"text-muted-foreground mb-2 text-sm\">{description}</div>\n        <div className=\"flex justify-end\">\n          <Button\n            size=\"sm\"\n            variant={destructive ? \"destructive\" : \"default\"}\n            onClick={() => {\n              onConfirm();\n              setOpen(false);\n            }}\n          >\n            确定\n          </Button>\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n};\n\nexport { Popover, PopoverAnchor, PopoverContent, PopoverTrigger };\n"
  },
  {
    "path": "app/src/components/ui/progress.tsx",
    "content": "import * as React from \"react\";\nimport * as ProgressPrimitive from \"@radix-ui/react-progress\";\n\nimport { cn } from \"@/utils/cn\";\n\nfunction Progress({ className, value, ...props }: React.ComponentProps<typeof ProgressPrimitive.Root>) {\n  return (\n    <ProgressPrimitive.Root\n      data-slot=\"progress\"\n      className={cn(\"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full\", className)}\n      {...props}\n    >\n      <ProgressPrimitive.Indicator\n        data-slot=\"progress-indicator\"\n        className=\"bg-primary h-full w-full flex-1 transition-all\"\n        style={{ transform: `translateX(-${100 - (value || 0)}%)` }}\n      />\n    </ProgressPrimitive.Root>\n  );\n}\n\nexport { Progress };\n"
  },
  {
    "path": "app/src/components/ui/resize-handle.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport type { VariantProps } from \"class-variance-authority\";\n\nimport {\n  type ResizeHandle as ResizeHandlePrimitive,\n  Resizable as ResizablePrimitive,\n  useResizeHandle,\n  useResizeHandleState,\n} from \"@platejs/resizable\";\nimport { cva } from \"class-variance-authority\";\n\nimport { cn } from \"@/utils/cn\";\n\nexport const mediaResizeHandleVariants = cva(\n  cn(\n    \"top-0 flex w-6 flex-col justify-center select-none\",\n    \"after:flex after:h-16 after:w-[3px] after:rounded-[6px] after:bg-ring after:opacity-0 after:content-['_'] group-hover:after:opacity-100\",\n  ),\n  {\n    variants: {\n      direction: {\n        left: \"-left-3 -ml-3 pl-3\",\n        right: \"-right-3 -mr-3 items-end pr-3\",\n      },\n    },\n  },\n);\n\nconst resizeHandleVariants = cva(\"absolute z-40\", {\n  variants: {\n    direction: {\n      bottom: \"w-full cursor-row-resize\",\n      left: \"h-full cursor-col-resize\",\n      right: \"h-full cursor-col-resize\",\n      top: \"w-full cursor-row-resize\",\n    },\n  },\n});\n\nexport function ResizeHandle({\n  className,\n  options,\n  ...props\n}: React.ComponentProps<typeof ResizeHandlePrimitive> & VariantProps<typeof resizeHandleVariants>) {\n  const state = useResizeHandleState(options ?? {});\n  const resizeHandle = useResizeHandle(state);\n\n  if (state.readOnly) return null;\n\n  return (\n    <div\n      className={cn(resizeHandleVariants({ direction: options?.direction }), className)}\n      data-resizing={state.isResizing}\n      {...resizeHandle.props}\n      {...props}\n    />\n  );\n}\n\nconst resizableVariants = cva(\"\", {\n  variants: {\n    align: {\n      center: \"mx-auto\",\n      left: \"mr-auto\",\n      right: \"ml-auto\",\n    },\n  },\n});\n\nexport function Resizable({\n  align,\n  className,\n  ...props\n}: React.ComponentProps<typeof ResizablePrimitive> & VariantProps<typeof resizableVariants>) {\n  return <ResizablePrimitive {...props} className={cn(resizableVariants({ align }), className)} />;\n}\n"
  },
  {
    "path": "app/src/components/ui/select.tsx",
    "content": "import * as React from \"react\";\nimport * as SelectPrimitive from \"@radix-ui/react-select\";\nimport { CheckIcon, ChevronDownIcon, ChevronUpIcon } from \"lucide-react\";\n\nimport { cn } from \"@/utils/cn\";\n\nfunction Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {\n  return <SelectPrimitive.Root data-slot=\"select\" {...props} />;\n}\n\nfunction SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {\n  return <SelectPrimitive.Group data-slot=\"select-group\" {...props} />;\n}\n\nfunction SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {\n  return <SelectPrimitive.Value data-slot=\"select-value\" {...props} />;\n}\n\nfunction SelectTrigger({\n  className,\n  size = \"default\",\n  children,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {\n  size?: \"sm\" | \"default\";\n}) {\n  return (\n    <SelectPrimitive.Trigger\n      data-slot=\"select-trigger\"\n      data-size={size}\n      className={cn(\n        \"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 shadow-xs flex w-fit items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n      <SelectPrimitive.Icon asChild>\n        <ChevronDownIcon className=\"size-4 opacity-50\" />\n      </SelectPrimitive.Icon>\n    </SelectPrimitive.Trigger>\n  );\n}\n\nfunction SelectContent({\n  className,\n  children,\n  position = \"popper\",\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.Content>) {\n  return (\n    <SelectPrimitive.Portal>\n      <SelectPrimitive.Content\n        data-slot=\"select-content\"\n        className={cn(\n          \"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--radix-select-content-available-height) origin-(--radix-select-content-transform-origin) relative z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border shadow-md\",\n          position === \"popper\" &&\n            \"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1\",\n          className,\n        )}\n        position={position}\n        {...props}\n      >\n        <SelectScrollUpButton />\n        <SelectPrimitive.Viewport\n          className={cn(\n            \"p-1\",\n            position === \"popper\" &&\n              \"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1\",\n          )}\n        >\n          {children}\n        </SelectPrimitive.Viewport>\n        <SelectScrollDownButton />\n      </SelectPrimitive.Content>\n    </SelectPrimitive.Portal>\n  );\n}\n\nfunction SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {\n  return (\n    <SelectPrimitive.Label\n      data-slot=\"select-label\"\n      className={cn(\"text-muted-foreground px-2 py-1.5 text-xs\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {\n  return (\n    <SelectPrimitive.Item\n      data-slot=\"select-item\"\n      className={cn(\n        \"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className,\n      )}\n      {...props}\n    >\n      <span className=\"absolute right-2 flex size-3.5 items-center justify-center\">\n        <SelectPrimitive.ItemIndicator>\n          <CheckIcon className=\"size-4\" />\n        </SelectPrimitive.ItemIndicator>\n      </span>\n      <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>\n    </SelectPrimitive.Item>\n  );\n}\n\nfunction SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {\n  return (\n    <SelectPrimitive.Separator\n      data-slot=\"select-separator\"\n      className={cn(\"bg-border pointer-events-none -mx-1 my-1 h-px\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {\n  return (\n    <SelectPrimitive.ScrollUpButton\n      data-slot=\"select-scroll-up-button\"\n      className={cn(\"flex cursor-default items-center justify-center py-1\", className)}\n      {...props}\n    >\n      <ChevronUpIcon className=\"size-4\" />\n    </SelectPrimitive.ScrollUpButton>\n  );\n}\n\nfunction SelectScrollDownButton({\n  className,\n  ...props\n}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {\n  return (\n    <SelectPrimitive.ScrollDownButton\n      data-slot=\"select-scroll-down-button\"\n      className={cn(\"flex cursor-default items-center justify-center py-1\", className)}\n      {...props}\n    >\n      <ChevronDownIcon className=\"size-4\" />\n    </SelectPrimitive.ScrollDownButton>\n  );\n}\n\nexport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectLabel,\n  SelectScrollDownButton,\n  SelectScrollUpButton,\n  SelectSeparator,\n  SelectTrigger,\n  SelectValue,\n};\n"
  },
  {
    "path": "app/src/components/ui/separator.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as SeparatorPrimitive from \"@radix-ui/react-separator\";\n\nimport { cn } from \"@/utils/cn\";\n\nfunction Separator({\n  className,\n  orientation = \"horizontal\",\n  decorative = true,\n  ...props\n}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {\n  return (\n    <SeparatorPrimitive.Root\n      data-slot=\"separator\"\n      decorative={decorative}\n      orientation={orientation}\n      className={cn(\n        \"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport { Separator };\n"
  },
  {
    "path": "app/src/components/ui/sheet.tsx",
    "content": "import * as React from \"react\";\nimport * as SheetPrimitive from \"@radix-ui/react-dialog\";\nimport { XIcon } from \"lucide-react\";\n\nimport { cn } from \"@/utils/cn\";\n\nfunction Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {\n  return <SheetPrimitive.Root data-slot=\"sheet\" {...props} />;\n}\n\nfunction SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {\n  return <SheetPrimitive.Trigger data-slot=\"sheet-trigger\" {...props} />;\n}\n\nfunction SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {\n  return <SheetPrimitive.Close data-slot=\"sheet-close\" {...props} />;\n}\n\nfunction SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {\n  return <SheetPrimitive.Portal data-slot=\"sheet-portal\" {...props} />;\n}\n\nfunction SheetOverlay({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Overlay>) {\n  return (\n    <SheetPrimitive.Overlay\n      data-slot=\"sheet-overlay\"\n      className={cn(\n        \"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SheetContent({\n  className,\n  children,\n  side = \"right\",\n  ...props\n}: React.ComponentProps<typeof SheetPrimitive.Content> & {\n  side?: \"top\" | \"right\" | \"bottom\" | \"left\";\n}) {\n  return (\n    <SheetPortal>\n      <SheetOverlay />\n      <SheetPrimitive.Content\n        data-slot=\"sheet-content\"\n        className={cn(\n          \"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500\",\n          side === \"right\" &&\n            \"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm\",\n          side === \"left\" &&\n            \"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm\",\n          side === \"top\" &&\n            \"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b\",\n          side === \"bottom\" &&\n            \"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t\",\n          className,\n        )}\n        {...props}\n      >\n        {children}\n        <SheetPrimitive.Close className=\"ring-offset-background focus:ring-ring data-[state=open]:bg-secondary rounded-xs focus:outline-hidden absolute right-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none\">\n          <XIcon className=\"size-4\" />\n          <span className=\"sr-only\">Close</span>\n        </SheetPrimitive.Close>\n      </SheetPrimitive.Content>\n    </SheetPortal>\n  );\n}\n\nfunction SheetHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return <div data-slot=\"sheet-header\" className={cn(\"flex flex-col gap-1.5 p-4\", className)} {...props} />;\n}\n\nfunction SheetFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return <div data-slot=\"sheet-footer\" className={cn(\"mt-auto flex flex-col gap-2 p-4\", className)} {...props} />;\n}\n\nfunction SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {\n  return (\n    <SheetPrimitive.Title\n      data-slot=\"sheet-title\"\n      className={cn(\"text-foreground font-semibold\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SheetDescription({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Description>) {\n  return (\n    <SheetPrimitive.Description\n      data-slot=\"sheet-description\"\n      className={cn(\"text-muted-foreground text-sm\", className)}\n      {...props}\n    />\n  );\n}\n\nexport { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription };\n"
  },
  {
    "path": "app/src/components/ui/sidebar.tsx",
    "content": "import { Slot } from \"@radix-ui/react-slot\";\nimport { cva, VariantProps } from \"class-variance-authority\";\nimport * as React from \"react\";\n\nimport { Input } from \"@/components/ui/input\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@/components/ui/tooltip\";\nimport { cn } from \"@/utils/cn\";\n\nfunction Sidebar({\n  variant = \"sidebar\",\n  className,\n  children,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  variant?: \"sidebar\" | \"floating\" | \"inset\";\n}) {\n  return (\n    <div\n      className=\"text-sidebar-foreground group peer hidden md:block\"\n      data-state=\"open\"\n      data-variant={variant}\n      data-side=\"left\"\n      data-slot=\"sidebar\"\n    >\n      {/* This is what handles the sidebar gap on desktop */}\n      <div\n        data-slot=\"sidebar-gap\"\n        className={cn(\n          \"relative w-[16rem] bg-transparent transition-[width] duration-200 ease-linear\",\n          \"group-data-[collapsible=offcanvas]:w-0\",\n          \"group-data-[side=right]:rotate-180\",\n          variant === \"floating\" || variant === \"inset\"\n            ? \"group-data-[collapsible=icon]:w-[calc(var(3rem)+(--spacing(4)))]\"\n            : \"group-data-[collapsible=icon]:w-(3rem)\",\n        )}\n      />\n      <div\n        data-slot=\"sidebar-container\"\n        className={cn(\n          \"w-(16rem) inset-y-0 hidden transition-[left,right,width] duration-200 ease-linear md:flex\",\n          \"left-0 group-data-[collapsible=offcanvas]:left-[calc(var(16rem)*-1)]\",\n          // Adjust the padding for floating and inset variants.\n          variant === \"floating\" || variant === \"inset\"\n            ? \"p-2 group-data-[collapsible=icon]:w-[calc(var(3rem)+(--spacing(4))+2px)]\"\n            : \"group-data-[collapsible=icon]:w-(3rem) group-data-[side=left]:border-r group-data-[side=right]:border-l\",\n          className,\n        )}\n        {...props}\n      >\n        <div\n          data-sidebar=\"sidebar\"\n          data-slot=\"sidebar-inner\"\n          className=\"bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm\"\n        >\n          {children}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction SidebarInset({ className, ...props }: React.ComponentProps<\"main\">) {\n  return (\n    <main\n      data-slot=\"sidebar-inset\"\n      className={cn(\n        \"bg-background relative flex w-full flex-1 flex-col\",\n        \"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {\n  return (\n    <Input\n      data-slot=\"sidebar-input\"\n      data-sidebar=\"input\"\n      className={cn(\"bg-background h-8 w-full shadow-none\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarHeader({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sidebar-header\"\n      data-sidebar=\"header\"\n      className={cn(\"flex flex-col gap-2 p-2\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sidebar-footer\"\n      data-sidebar=\"footer\"\n      className={cn(\"flex flex-col gap-2 p-2\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {\n  return (\n    <Separator\n      data-slot=\"sidebar-separator\"\n      data-sidebar=\"separator\"\n      className={cn(\"bg-sidebar-border mx-2 w-auto\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarContent({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sidebar-content\"\n      data-sidebar=\"content\"\n      className={cn(\n        \"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarGroup({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sidebar-group\"\n      data-sidebar=\"group\"\n      className={cn(\"relative flex w-full min-w-0 flex-col p-2\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarGroupLabel({\n  className,\n  asChild = false,\n  ...props\n}: React.ComponentProps<\"div\"> & { asChild?: boolean }) {\n  const Comp = asChild ? Slot : \"div\";\n\n  return (\n    <Comp\n      data-slot=\"sidebar-group-label\"\n      data-sidebar=\"group-label\"\n      className={cn(\n        \"text-sidebar-foreground/70 ring-sidebar-ring outline-hidden flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0\",\n        \"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarGroupAction({\n  className,\n  asChild = false,\n  ...props\n}: React.ComponentProps<\"button\"> & { asChild?: boolean }) {\n  const Comp = asChild ? Slot : \"button\";\n\n  return (\n    <Comp\n      data-slot=\"sidebar-group-action\"\n      data-sidebar=\"group-action\"\n      className={cn(\n        \"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground outline-hidden absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0\",\n        // Increases the hit area of the button on mobile.\n        \"after:absolute after:-inset-2 md:after:hidden\",\n        \"group-data-[collapsible=icon]:hidden\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarGroupContent({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sidebar-group-content\"\n      data-sidebar=\"group-content\"\n      className={cn(\"w-full text-sm\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenu({ className, ...props }: React.ComponentProps<\"ul\">) {\n  return (\n    <ul\n      data-slot=\"sidebar-menu\"\n      data-sidebar=\"menu\"\n      className={cn(\"flex w-full min-w-0 flex-col gap-1\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenuItem({ className, ...props }: React.ComponentProps<\"li\">) {\n  return (\n    <li\n      data-slot=\"sidebar-menu-item\"\n      data-sidebar=\"menu-item\"\n      className={cn(\"group/menu-item relative\", className)}\n      {...props}\n    />\n  );\n}\n\nconst sidebarMenuButtonVariants = cva(\n  \"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0\",\n  {\n    variants: {\n      variant: {\n        default: \"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground\",\n        outline:\n          \"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]\",\n      },\n      size: {\n        default: \"h-8 text-sm\",\n        sm: \"h-7 text-xs\",\n        lg: \"h-12 text-sm group-data-[collapsible=icon]:p-0!\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  },\n);\n\nfunction SidebarMenuButton({\n  asChild = false,\n  isActive = false,\n  variant = \"default\",\n  size = \"default\",\n  tooltip,\n  className,\n  ...props\n}: React.ComponentProps<\"button\"> & {\n  asChild?: boolean;\n  isActive?: boolean;\n  tooltip?: string | React.ComponentProps<typeof TooltipContent>;\n} & VariantProps<typeof sidebarMenuButtonVariants>) {\n  const Comp = asChild ? Slot : \"button\";\n\n  const button = (\n    <Comp\n      data-slot=\"sidebar-menu-button\"\n      data-sidebar=\"menu-button\"\n      data-size={size}\n      data-active={isActive}\n      className={cn(sidebarMenuButtonVariants({ variant, size }), className)}\n      {...props}\n    />\n  );\n\n  if (!tooltip) {\n    return button;\n  }\n\n  if (typeof tooltip === \"string\") {\n    tooltip = {\n      children: tooltip,\n    };\n  }\n\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>{button}</TooltipTrigger>\n      <TooltipContent side=\"right\" align=\"center\" {...tooltip} />\n    </Tooltip>\n  );\n}\n\nfunction SidebarMenuAction({\n  className,\n  asChild = false,\n  showOnHover = false,\n  ...props\n}: React.ComponentProps<\"button\"> & {\n  asChild?: boolean;\n  showOnHover?: boolean;\n}) {\n  const Comp = asChild ? Slot : \"button\";\n\n  return (\n    <Comp\n      data-slot=\"sidebar-menu-action\"\n      data-sidebar=\"menu-action\"\n      className={cn(\n        \"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground outline-hidden absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0\",\n        // Increases the hit area of the button on mobile.\n        \"after:absolute after:-inset-2 md:after:hidden\",\n        \"peer-data-[size=sm]/menu-button:top-1\",\n        \"peer-data-[size=default]/menu-button:top-1.5\",\n        \"peer-data-[size=lg]/menu-button:top-2.5\",\n        \"group-data-[collapsible=icon]:hidden\",\n        showOnHover &&\n          \"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenuBadge({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sidebar-menu-badge\"\n      data-sidebar=\"menu-badge\"\n      className={cn(\n        \"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums\",\n        \"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground\",\n        \"peer-data-[size=sm]/menu-button:top-1\",\n        \"peer-data-[size=default]/menu-button:top-1.5\",\n        \"peer-data-[size=lg]/menu-button:top-2.5\",\n        \"group-data-[collapsible=icon]:hidden\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenuSkeleton({\n  className,\n  showIcon = false,\n  ...props\n}: React.ComponentProps<\"div\"> & {\n  showIcon?: boolean;\n}) {\n  // Random width between 50 to 90%.\n  const width = React.useMemo(() => {\n    return `${Math.floor(Math.random() * 40) + 50}%`;\n  }, []);\n\n  return (\n    <div\n      data-slot=\"sidebar-menu-skeleton\"\n      data-sidebar=\"menu-skeleton\"\n      className={cn(\"flex h-8 items-center gap-2 rounded-md px-2\", className)}\n      {...props}\n    >\n      {showIcon && <Skeleton className=\"size-4 rounded-md\" data-sidebar=\"menu-skeleton-icon\" />}\n      <Skeleton\n        className=\"max-w-(--skeleton-width) h-4 flex-1\"\n        data-sidebar=\"menu-skeleton-text\"\n        style={\n          {\n            \"--skeleton-width\": width,\n          } as React.CSSProperties\n        }\n      />\n    </div>\n  );\n}\n\nfunction SidebarMenuSub({ className, ...props }: React.ComponentProps<\"ul\">) {\n  return (\n    <ul\n      data-slot=\"sidebar-menu-sub\"\n      data-sidebar=\"menu-sub\"\n      className={cn(\n        \"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5\",\n        \"group-data-[collapsible=icon]:hidden\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenuSubItem({ className, ...props }: React.ComponentProps<\"li\">) {\n  return (\n    <li\n      data-slot=\"sidebar-menu-sub-item\"\n      data-sidebar=\"menu-sub-item\"\n      className={cn(\"group/menu-sub-item relative\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction SidebarMenuSubButton({\n  asChild = false,\n  size = \"md\",\n  isActive = false,\n  className,\n  ...props\n}: React.ComponentProps<\"a\"> & {\n  asChild?: boolean;\n  size?: \"sm\" | \"md\";\n  isActive?: boolean;\n}) {\n  const Comp = asChild ? Slot : \"a\";\n\n  return (\n    <Comp\n      data-slot=\"sidebar-menu-sub-button\"\n      data-sidebar=\"menu-sub-button\"\n      data-size={size}\n      data-active={isActive}\n      className={cn(\n        \"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground outline-hidden flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0\",\n        \"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground\",\n        size === \"sm\" && \"text-xs\",\n        size === \"md\" && \"text-sm\",\n        \"group-data-[collapsible=icon]:hidden\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport {\n  Sidebar,\n  SidebarContent,\n  SidebarFooter,\n  SidebarGroup,\n  SidebarGroupAction,\n  SidebarGroupContent,\n  SidebarGroupLabel,\n  SidebarHeader,\n  SidebarInput,\n  SidebarInset,\n  SidebarMenu,\n  SidebarMenuAction,\n  SidebarMenuBadge,\n  SidebarMenuButton,\n  SidebarMenuItem,\n  SidebarMenuSkeleton,\n  SidebarMenuSub,\n  SidebarMenuSubButton,\n  SidebarMenuSubItem,\n  SidebarSeparator,\n};\n"
  },
  {
    "path": "app/src/components/ui/skeleton.tsx",
    "content": "import { cn } from \"@/utils/cn\";\n\nfunction Skeleton({ className, ...props }: React.ComponentProps<\"div\">) {\n  return <div data-slot=\"skeleton\" className={cn(\"bg-accent animate-pulse rounded-md\", className)} {...props} />;\n}\n\nexport { Skeleton };\n"
  },
  {
    "path": "app/src/components/ui/slider.tsx",
    "content": "import * as React from \"react\";\nimport * as SliderPrimitive from \"@radix-ui/react-slider\";\n\nimport { cn } from \"@/utils/cn\";\n\nfunction Slider({\n  className,\n  defaultValue,\n  value,\n  min = 0,\n  max = 100,\n  ...props\n}: React.ComponentProps<typeof SliderPrimitive.Root>) {\n  const _values = React.useMemo(\n    () => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),\n    [value, defaultValue, min, max],\n  );\n\n  return (\n    <SliderPrimitive.Root\n      data-slot=\"slider\"\n      defaultValue={defaultValue}\n      value={value}\n      min={min}\n      max={max}\n      className={cn(\n        \"relative flex w-full touch-none select-none items-center data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col data-[disabled]:opacity-50\",\n        className,\n      )}\n      {...props}\n    >\n      <SliderPrimitive.Track\n        data-slot=\"slider-track\"\n        className={cn(\n          \"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-1.5\",\n        )}\n      >\n        <SliderPrimitive.Range\n          data-slot=\"slider-range\"\n          className={cn(\"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full\")}\n        />\n      </SliderPrimitive.Track>\n      {Array.from({ length: _values.length }, (_, index) => (\n        <SliderPrimitive.Thumb\n          data-slot=\"slider-thumb\"\n          key={index}\n          className=\"border-primary bg-background ring-ring/50 focus-visible:outline-hidden block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 disabled:pointer-events-none disabled:opacity-50\"\n        />\n      ))}\n    </SliderPrimitive.Root>\n  );\n}\n\nexport { Slider };\n"
  },
  {
    "path": "app/src/components/ui/sonner.tsx",
    "content": "import { useTheme } from \"next-themes\";\nimport { Toaster as Sonner, ToasterProps } from \"sonner\";\n\nconst Toaster = ({ ...props }: ToasterProps) => {\n  const { theme = \"system\" } = useTheme();\n\n  return (\n    <Sonner\n      theme={theme as ToasterProps[\"theme\"]}\n      className=\"toaster group\"\n      style={\n        {\n          \"--normal-bg\": \"var(--popover)\",\n          \"--normal-text\": \"var(--popover-foreground)\",\n          \"--normal-border\": \"var(--border)\",\n        } as React.CSSProperties\n      }\n      {...props}\n    />\n  );\n};\n\nexport { Toaster };\n"
  },
  {
    "path": "app/src/components/ui/suggestion-node-static.tsx",
    "content": "// @ts-nocheck\nimport * as React from \"react\";\n\nimport type { SlateLeafProps, TSuggestionText } from \"platejs\";\n\nimport { BaseSuggestionPlugin } from \"@platejs/suggestion\";\nimport { SlateLeaf } from \"platejs\";\n\nimport { cn } from \"@/utils/cn\";\n\nexport function SuggestionLeafStatic(props: SlateLeafProps<TSuggestionText>) {\n  const { editor, leaf } = props;\n\n  const dataList = editor.getApi(BaseSuggestionPlugin).suggestion.dataList(leaf);\n  const hasRemove = dataList.some((data) => data.type === \"remove\");\n  const diffOperation = { type: hasRemove ? \"delete\" : \"insert\" } as const;\n\n  const Component = ({ delete: \"del\", insert: \"ins\", update: \"span\" } as const)[diffOperation.type];\n\n  return (\n    <SlateLeaf\n      {...props}\n      as={Component}\n      className={cn(\n        \"border-b-brand/[.24] bg-brand/[.08] text-brand/80 border-b-2 no-underline transition-colors duration-200\",\n        hasRemove && \"border-b-gray-300 bg-gray-300/25 text-gray-400 line-through\",\n      )}\n    >\n      {props.children}\n    </SlateLeaf>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/suggestion-node.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport type { TSuggestionData, TSuggestionText } from \"platejs\";\nimport type { PlateLeafProps, RenderNodeWrapper } from \"platejs/react\";\n\nimport { CornerDownLeftIcon } from \"lucide-react\";\nimport { PlateLeaf, useEditorPlugin, usePluginOption } from \"platejs/react\";\n\nimport { cn } from \"@/utils/cn\";\nimport { type SuggestionConfig, suggestionPlugin } from \"@/components/editor/plugins/suggestion-kit\";\n\nexport function SuggestionLeaf(props: PlateLeafProps<TSuggestionText>) {\n  const { api, setOption } = useEditorPlugin(suggestionPlugin);\n  const leaf = props.leaf;\n\n  const leafId: string = api.suggestion.nodeId(leaf) ?? \"\";\n  const activeSuggestionId = usePluginOption(suggestionPlugin, \"activeId\");\n  const hoverSuggestionId = usePluginOption(suggestionPlugin, \"hoverId\");\n  const dataList = api.suggestion.dataList(leaf);\n\n  const hasRemove = dataList.some((data) => data.type === \"remove\");\n  const hasActive = dataList.some((data) => data.id === activeSuggestionId);\n  const hasHover = dataList.some((data) => data.id === hoverSuggestionId);\n\n  const diffOperation = { type: hasRemove ? \"delete\" : \"insert\" } as const;\n\n  const Component = ({ delete: \"del\", insert: \"ins\", update: \"span\" } as const)[diffOperation.type];\n\n  return (\n    <PlateLeaf\n      {...props}\n      as={Component}\n      className={cn(\n        \"bg-emerald-100 text-emerald-700 no-underline transition-colors duration-200\",\n        (hasActive || hasHover) && \"bg-emerald-200/80\",\n        hasRemove && \"bg-red-100 text-red-700\",\n        (hasActive || hasHover) && hasRemove && \"bg-red-200/80 no-underline\",\n      )}\n      attributes={{\n        ...props.attributes,\n        onMouseEnter: () => setOption(\"hoverId\", leafId),\n        onMouseLeave: () => setOption(\"hoverId\", null),\n      }}\n    >\n      {props.children}\n    </PlateLeaf>\n  );\n}\n\nexport const SuggestionLineBreak: RenderNodeWrapper<SuggestionConfig> = ({ api, element }) => {\n  if (!api.suggestion.isBlockSuggestion(element)) return;\n\n  const suggestionData = element.suggestion;\n\n  if (!suggestionData?.isLineBreak) return;\n\n  return function Component({ children }) {\n    return (\n      <React.Fragment>\n        {children}\n        <SuggestionLineBreakContent suggestionData={suggestionData} />\n      </React.Fragment>\n    );\n  };\n};\n\nfunction SuggestionLineBreakContent({ suggestionData }: { suggestionData: TSuggestionData }) {\n  const { type } = suggestionData;\n  const isRemove = type === \"remove\";\n  const isInsert = type === \"insert\";\n\n  const activeSuggestionId = usePluginOption(suggestionPlugin, \"activeId\");\n  const hoverSuggestionId = usePluginOption(suggestionPlugin, \"hoverId\");\n\n  const isActive = activeSuggestionId === suggestionData.id;\n  const isHover = hoverSuggestionId === suggestionData.id;\n\n  const spanRef = React.useRef<HTMLSpanElement>(null);\n\n  return (\n    <span\n      ref={spanRef}\n      className={cn(\n        \"border-b-brand/[.24] bg-brand/[.08] text-brand/80 absolute border-b-2 text-justify no-underline transition-colors duration-200\",\n        isInsert && (isActive || isHover) && \"border-b-brand/[.60] bg-brand/[.13]\",\n        isRemove && \"border-b-gray-300 bg-gray-300/25 text-gray-400 line-through\",\n        isRemove && (isActive || isHover) && \"border-b-gray-500 bg-gray-400/25 text-gray-500 no-underline\",\n      )}\n      style={{\n        bottom: 4.5,\n        height: 21,\n      }}\n      contentEditable={false}\n    >\n      <CornerDownLeftIcon className=\"mt-0.5 size-4\" />\n    </span>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/suggestion-toolbar-button.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\n\nimport { SuggestionPlugin } from \"@platejs/suggestion/react\";\nimport { PencilLineIcon } from \"lucide-react\";\nimport { useEditorPlugin, usePluginOption } from \"platejs/react\";\n\nimport { cn } from \"@/utils/cn\";\n\nimport { ToolbarButton } from \"./toolbar\";\n\nexport function SuggestionToolbarButton() {\n  const { setOption } = useEditorPlugin(SuggestionPlugin);\n  const isSuggesting = usePluginOption(SuggestionPlugin, \"isSuggesting\");\n\n  return (\n    <ToolbarButton\n      className={cn(isSuggesting && \"text-brand/80 hover:text-brand/80\")}\n      onClick={() => setOption(\"isSuggesting\", !isSuggesting)}\n      onMouseDown={(e) => e.preventDefault()}\n      tooltip={isSuggesting ? \"Turn off suggesting\" : \"Suggestion edits\"}\n    >\n      <PencilLineIcon />\n    </ToolbarButton>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/switch.tsx",
    "content": "import * as React from \"react\";\nimport * as SwitchPrimitive from \"@radix-ui/react-switch\";\n\nimport { cn } from \"@/utils/cn\";\n\nfunction Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {\n  return (\n    <SwitchPrimitive.Root\n      data-slot=\"switch\"\n      className={cn(\n        \"data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 shadow-xs peer inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent outline-none transition-all focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50\",\n        className,\n      )}\n      {...props}\n    >\n      <SwitchPrimitive.Thumb\n        data-slot=\"switch-thumb\"\n        className={cn(\n          \"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0\",\n        )}\n      />\n    </SwitchPrimitive.Root>\n  );\n}\n\nexport { Switch };\n"
  },
  {
    "path": "app/src/components/ui/table-icons.tsx",
    "content": "\"use client\";\n\nimport type { LucideProps } from \"lucide-react\";\n\nexport function BorderAllIcon(props: LucideProps) {\n  return (\n    <svg fill=\"none\" height=\"15\" viewBox=\"0 0 15 15\" width=\"15\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <path\n        clipRule=\"evenodd\"\n        d=\"M0.25 1C0.25 0.585786 0.585786 0.25 1 0.25H14C14.4142 0.25 14.75 0.585786 14.75 1V14C14.75 14.4142 14.4142 14.75 14 14.75H1C0.585786 14.75 0.25 14.4142 0.25 14V1ZM1.75 1.75V13.25H13.25V1.75H1.75Z\"\n        fill=\"currentColor\"\n        fillRule=\"evenodd\"\n      ></path>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"7\" y=\"5\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"7\" y=\"3\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"7\" y=\"7\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"5\" y=\"7\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"3\" y=\"7\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"9\" y=\"7\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"11\" y=\"7\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"7\" y=\"9\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"7\" y=\"11\"></rect>\n    </svg>\n  );\n}\n\nexport function BorderBottomIcon(props: LucideProps) {\n  return (\n    <svg fill=\"none\" height=\"15\" viewBox=\"0 0 15 15\" width=\"15\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <path clipRule=\"evenodd\" d=\"M1 13.25L14 13.25V14.75L1 14.75V13.25Z\" fill=\"currentColor\" fillRule=\"evenodd\"></path>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"7\" y=\"5\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"13\" y=\"5\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"7\" y=\"3\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"13\" y=\"3\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"7\" y=\"7\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"7\" y=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"13\" y=\"7\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"13\" y=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"5\" y=\"7\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"5\" y=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"3\" y=\"7\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"3\" y=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"9\" y=\"7\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"9\" y=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"11\" y=\"7\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"11\" y=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"7\" y=\"9\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"13\" y=\"9\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"7\" y=\"11\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"13\" y=\"11\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"1\" y=\"5\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"1\" y=\"3\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"1\" y=\"7\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"1\" y=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"1\" y=\"9\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"1\" y=\"11\"></rect>\n    </svg>\n  );\n}\n\nexport function BorderLeftIcon(props: LucideProps) {\n  return (\n    <svg fill=\"none\" height=\"15\" viewBox=\"0 0 15 15\" width=\"15\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <path\n        clipRule=\"evenodd\"\n        d=\"M1.75 1L1.75 14L0.249999 14L0.25 1L1.75 1Z\"\n        fill=\"currentColor\"\n        fillRule=\"evenodd\"\n      ></path>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(90 10 7)\" width=\"1\" x=\"10\" y=\"7\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(90 10 13)\" width=\"1\" x=\"10\" y=\"13\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(90 12 7)\" width=\"1\" x=\"12\" y=\"7\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(90 12 13)\" width=\"1\" x=\"12\" y=\"13\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(90 8 7)\" width=\"1\" x=\"8\" y=\"7\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(90 14 7)\" width=\"1\" x=\"14\" y=\"7\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(90 8 13)\" width=\"1\" x=\"8\" y=\"13\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(90 14 13)\" width=\"1\" x=\"14\" y=\"13\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(90 8 5)\" width=\"1\" x=\"8\" y=\"5\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(90 14 5)\" width=\"1\" x=\"14\" y=\"5\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(90 8 3)\" width=\"1\" x=\"8\" y=\"3\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(90 14 3)\" width=\"1\" x=\"14\" y=\"3\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(90 8 9)\" width=\"1\" x=\"8\" y=\"9\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(90 14 9)\" width=\"1\" x=\"14\" y=\"9\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(90 8 11)\" width=\"1\" x=\"8\" y=\"11\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(90 14 11)\" width=\"1\" x=\"14\" y=\"11\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(90 6 7)\" width=\"1\" x=\"6\" y=\"7\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(90 6 13)\" width=\"1\" x=\"6\" y=\"13\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(90 4 7)\" width=\"1\" x=\"4\" y=\"7\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(90 4 13)\" width=\"1\" x=\"4\" y=\"13\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(90 10 1)\" width=\"1\" x=\"10\" y=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(90 12 1)\" width=\"1\" x=\"12\" y=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(90 8 1)\" width=\"1\" x=\"8\" y=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(90 14 1)\" width=\"1\" x=\"14\" y=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(90 6 1)\" width=\"1\" x=\"6\" y=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(90 4 1)\" width=\"1\" x=\"4\" y=\"1\"></rect>\n    </svg>\n  );\n}\n\nexport function BorderNoneIcon(props: LucideProps) {\n  return (\n    <svg fill=\"none\" height=\"15\" viewBox=\"0 0 15 15\" width=\"15\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"7\" y=\"5.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"13\" y=\"5.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"7\" y=\"3.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"13\" y=\"3.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"7\" y=\"7.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"7\" y=\"13.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"7\" y=\"1.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"13\" y=\"7.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"13\" y=\"13.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"13\" y=\"1.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"5\" y=\"7.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"5\" y=\"13.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"5\" y=\"1.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"3\" y=\"7.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"3\" y=\"13.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"3\" y=\"1.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"9\" y=\"7.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"9\" y=\"13.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"9\" y=\"1.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"11\" y=\"7.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"11\" y=\"13.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"11\" y=\"1.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"7\" y=\"9.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"13\" y=\"9.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"7\" y=\"11.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"13\" y=\"11.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"1\" y=\"5.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"1\" y=\"3.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"1\" y=\"7.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"1\" y=\"13.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"1\" y=\"1.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"1\" y=\"9.025\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" width=\"1\" x=\"1\" y=\"11.025\"></rect>\n    </svg>\n  );\n}\n\nexport function BorderRightIcon(props: LucideProps) {\n  return (\n    <svg fill=\"none\" height=\"15\" viewBox=\"0 0 15 15\" width=\"15\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <path\n        clipRule=\"evenodd\"\n        d=\"M13.25 1L13.25 14L14.75 14L14.75 1L13.25 1Z\"\n        fill=\"currentColor\"\n        fillRule=\"evenodd\"\n      ></path>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"matrix(0 1 1 0 5 7)\" width=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"matrix(0 1 1 0 5 13)\" width=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"matrix(0 1 1 0 3 7)\" width=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"matrix(0 1 1 0 3 13)\" width=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"matrix(0 1 1 0 7 7)\" width=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"matrix(0 1 1 0 1 7)\" width=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"matrix(0 1 1 0 7 13)\" width=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"matrix(0 1 1 0 1 13)\" width=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"matrix(0 1 1 0 7 5)\" width=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"matrix(0 1 1 0 1 5)\" width=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"matrix(0 1 1 0 7 3)\" width=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"matrix(0 1 1 0 1 3)\" width=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"matrix(0 1 1 0 7 9)\" width=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"matrix(0 1 1 0 1 9)\" width=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"matrix(0 1 1 0 7 11)\" width=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"matrix(0 1 1 0 1 11)\" width=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"matrix(0 1 1 0 9 7)\" width=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"matrix(0 1 1 0 9 13)\" width=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"matrix(0 1 1 0 11 7)\" width=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"matrix(0 1 1 0 11 13)\" width=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"matrix(0 1 1 0 5 1)\" width=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"matrix(0 1 1 0 3 1)\" width=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"matrix(0 1 1 0 7 1)\" width=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"matrix(0 1 1 0 1 1)\" width=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"matrix(0 1 1 0 9 1)\" width=\"1\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"matrix(0 1 1 0 11 1)\" width=\"1\"></rect>\n    </svg>\n  );\n}\n\nexport function BorderTopIcon(props: LucideProps) {\n  return (\n    <svg fill=\"none\" height=\"15\" viewBox=\"0 0 15 15\" width=\"15\" xmlns=\"http://www.w3.org/2000/svg\" {...props}>\n      <path\n        clipRule=\"evenodd\"\n        d=\"M14 1.75L1 1.75L1 0.249999L14 0.25L14 1.75Z\"\n        fill=\"currentColor\"\n        fillRule=\"evenodd\"\n      ></path>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(-180 8 10)\" width=\"1\" x=\"8\" y=\"10\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(-180 2 10)\" width=\"1\" x=\"2\" y=\"10\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(-180 8 12)\" width=\"1\" x=\"8\" y=\"12\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(-180 2 12)\" width=\"1\" x=\"2\" y=\"12\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(-180 8 8)\" width=\"1\" x=\"8\" y=\"8\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(-180 8 14)\" width=\"1\" x=\"8\" y=\"14\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(-180 2 8)\" width=\"1\" x=\"2\" y=\"8\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(-180 2 14)\" width=\"1\" x=\"2\" y=\"14\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(-180 10 8)\" width=\"1\" x=\"10\" y=\"8\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(-180 10 14)\" width=\"1\" x=\"10\" y=\"14\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(-180 12 8)\" width=\"1\" x=\"12\" y=\"8\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(-180 12 14)\" width=\"1\" x=\"12\" y=\"14\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(-180 6 8)\" width=\"1\" x=\"6\" y=\"8\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(-180 6 14)\" width=\"1\" x=\"6\" y=\"14\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(-180 4 8)\" width=\"1\" x=\"4\" y=\"8\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(-180 4 14)\" width=\"1\" x=\"4\" y=\"14\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(-180 8 6)\" width=\"1\" x=\"8\" y=\"6\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(-180 2 6)\" width=\"1\" x=\"2\" y=\"6\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(-180 8 4)\" width=\"1\" x=\"8\" y=\"4\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(-180 2 4)\" width=\"1\" x=\"2\" y=\"4\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(-180 14 10)\" width=\"1\" x=\"14\" y=\"10\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(-180 14 12)\" width=\"1\" x=\"14\" y=\"12\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(-180 14 8)\" width=\"1\" x=\"14\" y=\"8\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(-180 14 14)\" width=\"1\" x=\"14\" y=\"14\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(-180 14 6)\" width=\"1\" x=\"14\" y=\"6\"></rect>\n      <rect fill=\"currentColor\" height=\"1\" rx=\".5\" transform=\"rotate(-180 14 4)\" width=\"1\" x=\"14\" y=\"4\"></rect>\n    </svg>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/table-node-static.tsx",
    "content": "import * as React from \"react\";\n\nimport type { SlateElementProps, TTableCellElement, TTableElement } from \"platejs\";\n\nimport { BaseTablePlugin } from \"@platejs/table\";\nimport { SlateElement } from \"platejs\";\n\nimport { cn } from \"@/utils/cn\";\n\nexport function TableElementStatic({ children, ...props }: SlateElementProps<TTableElement>) {\n  const { disableMarginLeft } = props.editor.getOptions(BaseTablePlugin);\n  const marginLeft = disableMarginLeft ? 0 : props.element.marginLeft;\n\n  return (\n    <SlateElement {...props} className=\"overflow-x-auto py-5\" style={{ paddingLeft: marginLeft }}>\n      <div className=\"group/table relative w-fit\">\n        <table className=\"ml-px mr-0 table h-px table-fixed border-collapse\">\n          <tbody className=\"min-w-full\">{children}</tbody>\n        </table>\n      </div>\n    </SlateElement>\n  );\n}\n\nexport function TableRowElementStatic(props: SlateElementProps) {\n  return (\n    <SlateElement {...props} as=\"tr\" className=\"h-full\">\n      {props.children}\n    </SlateElement>\n  );\n}\n\nexport function TableCellElementStatic({\n  isHeader,\n  ...props\n}: SlateElementProps<TTableCellElement> & {\n  isHeader?: boolean;\n}) {\n  const { editor, element } = props;\n  const { api } = editor.getPlugin(BaseTablePlugin);\n\n  const { minHeight, width } = api.table.getCellSize({ element });\n  const borders = api.table.getCellBorders({ element });\n\n  return (\n    <SlateElement\n      {...props}\n      as={isHeader ? \"th\" : \"td\"}\n      className={cn(\n        \"bg-background h-full overflow-visible border-none p-0\",\n        element.background ? \"bg-(--cellBackground)\" : \"bg-background\",\n        isHeader && \"text-left font-normal *:m-0\",\n        \"before:size-full\",\n        \"before:absolute before:box-border before:select-none before:content-['']\",\n        borders &&\n          cn(\n            borders.bottom?.size && `before:border-b-border before:border-b`,\n            borders.right?.size && `before:border-r-border before:border-r`,\n            borders.left?.size && `before:border-l-border before:border-l`,\n            borders.top?.size && `before:border-t-border before:border-t`,\n          ),\n      )}\n      style={\n        {\n          \"--cellBackground\": element.background,\n          maxWidth: width || 240,\n          minWidth: width || 120,\n        } as React.CSSProperties\n      }\n      attributes={{\n        ...props.attributes,\n        colSpan: api.table.getColSpan(element),\n        rowSpan: api.table.getRowSpan(element),\n      }}\n    >\n      <div className=\"relative z-20 box-border h-full px-4 py-2\" style={{ minHeight }}>\n        {props.children}\n      </div>\n    </SlateElement>\n  );\n}\n\nexport function TableCellHeaderElementStatic(props: SlateElementProps<TTableCellElement>) {\n  return <TableCellElementStatic {...props} isHeader />;\n}\n"
  },
  {
    "path": "app/src/components/ui/table-node.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\n\nimport type * as DropdownMenuPrimitive from \"@radix-ui/react-dropdown-menu\";\n\nimport { useDraggable, useDropLine } from \"@platejs/dnd\";\nimport { BlockSelectionPlugin, useBlockSelected } from \"@platejs/selection/react\";\nimport { setCellBackground } from \"@platejs/table\";\nimport {\n  TablePlugin,\n  TableProvider,\n  useTableBordersDropdownMenuContentState,\n  useTableCellElement,\n  useTableCellElementResizable,\n  useTableElement,\n  useTableMergeState,\n} from \"@platejs/table/react\";\nimport { PopoverAnchor } from \"@radix-ui/react-popover\";\nimport { cva } from \"class-variance-authority\";\nimport {\n  ArrowDown,\n  ArrowLeft,\n  ArrowRight,\n  ArrowUp,\n  CombineIcon,\n  EraserIcon,\n  Grid2X2Icon,\n  GripVertical,\n  PaintBucketIcon,\n  SquareSplitHorizontalIcon,\n  Trash2Icon,\n  XIcon,\n} from \"lucide-react\";\nimport {\n  type TElement,\n  type TTableCellElement,\n  type TTableElement,\n  type TTableRowElement,\n  KEYS,\n  PathApi,\n} from \"platejs\";\nimport {\n  type PlateElementProps,\n  PlateElement,\n  useComposedRef,\n  useEditorPlugin,\n  useEditorRef,\n  useEditorSelector,\n  useElement,\n  useFocusedLast,\n  usePluginOption,\n  useReadOnly,\n  useRemoveNodeButton,\n  useSelected,\n  withHOC,\n} from \"platejs/react\";\nimport { useElementSelector } from \"platejs/react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuCheckboxItem,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuItem,\n  DropdownMenuPortal,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Popover, PopoverContent } from \"@/components/ui/popover\";\nimport { cn } from \"@/utils/cn\";\n\nimport { blockSelectionVariants } from \"./block-selection\";\nimport { ColorDropdownMenuItems, DEFAULT_COLORS } from \"./font-color-toolbar-button\";\nimport { ResizeHandle } from \"./resize-handle\";\nimport {\n  BorderAllIcon,\n  BorderBottomIcon,\n  BorderLeftIcon,\n  BorderNoneIcon,\n  BorderRightIcon,\n  BorderTopIcon,\n} from \"./table-icons\";\nimport { Toolbar, ToolbarButton, ToolbarGroup, ToolbarMenuGroup } from \"./toolbar\";\nexport const TableElement = withHOC(\n  TableProvider,\n  function TableElement({ children, ...props }: PlateElementProps<TTableElement>) {\n    const readOnly = useReadOnly();\n    const isSelectionAreaVisible = usePluginOption(BlockSelectionPlugin, \"isSelectionAreaVisible\");\n    const hasControls = !readOnly && !isSelectionAreaVisible;\n    const { isSelectingCell, marginLeft, props: tableProps } = useTableElement();\n\n    const isSelectingTable = useBlockSelected(props.element.id as string);\n\n    const content = (\n      <PlateElement\n        {...props}\n        className={cn(\"overflow-x-auto py-5\", hasControls && \"-ml-2 *:data-[slot=block-selection]:left-2\")}\n        style={{ paddingLeft: marginLeft }}\n      >\n        <div className=\"group/table relative w-fit\">\n          <table\n            className={cn(\n              \"ml-px mr-0 table h-px table-fixed border-collapse\",\n              isSelectingCell && \"selection:bg-transparent\",\n            )}\n            {...tableProps}\n          >\n            <tbody className=\"min-w-full\">{children}</tbody>\n          </table>\n\n          {isSelectingTable && <div className={blockSelectionVariants()} contentEditable={false} />}\n        </div>\n      </PlateElement>\n    );\n\n    if (readOnly) {\n      return content;\n    }\n\n    return <TableFloatingToolbar>{content}</TableFloatingToolbar>;\n  },\n);\n\nfunction TableFloatingToolbar({ children, ...props }: React.ComponentProps<typeof PopoverContent>) {\n  const { tf } = useEditorPlugin(TablePlugin);\n  const selected = useSelected();\n  const element = useElement<TTableElement>();\n  const { props: buttonProps } = useRemoveNodeButton({ element });\n  const collapsedInside = useEditorSelector((editor) => selected && editor.api.isCollapsed(), [selected]);\n  const isFocusedLast = useFocusedLast();\n\n  const { canMerge, canSplit } = useTableMergeState();\n\n  return (\n    <Popover open={isFocusedLast && (canMerge || canSplit || collapsedInside)} modal={false}>\n      <PopoverAnchor asChild>{children}</PopoverAnchor>\n      <PopoverContent asChild onOpenAutoFocus={(e) => e.preventDefault()} contentEditable={false} {...props}>\n        <Toolbar\n          className=\"scrollbar-hide bg-popover flex w-auto max-w-[80vw] flex-row overflow-x-auto rounded-md border p-1 shadow-md print:hidden\"\n          contentEditable={false}\n        >\n          <ToolbarGroup>\n            <ColorDropdownMenu tooltip=\"Background color\">\n              <PaintBucketIcon />\n            </ColorDropdownMenu>\n            {canMerge && (\n              <ToolbarButton\n                onClick={() => tf.table.merge()}\n                onMouseDown={(e) => e.preventDefault()}\n                tooltip=\"Merge cells\"\n              >\n                <CombineIcon />\n              </ToolbarButton>\n            )}\n            {canSplit && (\n              <ToolbarButton\n                onClick={() => tf.table.split()}\n                onMouseDown={(e) => e.preventDefault()}\n                tooltip=\"Split cell\"\n              >\n                <SquareSplitHorizontalIcon />\n              </ToolbarButton>\n            )}\n\n            <DropdownMenu modal={false}>\n              <DropdownMenuTrigger asChild>\n                <ToolbarButton tooltip=\"Cell borders\">\n                  <Grid2X2Icon />\n                </ToolbarButton>\n              </DropdownMenuTrigger>\n\n              <DropdownMenuPortal>\n                <TableBordersDropdownMenuContent />\n              </DropdownMenuPortal>\n            </DropdownMenu>\n\n            {collapsedInside && (\n              <ToolbarGroup>\n                <ToolbarButton tooltip=\"Delete table\" {...buttonProps}>\n                  <Trash2Icon />\n                </ToolbarButton>\n              </ToolbarGroup>\n            )}\n          </ToolbarGroup>\n\n          {collapsedInside && (\n            <ToolbarGroup>\n              <ToolbarButton\n                onClick={() => {\n                  tf.insert.tableRow({ before: true });\n                }}\n                onMouseDown={(e) => e.preventDefault()}\n                tooltip=\"Insert row before\"\n              >\n                <ArrowUp />\n              </ToolbarButton>\n              <ToolbarButton\n                onClick={() => {\n                  tf.insert.tableRow();\n                }}\n                onMouseDown={(e) => e.preventDefault()}\n                tooltip=\"Insert row after\"\n              >\n                <ArrowDown />\n              </ToolbarButton>\n              <ToolbarButton\n                onClick={() => {\n                  tf.remove.tableRow();\n                }}\n                onMouseDown={(e) => e.preventDefault()}\n                tooltip=\"Delete row\"\n              >\n                <XIcon />\n              </ToolbarButton>\n            </ToolbarGroup>\n          )}\n\n          {collapsedInside && (\n            <ToolbarGroup>\n              <ToolbarButton\n                onClick={() => {\n                  tf.insert.tableColumn({ before: true });\n                }}\n                onMouseDown={(e) => e.preventDefault()}\n                tooltip=\"Insert column before\"\n              >\n                <ArrowLeft />\n              </ToolbarButton>\n              <ToolbarButton\n                onClick={() => {\n                  tf.insert.tableColumn();\n                }}\n                onMouseDown={(e) => e.preventDefault()}\n                tooltip=\"Insert column after\"\n              >\n                <ArrowRight />\n              </ToolbarButton>\n              <ToolbarButton\n                onClick={() => {\n                  tf.remove.tableColumn();\n                }}\n                onMouseDown={(e) => e.preventDefault()}\n                tooltip=\"Delete column\"\n              >\n                <XIcon />\n              </ToolbarButton>\n            </ToolbarGroup>\n          )}\n        </Toolbar>\n      </PopoverContent>\n    </Popover>\n  );\n}\n\nfunction TableBordersDropdownMenuContent(props: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {\n  const editor = useEditorRef();\n  const {\n    getOnSelectTableBorder,\n    hasBottomBorder,\n    hasLeftBorder,\n    hasNoBorders,\n    hasOuterBorders,\n    hasRightBorder,\n    hasTopBorder,\n  } = useTableBordersDropdownMenuContentState();\n\n  return (\n    <DropdownMenuContent\n      className=\"min-w-[220px]\"\n      onCloseAutoFocus={(e) => {\n        e.preventDefault();\n        editor.tf.focus();\n      }}\n      align=\"start\"\n      side=\"right\"\n      sideOffset={0}\n      {...props}\n    >\n      <DropdownMenuGroup>\n        <DropdownMenuCheckboxItem checked={hasTopBorder} onCheckedChange={getOnSelectTableBorder(\"top\")}>\n          <BorderTopIcon />\n          <div>Top Border</div>\n        </DropdownMenuCheckboxItem>\n        <DropdownMenuCheckboxItem checked={hasRightBorder} onCheckedChange={getOnSelectTableBorder(\"right\")}>\n          <BorderRightIcon />\n          <div>Right Border</div>\n        </DropdownMenuCheckboxItem>\n        <DropdownMenuCheckboxItem checked={hasBottomBorder} onCheckedChange={getOnSelectTableBorder(\"bottom\")}>\n          <BorderBottomIcon />\n          <div>Bottom Border</div>\n        </DropdownMenuCheckboxItem>\n        <DropdownMenuCheckboxItem checked={hasLeftBorder} onCheckedChange={getOnSelectTableBorder(\"left\")}>\n          <BorderLeftIcon />\n          <div>Left Border</div>\n        </DropdownMenuCheckboxItem>\n      </DropdownMenuGroup>\n\n      <DropdownMenuGroup>\n        <DropdownMenuCheckboxItem checked={hasNoBorders} onCheckedChange={getOnSelectTableBorder(\"none\")}>\n          <BorderNoneIcon />\n          <div>No Border</div>\n        </DropdownMenuCheckboxItem>\n        <DropdownMenuCheckboxItem checked={hasOuterBorders} onCheckedChange={getOnSelectTableBorder(\"outer\")}>\n          <BorderAllIcon />\n          <div>Outside Borders</div>\n        </DropdownMenuCheckboxItem>\n      </DropdownMenuGroup>\n    </DropdownMenuContent>\n  );\n}\n\nfunction ColorDropdownMenu({ children, tooltip }: { children: React.ReactNode; tooltip: string }) {\n  const [open, setOpen] = React.useState(false);\n\n  const editor = useEditorRef();\n  const selectedCells = usePluginOption(TablePlugin, \"selectedCells\");\n\n  const onUpdateColor = React.useCallback(\n    (color: string) => {\n      setOpen(false);\n      setCellBackground(editor, { color, selectedCells: selectedCells ?? [] });\n    },\n    [selectedCells, editor],\n  );\n\n  const onClearColor = React.useCallback(() => {\n    setOpen(false);\n    setCellBackground(editor, {\n      color: null,\n      selectedCells: selectedCells ?? [],\n    });\n  }, [selectedCells, editor]);\n\n  return (\n    <DropdownMenu open={open} onOpenChange={setOpen} modal={false}>\n      <DropdownMenuTrigger asChild>\n        <ToolbarButton tooltip={tooltip}>{children}</ToolbarButton>\n      </DropdownMenuTrigger>\n\n      <DropdownMenuContent align=\"start\">\n        <ToolbarMenuGroup label=\"Colors\">\n          <ColorDropdownMenuItems className=\"px-2\" colors={DEFAULT_COLORS} updateColor={onUpdateColor} />\n        </ToolbarMenuGroup>\n        <DropdownMenuGroup>\n          <DropdownMenuItem className=\"p-2\" onClick={onClearColor}>\n            <EraserIcon />\n            <span>Clear</span>\n          </DropdownMenuItem>\n        </DropdownMenuGroup>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n\nexport function TableRowElement(props: PlateElementProps<TTableRowElement>) {\n  const { element } = props;\n  const readOnly = useReadOnly();\n  const selected = useSelected();\n  const editor = useEditorRef();\n  const isSelectionAreaVisible = usePluginOption(BlockSelectionPlugin, \"isSelectionAreaVisible\");\n  const hasControls = !readOnly && !isSelectionAreaVisible;\n\n  const { isDragging, previewRef, handleRef } = useDraggable({\n    element,\n    type: element.type,\n    canDropNode: ({ dragEntry, dropEntry }) =>\n      PathApi.equals(PathApi.parent(dragEntry[1]), PathApi.parent(dropEntry[1])),\n    onDropHandler: (_, { dragItem }) => {\n      const dragElement = (dragItem as { element: TElement }).element;\n\n      if (dragElement) {\n        editor.tf.select(dragElement);\n      }\n    },\n  });\n\n  return (\n    <PlateElement\n      {...props}\n      ref={useComposedRef(props.ref, previewRef)}\n      as=\"tr\"\n      className={cn(\"group/row\", isDragging && \"opacity-50\")}\n      attributes={{\n        ...props.attributes,\n        \"data-selected\": selected ? \"true\" : undefined,\n      }}\n    >\n      {hasControls && (\n        <td className=\"w-2 select-none\" contentEditable={false}>\n          <RowDragHandle dragRef={handleRef} />\n          <RowDropLine />\n        </td>\n      )}\n\n      {props.children}\n    </PlateElement>\n  );\n}\n\nfunction RowDragHandle({ dragRef }: { dragRef: React.Ref<any> }) {\n  const editor = useEditorRef();\n  const element = useElement();\n\n  return (\n    <Button\n      ref={dragRef}\n      variant=\"outline\"\n      className={cn(\n        \"z-51 absolute left-0 top-1/2 h-6 w-4 -translate-y-1/2 p-0 focus-visible:ring-0 focus-visible:ring-offset-0\",\n        \"cursor-grab active:cursor-grabbing\",\n        'group-has-data-[resizing=\"true\"]/row:opacity-0 opacity-0 transition-opacity duration-100 group-hover/row:opacity-100',\n      )}\n      onClick={() => {\n        editor.tf.select(element);\n      }}\n    >\n      <GripVertical className=\"text-muted-foreground\" />\n    </Button>\n  );\n}\n\nfunction RowDropLine() {\n  const { dropLine } = useDropLine();\n\n  if (!dropLine) return null;\n\n  return (\n    <div\n      className={cn(\"bg-brand/50 absolute inset-x-0 left-2 z-50 h-0.5\", dropLine === \"top\" ? \"-top-px\" : \"-bottom-px\")}\n    />\n  );\n}\n\nexport function TableCellElement({\n  isHeader,\n  ...props\n}: PlateElementProps<TTableCellElement> & {\n  isHeader?: boolean;\n}) {\n  const { api } = useEditorPlugin(TablePlugin);\n  const readOnly = useReadOnly();\n  const element = props.element;\n\n  const tableId = useElementSelector(([node]) => node.id as string, [], {\n    key: KEYS.table,\n  });\n  const rowId = useElementSelector(([node]) => node.id as string, [], {\n    key: KEYS.tr,\n  });\n  const isSelectingTable = useBlockSelected(tableId);\n  const isSelectingRow = useBlockSelected(rowId) || isSelectingTable;\n  const isSelectionAreaVisible = usePluginOption(BlockSelectionPlugin, \"isSelectionAreaVisible\");\n\n  const { borders, colIndex, colSpan, minHeight, rowIndex, selected, width } = useTableCellElement();\n\n  const { bottomProps, hiddenLeft, leftProps, rightProps } = useTableCellElementResizable({\n    colIndex,\n    colSpan,\n    rowIndex,\n  });\n\n  return (\n    <PlateElement\n      {...props}\n      as={isHeader ? \"th\" : \"td\"}\n      className={cn(\n        \"bg-background h-full overflow-visible border-none p-0\",\n        element.background ? \"bg-(--cellBackground)\" : \"bg-background\",\n        isHeader && \"text-left *:m-0\",\n        \"before:size-full\",\n        selected && \"before:bg-brand/5 before:z-10\",\n        \"before:absolute before:box-border before:select-none before:content-['']\",\n        borders.bottom?.size && `before:border-b-border before:border-b`,\n        borders.right?.size && `before:border-r-border before:border-r`,\n        borders.left?.size && `before:border-l-border before:border-l`,\n        borders.top?.size && `before:border-t-border before:border-t`,\n      )}\n      style={\n        {\n          \"--cellBackground\": element.background,\n          maxWidth: width || 240,\n          minWidth: width || 120,\n        } as React.CSSProperties\n      }\n      attributes={{\n        ...props.attributes,\n        colSpan: api.table.getColSpan(element),\n        rowSpan: api.table.getRowSpan(element),\n      }}\n    >\n      <div className=\"relative z-20 box-border h-full px-3 py-2\" style={{ minHeight }}>\n        {props.children}\n      </div>\n\n      {!isSelectionAreaVisible && (\n        <div\n          className=\"group absolute top-0 size-full select-none\"\n          contentEditable={false}\n          suppressContentEditableWarning={true}\n        >\n          {!readOnly && (\n            <>\n              <ResizeHandle {...rightProps} className=\"-right-1 -top-2 h-[calc(100%_+_8px)] w-2\" data-col={colIndex} />\n              <ResizeHandle {...bottomProps} className=\"-bottom-1 h-2\" />\n              {!hiddenLeft && (\n                <ResizeHandle\n                  {...leftProps}\n                  className=\"-left-1 top-0 w-2\"\n                  data-resizer-left={colIndex === 0 ? \"true\" : undefined}\n                />\n              )}\n\n              <div\n                className={cn(\n                  \"bg-ring absolute top-0 z-30 hidden h-full w-1\",\n                  \"right-[-1.5px]\",\n                  columnResizeVariants({ colIndex: colIndex as any }),\n                )}\n              />\n              {colIndex === 0 && (\n                <div\n                  className={cn(\n                    \"bg-ring absolute top-0 z-30 h-full w-1\",\n                    \"left-[-1.5px]\",\n                    'animate-in fade-in hidden group-has-[[data-resizer-left]:hover]/table:block group-has-[[data-resizer-left][data-resizing=\"true\"]]/table:block',\n                  )}\n                />\n              )}\n            </>\n          )}\n        </div>\n      )}\n\n      {isSelectingRow && <div className={blockSelectionVariants()} contentEditable={false} />}\n    </PlateElement>\n  );\n}\n\nexport function TableCellHeaderElement(props: React.ComponentProps<typeof TableCellElement>) {\n  return <TableCellElement {...props} isHeader />;\n}\n\nconst columnResizeVariants = cva(\"hidden animate-in fade-in\", {\n  variants: {\n    colIndex: {\n      0: 'group-has-[[data-col=\"0\"]:hover]/table:block group-has-[[data-col=\"0\"][data-resizing=\"true\"]]/table:block',\n      1: 'group-has-[[data-col=\"1\"]:hover]/table:block group-has-[[data-col=\"1\"][data-resizing=\"true\"]]/table:block',\n      2: 'group-has-[[data-col=\"2\"]:hover]/table:block group-has-[[data-col=\"2\"][data-resizing=\"true\"]]/table:block',\n      3: 'group-has-[[data-col=\"3\"]:hover]/table:block group-has-[[data-col=\"3\"][data-resizing=\"true\"]]/table:block',\n      4: 'group-has-[[data-col=\"4\"]:hover]/table:block group-has-[[data-col=\"4\"][data-resizing=\"true\"]]/table:block',\n      5: 'group-has-[[data-col=\"5\"]:hover]/table:block group-has-[[data-col=\"5\"][data-resizing=\"true\"]]/table:block',\n      6: 'group-has-[[data-col=\"6\"]:hover]/table:block group-has-[[data-col=\"6\"][data-resizing=\"true\"]]/table:block',\n      7: 'group-has-[[data-col=\"7\"]:hover]/table:block group-has-[[data-col=\"7\"][data-resizing=\"true\"]]/table:block',\n      8: 'group-has-[[data-col=\"8\"]:hover]/table:block group-has-[[data-col=\"8\"][data-resizing=\"true\"]]/table:block',\n      9: 'group-has-[[data-col=\"9\"]:hover]/table:block group-has-[[data-col=\"9\"][data-resizing=\"true\"]]/table:block',\n      10: 'group-has-[[data-col=\"10\"]:hover]/table:block group-has-[[data-col=\"10\"][data-resizing=\"true\"]]/table:block',\n    },\n  },\n});\n"
  },
  {
    "path": "app/src/components/ui/table-toolbar-button.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport type { DropdownMenuProps } from \"@radix-ui/react-dropdown-menu\";\n\nimport { TablePlugin, useTableMergeState } from \"@platejs/table/react\";\nimport {\n  ArrowDown,\n  ArrowLeft,\n  ArrowRight,\n  ArrowUp,\n  Combine,\n  Grid3x3Icon,\n  Table,\n  Trash2Icon,\n  Ungroup,\n  XIcon,\n} from \"lucide-react\";\nimport { KEYS } from \"platejs\";\nimport { useEditorPlugin, useEditorSelector } from \"platejs/react\";\n\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuItem,\n  DropdownMenuSub,\n  DropdownMenuSubContent,\n  DropdownMenuSubTrigger,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { cn } from \"@/utils/cn\";\n\nimport { ToolbarButton } from \"./toolbar\";\n\nexport function TableToolbarButton(props: DropdownMenuProps) {\n  const tableSelected = useEditorSelector((editor) => editor.api.some({ match: { type: KEYS.table } }), []);\n\n  const { editor, tf } = useEditorPlugin(TablePlugin);\n  const [open, setOpen] = React.useState(false);\n  const mergeState = useTableMergeState();\n\n  return (\n    <DropdownMenu open={open} onOpenChange={setOpen} modal={false} {...props}>\n      <DropdownMenuTrigger asChild>\n        <ToolbarButton pressed={open} tooltip=\"Table\" isDropdown>\n          <Table />\n        </ToolbarButton>\n      </DropdownMenuTrigger>\n\n      <DropdownMenuContent className=\"flex w-[180px] min-w-0 flex-col\" align=\"start\">\n        <DropdownMenuGroup>\n          <DropdownMenuSub>\n            <DropdownMenuSubTrigger className=\"gap-2 data-[disabled]:pointer-events-none data-[disabled]:opacity-50\">\n              <Grid3x3Icon className=\"size-4\" />\n              <span>Table</span>\n            </DropdownMenuSubTrigger>\n            <DropdownMenuSubContent className=\"m-0 p-0\">\n              <TablePicker />\n            </DropdownMenuSubContent>\n          </DropdownMenuSub>\n\n          <DropdownMenuSub>\n            <DropdownMenuSubTrigger\n              className=\"gap-2 data-[disabled]:pointer-events-none data-[disabled]:opacity-50\"\n              disabled={!tableSelected}\n            >\n              <div className=\"size-4\" />\n              <span>Cell</span>\n            </DropdownMenuSubTrigger>\n            <DropdownMenuSubContent>\n              <DropdownMenuItem\n                className=\"min-w-[180px]\"\n                disabled={!mergeState.canMerge}\n                onSelect={() => {\n                  tf.table.merge();\n                  editor.tf.focus();\n                }}\n              >\n                <Combine />\n                Merge cells\n              </DropdownMenuItem>\n              <DropdownMenuItem\n                className=\"min-w-[180px]\"\n                disabled={!mergeState.canSplit}\n                onSelect={() => {\n                  tf.table.split();\n                  editor.tf.focus();\n                }}\n              >\n                <Ungroup />\n                Split cell\n              </DropdownMenuItem>\n            </DropdownMenuSubContent>\n          </DropdownMenuSub>\n\n          <DropdownMenuSub>\n            <DropdownMenuSubTrigger\n              className=\"gap-2 data-[disabled]:pointer-events-none data-[disabled]:opacity-50\"\n              disabled={!tableSelected}\n            >\n              <div className=\"size-4\" />\n              <span>Row</span>\n            </DropdownMenuSubTrigger>\n            <DropdownMenuSubContent>\n              <DropdownMenuItem\n                className=\"min-w-[180px]\"\n                disabled={!tableSelected}\n                onSelect={() => {\n                  tf.insert.tableRow({ before: true });\n                  editor.tf.focus();\n                }}\n              >\n                <ArrowUp />\n                Insert row before\n              </DropdownMenuItem>\n              <DropdownMenuItem\n                className=\"min-w-[180px]\"\n                disabled={!tableSelected}\n                onSelect={() => {\n                  tf.insert.tableRow();\n                  editor.tf.focus();\n                }}\n              >\n                <ArrowDown />\n                Insert row after\n              </DropdownMenuItem>\n              <DropdownMenuItem\n                className=\"min-w-[180px]\"\n                disabled={!tableSelected}\n                onSelect={() => {\n                  tf.remove.tableRow();\n                  editor.tf.focus();\n                }}\n              >\n                <XIcon />\n                Delete row\n              </DropdownMenuItem>\n            </DropdownMenuSubContent>\n          </DropdownMenuSub>\n\n          <DropdownMenuSub>\n            <DropdownMenuSubTrigger\n              className=\"gap-2 data-[disabled]:pointer-events-none data-[disabled]:opacity-50\"\n              disabled={!tableSelected}\n            >\n              <div className=\"size-4\" />\n              <span>Column</span>\n            </DropdownMenuSubTrigger>\n            <DropdownMenuSubContent>\n              <DropdownMenuItem\n                className=\"min-w-[180px]\"\n                disabled={!tableSelected}\n                onSelect={() => {\n                  tf.insert.tableColumn({ before: true });\n                  editor.tf.focus();\n                }}\n              >\n                <ArrowLeft />\n                Insert column before\n              </DropdownMenuItem>\n              <DropdownMenuItem\n                className=\"min-w-[180px]\"\n                disabled={!tableSelected}\n                onSelect={() => {\n                  tf.insert.tableColumn();\n                  editor.tf.focus();\n                }}\n              >\n                <ArrowRight />\n                Insert column after\n              </DropdownMenuItem>\n              <DropdownMenuItem\n                className=\"min-w-[180px]\"\n                disabled={!tableSelected}\n                onSelect={() => {\n                  tf.remove.tableColumn();\n                  editor.tf.focus();\n                }}\n              >\n                <XIcon />\n                Delete column\n              </DropdownMenuItem>\n            </DropdownMenuSubContent>\n          </DropdownMenuSub>\n\n          <DropdownMenuItem\n            className=\"min-w-[180px]\"\n            disabled={!tableSelected}\n            onSelect={() => {\n              tf.remove.table();\n              editor.tf.focus();\n            }}\n          >\n            <Trash2Icon />\n            Delete table\n          </DropdownMenuItem>\n        </DropdownMenuGroup>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n\nfunction TablePicker() {\n  const { editor, tf } = useEditorPlugin(TablePlugin);\n\n  const [tablePicker, setTablePicker] = React.useState({\n    grid: Array.from({ length: 8 }, () => Array.from({ length: 8 }).fill(0)),\n    size: { colCount: 0, rowCount: 0 },\n  });\n\n  const onCellMove = (rowIndex: number, colIndex: number) => {\n    const newGrid = [...tablePicker.grid];\n\n    for (let i = 0; i < newGrid.length; i++) {\n      for (let j = 0; j < newGrid[i].length; j++) {\n        newGrid[i][j] = i >= 0 && i <= rowIndex && j >= 0 && j <= colIndex ? 1 : 0;\n      }\n    }\n\n    setTablePicker({\n      grid: newGrid,\n      size: { colCount: colIndex + 1, rowCount: rowIndex + 1 },\n    });\n  };\n\n  return (\n    <div\n      className=\"flex! m-0 flex-col p-0\"\n      onClick={() => {\n        tf.insert.table(tablePicker.size, { select: true });\n        editor.tf.focus();\n      }}\n    >\n      <div className=\"grid size-[130px] grid-cols-8 gap-0.5 p-1\">\n        {tablePicker.grid.map((rows, rowIndex) =>\n          rows.map((value, columIndex) => {\n            return (\n              <div\n                key={`(${rowIndex},${columIndex})`}\n                className={cn(\"bg-secondary col-span-1 size-3 border border-solid\", !!value && \"border-current\")}\n                onMouseMove={() => {\n                  onCellMove(rowIndex, columIndex);\n                }}\n              />\n            );\n          }),\n        )}\n      </div>\n\n      <div className=\"text-center text-xs text-current\">\n        {tablePicker.size.rowCount} x {tablePicker.size.colCount}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/tabs.tsx",
    "content": "import * as React from \"react\";\nimport * as TabsPrimitive from \"@radix-ui/react-tabs\";\n\nimport { cn } from \"@/utils/cn\";\nimport { SoundService } from \"@/core/service/feedbackService/SoundService\";\n\nfunction Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {\n  return <TabsPrimitive.Root data-slot=\"tabs\" className={cn(\"flex flex-col gap-2\", className)} {...props} />;\n}\n\nfunction TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {\n  return (\n    <TabsPrimitive.List\n      data-slot=\"tabs-list\"\n      className={cn(\n        \"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {\n  return (\n    <TabsPrimitive.Trigger\n      data-slot=\"tabs-trigger\"\n      onMouseEnter={() => {\n        SoundService.play.mouseEnterButton();\n      }}\n      onMouseDown={() => {\n        SoundService.play.mouseClickButton();\n      }}\n      className={cn(\n        \"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring hover:text-foreground focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 text-sm font-medium focus-visible:outline-1 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nfunction TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {\n  return <TabsPrimitive.Content data-slot=\"tabs-content\" className={cn(\"flex-1 outline-none\", className)} {...props} />;\n}\n\nexport { Tabs, TabsList, TabsTrigger, TabsContent };\n"
  },
  {
    "path": "app/src/components/ui/textarea.tsx",
    "content": "import * as React from \"react\";\n\nimport { cn } from \"@/utils/cn\";\n\nfunction Textarea({ className, ...props }: React.ComponentProps<\"textarea\">) {\n  return (\n    <textarea\n      data-slot=\"textarea\"\n      className={cn(\n        \"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 field-sizing-content shadow-xs flex min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport { Textarea };\n"
  },
  {
    "path": "app/src/components/ui/toc-node-static.tsx",
    "content": "// @ts-nocheck\nimport * as React from \"react\";\n\nimport type { SlateEditor, SlateElementProps, TElement } from \"platejs\";\n\nimport { type Heading, BaseTocPlugin, isHeading } from \"@platejs/toc\";\nimport { cva } from \"class-variance-authority\";\nimport { NodeApi, SlateElement } from \"platejs\";\n\nimport { Button } from \"@/components/ui/button\";\n\nconst headingItemVariants = cva(\n  \"block h-auto w-full cursor-pointer truncate rounded-none px-0.5 py-1.5 text-left font-medium text-muted-foreground underline decoration-[0.5px] underline-offset-4 hover:bg-accent hover:text-muted-foreground\",\n  {\n    variants: {\n      depth: {\n        1: \"pl-0.5\",\n        2: \"pl-[26px]\",\n        3: \"pl-[50px]\",\n      },\n    },\n  },\n);\n\nexport function TocElementStatic(props: SlateElementProps) {\n  const { editor } = props;\n  const headingList = getHeadingList(editor);\n\n  return (\n    <SlateElement {...props} className=\"mb-1 p-0\">\n      <div>\n        {headingList.length > 0 ? (\n          headingList.map((item) => (\n            <Button\n              key={item.title}\n              variant=\"ghost\"\n              className={headingItemVariants({\n                depth: item.depth as 1 | 2 | 3,\n              })}\n            >\n              {item.title}\n            </Button>\n          ))\n        ) : (\n          <div className=\"text-sm text-gray-500\">Create a heading to display the table of contents.</div>\n        )}\n      </div>\n      {props.children}\n    </SlateElement>\n  );\n}\n\nconst headingDepth: Record<string, number> = {\n  h1: 1,\n  h2: 2,\n  h3: 3,\n  h4: 4,\n  h5: 5,\n  h6: 6,\n};\n\nconst getHeadingList = (editor?: SlateEditor) => {\n  if (!editor) return [];\n\n  const options = editor.getOptions(BaseTocPlugin);\n\n  if (options.queryHeading) {\n    return options.queryHeading(editor);\n  }\n\n  const headingList: Heading[] = [];\n\n  const values = editor.api.nodes<TElement>({\n    at: [],\n    match: (n) => isHeading(n),\n  });\n\n  if (!values) return [];\n\n  Array.from(values, ([node, path]) => {\n    const { type } = node;\n    const title = NodeApi.string(node);\n    const depth = headingDepth[type];\n    const id = node.id as string;\n\n    if (title) {\n      headingList.push({ id, depth, path, title, type });\n    }\n  });\n\n  return headingList;\n};\n"
  },
  {
    "path": "app/src/components/ui/toc-node.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\n\nimport type { PlateElementProps } from \"platejs/react\";\n\nimport { useTocElement, useTocElementState } from \"@platejs/toc/react\";\nimport { cva } from \"class-variance-authority\";\nimport { PlateElement } from \"platejs/react\";\n\nimport { Button } from \"@/components/ui/button\";\n\nconst headingItemVariants = cva(\n  \"block h-auto w-full cursor-pointer truncate rounded-none px-0.5 py-1.5 text-left font-medium text-muted-foreground underline decoration-[0.5px] underline-offset-4 hover:bg-accent hover:text-muted-foreground\",\n  {\n    variants: {\n      depth: {\n        1: \"pl-0.5\",\n        2: \"pl-[26px]\",\n        3: \"pl-[50px]\",\n      },\n    },\n  },\n);\n\nexport function TocElement(props: PlateElementProps) {\n  const state = useTocElementState();\n  const { props: btnProps } = useTocElement(state);\n  const { headingList } = state;\n\n  return (\n    <PlateElement {...props} className=\"mb-1 p-0\">\n      <div contentEditable={false}>\n        {headingList.length > 0 ? (\n          headingList.map((item) => (\n            <Button\n              key={item.id}\n              variant=\"ghost\"\n              className={headingItemVariants({\n                depth: item.depth as 1 | 2 | 3,\n              })}\n              onClick={(e) => btnProps.onClick(e, item, \"smooth\")}\n              aria-current\n            >\n              {item.title}\n            </Button>\n          ))\n        ) : (\n          <div className=\"text-sm text-gray-500\">Create a heading to display the table of contents.</div>\n        )}\n      </div>\n      {props.children}\n    </PlateElement>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/toggle-node-static.tsx",
    "content": "// @ts-nocheck\nimport * as React from \"react\";\n\nimport type { SlateElementProps } from \"platejs\";\n\nimport { ChevronRight } from \"lucide-react\";\nimport { SlateElement } from \"platejs\";\n\nexport function ToggleElementStatic(props: SlateElementProps) {\n  return (\n    <SlateElement {...props} className=\"pl-6\">\n      <div\n        className=\"text-muted-foreground hover:bg-accent absolute -left-0.5 top-0 size-6 cursor-pointer select-none items-center justify-center rounded-md p-px transition-colors [&_svg]:size-4\"\n        contentEditable={false}\n      >\n        <ChevronRight className=\"rotate-0 transition-transform duration-75\" />\n      </div>\n      {props.children}\n    </SlateElement>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/toggle-node.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\n\nimport type { PlateElementProps } from \"platejs/react\";\n\nimport { useToggleButton, useToggleButtonState } from \"@platejs/toggle/react\";\nimport { ChevronRight } from \"lucide-react\";\nimport { PlateElement } from \"platejs/react\";\n\nimport { Button } from \"@/components/ui/button\";\n\nexport function ToggleElement(props: PlateElementProps) {\n  const element = props.element;\n  const state = useToggleButtonState(element.id as string);\n  const { buttonProps, open } = useToggleButton(state);\n\n  return (\n    <PlateElement {...props} className=\"pl-6\">\n      <Button\n        size=\"icon\"\n        variant=\"ghost\"\n        className=\"text-muted-foreground hover:bg-accent absolute -left-0.5 top-0 size-6 cursor-pointer select-none items-center justify-center rounded-md p-px transition-colors [&_svg]:size-4\"\n        contentEditable={false}\n        {...buttonProps}\n      >\n        <ChevronRight\n          className={open ? \"rotate-90 transition-transform duration-75\" : \"rotate-0 transition-transform duration-75\"}\n        />\n      </Button>\n      {props.children}\n    </PlateElement>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/toggle-toolbar-button.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { useToggleToolbarButton, useToggleToolbarButtonState } from \"@platejs/toggle/react\";\nimport { ListCollapseIcon } from \"lucide-react\";\n\nimport { ToolbarButton } from \"./toolbar\";\n\nexport function ToggleToolbarButton(props: React.ComponentProps<typeof ToolbarButton>) {\n  const state = useToggleToolbarButtonState();\n  const { props: buttonProps } = useToggleToolbarButton(state);\n\n  return (\n    <ToolbarButton {...props} {...buttonProps} tooltip=\"Toggle\">\n      <ListCollapseIcon />\n    </ToolbarButton>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/toolbar.tsx",
    "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport * as ToolbarPrimitive from \"@radix-ui/react-toolbar\";\nimport * as TooltipPrimitive from \"@radix-ui/react-tooltip\";\nimport { type VariantProps, cva } from \"class-variance-authority\";\nimport { ChevronDown } from \"lucide-react\";\n\nimport { DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuSeparator } from \"@/components/ui/dropdown-menu\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { Tooltip, TooltipTrigger } from \"@/components/ui/tooltip\";\nimport { cn } from \"@/utils/cn\";\n\nexport function Toolbar({ className, ...props }: React.ComponentProps<typeof ToolbarPrimitive.Root>) {\n  return <ToolbarPrimitive.Root className={cn(\"relative flex select-none items-center\", className)} {...props} />;\n}\n\nexport function ToolbarToggleGroup({\n  className,\n  ...props\n}: React.ComponentProps<typeof ToolbarPrimitive.ToolbarToggleGroup>) {\n  return <ToolbarPrimitive.ToolbarToggleGroup className={cn(\"flex items-center\", className)} {...props} />;\n}\n\nexport function ToolbarLink({ className, ...props }: React.ComponentProps<typeof ToolbarPrimitive.Link>) {\n  return <ToolbarPrimitive.Link className={cn(\"font-medium underline underline-offset-4\", className)} {...props} />;\n}\n\nexport function ToolbarSeparator({ className, ...props }: React.ComponentProps<typeof ToolbarPrimitive.Separator>) {\n  return <ToolbarPrimitive.Separator className={cn(\"bg-border mx-2 my-1 w-px shrink-0\", className)} {...props} />;\n}\n\n// From toggleVariants\nconst toolbarButtonVariants = cva(\n  \"inline-flex cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-checked:bg-accent aria-checked:text-accent-foreground aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\",\n  {\n    defaultVariants: {\n      size: \"default\",\n      variant: \"default\",\n    },\n    variants: {\n      size: {\n        default: \"h-9 min-w-9 px-2\",\n        lg: \"h-10 min-w-10 px-2.5\",\n        sm: \"h-8 min-w-8 px-1.5\",\n      },\n      variant: {\n        default: \"bg-transparent\",\n        outline: \"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground\",\n      },\n    },\n  },\n);\n\nconst dropdownArrowVariants = cva(\n  cn(\n    \"inline-flex items-center justify-center rounded-r-md text-sm font-medium text-foreground transition-colors disabled:pointer-events-none disabled:opacity-50\",\n  ),\n  {\n    defaultVariants: {\n      size: \"sm\",\n      variant: \"default\",\n    },\n    variants: {\n      size: {\n        default: \"h-9 w-6\",\n        lg: \"h-10 w-8\",\n        sm: \"h-8 w-4\",\n      },\n      variant: {\n        default:\n          \"bg-transparent hover:bg-muted hover:text-muted-foreground aria-checked:bg-accent aria-checked:text-accent-foreground\",\n        outline: \"border border-l-0 border-input bg-transparent hover:bg-accent hover:text-accent-foreground\",\n      },\n    },\n  },\n);\n\ntype ToolbarButtonProps = {\n  isDropdown?: boolean;\n  pressed?: boolean;\n} & Omit<React.ComponentPropsWithoutRef<typeof ToolbarToggleItem>, \"asChild\" | \"value\"> &\n  VariantProps<typeof toolbarButtonVariants>;\n\nexport const ToolbarButton = withTooltip(function ToolbarButton({\n  children,\n  className,\n  isDropdown,\n  pressed,\n  size = \"sm\",\n  variant,\n  ...props\n}: ToolbarButtonProps) {\n  return typeof pressed === \"boolean\" ? (\n    <ToolbarToggleGroup disabled={props.disabled} value=\"single\" type=\"single\">\n      <ToolbarToggleItem\n        className={cn(\n          toolbarButtonVariants({\n            size,\n            variant,\n          }),\n          isDropdown && \"justify-between gap-1 pr-1\",\n          className,\n        )}\n        value={pressed ? \"single\" : \"\"}\n        {...props}\n      >\n        {isDropdown ? (\n          <>\n            <div className=\"flex flex-1 items-center gap-2 whitespace-nowrap\">{children}</div>\n            <div>\n              <ChevronDown className=\"text-muted-foreground size-3.5\" data-icon />\n            </div>\n          </>\n        ) : (\n          children\n        )}\n      </ToolbarToggleItem>\n    </ToolbarToggleGroup>\n  ) : (\n    <ToolbarPrimitive.Button\n      className={cn(\n        toolbarButtonVariants({\n          size,\n          variant,\n        }),\n        isDropdown && \"pr-1\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n    </ToolbarPrimitive.Button>\n  );\n});\n\nexport function ToolbarSplitButton({ className, ...props }: React.ComponentPropsWithoutRef<typeof ToolbarButton>) {\n  return <ToolbarButton className={cn(\"group flex gap-0 px-0 hover:bg-transparent\", className)} {...props} />;\n}\n\ntype ToolbarSplitButtonPrimaryProps = Omit<React.ComponentPropsWithoutRef<typeof ToolbarToggleItem>, \"value\"> &\n  VariantProps<typeof toolbarButtonVariants>;\n\nexport function ToolbarSplitButtonPrimary({\n  children,\n  className,\n  size = \"sm\",\n  variant,\n  ...props\n}: ToolbarSplitButtonPrimaryProps) {\n  return (\n    <span\n      className={cn(\n        toolbarButtonVariants({\n          size,\n          variant,\n        }),\n        \"rounded-r-none\",\n        \"group-data-[pressed=true]:bg-accent group-data-[pressed=true]:text-accent-foreground\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n    </span>\n  );\n}\n\nexport function ToolbarSplitButtonSecondary({\n  className,\n  size,\n  variant,\n  ...props\n}: React.ComponentPropsWithoutRef<\"span\"> & VariantProps<typeof dropdownArrowVariants>) {\n  return (\n    <span\n      className={cn(\n        dropdownArrowVariants({\n          size,\n          variant,\n        }),\n        \"group-data-[pressed=true]:bg-accent group-data-[pressed=true]:text-accent-foreground\",\n        className,\n      )}\n      onClick={(e) => e.stopPropagation()}\n      role=\"button\"\n      {...props}\n    >\n      <ChevronDown className=\"text-muted-foreground size-3.5\" data-icon />\n    </span>\n  );\n}\n\nexport function ToolbarToggleItem({\n  className,\n  size = \"sm\",\n  variant,\n  ...props\n}: React.ComponentProps<typeof ToolbarPrimitive.ToggleItem> & VariantProps<typeof toolbarButtonVariants>) {\n  return <ToolbarPrimitive.ToggleItem className={cn(toolbarButtonVariants({ size, variant }), className)} {...props} />;\n}\n\nexport function ToolbarGroup({ children, className }: React.ComponentProps<\"div\">) {\n  return (\n    <div className={cn(\"group/toolbar-group\", \"relative hidden has-[button]:flex\", className)}>\n      <div className=\"flex items-center\">{children}</div>\n\n      <div className=\"group-last/toolbar-group:hidden! mx-1.5 py-0.5\">\n        <Separator orientation=\"vertical\" />\n      </div>\n    </div>\n  );\n}\n\ntype TooltipProps<T extends React.ElementType> = {\n  tooltip?: React.ReactNode;\n  tooltipContentProps?: Omit<React.ComponentPropsWithoutRef<typeof TooltipContent>, \"children\">;\n  tooltipProps?: Omit<React.ComponentPropsWithoutRef<typeof Tooltip>, \"children\">;\n  tooltipTriggerProps?: React.ComponentPropsWithoutRef<typeof TooltipTrigger>;\n} & React.ComponentProps<T>;\n\nfunction withTooltip<T extends React.ElementType>(Component: T) {\n  return function ExtendComponent({\n    tooltip,\n    tooltipContentProps,\n    tooltipProps,\n    tooltipTriggerProps,\n    ...props\n  }: TooltipProps<T>) {\n    const [mounted, setMounted] = React.useState(false);\n\n    React.useEffect(() => {\n      setMounted(true);\n    }, []);\n\n    const component = <Component {...(props as React.ComponentProps<T>)} />;\n\n    if (tooltip && mounted) {\n      return (\n        <Tooltip {...tooltipProps}>\n          <TooltipTrigger asChild {...tooltipTriggerProps}>\n            {component}\n          </TooltipTrigger>\n\n          <TooltipContent {...tooltipContentProps}>{tooltip}</TooltipContent>\n        </Tooltip>\n      );\n    }\n\n    return component;\n  };\n}\n\nfunction TooltipContent({\n  children,\n  className,\n  // CHANGE\n  sideOffset = 4,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Content>) {\n  return (\n    <TooltipPrimitive.Portal>\n      <TooltipPrimitive.Content\n        className={cn(\n          \"origin-(--radix-tooltip-content-transform-origin) bg-primary text-primary-foreground z-50 w-fit text-balance rounded-md px-3 py-1.5 text-xs\",\n          className,\n        )}\n        data-slot=\"tooltip-content\"\n        sideOffset={sideOffset}\n        {...props}\n      >\n        {children}\n        {/* CHANGE */}\n        {/* <TooltipPrimitive.Arrow className=\"z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-primary fill-primary\" /> */}\n      </TooltipPrimitive.Content>\n    </TooltipPrimitive.Portal>\n  );\n}\n\nexport function ToolbarMenuGroup({\n  children,\n  className,\n  label,\n  ...props\n}: React.ComponentProps<typeof DropdownMenuRadioGroup> & { label?: string }) {\n  return (\n    <>\n      <DropdownMenuSeparator\n        className={cn(\n          \"hidden\",\n          \"mb-0 shrink-0 peer-has-[[role=menuitem]]/menu-group:block peer-has-[[role=menuitemradio]]/menu-group:block peer-has-[[role=option]]/menu-group:block\",\n        )}\n      />\n\n      <DropdownMenuRadioGroup\n        {...props}\n        className={cn(\n          \"hidden\",\n          \"peer/menu-group group/menu-group my-1.5 has-[[role=menuitem]]:block has-[[role=menuitemradio]]:block has-[[role=option]]:block\",\n          className,\n        )}\n      >\n        {label && (\n          <DropdownMenuLabel className=\"text-muted-foreground select-none text-xs font-semibold\">\n            {label}\n          </DropdownMenuLabel>\n        )}\n        {children}\n      </DropdownMenuRadioGroup>\n    </>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/tooltip.tsx",
    "content": "\"use client\";\n\nimport * as TooltipPrimitive from \"@radix-ui/react-tooltip\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/utils/cn\";\n\nfunction TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {\n  return <TooltipPrimitive.Provider data-slot=\"tooltip-provider\" delayDuration={delayDuration} {...props} />;\n}\n\nfunction Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {\n  return (\n    <TooltipProvider>\n      <TooltipPrimitive.Root data-slot=\"tooltip\" {...props} />\n    </TooltipProvider>\n  );\n}\n\nfunction TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {\n  return <TooltipPrimitive.Trigger data-slot=\"tooltip-trigger\" {...props} />;\n}\n\nfunction TooltipContent({\n  className,\n  sideOffset = 0,\n  children,\n  ...props\n}: React.ComponentProps<typeof TooltipPrimitive.Content>) {\n  return (\n    <TooltipPrimitive.Portal>\n      <TooltipPrimitive.Content\n        data-slot=\"tooltip-content\"\n        sideOffset={sideOffset}\n        className={cn(\n          \"bg-primary/75 border-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-tooltip-content-transform-origin) z-50 w-fit text-balance rounded-md border px-3 py-1.5 text-xs\",\n          className,\n        )}\n        {...props}\n      >\n        {children}\n        {/* <TooltipPrimitive.Arrow className=\"bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]\" /> */}\n      </TooltipPrimitive.Content>\n    </TooltipPrimitive.Portal>\n  );\n}\n\nexport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };\n"
  },
  {
    "path": "app/src/components/ui/tree.tsx",
    "content": "import { cn } from \"@/utils/cn\";\nimport { Check, ChevronRight, X } from \"lucide-react\";\nimport { useState } from \"react\";\n\nexport default function Tree({ obj, deep = 0, className = \"\" }: { obj: any; deep?: number; className?: string }) {\n  const [fold, setFold] = useState(true);\n\n  return (\n    <div className={cn(\"flex flex-col\", className)}>\n      <ChevronRight\n        className={cn(\"cursor-pointer transition\", {\n          \"rotate-90\": !fold,\n          hidden: !(((typeof obj === \"object\" && obj !== null) || obj instanceof Array) && deep > 0),\n        })}\n        onClick={(ev) => {\n          ev.stopPropagation();\n          setFold((prev) => !prev);\n        }}\n      />\n      {((typeof obj === \"object\" && obj !== null) || obj instanceof Array) && deep > 0 && fold ? (\n        <></>\n      ) : // undefined是值，提前检测\n      typeof obj === \"undefined\" ? (\n        <span className=\"opacity-75\">undefined</span>\n      ) : // null属于对象，提前检测\n      obj === null ? (\n        <span className=\"opacity-75\">null</span>\n      ) : // 布尔值\n      typeof obj === \"boolean\" ? (\n        obj ? (\n          <Check className=\"text-indigo-300\" />\n        ) : (\n          <X className=\"text-indigo-300\" />\n        )\n      ) : // 数字\n      typeof obj === \"number\" ? (\n        <span className=\"text-indigo-300\">{obj}</span>\n      ) : // 其他对象\n      typeof obj === \"object\" ? (\n        Object.entries(obj).map(([k, v]) => (\n          <div\n            style={{\n              marginLeft: `${deep * 4}px`,\n            }}\n            key={k}\n            className=\"flex gap-2\"\n          >\n            <span className=\"inline text-green-400\">{obj instanceof Array ? <>[{k}]</> : <>{k}</>}: </span>\n            <Tree obj={v} deep={deep + 1} />\n          </div>\n        ))\n      ) : (\n        // 其他所有类型\n        obj\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/src/components/ui/turn-into-toolbar-button.tsx",
    "content": "// @ts-nocheck\n\"use client\";\n\nimport * as React from \"react\";\n\nimport type { DropdownMenuProps } from \"@radix-ui/react-dropdown-menu\";\nimport type { TElement } from \"platejs\";\n\nimport { DropdownMenuItemIndicator } from \"@radix-ui/react-dropdown-menu\";\nimport {\n  CheckIcon,\n  ChevronRightIcon,\n  Columns3Icon,\n  FileCodeIcon,\n  Heading1Icon,\n  Heading2Icon,\n  Heading3Icon,\n  Heading4Icon,\n  Heading5Icon,\n  Heading6Icon,\n  ListIcon,\n  ListOrderedIcon,\n  PilcrowIcon,\n  QuoteIcon,\n  SquareIcon,\n} from \"lucide-react\";\nimport { KEYS } from \"platejs\";\nimport { useEditorRef, useSelectionFragmentProp } from \"platejs/react\";\n\nimport { getBlockType, setBlockType } from \"@/components/editor/transforms\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuRadioItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\n\nimport { ToolbarButton, ToolbarMenuGroup } from \"./toolbar\";\n\nexport const turnIntoItems = [\n  {\n    icon: <PilcrowIcon />,\n    keywords: [\"paragraph\"],\n    label: \"段落\",\n    value: KEYS.p,\n  },\n  {\n    icon: <Heading1Icon />,\n    keywords: [\"title\", \"h1\"],\n    label: \"一级标题\",\n    value: \"h1\",\n  },\n  {\n    icon: <Heading2Icon />,\n    keywords: [\"subtitle\", \"h2\"],\n    label: \"二级标题\",\n    value: \"h2\",\n  },\n  {\n    icon: <Heading3Icon />,\n    keywords: [\"subtitle\", \"h3\"],\n    label: \"三级标题\",\n    value: \"h3\",\n  },\n  {\n    icon: <Heading4Icon />,\n    keywords: [\"subtitle\", \"h4\"],\n    label: \"四级标题\",\n    value: \"h4\",\n  },\n  {\n    icon: <Heading5Icon />,\n    keywords: [\"subtitle\", \"h5\"],\n    label: \"五级标题\",\n    value: \"h5\",\n  },\n  {\n    icon: <Heading6Icon />,\n    keywords: [\"subtitle\", \"h6\"],\n    label: \"六级标题\",\n    value: \"h6\",\n  },\n  {\n    icon: <ListIcon />,\n    keywords: [\"unordered\", \"ul\", \"-\"],\n    label: \"项目符号列表\",\n    value: KEYS.ul,\n  },\n  {\n    icon: <ListOrderedIcon />,\n    keywords: [\"ordered\", \"ol\", \"1\"],\n    label: \"编号列表\",\n    value: KEYS.ol,\n  },\n  {\n    icon: <SquareIcon />,\n    keywords: [\"checklist\", \"task\", \"checkbox\", \"[]\"],\n    label: \"待办列表\",\n    value: KEYS.listTodo,\n  },\n  {\n    icon: <ChevronRightIcon />,\n    keywords: [\"collapsible\", \"expandable\"],\n    label: \"折叠列表\",\n    value: KEYS.toggle,\n  },\n  {\n    icon: <FileCodeIcon />,\n    keywords: [\"```\"],\n    label: \"代码块\",\n    value: KEYS.codeBlock,\n  },\n  {\n    icon: <QuoteIcon />,\n    keywords: [\"citation\", \"blockquote\", \">\"],\n    label: \"引用\",\n    value: KEYS.blockquote,\n  },\n  {\n    icon: <Columns3Icon />,\n    label: \"三栏布局\",\n    value: \"action_three_columns\",\n  },\n];\n\nexport function TurnIntoToolbarButton(props: DropdownMenuProps) {\n  const editor = useEditorRef();\n  const [open, setOpen] = React.useState(false);\n\n  const value = useSelectionFragmentProp({\n    defaultValue: KEYS.p,\n    getProp: (node) => getBlockType(node as TElement),\n  });\n  const selectedItem = React.useMemo(\n    () => turnIntoItems.find((item) => item.value === (value ?? KEYS.p)) ?? turnIntoItems[0],\n    [value],\n  );\n\n  return (\n    <DropdownMenu open={open} onOpenChange={setOpen} modal={false} {...props}>\n      <DropdownMenuTrigger asChild>\n        <ToolbarButton className=\"min-w-[125px]\" pressed={open} tooltip=\"Turn into\" isDropdown>\n          {selectedItem.label}\n        </ToolbarButton>\n      </DropdownMenuTrigger>\n\n      <DropdownMenuContent\n        className=\"ignore-click-outside/toolbar min-w-0\"\n        onCloseAutoFocus={(e) => {\n          e.preventDefault();\n          editor.tf.focus();\n        }}\n        align=\"start\"\n      >\n        <ToolbarMenuGroup\n          value={value}\n          onValueChange={(type) => {\n            setBlockType(editor, type);\n          }}\n          label=\"Turn into\"\n        >\n          {turnIntoItems.map(({ icon, label, value: itemValue }) => (\n            <DropdownMenuRadioItem\n              key={itemValue}\n              className=\"*:first:[span]:hidden min-w-[180px] pl-2\"\n              value={itemValue}\n            >\n              <span className=\"pointer-events-none absolute right-2 flex size-3.5 items-center justify-center\">\n                <DropdownMenuItemIndicator>\n                  <CheckIcon />\n                </DropdownMenuItemIndicator>\n              </span>\n              {icon}\n              {label}\n            </DropdownMenuRadioItem>\n          ))}\n        </ToolbarMenuGroup>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "app/src/components/vditor-panel.tsx",
    "content": "import { useEffect, useRef, useState } from \"react\";\nimport Vditor from \"vditor\";\nimport \"vditor/dist/index.css\";\n\nexport default function MarkdownEditor({\n  defaultValue = \"\",\n  onChange,\n  id = \"\",\n  className = \"\",\n  options = {},\n}: {\n  defaultValue?: string;\n  onChange: (value: string) => void;\n  id?: string;\n  className?: string;\n  options?: Omit<IOptions, \"after\" | \"input\">;\n}) {\n  const [vd, setVd] = useState<Vditor>();\n  const el = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    // 在每次展开面板都会调用一次这个函数 v1.4.13\n\n    const vditor = new Vditor(el.current!, {\n      after: () => {\n        vditor.setValue(defaultValue);\n        setVd(vditor);\n      },\n      // input 有问题，只要一输入内容停下来的时候就被迫失去焦点了。\n      blur: (value: string) => {\n        onChange(value);\n      },\n      theme: \"dark\",\n      preview: {\n        theme: {\n          current: \"dark\",\n        },\n        hljs: {\n          style: \"darcula\",\n        },\n      },\n      cache: { enable: false },\n      ...options,\n    });\n    if (vditor) {\n      setTimeout(() => {\n        vditor.focus();\n      }, 100);\n    }\n\n    return () => {\n      vd?.destroy();\n      setVd(undefined);\n    };\n  }, [defaultValue]);\n\n  return (\n    <div\n      ref={el}\n      id={id}\n      className={className}\n      onKeyDown={(e) => e.stopPropagation()}\n      onKeyUp={(e) => e.stopPropagation()}\n    />\n  );\n}\n"
  },
  {
    "path": "app/src/components/welcome-page.tsx",
    "content": "import { RecentFileManager } from \"@/core/service/dataFileService/RecentFileManager\";\nimport { onNewDraft, onOpenFile } from \"@/core/service/GlobalMenu\";\nimport { Path } from \"@/utils/path\";\nimport { getVersion } from \"@tauri-apps/api/app\";\nimport { open as shellOpen } from \"@tauri-apps/plugin-shell\";\nimport { writeFile } from \"@tauri-apps/plugin-fs\";\nimport {\n  Earth,\n  FilePlus,\n  FolderOpen,\n  Info,\n  LoaderCircle,\n  Map as MapIcon,\n  Settings as SettingsIcon,\n  TableProperties,\n  AlertTriangle,\n  RefreshCw,\n} from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport SettingsWindow from \"../sub/SettingsWindow\";\nimport { toast } from \"sonner\";\nimport { cn } from \"@/utils/cn\";\nimport { AssetsRepository } from \"@/core/service/AssetsRepository\";\nimport { join, tempDir } from \"@tauri-apps/api/path\";\nimport { URI } from \"vscode-uri\";\nimport RecentFilesWindow from \"@/sub/RecentFilesWindow\";\nimport { isMac } from \"@/utils/platform\";\nimport { cpuInfo } from \"tauri-plugin-system-info-api\";\n\nexport default function WelcomePage() {\n  const [recentFiles, setRecentFiles] = useState<RecentFileManager.RecentFile[]>([]);\n  const { t } = useTranslation(\"welcome\");\n  const [appVersion, setAppVersion] = useState(\"unknown\");\n  const [isDownloadingGuideFile, setIsDownloadingGuideFile] = useState(false);\n  const [isLoading, setIsLoading] = useState(false);\n  const [lastClickFileURIPath, setLastClickFileURIPath] = useState(\"\");\n  const [isAmdCpu, setIsAmdCpu] = useState(false);\n  const [currentSlogan, setCurrentSlogan] = useState(\"\");\n  const [isHoveringSlogan, setIsHoveringSlogan] = useState(false);\n\n  // 中文 slogan 列表（展示一些有趣特性或技巧）\n  const slogans = [\n    \"思维框架图和思维导图的区别在于，思维框架图不局限于树形结构，可以自由连接节点，形成用于分析项目结构的网络结构\",\n    \"目前一般不建议一个prg文件超过50MB，否则文件过大可能影响性能，保存缓慢\",\n    \"格式化树形结构时，需要保证每个连线都是一种标准化的方向的连线，例如向右的连线从源头右侧发出，目标左侧接收\",\n    \"alt shift f 可以格式化树形结构，它源自于 VS Code 格式化代码的快捷键。每当按下这个快捷键时，左手像个兰花指。\",\n    \"移动视野有13种方法，具体可以在官网的摄像机章节查看\",\n    \"这个软件不是传统的思维导图软件\",\n    \"打开一个文件后，ctrl + 0 可以直接透明窗口。可以用于一边看视频一边纯键盘操作\",\n    \"WSAD可以移动视野。但其他软件的全局快捷键可能会干扰此软件监听WSAD的松开事件，进而导致视野一直朝着某方向移动，可以手动触发一次松开解决\",\n    \"当视野放大到极限时会回到宏观视角。源自《围观尽头》\",\n    \"F键可以快速聚焦视野到选中的物体，再按下shift+F可以快速回到按下F键之前的宏观视角\",\n    \"选中图片后右下角有一个绿色按钮，可以拖拽改变大小\",\n    \"向左框选和向右框选逻辑不同，源自CAD，框选可以大幅度提升自由布局的移动效率\",\n    \"windows系统中，可以将此软件的exe放入zip中，直接双击预览打开zip文件然后双击打开内部的exe，可以逃过某些禁止运行exe电脑的限制\",\n    \"软件是开源的，可以放心使用。可参考MIT和GPL-3.0双许可协议\",\n    \"prg文件是软件的专有格式，用于存储思维框架图数据。本质是一个zip压缩包，解压后可以看到里面的图片文件\",\n    \"windows系统中，跨文件复制需要在一个软件中打开两个标签页，而非直接双击prg文件打开两个软件\",\n    \"当一个文本节点的内容恰好为一个文件的绝对路径或者网页URL时，可以 中键双击/快捷键/右键 直接打开这个文件或者网页\",\n    \"按住ctrl键框选，可以实现反选。进而可以实现先选中一些节点，再用反选框选一次，选中所有的连线。\",\n    \"框选优先级：框选起点所在Section框 > 节点 > 连线\",\n    \"文本节点的字体大小是指数级别的，因为缩放视野时鼠标滚动的圈数和视野内大小变化尺度也是指数级别的。\",\n    \"WSAD移动时，如果是高空飞行，移动速度会很快，如果是低空飞行，移动速度会很慢。\",\n    \"选中两个节点，按两次数字4，可以左对齐，6是右对齐，8、2是上下对齐，看九宫格小键盘非常直观\",\n    \"当脑机接口与视网膜投屏实现的那一天出现时，这个软件的生命就终结了，获许会以一种新的形态出现\",\n    \"小特性：鼠标在空白地方拖出一个框选框不松手时，按下ctrl+G会直接创建一个框选框大小的Section框。用于自顶向下的绘制大板块结构\",\n    \"不要当一个囤积知识的笔记拷贝者，而是要当一个知识的创造者与思考者。\",\n    \"鼠标移动到窗口顶部空白地方时会出现一个带颜色的框，可以拖动这个区域来移动整个软件窗口\",\n    \"当窗口缩小到足够小时，导航栏等UI会变得极小，可以当成一个迷你小窗口软件来使用\",\n    \"windows系统中，右上角的小黄点可以用于想关闭软件时，直接鼠标无脑顶到右上角直接点击关闭。不需要再看x的位置并瞄准了\",\n    \"推荐使用新版的导出PNG图片功能而非旧版的拼接图片导出功能\",\n  ];\n\n  useEffect(() => {\n    refresh();\n    (async () => {\n      setAppVersion(await getVersion());\n      try {\n        const cpu = await cpuInfo();\n        const cpuBrand = cpu.cpus[0].brand;\n        setIsAmdCpu(cpuBrand.includes(\"AMD\"));\n      } catch (e) {\n        console.error(\"检测CPU信息失败:\", e);\n      }\n    })();\n\n    // 随机选择 slogan\n    randomizeSlogan();\n  }, []);\n\n  // 随机选择一条slogan\n  const randomizeSlogan = () => {\n    const randomIndex = Math.floor(Math.random() * slogans.length);\n    setCurrentSlogan(slogans[randomIndex]);\n  };\n\n  async function refresh() {\n    setIsLoading(true);\n    await RecentFileManager.sortTimeRecentFiles();\n    setRecentFiles(await RecentFileManager.getRecentFiles());\n    setIsLoading(false);\n  }\n\n  return (\n    <div className=\"flex h-full w-full items-center justify-center\">\n      <div className=\"m-2 flex flex-col p-4 sm:gap-8\">\n        {/* 顶部标题区域 */}\n        <div className=\"flex flex-col sm:gap-2\">\n          <div className=\"flex items-center gap-2\">\n            <span className=\"sm:text-3xl\">{t(\"title\")}</span>\n            <a\n              href=\"https://graphif.dev/docs/app/misc/history\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"border-card-foreground/30 hover:border-primary/90 hidden cursor-pointer border-2 opacity-50 sm:inline sm:rounded-lg sm:px-2 sm:py-1 md:text-lg\"\n            >\n              {appVersion}\n            </a>\n          </div>\n          <div\n            className=\"relative hidden text-xs opacity-50 sm:block\"\n            onMouseEnter={() => setIsHoveringSlogan(true)}\n            onMouseLeave={() => setIsHoveringSlogan(false)}\n          >\n            <span>{currentSlogan}</span>\n            {isHoveringSlogan && (\n              <button\n                onClick={(e) => {\n                  e.stopPropagation();\n                  randomizeSlogan();\n                }}\n                className=\"hover:bg-muted absolute right-0 top-0 ml-2 inline-flex cursor-pointer items-center justify-center rounded p-1 transition-all active:scale-90\"\n                title=\"换一条小技巧\"\n              >\n                <RefreshCw size={14} />\n              </button>\n            )}\n          </div>\n          {isAmdCpu && (\n            <div className=\"flex items-center gap-2 rounded-lg border p-3 text-sm text-yellow-600/75\">\n              <AlertTriangle />\n              <span>您的设备（AMD CPU）可能在大屏(4k)下使用时存在渲染卡顿问题</span>\n            </div>\n          )}\n        </div>\n        {/* 底部区域 */}\n        <div className=\"flex sm:gap-16\">\n          <div className=\"flex flex-col sm:gap-8\">\n            {/* 常用操作 宫格区 */}\n            <div className=\"grid grid-cols-2 grid-rows-2 *:flex *:w-max *:cursor-pointer *:items-center *:gap-2 *:hover:opacity-75 *:active:scale-90 sm:gap-2 sm:gap-x-4\">\n              <div\n                onClick={() => {\n                  if (isDownloadingGuideFile) {\n                    return;\n                  }\n                  setIsDownloadingGuideFile(true);\n                  toast.promise(\n                    async () => {\n                      const u8a = await AssetsRepository.fetchFile(\"tutorials/tutorial-main-2.9.prg\");\n                      const dir = await tempDir();\n                      const path = await join(dir, `tutorial-${crypto.randomUUID()}.prg`);\n                      await writeFile(path, u8a);\n                      await onOpenFile(URI.file(path), \"功能说明书\");\n                    },\n                    {\n                      loading: \"正在下载功能说明书\",\n                      error: (err) => {\n                        console.error(\"下载功能说明书失败:\", err);\n                        return (\n                          `下载功能说明书失败，可以尝试访问${AssetsRepository.getGuideFileUrl(\"tutorials/tutorial-main-2.9.prg\")}，请确保您能访问github。` +\n                          err\n                        );\n                      },\n                      finally: () => {\n                        setIsDownloadingGuideFile(false);\n                      },\n                    },\n                  );\n                }}\n              >\n                <MapIcon className={cn(isDownloadingGuideFile && \"animate-spin\")} />\n                <span className=\"hidden sm:inline\">{t(\"newUserGuide\")}</span>\n              </div>\n              <div onClick={onNewDraft}>\n                <FilePlus />\n                <span className=\"hidden sm:inline\">{t(\"newDraft\")}</span>\n                <span className=\"hidden text-xs opacity-50 sm:inline\">{isMac ? \"⌘ + N\" : \"Ctrl + N\"}</span>\n              </div>\n              <div onClick={() => RecentFilesWindow.open()}>\n                <TableProperties />\n                <span className=\"hidden sm:inline\">{t(\"openRecentFiles\")}</span>\n                <span className=\"hidden text-xs opacity-50 sm:inline\">Shift + #</span>\n              </div>\n              <div onClick={() => onOpenFile(undefined, \"欢迎页面\")}>\n                <FolderOpen />\n                <span className=\"hidden sm:inline\">{t(\"openFile\")}</span>\n                <span className=\"hidden text-xs opacity-50 sm:inline\">{isMac ? \"⌘ + O\" : \"Ctrl + O\"}</span>\n              </div>\n            </div>\n            <div className={cn(\"hidden flex-col gap-2 *:transition-opacity *:hover:opacity-75 sm:flex\")}>\n              {recentFiles.slice(0, 6).map((file, index) => (\n                <div\n                  className=\"flex flex-row items-center gap-2\"\n                  key={index}\n                  onClick={async () => {\n                    if (isLoading) {\n                      toast.error(\"正在打开文件，请稍后\");\n                      return;\n                    }\n                    setIsLoading(true);\n                    setLastClickFileURIPath(file.uri.fsPath);\n                    try {\n                      await onOpenFile(file.uri, \"欢迎页面-最近打开的文件\");\n                      await refresh();\n                    } catch (e) {\n                      toast.error(e as string);\n                    }\n                    setIsLoading(false);\n                    setLastClickFileURIPath(\"\");\n                  }}\n                >\n                  {isLoading && lastClickFileURIPath === file.uri.fsPath && (\n                    <LoaderCircle className={cn(isLoading && \"animate-spin\")} />\n                  )}\n                  <div className=\"flex flex-col gap-1\">\n                    <span className=\"text-sm\">{new Path(file.uri).nameWithoutExt}</span>\n                    <span className=\"text-xs opacity-50\">{file.uri.fsPath}</span>\n                  </div>\n                </div>\n              ))}\n            </div>\n          </div>\n          {/* 右侧区域 */}\n          <div className=\"flex flex-col *:flex *:w-max *:cursor-pointer *:gap-2 *:hover:opacity-75 *:active:scale-90 sm:gap-2\">\n            <div onClick={() => SettingsWindow.open(\"settings\")}>\n              <SettingsIcon />\n              <span className=\"hidden sm:inline\">{t(\"settings\")}</span>\n            </div>\n            <div onClick={() => SettingsWindow.open(\"about\")}>\n              <Info />\n              <span className=\"hidden sm:inline\">{t(\"about\")}</span>\n            </div>\n            <div onClick={() => shellOpen(\"https://project-graph.top\")}>\n              <Earth />\n              <span className=\"hidden sm:inline\">{t(\"website\")}</span>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/src/core/Project.tsx",
    "content": "import { Dialog } from \"@/components/ui/dialog\";\nimport { FileSystemProvider, Service } from \"@/core/interfaces/Service\";\nimport type { CurveRenderer } from \"@/core/render/canvas2d/basicRenderer/curveRenderer\";\nimport type { ImageRenderer } from \"@/core/render/canvas2d/basicRenderer/ImageRenderer\";\nimport type { ReferenceBlockRenderer } from \"@/core/render/canvas2d/entityRenderer/ReferenceBlockRenderer\";\nimport type { ShapeRenderer } from \"@/core/render/canvas2d/basicRenderer/shapeRenderer\";\nimport type { SvgRenderer } from \"@/core/render/canvas2d/basicRenderer/svgRenderer\";\nimport type { TextRenderer } from \"@/core/render/canvas2d/basicRenderer/textRenderer\";\nimport type { DrawingControllerRenderer } from \"@/core/render/canvas2d/controllerRenderer/drawingRenderer\";\nimport type { CollisionBoxRenderer } from \"@/core/render/canvas2d/entityRenderer/CollisionBoxRenderer\";\nimport type { StraightEdgeRenderer } from \"@/core/render/canvas2d/entityRenderer/edge/concrete/StraightEdgeRenderer\";\nimport type { SymmetryCurveEdgeRenderer } from \"@/core/render/canvas2d/entityRenderer/edge/concrete/SymmetryCurveEdgeRenderer\";\nimport type { VerticalPolyEdgeRenderer } from \"@/core/render/canvas2d/entityRenderer/edge/concrete/VerticalPolyEdgeRenderer\";\nimport type { EdgeRenderer } from \"@/core/render/canvas2d/entityRenderer/edge/EdgeRenderer\";\nimport type { EntityDetailsButtonRenderer } from \"@/core/render/canvas2d/entityRenderer/EntityDetailsButtonRenderer\";\nimport type { EntityRenderer } from \"@/core/render/canvas2d/entityRenderer/EntityRenderer\";\nimport type { MultiTargetUndirectedEdgeRenderer } from \"@/core/render/canvas2d/entityRenderer/multiTargetUndirectedEdge/MultiTargetUndirectedEdgeRenderer\";\nimport type { SectionRenderer } from \"@/core/render/canvas2d/entityRenderer/section/SectionRenderer\";\nimport type { SvgNodeRenderer } from \"@/core/render/canvas2d/entityRenderer/svgNode/SvgNodeRenderer\";\nimport type { TextNodeRenderer } from \"@/core/render/canvas2d/entityRenderer/textNode/TextNodeRenderer\";\nimport type { UrlNodeRenderer } from \"@/core/render/canvas2d/entityRenderer/urlNode/urlNodeRenderer\";\nimport type { Renderer } from \"@/core/render/canvas2d/renderer\";\nimport type { BackgroundRenderer } from \"@/core/render/canvas2d/utilsRenderer/backgroundRenderer\";\nimport type { RenderUtils } from \"@/core/render/canvas2d/utilsRenderer/RenderUtils\";\nimport type { SearchContentHighlightRenderer } from \"@/core/render/canvas2d/utilsRenderer/searchContentHighlightRenderer\";\nimport type { WorldRenderUtils } from \"@/core/render/canvas2d/utilsRenderer/WorldRenderUtils\";\nimport type { InputElement } from \"@/core/render/domElement/inputElement\";\nimport type { AutoLayoutFastTree } from \"@/core/service/controlService/autoLayoutEngine/autoLayoutFastTreeMode\";\nimport type { AutoLayout } from \"@/core/service/controlService/autoLayoutEngine/mainTick\";\nimport type { ControllerUtils } from \"@/core/service/controlService/controller/concrete/utilsControl\";\nimport type { Controller } from \"@/core/service/controlService/controller/Controller\";\nimport type { KeyboardOnlyEngine } from \"@/core/service/controlService/keyboardOnlyEngine/keyboardOnlyEngine\";\nimport type { KeyboardOnlyGraphEngine } from \"@/core/service/controlService/keyboardOnlyEngine/keyboardOnlyGraphEngine\";\nimport type { KeyboardOnlyTreeEngine } from \"@/core/service/controlService/keyboardOnlyEngine/keyboardOnlyTreeEngine\";\nimport type { SelectChangeEngine } from \"@/core/service/controlService/keyboardOnlyEngine/selectChangeEngine\";\nimport type { RectangleSelect } from \"@/core/service/controlService/rectangleSelectEngine/rectangleSelectEngine\";\nimport type { KeyBinds } from \"@/core/service/controlService/shortcutKeysEngine/KeyBinds\";\nimport type { KeyBindsRegistrar } from \"@/core/service/controlService/shortcutKeysEngine/shortcutKeysRegister\";\nimport type { MouseInteraction } from \"@/core/service/controlService/stageMouseInteractionCore/stageMouseInteractionCore\";\nimport type { AutoComputeUtils } from \"@/core/service/dataGenerateService/autoComputeEngine/AutoComputeUtils\";\nimport type { AutoCompute } from \"@/core/service/dataGenerateService/autoComputeEngine/mainTick\";\nimport type { GenerateFromFolder } from \"@/core/service/dataGenerateService/generateFromFolderEngine/GenerateFromFolderEngine\";\nimport type { StageExport } from \"@/core/service/dataGenerateService/stageExportEngine/stageExportEngine\";\nimport type { StageExportPng } from \"@/core/service/dataGenerateService/stageExportEngine/StageExportPng\";\nimport type { StageExportSvg } from \"@/core/service/dataGenerateService/stageExportEngine/StageExportSvg\";\nimport type { StageImport } from \"@/core/service/dataGenerateService/stageImportEngine/stageImportEngine\";\nimport type { AIEngine } from \"@/core/service/dataManageService/aiEngine/AIEngine\";\nimport type { ComplexityDetector } from \"@/core/service/dataManageService/ComplexityDetector\";\nimport type { ContentSearch } from \"@/core/service/dataManageService/contentSearchEngine/contentSearchEngine\";\nimport type { CopyEngine } from \"@/core/service/dataManageService/copyEngine/copyEngine\";\nimport type { Effects } from \"@/core/service/feedbackService/effectEngine/effectMachine\";\nimport { StageStyleManager } from \"@/core/service/feedbackService/stageStyle/StageStyleManager\";\nimport type { Camera } from \"@/core/stage/Camera\";\nimport type { Canvas } from \"@/core/stage/Canvas\";\nimport { GraphMethods } from \"@/core/stage/stageManager/basicMethods/GraphMethods\";\nimport { SectionMethods } from \"@/core/stage/stageManager/basicMethods/SectionMethods\";\nimport type { LayoutManager } from \"@/core/stage/stageManager/concreteMethods/LayoutManager\";\nimport type { AutoAlign } from \"@/core/stage/stageManager/concreteMethods/StageAutoAlignManager\";\nimport type { DeleteManager } from \"@/core/stage/stageManager/concreteMethods/StageDeleteManager\";\nimport type { EntityMoveManager } from \"@/core/stage/stageManager/concreteMethods/StageEntityMoveManager\";\nimport type { StageUtils } from \"@/core/stage/stageManager/concreteMethods/StageManagerUtils\";\nimport type { MultiTargetEdgeMove } from \"@/core/stage/stageManager/concreteMethods/StageMultiTargetEdgeMove\";\nimport type { NodeAdder } from \"@/core/stage/stageManager/concreteMethods/StageNodeAdder\";\nimport type { NodeConnector } from \"@/core/stage/stageManager/concreteMethods/StageNodeConnector\";\nimport type { StageNodeRotate } from \"@/core/stage/stageManager/concreteMethods/stageNodeRotate\";\nimport type { StageObjectColorManager } from \"@/core/stage/stageManager/concreteMethods/StageObjectColorManager\";\nimport type { StageObjectSelectCounter } from \"@/core/stage/stageManager/concreteMethods/StageObjectSelectCounter\";\nimport type { SectionInOutManager } from \"@/core/stage/stageManager/concreteMethods/StageSectionInOutManager\";\nimport type { SectionPackManager } from \"@/core/stage/stageManager/concreteMethods/StageSectionPackManager\";\nimport type { SectionCollisionSolver } from \"@/core/stage/stageManager/concreteMethods/SectionCollisionSolver\";\nimport type { TagManager } from \"@/core/stage/stageManager/concreteMethods/StageTagManager\";\nimport { HistoryManager } from \"@/core/stage/stageManager/StageHistoryManager\";\nimport type { StageManager } from \"@/core/stage/stageManager/StageManager\";\nimport { StageObject } from \"@/core/stage/stageObject/abstract/StageObject\";\nimport { nextProjectIdAtom, projectsAtom, store } from \"@/state\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { deserialize, serialize } from \"@graphif/serializer\";\nimport { Decoder, Encoder } from \"@msgpack/msgpack\";\nimport { BlobReader, BlobWriter, Uint8ArrayReader, Uint8ArrayWriter, ZipReader, ZipWriter } from \"@zip.js/zip.js\";\nimport { EventEmitter } from \"events\";\nimport md5 from \"md5\";\nimport mime from \"mime\";\nimport { toast } from \"sonner\";\nimport { getOriginalNameOf } from \"virtual:original-class-name\";\nimport { URI } from \"vscode-uri\";\nimport { Telemetry } from \"./service/Telemetry\";\nimport { AutoSaveBackupService } from \"./service/dataFileService/AutoSaveBackupService\";\nimport { ReferenceManager } from \"./stage/stageManager/concreteMethods/StageReferenceManager\";\nimport { ProjectUpgrader } from \"./stage/ProjectUpgrader\";\nimport { ProjectMetadata, createDefaultMetadata, isValidMetadata } from \"@/types/metadata\";\n\nif (import.meta.hot) {\n  import.meta.hot.accept();\n}\n\nexport enum ProjectState {\n  /**\n   * “已保存”\n   * 已写入到原始文件中\n   * 已上传到云端\n   */\n  Saved,\n  /**\n   * \"已暂存\"\n   * 未写入到原始文件中，但是已经暂存到数据目录\n   * 未上传到云端，但是已经暂存到本地\n   */\n  Stashed,\n  /**\n   * “未保存”\n   * 未写入到原始文件中，也未暂存到数据目录（真·未保存）\n   * 未上传到云端，也未暂存到本地\n   */\n  Unsaved,\n}\n\n/**\n * “工程”\n * 一个标签页对应一个工程，一个工程只能对应一个URI\n * 一个工程可以加载不同的服务，类似vscode的扩展（Extensions）机制\n */\nexport class Project extends EventEmitter<{\n  \"state-change\": [state: ProjectState];\n  contextmenu: [location: Vector];\n}> {\n  static readonly latestVersion = 18;\n\n  private readonly services = new Map<string, Service>();\n  private readonly tickableServices: Service[] = [];\n  /**\n   * 工程文件的URI\n   * key: 服务ID\n   * value: 服务实例\n   */\n  private readonly fileSystemProviders = new Map<string, FileSystemProvider>();\n  private rafHandle = -1;\n  private _uri: URI;\n  private _state: ProjectState = ProjectState.Unsaved;\n  private _isSaving = false;\n  public stage: StageObject[] = [];\n  public tags: string[] = [];\n  /**\n   * string：UUID\n   * value: Blob\n   */\n  public attachments = new Map<string, Blob>();\n  /**\n   * 创建Encoder对象比直接用encode()快\n   * @see https://github.com/msgpack/msgpack-javascript#reusing-encoder-and-decoder-instances\n   */\n  private encoder = new Encoder();\n  private decoder = new Decoder();\n\n  /**\n   * 创建一个项目\n   * @param uri 工程文件的URI\n   * 之所以从“路径”改为了“URI”，是因为要为后面的云同步功能做铺垫。\n   * 普通的“路径”无法表示云盘中的文件，而URI可以。\n   * 同时，草稿文件也从硬编码的“Project Graph”特殊文件路径改为了协议为draft、内容为UUID的URI。\n   * @see https://code.visualstudio.com/api/references/vscode-api#workspace.workspaceFile\n   */\n  constructor(uri: URI) {\n    super();\n    this._uri = uri;\n  }\n  /**\n   * 创建一个草稿工程\n   * URI为draft:UUID\n   */\n  static newDraft(): Project {\n    // const num = store.get(projectsAtom).filter((p) => p.isDraft).length + 1;\n    if (store.get(projectsAtom).length === 0) store.set(nextProjectIdAtom, 1);\n    const num = store.get(nextProjectIdAtom);\n    const uri = URI.parse(\"draft:\" + num);\n    store.set(nextProjectIdAtom, num + 1);\n    return new Project(uri);\n  }\n\n  /**\n   * 立刻加载一个新的服务\n   */\n  loadService(service: { id?: string; new (...args: any[]): any }) {\n    if (!service.id) {\n      service.id = crypto.randomUUID();\n      console.warn(\"[Project] 服务 %o 未指定 ID，自动生成：%s\", service, service.id);\n    }\n    const inst = new service(this);\n    this.services.set(service.id, inst);\n    if (\"tick\" in inst) {\n      this.tickableServices.push(inst);\n    }\n    this[service.id as keyof this] = inst as this[keyof this];\n  }\n  /**\n   * 立刻销毁一个服务\n   */\n  disposeService(serviceId: string) {\n    const service = this.services.get(serviceId);\n    if (service) {\n      service.dispose?.();\n      this.services.delete(serviceId);\n      this.tickableServices.splice(this.tickableServices.indexOf(service), 1);\n    }\n  }\n\n  /**\n   * 比较两个版本号字符串（格式：x.y.z）\n   * @param version1 版本1\n   * @param version2 版本2\n   * @returns 如果 version1 < version2 返回 -1，如果 version1 > version2 返回 1，如果相等返回 0\n   */\n  private compareVersion(version1: string, version2: string): number {\n    const v1Parts = version1.split(\".\").map(Number);\n    const v2Parts = version2.split(\".\").map(Number);\n    const maxLength = Math.max(v1Parts.length, v2Parts.length);\n\n    for (let i = 0; i < maxLength; i++) {\n      const v1Part = v1Parts[i] || 0;\n      const v2Part = v2Parts[i] || 0;\n      if (v1Part < v2Part) return -1;\n      if (v1Part > v2Part) return 1;\n    }\n    return 0;\n  }\n\n  /**\n   * 检查是否需要升级，如果需要则显示确认对话框\n   * @param currentVersion 当前文件版本\n   * @param latestVersion 最新版本\n   */\n  private async checkAndConfirmUpgrade(currentVersion: string, latestVersion: string): Promise<boolean> {\n    const needsUpgrade = this.compareVersion(currentVersion, latestVersion) < 0;\n\n    if (!needsUpgrade) {\n      return true;\n    }\n\n    // 显示确认对话框\n    const response = await Dialog.buttons(\n      \"检测到旧版本项目文件\",\n      `当前文件版本为 ${currentVersion}，需要升级到 ${latestVersion} (是prg文件版本,非软件版本)。\\n\\n升级过程不可逆且可能存在风险，特别是对于大型文件，建议提前备份。是否继续升级？`,\n      [\n        { id: \"cancel\", label: \"取消\", variant: \"ghost\" },\n        { id: \"upgrade\", label: \"确认升级\" },\n      ],\n    );\n\n    if (response === \"cancel\") {\n      // 用户取消升级，返回 false 表示取消\n      return false;\n    }\n\n    // 添加延迟，确保用户看到提示并给系统时间处理\n    await new Promise((resolve) => setTimeout(resolve, 500));\n    return true;\n  }\n\n  /**\n   * 解析项目文件（ZIP格式），提取所有数据\n   * @returns 解析后的数据对象\n   */\n  private async parseProjectFile(): Promise<{\n    serializedStageObjects: any[];\n    tags: string[];\n    references: { sections: Record<string, string[]>; files: string[] };\n    metadata: ProjectMetadata;\n  }> {\n    const fileContent = await this.fs.read(this.uri);\n    const reader = new ZipReader(new Uint8ArrayReader(fileContent));\n    const entries = await reader.getEntries();\n\n    let serializedStageObjects: any[] = [];\n    let tags: string[] = [];\n    let references: { sections: Record<string, string[]>; files: string[] } = { sections: {}, files: [] };\n    let metadata: ProjectMetadata = createDefaultMetadata(\"2.0.0\");\n\n    for (const entry of entries) {\n      if (entry.filename === \"stage.msgpack\") {\n        const stageRawData = await entry.getData!(new Uint8ArrayWriter());\n        serializedStageObjects = this.decoder.decode(stageRawData) as any[];\n      } else if (entry.filename === \"tags.msgpack\") {\n        const tagsRawData = await entry.getData!(new Uint8ArrayWriter());\n        tags = this.decoder.decode(tagsRawData) as string[];\n      } else if (entry.filename === \"reference.msgpack\") {\n        const referenceRawData = await entry.getData!(new Uint8ArrayWriter());\n        references = this.decoder.decode(referenceRawData) as { sections: Record<string, string[]>; files: string[] };\n      } else if (entry.filename === \"metadata.msgpack\") {\n        const metadataRawData = await entry.getData!(new Uint8ArrayWriter());\n        const decodedMetadata = this.decoder.decode(metadataRawData) as any;\n        // 验证并规范化 metadata\n        if (isValidMetadata(decodedMetadata)) {\n          metadata = decodedMetadata;\n        } else {\n          // 如果格式不正确，使用默认值\n          metadata = createDefaultMetadata(\"2.0.0\");\n        }\n      } else if (entry.filename.startsWith(\"attachments/\")) {\n        const match = entry.filename.trim().match(/^attachments\\/([a-zA-Z0-9-]+)\\.([a-zA-Z0-9]+)$/);\n        if (!match) {\n          console.warn(\"[Project] 附件文件名不符合规范: %s\", entry.filename);\n          continue;\n        }\n        const uuid = match[1];\n        const ext = match[2];\n        const type = mime.getType(ext) || \"application/octet-stream\";\n        const attachment = await entry.getData!(new BlobWriter(type));\n        this.attachments.set(uuid, attachment);\n      }\n    }\n\n    return { serializedStageObjects, tags, references, metadata };\n  }\n\n  /**\n   * 服务加载完成后再调用\n   */\n  async init() {\n    if (!(await this.fs.exists(this.uri))) {\n      return;\n    }\n    try {\n      // 解析项目文件\n      const { serializedStageObjects, tags, references, metadata } = await this.parseProjectFile();\n\n      // 检查并确认升级\n      const currentVersion = metadata?.version || \"2.0.0\";\n      const latestVersion = ProjectUpgrader.NLatestVersion;\n      const confirmed = await this.checkAndConfirmUpgrade(currentVersion, latestVersion);\n      if (!confirmed) return; // 用户取消升级，不打开文件，跳过 this.state = ProjectState.Saved\n\n      // 升级数据\n      const [upgradedStageObjects, upgradedMetadata] = ProjectUpgrader.upgradeNAnyToNLatest(\n        serializedStageObjects,\n        metadata,\n      );\n\n      // 应用升级后的数据\n      this.stage = deserialize(upgradedStageObjects, this);\n      this.tags = tags;\n      this.references = references;\n      this.metadata = upgradedMetadata;\n\n      // 更新引用关系，包括双向线的偏移状态\n      // 注意：这里需要在服务加载后才能调用，所以需要检查服务是否已加载\n      if (this.getService(\"stageManager\")) {\n        this.stageManager.updateReferences();\n      }\n    } catch (e) {\n      console.warn(e);\n    }\n    this.state = ProjectState.Saved;\n  }\n\n  loop() {\n    if (this.rafHandle !== -1) return;\n    const animationFrame = () => {\n      this.tick();\n      this.rafHandle = requestAnimationFrame(animationFrame.bind(this));\n    };\n    animationFrame();\n  }\n  pause() {\n    if (this.rafHandle === -1) return;\n    cancelAnimationFrame(this.rafHandle);\n    this.rafHandle = -1;\n  }\n  private tick() {\n    for (const service of this.tickableServices) {\n      try {\n        service.tick?.();\n      } catch (e) {\n        console.error(\"[%s] %o\", service, e);\n        this.tickableServices.splice(this.tickableServices.indexOf(service), 1);\n        Dialog.buttons(`${getOriginalNameOf(service.constructor)} 发生未知错误`, String(e), [\n          { id: \"cancel\", label: \"取消\", variant: \"ghost\" },\n          { id: \"save\", label: \"保存文件\" },\n        ]).then((result) => {\n          if (result === \"save\") {\n            this.save();\n          }\n        });\n        if (e !== null && typeof e === \"object\" && \"message\" in e && e.message === \"test\") {\n          continue;\n        }\n        toast.promise(\n          Telemetry.event(\"服务tick方法报错\", { service: getOriginalNameOf(service.constructor), error: String(e) }),\n          {\n            loading: \"正在上报错误\",\n            success: \"错误信息已发送给开发者\",\n            error: \"上报失败\",\n          },\n        );\n      }\n    }\n  }\n  /**\n   * 用户关闭标签页时，销毁工程\n   */\n  async dispose() {\n    cancelAnimationFrame(this.rafHandle);\n    const promises: Promise<void>[] = [];\n    for (const service of this.services.values()) {\n      const result = service.dispose?.();\n      if (result instanceof Promise) {\n        promises.push(result);\n      }\n    }\n    await Promise.allSettled(promises);\n    this.services.clear();\n    this.tickableServices.length = 0;\n  }\n\n  /**\n   * 获取某个服务的实例\n   */\n  getService<T extends keyof this & string>(serviceId: T): this[T] {\n    return this.services.get(serviceId) as this[T];\n  }\n\n  get isDraft() {\n    return this.uri.scheme === \"draft\";\n  }\n  get uri() {\n    return this._uri;\n  }\n  set uri(uri: URI) {\n    this._uri = uri;\n    this.state = ProjectState.Unsaved;\n  }\n\n  /**\n   * 将文件暂存到数据目录中（通常为~/.local/share）\n   * ~/.local/share/liren.project-graph/stash/<normalizedUri>\n   * @see https://code.visualstudio.com/blogs/2016/11/30/hot-exit-in-insiders\n   *\n   * 频繁用msgpack序列化不会卡吗？\n   * 虽然JSON.stringify()在V8上面速度和msgpack差不多\n   * 但是要考虑跨平台，目前linux和macos用的都是webkit，目前还没有JavaScriptCore相关的benchmark\n   * 而且考虑到以后会把图片也放进文件里面，JSON肯定不合适了\n   * @see https://github.com/msgpack/msgpack-javascript#benchmark\n   */\n  async stash() {\n    // TODO: stash\n    // const stashFilePath = await join(await appLocalDataDir(), \"stash\", Base64.encode(this.uri.toString()));\n    // const encoded = this.encoder.encodeSharedRef(this.data);\n    // await writeFile(stashFilePath, encoded);\n  }\n  async save() {\n    try {\n      this.isSaving = true;\n      await this.fs.write(this.uri, await this.getFileContent());\n      this.state = ProjectState.Saved;\n    } finally {\n      this.isSaving = false;\n    }\n  }\n\n  // 反向引用数据\n  public references: { sections: Record<string, string[]>; files: string[] } = { sections: {}, files: [] };\n  public metadata: ProjectMetadata = createDefaultMetadata(ProjectUpgrader.NLatestVersion);\n\n  // 更新引用信息的方法已经在changeTextNodeToReferenceBlock中直接实现，这里暂时不需要单独的方法\n\n  // 备份也要用到这个\n  async getFileContent() {\n    const serializedStage = serialize(this.stage);\n    const encodedStage = this.encoder.encode(serializedStage);\n    const uwriter = new Uint8ArrayWriter();\n\n    const writer = new ZipWriter(uwriter); // zip writer用于把zip文件写入uint8array writer\n    writer.add(\"stage.msgpack\", new Uint8ArrayReader(encodedStage));\n    writer.add(\"tags.msgpack\", new Uint8ArrayReader(this.encoder.encode(this.tags)));\n    writer.add(\"reference.msgpack\", new Uint8ArrayReader(this.encoder.encode(this.references)));\n    writer.add(\"metadata.msgpack\", new Uint8ArrayReader(this.encoder.encode(this.metadata)));\n    // 添加附件\n    for (const [uuid, attachment] of this.attachments.entries()) {\n      writer.add(`attachments/${uuid}.${mime.getExtension(attachment.type)}`, new BlobReader(attachment));\n    }\n    await writer.close();\n\n    const fileContent = await uwriter.getData();\n    return fileContent;\n  }\n\n  /**\n   * 备份用：生成项目内容的哈希值，用于检测内容是否发生变化\n   */\n  get stageHash() {\n    const serializedStage = serialize(this.stage);\n    // 创建临时Encoder来编码数据\n    const tempEncoder = new Encoder();\n    const encodedStage = tempEncoder.encode(serializedStage);\n    return md5(encodedStage);\n  }\n\n  /**\n   * 注册一个文件管理器\n   * @param scheme 目前有 \"file\" | \"draft\"， 以后可能有其他的协议\n   */\n  registerFileSystemProvider(scheme: string, provider: { new (...args: any[]): FileSystemProvider }) {\n    this.fileSystemProviders.set(scheme, new provider(this));\n  }\n\n  get fs(): FileSystemProvider {\n    return this.fileSystemProviders.get(this.uri.scheme)!;\n  }\n\n  addAttachment(data: Blob) {\n    const uuid = crypto.randomUUID();\n    this.attachments.set(uuid, data);\n    return uuid;\n  }\n\n  set state(state: ProjectState) {\n    if (state === this._state) return;\n    this._state = state;\n    this.emit(\"state-change\", state);\n  }\n\n  get state(): ProjectState {\n    return this._state;\n  }\n\n  set isSaving(isSaving: boolean) {\n    if (isSaving === this._isSaving) return;\n    this._isSaving = isSaving;\n    this.emit(\"state-change\", this._state);\n  }\n\n  get isSaving(): boolean {\n    return this._isSaving;\n  }\n\n  get isRunning(): boolean {\n    return this.rafHandle !== -1;\n  }\n}\n\ndeclare module \"./Project\" {\n  /*\n   * 不直接在class中定义的原因\n   * 在class中定义的话ts会报错，因为它没有初始值并且没有在构造函数中赋值\n   * 在这里用语法糖定义就能优雅的绕过这个限制\n   * 服务加载的顺序在调用registerService()时确定\n   */\n  interface Project {\n    canvas: Canvas;\n    inputElement: InputElement;\n    keyBinds: KeyBinds;\n    controllerUtils: ControllerUtils;\n    autoComputeUtils: AutoComputeUtils;\n    renderUtils: RenderUtils;\n    worldRenderUtils: WorldRenderUtils;\n    historyManager: HistoryManager;\n    stageManager: StageManager;\n    camera: Camera;\n    effects: Effects;\n    autoCompute: AutoCompute;\n    rectangleSelect: RectangleSelect;\n    stageNodeRotate: StageNodeRotate;\n    complexityDetector: ComplexityDetector;\n    aiEngine: AIEngine;\n    copyEngine: CopyEngine;\n    autoLayout: AutoLayout;\n    autoLayoutFastTree: AutoLayoutFastTree;\n    layoutManager: LayoutManager;\n    autoAlign: AutoAlign;\n    mouseInteraction: MouseInteraction;\n    contentSearch: ContentSearch;\n    deleteManager: DeleteManager;\n    nodeAdder: NodeAdder;\n    entityMoveManager: EntityMoveManager;\n    stageUtils: StageUtils;\n    multiTargetEdgeMove: MultiTargetEdgeMove;\n    nodeConnector: NodeConnector;\n    stageObjectColorManager: StageObjectColorManager;\n    stageObjectSelectCounter: StageObjectSelectCounter;\n    sectionInOutManager: SectionInOutManager;\n    sectionPackManager: SectionPackManager;\n    sectionCollisionSolver: SectionCollisionSolver;\n    tagManager: TagManager;\n    keyboardOnlyEngine: KeyboardOnlyEngine;\n    keyboardOnlyGraphEngine: KeyboardOnlyGraphEngine;\n    keyboardOnlyTreeEngine: KeyboardOnlyTreeEngine;\n    selectChangeEngine: SelectChangeEngine;\n    textRenderer: TextRenderer;\n    imageRenderer: ImageRenderer;\n    referenceBlockRenderer: ReferenceBlockRenderer;\n    shapeRenderer: ShapeRenderer;\n    entityRenderer: EntityRenderer;\n    edgeRenderer: EdgeRenderer;\n    multiTargetUndirectedEdgeRenderer: MultiTargetUndirectedEdgeRenderer;\n    curveRenderer: CurveRenderer;\n    svgRenderer: SvgRenderer;\n    drawingControllerRenderer: DrawingControllerRenderer;\n    collisionBoxRenderer: CollisionBoxRenderer;\n    entityDetailsButtonRenderer: EntityDetailsButtonRenderer;\n    straightEdgeRenderer: StraightEdgeRenderer;\n    symmetryCurveEdgeRenderer: SymmetryCurveEdgeRenderer;\n    verticalPolyEdgeRenderer: VerticalPolyEdgeRenderer;\n    sectionRenderer: SectionRenderer;\n    svgNodeRenderer: SvgNodeRenderer;\n    textNodeRenderer: TextNodeRenderer;\n    urlNodeRenderer: UrlNodeRenderer;\n    backgroundRenderer: BackgroundRenderer;\n    searchContentHighlightRenderer: SearchContentHighlightRenderer;\n    renderer: Renderer;\n    controller: Controller;\n    stageExport: StageExport;\n    stageExportPng: StageExportPng;\n    stageExportSvg: StageExportSvg;\n    stageImport: StageImport;\n    generateFromFolder: GenerateFromFolder;\n    keyBindsRegistrar: KeyBindsRegistrar;\n    sectionMethods: SectionMethods;\n    graphMethods: GraphMethods;\n    stageStyleManager: StageStyleManager;\n    autoSaveBackup: AutoSaveBackupService;\n    referenceManager: ReferenceManager;\n  }\n}\n\n/**\n * 装饰器\n * @example\n * @service(\"renderer\")\n * class Renderer {}\n *\n * 装饰了这个类之后，这个类会多一个id属性（静态属性），值为\"renderer\"\n * 可以通过 Renderer.id 获取到这个值\n */\nexport const service =\n  (id: string) =>\n  <\n    T extends {\n      [x: string | number | symbol]: any;\n      id?: string;\n      new (...args: any[]): any;\n    },\n  >(\n    target: T,\n  ): T & { id: string } => {\n    target.id = id;\n    return target as T & { id: string };\n  };\n"
  },
  {
    "path": "app/src/core/algorithm/arrayFunctions.tsx",
    "content": "export namespace ArrayFunctions {\n  /**\n   * 计算总和\n   */\n  export function sum(arr: number[]): number {\n    return arr.reduce((acc, cur) => acc + cur, 0);\n  }\n\n  /**\n   * 计算平均值\n   */\n  export function average(arr: number[]): number {\n    if (arr.length === 0) {\n      throw new Error(\"计算平均值时，数组不能为空\");\n    }\n    return sum(arr) / arr.length;\n  }\n\n  export function isSame(arr: number[]): boolean {\n    if (arr.length === 0) {\n      throw new Error(\"计算是否均一时，数组不能为空\");\n    }\n    if (arr.length === 1) {\n      return true;\n    }\n    const first = arr[0];\n    for (let i = 1; i < arr.length; i++) {\n      if (first !== arr[i]) {\n        return false;\n      }\n    }\n    return true;\n  }\n\n  /**\n   * 计算方差\n   */\n  export function variance(arr: number[]): number {\n    const avg = average(arr);\n    return arr.reduce((acc, cur) => acc + Math.pow(cur - avg, 2), 0) / arr.length;\n  }\n\n  /**\n   * 计算标准差\n   */\n  export function standardDeviation(arr: number[]): number {\n    return Math.sqrt(variance(arr));\n  }\n\n  /**\n   * 获取绝对值最小的那个值\n   * @param arr\n   */\n  export function getMinAbsValue(arr: number[]): number {\n    if (arr.length === 0) {\n      throw new Error(\"数组不能为空\");\n    }\n\n    let minAbsValue = arr[0];\n\n    for (let i = 1; i < arr.length; i++) {\n      if (Math.abs(arr[i]) < Math.abs(minAbsValue)) {\n        minAbsValue = arr[i];\n      }\n    }\n\n    return minAbsValue;\n  }\n}\n"
  },
  {
    "path": "app/src/core/algorithm/geometry/README.md",
    "content": "计算几何领域\n"
  },
  {
    "path": "app/src/core/algorithm/geometry/convexHull.tsx",
    "content": "import { Vector } from \"@graphif/data-structures\";\n\n/**\n * 凸包算法合集\n */\nexport namespace ConvexHull {\n  /**\n   * 计算给定二维点的凸包（Andrew's Monotone Chain 算法）\n   * 时间复杂度 O(n log n)，空间复杂度 O(n)\n   * @param points 二维点数组\n   * @returns 按逆时针顺序排列的凸包顶点数组（自动去除共线点）\n   */\n  export function computeConvexHull(points: Vector[]): Vector[] {\n    if (points.length <= 1) return [...points];\n\n    // 排序点集：先按 x 坐标，再按 y 坐标\n    const sorted = [...points].sort((a, b) => (a.x !== b.x ? a.x - b.x : a.y - b.y));\n\n    // 检查所有点是否共线\n    if (isCollinear(sorted)) {\n      return [sorted[0], sorted[sorted.length - 1]];\n    }\n\n    // 构建下凸包和上凸包\n    const lower: Vector[] = [];\n    const upper: Vector[] = [];\n\n    for (const point of sorted) {\n      buildHull(lower, point, (a, b, c) => cross(a, b, c) <= 0);\n    }\n\n    for (const point of sorted.reverse()) {\n      buildHull(upper, point, (a, b, c) => cross(a, b, c) <= 0);\n    }\n\n    // 合并结果并去除重复点\n    const hull = [...lower, ...upper];\n    return Array.from(new Set(hull.slice(0, -1))); // 使用 Set 去重\n  }\n\n  /** 辅助函数：三点叉积计算 */\n  function cross(a: Vector, b: Vector, c: Vector): number {\n    return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);\n  }\n\n  /** 辅助函数：构建单边凸包 */\n  function buildHull(hull: Vector[], point: Vector, shouldRemove: (a: Vector, b: Vector, c: Vector) => boolean) {\n    while (hull.length >= 2) {\n      const [b, a] = [hull[hull.length - 1], hull[hull.length - 2]];\n      if (shouldRemove(a, b, point)) {\n        hull.pop();\n      } else {\n        break;\n      }\n    }\n    hull.push(point);\n  }\n\n  /** 判断所有点是否共线 */\n  function isCollinear(points: Vector[]): boolean {\n    if (points.length < 3) return true;\n\n    const [a, b] = [points[0], points[1]];\n    return points.every((c) => cross(a, b, c) === 0);\n  }\n}\n"
  },
  {
    "path": "app/src/core/algorithm/numberFunctions.tsx",
    "content": "/**\n * 一些和数字相关的运算方法\n */\nexport namespace NumberFunctions {\n  /**\n   * 判断两个数是否相近\n   * @param number1\n   * @param number2\n   * @param tolerance\n   * @returns\n   */\n  export function isNumberNear(number1: number, number2: number, tolerance: number): boolean {\n    return Math.abs(number1 - number2) <= tolerance;\n  }\n\n  /**\n   * 此函数用于放在循环函数中，生成一个周期震荡的数字\n   * @param maxValue 震荡的最大值\n   * @param minValue 震荡的最小值\n   * @param cycleTime 周期时间，单位为秒\n   */\n  export function sinNumberByTime(maxValue: number, minValue: number, cycleTime: number) {\n    const t = performance.now() / 1000;\n    return Math.sin(((t % cycleTime) * (Math.PI * 2)) / cycleTime) * (maxValue - minValue) + minValue;\n  }\n\n  /**\n   * 此函数为了解决js求余运算变成负数的问题\n   * 可以看成：把一个x值压缩映射到0-y范围内\n   * @param x\n   */\n  export function mod(x: number, y: number): number {\n    return ((x % y) + y) % y;\n  }\n\n  /**\n   * 自定义底数的对数运算\n   * @param x 被对数的数\n   * @param b 底数\n   * @returns\n   */\n  export function logBase(x: number, b: number) {\n    if (x <= 0 || b <= 0 || b === 1) {\n      throw new Error(\"x and b must be positive numbers, and b cannot be 1.\");\n    }\n    return Math.log(x) / Math.log(b);\n  }\n}\n"
  },
  {
    "path": "app/src/core/algorithm/random.tsx",
    "content": "import { Vector } from \"@graphif/data-structures\";\n\nexport namespace Random {\n  export function randomInt(min: number, max: number): number {\n    return Math.floor(Math.random() * (max - min + 1)) + min;\n  }\n  export function randomFloat(min: number, max: number): number {\n    return Math.random() * (max - min) + min;\n  }\n  export function randomBoolean(): boolean {\n    return Math.random() < 0.5;\n  }\n  export function randomItem<T>(items: T[]): T {\n    return items[randomInt(0, items.length - 1)];\n  }\n  export function randomItems<T>(items: T[], count: number): T[] {\n    return items.slice(0, count).sort(() => Math.random() - 0.5);\n  }\n\n  /**\n   * 返回x坐标均匀分布在[min.x, max.x], y坐标均匀分布在[min.y, max.y]的随机向量\n   */\n  export function randomVector(min: Vector, max: Vector): Vector {\n    return new Vector(randomFloat(min.x, max.x), randomFloat(min.y, max.y));\n  }\n\n  /**\n   * 返回在单位圆上的随机点（落在圆周上）\n   */\n  export function randomVectorOnNormalCircle(): Vector {\n    const randomDegrees = randomFloat(0, 360);\n    return new Vector(1, 0).rotateDegrees(randomDegrees);\n  }\n\n  /**\n   * 泊松分布随机数\n   * @param lambda 泊松分布参数\n   */\n  export function poissonRandom(lambda: number): number {\n    const L = Math.exp(-lambda);\n    let p = 1.0;\n    let k = 0;\n    do {\n      k++;\n      p *= Math.random();\n    } while (p > L);\n    return k - 1;\n  }\n}\n"
  },
  {
    "path": "app/src/core/algorithm/setFunctions.tsx",
    "content": "export namespace SetFunctions {\n  /**\n   * 判断集合A是否是集合B的子集\n   * @param setA 待检查的子集\n   * @param setB 目标父集\n   * @returns 如果setA的所有元素都存在于setB中则返回true，否则返回false\n   */\n  export function isSubset<T>(setA: Set<T>, setB: Set<T>): boolean {\n    for (const item of setA) {\n      if (!setB.has(item)) {\n        return false;\n      }\n    }\n    return true;\n  }\n}\n"
  },
  {
    "path": "app/src/core/fileSystemProvider/FileSystemProviderDraft.tsx",
    "content": "import { encode } from \"@msgpack/msgpack\";\nimport { save } from \"@tauri-apps/plugin-dialog\";\nimport { writeFile } from \"@tauri-apps/plugin-fs\";\nimport { Uint8ArrayReader, Uint8ArrayWriter, ZipWriter } from \"@zip.js/zip.js\";\nimport { URI } from \"vscode-uri\";\nimport { FileSystemProvider } from \"@/core/interfaces/Service\";\nimport { Project } from \"@/core/Project\";\n\nexport class FileSystemProviderDraft implements FileSystemProvider {\n  constructor(private readonly project: Project) {}\n\n  async read() {\n    // 创建空白文件\n    const encodedStage = encode([]);\n    const uwriter = new Uint8ArrayWriter();\n    const writer = new ZipWriter(uwriter);\n    writer.add(\"stage.msgpack\", new Uint8ArrayReader(encodedStage));\n    writer.add(\"tags.msgpack\", new Uint8ArrayReader(encode([])));\n    await writer.close();\n    const fileContent = await uwriter.getData();\n    return fileContent;\n  }\n  async readDir() {\n    return [];\n  }\n  async write(_uri: URI, content: Uint8Array) {\n    // 先弹窗让用户选择路径\n    const path = await save({\n      title: \"保存草稿\",\n      filters: [{ name: \"Project Graph\", extensions: [\"prg\"] }],\n    });\n    if (!path) {\n      throw new Error(\"未选择路径\");\n    }\n    const newUri = URI.file(path);\n    await writeFile(newUri.fsPath, content);\n    this.project.uri = newUri;\n  }\n  async remove() {}\n  async exists() {\n    return false;\n  }\n  async mkdir() {}\n  async rename() {}\n}\n"
  },
  {
    "path": "app/src/core/fileSystemProvider/FileSystemProviderFile.tsx",
    "content": "import { exists, mkdir, readDir, readFile, remove, rename, writeFile } from \"@tauri-apps/plugin-fs\";\nimport { URI } from \"vscode-uri\";\nimport { FileSystemProvider } from \"@/core/interfaces/Service\";\n\nexport class FileSystemProviderFile implements FileSystemProvider {\n  async read(uri: URI) {\n    return await readFile(uri.fsPath);\n  }\n  async readDir(uri: URI) {\n    return await readDir(uri.fsPath);\n  }\n  async write(uri: URI, content: Uint8Array) {\n    return await writeFile(uri.fsPath, content);\n  }\n  async remove(uri: URI) {\n    return await remove(uri.fsPath);\n  }\n  async exists(uri: URI) {\n    return await exists(uri.fsPath);\n  }\n  async mkdir(uri: URI) {\n    return await mkdir(uri.fsPath);\n  }\n  async rename(oldUri: URI, newUri: URI) {\n    return await rename(oldUri.fsPath, newUri.fsPath);\n  }\n}\n"
  },
  {
    "path": "app/src/core/interfaces/Service.tsx",
    "content": "import { DirEntry } from \"@tauri-apps/plugin-fs\";\nimport { URI } from \"vscode-uri\";\n\nexport interface Service {\n  tick?(): void;\n  dispose?(): void | Promise<void>;\n}\n\nexport interface FileSystemProvider {\n  read(uri: URI): Promise<Uint8Array>;\n  readDir(uri: URI): Promise<DirEntry[]>;\n  write(uri: URI, content: Uint8Array): Promise<void>;\n  remove(uri: URI): Promise<void>;\n  exists(uri: URI): Promise<boolean>;\n  mkdir(uri: URI): Promise<void>;\n  rename(oldUri: URI, newUri: URI): Promise<void>;\n}\n"
  },
  {
    "path": "app/src/core/loadAllServices.tsx",
    "content": "import { FileSystemProviderDraft } from \"@/core/fileSystemProvider/FileSystemProviderDraft\";\nimport { FileSystemProviderFile } from \"@/core/fileSystemProvider/FileSystemProviderFile\";\nimport { Project } from \"@/core/Project\";\nimport { CurveRenderer } from \"@/core/render/canvas2d/basicRenderer/curveRenderer\";\nimport { ImageRenderer } from \"@/core/render/canvas2d/basicRenderer/ImageRenderer\";\nimport { ShapeRenderer } from \"@/core/render/canvas2d/basicRenderer/shapeRenderer\";\nimport { SvgRenderer } from \"@/core/render/canvas2d/basicRenderer/svgRenderer\";\nimport { TextRenderer } from \"@/core/render/canvas2d/basicRenderer/textRenderer\";\nimport { DrawingControllerRenderer } from \"@/core/render/canvas2d/controllerRenderer/drawingRenderer\";\nimport { CollisionBoxRenderer } from \"@/core/render/canvas2d/entityRenderer/CollisionBoxRenderer\";\nimport { StraightEdgeRenderer } from \"@/core/render/canvas2d/entityRenderer/edge/concrete/StraightEdgeRenderer\";\nimport { SymmetryCurveEdgeRenderer } from \"@/core/render/canvas2d/entityRenderer/edge/concrete/SymmetryCurveEdgeRenderer\";\nimport { VerticalPolyEdgeRenderer } from \"@/core/render/canvas2d/entityRenderer/edge/concrete/VerticalPolyEdgeRenderer\";\nimport { EdgeRenderer } from \"@/core/render/canvas2d/entityRenderer/edge/EdgeRenderer\";\nimport { EntityDetailsButtonRenderer } from \"@/core/render/canvas2d/entityRenderer/EntityDetailsButtonRenderer\";\nimport { EntityRenderer } from \"@/core/render/canvas2d/entityRenderer/EntityRenderer\";\nimport { MultiTargetUndirectedEdgeRenderer } from \"@/core/render/canvas2d/entityRenderer/multiTargetUndirectedEdge/MultiTargetUndirectedEdgeRenderer\";\nimport { SectionRenderer } from \"@/core/render/canvas2d/entityRenderer/section/SectionRenderer\";\nimport { SvgNodeRenderer } from \"@/core/render/canvas2d/entityRenderer/svgNode/SvgNodeRenderer\";\nimport { TextNodeRenderer } from \"@/core/render/canvas2d/entityRenderer/textNode/TextNodeRenderer\";\nimport { UrlNodeRenderer } from \"@/core/render/canvas2d/entityRenderer/urlNode/urlNodeRenderer\";\nimport { ReferenceBlockRenderer } from \"@/core/render/canvas2d/entityRenderer/ReferenceBlockRenderer\";\nimport { Renderer } from \"@/core/render/canvas2d/renderer\";\nimport { BackgroundRenderer } from \"@/core/render/canvas2d/utilsRenderer/backgroundRenderer\";\nimport { RenderUtils } from \"@/core/render/canvas2d/utilsRenderer/RenderUtils\";\nimport { SearchContentHighlightRenderer } from \"@/core/render/canvas2d/utilsRenderer/searchContentHighlightRenderer\";\nimport { WorldRenderUtils } from \"@/core/render/canvas2d/utilsRenderer/WorldRenderUtils\";\nimport { InputElement } from \"@/core/render/domElement/inputElement\";\nimport { AutoLayoutFastTree } from \"@/core/service/controlService/autoLayoutEngine/autoLayoutFastTreeMode\";\nimport { AutoLayout } from \"@/core/service/controlService/autoLayoutEngine/mainTick\";\nimport { ControllerUtils } from \"@/core/service/controlService/controller/concrete/utilsControl\";\nimport { Controller } from \"@/core/service/controlService/controller/Controller\";\nimport { KeyboardOnlyEngine } from \"@/core/service/controlService/keyboardOnlyEngine/keyboardOnlyEngine\";\nimport { KeyboardOnlyGraphEngine } from \"@/core/service/controlService/keyboardOnlyEngine/keyboardOnlyGraphEngine\";\nimport { KeyboardOnlyTreeEngine } from \"@/core/service/controlService/keyboardOnlyEngine/keyboardOnlyTreeEngine\";\nimport { SelectChangeEngine } from \"@/core/service/controlService/keyboardOnlyEngine/selectChangeEngine\";\nimport { RectangleSelect } from \"@/core/service/controlService/rectangleSelectEngine/rectangleSelectEngine\";\nimport { KeyBinds } from \"@/core/service/controlService/shortcutKeysEngine/KeyBinds\";\nimport { KeyBindsRegistrar } from \"@/core/service/controlService/shortcutKeysEngine/shortcutKeysRegister\";\nimport { MouseInteraction } from \"@/core/service/controlService/stageMouseInteractionCore/stageMouseInteractionCore\";\nimport { AutoComputeUtils } from \"@/core/service/dataGenerateService/autoComputeEngine/AutoComputeUtils\";\nimport { AutoCompute } from \"@/core/service/dataGenerateService/autoComputeEngine/mainTick\";\nimport { GenerateFromFolder } from \"@/core/service/dataGenerateService/generateFromFolderEngine/GenerateFromFolderEngine\";\nimport { StageExport } from \"@/core/service/dataGenerateService/stageExportEngine/stageExportEngine\";\nimport { StageExportPng } from \"@/core/service/dataGenerateService/stageExportEngine/StageExportPng\";\nimport { StageExportSvg } from \"@/core/service/dataGenerateService/stageExportEngine/StageExportSvg\";\nimport { StageImport } from \"@/core/service/dataGenerateService/stageImportEngine/stageImportEngine\";\nimport { AIEngine } from \"@/core/service/dataManageService/aiEngine/AIEngine\";\nimport { ComplexityDetector } from \"@/core/service/dataManageService/ComplexityDetector\";\nimport { ContentSearch } from \"@/core/service/dataManageService/contentSearchEngine/contentSearchEngine\";\nimport { CopyEngine } from \"@/core/service/dataManageService/copyEngine/copyEngine\";\nimport { Effects } from \"@/core/service/feedbackService/effectEngine/effectMachine\";\nimport { StageStyleManager } from \"@/core/service/feedbackService/stageStyle/StageStyleManager\";\nimport { Camera } from \"@/core/stage/Camera\";\nimport { Canvas } from \"@/core/stage/Canvas\";\nimport { GraphMethods } from \"@/core/stage/stageManager/basicMethods/GraphMethods\";\nimport { SectionMethods } from \"@/core/stage/stageManager/basicMethods/SectionMethods\";\nimport { LayoutManager } from \"@/core/stage/stageManager/concreteMethods/LayoutManager\";\nimport { AutoAlign } from \"@/core/stage/stageManager/concreteMethods/StageAutoAlignManager\";\nimport { DeleteManager } from \"@/core/stage/stageManager/concreteMethods/StageDeleteManager\";\nimport { EntityMoveManager } from \"@/core/stage/stageManager/concreteMethods/StageEntityMoveManager\";\nimport { StageUtils } from \"@/core/stage/stageManager/concreteMethods/StageManagerUtils\";\nimport { MultiTargetEdgeMove } from \"@/core/stage/stageManager/concreteMethods/StageMultiTargetEdgeMove\";\nimport { NodeAdder } from \"@/core/stage/stageManager/concreteMethods/StageNodeAdder\";\nimport { NodeConnector } from \"@/core/stage/stageManager/concreteMethods/StageNodeConnector\";\nimport { StageNodeRotate } from \"@/core/stage/stageManager/concreteMethods/stageNodeRotate\";\nimport { StageObjectColorManager } from \"@/core/stage/stageManager/concreteMethods/StageObjectColorManager\";\nimport { StageObjectSelectCounter } from \"@/core/stage/stageManager/concreteMethods/StageObjectSelectCounter\";\nimport { SectionInOutManager } from \"@/core/stage/stageManager/concreteMethods/StageSectionInOutManager\";\nimport { SectionPackManager } from \"@/core/stage/stageManager/concreteMethods/StageSectionPackManager\";\nimport { SectionCollisionSolver } from \"@/core/stage/stageManager/concreteMethods/SectionCollisionSolver\";\nimport { TagManager } from \"@/core/stage/stageManager/concreteMethods/StageTagManager\";\nimport { HistoryManager } from \"@/core/stage/stageManager/StageHistoryManager\";\nimport { StageManager } from \"@/core/stage/stageManager/StageManager\";\nimport { AutoSaveBackupService } from \"./service/dataFileService/AutoSaveBackupService\";\nimport { ReferenceManager } from \"./stage/stageManager/concreteMethods/StageReferenceManager\";\n\n/**\n * 以下方法在项目初始化之前加载所有服务\n * @param project\n */\nexport function loadAllServicesBeforeInit(project: Project): void {\n  project.registerFileSystemProvider(\"file\", FileSystemProviderFile);\n  project.registerFileSystemProvider(\"draft\", FileSystemProviderDraft);\n  project.loadService(Canvas);\n  project.loadService(InputElement);\n  project.loadService(StageStyleManager);\n  project.loadService(KeyBinds);\n  project.loadService(ControllerUtils);\n\n  // 基础算法\n  project.loadService(SectionMethods);\n  project.loadService(GraphMethods);\n\n  project.loadService(Controller);\n  project.loadService(AutoComputeUtils);\n  project.loadService(RenderUtils);\n  project.loadService(WorldRenderUtils);\n  project.loadService(StageManager);\n\n  // 自动计算引擎应该早于Camera，因为它会操作摄像机\n  project.loadService(AutoCompute);\n  project.loadService(Camera);\n\n  // 基础渲染器\n  project.loadService(Renderer);\n  // Effects必须在Renderer之后\n  project.loadService(Effects);\n\n  project.loadService(RectangleSelect);\n  project.loadService(StageNodeRotate);\n  project.loadService(ComplexityDetector);\n  project.loadService(AIEngine);\n  project.loadService(CopyEngine);\n  project.loadService(AutoLayout);\n  project.loadService(AutoLayoutFastTree);\n  project.loadService(LayoutManager);\n  project.loadService(AutoAlign);\n  project.loadService(MouseInteraction);\n  project.loadService(ContentSearch);\n  project.loadService(DeleteManager);\n  project.loadService(NodeAdder);\n  project.loadService(EntityMoveManager);\n  project.loadService(StageUtils);\n  project.loadService(MultiTargetEdgeMove);\n  project.loadService(NodeConnector);\n  project.loadService(StageObjectColorManager);\n  project.loadService(StageObjectSelectCounter);\n  project.loadService(SectionInOutManager);\n  project.loadService(SectionPackManager);\n  project.loadService(SectionCollisionSolver);\n  project.loadService(TagManager);\n  project.loadService(ReferenceManager);\n  project.loadService(KeyboardOnlyEngine);\n  project.loadService(KeyboardOnlyGraphEngine);\n  project.loadService(KeyboardOnlyTreeEngine);\n  project.loadService(SelectChangeEngine);\n\n  // 渲染服务\n  project.loadService(TextRenderer);\n  project.loadService(ImageRenderer);\n  project.loadService(ShapeRenderer);\n  project.loadService(EntityRenderer);\n  project.loadService(MultiTargetUndirectedEdgeRenderer);\n  project.loadService(CurveRenderer);\n  project.loadService(SvgRenderer);\n  project.loadService(DrawingControllerRenderer);\n  project.loadService(CollisionBoxRenderer);\n  project.loadService(EntityDetailsButtonRenderer);\n  project.loadService(StraightEdgeRenderer);\n  project.loadService(SymmetryCurveEdgeRenderer);\n  project.loadService(VerticalPolyEdgeRenderer);\n  project.loadService(EdgeRenderer);\n  project.loadService(SectionRenderer);\n  project.loadService(SvgNodeRenderer);\n  project.loadService(TextNodeRenderer);\n  project.loadService(UrlNodeRenderer);\n  project.loadService(ReferenceBlockRenderer);\n  project.loadService(BackgroundRenderer);\n  project.loadService(SearchContentHighlightRenderer);\n\n  // 导入导出服务\n  project.loadService(StageImport);\n  project.loadService(StageExport);\n  project.loadService(StageExportPng);\n  project.loadService(StageExportSvg);\n  project.loadService(GenerateFromFolder);\n\n  // 快捷键交互\n  project.loadService(KeyBindsRegistrar);\n\n  // 自动保存与备份\n  project.loadService(AutoSaveBackupService);\n}\n\nexport function loadAllServicesAfterInit(project: Project): void {\n  project.loadService(HistoryManager);\n}\n"
  },
  {
    "path": "app/src/core/plugin/PluginCodeParseData.tsx",
    "content": "/**\n * 插件代码解析数据\n */\nexport interface PluginCodeParseData {\n  name: string;\n  version: string;\n  description: string;\n  author: string;\n}\n\n/**\n * 从插件代码中解析出信息\n *\n * @param code 用户编写的脚本字符串\n * @returns 解析结果 { data: PluginCodeParseData, error: string, success: boolean }\n * @example\n * ```javascript\n * const code = `\n * // ==UserScript==\n * // @name     摄像机疯狂抖动插件\n * // @description 测试插件\n * // @version  1.0.0\n * // @author   Littlefean\n * // ==/UserScript==\n * `;\n * const result = parsePluginCode(code);\n * *\n * {\n *   data: { name: '摄像机疯狂抖动插件', version: '1.0.0', description: '测试插件', author: 'Littlefean' },\n *   error: '',\n *   success: true\n * }\n */\nexport function parsePluginCode(code: string): { data: PluginCodeParseData; error: string; success: boolean } {\n  const result = { data: { name: \"\", version: \"\", description: \"\", author: \"\" }, error: \"\", success: false };\n  if (!code) {\n    result.error = \"插件代码为空\";\n    return result;\n  }\n  const lines = code.split(\"\\n\");\n  let name = \"\";\n  let version = \"\";\n  let description = \"\";\n  let author = \"\";\n  for (const line of lines) {\n    if (line.startsWith(\"// @name\")) {\n      name = line.replace(\"// @name\", \"\").trim();\n    } else if (line.startsWith(\"// @version\")) {\n      version = line.replace(\"// @version\", \"\").trim();\n    } else if (line.startsWith(\"// @description\")) {\n      description = line.replace(\"// @description\", \"\").trim();\n    } else if (line.startsWith(\"// @author\")) {\n      author = line.replace(\"// @author\", \"\").trim();\n    }\n  }\n  if (name !== \"\" && version !== \"\" && description !== \"\" && author !== \"\") {\n    result.data = { name, version, description, author };\n  } else {\n    result.error = \"插件代码格式不正确\\n\";\n    if (name === \"\") {\n      result.error += \"缺少 @name 信息\\n\";\n    }\n    if (version === \"\") {\n      result.error += \"缺少 @version 信息\\n\";\n    }\n    if (description === \"\") {\n      result.error += \"缺少 @description 信息\\n\";\n    }\n    if (author === \"\") {\n      result.error += \"缺少 @author 信息\\n\";\n    }\n  }\n\n  return result;\n}\n"
  },
  {
    "path": "app/src/core/plugin/PluginWorker.tsx",
    "content": "import { pluginApis } from \"@/core/plugin/apis\";\nimport { apiTypes, PluginManifest, WorkerMessage } from \"@/core/plugin/types\";\n\n/**\n * 插件工作线程\n */\nexport class PluginWorker {\n  private blobUrl: string;\n  private worker: Worker;\n  private allowedMethods: Array<keyof typeof apiTypes>;\n\n  constructor(code: string, manifest: PluginManifest) {\n    // 把code转换成blob\n    const blob = new Blob([code], { type: \"text/javascript\" });\n    // 创建worker\n    this.blobUrl = URL.createObjectURL(blob);\n    this.worker = new Worker(this.blobUrl);\n    this.allowedMethods = manifest.permissions;\n\n    // worker接收到信息，判断是否为API调用\n    this.worker.onmessage = (e: MessageEvent<WorkerMessage>) => {\n      const { type, payload } = e.data;\n\n      // 插件调用API\n      if (type === \"callAPIMethod\") {\n        const { method, args, reqId } = payload;\n\n        // 校验API方法是否允许\n        if (!this.allowedMethods.includes(method)) {\n          this.worker.postMessage({\n            type: \"apiResponse\",\n            payload: {\n              reqId,\n              error: `Method \"${method}\" is not allowed by manifest`,\n            },\n          });\n          return;\n        }\n\n        // 校验API方法参数是否合法\n        const argsSchema = apiTypes[method][0];\n        if (!args) {\n          this.worker.postMessage({\n            type: \"apiResponse\",\n            payload: {\n              reqId,\n              error: `方法 method \"${method}\" 调用时，未声明参数列表，若该函数不需要参数，请传入空数组 args: []`,\n            },\n          });\n          return;\n        }\n        for (const [i, arg] of args.entries()) {\n          const parseResult = argsSchema[i].safeParse(arg);\n          if (!parseResult.success) {\n            this.worker.postMessage({\n              type: \"apiResponse\",\n              payload: {\n                reqId,\n                error: `Argument ${i} of method \"${method}\" is not valid: ${parseResult.error.message}`,\n              },\n            });\n            return;\n          }\n        }\n        // 校验通过\n\n        // 调用API方法\n        const apiMethod = pluginApis[method] as (...args: any[]) => any;\n        try {\n          const result = apiMethod(...args);\n          if (result instanceof Promise) {\n            result\n              .then((res) => {\n                this.worker.postMessage({\n                  type: \"apiResponse\",\n                  payload: {\n                    reqId,\n                    success: true,\n                    result: res,\n                  },\n                });\n              })\n              .catch((err) => {\n                this.worker.postMessage({\n                  type: \"apiResponse\",\n                  payload: {\n                    reqId,\n                    success: false,\n                    result: err,\n                  },\n                });\n              });\n          } else {\n            this.worker.postMessage({\n              type: \"apiResponse\",\n              payload: {\n                reqId,\n                success: true,\n                result,\n              },\n            });\n          }\n        } catch (err) {\n          this.worker.postMessage({\n            type: \"apiResponse\",\n            payload: {\n              reqId,\n              success: false,\n              result: err,\n            },\n          });\n        }\n      }\n    };\n  }\n\n  destroy() {\n    this.worker.terminate();\n    URL.revokeObjectURL(this.blobUrl);\n  }\n}\n"
  },
  {
    "path": "app/src/core/plugin/README.md",
    "content": "# 插件\n\n## 用户编写的插件代码格式要求\n\n做出个最最极简的插件跑通一下看看效果，只是一个测试\n\n```javascript\n// ==UserScript==\n// @name     摄像机疯狂抖动插件\n// @description 测试插件\n// @version  1.0.0\n// @author   Littlefean\n// ==/UserScript==\n```\n"
  },
  {
    "path": "app/src/core/plugin/UserScriptsManager.tsx",
    "content": "import { parsePluginCode, PluginCodeParseData } from \"@/core/plugin/PluginCodeParseData\";\nimport { PluginWorker } from \"@/core/plugin/PluginWorker\";\nimport { getAllAPIMethods } from \"@/core/plugin/types\";\nimport { createStore } from \"@/utils/store\";\nimport { exists, readTextFile } from \"@tauri-apps/plugin-fs\";\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport { toast } from \"sonner\";\n\n/**\n * 用户脚本管理器\n * 小插件\n */\nexport namespace UserScriptsManager {\n  let store: Store;\n\n  export type UserScriptFile = {\n    /**\n     * 绝对路径\n     */\n    path: string;\n    /**\n     * 是否启用\n     */\n    enabled: boolean;\n\n    scriptData: PluginCodeParseData;\n  };\n\n  const runningScripts: { [key: string]: PluginWorker } = {};\n\n  export async function init() {\n    store = await createStore(\"user-scripts.json\");\n    store.save();\n\n    // 延迟一小点时间，并开始加载并运行用户脚本\n    setTimeout(() => {\n      startRunUserScripts();\n    }, 1000);\n  }\n\n  /**\n   * 开始加载并运行用户脚本\n   */\n  export async function startRunUserScripts() {\n    await validAndRefresh();\n    const files = await getAllUserScripts();\n    for (const file of files) {\n      if (!file.enabled) {\n        continue;\n      }\n      enableUserScript(file.path);\n    }\n  }\n\n  /**\n   * 开启某个脚本的运行\n   * @param filePath 已经在上游确保是存在的脚本路径\n   */\n  async function enableUserScript(filePath: string) {\n    const code = await readTextFile(filePath);\n    const pluginWorker = new PluginWorker(code, {\n      permissions: getAllAPIMethods(),\n    });\n    runningScripts[filePath] = pluginWorker;\n  }\n\n  /**\n   * 关闭某个脚本的运行\n   * @param filePath\n   */\n  async function disableUserScript(filePath: string) {\n    if (runningScripts[filePath]) {\n      runningScripts[filePath].destroy();\n      delete runningScripts[filePath];\n    }\n  }\n\n  /**\n   * 重新解析全部脚本文件\n   */\n  export async function validAndRefresh() {\n    const files = await getAllUserScripts();\n    const validFiles: UserScriptFile[] = [];\n    const lostFiles: string[] = [];\n\n    for (const file of files) {\n      if (!exists(file.path)) {\n        // 这个文件丢了\n        lostFiles.push(file.path);\n        continue;\n      }\n\n      const { data, error, success } = parsePluginCode(await readTextFile(file.path));\n      if (success) {\n        validFiles.push({\n          path: file.path,\n          enabled: file.enabled,\n          scriptData: data,\n        });\n      } else {\n        lostFiles.push(file.path);\n        toast.error(`解析脚本失败：${file.path}\\n${error}`);\n      }\n    }\n\n    await store.set(\"userScripts\", validFiles); // 更新存储\n    await store.save();\n  }\n  /**\n   * 获取所有用户脚本文件\n   * @returns\n   */\n  export async function getAllUserScripts(): Promise<UserScriptFile[]> {\n    const data = ((await store.get(\"userScripts\")) as UserScriptFile[]) || [];\n    return data;\n  }\n\n  /**\n   * 添加用户脚本文件，内置了解析脚本功能\n   * @param filePath 文件绝对路径\n   * @returns\n   */\n  export async function addUserScript(filePath: string) {\n    const existingFiles = await getAllUserScripts();\n    for (const file of existingFiles) {\n      if (file.path === filePath) {\n        return false; // 文件已存在，不再添加\n      }\n    }\n    // 解析脚本\n    const code = await readTextFile(filePath);\n    const { data, error, success } = parsePluginCode(code);\n    if (!success) {\n      toast.error(`解析脚本失败：${filePath}\\n${error}`);\n      return false;\n    }\n\n    existingFiles.push({\n      path: filePath,\n      enabled: true,\n      scriptData: data,\n    });\n    await store.set(\"userScripts\", existingFiles);\n    await store.save();\n    return true;\n  }\n\n  export async function checkoutUserScriptEnabled(filePath: string, enabled: boolean) {\n    const existingFiles = (await store.get(\"userScripts\")) as UserScriptFile[];\n    for (const file of existingFiles) {\n      if (file.path === filePath) {\n        file.enabled = enabled;\n        if (enabled) {\n          // 用户关闭了某个正在运行的脚本\n          disableUserScript(filePath);\n          toast.success(`脚本已关闭：${filePath}`);\n        } else {\n          // 用户开启了某个脚本\n          if (runningScripts[filePath]) {\n            toast.warning(`脚本正在运行中，请先关闭：${filePath}`);\n          } else {\n            enableUserScript(filePath);\n            toast.success(`脚本已开启：${filePath}`);\n          }\n        }\n        break;\n      }\n    }\n    store.set(\"userScripts\", existingFiles);\n    store.save();\n  }\n\n  export async function removeUserScript(filePath: string) {\n    const existingFiles = await getAllUserScripts();\n    // 先检测有没有\n    let isFind = false;\n    for (const file of existingFiles) {\n      if (file.path === filePath) {\n        isFind = true;\n        break;\n      }\n    }\n    if (!isFind) {\n      return false;\n    }\n    // 先确保关闭\n    disableUserScript(filePath);\n    // 开始删除\n    const newFiles = existingFiles.filter((file) => file.path !== filePath);\n    await store.set(\"userScripts\", newFiles);\n    await store.save();\n    return true;\n  }\n}\n"
  },
  {
    "path": "app/src/core/plugin/apis.tsx",
    "content": "import { PluginAPIMayAsync } from \"@/core/plugin/types\";\nimport { toast } from \"sonner\";\n\nexport const pluginApis: PluginAPIMayAsync = {\n  hello(userString: string) {\n    toast.success(`Hello ${userString}`);\n    return \"hello\";\n  },\n};\n"
  },
  {
    "path": "app/src/core/plugin/types.tsx",
    "content": "import { z } from \"zod\";\n\n// 增加代码验证类型\nexport interface PluginPackage {\n  code: string;\n  manifest: PluginManifest;\n}\n// 定义允许插件调用的 API 方法类型\nexport const apiTypes = {\n  hello: [[z.string()], z.void()],\n  // getCameraLocation: [[], z.tuple([z.number(), z.number()])],\n  // setCameraLocation: [[z.number(), z.number()], z.void()],\n  // getPressingKey: [[], z.array(z.string())],\n  // clearPressingKey: [[], z.void()],\n  // getPressingKeySequence: [[], z.array(z.string())],\n  // clearPressingKeySequence: [[], z.void()],\n  // openDialog: [[z.string(), z.string()], z.void()],\n  // addDebugText: [[z.string()], z.void()],\n  // getCurrentStageJson: [[], z.string()],\n  // getCurrentStageSelectedObjectsUUIDs: [[], z.array(z.string())],\n  // createTextOnLocation: [[z.number(), z.number(), z.string()], z.string()],\n  // connectEntityByTwoUUID: [[z.string(), z.string()], z.boolean()],\n} as const;\n\nexport function getAllAPIMethods(): (keyof typeof apiTypes)[] {\n  return Object.keys(apiTypes) as (keyof typeof apiTypes)[];\n}\n\ntype Zod2Interface<T> = {\n  [K in keyof T]: T[K] extends readonly [\n    // 第一个元素：参数列表\n    infer Args extends readonly z.ZodTypeAny[],\n    // 第二个元素：返回值类型\n    infer Return extends z.ZodTypeAny,\n  ]\n    ? (\n        ...args: {\n          // 对每个参数使用z.infer\n          [L in keyof Args]: Args[L] extends z.ZodTypeAny ? z.infer<Args[L]> : never;\n        }\n      ) => z.infer<Return>\n    : never;\n};\n\nexport type Asyncize<T extends (...args: any[]) => any> = (...args: Parameters<T>) => Promise<ReturnType<T>>;\nexport type AsyncizeInterface<T> = {\n  [K in keyof T]: T[K] extends (...args: any[]) => any ? Asyncize<T[K]> : never;\n};\nexport type SyncOrAsyncizeInterface<T> = {\n  [K in keyof T]: T[K] extends (...args: any[]) => any ? Asyncize<T[K]> | T[K] : never;\n};\n\nexport type PluginAPI = Zod2Interface<typeof apiTypes>;\nexport type PluginAPIMayAsync = SyncOrAsyncizeInterface<PluginAPI>;\n\n// 消息通信协议类型\n\n/**\n * 插件发送给主进程的消息类型\n */\nexport type CallAPIMessage = {\n  type: \"callAPIMethod\";\n  payload: {\n    method: keyof typeof apiTypes;\n    args: any[];\n    reqId: string;\n  };\n};\n\n/**\n * 主进程响应给插件的消息类型\n */\nexport type APIResponseMessage = {\n  type: \"apiResponse\";\n  payload: {\n    reqId: string;\n    result?: any;\n    error?: string;\n  };\n};\n\nexport type WorkerMessage = CallAPIMessage | APIResponseMessage;\n\n/**\n * 插件清单类型\n */\nexport interface PluginManifest {\n  permissions: (keyof typeof apiTypes)[];\n}\n"
  },
  {
    "path": "app/src/core/render/3d/README.md",
    "content": "这个地方有可能会放其他的渲染引擎\n\n假如可能会用到3d的什么东西。。。\n"
  },
  {
    "path": "app/src/core/render/canvas2d/README.md",
    "content": "# Canvas2D 渲染器\n\n这个文件夹里分成三层\n\n最底层：基础渲染器，提供基础的渲染功能，包括画线、画矩形、画圆、画文本等。\n\n第二层：通用组件渲染器：相当于使用一些基础的可复用的组件来渲染。\n\n第三层：业务组件渲染器：专门针对各种节点、各种边来写的代码都在里面\n"
  },
  {
    "path": "app/src/core/render/canvas2d/basicRenderer/ImageRenderer.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { Vector } from \"@graphif/data-structures\";\n\n/**\n * 图片渲染器\n * 基于View坐标系\n */\n@service(\"imageRenderer\")\nexport class ImageRenderer {\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 根据图片HTML元素来渲染图片到canvas指定位置\n   * @param imageElement\n   * @param location 图片左上角位置\n   * @param scale 1 表示正常，0.5 表示缩小一半，2 表示放大两倍\n   */\n  renderImageElement(\n    source: Exclude<CanvasImageSource, VideoFrame | SVGElement>,\n    location: Vector,\n    scale: number = 1 / (window.devicePixelRatio || 1),\n  ) {\n    this.project.canvas.ctx.drawImage(\n      source,\n      location.x,\n      location.y,\n      source.width * scale * this.project.camera.currentScale,\n      source.height * scale * this.project.camera.currentScale,\n    );\n  }\n\n  /**\n   * 根据ImageBitmap来渲染图片到canvas指定位置\n   * @param bitmap ImageBitmap对象\n   * @param location 图片左上角位置\n   * @param scale 1 表示正常，0.5 表示缩小一半，2 表示放大两倍\n   */\n  renderImageBitmap(\n    bitmap: ImageBitmap | undefined,\n    location: Vector,\n    scale: number = 1 / (window.devicePixelRatio || 1),\n  ) {\n    if (!bitmap) {\n      return;\n    }\n    this.project.canvas.ctx.drawImage(\n      bitmap,\n      location.x,\n      location.y,\n      bitmap.width * scale * this.project.camera.currentScale,\n      bitmap.height * scale * this.project.camera.currentScale,\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/render/canvas2d/basicRenderer/curveRenderer.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { PenStrokeSegment } from \"@/core/stage/stageObject/entity/PenStroke\";\nimport { Color, Vector } from \"@graphif/data-structures\";\nimport { CubicBezierCurve, SymmetryCurve } from \"@graphif/shapes\";\n\n/**\n * 关于各种曲线和直线的渲染\n * 注意：这里全都是View坐标系\n */\n@service(\"curveRenderer\")\nexport class CurveRenderer {\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 绘制一条直线实线\n   * @param start\n   * @param end\n   * @param color\n   * @param width\n   */\n  renderSolidLine(start: Vector, end: Vector, color: Color, width: number): void {\n    this.project.canvas.ctx.beginPath();\n    this.project.canvas.ctx.moveTo(start.x, start.y);\n    this.project.canvas.ctx.lineTo(end.x, end.y);\n    this.project.canvas.ctx.lineWidth = width;\n    this.project.canvas.ctx.strokeStyle = color.toString();\n    this.project.canvas.ctx.stroke();\n  }\n\n  /**\n   * 绘制折线实线\n   * @param locations 所有点构成的数组\n   * @param color\n   * @param width\n   */\n  renderSolidLineMultiple(locations: Vector[], color: Color, width: number): void {\n    this.project.canvas.ctx.beginPath();\n    this.project.canvas.ctx.moveTo(locations[0].x, locations[0].y);\n    for (let i = 1; i < locations.length; i++) {\n      this.project.canvas.ctx.lineTo(locations[i].x, locations[i].y);\n    }\n    this.project.canvas.ctx.lineWidth = width;\n    this.project.canvas.ctx.strokeStyle = color.toString();\n    this.project.canvas.ctx.stroke();\n  }\n  renderPenStroke(stroke: PenStrokeSegment[], color: Color): void {\n    this.project.canvas.ctx.strokeStyle = color.toString();\n    this.project.canvas.ctx.lineJoin = \"round\";\n    if (stroke.length < 2) return;\n\n    this.project.canvas.ctx.beginPath();\n    this.project.canvas.ctx.moveTo(stroke[0].location.x, stroke[0].location.y);\n\n    for (let i = 1; i < stroke.length - 1; i++) {\n      const curr = stroke[i].location;\n      const next = stroke[i + 1].location;\n      // 取当前点和下一个点的中点\n      const midX = (curr.x + next.x) / 2;\n      const midY = (curr.y + next.y) / 2;\n\n      this.project.canvas.ctx.lineWidth = stroke[i].pressure * 5 * this.project.camera.currentScale;\n      this.project.canvas.ctx.quadraticCurveTo(curr.x, curr.y, midX, midY);\n      this.project.canvas.ctx.stroke();\n      this.project.canvas.ctx.beginPath();\n      this.project.canvas.ctx.moveTo(midX, midY);\n    }\n    // 处理最后一个点\n    const lastIndex = stroke.length - 1;\n    const last = stroke[lastIndex].location;\n    // 使用最后一个线段的pressure值设置lineWidth (AI修复的涂鸦粗细很恍惚的问题)\n    this.project.canvas.ctx.lineWidth = stroke[lastIndex - 1].pressure * 5 * this.project.camera.currentScale;\n    this.project.canvas.ctx.lineTo(last.x, last.y);\n    this.project.canvas.ctx.stroke();\n  }\n\n  /**\n   * 绘制经过平滑后的折线\n   * 核心思路：将折线的顶点转换为连续贝塞尔曲线的控制点。\n   * @param locations\n   * @param color\n   * @param width\n   */\n  renderSolidLineMultipleSmoothly(locations: Vector[], color: Color, width: number): void {\n    this.project.canvas.ctx.beginPath();\n    this.project.canvas.ctx.moveTo(locations[0].x, locations[0].y);\n\n    const segments = this.smoothPoints(locations, 0.25);\n    segments.forEach((seg) => {\n      this.project.canvas.ctx.bezierCurveTo(seg.cp1.x, seg.cp1.y, seg.cp2.x, seg.cp2.y, seg.end.x, seg.end.y);\n    });\n    this.project.canvas.ctx.lineWidth = width;\n    this.project.canvas.ctx.lineJoin = \"round\";\n    this.project.canvas.ctx.strokeStyle = color.toString();\n    this.project.canvas.ctx.stroke();\n  }\n\n  private smoothPoints(points: Vector[], tension = 0.5) {\n    const smoothed = [];\n    for (let i = 0; i < points.length - 1; i++) {\n      const p0 = i > 0 ? points[i - 1] : points[i];\n      const p1 = points[i];\n      const p2 = points[i + 1];\n      const p3 = i < points.length - 2 ? points[i + 2] : p2;\n\n      // 计算控制点（基于前后点位置）\n      const cp1x = p1.x + (p2.x - p0.x) * tension;\n      const cp1y = p1.y + (p2.y - p0.y) * tension;\n      const cp2x = p2.x - (p3.x - p1.x) * tension;\n      const cp2y = p2.y - (p3.y - p1.y) * tension;\n\n      // 添加三次贝塞尔曲线段\n      smoothed.push({\n        type: \"bezier\",\n        cp1: { x: cp1x, y: cp1y },\n        cp2: { x: cp2x, y: cp2y },\n        end: p2,\n      });\n    }\n    return smoothed;\n  }\n\n  /**\n   * 画一段折线，带有宽度实时变化\n   * 实测发现有宽度变化，频繁变更粗细会导致渲染卡顿\n   * @param locations\n   * @param color\n   * @param widthList\n   */\n  renderSolidLineMultipleWithWidth(locations: Vector[], color: Color, widthList: number[]): void {\n    this.project.canvas.ctx.beginPath();\n    this.project.canvas.ctx.lineJoin = \"round\";\n    this.project.canvas.ctx.lineWidth = widthList[0];\n    this.project.canvas.ctx.moveTo(locations[0].x, locations[0].y);\n    for (let i = 1; i < locations.length; i++) {\n      this.project.canvas.ctx.lineTo(locations[i].x, locations[i].y);\n      // this.project.canvas.ctx.stroke();\n    }\n    this.project.canvas.ctx.strokeStyle = color.toString();\n    this.project.canvas.ctx.stroke();\n    // this.project.canvas.ctx.strokeStyle = color.toString();\n    // this.project.canvas.ctx.beginPath();\n    // for (let i = 0; i < locations.length - 1; i++) {\n    //   const start = locations[i];\n    //   const end = locations[i + 1];\n    //   this.project.canvas.ctx.lineWidth = widthList[i + 1];\n    //   this.project.canvas.ctx.moveTo(start.x, start.y);\n    //   this.project.canvas.ctx.lineTo(end.x, end.y);\n    //   this.project.canvas.ctx.stroke();\n    // }\n  }\n\n  /**\n   * 绘制折线实线，带有阴影\n   * @param locations\n   * @param color\n   * @param width\n   * @param shadowColor\n   * @param shadowBlur\n   */\n  renderSolidLineMultipleWithShadow(\n    locations: Vector[],\n    color: Color,\n    width: number,\n    shadowColor: Color,\n    shadowBlur: number,\n  ): void {\n    this.project.canvas.ctx.beginPath();\n    this.project.canvas.ctx.moveTo(locations[0].x, locations[0].y);\n    for (let i = 1; i < locations.length; i++) {\n      this.project.canvas.ctx.lineTo(locations[i].x, locations[i].y);\n    }\n    this.project.canvas.ctx.lineWidth = width;\n    this.project.canvas.ctx.strokeStyle = color.toString();\n    this.project.canvas.ctx.shadowColor = shadowColor.toString();\n    this.project.canvas.ctx.shadowBlur = shadowBlur;\n    this.project.canvas.ctx.stroke();\n    this.project.canvas.ctx.shadowBlur = 0;\n  }\n\n  /**\n   * 绘制一条虚线\n   *\n   * 2024年11月10日 发现虚线渲染不生效，也很难排查到原因\n   * 2024年12月5日 突然发现又没有问题了，也不知道为什么。\n   * @param start\n   * @param end\n   * @param color\n   * @param width\n   * @param dashLength 虚线的长度，效果： =2: \"--  --  --  --\", =1: \"- - - - -\"\n   */\n  renderDashedLine(start: Vector, end: Vector, color: Color, width: number, dashLength: number): void {\n    this.project.canvas.ctx.setLineDash([dashLength, dashLength]);\n    this.project.canvas.ctx.beginPath();\n    this.project.canvas.ctx.moveTo(start.x, start.y);\n    this.project.canvas.ctx.lineTo(end.x, end.y);\n    this.project.canvas.ctx.lineWidth = width;\n    this.project.canvas.ctx.strokeStyle = color.toString();\n    this.project.canvas.ctx.stroke();\n    // 重置线型\n    this.project.canvas.ctx.setLineDash([]);\n  }\n\n  /**\n   * 绘制一条双实线\n   * 通过绘制两条平行的实线来实现双实线效果\n   * @param start\n   * @param end\n   * @param color\n   * @param width\n   * @param gap 两条线之间的间距\n   */\n  renderDoubleLine(start: Vector, end: Vector, color: Color, width: number, gap: number): void {\n    const direction = end.subtract(start).normalize();\n    const perpendicular = new Vector(-direction.y, direction.x).multiply(gap / 2);\n\n    // 绘制第一条线（上方/左侧）\n    this.renderSolidLine(start.add(perpendicular), end.add(perpendicular), color, width);\n    // 绘制第二条线（下方/右侧）\n    this.renderSolidLine(start.subtract(perpendicular), end.subtract(perpendicular), color, width);\n  }\n\n  /**\n   * 绘制一条贝塞尔曲线\n   * @param curve\n   */\n  renderBezierCurve(curve: CubicBezierCurve, color: Color, width: number): void {\n    this.project.canvas.ctx.beginPath();\n    this.project.canvas.ctx.moveTo(curve.start.x, curve.start.y);\n    this.project.canvas.ctx.bezierCurveTo(\n      curve.ctrlPt1.x,\n      curve.ctrlPt1.y,\n      curve.ctrlPt2.x,\n      curve.ctrlPt2.y,\n      curve.end.x,\n      curve.end.y,\n    );\n    this.project.canvas.ctx.lineWidth = width;\n    this.project.canvas.ctx.strokeStyle = color.toString();\n    this.project.canvas.ctx.stroke();\n  }\n\n  /**\n   * 绘制一条虚线贝塞尔曲线\n   * @param curve\n   * @param color\n   * @param width\n   * @param dashLength 虚线的长度\n   */\n  renderDashedBezierCurve(curve: CubicBezierCurve, color: Color, width: number, dashLength: number): void {\n    this.project.canvas.ctx.setLineDash([dashLength, dashLength]);\n    this.project.canvas.ctx.beginPath();\n    this.project.canvas.ctx.moveTo(curve.start.x, curve.start.y);\n    this.project.canvas.ctx.bezierCurveTo(\n      curve.ctrlPt1.x,\n      curve.ctrlPt1.y,\n      curve.ctrlPt2.x,\n      curve.ctrlPt2.y,\n      curve.end.x,\n      curve.end.y,\n    );\n    this.project.canvas.ctx.lineWidth = width;\n    this.project.canvas.ctx.strokeStyle = color.toString();\n    this.project.canvas.ctx.stroke();\n    // 重置线型\n    this.project.canvas.ctx.setLineDash([]);\n  }\n\n  /**\n   * 绘制一条双实线贝塞尔曲线\n   * 通过绘制两条平行的贝塞尔曲线来实现双实线效果\n   * @param curve\n   * @param color\n   * @param width\n   * @param gap 两条线之间的间距\n   */\n  renderDoubleBezierCurve(curve: CubicBezierCurve, color: Color, width: number, gap: number): void {\n    // 计算起点和终点的方向向量\n    const startDirection = curve.ctrlPt1.subtract(curve.start).normalize();\n    const endDirection = curve.end.subtract(curve.ctrlPt2).normalize();\n\n    // 计算垂直于方向的向量（用于偏移）\n    const startPerpendicular = new Vector(-startDirection.y, startDirection.x).multiply(gap / 2);\n    const endPerpendicular = new Vector(-endDirection.y, endDirection.x).multiply(gap / 2);\n\n    // 计算控制点的偏移（使用起点和终点的平均方向）\n    const midDirection = endDirection.add(startDirection).normalize();\n    const midPerpendicular = new Vector(-midDirection.y, midDirection.x).multiply(gap / 2);\n\n    // 创建第一条曲线（上方/左侧）\n    const curve1 = new CubicBezierCurve(\n      curve.start.add(startPerpendicular),\n      curve.ctrlPt1.add(midPerpendicular),\n      curve.ctrlPt2.add(midPerpendicular),\n      curve.end.add(endPerpendicular),\n    );\n    this.renderBezierCurve(curve1, color, width);\n\n    // 创建第二条曲线（下方/右侧）\n    const curve2 = new CubicBezierCurve(\n      curve.start.subtract(startPerpendicular),\n      curve.ctrlPt1.subtract(midPerpendicular),\n      curve.ctrlPt2.subtract(midPerpendicular),\n      curve.end.subtract(endPerpendicular),\n    );\n    this.renderBezierCurve(curve2, color, width);\n  }\n\n  /**\n   * 绘制一条对称曲线\n   * @param curve\n   */\n  renderSymmetryCurve(curve: SymmetryCurve, color: Color, width: number): void {\n    this.renderBezierCurve(curve.bezier, color, width);\n  }\n\n  /**\n   * 绘制一条从颜色渐变到另一种颜色的实线\n   */\n  renderGradientLine(start: Vector, end: Vector, startColor: Color, endColor: Color, width: number): void {\n    const gradient = this.project.canvas.ctx.createLinearGradient(start.x, start.y, end.x, end.y);\n    // 添加颜色\n    gradient.addColorStop(0, startColor.toString()); // 起始颜色\n    gradient.addColorStop(1, endColor.toString()); // 结束颜色\n    this.project.canvas.ctx.beginPath();\n    this.project.canvas.ctx.moveTo(start.x, start.y);\n    this.project.canvas.ctx.lineTo(end.x, end.y);\n    this.project.canvas.ctx.lineWidth = width;\n    this.project.canvas.ctx.strokeStyle = gradient;\n    this.project.canvas.ctx.stroke();\n  }\n  /**\n   * 绘制一条颜色渐变的贝塞尔曲线\n   * @param curve\n   */\n  renderGradientBezierCurve(curve: CubicBezierCurve, startColor: Color, endColor: Color, width: number): void {\n    const gradient = this.project.canvas.ctx.createLinearGradient(\n      curve.start.x,\n      curve.start.y,\n      curve.end.x,\n      curve.end.y,\n    );\n    // 添加颜色\n    gradient.addColorStop(0, startColor.toString()); // 起始颜色\n    gradient.addColorStop(1, endColor.toString()); // 结束颜色\n    this.project.canvas.ctx.beginPath();\n    this.project.canvas.ctx.moveTo(curve.start.x, curve.start.y);\n    this.project.canvas.ctx.bezierCurveTo(\n      curve.ctrlPt1.x,\n      curve.ctrlPt1.y,\n      curve.ctrlPt2.x,\n      curve.ctrlPt2.y,\n      curve.end.x,\n      curve.end.y,\n    );\n    this.project.canvas.ctx.lineWidth = width;\n    this.project.canvas.ctx.strokeStyle = gradient;\n    this.project.canvas.ctx.stroke();\n  }\n}\n"
  },
  {
    "path": "app/src/core/render/canvas2d/basicRenderer/shapeRenderer.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { Color, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\n\n/**\n * 基础图形渲染器\n * 注意：全部都是基于View坐标系\n */\n@service(\"shapeRenderer\")\nexport class ShapeRenderer {\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 画一个圆\n   * @param ctx\n   * @param centerLocation\n   * @param radius\n   * @param color\n   * @param strokeColor\n   * @param strokeWidth\n   */\n  renderCircle(centerLocation: Vector, radius: number, color: Color, strokeColor: Color, strokeWidth: number): void {\n    this.project.canvas.ctx.beginPath();\n    this.project.canvas.ctx.arc(centerLocation.x, centerLocation.y, radius, 0, 2 * Math.PI);\n    this.project.canvas.ctx.fillStyle = color.toString();\n    this.project.canvas.ctx.fill();\n    this.project.canvas.ctx.lineWidth = strokeWidth;\n    this.project.canvas.ctx.strokeStyle = strokeColor.toString();\n    this.project.canvas.ctx.stroke();\n  }\n\n  /**\n   * 画一个圆弧但不填充\n   * 从开始弧度到结束弧度，逆时针转过去。（因为y轴向下）\n   * @param centerLocation 圆弧的中心\n   * @param radius 半径\n   * @param angle1 开始弧度\n   * @param angle2 结束弧度\n   * @param strokeColor\n   * @param strokeWidth\n   */\n  renderArc(\n    centerLocation: Vector,\n    radius: number,\n    angle1: number,\n    angle2: number,\n    strokeColor: Color,\n    strokeWidth: number,\n  ): void {\n    this.project.canvas.ctx.beginPath();\n    this.project.canvas.ctx.arc(centerLocation.x, centerLocation.y, radius, angle1, angle2);\n    this.project.canvas.ctx.lineWidth = strokeWidth;\n    this.project.canvas.ctx.strokeStyle = strokeColor.toString();\n    this.project.canvas.ctx.stroke();\n  }\n\n  /**\n   * 画一个矩形，但是坐标点是矩形的中心点\n   * @param centerLocation\n   * @param width\n   * @param height\n   * @param color\n   * @param strokeColor\n   * @param strokeWidth\n   * @param radius\n   */\n  renderRectFromCenter(\n    centerLocation: Vector,\n    width: number,\n    height: number,\n    color: Color,\n    strokeColor: Color,\n    strokeWidth: number,\n    radius: number = 0,\n  ): void {\n    this.project.canvas.ctx.beginPath();\n    this.project.canvas.ctx.roundRect(\n      centerLocation.x - width / 2,\n      centerLocation.y - height / 2,\n      width,\n      height,\n      radius,\n    );\n    this.project.canvas.ctx.fillStyle = color.toString();\n    this.project.canvas.ctx.fill();\n    this.project.canvas.ctx.lineWidth = strokeWidth;\n    this.project.canvas.ctx.strokeStyle = strokeColor.toString();\n    this.project.canvas.ctx.stroke();\n  }\n\n  /**\n   * 画矩形\n   * @param rect\n   * @param color\n   * @param strokeColor\n   * @param strokeWidth\n   * @param radius\n   */\n  renderRect(rect: Rectangle, color: Color, strokeColor: Color, strokeWidth: number, radius: number = 0) {\n    this.project.canvas.ctx.beginPath();\n    this.project.canvas.ctx.roundRect(rect.location.x, rect.location.y, rect.size.x, rect.size.y, radius);\n    this.project.canvas.ctx.fillStyle = color.toString();\n    this.project.canvas.ctx.fill();\n    this.project.canvas.ctx.lineWidth = strokeWidth;\n    this.project.canvas.ctx.strokeStyle = strokeColor.toString();\n    this.project.canvas.ctx.stroke();\n  }\n\n  renderDashedRect(\n    rect: Rectangle,\n    color: Color,\n    strokeColor: Color,\n    strokeWidth: number,\n    radius: number = 0,\n    dashLength = 5,\n  ) {\n    this.project.canvas.ctx.setLineDash([dashLength, dashLength]);\n    this.project.canvas.ctx.beginPath();\n    this.project.canvas.ctx.roundRect(rect.location.x, rect.location.y, rect.size.x, rect.size.y, radius);\n    this.project.canvas.ctx.fillStyle = color.toString();\n    this.project.canvas.ctx.fill();\n    this.project.canvas.ctx.lineWidth = strokeWidth;\n    this.project.canvas.ctx.strokeStyle = strokeColor.toString();\n    this.project.canvas.ctx.stroke();\n    // 重置线型\n    this.project.canvas.ctx.setLineDash([]);\n  }\n\n  /**\n   * 画一个带阴影的矩形\n   * @param rect\n   */\n  renderRectWithShadow(\n    rect: Rectangle,\n    fillColor: Color,\n    strokeColor: Color,\n    strokeWidth: number,\n    shadowColor: Color,\n    shadowBlur: number,\n    shadowOffsetX: number = 0,\n    shadowOffsetY: number = 0,\n    radius: number = 0,\n  ) {\n    this.project.canvas.ctx.shadowColor = shadowColor.toString();\n    this.project.canvas.ctx.shadowBlur = shadowBlur;\n    this.project.canvas.ctx.shadowOffsetX = shadowOffsetX;\n    this.project.canvas.ctx.shadowOffsetY = shadowOffsetY;\n    this.project.canvas.ctx.beginPath();\n    this.project.canvas.ctx.roundRect(rect.location.x, rect.location.y, rect.size.x, rect.size.y, radius);\n    this.project.canvas.ctx.fillStyle = fillColor.toString();\n    this.project.canvas.ctx.fill();\n    this.project.canvas.ctx.lineWidth = strokeWidth;\n    this.project.canvas.ctx.strokeStyle = strokeColor.toString();\n    this.project.canvas.ctx.stroke();\n    this.project.canvas.ctx.shadowColor = \"transparent\";\n    this.project.canvas.ctx.shadowBlur = 0;\n    this.project.canvas.ctx.shadowOffsetX = 0;\n    this.project.canvas.ctx.shadowOffsetY = 0;\n  }\n\n  /**\n   * 绘制一个多边形并填充\n   * @param points 多边形的顶点数组，三角形就只需三个点，\n   * 不用考虑首尾点闭合。\n   * @param fillColor\n   * @param strokeColor\n   * @param strokeWidth\n   */\n  renderPolygonAndFill(\n    points: Vector[],\n    fillColor: Color,\n    strokeColor: Color,\n    strokeWidth: number,\n    lineJoin: \"round\" | \"bevel\" = \"round\",\n  ): void {\n    this.project.canvas.ctx.lineJoin = lineJoin; // 圆角\n    // bevel，斜角\n    this.project.canvas.ctx.beginPath();\n    this.project.canvas.ctx.moveTo(points[0].x, points[0].y);\n    for (let i = 1; i < points.length; i++) {\n      this.project.canvas.ctx.lineTo(points[i].x, points[i].y);\n    }\n    this.project.canvas.ctx.closePath();\n    this.project.canvas.ctx.fillStyle = fillColor.toString();\n    this.project.canvas.ctx.fill();\n    this.project.canvas.ctx.lineWidth = strokeWidth;\n    this.project.canvas.ctx.strokeStyle = strokeColor.toString();\n    this.project.canvas.ctx.stroke();\n  }\n\n  /**\n   * 绘制中心过渡的圆形不加边框\n   * 常用于一些特效\n   */\n  renderCircleTransition(viewLocation: Vector, radius: number, centerColor: Color) {\n    const gradient = this.project.canvas.ctx.createRadialGradient(\n      viewLocation.x,\n      viewLocation.y,\n      0,\n      viewLocation.x,\n      viewLocation.y,\n      radius,\n    );\n    // 添加颜色\n    gradient.addColorStop(0, centerColor.toString()); // 中心\n    const transparentColor = centerColor.clone();\n    transparentColor.a = 0;\n    gradient.addColorStop(1, transparentColor.toString()); // 边缘透明\n    this.project.canvas.ctx.fillStyle = gradient;\n    this.project.canvas.ctx.strokeStyle = \"transparent\";\n    // 绘制圆形\n    this.project.canvas.ctx.beginPath();\n    this.project.canvas.ctx.arc(viewLocation.x, viewLocation.y, radius, 0, 2 * Math.PI, false);\n    this.project.canvas.ctx.fill();\n  }\n\n  /**\n   * 画一个类似摄像机的形状，矩形边框\n   * 表面上看上去是一个矩形框，但是只有四个角，每隔边的中间部分是透明的\n   * @param rect 矩形\n   * @param borderColor 边框颜色\n   * @param borderWidth 边框宽度\n   */\n  renderCameraShapeBorder(rect: Rectangle, borderColor: Color, borderWidth: number) {\n    const x = rect.location.x;\n    const y = rect.location.y;\n    const w = rect.size.x;\n    const h = rect.size.y;\n\n    // 计算四个角线段的长度（取各边长的25%）\n    const hLineLen = w * 0.25;\n    const vLineLen = h * 0.25;\n\n    this.project.canvas.ctx.beginPath();\n\n    // 左上角（右向线段 + 下向线段）\n    this.project.canvas.ctx.moveTo(x, y);\n    this.project.canvas.ctx.lineTo(x + hLineLen, y);\n    this.project.canvas.ctx.moveTo(x, y);\n    this.project.canvas.ctx.lineTo(x, y + vLineLen);\n\n    // 右上角（左向线段 + 下向线段）\n    this.project.canvas.ctx.moveTo(x + w, y);\n    this.project.canvas.ctx.lineTo(x + w - hLineLen, y);\n    this.project.canvas.ctx.moveTo(x + w, y);\n    this.project.canvas.ctx.lineTo(x + w, y + vLineLen);\n\n    // 右下角（左向线段 + 上向线段）\n    this.project.canvas.ctx.moveTo(x + w, y + h);\n    this.project.canvas.ctx.lineTo(x + w - hLineLen, y + h);\n    this.project.canvas.ctx.moveTo(x + w, y + h);\n    this.project.canvas.ctx.lineTo(x + w, y + h - vLineLen);\n\n    // 左下角（右向线段 + 上向线段）\n    this.project.canvas.ctx.moveTo(x, y + h);\n    this.project.canvas.ctx.lineTo(x + hLineLen, y + h);\n    this.project.canvas.ctx.moveTo(x, y + h);\n    this.project.canvas.ctx.lineTo(x, y + h - vLineLen);\n\n    // 设置绘制样式\n    this.project.canvas.ctx.strokeStyle = borderColor.toString();\n    this.project.canvas.ctx.lineWidth = borderWidth;\n    this.project.canvas.ctx.lineCap = \"round\"; // 线段末端圆角\n    this.project.canvas.ctx.stroke();\n  }\n\n  /**\n   * 渲染缩放控制点的箭头指示\n   * 在矩形的左上角和右下角绘制箭头，用于提示用户可以拖拽缩放\n   * @param rect 缩放控制点矩形\n   * @param color 箭头颜色\n   * @param strokeWidth 箭头线条宽度\n   */\n  renderResizeArrow(rect: Rectangle, color: Color, strokeWidth: number) {\n    const x = rect.location.x;\n    const y = rect.location.y;\n    const w = rect.size.x;\n    const h = rect.size.y;\n    const arrowSize = Math.min(w, h) * 0.6;\n    const offset = strokeWidth * 5;\n    const shortLineSize = arrowSize * 0.01;\n\n    this.project.canvas.ctx.beginPath();\n    this.project.canvas.ctx.strokeStyle = color.toString();\n    this.project.canvas.ctx.lineWidth = strokeWidth;\n    this.project.canvas.ctx.lineCap = \"round\";\n    this.project.canvas.ctx.lineJoin = \"round\";\n\n    // 左上角箭头 - 指向左上，绘制在矩形外部\n    // 主箭头\n    this.project.canvas.ctx.moveTo(x + arrowSize, y - offset);\n    this.project.canvas.ctx.lineTo(x - offset, y - offset);\n    this.project.canvas.ctx.lineTo(x - offset, y + arrowSize);\n    // 添加短斜线\n    this.project.canvas.ctx.moveTo(x - offset, y - offset);\n    this.project.canvas.ctx.lineTo(x + shortLineSize, y + shortLineSize);\n\n    // 右下角箭头 - 指向右下，绘制在矩形外部\n    // 主箭头\n    this.project.canvas.ctx.moveTo(x + w - arrowSize, y + h + offset);\n    this.project.canvas.ctx.lineTo(x + w + offset, y + h + offset);\n    this.project.canvas.ctx.lineTo(x + w + offset, y + h - arrowSize);\n    // 添加短斜线\n    this.project.canvas.ctx.moveTo(x + w + offset, y + h + offset);\n    this.project.canvas.ctx.lineTo(x + w - shortLineSize, y + h - shortLineSize);\n\n    this.project.canvas.ctx.stroke();\n  }\n}\n"
  },
  {
    "path": "app/src/core/render/canvas2d/basicRenderer/svgRenderer.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { Vector } from \"@graphif/data-structures\";\n\n@service(\"svgRenderer\")\nexport class SvgRenderer {\n  svgCache: { [key: string]: HTMLImageElement } = {};\n\n  constructor(private readonly project: Project) {}\n\n  renderSvgFromLeftTop(svg: string, location: Vector, width: number, height: number): void {\n    if (svg in this.svgCache) {\n      this.project.canvas.ctx.drawImage(this.svgCache[svg], location.x, location.y, width, height);\n    } else {\n      const img = new Image();\n      img.src = \"data:image/svg+xml,\" + encodeURIComponent(svg);\n      img.onload = () => {\n        this.svgCache[svg] = img;\n      };\n    }\n  }\n\n  renderSvgFromCenter(svg: string, centerLocation: Vector, width: number, height: number): void {\n    if (svg in this.svgCache) {\n      this.project.canvas.ctx.drawImage(\n        this.svgCache[svg],\n        centerLocation.x - width / 2,\n        centerLocation.y - height / 2,\n        width,\n        height,\n      );\n    } else {\n      const img = new Image();\n      img.src = \"data:image/svg+xml,\" + encodeURIComponent(svg);\n      img.onload = () => {\n        this.svgCache[svg] = img;\n      };\n    }\n  }\n\n  renderSvgFromLeftTopWithoutSize(svg: string, location: Vector, scaleNumber = 1): void {\n    if (svg in this.svgCache) {\n      const img = this.svgCache[svg];\n      this.project.canvas.ctx.drawImage(\n        this.svgCache[svg],\n        location.x,\n        location.y,\n        img.naturalWidth * this.project.camera.currentScale * scaleNumber,\n        img.naturalHeight * this.project.camera.currentScale * scaleNumber,\n      );\n    } else {\n      const img = new Image();\n      img.src = \"data:image/svg+xml,\" + encodeURIComponent(svg);\n      img.onload = () => {\n        this.svgCache[svg] = img;\n      };\n    }\n  }\n\n  renderSvgFromCenterWithoutSize(svg: string, centerLocation: Vector): void {\n    if (svg in this.svgCache) {\n      const img = this.svgCache[svg];\n      this.project.canvas.ctx.drawImage(\n        this.svgCache[svg],\n        centerLocation.x - (img.naturalWidth / 2) * this.project.camera.currentScale,\n        centerLocation.y - (img.naturalHeight / 2) * this.project.camera.currentScale,\n        img.naturalWidth * this.project.camera.currentScale,\n        img.naturalHeight * this.project.camera.currentScale,\n      );\n    } else {\n      const img = new Image();\n      img.src = \"data:image/svg+xml,\" + encodeURIComponent(svg);\n      img.onload = () => {\n        this.svgCache[svg] = img;\n      };\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/render/canvas2d/basicRenderer/textRenderer.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { FONT, getTextSize, replaceTextWhenProtect } from \"@/utils/font\";\nimport { Color, LruCache, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport md5 from \"md5\";\n\n/**\n * 专门用于在Canvas上渲染文字\n * 支持缓存\n * 注意：基于View坐标系\n */\n@service(\"textRenderer\")\nexport class TextRenderer {\n  private cache = new LruCache<string, ImageBitmap>(Settings.textCacheSize);\n\n  constructor(private readonly project: Project) {}\n\n  private hash(text: string, size: number): string {\n    // md5(text)_fontSize\n    const textHash = md5(text);\n    return `${textHash}_${size}`;\n  }\n  private getCache(text: string, size: number) {\n    const cacheKey = this.hash(text, size);\n    const cacheValue = this.cache.get(cacheKey);\n    return cacheValue;\n  }\n  /**\n   * 获取text相同，fontSize最接近的缓存图片\n   */\n  private getCacheNearestSize(text: string, size: number): ImageBitmap | undefined {\n    const textHash = md5(text);\n    let nearestBitmap: ImageBitmap | undefined;\n    let minDiff = Infinity;\n\n    // 遍历缓存中所有key\n    for (const key of this.cache.keys()) {\n      // 解构出textHash和fontSize\n      const [cachedTextHash, cachedFontSizeStr] = key.split(\"_\");\n      const cachedFontSize = Number(cachedFontSizeStr);\n\n      // 只处理相同text的缓存\n      if (cachedTextHash === textHash) {\n        const diff = Math.abs(cachedFontSize - size);\n        if (diff < minDiff) {\n          minDiff = diff;\n          nearestBitmap = this.cache.get(key);\n        }\n      }\n    }\n\n    return nearestBitmap;\n  }\n\n  private buildCache(text: string, size: number, color: Color): CanvasImageSource {\n    const textSize = getTextSize(text, size);\n    // 这里用OffscreenCanvas而不是document.createElement(\"canvas\")\n    // 因为OffscreenCanvas有神秘优化，后续也方便移植到Worker中渲染\n    if (textSize.x <= 1 || textSize.y <= 1) {\n      // 如果文本大小为0，直接返回一个透明图片\n      return new Image();\n    }\n    const canvas = new OffscreenCanvas(textSize.x, textSize.y * 1.5);\n    const ctx = canvas.getContext(\"2d\")!;\n    // 如果这里开了抗锯齿，并且外层的canvas也开了抗锯齿，会导致文字模糊\n    ctx.imageSmoothingEnabled = false;\n    ctx.textBaseline = \"middle\";\n    ctx.textAlign = \"left\";\n    ctx.font = `${size}px normal ${FONT}`;\n    ctx.fillStyle = color.toString();\n    ctx.fillText(text, 0, size / 2);\n    createImageBitmap(canvas)\n      .then((bmp) => {\n        const cacheKey = this.hash(text, size);\n        this.cache.set(cacheKey, bmp);\n      })\n      .catch(() => {});\n    return canvas;\n  }\n\n  /**\n   * 从左上角画文本\n   */\n  renderText(text: string, location: Vector, size: number, color: Color = Color.White): void {\n    if (text.trim().length === 0) return;\n    text = Settings.protectingPrivacy ? replaceTextWhenProtect(text) : text;\n\n    if (!Settings.cacheTextAsBitmap) {\n      // 如果不开启位图渲染，则直接渲染\n      this.renderTempText(text, location, size, color);\n      return;\n    }\n\n    // 如果有缓存，直接渲染\n    const cache = this.getCache(text, size);\n    if (cache) {\n      this.project.canvas.ctx.drawImage(cache, location.x, location.y);\n      return;\n    }\n    const currentScale = this.project.camera.currentScale.toFixed(2);\n    const targetScale = this.project.camera.targetScale.toFixed(2);\n    // 如果摄像机正在缩放，就找到大小最接近的缓存图片，然后位图缩放\n    if (currentScale !== targetScale) {\n      if (Settings.textScalingBehavior === \"cacheEveryTick\") {\n        // 每帧都缓存\n        this.project.canvas.ctx.drawImage(this.buildCache(text, size, color), location.x, location.y);\n      } else if (Settings.textScalingBehavior === \"nearestCache\") {\n        // 文字应该渲染成什么大小\n        const textSize = getTextSize(text, size);\n        const nearestBitmap = this.getCacheNearestSize(text, size);\n        if (nearestBitmap) {\n          this.project.canvas.ctx.drawImage(nearestBitmap, location.x, location.y, textSize.x, textSize.y * 1.5);\n          return;\n        }\n      } else if (Settings.textScalingBehavior === \"temp\") {\n        // 不走缓存\n        this.renderTempText(text, location, size, color);\n        return;\n      }\n    } else {\n      // 如果摄像机没有缩放，直接缓存然后渲染\n      const cache = this.getCache(text, size) ?? this.buildCache(text, size, color);\n      this.project.canvas.ctx.drawImage(cache, location.x, location.y);\n    }\n  }\n  /**\n   * 渲染临时文字，不构建缓存，不使用缓存\n   */\n  renderTempText(text: string, location: Vector, size: number, color: Color = Color.White): void {\n    if (text.trim().length === 0) return;\n    text = Settings.protectingPrivacy ? replaceTextWhenProtect(text) : text;\n    if (Settings.textIntegerLocationAndSizeRender) {\n      location = location.toInteger();\n      size = Math.round(size);\n    }\n    this.project.canvas.ctx.textBaseline = \"middle\";\n    this.project.canvas.ctx.textAlign = \"left\";\n    this.project.canvas.ctx.font = `${size}px normal ${FONT}`;\n    this.project.canvas.ctx.fillStyle = color.toString();\n    this.project.canvas.ctx.fillText(text, location.x, location.y + size / 2);\n  }\n\n  /**\n   * 从中心位置开始绘制文本\n   */\n  renderTextFromCenter(text: string, centerLocation: Vector, size: number, color: Color = Color.White): void {\n    if (text.trim().length === 0) return;\n    if (Settings.textIntegerLocationAndSizeRender) {\n      centerLocation = centerLocation.toInteger();\n      size = Math.round(size);\n    }\n    const textSize = getTextSize(text, size);\n    this.renderText(text, centerLocation.subtract(textSize.divide(2)), size, color);\n  }\n  renderTempTextFromCenter(text: string, centerLocation: Vector, size: number, color: Color = Color.White): void {\n    if (text.trim().length === 0) return;\n    if (Settings.textIntegerLocationAndSizeRender) {\n      centerLocation = centerLocation.toInteger();\n      size = Math.round(size);\n    }\n    const textSize = getTextSize(text, size);\n    this.renderTempText(text, centerLocation.subtract(textSize.divide(2)), size, color);\n  }\n\n  renderTextInRectangle(text: string, rectangle: Rectangle, color: Color): void {\n    if (text.trim().length === 0) return;\n    this.renderTextFromCenter(text, rectangle.center, this.getFontSizeByRectangleSize(text, rectangle).y, color);\n  }\n\n  private getFontSizeByRectangleSize(text: string, rectangle: Rectangle): Vector {\n    // 使用getTextSize获取准确的文本尺寸\n    const baseFontSize = 100;\n    const measuredSize = getTextSize(text, baseFontSize);\n    const ratio = measuredSize.x / measuredSize.y;\n    const sectionRatio = rectangle.size.x / rectangle.size.y;\n\n    // 计算最大可用字体高度\n    let fontHeight;\n    const paddingRatio = 0.9; // 增加边距比例，确保文字不会贴边\n    if (sectionRatio < ratio) {\n      // 宽度受限\n      fontHeight = (rectangle.size.x / ratio) * paddingRatio;\n    } else {\n      // 高度受限\n      fontHeight = rectangle.size.y * paddingRatio;\n    }\n\n    // 最小字体\n    const minFontSize = 0.1;\n    const maxFontSize = Math.max(rectangle.size.x, rectangle.size.y) * 0.8; // 限制最大字体\n    fontHeight = Math.max(minFontSize, Math.min(fontHeight, maxFontSize));\n\n    return new Vector(ratio * fontHeight, fontHeight);\n  }\n\n  /**\n   * 渲染多行文本\n   * @param text\n   * @param location\n   * @param fontSize\n   * @param color\n   * @param lineHeight\n   */\n  renderMultiLineText(\n    text: string,\n    location: Vector,\n    fontSize: number,\n    limitWidth: number,\n    color: Color = Color.White,\n    lineHeight: number = 1.2,\n    limitLines: number = Infinity,\n  ): void {\n    if (!text) return;\n    if (text.length === 0) return;\n    if (Settings.textIntegerLocationAndSizeRender) {\n      location = location.toInteger();\n      fontSize = Math.round(fontSize);\n      limitWidth = Math.round(limitWidth);\n    }\n    // 如果文本里面没有换行符就直接渲染单行文本，不要计算了\n    // if (!text.includes(\"\\n\")) {\n    //   this.renderText(text, location, fontSize, color);\n    //   return;\n    // }\n    let currentY = 0; // 顶部偏移量\n    let textLineArray = this.textToTextArrayWrapCache(text, fontSize, limitWidth);\n    // 限制行数\n    if (limitLines < textLineArray.length) {\n      textLineArray = textLineArray.slice(0, limitLines);\n      textLineArray[limitLines - 1] += \"...\"; // 最后一行加省略号\n    }\n    for (const line of textLineArray) {\n      this.renderText(line, location.add(new Vector(0, currentY)), fontSize, color);\n      currentY += fontSize * lineHeight;\n    }\n  }\n  renderTempMultiLineText(\n    text: string,\n    location: Vector,\n    fontSize: number,\n    limitWidth: number,\n    color: Color = Color.White,\n    lineHeight: number = 1.2,\n    limitLines: number = Infinity,\n  ): void {\n    if (text.trim().length === 0) return;\n    if (Settings.textIntegerLocationAndSizeRender) {\n      location = location.toInteger();\n      fontSize = Math.round(fontSize);\n      limitWidth = Math.round(limitWidth);\n    }\n    text = Settings.protectingPrivacy ? replaceTextWhenProtect(text) : text;\n    let currentY = 0; // 顶部偏移量\n    let textLineArray = this.textToTextArrayWrapCache(text, fontSize, limitWidth);\n    // 限制行数\n    if (limitLines < textLineArray.length) {\n      textLineArray = textLineArray.slice(0, limitLines);\n      textLineArray[limitLines - 1] += \"...\"; // 最后一行加省略号\n    }\n    for (const line of textLineArray) {\n      this.renderTempText(line, location.add(new Vector(0, currentY)), fontSize, color);\n      currentY += fontSize * lineHeight;\n    }\n  }\n\n  renderMultiLineTextFromCenter(\n    text: string,\n    centerLocation: Vector,\n    size: number,\n    limitWidth: number,\n    color: Color,\n    lineHeight: number = 1.2,\n    limitLines: number = Infinity,\n  ): void {\n    if (text.trim().length === 0) return;\n    if (Settings.textIntegerLocationAndSizeRender) {\n      centerLocation = centerLocation.toInteger();\n      size = Math.round(size);\n      limitWidth = Math.round(limitWidth);\n    }\n    text = Settings.protectingPrivacy ? replaceTextWhenProtect(text) : text;\n    let currentY = 0; // 顶部偏移量\n    let textLineArray = this.textToTextArrayWrapCache(text, size, limitWidth);\n    // 限制行数\n    if (limitLines < textLineArray.length) {\n      textLineArray = textLineArray.slice(0, limitLines);\n      textLineArray[limitLines - 1] += \"...\"; // 最后一行加省略号\n    }\n    for (const line of textLineArray) {\n      this.renderTextFromCenter(\n        line,\n        centerLocation.add(new Vector(0, currentY - ((textLineArray.length - 1) * size) / 2)),\n        size,\n        color,\n      );\n      currentY += size * lineHeight;\n    }\n  }\n  renderTempMultiLineTextFromCenter(\n    text: string,\n    centerLocation: Vector,\n    size: number,\n    limitWidth: number,\n    color: Color,\n    lineHeight: number = 1.2,\n    limitLines: number = Infinity,\n  ): void {\n    if (text.trim().length === 0) return;\n    if (Settings.textIntegerLocationAndSizeRender) {\n      centerLocation = centerLocation.toInteger();\n      size = Math.round(size);\n      limitWidth = Math.round(limitWidth);\n    }\n    text = Settings.protectingPrivacy ? replaceTextWhenProtect(text) : text;\n    let currentY = 0; // 顶部偏移量\n    let textLineArray = this.textToTextArrayWrapCache(text, size, limitWidth);\n    // 限制行数\n    if (limitLines < textLineArray.length) {\n      textLineArray = textLineArray.slice(0, limitLines);\n      textLineArray[limitLines - 1] += \"...\"; // 最后一行加省略号\n    }\n    for (const line of textLineArray) {\n      this.renderTempTextFromCenter(\n        line,\n        centerLocation.add(new Vector(0, currentY - ((textLineArray.length - 1) * size) / 2)),\n        size,\n        color,\n      );\n      currentY += size * lineHeight;\n    }\n  }\n\n  textArrayCache: LruCache<string, string[]> = new LruCache(1000);\n\n  /**\n   * 加了缓存后的多行文本渲染函数\n   * @param text\n   * @param fontSize\n   * @param limitWidth\n   */\n  private textToTextArrayWrapCache(text: string, fontSize: number, limitWidth: number): string[] {\n    const cacheKey = `${fontSize}_${limitWidth}_${text}`;\n    const cacheValue = this.textArrayCache.get(cacheKey);\n    if (cacheValue) {\n      return cacheValue;\n    }\n    const lines = this.textToTextArray(text, fontSize, limitWidth);\n    this.textArrayCache.set(cacheKey, lines);\n    return lines;\n  }\n\n  /**\n   * 渲染多行文本的辅助函数\n   * 将一段字符串分割成多行数组，遇到宽度限制和换行符进行换行。\n   * @param text\n   */\n  private textToTextArray(text: string, fontSize: number, limitWidth: number): string[] {\n    let currentLine = \"\";\n    // 先渲染一下空字符串，否则长度大小可能不匹配，造成蜜汁bug\n    this.renderText(\"\", Vector.getZero(), fontSize, Color.White);\n    const lines: string[] = [];\n\n    // 保存当前的字体设置\n    const originalFont = this.project.canvas.ctx.font;\n    // 确保使用与实际渲染相同的字体大小\n    this.project.canvas.ctx.font = `${fontSize}px normal ${FONT}`;\n\n    for (const char of text) {\n      // 新来字符的宽度\n      const measureSize = this.project.canvas.ctx.measureText(currentLine + char);\n      // 先判断是否溢出\n      if (measureSize.width > limitWidth || char === \"\\n\") {\n        // 溢出了，将这一整行渲染出来\n        lines.push(currentLine);\n        if (char !== \"\\n\") {\n          currentLine = char;\n        } else {\n          currentLine = \"\";\n        }\n      } else {\n        // 未溢出，继续添加字符\n        // 当前行更新\n        currentLine += char;\n      }\n    }\n    if (currentLine) {\n      lines.push(currentLine);\n    }\n\n    // 恢复原始字体设置\n    this.project.canvas.ctx.font = originalFont;\n\n    return lines;\n  }\n\n  /**\n   * 测量多行文本的大小\n   * @param text\n   * @param fontSize\n   * @param limitWidth\n   * @returns\n   */\n  measureMultiLineTextSize(text: string, fontSize: number, limitWidth: number, lineHeight: number = 1.2): Vector {\n    const lines = this.textToTextArrayWrapCache(text, fontSize, limitWidth);\n    let maxWidth = 0;\n    let totalHeight = 0;\n\n    // 保存当前的字体设置\n    const originalFont = this.project.canvas.ctx.font;\n    // 确保使用与实际渲染相同的字体大小\n    this.project.canvas.ctx.font = `${fontSize}px normal ${FONT}`;\n\n    for (const line of lines) {\n      const measureSize = this.project.canvas.ctx.measureText(line);\n      maxWidth = Math.max(maxWidth, measureSize.width);\n      totalHeight += fontSize * lineHeight;\n    }\n\n    // 恢复原始字体设置\n    this.project.canvas.ctx.font = originalFont;\n\n    return new Vector(Math.ceil(maxWidth), totalHeight);\n  }\n}\n"
  },
  {
    "path": "app/src/core/render/canvas2d/controllerRenderer/README.md",
    "content": "这里用于绘制一些非实体的，控制器相关的内容，比如鼠标交互的东西\n"
  },
  {
    "path": "app/src/core/render/canvas2d/controllerRenderer/drawingRenderer.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { MouseLocation } from \"@/core/service/controlService/MouseLocation\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { isMac } from \"@/utils/platform\";\nimport { Color, Vector } from \"@graphif/data-structures\";\n\n/**\n * 绘画控制器\n */\n@service(\"drawingControllerRenderer\")\nexport class DrawingControllerRenderer {\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 渲染预渲染的涂鸦\n   */\n  renderTempDrawing() {\n    const currentStrokeColor = this.project.controller.penStrokeDrawing.getCurrentStrokeColor();\n\n    if (Settings.mouseLeftMode !== \"draw\") {\n      return;\n    }\n\n    if (this.project.controller.penStrokeDrawing.currentSegments.length > 0) {\n      // 画鼠标绘制过程，还未抬起鼠标左键的 笔迹\n      this.renderTrace(currentStrokeColor);\n    }\n    if (this.project.controller.penStrokeControl.isAdjusting) {\n      // 正在调整粗细\n      this.renderAdjusting(currentStrokeColor);\n    } else {\n      // 吸附在鼠标上的小圆点 和 量角器\n      this.renderMouse(currentStrokeColor);\n    }\n  }\n\n  private renderTrace(currentStrokeColor: Color) {\n    const startLocation = this.project.controller.penStrokeDrawing.currentSegments[0].location;\n    const endLocation = this.project.renderer.transformView2World(MouseLocation.vector());\n\n    // 正在绘制直线\n    if (this.project.controller.pressingKeySet.has(\"shift\")) {\n      // 垂直于坐标轴的直线\n      if (this.project.controller.pressingKeySet.has(isMac ? \"meta\" : \"control\")) {\n        const dy = Math.abs(endLocation.y - startLocation.y);\n        const dx = Math.abs(endLocation.x - startLocation.x);\n        if (dy > dx) {\n          // 垂直\n          endLocation.x = startLocation.x;\n        } else {\n          // 水平\n          endLocation.y = startLocation.y;\n        }\n\n        this.project.curveRenderer.renderSolidLine(\n          this.project.renderer.transformWorld2View(startLocation),\n          this.project.renderer.transformWorld2View(endLocation),\n          currentStrokeColor.a === 0\n            ? this.project.stageStyleManager.currentStyle.StageObjectBorder\n            : currentStrokeColor,\n          5 * this.project.camera.currentScale,\n        );\n      } else {\n        this.project.curveRenderer.renderSolidLine(\n          this.project.renderer.transformWorld2View(startLocation),\n          MouseLocation.vector(),\n          currentStrokeColor.a === 0\n            ? this.project.stageStyleManager.currentStyle.StageObjectBorder\n            : currentStrokeColor,\n          5 * this.project.camera.currentScale,\n        );\n      }\n    } else {\n      this.project.curveRenderer.renderPenStroke(\n        this.project.controller.penStrokeDrawing.currentSegments.map((segment) => ({\n          location: this.project.renderer.transformWorld2View(segment.location),\n          pressure: segment.pressure,\n        })),\n        currentStrokeColor.a === 0 ? this.project.stageStyleManager.currentStyle.StageObjectBorder : currentStrokeColor,\n      );\n    }\n  }\n\n  private renderMouse(currentStrokeColor: Color) {\n    // 画跟随鼠标的笔头\n    // 如果粗细大于一定程度，则渲染成空心的\n    this.project.shapeRenderer.renderCircle(\n      MouseLocation.vector(),\n      (5 / 2) * this.project.camera.currentScale,\n      currentStrokeColor.a === 0 ? this.project.stageStyleManager.currentStyle.StageObjectBorder : currentStrokeColor,\n      Color.Transparent,\n      0,\n    );\n    // 如果按下shift键，说明正在画直线\n    if (this.project.controller.pressingKeySet.has(\"shift\")) {\n      this.renderAxisMouse();\n    }\n  }\n\n  private renderAdjusting(currentStrokeColor: Color) {\n    const circleCenter = this.project.renderer.transformWorld2View(\n      this.project.controller.penStrokeControl.startAdjustWidthLocation,\n    );\n    // 鼠标正在调整状态\n    this.project.shapeRenderer.renderCircle(\n      circleCenter,\n      (5 / 2) * this.project.camera.currentScale,\n      currentStrokeColor.a === 0 ? this.project.stageStyleManager.currentStyle.StageObjectBorder : currentStrokeColor,\n      Color.Transparent,\n      0,\n    );\n    // 当前粗细显示\n    this.project.textRenderer.renderTextFromCenter(\n      `2R: ${5}px`,\n      circleCenter.add(new Vector(0, (-(5 / 2) - 40) * this.project.camera.currentScale)),\n      24,\n      currentStrokeColor.a === 0 ? this.project.stageStyleManager.currentStyle.StageObjectBorder : currentStrokeColor,\n    );\n  }\n\n  /**\n   * 画一个跟随鼠标的巨大十字准星\n   * 直线模式\n   */\n  private renderAxisMouse() {\n    // 画一个跟随鼠标的十字准星\n    // const crossSize = 2000;\n\n    const crossCenter = MouseLocation.vector();\n\n    // 量角器功能\n    // 计算角度，拿到两个世界坐标\n    // const startLocation = this.project.controller.penStrokeDrawing.currentStroke[0].startLocation;\n    // const endLocation =this.project.renderer.transformView2World(MouseLocation.vector());\n\n    this.renderAngleMouse(crossCenter);\n  }\n\n  private diffAngle = 0;\n\n  rotateUpAngle() {\n    this.diffAngle += 5;\n  }\n\n  rotateDownAngle() {\n    this.diffAngle -= 5;\n  }\n\n  /**\n   * 画跟随鼠标的角度量角器\n   */\n  private renderAngleMouse(mouseLocation: Vector) {\n    const R1 = 50;\n    const R2 = 60;\n    const R3 = 70;\n    for (let i = 0 + this.diffAngle; i < 360 + this.diffAngle; i += 5) {\n      let startRadius = R1;\n      let remoteRadius = R2;\n      if ((i - this.diffAngle) % 15 === 0) {\n        remoteRadius = R3;\n      }\n      if ((i - this.diffAngle) % 30 === 0) {\n        startRadius = 10;\n        remoteRadius = 200;\n      }\n      if ((i - this.diffAngle) % 90 === 0) {\n        startRadius = 10;\n        remoteRadius = 2000;\n      }\n\n      const angle = (i * Math.PI) / 180;\n      const lineStart = mouseLocation.add(new Vector(Math.cos(angle) * startRadius, Math.sin(angle) * startRadius));\n      const lineEnd = mouseLocation.add(new Vector(Math.cos(angle) * remoteRadius, Math.sin(angle) * remoteRadius));\n      this.renderLine(lineStart, lineEnd);\n    }\n  }\n\n  /**\n   * 画一条线，专用于在透明状态的时候能清晰的看到线条\n   * 因此需要叠两层\n   * @param lineStart\n   * @param lineEnd\n   */\n  private renderLine(lineStart: Vector, lineEnd: Vector) {\n    this.project.curveRenderer.renderSolidLine(\n      lineStart,\n      lineEnd,\n      this.project.stageStyleManager.currentStyle.Background,\n      2,\n    );\n    this.project.curveRenderer.renderSolidLine(\n      lineStart,\n      lineEnd,\n      this.project.stageStyleManager.currentStyle.effects.successShadow,\n      0.5,\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/render/canvas2d/debugRender.tsx",
    "content": "/**\n * 调试渲染用\n */\nexport function debugRender() {}\n"
  },
  {
    "path": "app/src/core/render/canvas2d/entityRenderer/CollisionBoxRenderer.tsx",
    "content": "import { Color, Vector } from \"@graphif/data-structures\";\nimport { Circle, CubicCatmullRomSpline, Line, Rectangle, SymmetryCurve } from \"@graphif/shapes\";\nimport { Project, service } from \"@/core/Project\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\n\n/**\n * 碰撞箱渲染器\n */\n@service(\"collisionBoxRenderer\")\nexport class CollisionBoxRenderer {\n  constructor(private readonly project: Project) {}\n\n  render(collideBox: CollisionBox, color: Color) {\n    const scale =\n      this.project.camera.currentScale > 0.02\n        ? this.project.camera.currentScale\n        : this.project.camera.currentScale * 20;\n    for (const shape of collideBox.shapes) {\n      if (shape instanceof Rectangle) {\n        this.project.shapeRenderer.renderRect(\n          new Rectangle(\n            this.project.renderer.transformWorld2View(shape.location.subtract(Vector.same(7.5))),\n            shape.size.add(Vector.same(15)).multiply(this.project.camera.currentScale),\n          ),\n          Color.Transparent,\n          color,\n          8 * scale,\n          16 * scale,\n        );\n      } else if (shape instanceof Circle) {\n        this.project.shapeRenderer.renderCircle(\n          this.project.renderer.transformWorld2View(shape.location),\n          (shape.radius + 7.5) * this.project.camera.currentScale,\n          Color.Transparent,\n          color,\n          10 * scale,\n        );\n      } else if (shape instanceof Line) {\n        this.project.curveRenderer.renderSolidLine(\n          this.project.renderer.transformWorld2View(shape.start),\n          this.project.renderer.transformWorld2View(shape.end),\n          color,\n          12 * 2 * scale,\n        );\n      } else if (shape instanceof SymmetryCurve) {\n        // shape.endDirection = shape.endDirection.normalize();\n        // const size = 15; // 箭头大小\n        // shape.end = shape.end.subtract(shape.endDirection.multiply(size / -2));\n        this.project.worldRenderUtils.renderSymmetryCurve(shape, color, 10);\n      } else if (shape instanceof CubicCatmullRomSpline) {\n        this.project.worldRenderUtils.renderCubicCatmullRomSpline(shape, color, 10);\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/render/canvas2d/entityRenderer/EntityDetailsButtonRenderer.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { MouseLocation } from \"@/core/service/controlService/MouseLocation\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { Color, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\n\n/**\n * 仅仅渲染一个节点右上角的按钮\n */\n@service(\"entityDetailsButtonRenderer\")\nexport class EntityDetailsButtonRenderer {\n  constructor(private readonly project: Project) {}\n\n  render(entity: Entity) {\n    if (entity.detailsManager.isEmpty()) {\n      return;\n    }\n    // this.project.shapeRenderer.renderRect(\n    //   entity.detailsButtonRectangle().transformWorld2View(),\n    //   this.project.stageStyleManager.currentStyle.DetailsDebugTextColor,\n    //   this.project.stageStyleManager.currentStyle.DetailsDebugTextColor,\n    //   2 * Camera.currentScale,\n    //   Renderer.NODE_ROUNDED_RADIUS * Camera.currentScale,\n    // );\n    let isMouseHovering = false;\n    // 鼠标悬浮在按钮上提示文字\n    if (entity.detailsButtonRectangle().isPointIn(this.project.renderer.transformView2World(MouseLocation.vector()))) {\n      isMouseHovering = true;\n      // 鼠标悬浮在这上面\n      this.project.textRenderer.renderText(\n        \"点击展开或关闭节点注释详情\",\n        this.project.renderer.transformWorld2View(\n          entity.detailsButtonRectangle().topCenter.subtract(new Vector(0, 12)),\n        ),\n        12 * this.project.camera.currentScale,\n        this.project.stageStyleManager.currentStyle.DetailsDebugText,\n      );\n    }\n    const rect = entity.detailsButtonRectangle();\n    const color = isMouseHovering ? this.project.stageStyleManager.currentStyle.CollideBoxSelected : Color.Transparent;\n    this.project.shapeRenderer.renderRect(\n      new Rectangle(\n        this.project.renderer.transformWorld2View(rect.leftTop),\n        rect.size.multiply(this.project.camera.currentScale),\n      ),\n      color,\n      this.project.stageStyleManager.currentStyle.StageObjectBorder.toNewAlpha(0.5),\n      2 * this.project.camera.currentScale,\n      5 * this.project.camera.currentScale,\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/render/canvas2d/entityRenderer/EntityRenderer.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { ConnectPoint } from \"@/core/stage/stageObject/entity/ConnectPoint\";\nimport { ImageNode } from \"@/core/stage/stageObject/entity/ImageNode\";\nimport { PenStroke } from \"@/core/stage/stageObject/entity/PenStroke\";\nimport { ReferenceBlockNode } from \"@/core/stage/stageObject/entity/ReferenceBlockNode\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { SvgNode } from \"@/core/stage/stageObject/entity/SvgNode\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { UrlNode } from \"@/core/stage/stageObject/entity/UrlNode\";\nimport { DetailsManager } from \"@/core/stage/stageObject/tools/entityDetailsManager\";\nimport { Color, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\n\n/**\n * 处理节点相关的绘制\n */\n@service(\"entityRenderer\")\nexport class EntityRenderer {\n  private sectionSortedZIndex: Section[] = [];\n\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 对所有section排序一次\n   * 为了防止每帧都调用导致排序，为了提高性能\n   * 决定：每隔几秒更新一次\n   */\n  sortSectionsByZIndex() {\n    const sections = this.project.stageManager.getSections();\n    sections.sort((a, b) => a.collisionBox.getRectangle().top - b.collisionBox.getRectangle().top);\n    this.sectionSortedZIndex = sections;\n  }\n\n  private tickNumber = 0;\n\n  renderAllSectionsBackground(viewRectangle: Rectangle) {\n    if (this.sectionSortedZIndex.length != this.project.stageManager.getSections().length) {\n      this.sortSectionsByZIndex();\n    } else {\n      // 假设fps=60，则10秒更新一次\n      if (this.tickNumber % 600 === 0) {\n        this.sortSectionsByZIndex();\n      }\n    }\n    // 1 遍历所有section实体，画底部颜色\n    for (const section of this.sectionSortedZIndex) {\n      if (this.project.renderer.isOverView(viewRectangle, section)) {\n        continue;\n      }\n      this.project.sectionRenderer.renderBackgroundColor(section);\n    }\n    // 最后更新帧\n    this.tickNumber++;\n  }\n\n  /**\n   * 统一渲染全部框的大标题\n   */\n  renderAllSectionsBigTitle(viewRectangle: Rectangle) {\n    if (\n      Settings.sectionBitTitleRenderType === \"none\" ||\n      this.project.camera.currentScale > Settings.sectionBigTitleCameraScaleThreshold\n    ) {\n      return;\n    }\n    // 从最深层的最小框开始渲染\n    // 目前的层级排序是假的，是直接按y轴从上往下判定\n    // 认为最靠上的才是最底下的\n    for (let z = this.sectionSortedZIndex.length - 1; z >= 0; z--) {\n      const section = this.sectionSortedZIndex[z];\n      if (this.project.renderer.isOverView(viewRectangle, section)) {\n        continue;\n      }\n      if (Settings.sectionBitTitleRenderType === \"cover\") {\n        this.project.sectionRenderer.renderBigCoveredTitle(section);\n      } else if (Settings.sectionBitTitleRenderType === \"top\") {\n        this.project.sectionRenderer.renderTopTitle(section);\n      }\n    }\n  }\n\n  /**\n   * 检查实体是否应该被跳过渲染\n   */\n  private shouldSkipEntity(entity: Entity, viewRectangle: Rectangle): boolean {\n    return (\n      entity instanceof Section ||\n      entity instanceof PenStroke ||\n      this.project.renderer.isOverView(viewRectangle, entity)\n    );\n  }\n\n  private isBackgroundImageNode(entity: Entity): boolean {\n    return entity instanceof ImageNode && entity.isBackground;\n  }\n\n  /**\n   * 统一渲染所有实体\n   */\n  renderAllEntities(viewRectangle: Rectangle) {\n    const entities = this.project.stageManager.getEntities();\n\n    // 先渲染所有背景图片\n    entities.forEach((entity) => {\n      if (this.isBackgroundImageNode(entity) && !this.project.renderer.isOverView(viewRectangle, entity)) {\n        this.renderEntity(entity);\n      }\n    });\n\n    // 再渲染所有非背景图片的实体\n    for (const entity of entities) {\n      if (this.isBackgroundImageNode(entity) || this.shouldSkipEntity(entity, viewRectangle)) {\n        continue;\n      }\n      this.renderEntity(entity);\n    }\n    // 3 遍历所有section实体，画顶部大文字\n    for (const section of this.project.stageManager.getSections()) {\n      if (this.project.renderer.isOverView(viewRectangle, section)) {\n        continue;\n      }\n      this.project.sectionRenderer.render(section);\n      // details右上角小按钮\n      if (this.project.camera.currentScale > 0.065) {\n        this.project.entityDetailsButtonRenderer.render(section);\n      }\n      this.renderEntityDebug(section);\n      this.renderEntityTagShap(section);\n    }\n    // 4 遍历所有涂鸦实体\n    for (const penStroke of this.project.stageManager.getPenStrokes()) {\n      if (this.project.renderer.isOverView(viewRectangle, penStroke)) {\n        continue;\n      }\n      this.renderEntity(penStroke);\n    }\n  }\n\n  /**\n   * 父渲染函数,这里在代码上游不会传入Section\n   * @param entity\n   */\n  renderEntity(entity: Entity) {\n    // section 折叠不画\n    if (entity.isHiddenBySectionCollapse) {\n      return;\n    }\n    if (entity instanceof TextNode) {\n      this.project.textNodeRenderer.renderTextNode(entity);\n    } else if (entity instanceof ConnectPoint) {\n      this.renderConnectPoint(entity);\n    } else if (entity instanceof ImageNode) {\n      this.renderImageNode(entity);\n    } else if (entity instanceof UrlNode) {\n      this.project.urlNodeRenderer.render(entity);\n    } else if (entity instanceof PenStroke) {\n      this.renderPenStroke(entity);\n    } else if (entity instanceof SvgNode) {\n      this.project.svgNodeRenderer.render(entity);\n    } else if (entity instanceof ReferenceBlockNode) {\n      this.project.referenceBlockRenderer.render(entity);\n    }\n    // details右上角小按钮\n    if (this.project.camera.currentScale > 0.065) {\n      this.project.entityDetailsButtonRenderer.render(entity);\n    }\n    // 渲染详细信息\n    this.renderEntityDetails(entity);\n    this.renderEntityDebug(entity);\n    this.renderEntityTagShap(entity);\n  }\n\n  private renderEntityDebug(entity: Entity) {\n    // debug模式下, 左上角渲染一个uuid\n    if (Settings.showDebug) {\n      this.project.textRenderer.renderText(\n        entity.uuid,\n        this.project.renderer.transformWorld2View(entity.collisionBox.getRectangle().leftTop.add(new Vector(0, -10))),\n        4 * this.project.camera.currentScale,\n      );\n    }\n  }\n\n  private renderConnectPoint(connectPoint: ConnectPoint) {\n    // 在中心点一个点，防止独立质点看不到\n    this.project.shapeRenderer.renderCircle(\n      this.project.renderer.transformWorld2View(connectPoint.geometryCenter),\n      1 * this.project.camera.currentScale,\n      Color.Transparent,\n      this.project.stageStyleManager.currentStyle.StageObjectBorder,\n      2 * this.project.camera.currentScale,\n    );\n    if (Settings.showDebug) {\n      this.project.shapeRenderer.renderCircle(\n        this.project.renderer.transformWorld2View(connectPoint.geometryCenter),\n        connectPoint.radius * this.project.camera.currentScale,\n        Color.Transparent,\n        this.project.stageStyleManager.currentStyle.StageObjectBorder,\n        2 * this.project.camera.currentScale,\n      );\n    }\n    if (connectPoint.isSelected) {\n      // 在外面增加一个框\n      this.project.collisionBoxRenderer.render(\n        connectPoint.collisionBox,\n        this.project.stageStyleManager.currentStyle.CollideBoxSelected,\n      );\n    }\n    if (this.project.camera.currentScale < 0.2 && !connectPoint.detailsManager.isEmpty()) {\n      const detailsText = DetailsManager.detailsToMarkdown(connectPoint.details);\n      this.project.textRenderer.renderTextFromCenter(\n        detailsText,\n        this.project.renderer.transformWorld2View(connectPoint.geometryCenter),\n        12, // 不随视野缩放而变化\n        this.project.stageStyleManager.currentStyle.StageObjectBorder,\n      );\n    }\n  }\n\n  private renderImageNode(imageNode: ImageNode) {\n    // 隐私模式下隐藏图片内容，只渲染边框\n    if (Settings.protectingPrivacy) {\n      // 渲染图片节点的边框（使用StageObjectBorder颜色）\n      this.project.collisionBoxRenderer.render(\n        imageNode.collisionBox,\n        this.project.stageStyleManager.currentStyle.StageObjectBorder,\n      );\n      return;\n    }\n\n    // 先渲染图片内容\n    if (imageNode.state === \"loading\") {\n      this.project.textRenderer.renderTextFromCenter(\n        \"loading...\",\n        this.project.renderer.transformWorld2View(imageNode.rectangle.center),\n        20 * this.project.camera.currentScale,\n        this.project.stageStyleManager.currentStyle.StageObjectBorder,\n      );\n    } else if (imageNode.state === \"success\") {\n      this.project.imageRenderer.renderImageElement(\n        imageNode.bitmap!,\n        this.project.renderer.transformWorld2View(imageNode.rectangle.location),\n        imageNode.scale,\n      );\n    } else if (imageNode.state === \"notFound\") {\n      this.project.textRenderer.renderTextFromCenter(\n        `图片未找到：${imageNode.attachmentId}`,\n        this.project.renderer.transformWorld2View(imageNode.rectangle.center),\n        20 * this.project.camera.currentScale,\n        this.project.stageStyleManager.currentStyle.StageObjectBorder,\n      );\n      // 画出它的碰撞箱\n      this.project.shapeRenderer.renderRect(\n        new Rectangle(\n          this.project.renderer.transformWorld2View(imageNode.rectangle.location),\n          imageNode.rectangle.size.multiply(this.project.camera.currentScale),\n        ),\n        Color.Red.toNewAlpha(0.5),\n        Color.Red.clone(),\n        2 * this.project.camera.currentScale,\n      );\n    }\n\n    // 然后渲染选中效果和缩放控制点，确保显示在图片上方\n    if (imageNode.isSelected) {\n      // 在外面增加一个框\n      this.project.collisionBoxRenderer.render(\n        imageNode.collisionBox,\n        this.project.stageStyleManager.currentStyle.CollideBoxSelected,\n      );\n      // 渲染右下角缩放控制点\n      const resizeHandleRect = imageNode.getResizeHandleRect();\n      const viewResizeHandleRect = new Rectangle(\n        this.project.renderer.transformWorld2View(resizeHandleRect.location),\n        resizeHandleRect.size.multiply(this.project.camera.currentScale),\n      );\n      this.project.shapeRenderer.renderRect(\n        viewResizeHandleRect,\n        this.project.stageStyleManager.currentStyle.CollideBoxSelected,\n        this.project.stageStyleManager.currentStyle.StageObjectBorder,\n        2 * this.project.camera.currentScale,\n        8 * this.project.camera.currentScale,\n      );\n      // 渲染箭头指示\n      this.project.shapeRenderer.renderResizeArrow(\n        viewResizeHandleRect,\n        this.project.stageStyleManager.currentStyle.StageObjectBorder,\n        2 * this.project.camera.currentScale,\n      );\n    }\n  }\n\n  /**\n   * 渲染涂鸦笔画\n   * TODO: 绘制时的碰撞箱应该有一个合适的宽度\n   * @param penStroke\n   */\n  private renderPenStroke(penStroke: PenStroke) {\n    let penStrokeColor = penStroke.color;\n    if (penStrokeColor.a === 0) {\n      penStrokeColor = this.project.stageStyleManager.currentStyle.StageObjectBorder.clone();\n    }\n    // const path = penStroke.getPath();\n    // if (path.length <= 3) {\n    //   CurveRenderer.renderSolidLineMultipleWithWidth(\n    //     penStroke.getPath().map((v) => Renderer.transformWorld2View(v)),\n    //     penStrokeColor,\n    //     penStroke.getSegmentList().map((seg) => seg.width * this.project.camera.currentScale),\n    //   );\n    // } else {\n    //   CurveRenderer.renderSolidLineMultipleSmoothly(\n    //     penStroke.getPath().map((v) => Renderer.transformWorld2View(v)),\n    //     penStrokeColor,\n    //     penStroke.getSegmentList()[0].width * this.project.camera.currentScale,\n    //   );\n    // }\n    this.project.curveRenderer.renderPenStroke(\n      penStroke.segments.map((segment) => ({\n        location: this.project.renderer.transformWorld2View(segment.location),\n        pressure: segment.pressure,\n      })),\n      penStrokeColor,\n    );\n    if (penStroke.isMouseHover) {\n      this.project.collisionBoxRenderer.render(\n        penStroke.collisionBox,\n        this.project.stageStyleManager.currentStyle.CollideBoxPreSelected,\n      );\n    }\n    if (penStroke.isSelected) {\n      this.project.collisionBoxRenderer.render(\n        penStroke.collisionBox,\n        this.project.stageStyleManager.currentStyle.CollideBoxSelected.toNewAlpha(0.5),\n      );\n    }\n  }\n\n  renderEntityDetails(entity: Entity) {\n    if (entity.details) {\n      if (Settings.alwaysShowDetails) {\n        this._renderEntityDetails(entity, Settings.entityDetailsLinesLimit);\n      } else {\n        if (entity.isMouseHover) {\n          this._renderEntityDetails(entity, Settings.entityDetailsLinesLimit);\n        }\n      }\n    }\n  }\n  _renderEntityDetails(entity: Entity, limitLiens: number) {\n    this.project.textRenderer.renderMultiLineText(\n      entity.detailsManager.getRenderStageString(),\n      this.project.renderer.transformWorld2View(\n        entity.collisionBox.getRectangle().location.add(new Vector(0, entity.collisionBox.getRectangle().size.y)),\n      ),\n      Settings.entityDetailsFontSize * this.project.camera.currentScale,\n      Math.max(\n        Settings.entityDetailsWidthLimit * this.project.camera.currentScale,\n        entity.collisionBox.getRectangle().size.x * this.project.camera.currentScale,\n      ),\n      this.project.stageStyleManager.currentStyle.NodeDetailsText,\n      1.2,\n      limitLiens,\n    );\n  }\n\n  renderEntityTagShap(entity: Entity) {\n    if (!this.project.tagManager.hasTag(entity.uuid)) {\n      return;\n    }\n    const rect = entity.collisionBox.getRectangle();\n    this.project.shapeRenderer.renderPolygonAndFill(\n      [\n        this.project.renderer.transformWorld2View(rect.leftTop.add(new Vector(0, 8))),\n        this.project.renderer.transformWorld2View(rect.leftCenter.add(new Vector(-15, 0))),\n        this.project.renderer.transformWorld2View(rect.leftBottom.add(new Vector(0, -8))),\n      ],\n      new Color(255, 0, 0, 0.5),\n      this.project.stageStyleManager.currentStyle.StageObjectBorder,\n      2 * this.project.camera.currentScale,\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/render/canvas2d/entityRenderer/ReferenceBlockRenderer.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { ReferenceBlockNode } from \"@/core/stage/stageObject/entity/ReferenceBlockNode\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { Color, Vector } from \"@graphif/data-structures\";\nimport { Rectangle, Circle } from \"@graphif/shapes\";\nimport { MouseLocation } from \"@/core/service/controlService/MouseLocation\";\n\n/**\n * 引用块节点渲染器\n */\n@service(\"referenceBlockRenderer\")\nexport class ReferenceBlockRenderer {\n  constructor(private readonly project: Project) {}\n\n  render(referenceBlockNode: ReferenceBlockNode) {\n    // 需要有一个边框\n    const renderViewRectangle = new Rectangle(\n      this.project.renderer.transformWorld2View(referenceBlockNode.rectangle.location),\n      referenceBlockNode.rectangle.size.multiply(this.project.camera.currentScale),\n    );\n    this.project.shapeRenderer.renderRect(\n      renderViewRectangle,\n      Color.Transparent,\n      this.project.stageStyleManager.currentStyle.StageObjectBorder,\n      1 * this.project.camera.currentScale,\n    );\n\n    // 先渲染图片内容\n    if (referenceBlockNode.state === \"loading\") {\n      // 渲染加载状态\n      this.project.textRenderer.renderTextFromCenter(\n        \"Loading...\",\n        renderViewRectangle.center,\n        12 * this.project.camera.currentScale,\n        this.project.stageStyleManager.currentStyle.StageObjectBorder,\n      );\n      const i = this.project.renderer.frameIndex % 4;\n      const dashColor = new Color(197, 174, 243);\n      const lineWidth = 16 * this.project.camera.currentScale;\n      // 渲染四个边\n      if (i === 0) {\n        this.project.curveRenderer.renderSolidLine(\n          renderViewRectangle.leftTop,\n          renderViewRectangle.rightTop,\n          dashColor,\n          lineWidth,\n        );\n      } else if (i === 1) {\n        this.project.curveRenderer.renderSolidLine(\n          renderViewRectangle.rightTop,\n          renderViewRectangle.rightBottom,\n          dashColor,\n          lineWidth,\n        );\n      } else if (i === 2) {\n        this.project.curveRenderer.renderSolidLine(\n          renderViewRectangle.rightBottom,\n          renderViewRectangle.leftBottom,\n          dashColor,\n          lineWidth,\n        );\n      } else if (i === 3) {\n        this.project.curveRenderer.renderSolidLine(\n          renderViewRectangle.leftBottom,\n          renderViewRectangle.leftTop,\n          dashColor,\n          lineWidth,\n        );\n      }\n    } else if (referenceBlockNode.state === \"notFound\" || !referenceBlockNode.bitmap) {\n      const rect = referenceBlockNode.collisionBox.getRectangle();\n      // 渲染错误状态\n      this.project.textRenderer.renderMultiLineTextFromCenter(\n        `Not Found: \\nfile:\"${referenceBlockNode.fileName}\"\\nsection:\"${referenceBlockNode.sectionName}\"`,\n        this.project.renderer.transformWorld2View(rect.center),\n        12 * this.project.camera.currentScale,\n        rect.width * 2 * this.project.camera.currentScale,\n        this.project.stageStyleManager.currentStyle.effects.warningShadow,\n      );\n    } else {\n      // 渲染图片\n      this.project.imageRenderer.renderImageBitmap(\n        referenceBlockNode.bitmap,\n        this.project.renderer.transformWorld2View(referenceBlockNode.collisionBox.getRectangle().location),\n        referenceBlockNode.scale,\n      );\n\n      if (referenceBlockNode.state === \"success\") {\n        let expand = 4;\n        let lightColor = this.project.stageStyleManager.currentStyle.StageObjectBorder.toNewAlpha(0.5);\n        let darkColor = this.project.stageStyleManager.currentStyle.StageObjectBorder.toNewAlpha(0.8);\n        if (referenceBlockNode.isSelected) {\n          expand = 16;\n          lightColor = this.project.stageStyleManager.currentStyle.CollideBoxSelected;\n          darkColor = this.project.stageStyleManager.currentStyle.CollideBoxSelected.toNewAlpha(1);\n        }\n        const baseRect = referenceBlockNode.collisionBox.getRectangle();\n        // 渲染外层括号\n        this.renderBrackets(baseRect.expandFromCenter(expand + 4), darkColor);\n        // 渲染内层括号\n        this.renderBrackets(baseRect.expandFromCenter(expand), lightColor);\n      }\n    }\n\n    // 然后渲染选中效果和缩放控制点，确保显示在图片上方\n    // 选中状态\n    if (referenceBlockNode.isSelected) {\n      this.project.collisionBoxRenderer.render(\n        referenceBlockNode.collisionBox,\n        this.project.stageStyleManager.currentStyle.CollideBoxSelected,\n      );\n\n      // 在引用块底部添加提示文本\n      const bottomCenter = this.project.renderer.transformWorld2View(\n        referenceBlockNode.rectangle.location.add(\n          new Vector(referenceBlockNode.rectangle.size.x / 2, referenceBlockNode.rectangle.size.y + 15),\n        ),\n      );\n      this.project.textRenderer.renderTextFromCenter(\n        \"双击跳转到源头位置\",\n        bottomCenter,\n        10 * this.project.camera.currentScale,\n        this.project.stageStyleManager.currentStyle.StageObjectBorder,\n      );\n\n      // 渲染右下角缩放控制点\n      const resizeHandleRect = referenceBlockNode.getResizeHandleRect();\n      const viewResizeHandleRect = new Rectangle(\n        this.project.renderer.transformWorld2View(resizeHandleRect.location),\n        resizeHandleRect.size.multiply(this.project.camera.currentScale),\n      );\n      this.project.shapeRenderer.renderRect(\n        viewResizeHandleRect,\n        this.project.stageStyleManager.currentStyle.CollideBoxSelected,\n        this.project.stageStyleManager.currentStyle.StageObjectBorder,\n        2 * this.project.camera.currentScale,\n        8 * this.project.camera.currentScale,\n      );\n      // 渲染箭头指示\n      this.project.shapeRenderer.renderResizeArrow(\n        viewResizeHandleRect,\n        this.project.stageStyleManager.currentStyle.StageObjectBorder,\n        2 * this.project.camera.currentScale,\n      );\n    }\n  }\n\n  /**\n   * 渲染中括号边框\n   */\n  private renderBrackets(rect: Rectangle, color: Color) {\n    const renderViewRectangle = new Rectangle(\n      this.project.renderer.transformWorld2View(rect.location),\n      rect.size.multiply(this.project.camera.currentScale),\n    );\n    const lineWidth = 4 * this.project.camera.currentScale;\n    const bracketLength = 30 * this.project.camera.currentScale;\n\n    // 渲染左右竖线\n    this.project.curveRenderer.renderSolidLine(\n      renderViewRectangle.leftTop,\n      renderViewRectangle.leftBottom,\n      color,\n      lineWidth,\n    );\n    this.project.curveRenderer.renderSolidLine(\n      renderViewRectangle.rightTop,\n      renderViewRectangle.rightBottom,\n      color,\n      lineWidth,\n    );\n\n    // 渲染左括号的上下横线\n    this.project.curveRenderer.renderSolidLine(\n      renderViewRectangle.leftTop,\n      renderViewRectangle.leftTop.add(new Vector(bracketLength, 0)),\n      color,\n      lineWidth,\n    );\n    this.project.curveRenderer.renderSolidLine(\n      renderViewRectangle.leftBottom,\n      renderViewRectangle.leftBottom.add(new Vector(bracketLength, 0)),\n      color,\n      lineWidth,\n    );\n\n    // 渲染右括号的上下横线\n    this.project.curveRenderer.renderSolidLine(\n      renderViewRectangle.rightTop,\n      renderViewRectangle.rightTop.add(new Vector(-bracketLength, 0)),\n      color,\n      lineWidth,\n    );\n    this.project.curveRenderer.renderSolidLine(\n      renderViewRectangle.rightBottom,\n      renderViewRectangle.rightBottom.add(new Vector(-bracketLength, 0)),\n      color,\n      lineWidth,\n    );\n  }\n\n  /**\n   * 渲染被引用的section边框\n   */\n  public renderSourceSectionBorder(section: Section, countNumber: number, color: Color = new Color(118, 78, 209)) {\n    // 获取section的矩形，向外膨胀20像素\n    const worldRect = section.rectangle.expandFromCenter(8);\n    const renderViewRect = new Rectangle(\n      this.project.renderer.transformWorld2View(worldRect.location),\n      worldRect.size.multiply(this.project.camera.currentScale),\n    );\n\n    const lineWidth = 8 * this.project.camera.currentScale;\n\n    // 计算各边中点\n    const topMid = new Vector(renderViewRect.leftTop.x + renderViewRect.size.x / 2, renderViewRect.leftTop.y);\n    const leftMid = new Vector(renderViewRect.leftTop.x, renderViewRect.leftTop.y + renderViewRect.size.y / 2);\n    const bottomMid = new Vector(\n      renderViewRect.rightBottom.x - renderViewRect.size.x / 2,\n      renderViewRect.rightBottom.y,\n    );\n    const rightMid = new Vector(renderViewRect.rightBottom.x, renderViewRect.rightBottom.y - renderViewRect.size.y / 2);\n\n    // 绘制左上角括号：左到上中点，上到左中点\n    this.project.curveRenderer.renderSolidLine(renderViewRect.leftTop, topMid, color, lineWidth);\n    this.project.curveRenderer.renderSolidLine(renderViewRect.leftTop, leftMid, color, lineWidth);\n\n    // 绘制右下角括号：右到下中点，下到右中点\n    this.project.curveRenderer.renderSolidLine(renderViewRect.rightBottom, bottomMid, color, lineWidth);\n    this.project.curveRenderer.renderSolidLine(renderViewRect.rightBottom, rightMid, color, lineWidth);\n\n    // 在左上角渲染计数\n    const mouseWorldPos = this.project.renderer.transformView2World(MouseLocation.vector());\n    // 创建计数圆形的世界坐标\n    const circleWorld = section.referenceButtonCircle();\n    const countCircleCenter = circleWorld.location;\n    const countCircleRadius = circleWorld.radius;\n    // 转换为视图坐标\n    const countCircleCenterView = this.project.renderer.transformWorld2View(countCircleCenter);\n    const countCircleRadiusView = countCircleRadius * this.project.camera.currentScale;\n    const fontSize = countCircleRadius * 1.5 * this.project.camera.currentScale;\n\n    // 检查鼠标是否悬浮在圆形上\n    const isMouseHovering = new Circle(countCircleCenter, countCircleRadius).isPointIn(mouseWorldPos);\n\n    // 根据鼠标是否悬浮选择圆形颜色\n    const circleColor = isMouseHovering ? new Color(150, 120, 230) : color;\n\n    // 绘制圆形背景\n    this.project.shapeRenderer.renderCircle(\n      countCircleCenterView,\n      countCircleRadiusView,\n      circleColor,\n      Color.Transparent,\n      0,\n    );\n\n    // 绘制白色文字，中心对准圆心\n    this.project.textRenderer.renderTextFromCenter(\n      countNumber.toString(),\n      countCircleCenterView,\n      fontSize,\n      Color.White,\n    );\n\n    // 如果鼠标悬浮，显示提示文字\n    if (isMouseHovering) {\n      this.project.textRenderer.renderText(\n        \"点击查看引用\",\n        countCircleCenterView.add(new Vector(countCircleRadiusView + 5, -countCircleRadiusView / 2)),\n        12 * this.project.camera.currentScale,\n        Color.White,\n      );\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/render/canvas2d/entityRenderer/edge/EdgeRenderer.tsx",
    "content": "import { Color } from \"@graphif/data-structures\";\n\nimport { CubicCatmullRomSplineEdge } from \"@/core/stage/stageObject/association/CubicCatmullRomSplineEdge\";\nimport { LineEdge } from \"@/core/stage/stageObject/association/LineEdge\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { Vector } from \"@graphif/data-structures\";\n\nimport { Project, service } from \"@/core/Project\";\nimport { StraightEdgeRenderer } from \"@/core/render/canvas2d/entityRenderer/edge/concrete/StraightEdgeRenderer\";\nimport { SymmetryCurveEdgeRenderer } from \"@/core/render/canvas2d/entityRenderer/edge/concrete/SymmetryCurveEdgeRenderer\";\nimport { VerticalPolyEdgeRenderer } from \"@/core/render/canvas2d/entityRenderer/edge/concrete/VerticalPolyEdgeRenderer\";\nimport { EdgeRendererClass } from \"@/core/render/canvas2d/entityRenderer/edge/EdgeRendererClass\";\nimport { Renderer } from \"@/core/render/canvas2d/renderer\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { Edge } from \"@/core/stage/stageObject/association/Edge\";\n\n/**\n * 边的总渲染器单例\n */\n@service(\"edgeRenderer\")\nexport class EdgeRenderer {\n  // let currentRenderer = new StraightEdgeRenderer();\n  private currentRenderer: EdgeRendererClass;\n\n  /**\n   * 初始化边的渲染器\n   */\n  constructor(private readonly project: Project) {\n    this.currentRenderer = this.project.symmetryCurveEdgeRenderer;\n    Settings.watch(\"lineStyle\", this.updateRenderer.bind(this));\n  }\n\n  checkRendererBySettings(lineStyle: Settings[\"lineStyle\"]) {\n    if (lineStyle === \"straight\") {\n      this.currentRenderer = this.project.straightEdgeRenderer;\n    } else if (lineStyle === \"bezier\") {\n      this.currentRenderer = this.project.symmetryCurveEdgeRenderer;\n    }\n  }\n\n  /**\n   * 更新渲染器\n   */\n  async updateRenderer(style: Settings[\"lineStyle\"]) {\n    if (style === \"straight\" && !(this.currentRenderer instanceof StraightEdgeRenderer)) {\n      this.currentRenderer = this.project.straightEdgeRenderer;\n    } else if (style === \"bezier\" && !(this.currentRenderer instanceof SymmetryCurveEdgeRenderer)) {\n      this.currentRenderer = this.project.symmetryCurveEdgeRenderer;\n    } else if (style === \"vertical\" && !(this.currentRenderer instanceof VerticalPolyEdgeRenderer)) {\n      this.currentRenderer = this.project.verticalPolyEdgeRenderer;\n    }\n  }\n\n  renderLineEdge(edge: LineEdge) {\n    if (edge.source.isHiddenBySectionCollapse && edge.target.isHiddenBySectionCollapse) {\n      return;\n    }\n\n    edge = this.getEdgeView(edge);\n\n    const source = edge.source;\n    const target = edge.target;\n\n    if (source.uuid == target.uuid) {\n      this.currentRenderer.renderCycleState(edge);\n    } else {\n      if (edge.isShifting) {\n        this.currentRenderer.renderShiftingState(edge);\n      } else {\n        this.currentRenderer.renderNormalState(edge);\n      }\n    }\n\n    // 选中的高亮效果\n    if (edge.isSelected) {\n      this.project.collisionBoxRenderer.render(\n        edge.collisionBox,\n        this.project.stageStyleManager.currentStyle.CollideBoxSelected,\n      );\n      // 还要标注起始点和终止点\n      this.project.shapeRenderer.renderCircle(\n        this.project.renderer.transformWorld2View(edge.sourceLocation),\n        10 * this.project.camera.currentScale,\n        Color.Transparent,\n        this.project.stageStyleManager.currentStyle.CollideBoxSelected,\n        2 * this.project.camera.currentScale,\n      );\n      this.project.shapeRenderer.renderCircle(\n        this.project.renderer.transformWorld2View(edge.targetLocation),\n        10 * this.project.camera.currentScale,\n        Color.Transparent,\n        this.project.stageStyleManager.currentStyle.CollideBoxSelected,\n        2 * this.project.camera.currentScale,\n      );\n      // 画一个虚线\n      this.project.curveRenderer.renderDashedLine(\n        this.project.renderer.transformWorld2View(edge.sourceLocation),\n        this.project.renderer.transformWorld2View(edge.targetLocation),\n        this.project.stageStyleManager.currentStyle.CollideBoxSelected,\n        2 * this.project.camera.currentScale,\n        10 * this.project.camera.currentScale,\n      );\n    }\n  }\n\n  renderCrEdge(edge: CubicCatmullRomSplineEdge) {\n    if (edge.source.isHiddenBySectionCollapse && edge.target.isHiddenBySectionCollapse) {\n      return;\n    }\n    const crShape = edge.getShape();\n    const edgeColor = edge.color.a === 0 ? this.project.stageStyleManager.currentStyle.StageObjectBorder : edge.color;\n    // 画曲线\n    this.project.worldRenderUtils.renderCubicCatmullRomSpline(crShape, edgeColor, 2);\n    if (edge.isSelected) {\n      this.project.collisionBoxRenderer.render(\n        edge.collisionBox,\n        this.project.stageStyleManager.currentStyle.CollideBoxSelected,\n      );\n    }\n    // 画控制点们\n    for (const point of crShape.controlPoints) {\n      this.project.shapeRenderer.renderCircle(\n        this.project.renderer.transformWorld2View(point),\n        5 * this.project.camera.currentScale,\n        Color.Transparent,\n        edgeColor,\n        2 * this.project.camera.currentScale,\n      );\n    }\n    // 画文字\n    if (edge.text !== \"\") {\n      const textRect = edge.textRectangle;\n      this.project.shapeRenderer.renderRect(\n        this.project.renderer.transformWorld2View(textRect),\n        this.project.stageStyleManager.currentStyle.Background,\n        Color.Transparent,\n        0,\n      );\n      this.project.textRenderer.renderMultiLineTextFromCenter(\n        edge.text,\n        this.project.renderer.transformWorld2View(textRect.center),\n        Renderer.FONT_SIZE * this.project.camera.currentScale,\n        Infinity,\n        edgeColor,\n      );\n    }\n    // 画箭头\n    const { location, direction } = edge.getArrowHead();\n    this.renderArrowHead(location, direction.normalize(), 15, edgeColor);\n  }\n\n  /**\n   * 当一个内部可连接实体被外部连接但它的父级section折叠了\n   * 通过这个函数能获取它的最小非折叠父级\n   * 可以用于连线的某一端被折叠隐藏了的情况\n   * @param innerEntity\n   */\n  getMinNonCollapseParentSection(innerEntity: ConnectableEntity): Section {\n    const father = this.project.sectionMethods.getFatherSections(innerEntity);\n    if (father.length === 0) {\n      // 直接抛出错误\n      throw new Error(\"Can't find parent section\");\n    }\n    const minSection = father[0];\n    if (minSection.isHiddenBySectionCollapse) {\n      return this.getMinNonCollapseParentSection(minSection);\n    } else {\n      return minSection;\n    }\n  }\n\n  getEdgeView(edge: LineEdge): LineEdge {\n    if (edge.source.isHiddenBySectionCollapse && edge.target.isHiddenBySectionCollapse) {\n      return edge;\n    } else if (!edge.source.isHiddenBySectionCollapse && !edge.target.isHiddenBySectionCollapse) {\n      return edge;\n    }\n\n    if (edge.source.isHiddenBySectionCollapse) {\n      return new LineEdge(this.project, {\n        associationList: [this.getMinNonCollapseParentSection(edge.source), edge.target],\n        text: edge.text,\n        uuid: edge.uuid,\n      });\n    }\n    if (edge.target.isHiddenBySectionCollapse) {\n      return new LineEdge(this.project, {\n        associationList: [edge.source, this.getMinNonCollapseParentSection(edge.target)],\n        text: edge.text,\n        uuid: edge.uuid,\n      });\n    }\n    return edge;\n  }\n\n  getEdgeSvg(edge: LineEdge): React.ReactNode {\n    if (edge.source.isHiddenBySectionCollapse && edge.target.isHiddenBySectionCollapse) {\n      return <></>;\n    }\n\n    if (edge.source.uuid == edge.target.uuid) {\n      return this.currentRenderer.getCycleStageSvg(edge);\n    } else {\n      if (edge.isShifting) {\n        return this.currentRenderer.getShiftingStageSvg(edge);\n      } else {\n        return this.currentRenderer.getNormalStageSvg(edge);\n      }\n    }\n  }\n\n  renderVirtualEdge(startNode: ConnectableEntity, mouseLocation: Vector) {\n    this.currentRenderer.renderVirtualEdge(startNode, mouseLocation);\n  }\n  renderVirtualConfirmedEdge(startNode: ConnectableEntity, endNode: ConnectableEntity) {\n    this.currentRenderer.renderVirtualConfirmedEdge(startNode, endNode);\n  }\n\n  getCuttingEffects(edge: Edge) {\n    return this.currentRenderer.getCuttingEffects(edge);\n  }\n  getConnectedEffects(startNode: ConnectableEntity, toNode: ConnectableEntity) {\n    return this.currentRenderer.getConnectedEffects(startNode, toNode);\n  }\n\n  /**\n   * 绘制箭头\n   * @param endPoint 世界坐标\n   * @param direction\n   * @param size\n   */\n  renderArrowHead(endPoint: Vector, direction: Vector, size: number, color: Color) {\n    const reDirection = direction.clone().multiply(-1);\n    const location2 = endPoint.add(reDirection.multiply(size).rotateDegrees(15));\n    const location3 = endPoint.add(reDirection.multiply(size * 0.5));\n    const location4 = endPoint.add(reDirection.multiply(size).rotateDegrees(-15));\n    this.project.shapeRenderer.renderPolygonAndFill(\n      [\n        this.project.renderer.transformWorld2View(endPoint),\n        this.project.renderer.transformWorld2View(location2),\n        this.project.renderer.transformWorld2View(location3),\n        this.project.renderer.transformWorld2View(location4),\n      ],\n      color,\n      color,\n      0,\n    );\n  }\n\n  /**\n   * 生成箭头的SVG多边形\n   * @param endPoint 世界坐标\n   * @param direction\n   * @param size\n   * @returns SVG多边形字符串\n   */\n  generateArrowHeadSvg(endPoint: Vector, direction: Vector, size: number, edgeColor: Color): React.ReactNode {\n    const reDirection = direction.clone().multiply(-1);\n    const location2 = endPoint.add(reDirection.multiply(size).rotateDegrees(15));\n    const location3 = endPoint.add(reDirection.multiply(size * 0.5));\n    const location4 = endPoint.add(reDirection.multiply(size).rotateDegrees(-15));\n\n    // 将计算得到的点转换为 SVG 坐标\n    const pointsString = [endPoint, location2, location3, location4]\n      .map((point) => `${point.x.toFixed(1)},${point.y.toFixed(1)}`)\n      .join(\" \");\n\n    // 返回SVG多边形字符串\n    return <polygon points={pointsString} fill={edgeColor.toString()} stroke={edgeColor.toString()} />;\n  }\n}\n"
  },
  {
    "path": "app/src/core/render/canvas2d/entityRenderer/edge/EdgeRendererClass.tsx",
    "content": "import { Vector } from \"@graphif/data-structures\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { Edge } from \"@/core/stage/stageObject/association/Edge\";\nimport { LineEdge } from \"@/core/stage/stageObject/association/LineEdge\";\n\n/**\n * 不同类型的边的渲染器 基类\n *\n * 形态：\n *   正常形态\n *   自环形态\n *   偏移形态（未实现）\n *\n * 交互时状态 阴影：\n *   鼠标悬浮阴影\n *   选中阴影\n *   即将删除警告阴影\n *\n * 虚拟连线：\n *   鼠标拖拽时还未连接到目标\n *   鼠标拖拽时吸附到目标\n *\n * 特效：\n *   连接成功特效\n *   删除斩断特效\n */\nexport abstract class EdgeRendererClass {\n  constructor() {}\n\n  isCycleState(edge: LineEdge): boolean {\n    return edge.target.uuid === edge.source.uuid;\n  }\n  isNormalState(edge: LineEdge): boolean {\n    return !this.isCycleState(edge);\n  }\n\n  /**\n   * 绘制正常看到的状态\n   */\n  public abstract renderNormalState(edge: LineEdge): void;\n\n  /**\n   * 绘制双向线的偏移状态\n   */\n  public abstract renderShiftingState(edge: LineEdge): void;\n\n  /**\n   * 绘制自环状态\n   */\n  public abstract renderCycleState(edge: LineEdge): void;\n\n  public abstract getNormalStageSvg(edge: LineEdge): React.ReactNode;\n  public abstract getShiftingStageSvg(edge: LineEdge): React.ReactNode;\n  public abstract getCycleStageSvg(edge: LineEdge): React.ReactNode;\n\n  /**\n   * 绘制鼠标连线移动时的虚拟连线效果\n   * @param startNode\n   * @param mouseLocation 世界坐标系\n   */\n  public abstract renderVirtualEdge(startNode: ConnectableEntity, mouseLocation: Vector): void;\n\n  /**\n   * 绘制鼠标连线移动到目标节点上吸附住 时候虚拟连线效果\n   * @param startNode\n   * @param endNode\n   */\n  public abstract renderVirtualConfirmedEdge(startNode: ConnectableEntity, endNode: ConnectableEntity): void;\n  /**\n   * 获取这个线在切断时的特效\n   * 外层将在切断时根据此函数来获取特效并自动加入到渲染器中\n   */\n  abstract getCuttingEffects(edge: Edge): Effect[];\n\n  /**\n   * 获取这个线在连接成功时的特效\n   */\n  abstract getConnectedEffects(startNode: ConnectableEntity, toNode: ConnectableEntity): Effect[];\n}\n"
  },
  {
    "path": "app/src/core/render/canvas2d/entityRenderer/edge/concrete/StraightEdgeRenderer.tsx",
    "content": "import { Color, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Line } from \"@graphif/shapes\";\nimport { Project, service } from \"@/core/Project\";\nimport { CircleFlameEffect } from \"@/core/service/feedbackService/effectEngine/concrete/CircleFlameEffect\";\nimport { EdgeCutEffect } from \"@/core/service/feedbackService/effectEngine/concrete/EdgeCutEffect\";\nimport { LineCuttingEffect } from \"@/core/service/feedbackService/effectEngine/concrete/LineCuttingEffect\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { LineEdge } from \"@/core/stage/stageObject/association/LineEdge\";\nimport { ConnectPoint } from \"@/core/stage/stageObject/entity/ConnectPoint\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { SvgUtils } from \"@/core/render/svg/SvgUtils\";\nimport { Renderer } from \"@/core/render/canvas2d/renderer\";\nimport { EdgeRendererClass } from \"@/core/render/canvas2d/entityRenderer/edge/EdgeRendererClass\";\n\n/**\n * 直线渲染器\n */\n@service(\"straightEdgeRenderer\")\nexport class StraightEdgeRenderer extends EdgeRendererClass {\n  constructor(private readonly project: Project) {\n    super();\n  }\n\n  getCuttingEffects(edge: LineEdge): Effect[] {\n    return [\n      EdgeCutEffect.default(\n        edge.bodyLine.start,\n        edge.bodyLine.end,\n        this.project.stageStyleManager.currentStyle.StageObjectBorder,\n        2,\n      ),\n    ];\n  }\n\n  getConnectedEffects(startNode: ConnectableEntity, toNode: ConnectableEntity): Effect[] {\n    return [\n      new CircleFlameEffect(\n        new ProgressNumber(0, 15),\n        startNode.collisionBox.getRectangle().center,\n        80,\n        this.project.stageStyleManager.currentStyle.effects.successShadow.clone(),\n      ),\n      new LineCuttingEffect(\n        new ProgressNumber(0, 30),\n        startNode.collisionBox.getRectangle().center,\n        toNode.collisionBox.getRectangle().center,\n        this.project.stageStyleManager.currentStyle.effects.successShadow.clone(),\n        this.project.stageStyleManager.currentStyle.effects.successShadow.clone(),\n        20,\n      ),\n    ];\n  }\n\n  private renderLine(start: Vector, end: Vector, edge: LineEdge, width: number): void {\n    const edgeColor = edge.color.equals(Color.Transparent)\n      ? this.project.stageStyleManager.currentStyle.StageObjectBorder\n      : edge.color;\n\n    const lineType = edge.lineType || \"solid\";\n    if (lineType === \"dashed\") {\n      this.project.curveRenderer.renderDashedLine(start, end, edgeColor, width, 10 * this.project.camera.currentScale);\n    } else if (lineType === \"double\") {\n      this.project.curveRenderer.renderDoubleLine(start, end, edgeColor, width, 5 * this.project.camera.currentScale);\n    } else {\n      this.project.curveRenderer.renderSolidLine(start, end, edgeColor, width);\n    }\n  }\n\n  public renderNormalState(edge: LineEdge): void {\n    // 直线绘制\n    const edgeColor = edge.color.equals(Color.Transparent)\n      ? this.project.stageStyleManager.currentStyle.StageObjectBorder\n      : edge.color;\n\n    let edgeWidth = 2;\n    if (edge.target instanceof Section && edge.source instanceof Section) {\n      const rect1 = edge.source.collisionBox.getRectangle();\n      const rect2 = edge.target.collisionBox.getRectangle();\n      edgeWidth = Math.min(\n        Math.min(Math.max(rect1.width, rect1.height), Math.max(rect2.width, rect2.height)) / 100,\n        100,\n      );\n    }\n    const straightBodyLine = edge.bodyLine;\n    const scaledWidth = edgeWidth * this.project.camera.currentScale;\n\n    if (edge.text.trim() === \"\") {\n      // 没有文字的边\n      this.renderLine(\n        this.project.renderer.transformWorld2View(straightBodyLine.start),\n        this.project.renderer.transformWorld2View(straightBodyLine.end),\n        edge,\n        scaledWidth,\n      );\n    } else {\n      // 有文字的边\n      const midPoint = straightBodyLine.midPoint();\n      const startHalf = new Line(straightBodyLine.start, midPoint);\n      const endHalf = new Line(midPoint, straightBodyLine.end);\n      this.project.textRenderer.renderMultiLineTextFromCenter(\n        edge.text,\n        this.project.renderer.transformWorld2View(midPoint),\n        Renderer.FONT_SIZE * this.project.camera.currentScale,\n        Infinity,\n        edgeColor,\n      );\n      const edgeTextRectangle = edge.textRectangle;\n\n      this.renderLine(\n        this.project.renderer.transformWorld2View(straightBodyLine.start),\n        this.project.renderer.transformWorld2View(edgeTextRectangle.getLineIntersectionPoint(startHalf)),\n        edge,\n        scaledWidth,\n      );\n      this.renderLine(\n        this.project.renderer.transformWorld2View(straightBodyLine.end),\n        this.project.renderer.transformWorld2View(edgeTextRectangle.getLineIntersectionPoint(endHalf)),\n        edge,\n        scaledWidth,\n      );\n    }\n    if (!(edge.target instanceof ConnectPoint)) {\n      // 画箭头\n      this.renderArrowHead(\n        edge,\n        straightBodyLine.end.subtract(straightBodyLine.start).normalize(),\n        straightBodyLine.end.clone(),\n        8 * edgeWidth,\n      );\n    }\n  }\n\n  public getNormalStageSvg(edge: LineEdge): React.ReactNode {\n    let lineBody: React.ReactNode = <></>;\n    let textNode: React.ReactNode = <></>;\n    const edgeColor = edge.color.equals(Color.Transparent)\n      ? this.project.stageStyleManager.currentStyle.StageObjectBorder\n      : edge.color;\n    if (edge.text.trim() === \"\") {\n      // 没有文字的边\n      lineBody = SvgUtils.line(edge.bodyLine.start, edge.bodyLine.end, edgeColor, 2);\n    } else {\n      // 有文字的边\n      const midPoint = edge.bodyLine.midPoint();\n      const startHalf = new Line(edge.bodyLine.start, midPoint);\n      const endHalf = new Line(midPoint, edge.bodyLine.end);\n      const edgeTextRectangle = edge.textRectangle;\n\n      textNode = SvgUtils.textFromCenter(edge.text, midPoint, Renderer.FONT_SIZE, edgeColor);\n      lineBody = (\n        <>\n          {SvgUtils.line(edge.bodyLine.start, edgeTextRectangle.getLineIntersectionPoint(startHalf), edgeColor, 2)}\n          {SvgUtils.line(edge.bodyLine.end, edgeTextRectangle.getLineIntersectionPoint(endHalf), edgeColor, 2)}\n        </>\n      );\n    }\n    // 加箭头\n    const arrowHead = this.project.edgeRenderer.generateArrowHeadSvg(\n      edge.bodyLine.end.clone(),\n      edge.target.collisionBox\n        .getRectangle()\n        .getCenter()\n        .subtract(edge.source.collisionBox.getRectangle().getCenter())\n        .normalize(),\n      15,\n      edgeColor,\n    );\n    return (\n      <>\n        {lineBody}\n        {textNode}\n        {arrowHead}\n      </>\n    );\n  }\n  public getCycleStageSvg(): React.ReactNode {\n    return <></>;\n  }\n  public getShiftingStageSvg(): React.ReactNode {\n    return <></>;\n  }\n\n  private renderArrowHead(edge: LineEdge, direction: Vector, endPoint = edge.bodyLine.end.clone(), size = 15) {\n    const edgeColor = edge.color.equals(Color.Transparent)\n      ? this.project.stageStyleManager.currentStyle.StageObjectBorder\n      : edge.color;\n    this.project.edgeRenderer.renderArrowHead(endPoint, direction, size, edgeColor);\n  }\n\n  public renderShiftingState(edge: LineEdge): void {\n    const shiftingMidPoint = edge.shiftingMidPoint;\n    // 从source.Center到shiftingMidPoint的线\n    const sourceRectangle = edge.source.collisionBox.getRectangle();\n    const targetRectangle = edge.target.collisionBox.getRectangle();\n    const startLine = new Line(\n      sourceRectangle.getInnerLocationByRateVector(edge.sourceRectangleRate),\n      shiftingMidPoint,\n    );\n    const endLine = new Line(shiftingMidPoint, targetRectangle.getInnerLocationByRateVector(edge.targetRectangleRate));\n    const startPoint = sourceRectangle.getLineIntersectionPoint(startLine);\n    const endPoint = targetRectangle.getLineIntersectionPoint(endLine);\n    const scaledWidth = 2 * this.project.camera.currentScale;\n\n    if (edge.text.trim() === \"\") {\n      // 没有文字的边\n      this.renderLine(\n        this.project.renderer.transformWorld2View(startPoint),\n        this.project.renderer.transformWorld2View(shiftingMidPoint),\n        edge,\n        scaledWidth,\n      );\n      this.renderLine(\n        this.project.renderer.transformWorld2View(shiftingMidPoint),\n        this.project.renderer.transformWorld2View(endPoint),\n        edge,\n        scaledWidth,\n      );\n    } else {\n      // 有文字的边\n      const edgeColor = edge.color.equals(Color.Transparent)\n        ? this.project.stageStyleManager.currentStyle.StageObjectBorder\n        : edge.color;\n      this.project.textRenderer.renderTextFromCenter(\n        edge.text,\n        this.project.renderer.transformWorld2View(shiftingMidPoint),\n        Renderer.FONT_SIZE * this.project.camera.currentScale,\n        edgeColor,\n      );\n      const edgeTextRectangle = edge.textRectangle;\n      const start2MidPoint = edgeTextRectangle.getLineIntersectionPoint(startLine);\n      const mid2EndPoint = edgeTextRectangle.getLineIntersectionPoint(endLine);\n      this.renderLine(\n        this.project.renderer.transformWorld2View(startPoint),\n        this.project.renderer.transformWorld2View(start2MidPoint),\n        edge,\n        scaledWidth,\n      );\n      this.renderLine(\n        this.project.renderer.transformWorld2View(mid2EndPoint),\n        this.project.renderer.transformWorld2View(endPoint),\n        edge,\n        scaledWidth,\n      );\n    }\n    this.renderArrowHead(\n      edge,\n      edge.target.collisionBox.getRectangle().getCenter().subtract(shiftingMidPoint).normalize(),\n      endPoint,\n    );\n  }\n\n  public renderCycleState(edge: LineEdge): void {\n    // 自环\n    const edgeColor = edge.color.equals(Color.Transparent)\n      ? this.project.stageStyleManager.currentStyle.StageObjectBorder\n      : edge.color;\n    this.project.shapeRenderer.renderArc(\n      this.project.renderer.transformWorld2View(edge.target.collisionBox.getRectangle().location),\n      (edge.target.collisionBox.getRectangle().size.y / 2) * this.project.camera.currentScale,\n      Math.PI / 2,\n      0,\n      edgeColor,\n      2 * this.project.camera.currentScale,\n    );\n    // 画箭头\n    this.renderArrowHead(edge, new Vector(1, 0).rotateDegrees(15), edge.target.collisionBox.getRectangle().leftCenter);\n    // 画文字\n    if (edge.text.trim() === \"\") {\n      // 没有文字的边\n      return;\n    }\n    this.project.textRenderer.renderTextFromCenter(\n      edge.text,\n      this.project.renderer.transformWorld2View(\n        edge.target.collisionBox.getRectangle().location.add(new Vector(0, -50)),\n      ),\n      Renderer.FONT_SIZE * this.project.camera.currentScale,\n      edgeColor,\n    );\n  }\n\n  public renderVirtualEdge(startNode: ConnectableEntity, mouseLocation: Vector): void {\n    this.project.curveRenderer.renderGradientLine(\n      this.project.renderer.transformWorld2View(startNode.collisionBox.getRectangle().getCenter()),\n      this.project.renderer.transformWorld2View(mouseLocation),\n      this.project.stageStyleManager.currentStyle.StageObjectBorder.toTransparent(),\n      this.project.stageStyleManager.currentStyle.StageObjectBorder,\n      2,\n    );\n  }\n\n  public renderVirtualConfirmedEdge(startNode: ConnectableEntity, endNode: ConnectableEntity): void {\n    this.project.curveRenderer.renderGradientLine(\n      this.project.renderer.transformWorld2View(startNode.collisionBox.getRectangle().getCenter()),\n      this.project.renderer.transformWorld2View(endNode.collisionBox.getRectangle().getCenter()),\n      this.project.stageStyleManager.currentStyle.effects.successShadow.toNewAlpha(0.5),\n      this.project.stageStyleManager.currentStyle.effects.successShadow.toSolid(),\n      2,\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/render/canvas2d/entityRenderer/edge/concrete/SymmetryCurveEdgeRenderer.tsx",
    "content": "import { CircleFlameEffect } from \"@/core/service/feedbackService/effectEngine/concrete/CircleFlameEffect\";\nimport { LineCuttingEffect } from \"@/core/service/feedbackService/effectEngine/concrete/LineCuttingEffect\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\nimport { LineEdge } from \"@/core/stage/stageObject/association/LineEdge\";\nimport { Color, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Line, SymmetryCurve } from \"@graphif/shapes\";\n// import { ConnectPoint } from \"@/core/stage/stageObject/entity/ConnectPoint\";\nimport { Project, service } from \"@/core/Project\";\nimport { EdgeRendererClass } from \"@/core/render/canvas2d/entityRenderer/edge/EdgeRendererClass\";\nimport { Renderer } from \"@/core/render/canvas2d/renderer\";\nimport { SvgUtils } from \"@/core/render/svg/SvgUtils\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { ConnectPoint } from \"@/core/stage/stageObject/entity/ConnectPoint\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { ImageNode } from \"@/core/stage/stageObject/entity/ImageNode\";\n\n/**\n * 贝塞尔曲线\n */\n@service(\"symmetryCurveEdgeRenderer\")\nexport class SymmetryCurveEdgeRenderer extends EdgeRendererClass {\n  constructor(private readonly project: Project) {\n    super();\n  }\n\n  getCuttingEffects(edge: LineEdge): Effect[] {\n    const midLocation = edge.bodyLine.midPoint();\n    return [\n      new LineCuttingEffect(\n        new ProgressNumber(0, 15),\n        midLocation,\n        edge.bodyLine.start,\n        new Color(255, 0, 0, 1),\n        new Color(255, 0, 0, 1),\n        20,\n      ),\n      new LineCuttingEffect(\n        new ProgressNumber(0, 15),\n        midLocation,\n        edge.bodyLine.end,\n        new Color(255, 0, 0, 1),\n        new Color(255, 0, 0, 1),\n        20,\n      ),\n      new CircleFlameEffect(new ProgressNumber(0, 15), edge.bodyLine.midPoint(), 50, new Color(255, 0, 0, 1)),\n    ];\n  }\n  getConnectedEffects(startNode: ConnectableEntity, toNode: ConnectableEntity): Effect[] {\n    return [\n      new CircleFlameEffect(\n        new ProgressNumber(0, 15),\n        startNode.collisionBox.getRectangle().center,\n        80,\n        new Color(83, 175, 29, 1),\n      ),\n      new LineCuttingEffect(\n        new ProgressNumber(0, 30),\n        startNode.collisionBox.getRectangle().center,\n        toNode.collisionBox.getRectangle().center,\n        new Color(78, 201, 176, 1),\n        new Color(83, 175, 29, 1),\n        20,\n      ),\n    ];\n  }\n\n  public renderNormalState(edge: LineEdge): void {\n    const start = edge.bodyLine.start;\n    const end = edge.bodyLine.end;\n\n    // 计算连线方向\n    const lineDirection = end.subtract(start).normalize();\n\n    let startDirection: Vector;\n    let endDirection: Vector;\n\n    if (edge.source instanceof ConnectPoint) {\n      startDirection = Vector.getZero();\n    } else {\n      const sourceRect = edge.source.collisionBox.getRectangle();\n      // 检查是否是图片或引用块节点的精确位置（不在边缘上）\n      const isSourceExactPosition =\n        (edge.source instanceof ImageNode || edge.source.constructor.name === \"ReferenceBlockNode\") &&\n        start.x !== sourceRect.left &&\n        start.x !== sourceRect.right &&\n        start.y !== sourceRect.top &&\n        start.y !== sourceRect.bottom;\n\n      if (isSourceExactPosition) {\n        // 对于图片或引用块节点的精确位置，使用连线方向作为法线向量\n        startDirection = lineDirection;\n      } else {\n        // 否则使用矩形边缘的法线向量\n        startDirection = sourceRect.getNormalVectorAt(start);\n      }\n    }\n\n    if (edge.target instanceof ConnectPoint) {\n      endDirection = Vector.getZero();\n    } else {\n      const targetRect = edge.target.collisionBox.getRectangle();\n      // 检查是否是图片或引用块节点的精确位置（不在边缘上）\n      const isTargetExactPosition =\n        (edge.target instanceof ImageNode || edge.target.constructor.name === \"ReferenceBlockNode\") &&\n        end.x !== targetRect.left &&\n        end.x !== targetRect.right &&\n        end.y !== targetRect.top &&\n        end.y !== targetRect.bottom;\n\n      if (isTargetExactPosition) {\n        // 对于图片或引用块节点的精确位置，使用连线方向的反方向作为法线向量\n        endDirection = lineDirection.multiply(-1);\n      } else {\n        // 否则使用矩形边缘的法线向量\n        endDirection = targetRect.getNormalVectorAt(end);\n      }\n    }\n\n    let edgeWidth = 2;\n    if (edge.target instanceof Section && edge.source instanceof Section) {\n      const rect1 = edge.source.collisionBox.getRectangle();\n      const rect2 = edge.target.collisionBox.getRectangle();\n      edgeWidth = Math.min(\n        Math.min(Math.max(rect1.width, rect1.height), Math.max(rect2.width, rect2.height)) / 100,\n        100,\n      );\n    }\n\n    const curve = new SymmetryCurve(\n      start,\n      startDirection,\n      end,\n      endDirection,\n      Math.max(50, Math.abs(Math.min(Math.abs(start.x - end.x), Math.abs(start.y - end.y))) / 2),\n    );\n\n    // 曲线模式先不屏蔽箭头，有点不美观，空出来一段距离\n    this.renderArrowCurve(\n      curve,\n      edge.color.equals(Color.Transparent) ? this.project.stageStyleManager.currentStyle.StageObjectBorder : edge.color,\n      edgeWidth,\n      edge,\n    );\n    this.renderText(curve, edge);\n  }\n\n  public renderShiftingState(edge: LineEdge): void {\n    const shiftingMidPoint = edge.shiftingMidPoint;\n    const sourceRectangle = edge.source.collisionBox.getRectangle();\n    const targetRectangle = edge.target.collisionBox.getRectangle();\n\n    // 从source.Center到shiftingMidPoint的线\n    const startLine = new Line(sourceRectangle.center, shiftingMidPoint);\n    const endLine = new Line(shiftingMidPoint, edge.target.collisionBox.getRectangle().center);\n    let startPoint = sourceRectangle.getLineIntersectionPoint(startLine);\n    if (startPoint.equals(sourceRectangle.center)) {\n      startPoint = sourceRectangle.getLineIntersectionPoint(endLine);\n    }\n    let endPoint = targetRectangle.getLineIntersectionPoint(endLine);\n    if (endPoint.equals(targetRectangle.center)) {\n      endPoint = targetRectangle.getLineIntersectionPoint(startLine);\n    }\n    const curve = new SymmetryCurve(\n      startPoint,\n      startLine.direction(),\n      endPoint,\n      endLine.direction().multiply(-1),\n      Math.abs(endPoint.subtract(startPoint).magnitude()) / 2,\n    );\n    this.renderArrowCurve(\n      curve,\n      edge.color.equals(Color.Transparent) ? this.project.stageStyleManager.currentStyle.StageObjectBorder : edge.color,\n      2,\n      edge,\n    );\n    this.renderText(curve, edge);\n  }\n\n  public renderCycleState(edge: LineEdge): void {\n    // 自环\n    this.project.shapeRenderer.renderArc(\n      this.project.renderer.transformWorld2View(edge.target.collisionBox.getRectangle().location),\n      (edge.target.collisionBox.getRectangle().size.y / 2) * this.project.camera.currentScale,\n      Math.PI / 2,\n      0,\n      edge.color.equals(Color.Transparent) ? this.project.stageStyleManager.currentStyle.StageObjectBorder : edge.color,\n      2 * this.project.camera.currentScale,\n    );\n    // 画箭头\n    {\n      const size = 15;\n      const direction = new Vector(1, 0).rotateDegrees(15);\n      const endPoint = edge.target.collisionBox.getRectangle().leftCenter;\n      this.project.edgeRenderer.renderArrowHead(\n        endPoint,\n        direction,\n        size,\n        edge.color.equals(Color.Transparent)\n          ? this.project.stageStyleManager.currentStyle.StageObjectBorder\n          : edge.color,\n      );\n    }\n  }\n  public getNormalStageSvg(edge: LineEdge): React.ReactNode {\n    let lineBody: React.ReactNode = <></>;\n    let textNode: React.ReactNode = <></>;\n    const edgeColor = edge.color.equals(Color.Transparent)\n      ? this.project.stageStyleManager.currentStyle.StageObjectBorder\n      : edge.color;\n    if (edge.text.trim() === \"\") {\n      // 没有文字的边\n      lineBody = SvgUtils.line(edge.bodyLine.start, edge.bodyLine.end, edgeColor, 2);\n    } else {\n      // 有文字的边\n      const midPoint = edge.bodyLine.midPoint();\n      const startHalf = new Line(edge.bodyLine.start, midPoint);\n      const endHalf = new Line(midPoint, edge.bodyLine.end);\n      const edgeTextRectangle = edge.textRectangle;\n\n      textNode = SvgUtils.textFromCenter(edge.text, midPoint, Renderer.FONT_SIZE, edgeColor);\n      lineBody = (\n        <>\n          {SvgUtils.line(edge.bodyLine.start, edgeTextRectangle.getLineIntersectionPoint(startHalf), edgeColor, 2)}\n          {SvgUtils.line(edge.bodyLine.end, edgeTextRectangle.getLineIntersectionPoint(endHalf), edgeColor, 2)}\n        </>\n      );\n    }\n    // 加箭头\n    const arrowHead = this.project.edgeRenderer.generateArrowHeadSvg(\n      edge.bodyLine.end.clone(),\n      edge.target.collisionBox\n        .getRectangle()\n        .getCenter()\n        .subtract(edge.source.collisionBox.getRectangle().getCenter())\n        .normalize(),\n      15,\n      edgeColor,\n    );\n    return (\n      <>\n        {lineBody}\n        {textNode}\n        {arrowHead}\n      </>\n    );\n  }\n  public getCycleStageSvg(): React.ReactNode {\n    return <></>;\n  }\n  public getShiftingStageSvg(): React.ReactNode {\n    return <></>;\n  }\n  public renderVirtualEdge(startNode: ConnectableEntity, mouseLocation: Vector): void {\n    const rect = startNode.collisionBox.getRectangle();\n    const start = rect.getLineIntersectionPoint(new Line(rect.center, mouseLocation));\n    const end = mouseLocation;\n    const direction = end.subtract(start);\n    const endDirection = new Vector(\n      Math.abs(direction.x) >= Math.abs(direction.y) ? direction.x : 0,\n      Math.abs(direction.x) >= Math.abs(direction.y) ? 0 : direction.y,\n    )\n      .normalize()\n      .multiply(-1);\n    this.renderArrowCurve(\n      new SymmetryCurve(\n        start,\n        rect.getNormalVectorAt(start),\n        end,\n        endDirection,\n        Math.abs(end.subtract(start).magnitude()) / 2,\n      ),\n      this.project.stageStyleManager.currentStyle.StageObjectBorder,\n    );\n  }\n\n  public renderVirtualConfirmedEdge(startNode: ConnectableEntity, endNode: ConnectableEntity): void {\n    const startRect = startNode.collisionBox.getRectangle();\n    const endRect = endNode.collisionBox.getRectangle();\n    const line = new Line(startRect.center, endRect.center);\n    const start = startRect.getLineIntersectionPoint(line);\n    const end = endRect.getLineIntersectionPoint(line);\n    this.renderArrowCurve(\n      new SymmetryCurve(\n        start,\n        startRect.getNormalVectorAt(start),\n        end,\n        endRect.getNormalVectorAt(end),\n        Math.abs(end.subtract(start).magnitude()) / 2,\n      ),\n      this.project.stageStyleManager.currentStyle.StageObjectBorder,\n    );\n  }\n\n  /**\n   * 渲染curve及箭头,curve.end即箭头头部\n   * @param curve\n   */\n  private renderArrowCurve(curve: SymmetryCurve, color: Color, width = 2, edge?: LineEdge): void {\n    // 绘制曲线本体\n    curve.endDirection = curve.endDirection.normalize();\n    const end = curve.end.clone();\n    const size = 8 * width; // 箭头大小\n    curve.end = curve.end.subtract(curve.endDirection.multiply(size / -2));\n    // 绘制碰撞箱\n    // const segment = 40;\n    // let lastPoint = curve.start;\n    // for (let i = 1; i <= segment; i++) {\n    //   const line = new Line(lastPoint, curve.bezier.getPointByT(i / segment));\n    //   CurveRenderer.renderSolidLine(\n    //     Renderer.transformWorld2View(line.start),\n    //     Renderer.transformWorld2View(line.end),\n    //     new Color(0, 104, 0),\n    //     10 * Camera.currentScale\n    //   )\n    //   lastPoint = line.end;\n    // }\n    // 根据 lineType 选择渲染方式\n    const lineType = edge?.lineType || \"solid\";\n    if (lineType === \"dashed\") {\n      this.project.worldRenderUtils.renderDashedSymmetryCurve(\n        curve,\n        color,\n        width,\n        10 * this.project.camera.currentScale,\n      );\n    } else if (lineType === \"double\") {\n      this.project.worldRenderUtils.renderDoubleSymmetryCurve(\n        curve,\n        color,\n        width,\n        5 * this.project.camera.currentScale,\n      );\n    } else {\n      this.project.worldRenderUtils.renderSymmetryCurve(curve, color, width);\n    }\n    // 画箭头\n    const endPoint = end.add(curve.endDirection.multiply(2));\n    this.project.edgeRenderer.renderArrowHead(endPoint, curve.endDirection.multiply(-1), size, color);\n\n    if (Settings.showDebug) {\n      const controlPoint1 = curve.bezier.ctrlPt1;\n      const controlPoint2 = curve.bezier.ctrlPt2;\n      this.project.shapeRenderer.renderCircle(\n        this.project.renderer.transformWorld2View(controlPoint1),\n        2 * this.project.camera.currentScale,\n        Color.Transparent,\n        this.project.stageStyleManager.currentStyle.StageObjectBorder,\n        1 * this.project.camera.currentScale,\n      );\n      this.project.shapeRenderer.renderCircle(\n        this.project.renderer.transformWorld2View(controlPoint2),\n        2 * this.project.camera.currentScale,\n        Color.Transparent,\n        this.project.stageStyleManager.currentStyle.StageObjectBorder,\n        1 * this.project.camera.currentScale,\n      );\n      this.project.curveRenderer.renderDashedLine(\n        this.project.renderer.transformWorld2View(curve.start),\n        this.project.renderer.transformWorld2View(controlPoint1),\n        this.project.stageStyleManager.currentStyle.StageObjectBorder.toNewAlpha(0.2),\n        1 * this.project.camera.currentScale,\n        10 * this.project.camera.currentScale,\n      );\n      this.project.curveRenderer.renderDashedLine(\n        this.project.renderer.transformWorld2View(curve.end),\n        this.project.renderer.transformWorld2View(controlPoint2),\n        this.project.stageStyleManager.currentStyle.StageObjectBorder.toNewAlpha(0.2),\n        1 * this.project.camera.currentScale,\n        10 * this.project.camera.currentScale,\n      );\n    }\n  }\n  // /**\n  //  * 仅仅绘制曲线\n  //  * @param curve\n  //  */\n  // private renderCurveOnly(curve: SymmetryCurve): void {\n  //   // 绘制曲线本体\n  //   curve.endDirection = curve.endDirection.normalize();\n  //   const end = curve.end.clone();\n  //   const size = 15; // 箭头大小\n  //   curve.end = curve.end.subtract(curve.endDirection.multiply(size / -2));\n  //   WorldRenderUtils.renderSymmetryCurve(curve, new Color(204, 204, 204), 2);\n  // }\n\n  private renderText(curve: SymmetryCurve, edge: LineEdge): void {\n    if (edge.text.trim() === \"\") {\n      return;\n    }\n    // 画文本底色\n    this.project.shapeRenderer.renderRect(\n      this.project.renderer.transformWorld2View(edge.textRectangle),\n      this.project.stageStyleManager.currentStyle.Background.toNewAlpha(Settings.windowBackgroundAlpha),\n      Color.Transparent,\n      1,\n    );\n\n    this.project.textRenderer.renderMultiLineTextFromCenter(\n      edge.text,\n      this.project.renderer.transformWorld2View(curve.bezier.getPointByT(0.5)),\n      Renderer.FONT_SIZE * this.project.camera.currentScale,\n      Infinity,\n      edge.color.equals(Color.Transparent) ? this.project.stageStyleManager.currentStyle.StageObjectBorder : edge.color,\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/render/canvas2d/entityRenderer/edge/concrete/VerticalPolyEdgeRenderer.tsx",
    "content": "import { Color, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Line } from \"@graphif/shapes\";\nimport { Project, service } from \"@/core/Project\";\nimport { CircleFlameEffect } from \"@/core/service/feedbackService/effectEngine/concrete/CircleFlameEffect\";\nimport { LineCuttingEffect } from \"@/core/service/feedbackService/effectEngine/concrete/LineCuttingEffect\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { LineEdge } from \"@/core/stage/stageObject/association/LineEdge\";\nimport { ConnectPoint } from \"@/core/stage/stageObject/entity/ConnectPoint\";\nimport { SvgUtils } from \"@/core/render/svg/SvgUtils\";\nimport { Renderer } from \"@/core/render/canvas2d/renderer\";\nimport { EdgeRendererClass } from \"@/core/render/canvas2d/entityRenderer/edge/EdgeRendererClass\";\n\n/**\n * 折线渲染器\n */\n@service(\"verticalPolyEdgeRenderer\")\nexport class VerticalPolyEdgeRenderer extends EdgeRendererClass {\n  constructor(private readonly project: Project) {\n    super();\n  }\n\n  getCuttingEffects(edge: LineEdge): Effect[] {\n    const midLocation = edge.bodyLine.midPoint();\n    return [\n      new LineCuttingEffect(\n        new ProgressNumber(0, 15),\n        midLocation,\n        edge.bodyLine.start,\n        new Color(255, 0, 0, 1),\n        new Color(255, 0, 0, 1),\n        20,\n      ),\n      new LineCuttingEffect(\n        new ProgressNumber(0, 15),\n        midLocation,\n        edge.bodyLine.end,\n        new Color(255, 0, 0, 1),\n        new Color(255, 0, 0, 1),\n        20,\n      ),\n      new CircleFlameEffect(new ProgressNumber(0, 15), edge.bodyLine.midPoint(), 50, new Color(255, 0, 0, 1)),\n    ];\n  }\n\n  getConnectedEffects(startNode: ConnectableEntity, toNode: ConnectableEntity): Effect[] {\n    return [\n      new CircleFlameEffect(\n        new ProgressNumber(0, 15),\n        startNode.collisionBox.getRectangle().center,\n        80,\n        new Color(83, 175, 29, 1),\n      ),\n      new LineCuttingEffect(\n        new ProgressNumber(0, 30),\n        startNode.collisionBox.getRectangle().center,\n        toNode.collisionBox.getRectangle().center,\n        new Color(78, 201, 176, 1),\n        new Color(83, 175, 29, 1),\n        20,\n      ),\n    ];\n  }\n\n  /**\n   * 起始点在目标点的哪个区域，返回起始点朝向终点的垂直向量\n   *    上\n   * 左 end 右\n   *    下\n   * 如果起点在左侧，返回 \"->\" 即 new Vector(1, 0)\n   * @param edge\n   * @returns\n   */\n  getVerticalDirection(edge: LineEdge): Vector {\n    const startLocation = edge.source.collisionBox.getRectangle().center;\n    const endLocation = edge.target.collisionBox.getRectangle().center;\n    const startToEnd = endLocation.subtract(startLocation);\n    if (startLocation.x < endLocation.x) {\n      // |左侧\n      if (startLocation.y < endLocation.y) {\n        // |左上\n        if (Math.abs(startToEnd.y) > Math.abs(startToEnd.x)) {\n          // ↓\n          return new Vector(0, 1);\n        } else {\n          // →\n          return new Vector(1, 0);\n        }\n      } else {\n        // |左下\n        if (Math.abs(startToEnd.y) > Math.abs(startToEnd.x)) {\n          // ↑\n          return new Vector(0, -1);\n        } else {\n          // →\n          return new Vector(1, 0);\n        }\n      }\n    } else {\n      // |右侧\n      if (startLocation.y < endLocation.y) {\n        // |右上\n        if (Math.abs(startToEnd.y) > Math.abs(startToEnd.x)) {\n          // ↓\n          return new Vector(0, 1);\n        } else {\n          // ←\n          return new Vector(-1, 0);\n        }\n      } else {\n        // |右下\n        if (Math.abs(startToEnd.y) > Math.abs(startToEnd.x)) {\n          // ↑\n          return new Vector(0, -1);\n        } else {\n          // ←\n          return new Vector(-1, 0);\n        }\n      }\n    }\n  }\n\n  /**\n   * 固定长度\n   */\n  fixedLength: number = 100;\n\n  // debug 测试\n  renderTest(edge: LineEdge) {\n    for (let i = 0; i < 4; i++) {\n      this.project.curveRenderer.renderSolidLine(\n        this.project.renderer.transformWorld2View(edge.target.collisionBox.getRectangle().center),\n        this.project.renderer.transformWorld2View(\n          edge.target.collisionBox.getRectangle().center.add(new Vector(100, 0).rotateDegrees(45 + 90 * i)),\n        ),\n        Color.Green,\n        1,\n      );\n    }\n  }\n  gaussianFunction(x: number) {\n    // e ^(-x^2)\n    return Math.exp(-(x * x) / 10000);\n  }\n\n  public renderNormalState(edge: LineEdge): void {\n    // this.renderTest(edge);\n    // 直线绘制\n    if (edge.text.trim() === \"\") {\n      const verticalDirection = this.getVerticalDirection(edge);\n      if (verticalDirection.x === 0) {\n        // 左右偏离程度\n\n        const rate =\n          1 -\n          this.gaussianFunction(\n            edge.target.collisionBox.getRectangle().center.x - edge.source.collisionBox.getRectangle().center.x,\n          );\n        // 左右偏离距离 恒正\n        const distance = (rate * edge.target.collisionBox.getRectangle().size.x) / 2;\n        // 根据偏移距离计算附加高度  恒正\n        const h = (edge.target.collisionBox.getRectangle().size.x / 2) * (1 - rate);\n        // 终点\n        const p1 = new Vector(\n          edge.target.collisionBox.getRectangle().center.x +\n            distance *\n              (edge.source.collisionBox.getRectangle().center.x > edge.target.collisionBox.getRectangle().center.x\n                ? 1\n                : -1),\n          verticalDirection.y > 0\n            ? edge.target.collisionBox.getRectangle().top\n            : edge.target.collisionBox.getRectangle().bottom,\n        );\n        const length = (this.fixedLength + h) * (verticalDirection.y > 0 ? -1 : 1);\n        const p2 = p1.add(new Vector(0, length));\n\n        const p4 = new Vector(\n          edge.source.collisionBox.getRectangle().center.x,\n          verticalDirection.y > 0\n            ? edge.source.collisionBox.getRectangle().bottom\n            : edge.source.collisionBox.getRectangle().top,\n        );\n\n        const p3 = new Vector(p4.x, p2.y);\n        this.project.curveRenderer.renderSolidLineMultiple(\n          [\n            this.project.renderer.transformWorld2View(p1),\n            this.project.renderer.transformWorld2View(p2),\n            this.project.renderer.transformWorld2View(p3),\n            this.project.renderer.transformWorld2View(p4),\n          ],\n          new Color(204, 204, 204),\n          2 * this.project.camera.currentScale,\n        );\n\n        if (!(edge.target instanceof ConnectPoint)) {\n          this.project.edgeRenderer.renderArrowHead(p1, verticalDirection, 15, edge.color);\n        }\n      } else if (verticalDirection.y === 0) {\n        // 左右\n        const rate =\n          1 -\n          this.gaussianFunction(\n            edge.target.collisionBox.getRectangle().center.y - edge.source.collisionBox.getRectangle().center.y,\n          );\n        // 偏离距离 恒正\n        const distance = (rate * edge.target.collisionBox.getRectangle().size.y) / 2;\n        // 根据偏移距离计算附加高度\n        const h = (edge.target.collisionBox.getRectangle().size.y / 2) * (1 - rate);\n        // 终点\n        const p1 = new Vector(\n          verticalDirection.x > 0\n            ? edge.target.collisionBox.getRectangle().left\n            : edge.target.collisionBox.getRectangle().right,\n          edge.target.collisionBox.getRectangle().center.y +\n            distance *\n              (edge.source.collisionBox.getRectangle().center.y > edge.target.collisionBox.getRectangle().center.y\n                ? 1\n                : -1),\n        );\n        // length 是固定长度+h\n        const length = (this.fixedLength + h) * (verticalDirection.x > 0 ? -1 : 1);\n        const p2 = p1.add(new Vector(length, 0));\n\n        const p4 = new Vector(\n          verticalDirection.x > 0\n            ? edge.source.collisionBox.getRectangle().right\n            : edge.source.collisionBox.getRectangle().left,\n          edge.source.collisionBox.getRectangle().center.y,\n        );\n\n        const p3 = new Vector(p2.x, p4.y);\n\n        this.project.curveRenderer.renderSolidLineMultiple(\n          [\n            this.project.renderer.transformWorld2View(p1),\n            this.project.renderer.transformWorld2View(p2),\n            this.project.renderer.transformWorld2View(p3),\n            this.project.renderer.transformWorld2View(p4),\n          ],\n          new Color(204, 204, 204),\n          2 * this.project.camera.currentScale,\n        );\n\n        if (!(edge.target instanceof ConnectPoint)) {\n          this.project.edgeRenderer.renderArrowHead(p1, verticalDirection, 15, edge.color);\n        }\n      } else {\n        // 不会出现的情况\n      }\n\n      // 没有文字的边\n      // this.project.curveRenderer.renderSolidLine(\n      //  this.project.renderer.transformWorld2View(edge.bodyLine.start),\n      //  this.project.renderer.transformWorld2View(edge.bodyLine.end),\n      //   new Color(204, 204, 204),\n      //   2 * this.project.camera.currentScale,\n      // );\n    } else {\n      // 有文字的边\n      const midPoint = edge.bodyLine.midPoint();\n      const startHalf = new Line(edge.bodyLine.start, midPoint);\n      const endHalf = new Line(midPoint, edge.bodyLine.end);\n      this.project.textRenderer.renderTextFromCenter(\n        edge.text,\n        this.project.renderer.transformWorld2View(midPoint),\n        Renderer.FONT_SIZE * this.project.camera.currentScale,\n      );\n      const edgeTextRectangle = edge.textRectangle;\n\n      this.project.curveRenderer.renderSolidLine(\n        this.project.renderer.transformWorld2View(edge.bodyLine.start),\n        this.project.renderer.transformWorld2View(edgeTextRectangle.getLineIntersectionPoint(startHalf)),\n        new Color(204, 204, 204),\n        2 * this.project.camera.currentScale,\n      );\n      this.project.curveRenderer.renderSolidLine(\n        this.project.renderer.transformWorld2View(edge.bodyLine.end),\n        this.project.renderer.transformWorld2View(edgeTextRectangle.getLineIntersectionPoint(endHalf)),\n        new Color(204, 204, 204),\n        2 * this.project.camera.currentScale,\n      );\n      // 画箭头\n      if (!(edge.target instanceof ConnectPoint)) {\n        const size = 15;\n        const direction = edge.target.collisionBox\n          .getRectangle()\n          .getCenter()\n          .subtract(edge.source.collisionBox.getRectangle().getCenter())\n          .normalize();\n        const endPoint = edge.bodyLine.end.clone();\n        this.project.edgeRenderer.renderArrowHead(endPoint, direction, size, edge.color);\n      }\n    }\n  }\n  public renderShiftingState(edge: LineEdge): void {\n    const shiftingMidPoint = edge.shiftingMidPoint;\n    // 从source.Center到shiftingMidPoint的线\n    const startLine = new Line(edge.source.collisionBox.getRectangle().center, shiftingMidPoint);\n    const endLine = new Line(shiftingMidPoint, edge.target.collisionBox.getRectangle().center);\n    const startPoint = edge.source.collisionBox.getRectangle().getLineIntersectionPoint(startLine);\n    const endPoint = edge.target.collisionBox.getRectangle().getLineIntersectionPoint(endLine);\n\n    if (edge.text.trim() === \"\") {\n      // 没有文字的边\n      this.project.curveRenderer.renderSolidLine(\n        this.project.renderer.transformWorld2View(startPoint),\n        this.project.renderer.transformWorld2View(shiftingMidPoint),\n        new Color(204, 204, 204),\n        2 * this.project.camera.currentScale,\n      );\n      this.project.curveRenderer.renderSolidLine(\n        this.project.renderer.transformWorld2View(shiftingMidPoint),\n        this.project.renderer.transformWorld2View(endPoint),\n        new Color(204, 204, 204),\n        2 * this.project.camera.currentScale,\n      );\n    } else {\n      // 有文字的边\n      this.project.textRenderer.renderTextFromCenter(\n        edge.text,\n        this.project.renderer.transformWorld2View(shiftingMidPoint),\n        Renderer.FONT_SIZE * this.project.camera.currentScale,\n      );\n      const edgeTextRectangle = edge.textRectangle;\n      const start2MidPoint = edgeTextRectangle.getLineIntersectionPoint(startLine);\n      const mid2EndPoint = edgeTextRectangle.getLineIntersectionPoint(endLine);\n      this.project.curveRenderer.renderSolidLine(\n        this.project.renderer.transformWorld2View(startPoint),\n        this.project.renderer.transformWorld2View(start2MidPoint),\n        new Color(204, 204, 204),\n        2 * this.project.camera.currentScale,\n      );\n      this.project.curveRenderer.renderSolidLine(\n        this.project.renderer.transformWorld2View(mid2EndPoint),\n        this.project.renderer.transformWorld2View(endPoint),\n        new Color(204, 204, 204),\n        2 * this.project.camera.currentScale,\n      );\n    }\n    this.renderArrowHead(\n      edge,\n      edge.target.collisionBox.getRectangle().getCenter().subtract(shiftingMidPoint).normalize(),\n      endPoint,\n    );\n  }\n  private renderArrowHead(edge: LineEdge, direction: Vector, endPoint = edge.bodyLine.end.clone()) {\n    const size = 15;\n    this.project.edgeRenderer.renderArrowHead(endPoint, direction, size, edge.color);\n  }\n\n  public renderCycleState(edge: LineEdge): void {\n    // 自环\n    this.project.shapeRenderer.renderArc(\n      this.project.renderer.transformWorld2View(edge.target.collisionBox.getRectangle().location),\n      (edge.target.collisionBox.getRectangle().size.y / 2) * this.project.camera.currentScale,\n      Math.PI / 2,\n      0,\n      new Color(204, 204, 204),\n      2 * this.project.camera.currentScale,\n    );\n    // 画箭头\n    {\n      const size = 15;\n      const direction = new Vector(1, 0).rotateDegrees(15);\n      const endPoint = edge.target.collisionBox.getRectangle().leftCenter;\n      this.project.edgeRenderer.renderArrowHead(endPoint, direction, size, edge.color);\n    }\n  }\n  public getNormalStageSvg(edge: LineEdge): React.ReactNode {\n    let lineBody: React.ReactNode = <></>;\n    let textNode: React.ReactNode = <></>;\n    const edgeColor = edge.color.equals(Color.Transparent)\n      ? this.project.stageStyleManager.currentStyle.StageObjectBorder\n      : edge.color;\n    if (edge.text.trim() === \"\") {\n      // 没有文字的边\n      lineBody = SvgUtils.line(edge.bodyLine.start, edge.bodyLine.end, edgeColor, 2);\n    } else {\n      // 有文字的边\n      const midPoint = edge.bodyLine.midPoint();\n      const startHalf = new Line(edge.bodyLine.start, midPoint);\n      const endHalf = new Line(midPoint, edge.bodyLine.end);\n      const edgeTextRectangle = edge.textRectangle;\n\n      textNode = SvgUtils.textFromCenter(edge.text, midPoint, Renderer.FONT_SIZE, edgeColor);\n      lineBody = (\n        <>\n          {SvgUtils.line(edge.bodyLine.start, edgeTextRectangle.getLineIntersectionPoint(startHalf), edgeColor, 2)}\n          {SvgUtils.line(edge.bodyLine.end, edgeTextRectangle.getLineIntersectionPoint(endHalf), edgeColor, 2)}\n        </>\n      );\n    }\n    // 加箭头\n    const arrowHead = this.project.edgeRenderer.generateArrowHeadSvg(\n      edge.bodyLine.end.clone(),\n      edge.target.collisionBox\n        .getRectangle()\n        .getCenter()\n        .subtract(edge.source.collisionBox.getRectangle().getCenter())\n        .normalize(),\n      15,\n      edgeColor,\n    );\n    return (\n      <>\n        {lineBody}\n        {textNode}\n        {arrowHead}\n      </>\n    );\n  }\n  public getCycleStageSvg(): React.ReactNode {\n    return <></>;\n  }\n  public getShiftingStageSvg(): React.ReactNode {\n    return <></>;\n  }\n\n  public renderVirtualEdge(startNode: ConnectableEntity, mouseLocation: Vector): void {\n    this.project.curveRenderer.renderGradientLine(\n      this.project.renderer.transformWorld2View(startNode.collisionBox.getRectangle().getCenter()),\n      this.project.renderer.transformWorld2View(mouseLocation),\n      new Color(255, 255, 255, 0),\n      new Color(255, 255, 255, 0.5),\n      2,\n    );\n  }\n\n  public renderVirtualConfirmedEdge(startNode: ConnectableEntity, endNode: ConnectableEntity): void {\n    this.project.curveRenderer.renderGradientLine(\n      this.project.renderer.transformWorld2View(startNode.collisionBox.getRectangle().getCenter()),\n      this.project.renderer.transformWorld2View(endNode.collisionBox.getRectangle().getCenter()),\n      new Color(0, 255, 0, 0),\n      new Color(0, 255, 0, 0.5),\n      2,\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/render/canvas2d/entityRenderer/multiTargetUndirectedEdge/MultiTargetUndirectedEdgeRenderer.tsx",
    "content": "import { ConvexHull } from \"@/core/algorithm/geometry/convexHull\";\nimport { Project, service } from \"@/core/Project\";\nimport { Renderer } from \"@/core/render/canvas2d/renderer\";\nimport { MultiTargetUndirectedEdge } from \"@/core/stage/stageObject/association/MutiTargetUndirectedEdge\";\nimport { Color, Vector } from \"@graphif/data-structures\";\nimport { Line } from \"@graphif/shapes\";\n\n@service(\"multiTargetUndirectedEdgeRenderer\")\nexport class MultiTargetUndirectedEdgeRenderer {\n  constructor(private readonly project: Project) {}\n\n  render(edge: MultiTargetUndirectedEdge) {\n    if (edge.isSelected) {\n      this.project.collisionBoxRenderer.render(\n        edge.collisionBox,\n        this.project.stageStyleManager.currentStyle.CollideBoxSelected,\n      );\n    }\n    if (edge.associationList.length < 2) {\n      // 特殊情况，出问题了属于是\n      if (edge.associationList.length === 1) {\n        // 画一个圆环\n        const node = edge.associationList[0];\n        const center = node.collisionBox.getRectangle().center;\n        this.project.shapeRenderer.renderCircle(\n          this.project.renderer.transformWorld2View(center),\n          100 * this.project.camera.currentScale,\n          Color.Transparent,\n          this.project.stageStyleManager.currentStyle.StageObjectBorder,\n          2 * this.project.camera.currentScale,\n        );\n      }\n      if (edge.associationList.length === 0) {\n        // 在0 0 位置画圆\n        this.project.shapeRenderer.renderCircle(\n          this.project.renderer.transformWorld2View(Vector.getZero()),\n          100 * this.project.camera.currentScale,\n          Color.Transparent,\n          this.project.stageStyleManager.currentStyle.StageObjectBorder,\n          2 * this.project.camera.currentScale,\n        );\n      }\n      return;\n    }\n\n    // 正常情况, target >= 2\n    const centerLocation = edge.centerLocation;\n    const edgeColor = edge.color.equals(Color.Transparent)\n      ? this.project.stageStyleManager.currentStyle.StageObjectBorder\n      : edge.color;\n    // 画文字\n    if (edge.text !== \"\") {\n      // 画文字\n      this.project.textRenderer.renderMultiLineTextFromCenter(\n        edge.text,\n        this.project.renderer.transformWorld2View(centerLocation),\n        Renderer.FONT_SIZE * this.project.camera.currentScale,\n        Infinity,\n        edgeColor,\n      );\n    }\n    if (edge.renderType === \"line\") {\n      // if (edge.associationList.length === 2) {\n      //   if (edge.centerRate.nearlyEqual(new Vector(0.5, 0.5), 0.3)) {\n      //     this.renderLineShape(edge, edgeColor, centerLocation);\n      //   } else {\n      //     this.renderCurveShape(edge, edgeColor, centerLocation);\n      //   }\n      // } else {\n      //   this.renderLineShape(edge, edgeColor, centerLocation);\n      // }\n      this.renderLineShape(edge, edgeColor, centerLocation);\n    } else if (edge.renderType === \"convex\") {\n      this.renderConvexShape(edge, edgeColor);\n    } else if (edge.renderType === \"circle\") {\n      this.renderCircle(edge, edgeColor);\n    }\n  }\n\n  private renderLineShape(edge: MultiTargetUndirectedEdge, edgeColor: Color, centerLocation: Vector): void {\n    // 画每一条线\n    // node[i] ----> center\n    for (let i = 0; i < edge.associationList.length; i++) {\n      const node = edge.associationList[i];\n      const nodeRectangle = node.collisionBox.getRectangle();\n      const targetLocation = nodeRectangle.getInnerLocationByRateVector(edge.rectRates[i]);\n      const line = new Line(centerLocation, targetLocation);\n      const targetPoint = nodeRectangle.getLineIntersectionPoint(line);\n      let toCenterPoint = centerLocation;\n      if (edge.text !== \"\") {\n        const textRectangle = edge.textRectangle;\n        toCenterPoint = textRectangle.getLineIntersectionPoint(new Line(centerLocation, targetLocation));\n      }\n      this.project.curveRenderer.renderSolidLine(\n        this.project.renderer.transformWorld2View(targetPoint),\n        this.project.renderer.transformWorld2View(toCenterPoint),\n        edgeColor,\n        2 * this.project.camera.currentScale,\n      );\n      // 画箭头\n      if (edge.arrow === \"inner\") {\n        //\n        this.project.edgeRenderer.renderArrowHead(\n          // Renderer.transformWorld2View(toCenterPoint),\n          toCenterPoint,\n          toCenterPoint.subtract(targetPoint).normalize(),\n          15,\n          edgeColor,\n        );\n      } else if (edge.arrow === \"outer\") {\n        //\n        this.project.edgeRenderer.renderArrowHead(\n          // Renderer.transformWorld2View(targetPoint),\n          targetPoint,\n          targetPoint.subtract(toCenterPoint).normalize(),\n          15,\n          edgeColor,\n        );\n      }\n    }\n  }\n\n  private renderConvexShape(edge: MultiTargetUndirectedEdge, edgeColor: Color): void {\n    // 凸包渲染\n    let convexPoints: Vector[] = [];\n    edge.associationList.map((node) => {\n      const nodeRectangle = node.collisionBox.getRectangle().expandFromCenter(edge.padding);\n      convexPoints.push(nodeRectangle.leftTop);\n      convexPoints.push(nodeRectangle.rightTop);\n      convexPoints.push(nodeRectangle.rightBottom);\n      convexPoints.push(nodeRectangle.leftBottom);\n    });\n    if (edge.text !== \"\") {\n      const textRectangle = edge.textRectangle.expandFromCenter(edge.padding);\n      convexPoints.push(textRectangle.leftTop);\n      convexPoints.push(textRectangle.rightTop);\n      convexPoints.push(textRectangle.rightBottom);\n      convexPoints.push(textRectangle.leftBottom);\n    }\n    convexPoints = ConvexHull.computeConvexHull(convexPoints);\n    // 保证首尾相接\n    convexPoints.push(convexPoints[0]);\n    this.project.curveRenderer.renderSolidLineMultiple(\n      convexPoints.map((point) => this.project.renderer.transformWorld2View(point)),\n      edgeColor.toNewAlpha(0.5),\n      // 当视野缩放足够小时，边框固定粗细\n      this.project.camera.currentScale <= 0.065 ? 8 : 8 * this.project.camera.currentScale,\n    );\n  }\n\n  private renderCircle(edge: MultiTargetUndirectedEdge, edgeColor: Color): void {\n    // 圆形渲染 - 使用最小的圆形套住所有实体\n    if (edge.associationList.length === 0) {\n      return;\n    }\n\n    // 计算包围所有实体的最小圆\n    const allPoints: Vector[] = [];\n    edge.associationList.map((node) => {\n      const nodeRectangle = node.collisionBox.getRectangle().expandFromCenter(edge.padding);\n      allPoints.push(nodeRectangle.leftTop);\n      allPoints.push(nodeRectangle.rightTop);\n      allPoints.push(nodeRectangle.rightBottom);\n      allPoints.push(nodeRectangle.leftBottom);\n    });\n\n    if (edge.text !== \"\") {\n      const textRectangle = edge.textRectangle.expandFromCenter(edge.padding);\n      allPoints.push(textRectangle.leftTop);\n      allPoints.push(textRectangle.rightTop);\n      allPoints.push(textRectangle.rightBottom);\n      allPoints.push(textRectangle.leftBottom);\n    }\n\n    // 计算圆心（使用所有点的中心点）\n    const center = Vector.averageMultiple(allPoints);\n\n    // 计算最大距离作为半径\n    let maxDistance = 0;\n    for (const point of allPoints) {\n      const distance = center.distance(point);\n      if (distance > maxDistance) {\n        maxDistance = distance;\n      }\n    }\n\n    // 绘制圆形\n    this.project.shapeRenderer.renderCircle(\n      this.project.renderer.transformWorld2View(center),\n      maxDistance * this.project.camera.currentScale,\n      Color.Transparent,\n      edgeColor.toNewAlpha(0.5),\n      // 当视野缩放足够小时，边框固定粗细\n      this.project.camera.currentScale <= 0.065 ? 8 : 8 * this.project.camera.currentScale,\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/render/canvas2d/entityRenderer/section/SectionRenderer.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { Renderer } from \"@/core/render/canvas2d/renderer\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { getTextSize } from \"@/utils/font\";\nimport { Color, colorInvert, mixColors, Vector } from \"@graphif/data-structures\";\nimport { CubicBezierCurve, Rectangle } from \"@graphif/shapes\";\n\n@service(\"sectionRenderer\")\nexport class SectionRenderer {\n  constructor(private readonly project: Project) {}\n\n  /** 画折叠状态 */\n  private renderCollapsed(section: Section) {\n    // 折叠状态\n    const renderRectangle = new Rectangle(\n      this.project.renderer.transformWorld2View(section.rectangle.location),\n      section.rectangle.size.multiply(this.project.camera.currentScale),\n    );\n    this.project.shapeRenderer.renderRect(\n      renderRectangle,\n      section.color,\n      mixColors(this.project.stageStyleManager.currentStyle.StageObjectBorder, Color.Black, 0.5),\n      2 * this.project.camera.currentScale,\n      Renderer.NODE_ROUNDED_RADIUS * this.project.camera.currentScale,\n    );\n    // 外框\n    this.project.shapeRenderer.renderRect(\n      new Rectangle(\n        this.project.renderer.transformWorld2View(section.rectangle.location.subtract(Vector.same(4))),\n        section.rectangle.size.add(Vector.same(4 * 2)).multiply(this.project.camera.currentScale),\n      ),\n      section.color,\n      this.project.stageStyleManager.currentStyle.StageObjectBorder,\n      2 * this.project.camera.currentScale,\n      Renderer.NODE_ROUNDED_RADIUS * 1.5 * this.project.camera.currentScale,\n    );\n    if (!section.isEditingTitle) {\n      this.project.textRenderer.renderText(\n        section.text,\n        this.project.renderer.transformWorld2View(section.rectangle.location.add(Vector.same(Renderer.NODE_PADDING))),\n        Renderer.FONT_SIZE * this.project.camera.currentScale,\n        section.color.a === 1\n          ? colorInvert(section.color)\n          : colorInvert(this.project.stageStyleManager.currentStyle.Background),\n      );\n    }\n  }\n\n  // 非折叠状态\n  private renderNoCollapse(section: Section) {\n    let borderWidth = 2 * this.project.camera.currentScale;\n    if (Settings.sectionBitTitleRenderType !== \"none\") {\n      borderWidth = this.project.camera.currentScale > 0.065 ? 2 * this.project.camera.currentScale : 2;\n    }\n    // 注意：这里只能画边框\n    this.project.shapeRenderer.renderRect(\n      new Rectangle(\n        this.project.renderer.transformWorld2View(section.rectangle.location),\n        section.rectangle.size.multiply(this.project.camera.currentScale),\n      ),\n      Color.Transparent,\n      this.project.stageStyleManager.currentStyle.StageObjectBorder,\n      borderWidth,\n      Renderer.NODE_ROUNDED_RADIUS * this.project.camera.currentScale,\n    );\n\n    if (this.project.camera.currentScale > 0.065 && !section.isEditingTitle) {\n      // 正常显示标题\n      this.project.textRenderer.renderText(\n        section.text,\n        this.project.renderer.transformWorld2View(section.rectangle.location.add(Vector.same(Renderer.NODE_PADDING))),\n        Renderer.FONT_SIZE * this.project.camera.currentScale,\n        section.color.a === 1\n          ? colorInvert(section.color)\n          : colorInvert(this.project.stageStyleManager.currentStyle.Background),\n      );\n    }\n  }\n\n  renderBackgroundColor(section: Section) {\n    if (Settings.sectionBackgroundFillMode === \"titleOnly\") {\n      // 只填充顶部标题条（不透明）\n      const color = section.color.clone();\n      const titleBarHeight = (Renderer.FONT_SIZE + Renderer.NODE_PADDING * 2) * this.project.camera.currentScale;\n      const titleBarRect = new Rectangle(\n        this.project.renderer.transformWorld2View(section.rectangle.location),\n        new Vector(section.rectangle.size.x * this.project.camera.currentScale, titleBarHeight),\n      );\n      this.project.shapeRenderer.renderRect(\n        titleBarRect,\n        color,\n        Color.Transparent,\n        0,\n        Renderer.NODE_ROUNDED_RADIUS * this.project.camera.currentScale,\n      );\n    } else {\n      // 完整填充（默认方式，有透明度化和遮罩顺序判断）\n      const color = section.color.clone();\n      color.a = Math.min(color.a, 0.5);\n      this.project.shapeRenderer.renderRect(\n        new Rectangle(\n          this.project.renderer.transformWorld2View(section.rectangle.location),\n          section.rectangle.size.multiply(this.project.camera.currentScale),\n        ),\n        color,\n        Color.Transparent,\n        0,\n        Renderer.NODE_ROUNDED_RADIUS * this.project.camera.currentScale,\n      );\n    }\n  }\n\n  /**\n   * 渲染覆盖了的大标题\n   * @param section\n   * @returns\n   */\n  renderBigCoveredTitle(section: Section) {\n    // TODO: 性能有待优化\n    // 计算视野范围矩形\n    const viewRect = this.project.renderer.getCoverWorldRectangle();\n    // 计算section框的最长边\n    const sectionMaxSide = Math.max(section.rectangle.size.x, section.rectangle.size.y);\n    // 计算视野范围矩形的最长边\n    const viewMaxSide = Math.max(viewRect.size.x, viewRect.size.y);\n    // 判断是否需要渲染大标题形态\n    if (\n      sectionMaxSide >= viewMaxSide * Settings.sectionBigTitleThresholdRatio ||\n      this.project.camera.currentScale > Settings.sectionBigTitleCameraScaleThreshold\n    ) {\n      return;\n    }\n    this.project.shapeRenderer.renderRect(\n      new Rectangle(\n        this.project.renderer.transformWorld2View(section.rectangle.location),\n        section.rectangle.size.multiply(this.project.camera.currentScale),\n      ),\n      section.color.a === 0\n        ? this.project.stageStyleManager.currentStyle.Background.toNewAlpha(Settings.sectionBigTitleOpacity)\n        : section.color.toNewAlpha(Settings.sectionBigTitleOpacity),\n      this.project.stageStyleManager.currentStyle.StageObjectBorder,\n      2 * this.project.camera.currentScale,\n    );\n    // 缩放过小了，显示巨大化文字\n    this.project.textRenderer.renderTextInRectangle(\n      section.text,\n      new Rectangle(\n        this.project.renderer.transformWorld2View(section.rectangle.location),\n        section.rectangle.size.multiply(this.project.camera.currentScale),\n      ),\n      section.color.a === 1\n        ? colorInvert(section.color)\n        : colorInvert(this.project.stageStyleManager.currentStyle.Background),\n    );\n  }\n\n  /**\n   * 渲染框的标题，以Figma白板的方式\n   * @param section\n   * @returns\n   */\n  renderTopTitle(section: Section) {\n    // TODO: 性能有待优化\n    // 计算视野范围矩形\n    const viewRect = this.project.renderer.getCoverWorldRectangle();\n    // 计算section框的最长边\n    const sectionMaxSide = Math.max(section.rectangle.size.x, section.rectangle.size.y);\n    // 计算视野范围矩形的最长边\n    const viewMaxSide = Math.max(viewRect.size.x, viewRect.size.y);\n    // 判断是否需要渲染大标题形态\n    if (\n      sectionMaxSide >= viewMaxSide * Settings.sectionBigTitleThresholdRatio ||\n      this.project.camera.currentScale > Settings.sectionBigTitleCameraScaleThreshold\n    ) {\n      return;\n    }\n    const fontSize = 20 * (0.5 * this.project.camera.currentScale + 0.5);\n    const leftTopLocation = section.collisionBox.getRectangle().leftTop;\n    const leftTopViewLocation = this.project.renderer.transformWorld2View(leftTopLocation);\n    const leftTopFontViewLocation = leftTopViewLocation.subtract(new Vector(0, fontSize));\n    const bgColor =\n      section.color.a === 0\n        ? this.project.stageStyleManager.currentStyle.Background.toNewAlpha(Settings.sectionBigTitleOpacity)\n        : section.color.toNewAlpha(Settings.sectionBigTitleOpacity);\n\n    const textColor =\n      section.color.a === 1\n        ? colorInvert(section.color)\n        : colorInvert(this.project.stageStyleManager.currentStyle.Background);\n    const textSize = getTextSize(section.text, fontSize);\n    this.project.shapeRenderer.renderRect(\n      new Rectangle(leftTopFontViewLocation, textSize).expandFromCenter(2),\n      bgColor,\n      this.project.stageStyleManager.currentStyle.StageObjectBorder,\n      2 * this.project.camera.currentScale,\n      2,\n    );\n\n    this.project.textRenderer.renderText(section.text, leftTopFontViewLocation, fontSize, textColor);\n  }\n\n  // private getFontSizeBySectionSize(section: Section): Vector {\n  //   // 使用getTextSize获取准确的文本尺寸\n  //   const baseFontSize = 100;\n  //   const measuredSize = getTextSize(section.text, baseFontSize);\n  //   const ratio = measuredSize.x / measuredSize.y;\n  //   const sectionRatio = section.rectangle.size.x / section.rectangle.size.y;\n\n  //   // 计算最大可用字体高度\n  //   let fontHeight;\n  //   const paddingRatio = 0.9; // 增加边距比例，确保文字不会贴边\n  //   if (sectionRatio < ratio) {\n  //     // 宽度受限\n  //     fontHeight = (section.rectangle.size.x / ratio) * paddingRatio;\n  //   } else {\n  //     // 高度受限\n  //     fontHeight = section.rectangle.size.y * paddingRatio;\n  //   }\n\n  //   // 确保字体大小合理\n  //   const minFontSize = 8;\n  //   const maxFontSize = Math.max(section.rectangle.size.x, section.rectangle.size.y) * 0.8; // 限制最大字体\n  //   fontHeight = Math.max(minFontSize, Math.min(fontHeight, maxFontSize));\n\n  //   return new Vector(ratio * fontHeight, fontHeight);\n  // }\n\n  render(section: Section): void {\n    if (section.isHiddenBySectionCollapse) {\n      return;\n    }\n\n    if (section.isCollapsed) {\n      // 折叠状态\n      this.renderCollapsed(section);\n    } else {\n      // 非折叠状态\n      this.renderNoCollapse(section);\n    }\n\n    if (!section.isSelected && this.project.references.sections[section.text]) {\n      this.project.referenceBlockRenderer.renderSourceSectionBorder(\n        section,\n        this.project.references.sections[section.text].length,\n      );\n    }\n\n    if (section.isSelected) {\n      // 在外面增加一个框\n      this.project.collisionBoxRenderer.render(\n        section.collisionBox,\n        this.project.stageStyleManager.currentStyle.CollideBoxSelected,\n      );\n      // 锁定且选中时：在碰撞箱矩形右上角画半透明绿色三角形（右上角点、右边上四分之一点、上边右四分之一点）\n      if (section.locked) {\n        const rect = section.collisionBox.getRectangle();\n        const p1 = rect.rightTop; // 最右上角\n        const p2 = new Vector(rect.right, rect.top + rect.size.y / 4); // 右边上四分之一\n        const p3 = new Vector(rect.right - rect.size.x / 4, rect.top); // 上边右四分之一\n        const pointsView = [p1, p2, p3].map((p) => this.project.renderer.transformWorld2View(p));\n        const fillColor = this.project.stageStyleManager.currentStyle.CollideBoxSelected.toNewAlpha(0.35);\n        this.project.shapeRenderer.renderPolygonAndFill(pointsView, fillColor, fillColor, 0, \"round\");\n      }\n    }\n    // debug: 绿色虚线 观察父子关系\n    if (Settings.showDebug) {\n      for (const child of section.children) {\n        const start = section.rectangle.topCenter;\n        const end = child.collisionBox.getRectangle().leftTop;\n        const DIS = 100;\n        // const rate = (end.y - start.y) / section.rectangle.height;\n        this.project.curveRenderer.renderGradientBezierCurve(\n          new CubicBezierCurve(\n            this.project.renderer.transformWorld2View(start),\n            this.project.renderer.transformWorld2View(start.add(new Vector(0, -DIS))),\n            this.project.renderer.transformWorld2View(end.add(new Vector(0, -DIS))),\n            this.project.renderer.transformWorld2View(end),\n          ),\n          Color.Green,\n          Color.Red,\n          2 * this.project.camera.currentScale,\n        );\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/render/canvas2d/entityRenderer/svgNode/SvgNodeRenderer.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { SvgNode } from \"@/core/stage/stageObject/entity/SvgNode\";\nimport { Rectangle } from \"@graphif/shapes\";\n\n/**\n * 渲染SVG节点\n */\n@service(\"svgNodeRenderer\")\nexport class SvgNodeRenderer {\n  constructor(private readonly project: Project) {}\n\n  // 渲染SVG节点\n  render(svgNode: SvgNode) {\n    if (svgNode.isSelected) {\n      // 在外面增加一个框\n      this.project.collisionBoxRenderer.render(\n        svgNode.collisionBox,\n        this.project.stageStyleManager.currentStyle.CollideBoxSelected,\n      );\n\n      // 渲染右下角缩放控制点\n      const resizeHandleRect = svgNode.getResizeHandleRect();\n      const viewResizeHandleRect = new Rectangle(\n        this.project.renderer.transformWorld2View(resizeHandleRect.location),\n        resizeHandleRect.size.multiply(this.project.camera.currentScale),\n      );\n      this.project.shapeRenderer.renderRect(\n        viewResizeHandleRect,\n        this.project.stageStyleManager.currentStyle.CollideBoxSelected,\n        this.project.stageStyleManager.currentStyle.StageObjectBorder,\n        2 * this.project.camera.currentScale,\n        8 * this.project.camera.currentScale,\n      );\n      // 渲染箭头指示\n      this.project.shapeRenderer.renderResizeArrow(\n        viewResizeHandleRect,\n        this.project.stageStyleManager.currentStyle.StageObjectBorder,\n        2 * this.project.camera.currentScale,\n      );\n    }\n    this.project.imageRenderer.renderImageElement(\n      svgNode.image,\n      this.project.renderer.transformWorld2View(svgNode.collisionBox.getRectangle().location),\n      svgNode.scale,\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/render/canvas2d/entityRenderer/textNode/TextNodeRenderer.tsx",
    "content": "import { Random } from \"@/core/algorithm/random\";\nimport { Project, service } from \"@/core/Project\";\nimport { Renderer } from \"@/core/render/canvas2d/renderer\";\nimport {\n  getLogicNodeRenderName,\n  LogicNodeNameEnum,\n  LogicNodeNameToRenderNameMap,\n} from \"@/core/service/dataGenerateService/autoComputeEngine/logicNodeNameEnum\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { Color, colorInvert, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\n\n@service(\"textNodeRenderer\")\nexport class TextNodeRenderer {\n  // 初始化时监听设置变化\n  constructor(private readonly project: Project) {}\n\n  renderTextNode(node: TextNode) {\n    // 检查是否是逻辑节点\n    const isLogicNode = this.project.autoComputeUtils.isNameIsLogicNode(node.text);\n\n    // 节点身体矩形\n    let fillColor = node.color;\n    let renderedFontSize = node.getFontSize() * this.project.camera.currentScale;\n    if (renderedFontSize < Settings.ignoreTextNodeTextRenderLessThanFontSize && fillColor.a === 0) {\n      const color = this.project.stageStyleManager.currentStyle.StageObjectBorder.clone();\n      color.a = 0.2;\n      fillColor = color;\n    }\n    const borderColor = Settings.showTextNodeBorder\n      ? this.project.stageStyleManager.currentStyle.StageObjectBorder\n      : Color.Transparent;\n\n    // 渲染节点背景（逻辑节点和非逻辑节点都使用相同的背景）\n    this.project.shapeRenderer.renderRect(\n      new Rectangle(\n        this.project.renderer.transformWorld2View(node.rectangle.location),\n        node.rectangle.size.multiply(this.project.camera.currentScale),\n      ),\n      fillColor,\n      borderColor,\n      2 * this.project.camera.currentScale,\n      Renderer.NODE_ROUNDED_RADIUS * this.project.camera.currentScale,\n    );\n\n    // 如果是逻辑节点，在内部边缘绘制标记\n    if (isLogicNode) {\n      this.renderLogicNodeWarningTrap(node);\n    }\n\n    // 视野缩放过小就不渲染内部文字\n    renderedFontSize = node.getFontSize() * this.project.camera.currentScale;\n    if (renderedFontSize > Settings.ignoreTextNodeTextRenderLessThanFontSize) {\n      this.renderTextNodeTextLayer(node);\n    }\n\n    if (node.isSelected) {\n      // 在外面增加一个框\n      this.project.collisionBoxRenderer.render(\n        node.collisionBox,\n        this.project.stageStyleManager.currentStyle.CollideBoxSelected,\n      );\n      // 改变大小的拖拽\n      if (node.sizeAdjust === \"manual\") {\n        const resizeHandleRect = node.getResizeHandleRect();\n        const viewResizeHandleRect = this.project.renderer.transformWorld2View(resizeHandleRect);\n        this.project.shapeRenderer.renderRect(\n          viewResizeHandleRect,\n          this.project.stageStyleManager.currentStyle.CollideBoxSelected,\n          this.project.stageStyleManager.currentStyle.StageObjectBorder,\n          2 * this.project.camera.currentScale,\n          8 * this.project.camera.currentScale,\n        );\n        // 渲染箭头指示\n        this.project.shapeRenderer.renderResizeArrow(\n          viewResizeHandleRect,\n          this.project.stageStyleManager.currentStyle.StageObjectBorder,\n          2 * this.project.camera.currentScale,\n        );\n      }\n      // 渲染键盘树形模式方向提示（仅在键盘操作模式下且非编辑状态时显示）\n      if (this.project.keyboardOnlyEngine.isOpenning() && !node.isEditing && Settings.showTreeDirectionHint) {\n        this.renderKeyboardTreeHint(node);\n      }\n    }\n    if (node.isAiGenerating) {\n      const borderColor = this.project.stageStyleManager.currentStyle.CollideBoxSelected.clone();\n      borderColor.a = Random.randomFloat(0.2, 1);\n      // 在外面增加一个框\n      this.project.shapeRenderer.renderRect(\n        new Rectangle(\n          this.project.renderer.transformWorld2View(node.rectangle.location),\n          node.rectangle.size.multiply(this.project.camera.currentScale),\n        ),\n        node.color,\n        borderColor,\n        Random.randomFloat(1, 10) * this.project.camera.currentScale,\n        Renderer.NODE_ROUNDED_RADIUS * this.project.camera.currentScale,\n      );\n    }\n    // 用户不建议放大标签，所以这里注释掉了，但又有用户觉得这个也挺好，所以加个设置项\n    if (Settings.enableTagTextNodesBigDisplay) {\n      // TODO：标签待做，这里先注释掉\n      // if (this.project.stageManager.TagOptions.getTagUUIDs().includes(node.uuid)) {\n      //   if (this.project.camera.currentScale < 0.25) {\n      //     const scaleRate = 5;\n      //     const rect = node.collisionBox.getRectangle();\n      //     const rectBgc =\n      //       node.color.a === 0 ? this.project.stageStyleManager.currentStyle.Background.clone() : node.color.clone();\n      //     rectBgc.a = 0.5;\n      //     this.project.shapeRenderer.renderRectFromCenter(\n      //       this.project.renderer.transformWorld2View(rect.center),\n      //       rect.width * scaleRate * this.project.camera.currentScale,\n      //       rect.height * scaleRate * this.project.camera.currentScale,\n      //       rectBgc,\n      //       this.project.stageStyleManager.currentStyle.StageObjectBorder,\n      //       2 * this.project.camera.currentScale,\n      //       Renderer.NODE_ROUNDED_RADIUS * scaleRate * this.project.camera.currentScale,\n      //     );\n      //     this.project.textRenderer.renderTextFromCenter(\n      //       node.text,\n      //       this.project.renderer.transformWorld2View(rect.center),\n      //       Renderer.FONT_SIZE * scaleRate * this.project.camera.currentScale,\n      //       this.project.stageStyleManager.currentStyle.StageObjectBorder,\n      //     );\n      //   }\n      // }\n    }\n  }\n\n  /**\n   * 渲染键盘树形模式下的方向提示：\n   * - 当前预测生长方向：显示 \"tab→/←/↑/↓\"（绿色高亮）\n   * - 其余三个方向：显示对应的方向切换快捷键（\"W W\"/\"S S\"/\"A A\"/\"D D\"），颜色较淡\n   */\n  private renderKeyboardTreeHint(node: TextNode): void {\n    const direction = this.project.keyboardOnlyTreeEngine.getNodePreDirection(node);\n    const rect = node.collisionBox.getRectangle();\n    const GAP = 30;\n\n    const tabColor = this.project.stageStyleManager.currentStyle.CollideBoxSelected.clone();\n    tabColor.a = 0.8;\n    const hintColor = this.project.stageStyleManager.currentStyle.StageObjectBorder.clone();\n    hintColor.a = 0.45;\n\n    const allDirections: Array<{\n      dir: \"right\" | \"left\" | \"up\" | \"down\";\n      pos: Vector;\n      key: string;\n      arrow: string;\n    }> = [\n      { dir: \"right\", pos: rect.rightCenter.add(new Vector(GAP, 0)), key: \"D D\", arrow: \"→\" },\n      { dir: \"left\", pos: rect.leftCenter.add(new Vector(-GAP, 0)), key: \"A A\", arrow: \"←\" },\n      { dir: \"up\", pos: rect.topCenter.add(new Vector(0, -GAP)), key: \"W W\", arrow: \"↑\" },\n      { dir: \"down\", pos: rect.bottomCenter.add(new Vector(0, GAP)), key: \"S S\", arrow: \"↓\" },\n    ];\n\n    for (const { dir, pos, key, arrow } of allDirections) {\n      const viewPos = this.project.renderer.transformWorld2View(pos);\n      if (dir === direction) {\n        // 当前预测方向：显示 tab + 箭头，绿色\n        this.project.textRenderer.renderTextFromCenter(\n          `tab${arrow}`,\n          viewPos,\n          18 * this.project.camera.currentScale,\n          tabColor,\n        );\n      } else {\n        // 其他方向：显示快捷键，半透明淡色\n        this.project.textRenderer.renderTextFromCenter(key, viewPos, 13 * this.project.camera.currentScale, hintColor);\n      }\n    }\n\n    // 反斜杠（\\）广度生长：在父节点方向确定的新兄弟节点预测位置画一个虚框\n    // onBroadGenerateNode 创建的是父节点的新子节点（当前选中节点的兄弟）\n    // 父节点方向 left/right → 新兄弟在当前节点下方\n    // 父节点方向 up/down   → 新兄弟在当前节点右侧\n    const parents = this.project.graphMethods.nodeParentArray(node);\n    if (parents.length === 1) {\n      const parentNode = parents[0];\n      const parentDirection = this.project.keyboardOnlyTreeEngine.getNodePreDirection(parentNode);\n      const SIBLING_GAP = 10;\n      let previewLocation: Vector;\n      if (parentDirection === \"right\" || parentDirection === \"left\") {\n        previewLocation = rect.leftBottom.add(new Vector(0, SIBLING_GAP));\n      } else {\n        previewLocation = rect.rightTop.add(new Vector(SIBLING_GAP, 0));\n      }\n      const previewCenter = previewLocation.add(new Vector(rect.width / 2, rect.height / 2));\n      this.project.textRenderer.renderTextFromCenter(\n        \"backslash \\\\\",\n        this.project.renderer.transformWorld2View(previewCenter),\n        10 * this.project.camera.currentScale,\n        hintColor,\n      );\n    }\n  }\n\n  /**\n   * 为逻辑节点在内部边缘绘制「」标记\n   */\n  private renderLogicNodeWarningTrap(node: TextNode) {\n    const scale = this.project.camera.currentScale;\n    const nodeViewRect = new Rectangle(\n      this.project.renderer.transformWorld2View(node.rectangle.location),\n      node.rectangle.size.multiply(scale),\n    );\n\n    // 使用样式管理器中的边框颜色\n    const markerColor = this.project.stageStyleManager.currentStyle.effects.successShadow.toNewAlpha(0.5);\n    const lineWidth = 6 * scale;\n\n    // 计算内边缘的位置（距离边界有一定间距）\n    const padding = 10 * scale;\n    const innerLeft = nodeViewRect.left + padding;\n    const innerRight = nodeViewRect.right - padding;\n    const innerTop = nodeViewRect.top + padding;\n    const innerBottom = nodeViewRect.bottom - padding;\n    const middleX = (innerLeft + innerRight) / 2;\n\n    // 左侧标记「\n    // |\n    this.project.curveRenderer.renderSolidLine(\n      new Vector(innerLeft, innerTop),\n      new Vector(innerLeft, innerBottom),\n      markerColor,\n      lineWidth,\n    );\n    // 绘制左侧横线\n    this.project.curveRenderer.renderSolidLine(\n      new Vector(innerLeft, innerTop),\n      new Vector(middleX, innerTop),\n      markerColor,\n      lineWidth,\n    );\n\n    // 右侧标记」\n    // |\n    this.project.curveRenderer.renderSolidLine(\n      new Vector(innerRight, innerTop),\n      new Vector(innerRight, innerBottom),\n      markerColor,\n      lineWidth,\n    );\n    // 绘制右侧横线\n    this.project.curveRenderer.renderSolidLine(\n      new Vector(innerRight, innerBottom),\n      new Vector(middleX, innerBottom),\n      markerColor,\n      lineWidth,\n    );\n  }\n\n  /**\n   * 画节点文字层信息\n   * @param node\n   */\n  private renderTextNodeTextLayer(node: TextNode) {\n    // 编辑状态\n    if (node.isEditing) {\n      // 编辑状态下，显示一些提示信息\n      // this.project.textRenderer.renderText(\n      //   \"Esc 或 Ctrl+Enter 退出编辑状态\",\n      //   Renderer.transformWorld2View(\n      //     node.rectangle.location.add(new Vector(0, -25)),\n      //   ),\n      //   20 * Camera.currentScale,\n      //   this.project.stageStyleManager.currentStyle.GridHeavyColor,\n      // );\n      return;\n    }\n\n    const fontSize = node.getFontSize() * this.project.camera.currentScale;\n\n    if (node.text === undefined) {\n      this.project.textRenderer.renderTextFromCenter(\n        \"undefined\",\n        this.project.renderer.transformWorld2View(node.rectangle.center),\n        fontSize,\n        node.color.a === 1\n          ? colorInvert(node.color)\n          : colorInvert(this.project.stageStyleManager.currentStyle.Background),\n      );\n    } else if (this.project.autoComputeUtils.isNameIsLogicNode(node.text)) {\n      // 检查下是不是逻辑节点\n      let isFindLogicName = false;\n      for (const key of Object.keys(LogicNodeNameToRenderNameMap)) {\n        if (node.text === key) {\n          isFindLogicName = true;\n          const logicNodeName = key as LogicNodeNameEnum;\n          this.project.textRenderer.renderTextFromCenter(\n            getLogicNodeRenderName(logicNodeName),\n            this.project.renderer.transformWorld2View(node.rectangle.center),\n            fontSize,\n            node.color.a === 1\n              ? colorInvert(node.color)\n              : colorInvert(this.project.stageStyleManager.currentStyle.Background),\n          );\n        }\n      }\n      if (!isFindLogicName) {\n        // 未知的逻辑节点，可能是版本过低\n        this.project.textRenderer.renderTextFromCenter(\n          node.text,\n          this.project.renderer.transformWorld2View(node.rectangle.center),\n          fontSize,\n          node.color.a === 1\n            ? colorInvert(node.color)\n            : colorInvert(this.project.stageStyleManager.currentStyle.Background),\n        );\n        this.project.shapeRenderer.renderRect(\n          new Rectangle(\n            this.project.renderer.transformWorld2View(\n              node.rectangle.location.add(new Vector(Random.randomInt(-5, 5), Random.randomInt(-5, 5))),\n            ),\n            node.rectangle.size.multiply(this.project.camera.currentScale),\n          ),\n          node.color,\n          new Color(255, 0, 0, 0.5),\n          Random.randomFloat(1, 10) * this.project.camera.currentScale,\n          Renderer.NODE_ROUNDED_RADIUS * this.project.camera.currentScale,\n        );\n      }\n    } else {\n      this.project.textRenderer.renderMultiLineText(\n        node.text,\n        this.project.renderer.transformWorld2View(\n          node.rectangle.location.add(Vector.same(Renderer.NODE_PADDING)).add(new Vector(0, node.getFontSize() / 4)),\n        ),\n        fontSize,\n        // Infinity,\n        node.sizeAdjust === \"manual\"\n          ? (node.rectangle.size.x - Renderer.NODE_PADDING * 2) * this.project.camera.currentScale\n          : Infinity,\n        node.color.a === 1\n          ? colorInvert(node.color)\n          : colorInvert(this.project.stageStyleManager.currentStyle.Background),\n        1.5,\n      );\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/render/canvas2d/entityRenderer/urlNode/urlNodeRenderer.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { Renderer } from \"@/core/render/canvas2d/renderer\";\nimport { MouseLocation } from \"@/core/service/controlService/MouseLocation\";\nimport { UrlNode } from \"@/core/stage/stageObject/entity/UrlNode\";\nimport { colorInvert, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\n\n@service(\"urlNodeRenderer\")\nexport class UrlNodeRenderer {\n  constructor(private readonly project: Project) {}\n\n  render(urlNode: UrlNode): void {\n    if (urlNode.isSelected) {\n      // 在外面增加一个框\n      this.project.collisionBoxRenderer.render(\n        urlNode.collisionBox,\n        this.project.stageStyleManager.currentStyle.CollideBoxSelected,\n      );\n    }\n    // 节点身体矩形\n    this.project.shapeRenderer.renderRect(\n      new Rectangle(\n        this.project.renderer.transformWorld2View(urlNode.rectangle.location),\n        urlNode.rectangle.size.multiply(this.project.camera.currentScale),\n      ),\n      urlNode.color,\n      this.project.stageStyleManager.currentStyle.StageObjectBorder,\n      2 * this.project.camera.currentScale,\n      Renderer.NODE_ROUNDED_RADIUS * this.project.camera.currentScale,\n    );\n    // 绘制标题\n    if (!urlNode.isEditingTitle) {\n      this.project.textRenderer.renderText(\n        urlNode.title,\n        this.project.renderer.transformWorld2View(urlNode.rectangle.location.add(Vector.same(Renderer.NODE_PADDING))),\n        Renderer.FONT_SIZE * this.project.camera.currentScale,\n        urlNode.color.a === 1\n          ? colorInvert(urlNode.color)\n          : colorInvert(this.project.stageStyleManager.currentStyle.Background),\n      );\n    }\n    // 绘制分界线\n    this.project.curveRenderer.renderDashedLine(\n      this.project.renderer.transformWorld2View(urlNode.rectangle.location.add(new Vector(0, UrlNode.titleHeight))),\n      this.project.renderer.transformWorld2View(\n        urlNode.rectangle.location.add(new Vector(urlNode.rectangle.size.x, UrlNode.titleHeight)),\n      ),\n      urlNode.color.a === 1\n        ? colorInvert(urlNode.color)\n        : colorInvert(this.project.stageStyleManager.currentStyle.Background),\n      1 * this.project.camera.currentScale,\n      4 * this.project.camera.currentScale,\n    );\n    // 绘制url\n    this.project.textRenderer.renderText(\n      urlNode.url.length > 35 ? urlNode.url.slice(0, 35) + \"...\" : urlNode.url,\n      this.project.renderer.transformWorld2View(\n        urlNode.rectangle.location.add(new Vector(Renderer.NODE_PADDING, UrlNode.titleHeight + Renderer.NODE_PADDING)),\n      ),\n      Renderer.FONT_SIZE * 0.5 * this.project.camera.currentScale,\n      urlNode.color.a === 1\n        ? colorInvert(urlNode.color)\n        : colorInvert(this.project.stageStyleManager.currentStyle.Background),\n    );\n    // 绘制特效\n    this.renderHoverState(urlNode);\n  }\n\n  private renderHoverState(urlNode: UrlNode): void {\n    const mouseLocation = this.project.renderer.transformView2World(MouseLocation.vector());\n    if (urlNode.titleRectangle.isPointIn(mouseLocation)) {\n      // 鼠标在标题上\n      this.project.shapeRenderer.renderRect(\n        this.project.renderer.transformWorld2View(urlNode.titleRectangle),\n        this.project.stageStyleManager.currentStyle.CollideBoxPreSelected,\n        this.project.stageStyleManager.currentStyle.CollideBoxSelected,\n        2 * this.project.camera.currentScale,\n        0,\n      );\n    } else if (urlNode.urlRectangle.isPointIn(mouseLocation)) {\n      // 鼠标在url上\n      this.project.shapeRenderer.renderRect(\n        this.project.renderer.transformWorld2View(urlNode.urlRectangle),\n        this.project.stageStyleManager.currentStyle.CollideBoxPreSelected,\n        this.project.stageStyleManager.currentStyle.CollideBoxSelected,\n        2 * this.project.camera.currentScale,\n        0,\n      );\n      // 绘制提示\n      this.project.textRenderer.renderText(\n        \"双击打开链接\",\n        this.project.renderer.transformWorld2View(urlNode.rectangle.leftBottom.add(new Vector(0, 20))),\n        Renderer.FONT_SIZE * 0.5 * this.project.camera.currentScale,\n        this.project.stageStyleManager.currentStyle.StageObjectBorder,\n      );\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/render/canvas2d/renderer.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { MouseLocation } from \"@/core/service/controlService/MouseLocation\";\nimport { StageObject } from \"@/core/stage/stageObject/abstract/StageObject\";\nimport { CubicCatmullRomSplineEdge } from \"@/core/stage/stageObject/association/CubicCatmullRomSplineEdge\";\nimport { LineEdge } from \"@/core/stage/stageObject/association/LineEdge\";\nimport { MultiTargetUndirectedEdge } from \"@/core/stage/stageObject/association/MutiTargetUndirectedEdge\";\nimport { getTextSize } from \"@/utils/font\";\nimport { isFrame, isMac } from \"@/utils/platform\";\nimport { Color, mixColors, Vector } from \"@graphif/data-structures\";\nimport { CubicBezierCurve, Rectangle } from \"@graphif/shapes\";\nimport { GlobalMaskRenderer } from \"./utilsRenderer/globalMaskRenderer\";\n\n/**\n * 渲染器\n */\n@service(\"renderer\")\nexport class Renderer {\n  /**\n   * 节点上的文字大小\n   */\n  static FONT_SIZE = 32;\n  static NODE_PADDING = 14;\n  /// 节点的圆角半径\n  static NODE_ROUNDED_RADIUS = 8;\n\n  w = 0;\n  h = 0;\n  // let canvasRect: Rectangle;\n  renderedEdges: number = 0;\n\n  /**\n   * 记录每一项渲染的耗时\n   * {\n   *   [渲染项的名字]: ?ms\n   * }\n   */\n  private readonly timings: { [key: string]: number } = {};\n\n  deltaTime = 0;\n\n  // 上一次记录fps的时间\n  private lastTime = performance.now();\n  // 自上一次记录fps以来的帧数是几\n  frameCount = 0;\n  frameIndex = 0; // 无穷累加\n  // 上一次记录的fps数值\n  fps = 0;\n\n  /**\n   * 解决Canvas模糊问题\n   * 它能让画布的大小和屏幕的大小保持一致\n   */\n  resizeWindow(newW: number, newH: number) {\n    const scale = window.devicePixelRatio;\n    this.w = newW;\n    this.h = newH;\n    this.project.canvas.element.width = newW * scale;\n    this.project.canvas.element.height = newH * scale;\n    this.project.canvas.element.style.width = `${newW}px`;\n    this.project.canvas.element.style.height = `${newH}px`;\n    this.project.canvas.ctx.scale(scale, scale);\n  }\n\n  // 确保这个函数在软件打开的那一次调用\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 渲染总入口\n   * 建议此函数内部的调用就像一个清单一样，全是函数（这些函数都不是export的）。\n   * @returns\n   */\n  tick() {\n    if (Settings.isPauseRenderWhenManipulateOvertime) {\n      if (!this.project.controller.isManipulateOverTime()) {\n        this.tick_();\n      }\n    } else {\n      this.tick_();\n    }\n  }\n\n  private tick_() {\n    this.updateFPS();\n    const viewRectangle = this.getCoverWorldRectangle();\n    this.renderBackground();\n    this.renderMainStageElements(viewRectangle);\n\n    GlobalMaskRenderer.renderMask(this.project, MouseLocation.vector(), Settings.stealthModeReverseMask);\n\n    this.renderViewElements(viewRectangle);\n  }\n\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  private renderViewElements(_viewRectangle: Rectangle) {\n    this.renderSpecialKeys();\n    this.renderCenterPointer();\n    this.renderDebugDetails();\n  }\n\n  private renderMainStageElements(viewRectangle: Rectangle) {\n    // 先渲染主场景\n    this.renderStageElementsWithoutReactions(viewRectangle);\n    // 交互相关的\n    this.project.drawingControllerRenderer.renderTempDrawing();\n    this.renderWarningStageObjects();\n    this.renderHoverCollisionBox();\n    this.renderSelectingRectangle();\n    this.renderCuttingLine();\n    this.renderConnectingLine();\n    this.renderKeyboardOnly();\n    this.rendererLayerMovingLine();\n    this.project.searchContentHighlightRenderer.render(this.frameIndex);\n    // renderViewRectangle(viewRectangle);\n  }\n\n  // 渲染一切实体相关的要素\n  private renderStageElementsWithoutReactions(viewRectangle: Rectangle) {\n    this.project.entityRenderer.renderAllSectionsBackground(viewRectangle);\n    this.renderEntities(viewRectangle);\n    this.renderEdges(viewRectangle); // 先渲染实体再渲染连线，因为连线要在图片上面\n    this.project.entityRenderer.renderAllSectionsBigTitle(viewRectangle);\n    this.renderTags();\n    // debug\n\n    // debugRender();\n  }\n\n  // 是否超出了视野之外\n  isOverView(viewRectangle: Rectangle, entity: StageObject): boolean {\n    if (!Settings.limitCameraInCycleSpace) {\n      // 如果没有开循环空间，就要看看是否超出了视野\n      return !viewRectangle.isCollideWith(entity.collisionBox.getRectangle());\n    }\n    // 如果开了循环空间，就永远不算超出视野\n    return false;\n  }\n\n  // 渲染中心准星\n  private renderCenterPointer() {\n    if (!Settings.isRenderCenterPointer) {\n      return;\n    }\n    const viewCenterLocation = this.transformWorld2View(this.project.camera.location);\n    this.project.shapeRenderer.renderCircle(\n      viewCenterLocation,\n      1,\n      this.project.stageStyleManager.currentStyle.GridHeavy,\n      Color.Transparent,\n      0,\n    );\n    for (let i = 0; i < 4; i++) {\n      const degrees = i * 90;\n      const shortLineStart = viewCenterLocation.add(new Vector(10, 0).rotateDegrees(degrees));\n      const shortLineEnd = viewCenterLocation.add(new Vector(20, 0).rotateDegrees(degrees));\n      this.project.curveRenderer.renderSolidLine(\n        shortLineStart,\n        shortLineEnd,\n        this.project.stageStyleManager.currentStyle.GridHeavy,\n        1,\n      );\n    }\n  }\n\n  /** 鼠标hover的边 */\n  private renderHoverCollisionBox() {\n    for (const edge of this.project.mouseInteraction.hoverEdges) {\n      this.project.collisionBoxRenderer.render(\n        edge.collisionBox,\n        this.project.stageStyleManager.currentStyle.CollideBoxPreSelected,\n      );\n    }\n    for (const section of this.project.mouseInteraction.hoverSections) {\n      this.project.collisionBoxRenderer.render(\n        section.collisionBox,\n        this.project.stageStyleManager.currentStyle.CollideBoxPreSelected,\n      );\n    }\n    for (const multiTargetUndirectedEdge of this.project.mouseInteraction.hoverMultiTargetEdges) {\n      this.project.collisionBoxRenderer.render(\n        multiTargetUndirectedEdge.collisionBox,\n        this.project.stageStyleManager.currentStyle.CollideBoxPreSelected,\n      );\n    }\n    for (const connectPoint of this.project.mouseInteraction.hoverConnectPoints) {\n      this.project.collisionBoxRenderer.render(\n        connectPoint.collisionBox,\n        this.project.stageStyleManager.currentStyle.StageObjectBorder,\n      );\n    }\n  }\n\n  /** 框选框 */\n  private renderSelectingRectangle() {\n    const rectangle = this.project.rectangleSelect.getRectangle();\n    if (rectangle) {\n      if (\n        isMac\n          ? this.project.controller.pressingKeySet.has(\"meta\")\n          : this.project.controller.pressingKeySet.has(\"control\")\n      ) {\n        this.project.textRenderer.renderTextInRectangle(\n          \"!\",\n          this.transformWorld2View(rectangle),\n          this.project.stageStyleManager.currentStyle.SelectRectangleBorder,\n        );\n      }\n      if (this.project.controller.pressingKeySet.has(\"shift\")) {\n        this.project.textRenderer.renderTextInRectangle(\n          \"+\",\n          this.transformWorld2View(rectangle),\n          this.project.stageStyleManager.currentStyle.SelectRectangleBorder,\n        );\n      }\n      const selectMode = this.project.rectangleSelect.getSelectMode();\n      if (selectMode === \"intersect\") {\n        this.project.shapeRenderer.renderRect(\n          this.transformWorld2View(rectangle),\n          this.project.stageStyleManager.currentStyle.SelectRectangleFill,\n          this.project.stageStyleManager.currentStyle.SelectRectangleBorder,\n          1,\n        );\n      } else if (selectMode === \"contain\") {\n        this.project.shapeRenderer.renderRect(\n          this.transformWorld2View(rectangle),\n          this.project.stageStyleManager.currentStyle.SelectRectangleFill,\n          Color.Transparent,\n          0,\n        );\n        this.project.shapeRenderer.renderCameraShapeBorder(\n          this.transformWorld2View(rectangle),\n          this.project.stageStyleManager.currentStyle.SelectRectangleBorder,\n          1,\n        );\n        // 完全覆盖框选的提示\n        this.project.textRenderer.renderText(\n          \"完全覆盖框选\",\n          this.transformWorld2View(rectangle.leftBottom).add(new Vector(20, 10)),\n          10,\n          this.project.stageStyleManager.currentStyle.SelectRectangleBorder,\n        );\n      }\n    }\n    // if (Stage.selectMachine.isUsing && Stage.selectMachine.selectingRectangle) {\n    //   const selectMode = Stage.selectMachine.getSelectMode();\n    //   if (selectMode === \"intersect\") {\n    //     this.project.shapeRenderer.renderRect(\n    //       Stage.selectMachine.selectingRectangle.transformWorld2View(),\n    //       this.project.stageStyleManager.currentStyle.SelectRectangleFill,\n    //       this.project.stageStyleManager.currentStyle.SelectRectangleBorder,\n    //       1,\n    //     );\n    //   } else if (selectMode === \"contain\") {\n    //     this.project.shapeRenderer.renderRect(\n    //       Stage.selectMachine.selectingRectangle.transformWorld2View(),\n    //       this.project.stageStyleManager.currentStyle.SelectRectangleFill,\n    //       Color.Transparent,\n    //       0,\n    //     );\n    //     this.project.shapeRenderer.renderCameraShapeBorder(\n    //       Stage.selectMachine.selectingRectangle.transformWorld2View(),\n    //       this.project.stageStyleManager.currentStyle.SelectRectangleBorder,\n    //       1,\n    //     );\n    //     // 完全覆盖框选的提示\n    //     this.project.textRenderer.renderOneLineText(\n    //       \"完全覆盖框选\",\n    //       transformWorld2View(Stage.selectMachine.selectingRectangle.leftBottom).add(new Vector(20, 10)),\n    //       10,\n    //       this.project.stageStyleManager.currentStyle.SelectRectangleBorder,\n    //     );\n    //   }\n    // }\n  }\n  /** 切割线 */\n  private renderCuttingLine() {\n    if (this.project.controller.cutting.isUsing && this.project.controller.cutting.cuttingLine) {\n      this.project.worldRenderUtils.renderLaser(\n        this.project.controller.cutting.cuttingLine.start,\n        this.project.controller.cutting.cuttingLine.end,\n        2,\n        this.project.stageStyleManager.currentStyle.effects.warningShadow,\n      );\n    }\n  }\n\n  /** 手动连接线 */\n  private renderConnectingLine() {\n    if (this.project.controller.nodeConnection.isUsing) {\n      // 如果鼠标位置没有和任何节点相交\n      let connectTargetNode = null;\n      const mouseLocation = this.transformView2World(MouseLocation.vector());\n      for (const node of this.project.stageManager.getConnectableEntity()) {\n        if (node.collisionBox.isContainsPoint(mouseLocation)) {\n          connectTargetNode = node;\n          break;\n        }\n      }\n      if (connectTargetNode === null) {\n        for (const node of this.project.controller.nodeConnection.connectFromEntities) {\n          this.project.edgeRenderer.renderVirtualEdge(node, mouseLocation);\n        }\n      } else {\n        // 画一条像吸住了的线\n        for (const node of this.project.controller.nodeConnection.connectFromEntities) {\n          this.project.edgeRenderer.renderVirtualConfirmedEdge(node, connectTargetNode);\n        }\n      }\n      if (Settings.showDebug) {\n        // 调试模式下显示右键连线轨迹\n        const points = this.project.controller.nodeConnection\n          .getMouseLocationsPoints()\n          .map((point) => this.transformWorld2View(point));\n        if (points.length > 1) {\n          this.project.curveRenderer.renderSolidLineMultiple(\n            this.project.controller.nodeConnection\n              .getMouseLocationsPoints()\n              .map((point) => this.transformWorld2View(point)),\n            this.project.stageStyleManager.currentStyle.effects.warningShadow,\n            1,\n          );\n        }\n      }\n    }\n  }\n\n  /**\n   * 渲染和纯键盘操作相关的功能\n   */\n  private renderKeyboardOnly() {\n    if (this.project.keyboardOnlyGraphEngine.isCreating()) {\n      const isHaveEntity = this.project.keyboardOnlyGraphEngine.isTargetLocationHaveEntity();\n      for (const node of this.project.stageManager.getTextNodes()) {\n        if (node.isSelected) {\n          {\n            const startLocation = node.rectangle.center;\n            const endLocation = this.project.keyboardOnlyGraphEngine.virtualTargetLocation();\n            let rate = this.project.keyboardOnlyGraphEngine.getPressTabTimeInterval() / 100;\n            rate = Math.min(1, rate);\n            const currentLocation = startLocation.add(endLocation.subtract(startLocation).multiply(rate));\n            this.project.worldRenderUtils.renderLaser(\n              startLocation,\n              currentLocation,\n              2,\n              rate < 1 ? Color.Yellow : isHaveEntity ? Color.Blue : Color.Green,\n            );\n            if (rate === 1 && !isHaveEntity) {\n              this.project.shapeRenderer.renderRectFromCenter(\n                this.transformWorld2View(this.project.keyboardOnlyGraphEngine.virtualTargetLocation()),\n                120 * this.project.camera.currentScale,\n                60 * this.project.camera.currentScale,\n                Color.Transparent,\n                mixColors(this.project.stageStyleManager.currentStyle.StageObjectBorder, Color.Transparent, 0.5),\n                2 * this.project.camera.currentScale,\n                Renderer.NODE_ROUNDED_RADIUS * this.project.camera.currentScale,\n              );\n            }\n          }\n          let hintText = \"再按一次 “生长自由节点键（默认是反引号键）” 完成并退出新节点创建,IKJL键移动生长位置\";\n          if (isHaveEntity) {\n            hintText = \"连接！\";\n          }\n          // 在生成点下方写文字提示\n          this.project.textRenderer.renderMultiLineText(\n            hintText,\n            this.transformWorld2View(\n              this.project.keyboardOnlyGraphEngine.virtualTargetLocation().add(new Vector(0, 50)),\n            ),\n            10 * this.project.camera.currentScale,\n            Infinity,\n            this.project.stageStyleManager.currentStyle.StageObjectBorder,\n          );\n        }\n      }\n    }\n  }\n\n  /** 层级移动时，渲染移动指向线 */\n  private rendererLayerMovingLine() {\n    if (!this.project.controller.layerMoving.isEnabled) {\n      return;\n    }\n    // 有alt\n    if (!this.project.controller.pressingKeySet.has(\"alt\")) {\n      return;\n    }\n    // 有alt且仅按下了alt键\n    if (this.project.controller.pressingKeySet.size !== 1) {\n      return;\n    }\n    if (this.project.stageManager.getSelectedEntities().length === 0) {\n      return;\n    }\n\n    const boundingRectangle = Rectangle.getBoundingRectangle(\n      this.project.stageManager.getSelectedEntities().map((entity) => {\n        return entity.collisionBox.getRectangle();\n      }),\n    );\n    const targetBoundingRectangle = new Rectangle(\n      boundingRectangle.location.add(this.project.controller.mouseLocation.subtract(boundingRectangle.location)),\n      boundingRectangle.size.clone(),\n    );\n    const targetSections = this.project.sectionMethods.getSectionsByInnerLocation(\n      this.project.controller.mouseLocation,\n    );\n    for (const targetSection of targetSections) {\n      const sectionAndSelectedBoundingRectagnle = Rectangle.getBoundingRectangle([\n        targetBoundingRectangle,\n        targetSection.collisionBox.getRectangle(),\n      ]);\n      this.project.shapeRenderer.renderDashedRect(\n        new Rectangle(\n          this.transformWorld2View(sectionAndSelectedBoundingRectagnle.location),\n          sectionAndSelectedBoundingRectagnle.size.multiply(this.project.camera.currentScale),\n        ),\n        Color.Transparent,\n        this.project.stageStyleManager.currentStyle.StageObjectBorder.toNewAlpha(0.8),\n        2 * this.project.camera.currentScale,\n        Renderer.NODE_ROUNDED_RADIUS * this.project.camera.currentScale,\n      );\n    }\n\n    this.project.shapeRenderer.renderDashedRect(\n      new Rectangle(\n        this.transformWorld2View(boundingRectangle.location),\n        boundingRectangle.size.multiply(this.project.camera.currentScale),\n      ),\n      Color.Transparent,\n      Color.Green.toNewAlpha(0.5),\n      2,\n      Renderer.NODE_ROUNDED_RADIUS * this.project.camera.currentScale,\n    );\n    this.project.shapeRenderer.renderDashedRect(\n      new Rectangle(\n        this.transformWorld2View(targetBoundingRectangle.location),\n        targetBoundingRectangle.size.multiply(this.project.camera.currentScale),\n      ),\n      Color.Transparent,\n      Color.Green.toNewAlpha(0.5),\n      2,\n      Renderer.NODE_ROUNDED_RADIUS * this.project.camera.currentScale,\n    );\n\n    this.renderJumpLine(boundingRectangle.leftTop, targetBoundingRectangle.leftTop);\n    this.project.textRenderer.renderTextFromCenter(\n      \"Jump To\",\n      this.transformWorld2View(this.project.controller.mouseLocation).subtract(new Vector(0, -30)),\n      16,\n      this.project.stageStyleManager.currentStyle.CollideBoxPreSelected.toSolid(),\n    );\n  }\n\n  private renderJumpLine(startLocation: Vector, endLocation: Vector) {\n    let lineWidth = 8;\n    if (this.project.controller.isMouseDown[0]) {\n      lineWidth = 16;\n    }\n    const distance = startLocation.distance(endLocation);\n    const height = distance / 2;\n    // 影子\n    this.project.curveRenderer.renderGradientLine(\n      this.transformWorld2View(startLocation),\n      this.transformWorld2View(endLocation),\n      Color.Transparent,\n      new Color(0, 0, 0, 0.2),\n      lineWidth * this.project.camera.currentScale,\n    );\n    this.project.curveRenderer.renderGradientBezierCurve(\n      new CubicBezierCurve(\n        this.transformWorld2View(startLocation),\n        this.transformWorld2View(startLocation.add(new Vector(0, -height))),\n        this.transformWorld2View(endLocation.add(new Vector(0, -height))),\n        this.transformWorld2View(endLocation),\n      ),\n      this.project.stageStyleManager.currentStyle.CollideBoxPreSelected.toTransparent(),\n      this.project.stageStyleManager.currentStyle.CollideBoxPreSelected.toSolid(),\n      lineWidth * this.project.camera.currentScale,\n    );\n    // 画箭头\n    const arrowLen = 10 + distance * 0.01;\n    this.project.curveRenderer.renderBezierCurve(\n      new CubicBezierCurve(\n        this.transformWorld2View(endLocation),\n        this.transformWorld2View(endLocation),\n        this.transformWorld2View(endLocation),\n        this.transformWorld2View(endLocation.add(new Vector(-arrowLen, -arrowLen * 2))),\n      ),\n      this.project.stageStyleManager.currentStyle.CollideBoxPreSelected.toSolid(),\n      lineWidth * this.project.camera.currentScale,\n    );\n    this.project.curveRenderer.renderBezierCurve(\n      new CubicBezierCurve(\n        this.transformWorld2View(endLocation),\n        this.transformWorld2View(endLocation),\n        this.transformWorld2View(endLocation),\n        this.transformWorld2View(endLocation.add(new Vector(arrowLen, -arrowLen * 2))),\n      ),\n      this.project.stageStyleManager.currentStyle.CollideBoxPreSelected.toSolid(),\n      lineWidth * this.project.camera.currentScale,\n    );\n  }\n\n  /** 待删除的节点和边 */\n  private renderWarningStageObjects() {\n    // 待删除的节点\n    for (const node of this.project.controller.cutting.warningEntity) {\n      this.project.collisionBoxRenderer.render(\n        node.collisionBox,\n        this.project.stageStyleManager.currentStyle.effects.warningShadow.toNewAlpha(0.5),\n      );\n    }\n    // 待删除的边\n    for (const association of this.project.controller.cutting.warningAssociations) {\n      this.project.collisionBoxRenderer.render(\n        association.collisionBox,\n        this.project.stageStyleManager.currentStyle.effects.warningShadow.toNewAlpha(0.5),\n      );\n    }\n    for (const section of this.project.controller.cutting.warningSections) {\n      this.project.collisionBoxRenderer.render(\n        section.collisionBox,\n        this.project.stageStyleManager.currentStyle.effects.warningShadow.toNewAlpha(0.5),\n      );\n    }\n  }\n\n  /** 画所有被标签了的节点的特殊装饰物和缩小视野时的直观显示 */\n  private renderTags() {\n    for (const tagString of this.project.tags) {\n      const tagObject = this.project.stageManager.get(tagString); // 这不成了ON方了？\n      if (!tagObject) {\n        continue;\n      }\n      const rect = tagObject.collisionBox.getRectangle();\n      this.project.shapeRenderer.renderPolygonAndFill(\n        [\n          this.transformWorld2View(rect.leftTop.add(new Vector(0, 8))),\n          this.transformWorld2View(rect.leftCenter.add(new Vector(-15, 0))),\n          this.transformWorld2View(rect.leftBottom.add(new Vector(0, -8))),\n        ],\n        new Color(255, 0, 0, 0.5),\n        this.project.stageStyleManager.currentStyle.StageObjectBorder,\n        2 * this.project.camera.currentScale,\n      );\n    }\n  }\n  private renderEntities(viewRectangle: Rectangle) {\n    this.project.entityRenderer.renderAllEntities(viewRectangle);\n  }\n\n  private renderEdges(viewRectangle: Rectangle) {\n    this.renderedEdges = 0;\n    for (const association of this.project.stageManager.getAssociations()) {\n      if (this.isOverView(viewRectangle, association)) {\n        continue;\n      }\n      if (association instanceof MultiTargetUndirectedEdge) {\n        this.project.multiTargetUndirectedEdgeRenderer.render(association);\n      }\n      if (association instanceof LineEdge) {\n        this.project.edgeRenderer.renderLineEdge(association);\n      }\n      if (association instanceof CubicCatmullRomSplineEdge) {\n        this.project.edgeRenderer.renderCrEdge(association);\n      }\n      this.renderedEdges++;\n    }\n  }\n\n  /**\n   * 渲染背景\n   */\n  private renderBackground() {\n    const rect = this.getCoverWorldRectangle();\n    // 先清空一下背景\n    this.project.canvas.ctx.clearRect(0, 0, this.w, this.h);\n    // 画canvas底色\n    this.project.shapeRenderer.renderRect(\n      this.transformWorld2View(rect),\n      this.project.stageStyleManager.currentStyle.Background.toNewAlpha(Settings.windowBackgroundAlpha),\n      Color.Transparent,\n      0,\n    );\n    if (Settings.showBackgroundDots) {\n      this.project.backgroundRenderer.renderDotBackground(rect);\n    }\n    if (Settings.showBackgroundHorizontalLines) {\n      this.project.backgroundRenderer.renderHorizonBackground(rect);\n    }\n    if (Settings.showBackgroundVerticalLines) {\n      this.project.backgroundRenderer.renderVerticalBackground(rect);\n    }\n    if (Settings.showBackgroundCartesian) {\n      this.project.backgroundRenderer.renderCartesianBackground(rect);\n    }\n  }\n\n  /**\n   * 每次在frameTick最开始的时候调用一次\n   */\n  private updateFPS() {\n    // 计算FPS\n    const now = performance.now();\n    const deltaTime = (now - this.lastTime) / 1000; // s\n    this.deltaTime = deltaTime;\n\n    this.frameIndex++;\n    const currentTime = performance.now();\n    this.frameCount++;\n    if (currentTime - this.lastTime > 1000) {\n      this.fps = this.frameCount;\n      this.frameCount = 0;\n      this.lastTime = currentTime;\n    }\n  }\n\n  /** 画debug信息 */\n  private renderDebugDetails() {\n    if (!Settings.showDebug || isFrame) {\n      return;\n    }\n\n    const detailsData = [\n      \"调试信息已开启，可在设置中关闭，或快捷键关闭\",\n      `scale: ${this.project.camera.currentScale}`,\n      `target: ${this.project.camera.targetScale}`,\n      `shake: ${this.project.camera.shakeLocation.toString()}`,\n      `location: ${this.project.camera.location.x.toFixed(2)}, ${this.project.camera.location.y.toFixed(2)}`,\n      `location: ${this.project.camera.location.x}, ${this.project.camera.location.y}`,\n      `speed: ${this.project.camera.speed.x}, ${this.project.camera.speed.y}`,\n      `window: ${this.w}x${this.h}`,\n      `effect count: ${this.project.effects.effectsCount}`,\n      `node count: ${this.project.stageManager.getTextNodes().length}`,\n      `edge count: ${this.project.stageManager.getLineEdges().length}`,\n      `section count: ${this.project.stageManager.getSections().length}`,\n      `pressingKeys: ${this.project.controller.pressingKeysString()}`,\n      `鼠标按下情况: ${this.project.controller.isMouseDown}`,\n      `框选框: ${JSON.stringify(this.project.rectangleSelect.getRectangle())}`,\n      `正在切割: ${this.project.controller.cutting.isUsing}`,\n      `Stage.warningNodes: ${this.project.controller.cutting.warningEntity.length}`,\n      `Stage.warningAssociations: ${this.project.controller.cutting.warningAssociations.length}`,\n      `ConnectFromNodes: ${this.project.controller.nodeConnection.connectFromEntities}`,\n      `lastSelectedNode: ${this.project.controller.lastSelectedEntityUUID.size}`,\n      `粘贴板: ${this.project.copyEngine ? \"存在\" : \"undefined\"}`,\n      `fps: ${this.fps}`,\n      `delta: ${this.deltaTime.toFixed(2)}`,\n      `uri: ${decodeURI(this.project.uri.toString())}`,\n      `isEnableEntityCollision: ${Settings.isEnableEntityCollision}`,\n    ];\n    for (const [k, v] of Object.entries(this.timings)) {\n      detailsData.push(`render time:${k}: ${v.toFixed(2)}`);\n    }\n    for (const line of detailsData) {\n      this.project.textRenderer.renderTempText(\n        line,\n        new Vector(10, 80 + detailsData.indexOf(line) * 12),\n        10,\n        this.project.stageStyleManager.currentStyle.DetailsDebugText,\n      );\n    }\n  }\n\n  /**\n   * 渲染左下角的文字\n   * @returns\n   */\n  private renderSpecialKeys() {\n    if (this.project.controller.pressingKeySet.size === 0) {\n      return;\n    }\n\n    const margin = 10;\n    let x = margin;\n    const fontSize = 30;\n\n    for (let key of this.project.controller.pressingKeySet) {\n      if (key === \" \") {\n        key = \"␣\";\n      }\n      const textLocation = new Vector(x, this.h - 100);\n      this.project.textRenderer.renderText(\n        key,\n        textLocation,\n        fontSize,\n        this.project.stageStyleManager.currentStyle.StageObjectBorder,\n      );\n      const textSize = getTextSize(key, fontSize);\n      x += textSize.x + margin;\n    }\n    if (\n      !Settings.allowMoveCameraByWSAD &&\n      (this.project.controller.pressingKeySet.has(\"w\") ||\n        this.project.controller.pressingKeySet.has(\"s\") ||\n        this.project.controller.pressingKeySet.has(\"a\") ||\n        this.project.controller.pressingKeySet.has(\"d\"))\n    ) {\n      this.project.textRenderer.renderText(\n        \"      方向键移动视野被禁止，可通过快捷键或设置界面松开“手刹”\",\n        new Vector(margin, this.h - 60),\n        15,\n        this.project.stageStyleManager.currentStyle.effects.flash,\n      );\n\n      this.project.svgRenderer.renderSvgFromLeftTop(\n        `<svg\n  xmlns=\"http://www.w3.org/2000/svg\"\n  width=\"24\"\n  height=\"24\"\n  viewBox=\"0 0 24 24\"\n  fill=\"none\"\n  stroke=\"${this.project.stageStyleManager.currentStyle.effects.warningShadow.toString()}\"\n  stroke-width=\"2\"\n  stroke-linecap=\"round\"\n  stroke-linejoin=\"round\"\n>\n  <path d=\"M 12 12.5 C12 8.5 12 12 12 9\" />\n  <path d=\"M 12 15 C12 15 12 15 12 15\" />\n  <path d=\"M 12 18 C15.5 18 18 15.5 18 12\" />\n  <path d=\"M 12 6 C8.5 6 6 8.5 6 12\" />\n  <path d=\"M 18 12 C18 8.5 15.5 6 12 6\" />\n  <path d=\"M 19 18 C21 16 21.5 8.5 19 6\" />\n  <path d=\"M 4.5 18 C2.5 16 2.5 8.5 4.5 6\" />\n  <path d=\"M 6 12 C6 15.5 8.5 18 12 18\" />\n</svg>`,\n        new Vector(margin, this.h - 60),\n        24,\n        24,\n      );\n    }\n  }\n\n  /**\n   * 将世界坐标转换为视野坐标 (渲染经常用)\n   * 可以画图推理出\n   * renderLocation + viewLocation = worldLocation\n   * 所以\n   * viewLocation = worldLocation - renderLocation\n   * 但viewLocation是左上角，还要再平移一下\n   * @param worldLocation\n   * @returns\n   */\n  transformWorld2View(location: Vector): Vector;\n  transformWorld2View(rectangle: Rectangle): Rectangle;\n  transformWorld2View(arg1: Vector | Rectangle): Vector | Rectangle {\n    if (arg1 instanceof Rectangle) {\n      return new Rectangle(\n        this.transformWorld2View(arg1.location),\n        arg1.size.multiply(this.project.camera.currentScale),\n      );\n    }\n    if (arg1 instanceof Vector) {\n      return arg1\n        .subtract(this.project.camera.location)\n        .multiply(this.project.camera.currentScale)\n        .add(new Vector(this.w / 2, this.h / 2))\n        .add(this.project.camera.shakeLocation);\n    }\n    return arg1;\n  }\n\n  /**\n   * 将视野坐标转换为世界坐标 (处理鼠标点击事件用)\n   * 上一个函数的相反，就把上一个顺序倒着来就行了\n   * worldLocation = viewLocation + renderLocation\n   * @param viewLocation\n   * @returns\n   */\n  transformView2World(location: Vector): Vector;\n  transformView2World(rectangle: Rectangle): Rectangle;\n  transformView2World(arg1: Vector | Rectangle): Vector | Rectangle {\n    if (arg1 instanceof Rectangle) {\n      return new Rectangle(this.transformView2World(arg1.location), this.transformView2World(arg1.size));\n    }\n    if (arg1 instanceof Vector) {\n      return arg1\n        .subtract(this.project.camera.shakeLocation)\n        .subtract(new Vector(this.w / 2, this.h / 2))\n        .multiply(1 / this.project.camera.currentScale)\n        .add(this.project.camera.location);\n    }\n    return arg1;\n  }\n\n  /**\n   * 获取摄像机视野范围内所覆盖住的世界范围矩形\n   * 返回的矩形是世界坐标下的矩形\n   */\n  getCoverWorldRectangle(): Rectangle {\n    const size = new Vector(this.w / this.project.camera.currentScale, this.h / this.project.camera.currentScale);\n    return new Rectangle(this.project.camera.location.subtract(size.divide(2)), size);\n  }\n}\n"
  },
  {
    "path": "app/src/core/render/canvas2d/utilsRenderer/RenderUtils.tsx",
    "content": "import { Color, Vector } from \"@graphif/data-structures\";\nimport { Project, service } from \"@/core/Project\";\n\n/**\n * 一些基础的渲染图形\n * 注意：这些渲染的参数都是View坐标系下的。\n */\n@service(\"renderUtils\")\nexport class RenderUtils {\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 绘制一个像素点\n   * @param location\n   * @param color\n   */\n  renderPixel(location: Vector, color: Color) {\n    this.project.canvas.ctx.fillStyle = color.toString();\n    this.project.canvas.ctx.fillRect(\n      location.x,\n      location.y,\n      1 * this.project.camera.currentScale,\n      1 * this.project.camera.currentScale,\n    );\n  }\n\n  /**\n   * 画箭头（只画头，不画线）\n   */\n  renderArrow(direction: Vector, location: Vector, color: Color, size: number) {\n    /*\n    Python 代码：\n    self.path = QPainterPath(point_at.to_qt())\n        nor = direction.normalize()\n        self.path.lineTo((point_at - nor.rotate(20) * arrow_size).to_qt())\n        self.path.lineTo((point_at - nor * (arrow_size / 2)).to_qt())\n        self.path.lineTo((point_at - nor.rotate(-20) * arrow_size).to_qt())\n        self.path.closeSubpath()\n    */\n    const nor = direction.normalize();\n    const arrow_size = size / 2;\n    this.project.canvas.ctx.beginPath();\n    this.project.canvas.ctx.moveTo(location.x, location.y);\n    this.project.canvas.ctx.lineTo(\n      location.x - nor.rotate(20).x * arrow_size,\n      location.y - nor.rotate(20).y * arrow_size,\n    );\n    this.project.canvas.ctx.lineTo(location.x - nor.x * (arrow_size / 2), location.y - nor.y * (arrow_size / 2));\n    this.project.canvas.ctx.lineTo(\n      location.x - nor.rotate(-20).x * arrow_size,\n      location.y - nor.rotate(-20).y * arrow_size,\n    );\n    this.project.canvas.ctx.closePath();\n    this.project.canvas.ctx.fillStyle = color.toString();\n    this.project.canvas.ctx.fill();\n  }\n}\n"
  },
  {
    "path": "app/src/core/render/canvas2d/utilsRenderer/WorldRenderUtils.tsx",
    "content": "import { Color, Vector } from \"@graphif/data-structures\";\nimport { CubicBezierCurve, CubicCatmullRomSpline, Rectangle, SymmetryCurve } from \"@graphif/shapes\";\nimport { Project, service } from \"@/core/Project\";\n\n/**\n * 一些基础的渲染图形\n * 注意：这些渲染的参数都是World坐标系下的。\n */\n@service(\"worldRenderUtils\")\nexport class WorldRenderUtils {\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 绘制一条Catmull-Rom样条线\n   * @param curve\n   */\n  renderCubicCatmullRomSpline(spline: CubicCatmullRomSpline, color: Color, width: number): void {\n    const points = spline.computePath().map((it) => this.project.renderer.transformWorld2View(it));\n    width *= this.project.camera.currentScale;\n    const start = this.project.renderer.transformWorld2View(spline.controlPoints[1]);\n    const end = this.project.renderer.transformWorld2View(spline.controlPoints[spline.controlPoints.length - 2]);\n    // 绘制首位控制点到曲线首尾的虚线\n    const dashedColor = color.clone();\n    dashedColor.a /= 2;\n    this.project.curveRenderer.renderDashedLine(\n      this.project.renderer.transformWorld2View(spline.controlPoints[0]),\n      start,\n      dashedColor,\n      width,\n      width * 2,\n    );\n    this.project.curveRenderer.renderDashedLine(\n      end,\n      this.project.renderer.transformWorld2View(spline.controlPoints[spline.controlPoints.length - 1]),\n      dashedColor,\n      width,\n      width * 2,\n    );\n    // 绘制曲线\n    this.project.canvas.ctx.beginPath();\n    this.project.canvas.ctx.lineJoin = \"bevel\";\n    this.project.canvas.ctx.moveTo(points[0].x, points[0].y);\n    this.project.canvas.ctx.lineWidth = width;\n    for (let i = 1; i < points.length; i++) {\n      this.project.canvas.ctx.lineTo(points[i].x, points[i].y);\n    }\n    this.project.canvas.ctx.strokeStyle = color.toString();\n    this.project.canvas.ctx.stroke();\n    // 绘制曲线上的样点\n    // for (const p of points) {\n    //   RenderUtils.renderCircle(p, width, color, color, width);\n    // }\n    // 绘制控制点\n    // for (const p of spline.controlPoints) {\n    //   RenderUtils.renderCircle(\n    //     Renderer.transformWorld2View(p),\n    //     width * 2,\n    //     Color.Red,\n    //     dashedColor,\n    //     Camera.currentScale,\n    //   );\n    // }\n  }\n\n  /**\n   * 绘制一条贝塞尔曲线\n   * @param curve\n   */\n  renderBezierCurve(curve: CubicBezierCurve, color: Color, width: number): void {\n    // 创建新的曲线对象，避免修改原始曲线数据\n    const viewStart = this.project.renderer.transformWorld2View(curve.start);\n    const viewEnd = this.project.renderer.transformWorld2View(curve.end);\n    const viewCtrlPt1 = this.project.renderer.transformWorld2View(curve.ctrlPt1);\n    const viewCtrlPt2 = this.project.renderer.transformWorld2View(curve.ctrlPt2);\n\n    // 使用视图坐标系下的点创建新的贝塞尔曲线\n    const viewBezier = new CubicBezierCurve(viewStart, viewCtrlPt1, viewCtrlPt2, viewEnd);\n\n    this.project.curveRenderer.renderBezierCurve(viewBezier, color, width * this.project.camera.currentScale);\n  }\n\n  /**\n   * 绘制一条对称曲线\n   * @param curve\n   */\n  renderSymmetryCurve(curve: SymmetryCurve, color: Color, width: number): void {\n    this.renderBezierCurve(curve.bezier, color, width);\n  }\n\n  /**\n   * 绘制一条虚线对称曲线\n   * @param curve\n   */\n  renderDashedSymmetryCurve(curve: SymmetryCurve, color: Color, width: number, dashLength: number): void {\n    // 创建新的曲线对象，避免修改原始曲线数据\n    const viewStart = this.project.renderer.transformWorld2View(curve.start);\n    const viewEnd = this.project.renderer.transformWorld2View(curve.end);\n    const viewCtrlPt1 = this.project.renderer.transformWorld2View(curve.bezier.ctrlPt1);\n    const viewCtrlPt2 = this.project.renderer.transformWorld2View(curve.bezier.ctrlPt2);\n\n    // 使用视图坐标系下的点创建新的贝塞尔曲线\n    const viewBezier = new CubicBezierCurve(viewStart, viewCtrlPt1, viewCtrlPt2, viewEnd);\n\n    this.project.curveRenderer.renderDashedBezierCurve(\n      viewBezier,\n      color,\n      width * this.project.camera.currentScale,\n      dashLength,\n    );\n  }\n\n  /**\n   * 绘制一条双实线对称曲线\n   * @param curve\n   */\n  renderDoubleSymmetryCurve(curve: SymmetryCurve, color: Color, width: number, gap: number): void {\n    // 创建新的曲线对象，避免修改原始曲线数据\n    const viewStart = this.project.renderer.transformWorld2View(curve.start);\n    const viewEnd = this.project.renderer.transformWorld2View(curve.end);\n    const viewCtrlPt1 = this.project.renderer.transformWorld2View(curve.bezier.ctrlPt1);\n    const viewCtrlPt2 = this.project.renderer.transformWorld2View(curve.bezier.ctrlPt2);\n\n    // 使用视图坐标系下的点创建新的贝塞尔曲线\n    const viewBezier = new CubicBezierCurve(viewStart, viewCtrlPt1, viewCtrlPt2, viewEnd);\n\n    this.project.curveRenderer.renderDoubleBezierCurve(\n      viewBezier,\n      color,\n      width * this.project.camera.currentScale,\n      gap,\n    );\n  }\n\n  renderLaser(start: Vector, end: Vector, width: number, color: Color): void {\n    this.project.canvas.ctx.shadowColor = color.toString();\n    this.project.canvas.ctx.shadowBlur = 15;\n\n    if (start.distance(end) === 0) {\n      this.renderPrismaticBlock(\n        start,\n        4,\n        Color.Transparent,\n        this.project.stageStyleManager.currentStyle.effects.flash,\n        2,\n      );\n    } else {\n      this.project.curveRenderer.renderSolidLine(\n        this.project.renderer.transformWorld2View(start),\n        this.project.renderer.transformWorld2View(end),\n        this.project.stageStyleManager.currentStyle.effects.flash,\n        width * this.project.camera.currentScale,\n      );\n    }\n\n    // debug\n\n    // RenderUtils.renderCircle(\n    //   Renderer.transformWorld2View(start),\n    //   10 * Camera.currentScale,\n    //   Color.Transparent,\n    //   new Color(255, 0, 0),\n    //   2 * Camera.currentScale\n    // )\n    // RenderUtils.renderCircle(\n    //   Renderer.transformWorld2View(end),\n    //   10 * Camera.currentScale,\n    //   Color.Transparent,\n    //   Color.White,\n    //   2 * Camera.currentScale\n    // )\n    this.project.canvas.ctx.shadowBlur = 0;\n  }\n\n  renderPrismaticBlock(\n    centerLocation: Vector,\n    radius: number,\n    color: Color,\n    strokeColor: Color,\n    strokeWidth: number,\n  ): void {\n    const c = this.project.renderer.transformWorld2View(centerLocation);\n    radius *= this.project.camera.currentScale;\n    strokeWidth *= this.project.camera.currentScale;\n    const originLineJoin = this.project.canvas.ctx.lineJoin;\n    this.project.canvas.ctx.lineJoin = \"miter\";\n    this.project.canvas.ctx.beginPath();\n    this.project.canvas.ctx.moveTo(c.x + radius, c.y);\n    this.project.canvas.ctx.lineTo(c.x, c.y - radius);\n    this.project.canvas.ctx.lineTo(c.x - radius, c.y);\n    this.project.canvas.ctx.lineTo(c.x, c.y + radius);\n    this.project.canvas.ctx.closePath();\n    this.project.canvas.ctx.fillStyle = color.toString();\n    this.project.canvas.ctx.fill();\n    this.project.canvas.ctx.lineWidth = strokeWidth;\n    this.project.canvas.ctx.strokeStyle = strokeColor.toString();\n    this.project.canvas.ctx.stroke();\n    this.project.canvas.ctx.lineJoin = originLineJoin;\n  }\n\n  renderRectangleFlash(rectangle: Rectangle, shadowColor: Color, shadowBlur: number, roundedRadius = 0) {\n    this.project.canvas.ctx.shadowColor = shadowColor.toString();\n    this.project.canvas.ctx.shadowBlur = shadowBlur;\n    // 绘制矩形\n    this.project.canvas.ctx.beginPath();\n    this.project.canvas.ctx.roundRect(\n      rectangle.location.x,\n      rectangle.location.y,\n      rectangle.size.x,\n      rectangle.size.y,\n      roundedRadius,\n    );\n    this.project.canvas.ctx.fillStyle = Color.Transparent.toString();\n    this.project.canvas.ctx.fill();\n    this.project.canvas.ctx.lineWidth = 0;\n    this.project.canvas.ctx.strokeStyle = shadowColor.toString();\n    this.project.canvas.ctx.stroke();\n    // 恢复\n    this.project.canvas.ctx.shadowBlur = 0;\n  }\n\n  renderCuttingFlash(start: Vector, end: Vector, width: number, shadowColor: Color): void {\n    this.project.canvas.ctx.shadowColor = shadowColor.toString();\n    this.project.canvas.ctx.shadowBlur = 15;\n    width = Math.min(width, 20);\n\n    const direction = end.subtract(start).normalize();\n    const headShiftBack = end.subtract(direction.multiply(20));\n    const headLeft = headShiftBack.add(direction.rotateDegrees(90).multiply(width / 2));\n    const headRight = headShiftBack.add(direction.rotateDegrees(-90).multiply(width / 2));\n\n    this.project.shapeRenderer.renderPolygonAndFill(\n      [\n        this.project.renderer.transformWorld2View(start),\n        this.project.renderer.transformWorld2View(headLeft),\n        this.project.renderer.transformWorld2View(end),\n        this.project.renderer.transformWorld2View(headRight),\n      ],\n      this.project.stageStyleManager.currentStyle.effects.flash,\n      Color.Transparent,\n      0,\n    );\n    // 恢复\n    this.project.canvas.ctx.shadowBlur = 0;\n  }\n}\n"
  },
  {
    "path": "app/src/core/render/canvas2d/utilsRenderer/backgroundRenderer.tsx",
    "content": "import { Color, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { Project, service } from \"@/core/Project\";\n\n@service(\"backgroundRenderer\")\nexport class BackgroundRenderer {\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 画洞洞板式的背景\n   * @param ctx\n   * @param width\n   * @param height\n   */\n  renderDotBackground(viewRect: Rectangle) {\n    const currentGap = this.getCurrentGap();\n    const gridColor = this.project.stageStyleManager.currentStyle.GridNormal;\n    const mainColor = this.project.stageStyleManager.currentStyle.GridHeavy;\n\n    for (const y of this.getLocationYIterator(viewRect, currentGap)) {\n      for (const x of this.getLocationXIterator(viewRect, currentGap)) {\n        this.project.shapeRenderer.renderCircle(\n          this.project.renderer.transformWorld2View(new Vector(x, y)),\n          1,\n          x === 0 || y === 0 ? mainColor : gridColor,\n          Color.Transparent,\n          0,\n        );\n      }\n    }\n  }\n\n  /**\n   * 水平线条式的背景\n   */\n  renderHorizonBackground(viewRect: Rectangle) {\n    const currentGap = this.getCurrentGap();\n    const gridColor = this.project.stageStyleManager.currentStyle.GridNormal;\n    const mainColor = this.project.stageStyleManager.currentStyle.GridHeavy;\n\n    // 画横线\n    for (const y of this.getLocationYIterator(viewRect, currentGap)) {\n      const lineStartLocation = new Vector(viewRect.left, y);\n      const lineEndLocation = new Vector(viewRect.right, y);\n\n      this.project.curveRenderer.renderSolidLine(\n        this.project.renderer.transformWorld2View(lineStartLocation),\n        this.project.renderer.transformWorld2View(lineEndLocation),\n        y === 0 ? mainColor : gridColor,\n        0.2,\n      );\n    }\n  }\n\n  /**\n   * 垂直线条式的背景\n   */\n  renderVerticalBackground(viewRect: Rectangle) {\n    const currentGap = this.getCurrentGap();\n    const gridColor = this.project.stageStyleManager.currentStyle.GridNormal;\n    const mainColor = this.project.stageStyleManager.currentStyle.GridHeavy;\n\n    // 画竖线\n    for (const x of this.getLocationXIterator(viewRect, currentGap)) {\n      const lineStartLocation = new Vector(x, viewRect.top);\n      const lineEndLocation = new Vector(x, viewRect.bottom);\n      this.project.curveRenderer.renderSolidLine(\n        this.project.renderer.transformWorld2View(lineStartLocation),\n        this.project.renderer.transformWorld2View(lineEndLocation),\n        x === 0 ? mainColor : gridColor,\n        0.2,\n      );\n    }\n  }\n\n  /**\n   * 平面直角坐标系背景\n   * 只画一个十字坐标\n   */\n  renderCartesianBackground(viewRect: Rectangle) {\n    // x轴\n    this.project.curveRenderer.renderSolidLine(\n      this.project.renderer.transformWorld2View(new Vector(viewRect.left, 0)),\n      this.project.renderer.transformWorld2View(new Vector(viewRect.right, 0)),\n      this.project.stageStyleManager.currentStyle.GridNormal,\n      1,\n    );\n    // y轴\n    this.project.curveRenderer.renderSolidLine(\n      this.project.renderer.transformWorld2View(new Vector(0, viewRect.top)),\n      this.project.renderer.transformWorld2View(new Vector(0, viewRect.bottom)),\n      this.project.stageStyleManager.currentStyle.GridNormal,\n      1,\n    );\n    const currentGap = this.getCurrentGap();\n    // 画x轴上的刻度\n    for (const x of this.getLocationXIterator(viewRect, currentGap)) {\n      const renderLocation = this.project.renderer.transformWorld2View(new Vector(x, 0));\n      renderLocation.y = Math.max(renderLocation.y, 0);\n      renderLocation.y = Math.min(renderLocation.y, this.project.renderer.h - 10);\n      this.project.textRenderer.renderText(\n        `${x}`,\n        renderLocation,\n        10,\n        this.project.stageStyleManager.currentStyle.GridNormal,\n      );\n    }\n    // 画y轴上的刻度\n    for (const y of this.getLocationYIterator(viewRect, currentGap)) {\n      const renderLocation = this.project.renderer.transformWorld2View(new Vector(0, y));\n      renderLocation.x = Math.max(renderLocation.x, 0);\n      renderLocation.x = Math.min(renderLocation.x, this.project.renderer.w - 40);\n      this.project.textRenderer.renderText(\n        `${y}`,\n        renderLocation,\n        10,\n        this.project.stageStyleManager.currentStyle.GridNormal,\n      );\n    }\n  }\n\n  getCurrentGap(): number {\n    const gap = 50;\n    let currentGap = gap;\n    if (this.project.camera.currentScale < 1) {\n      while (currentGap * this.project.camera.currentScale < gap - 1) {\n        currentGap *= 2;\n      }\n    }\n    return currentGap;\n  }\n\n  *getLocationXIterator(viewRect: Rectangle, currentGap: number): IterableIterator<number> {\n    let xStart = viewRect.location.x - (viewRect.location.x % currentGap);\n    while (xStart < viewRect.right) {\n      yield xStart;\n      xStart += currentGap;\n    }\n  }\n\n  *getLocationYIterator(viewRect: Rectangle, currentGap: number): IterableIterator<number> {\n    let yStart = viewRect.location.y - (viewRect.location.y % currentGap);\n    while (yStart < viewRect.bottom) {\n      yield yStart;\n      yStart += currentGap;\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/render/canvas2d/utilsRenderer/globalMaskRenderer.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { Vector } from \"@graphif/data-structures\";\n\n/**\n * 全局遮罩渲染器\n */\nexport namespace GlobalMaskRenderer {\n  export function renderMask(project: Project, mouseLocation: { x: number; y: number }, reverse = false) {\n    if (Settings.stealthModeMaskShape === \"circle\") {\n      renderCircleMask(project, mouseLocation, reverse);\n    } else if (Settings.stealthModeMaskShape === \"square\") {\n      renderSquareMask(project, mouseLocation, reverse);\n    } else if (Settings.stealthModeMaskShape === \"topLeft\") {\n      renderTopLeftQuadrantMask(project, mouseLocation, reverse);\n    } else if (Settings.stealthModeMaskShape === \"smartContext\") {\n      renderSmartContextMask(project, mouseLocation, reverse);\n    }\n  }\n\n  /**\n   * 渲染鼠标位置的圆形遮罩\n   * @param project\n   * @param mouseLocation\n   * @param reverse\n   */\n  function renderCircleMask(project: Project, mouseLocation: { x: number; y: number }, reverse = false) {\n    if (Settings.isStealthModeEnabled) {\n      const ctx = project.canvas.ctx;\n      // 设置合成模式为目标输入模式\n      if (reverse) {\n        ctx.globalCompositeOperation = \"destination-out\";\n      } else {\n        ctx.globalCompositeOperation = \"destination-in\";\n      }\n      // 获取鼠标位置\n      const mouseX = mouseLocation.x;\n      const mouseY = mouseLocation.y;\n      // 获取潜行模式半径\n      const scopeRadius = Settings.stealthModeScopeRadius;\n      // 绘制圆形区域\n      ctx.beginPath();\n      ctx.arc(mouseX, mouseY, scopeRadius, 0, Math.PI * 2);\n      ctx.fillStyle = \"rgba(0, 0, 0, 1)\"; // 设置填充颜色为完全不透明的黑色\n      ctx.fill();\n      // 恢复合成模式\n      ctx.globalCompositeOperation = \"source-over\";\n    }\n  }\n\n  /**\n   * 渲染鼠标位置的正方形遮罩\n   * @param project\n   * @param mouseLocation\n   * @param reverse\n   */\n  function renderSquareMask(project: Project, mouseLocation: { x: number; y: number }, reverse = false) {\n    if (Settings.isStealthModeEnabled) {\n      const ctx = project.canvas.ctx;\n      // 设置合成模式为目标输入模式\n      if (reverse) {\n        ctx.globalCompositeOperation = \"destination-out\";\n      } else {\n        ctx.globalCompositeOperation = \"destination-in\";\n      }\n      // 获取鼠标位置\n      const mouseX = mouseLocation.x;\n      const mouseY = mouseLocation.y;\n      // 获取潜行模式半径作为正方形边长\n      const sideLength = Settings.stealthModeScopeRadius;\n      // 计算正方形左上角坐标（以鼠标位置为中心）\n      const squareX = mouseX - sideLength / 2;\n      const squareY = mouseY - sideLength / 2;\n      // 绘制正方形区域\n      ctx.beginPath();\n      ctx.rect(squareX, squareY, sideLength, sideLength);\n      ctx.fillStyle = \"rgba(0, 0, 0, 1)\"; // 设置填充颜色为完全不透明的黑色\n      ctx.fill();\n      // 恢复合成模式\n      ctx.globalCompositeOperation = \"source-over\";\n    }\n  }\n\n  /**\n   * 渲染鼠标位置的左上角象限遮罩\n   * 只显示鼠标左上方的矩形区域\n   * @param project\n   * @param mouseLocation\n   * @param reverse\n   */\n  function renderTopLeftQuadrantMask(project: Project, mouseLocation: { x: number; y: number }, reverse = false) {\n    if (Settings.isStealthModeEnabled) {\n      const ctx = project.canvas.ctx;\n      // 设置合成模式\n      if (reverse) {\n        ctx.globalCompositeOperation = \"destination-out\";\n      } else {\n        ctx.globalCompositeOperation = \"destination-in\";\n      }\n\n      // 获取鼠标位置\n      const mouseX = mouseLocation.x;\n      const mouseY = mouseLocation.y;\n\n      // 绘制左上角象限矩形区域\n      // 从鼠标位置向左延伸到画布左边缘，向上延伸到画布上边缘\n      ctx.beginPath();\n      ctx.rect(\n        0, // 从画布最左边开始\n        0, // 从画布最上边开始\n        mouseX, // 宽度到鼠标x位置\n        mouseY, // 高度到鼠标y位置\n      );\n      ctx.fillStyle = \"rgba(0, 0, 0, 1)\";\n      ctx.fill();\n\n      // 恢复合成模式\n      ctx.globalCompositeOperation = \"source-over\";\n    }\n  }\n\n  /**\n   * 渲染鼠标位置的智能上下文遮罩\n   * 优先显示最小Section，其次显示悬浮实体范围\n   * @param project\n   * @param mouseLocation\n   * @param reverse\n   */\n  function renderSmartContextMask(project: Project, mouseLocation: { x: number; y: number }, reverse = false) {\n    if (Settings.isStealthModeEnabled) {\n      const ctx = project.canvas.ctx;\n      // 设置合成模式\n      if (reverse) {\n        ctx.globalCompositeOperation = \"destination-out\";\n      } else {\n        ctx.globalCompositeOperation = \"destination-in\";\n      }\n\n      // 关键修改：将屏幕坐标转换为世界坐标\n      const mouseScreenLocation = new Vector(mouseLocation.x, mouseLocation.y);\n      const mouseWorldLocation = project.renderer.transformView2World(mouseScreenLocation);\n\n      // 优先级1: 查找鼠标位置所在的最小Section（使用世界坐标）\n      const sectionsAtMouse = project.sectionMethods.getSectionsByInnerLocation(mouseWorldLocation);\n\n      if (sectionsAtMouse.length > 0) {\n        // 找到了Section，使用第一个（最深的）Section的矩形区域\n        const targetSection = sectionsAtMouse[0];\n        const worldRect = targetSection.collisionBox.getRectangle();\n\n        // 关键：将世界坐标的矩形转换回屏幕坐标来绘制\n        const screenRect = project.renderer.transformWorld2View(worldRect);\n\n        ctx.beginPath();\n        ctx.rect(screenRect.location.x, screenRect.location.y, screenRect.size.x, screenRect.size.y);\n        ctx.fillStyle = \"rgba(0, 0, 0, 1)\";\n        ctx.fill();\n      } else {\n        // 优先级2: 没有Section，查找悬浮的实体（使用世界坐标）\n        const hoverEntity = project.stageManager.findEntityByLocation(mouseWorldLocation);\n\n        if (hoverEntity) {\n          const worldRect = hoverEntity.collisionBox.getRectangle();\n\n          // 关键：将世界坐标的矩形转换回屏幕坐标来绘制\n          const screenRect = project.renderer.transformWorld2View(worldRect);\n\n          ctx.beginPath();\n          ctx.rect(screenRect.location.x, screenRect.location.y, screenRect.size.x, screenRect.size.y);\n          ctx.fillStyle = \"rgba(0, 0, 0, 1)\";\n          ctx.fill();\n        }\n        // 优先级3: 如果既没有Section也没有悬浮实体，不绘制任何遮罩（全部隐藏）\n      }\n\n      // 恢复合成模式\n      ctx.globalCompositeOperation = \"source-over\";\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/render/canvas2d/utilsRenderer/searchContentHighlightRenderer.tsx",
    "content": "import { Color } from \"@graphif/data-structures\";\nimport { Project, service } from \"@/core/Project\";\n\n/**\n * 高亮渲染所有搜索结果\n */\n@service(\"searchContentHighlightRenderer\")\nexport class SearchContentHighlightRenderer {\n  constructor(private readonly project: Project) {}\n\n  render(frameTickIndex: number) {\n    //\n    for (const stageObject of this.project.contentSearch.searchResultNodes) {\n      const rect = stageObject.collisionBox.getRectangle().expandFromCenter(10);\n      this.project.shapeRenderer.renderRect(\n        this.project.renderer.transformWorld2View(rect),\n        Color.Transparent,\n        this.project.stageStyleManager.currentStyle.effects.warningShadow.toNewAlpha(\n          Math.sin(frameTickIndex * 0.25) * 0.25 + 0.5,\n        ),\n        4,\n      );\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/render/domElement/RectangleElement.tsx",
    "content": "import { Color } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\n\n/**\n * 测试canvas上叠放dom元素\n */\nexport namespace RectangleElement {\n  export function div(rectangle: Rectangle, color: Color) {\n    const divElement = document.createElement(\"div\");\n    divElement.style.position = \"fixed\";\n    divElement.style.top = `${rectangle.location.y}px`;\n    divElement.style.left = `${rectangle.location.x}px`;\n    divElement.style.width = `${rectangle.size.x}px`;\n    divElement.style.height = `${rectangle.size.y}px`;\n    divElement.style.backgroundColor = color.toString();\n    document.body.appendChild(divElement);\n  }\n}\n"
  },
  {
    "path": "app/src/core/render/domElement/inputElement.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { EntityDashTipEffect } from \"@/core/service/feedbackService/effectEngine/concrete/EntityDashTipEffect\";\nimport { EntityShakeEffect } from \"@/core/service/feedbackService/effectEngine/concrete/EntityShakeEffect\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { getEnterKey } from \"@/utils/keyboardFunctions\";\nimport { isMac } from \"@/utils/platform\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { toast } from \"sonner\";\n\n/**\n * 主要用于解决canvas上无法输入的问题，用临时生成的jsdom元素透明地贴在上面\n */\n@service(\"inputElement\")\nexport class InputElement {\n  /**\n   * 在指定位置创建一个输入框\n   * @param location 输入框的左上角位置（相对于窗口左上角的位置）\n   * @param defaultValue 一开始的默认文本\n   * @param onChange 输入框文本改变函数\n   * @param style 输入框样式\n   * @returns\n   */\n  input(\n    location: Vector,\n    defaultValue: string,\n    onChange: (value: string) => void = () => {},\n    style: Partial<CSSStyleDeclaration> = {},\n  ): Promise<string> {\n    return new Promise((resolve) => {\n      const inputElement = document.createElement(\"input\");\n      inputElement.type = \"text\";\n      inputElement.value = defaultValue;\n\n      inputElement.style.position = \"fixed\";\n      inputElement.style.top = `${location.y}px`;\n      inputElement.style.left = `${location.x}px`;\n\n      inputElement.id = \"pg-input\";\n      inputElement.autocomplete = \"off\";\n      Object.assign(inputElement.style, style);\n      document.body.appendChild(inputElement);\n      inputElement.focus();\n      inputElement.select();\n      const removeElement = () => {\n        if (document.body.contains(inputElement)) {\n          try {\n            // 暂时关闭频繁弹窗报错。\n            document.body.removeChild(inputElement);\n          } catch (error) {\n            console.error(error);\n          }\n        }\n      };\n      const adjustSize = () => {\n        // inputElement.style.width = `${inputElement.scrollWidth + 2}px`;\n      };\n\n      const onOutsideClick = (event: Event) => {\n        if (!inputElement.contains(event.target as Node)) {\n          resolve(inputElement.value);\n          onChange(inputElement.value);\n          document.body.removeEventListener(\"mousedown\", onOutsideClick);\n          removeElement();\n        }\n      };\n      const onOutsideWheel = () => {\n        resolve(inputElement.value);\n        onChange(inputElement.value);\n        document.body.removeEventListener(\"mousedown\", onOutsideClick);\n        removeElement();\n      };\n\n      // 初始化\n      setTimeout(() => {\n        document.body.addEventListener(\"mousedown\", onOutsideClick);\n        document.body.addEventListener(\"touchstart\", onOutsideClick);\n        document.body.addEventListener(\"wheel\", onOutsideWheel);\n        adjustSize(); // 初始化时调整大小\n      }, 10);\n\n      inputElement.addEventListener(\"input\", () => {\n        this.project.controller.resetCountdownTimer();\n        onChange(inputElement.value);\n        adjustSize();\n      });\n      inputElement.addEventListener(\"blur\", () => {\n        resolve(inputElement.value);\n        onChange(inputElement.value);\n        document.body.removeEventListener(\"mousedown\", onOutsideClick);\n        removeElement();\n      });\n      let isComposing = false;\n      inputElement.addEventListener(\"compositionstart\", () => {\n        isComposing = true;\n      });\n      inputElement.addEventListener(\"compositionend\", () => {\n        // 防止此事件早于enter键按下触发（Mac的bug）\n        setTimeout(() => {\n          isComposing = false;\n        }, 100);\n      });\n      inputElement.addEventListener(\"keydown\", (event) => {\n        event.stopPropagation();\n\n        if (event.key === \"Enter\") {\n          if (!(event.isComposing || isComposing)) {\n            resolve(inputElement.value);\n            onChange(inputElement.value);\n            document.body.removeEventListener(\"mousedown\", onOutsideClick);\n            removeElement();\n          }\n        }\n        if (event.key === \"Tab\") {\n          // 防止tab切换到其他按钮\n          event.preventDefault();\n        }\n      });\n    });\n  }\n  /**\n   * 在指定位置创建一个多行输入框\n   * @param location 输入框的左上角位置（相对于窗口左上角的位置）\n   * @param defaultValue 一开始的默认文本\n   * @param onChange 输入框文本改变函数\n   * @param style 输入框样式\n   * @param selectAllWhenCreated 是否在创建时全选内容\n   * @returns\n   */\n  textarea(\n    defaultValue: string,\n    onChange: (value: string, element: HTMLTextAreaElement) => void = () => {},\n    style: Partial<CSSStyleDeclaration> = {},\n    selectAllWhenCreated = true,\n    // limitWidth = 100,\n  ): Promise<string> {\n    return new Promise((resolve) => {\n      const textareaElement = document.createElement(\"textarea\");\n      textareaElement.value = defaultValue;\n\n      textareaElement.id = \"pg-textarea\";\n      textareaElement.autocomplete = \"off\"; // 禁止使用自动填充内容，防止影响输入体验\n      // const initSizeView = this.project.textRenderer.measureMultiLineTextSize(\n      //   defaultValue,\n      //   Renderer.FONT_SIZE * this.project.camera.currentScale,\n      //   limitWidth,\n      //   1.5,\n      // );\n      Object.assign(textareaElement.style, style);\n      document.body.appendChild(textareaElement);\n\n      // web版在右键连线直接练到空白部分触发节点生成并编辑出现此元素时，防止触发右键菜单\n      textareaElement.addEventListener(\"contextmenu\", (event) => {\n        event.preventDefault();\n      });\n      textareaElement.focus();\n      if (selectAllWhenCreated) {\n        textareaElement.select();\n      }\n      // 以上这两部必须在appendChild之后执行\n      const removeElement = () => {\n        if (document.body.contains(textareaElement)) {\n          try {\n            document.body.removeChild(textareaElement);\n          } catch (error) {\n            console.error(error);\n          }\n        }\n      };\n\n      // 自动调整textarea的高度和宽度\n      const adjustSize = () => {\n        // 重置高度和宽度以获取正确的scrollHeight和scrollWidth\n        textareaElement.style.height = \"auto\";\n        textareaElement.style.height = `${textareaElement.scrollHeight}px`;\n        // textareaElement.style.width = `${textareaElement.scrollWidth + 2}px`;\n      };\n      setTimeout(() => {\n        adjustSize(); // 初始化时调整大小\n      }, 20);\n      textareaElement.addEventListener(\"blur\", () => {\n        resolve(textareaElement.value);\n        onChange(textareaElement.value, textareaElement);\n        removeElement();\n      });\n      textareaElement.addEventListener(\"input\", () => {\n        this.project.controller.resetCountdownTimer();\n        onChange(textareaElement.value, textareaElement);\n      });\n\n      // 在输入之前判断是否进行了撤销操作，此监听器在keydown之后触发\n      let hasTextareaUndone = false;\n      textareaElement.addEventListener(\"beforeinput\", (event: InputEvent) => {\n        if (event.inputType === \"historyUndo\") {\n          hasTextareaUndone = true;\n        }\n      });\n\n      let isComposing = false;\n      textareaElement.addEventListener(\"compositionstart\", () => {\n        isComposing = true;\n      });\n      textareaElement.addEventListener(\"compositionend\", () => {\n        // 防止此事件早于enter键按下触发（Mac的bug）\n        setTimeout(() => {\n          isComposing = false;\n        }, 100);\n      });\n      textareaElement.addEventListener(\"click\", () => {\n        console.log(\"click\");\n      });\n\n      textareaElement.addEventListener(\"keydown\", (event) => {\n        event.stopPropagation();\n        if (isMac) {\n          // 补充mac平台快捷键，home/end移动到行首/行尾\n          // shift+home/end 选中当前光标位置到行首/行尾\n          if (event.key === \"Home\") {\n            moveToLineStart(textareaElement, event.shiftKey);\n            event.preventDefault();\n          } else if (event.key === \"End\") {\n            moveToLineEnd(textareaElement, event.shiftKey);\n            event.preventDefault();\n          }\n        }\n\n        if (event.code === \"Backslash\") {\n          const currentSelectNode = this.project.stageManager.getConnectableEntity().find((node) => node.isSelected);\n          if (!currentSelectNode) return;\n          if (this.project.graphMethods.isCurrentNodeInTreeStructAndNotRoot(currentSelectNode)) {\n            // 广度生长节点\n            if (Settings.enableBackslashGenerateNodeInInput) {\n              event.preventDefault();\n              let currentValue = textareaElement.value;\n              if (currentValue.endsWith(\"、\")) {\n                // 删除结尾 防止把顿号写进去\n                currentValue = currentValue.slice(0, -1);\n              }\n              resolve(currentValue);\n              onChange(currentValue, textareaElement);\n              removeElement();\n              this.project.keyboardOnlyTreeEngine.onBroadGenerateNode();\n            }\n          }\n        } else if (event.code === \"Backspace\") {\n          // event.preventDefault();  // 不能这样否则就删除不了了。\n          if (textareaElement.value === \"\") {\n            if (Settings.textNodeBackspaceDeleteWhenEmpty) {\n              // 已经要删空了。\n              resolve(\"\");\n              onChange(\"\", textareaElement);\n              removeElement();\n              this.project.stageManager.deleteSelectedStageObjects();\n            } else {\n              // 整一个特效\n              this.addFailEffect(false);\n            }\n          }\n        } else if (event.key === \"Tab\") {\n          // 防止tab切换到其他按钮\n          event.preventDefault();\n          // const start = textareaElement.selectionStart;\n          const end = textareaElement.selectionEnd;\n          // textareaElement.value =\n          //   textareaElement.value.substring(0, start) + \"\\t\" + textareaElement.value.substring(end);\n          // textareaElement.selectionStart = start + 1;\n          // textareaElement.selectionEnd = start + 1;\n\n          // 获取光标后面的内容：\n          const afterText = textareaElement.value.substring(end);\n\n          // tab生长后是否选中后面的内容\n          let selectAllTextWhenCreated = true;\n          if (afterText.trim() !== \"\") {\n            // 如果后面有内容，则在当前节点删除后面的内容\n            textareaElement.value = textareaElement.value.substring(0, end);\n            selectAllTextWhenCreated = false;\n          }\n\n          resolve(textareaElement.value);\n          onChange(textareaElement.value, textareaElement);\n          removeElement();\n          // xmind用户\n          this.project.keyboardOnlyTreeEngine.onDeepGenerateNode(afterText, selectAllTextWhenCreated);\n        } else if (event.key === \"Escape\") {\n          event.preventDefault(); // 这里可以阻止mac退出全屏\n          // Escape 是通用的取消编辑的快捷键\n          resolve(textareaElement.value);\n          onChange(textareaElement.value, textareaElement);\n          removeElement();\n        } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === \"z\") {\n          // 如果按下了撤销键但没撤销，则textarea撤销栈已空，认为用户的想法是退出编辑\n          setTimeout(() => {\n            if (!hasTextareaUndone) {\n              resolve(textareaElement.value);\n              onChange(textareaElement.value, textareaElement);\n              removeElement();\n            }\n          }, 10); // 延迟10ms再检测撤销操作是否完成\n          hasTextareaUndone = false; // 重置标志\n        }\n\n        const breakLine = () => {\n          const start = textareaElement.selectionStart;\n          const end = textareaElement.selectionEnd;\n          textareaElement.value =\n            textareaElement.value.substring(0, start) + \"\\n\" + textareaElement.value.substring(end);\n          textareaElement.selectionStart = start + 1;\n          textareaElement.selectionEnd = start + 1;\n          // 调整\n          adjustSize(); // 调整textarea\n          onChange(textareaElement.value, textareaElement); // 调整canvas渲染上去的框大小\n        };\n\n        const exitEditMode = () => {\n          resolve(textareaElement.value);\n          onChange(textareaElement.value, textareaElement);\n          removeElement();\n        };\n\n        if (event.key === \"Enter\") {\n          event.preventDefault();\n          // 使用event.isComposing和自定义isComposing双重检查\n          if (!(event.isComposing || isComposing)) {\n            const enterKeyDetail = getEnterKey(event);\n            if (Settings.textNodeExitEditMode === enterKeyDetail) {\n              // 用户想退出编辑\n              exitEditMode();\n              this.addSuccessEffect();\n            } else if (Settings.textNodeContentLineBreak === enterKeyDetail) {\n              // 用户想换行\n              breakLine();\n            } else {\n              // 用户可能记错了快捷键\n              this.addFailEffect();\n            }\n          }\n        }\n      });\n    });\n  }\n\n  private addSuccessEffect() {\n    const textNodes = this.project.stageManager.getTextNodes().filter((textNode) => textNode.isEditing);\n    for (const textNode of textNodes) {\n      this.project.effects.addEffect(new EntityDashTipEffect(50, textNode.collisionBox.getRectangle()));\n    }\n  }\n\n  private addFailEffect(withToast = true) {\n    const textNodes = this.project.stageManager.getTextNodes().filter((textNode) => textNode.isEditing);\n    for (const textNode of textNodes) {\n      this.project.effects.addEffect(EntityShakeEffect.fromEntity(textNode));\n    }\n    if (withToast) {\n      toast(\"您可能记错了退出或换行的控制设置\");\n    }\n  }\n\n  constructor(private readonly project: Project) {}\n}\n\n// 移动到当前行的行首\nfunction moveToLineStart(textarea: HTMLTextAreaElement, isSelecting = false) {\n  const value = textarea.value;\n  const start = textarea.selectionStart;\n  const end = textarea.selectionEnd;\n\n  // 找到当前行的开始位置\n  let lineStart = 0;\n  for (let i = start - 1; i >= 0; i--) {\n    if (value[i] === \"\\n\") {\n      lineStart = i + 1;\n      break;\n    }\n  }\n\n  if (isSelecting) {\n    // Shift+Home: 选中从当前光标到行首\n    // 保持selectionEnd不变（当前光标位置），移动selectionStart到行首\n    if (start === end) {\n      // 没有选中文本时\n      textarea.selectionStart = lineStart;\n      textarea.selectionEnd = end;\n    } else {\n      // 已经有选中文本时，扩展选中范围到行首\n      textarea.selectionStart = lineStart;\n      // selectionEnd保持不变\n    }\n  } else {\n    // Home: 只移动光标到行首\n    textarea.selectionStart = lineStart;\n    textarea.selectionEnd = lineStart;\n  }\n}\n\n// 移动到当前行的行尾\nfunction moveToLineEnd(textarea: HTMLTextAreaElement, isSelecting = false) {\n  const value = textarea.value;\n  const start = textarea.selectionStart;\n  const end = textarea.selectionEnd;\n  const length = value.length;\n\n  // 找到当前行的结束位置\n  let lineEnd = length;\n  for (let i = end; i < length; i++) {\n    if (value[i] === \"\\n\") {\n      lineEnd = i;\n      break;\n    }\n  }\n\n  if (isSelecting) {\n    // Shift+End: 选中从当前光标到行尾\n    // 保持selectionStart不变（当前光标位置），移动selectionEnd到行尾\n    if (start === end) {\n      // 没有选中文本时\n      textarea.selectionStart = start;\n      textarea.selectionEnd = lineEnd;\n    } else {\n      // 已经有选中文本时，扩展选中范围到行尾\n      textarea.selectionEnd = lineEnd;\n      // selectionStart保持不变\n    }\n  } else {\n    // End: 只移动光标到行尾\n    textarea.selectionStart = lineEnd;\n    textarea.selectionEnd = lineEnd;\n  }\n}\n"
  },
  {
    "path": "app/src/core/render/svg/README.md",
    "content": "注意：这里不是在Canvas上渲染svg，而是直接通过一些参数生成svg文件或svg代码。\n"
  },
  {
    "path": "app/src/core/render/svg/SvgUtils.tsx",
    "content": "import { Color, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { v4 } from \"uuid\";\nimport { FONT, getTextSize } from \"@/utils/font\";\nimport { Renderer } from \"@/core/render/canvas2d/renderer\";\n\n/**\n * 专门存放生成svg的东西\n */\nexport namespace SvgUtils {\n  export function line(start: Vector, end: Vector, strokeColor: Color, strokeWidth: number): React.ReactNode {\n    return (\n      <line\n        key={v4()}\n        x1={start.x.toFixed(1)}\n        y1={start.y.toFixed(1)}\n        x2={end.x.toFixed(1)}\n        y2={end.y.toFixed(1)}\n        stroke={strokeColor.toString()}\n        strokeWidth={strokeWidth}\n      />\n    );\n  }\n\n  export function textFromCenter(text: string, location: Vector, fontSize: number, color: Color) {\n    return (\n      // 这里居中实际上还没完全居中，垂直方向有点问题\n      <text\n        x={location.x}\n        y={location.y + Renderer.NODE_PADDING}\n        key={v4()}\n        fill={color.toString()}\n        fontSize={fontSize}\n        textAnchor=\"middle\"\n        fontFamily={FONT}\n      >\n        {text}\n      </text>\n    );\n  }\n\n  export function textFromLeftTop(text: string, location: Vector, fontSize: number, color: Color) {\n    const textSize = getTextSize(text, fontSize);\n    return (\n      <text\n        x={(location.x + Renderer.NODE_PADDING).toFixed(1)}\n        y={(location.y + textSize.y / 2 + Renderer.NODE_PADDING).toFixed(1)}\n        key={v4()}\n        fill={color.toString()}\n        fontSize={fontSize}\n        textAnchor=\"start\"\n        fontFamily={FONT}\n      >\n        {text}\n      </text>\n    );\n  }\n\n  export function multiLineTextFromLeftTop(\n    text: string,\n    location: Vector,\n    fontSize: number,\n    color: Color,\n    lineHeight: number = 1.5,\n  ) {\n    const textSizeHeight = getTextSize(text, fontSize).y;\n    const lines = text.split(\"\\n\");\n    const result: React.ReactNode[] = [];\n    for (let y = 0; y < lines.length; y++) {\n      const line = lines[y];\n      result.push(textFromLeftTop(line, location.add(new Vector(0, y * textSizeHeight * lineHeight)), fontSize, color));\n    }\n    return <>{result.map((item) => item)}</>;\n  }\n\n  export function rectangle(rectangle: Rectangle, fillColor: Color, strokeColor: Color, strokeWidth: number) {\n    return (\n      <rect\n        key={v4()}\n        x={rectangle.location.x.toFixed(1)}\n        y={rectangle.location.y.toFixed(1)}\n        width={rectangle.size.x.toFixed(1)}\n        height={rectangle.size.y.toFixed(1)}\n        rx={Renderer.NODE_ROUNDED_RADIUS}\n        ry={Renderer.NODE_ROUNDED_RADIUS}\n        fill={fillColor.toString()}\n        stroke={strokeColor.toString()}\n        strokeWidth={strokeWidth}\n      />\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/AssetsRepository.tsx",
    "content": "export namespace AssetsRepository {\n  const repo = \"https://assets.graphif.dev\";\n\n  /**\n   * @param path 开头不能是`/`\n   */\n  export async function fetchFile<T extends string>(path: T extends `/${string}` ? never : T): Promise<Uint8Array> {\n    const r = await fetch(AssetsRepository.getGuideFileUrl(path));\n    if (!r.ok) throw new Error(`Failed to fetch asset: ${r.status} ${r.statusText}`);\n    return new Uint8Array(await r.arrayBuffer());\n  }\n\n  export function getGuideFileUrl<T extends string>(path: T extends `/${string}` ? never : T) {\n    return `${repo}/${path}`;\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/FeatureFlags.tsx",
    "content": "export namespace FeatureFlags {\n  /**\n   * 用户登录、注册以及所有和云服务有关的功能\n   */\n  export const USER = \"LR_API_BASE_URL\" in import.meta.env && \"LR_TURNSTILE_SITE_KEY\" in import.meta.env;\n  /**\n   * AI扩展节点等所有和AI有关的功能\n   */\n  export const AI = \"LR_API_BASE_URL\" in import.meta.env;\n  export const TELEMETRY = \"LR_API_BASE_URL\" in import.meta.env;\n}\n"
  },
  {
    "path": "app/src/core/service/GlobalMenu.tsx",
    "content": "import { Dialog } from \"@/components/ui/dialog\";\nimport {\n  Menubar,\n  MenubarContent,\n  MenubarItem,\n  MenubarMenu,\n  MenubarSeparator,\n  MenubarSub,\n  MenubarSubContent,\n  MenubarSubTrigger,\n  MenubarTrigger,\n} from \"@/components/ui/menubar\";\n\nimport { loadAllServicesAfterInit, loadAllServicesBeforeInit } from \"@/core/loadAllServices\";\nimport { Project, ProjectState } from \"@/core/Project\";\nimport { activeProjectAtom, isClassroomModeAtom, isDevAtom, projectsAtom, store } from \"@/state\";\nimport AIToolsWindow from \"@/sub/AIToolsWindow\";\nimport AIWindow from \"@/sub/AIWindow\";\nimport AttachmentsWindow from \"@/sub/AttachmentsWindow\";\nimport LogicNodePanel from \"@/sub/AutoComputeWindow\";\nimport BackgroundManagerWindow from \"@/sub/BackgroundManagerWindow\";\nimport ExportPngWindow from \"@/sub/ExportPngWindow\";\nimport FindWindow from \"@/sub/FindWindow\";\nimport GenerateNodeTree, {\n  GenerateNodeGraph,\n  GenerateNodeMermaid,\n  GenerateNodeTreeByMarkdown,\n} from \"@/sub/GenerateNodeWindow\";\nimport LoginWindow from \"@/sub/LoginWindow\";\nimport NewExportPngWindow from \"@/sub/NewExportPngWindow\";\nimport NodeDetailsWindow from \"@/sub/NodeDetailsWindow\";\nimport OnboardingWindow from \"@/sub/OnboardingWindow\";\nimport RecentFilesWindow from \"@/sub/RecentFilesWindow\";\nimport ReferencesWindow from \"@/sub/ReferencesWindow\";\nimport SettingsWindow from \"@/sub/SettingsWindow\";\nimport TagWindow from \"@/sub/TagWindow\";\nimport TestWindow from \"@/sub/TestWindow\";\nimport UserWindow from \"@/sub/UserWindow\";\nimport { getDeviceId } from \"@/utils/otherApi\";\nimport { PathString } from \"@/utils/pathString\";\nimport { Color, Vector } from \"@graphif/data-structures\";\nimport { deserialize, serialize } from \"@graphif/serializer\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { Decoder } from \"@msgpack/msgpack\";\nimport { getVersion } from \"@tauri-apps/api/app\";\nimport { appCacheDir, dataDir, join, tempDir } from \"@tauri-apps/api/path\";\nimport { getCurrentWindow } from \"@tauri-apps/api/window\";\nimport { open, save } from \"@tauri-apps/plugin-dialog\";\nimport { exists, readFile, writeFile } from \"@tauri-apps/plugin-fs\";\nimport { open as shellOpen } from \"@tauri-apps/plugin-shell\";\nimport { useAtom } from \"jotai\";\nimport {\n  Airplay,\n  AppWindow,\n  Archive,\n  Axe,\n  BookOpen,\n  BookOpenText,\n  Bot,\n  Bug,\n  BugPlay,\n  CircleAlert,\n  CircleDot,\n  CircleMinus,\n  CirclePlus,\n  Columns4,\n  Dices,\n  Dumbbell,\n  ExternalLink,\n  File,\n  FileBadge,\n  FileBox,\n  FileClock,\n  FileCode,\n  FileDigit,\n  FileDown,\n  FileImage,\n  FileInput,\n  FileOutput,\n  FilePlus,\n  FileSpreadsheet,\n  FolderClock,\n  FolderCog,\n  FolderOpen,\n  FolderTree,\n  Fullscreen,\n  GitCompareArrows,\n  Globe,\n  Grip,\n  Images,\n  Keyboard,\n  LayoutGrid,\n  Link,\n  MapPin,\n  MessageCircleWarning,\n  MousePointer2,\n  Move3d,\n  Network,\n  OctagonX,\n  Palette,\n  Paperclip,\n  PictureInPicture2,\n  Rabbit,\n  Radiation,\n  Redo,\n  RefreshCcwDot,\n  Rows4,\n  Save,\n  Scaling,\n  Search,\n  SettingsIcon,\n  Sparkles,\n  SquareDashedMousePointer,\n  SquareSquare,\n  Tag,\n  TestTube2,\n  TextQuote,\n  Tv,\n  Undo,\n  VectorSquare,\n  VenetianMask,\n  View,\n  Workflow,\n  Wrench,\n  X,\n} from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport { URI } from \"vscode-uri\";\nimport { ProjectUpgrader } from \"../stage/ProjectUpgrader\";\nimport { Entity } from \"../stage/stageObject/abstract/StageEntity\";\nimport { LineEdge } from \"../stage/stageObject/association/LineEdge\";\nimport { CollisionBox } from \"../stage/stageObject/collisionBox/collisionBox\";\nimport { TextNode } from \"../stage/stageObject/entity/TextNode\";\nimport { AssetsRepository } from \"./AssetsRepository\";\nimport { KeyBindsUI } from \"./controlService/shortcutKeysEngine/KeyBindsUI\";\nimport { RecentFileManager } from \"./dataFileService/RecentFileManager\";\nimport { generateKeyboardLayout } from \"./dataGenerateService/generateFromFolderEngine/GenerateFromFolderEngine\";\nimport { DragFileIntoStageEngine } from \"./dataManageService/dragFileIntoStageEngine/dragFileIntoStageEngine\";\nimport { FeatureFlags } from \"./FeatureFlags\";\nimport { Settings } from \"./Settings\";\nimport { SubWindow } from \"./SubWindow\";\nimport { Telemetry } from \"./Telemetry\";\n\nconst Content = MenubarContent;\nconst Item = MenubarItem;\nconst Menu = MenubarMenu;\nconst Separator = MenubarSeparator;\nconst Sub = MenubarSub;\nconst SubContent = MenubarSubContent;\nconst SubTrigger = MenubarSubTrigger;\nconst Trigger = MenubarTrigger;\n\nexport function GlobalMenu() {\n  // const [projects, setProjects] = useAtom(projectsAtom);\n  const [activeProject] = useAtom(activeProjectAtom);\n  const [isClassroomMode, setIsClassroomMode] = useAtom(isClassroomModeAtom);\n  const [recentFiles, setRecentFiles] = useState<RecentFileManager.RecentFile[]>([]);\n  const [version, setVersion] = useState<string>(\"\");\n  const [isUnstableVersion, setIsUnstableVersion] = useState(false);\n  const [isDev, setIsDev] = useAtom(isDevAtom);\n  const subWindows = SubWindow.use();\n  const { t } = useTranslation(\"globalMenu\");\n\n  useEffect(() => {\n    refresh();\n  }, []);\n\n  async function refresh() {\n    await RecentFileManager.sortTimeRecentFiles();\n    setRecentFiles(await RecentFileManager.getRecentFiles());\n    const ver = await getVersion();\n    setVersion(ver);\n    setIsUnstableVersion(\n      ver.includes(\"alpha\") ||\n        ver.includes(\"beta\") ||\n        ver.includes(\"rc\") ||\n        ver.includes(\"dev\") ||\n        ver.includes(\"nightly\"),\n    );\n    setIsDev(ver.includes(\"dev\"));\n  }\n\n  return (\n    <Menubar className=\"shrink-0\">\n      {/* 文件 */}\n      <Menu>\n        <Trigger>\n          <File />\n          <span className=\"hidden sm:inline\">{t(\"file.title\")}</span>\n        </Trigger>\n        <Content>\n          <Item onClick={() => onNewDraft()}>\n            <FilePlus />\n            {t(\"file.new\")}\n          </Item>\n          <Item\n            disabled={!activeProject || activeProject.isDraft}\n            onClick={() => {\n              createFileAtCurrentProjectDir(activeProject!, refresh);\n            }}\n          >\n            <FilePlus />\n            在当前项目同一文件夹下新建prg文件\n          </Item>\n          <Item\n            onClick={async () => {\n              await onOpenFile(undefined, \"GlobalMenu\");\n              await refresh();\n            }}\n          >\n            <FolderOpen />\n            {t(\"file.open\")} （.prg / .json）\n          </Item>\n          <Item\n            disabled={!activeProject || activeProject.isDraft}\n            onClick={async () => {\n              const path = await join(activeProject!.uri.fsPath, \"..\");\n              await shellOpen(path);\n            }}\n          >\n            <FolderOpen />\n            打开当前工程文件所在文件夹\n          </Item>\n          <Sub>\n            <SubTrigger\n              onMouseEnter={() => {\n                // 刷新最近打开的文件列表\n                refresh();\n              }}\n            >\n              <FileClock />\n              {t(\"file.recentFiles\")}\n            </SubTrigger>\n            <SubContent>\n              {recentFiles.slice(0, 12).map((file) => (\n                <Item\n                  key={file.uri.toString()}\n                  onClick={async () => {\n                    await onOpenFile(file.uri, \"GlobalMenu最近打开的文件\");\n                    await refresh();\n                  }}\n                >\n                  <File />\n                  {PathString.absolute2file(decodeURI(file.uri.toString()))}\n                </Item>\n              ))}\n              {recentFiles.length > 12 && (\n                <>\n                  <Separator />\n                  <span className=\"p-2 text-sm opacity-50\">注：此处仅显示12个</span>\n                </>\n              )}\n\n              {/* <Item\n                variant=\"destructive\"\n                onClick={async () => {\n                  await RecentFileManager.clearAllRecentFiles();\n                  await refresh();\n                }}\n              >\n                <Trash />\n                {t(\"file.clear\")}\n              </Item> */}\n            </SubContent>\n          </Sub>\n          <Item\n            onClick={() => {\n              RecentFilesWindow.open();\n            }}\n          >\n            <LayoutGrid />\n            查看全部历史文件\n          </Item>\n          <Separator />\n          <Item\n            disabled={!activeProject}\n            onClick={() => {\n              activeProject?.save();\n            }}\n          >\n            <Save />\n            {t(\"file.save\")}\n          </Item>\n          <Item\n            disabled={!activeProject}\n            onClick={async () => {\n              const path = await save({\n                title: t(\"file.saveAs\"),\n                filters: [{ name: \"Project Graph\", extensions: [\"prg\"] }],\n              });\n              if (!path) return;\n              activeProject!.uri = URI.file(path);\n              await RecentFileManager.addRecentFileByUri(activeProject!.uri);\n              await activeProject!.save();\n            }}\n          >\n            <FileDown />\n            {t(\"file.saveAs\")}\n          </Item>\n          <Item\n            onClick={async () => {\n              activeProject!.autoSaveBackup.manualBackup();\n            }}\n          >\n            <Archive />\n            手动创建备份（防坏档）\n          </Item>\n          <Item\n            onClick={async () => {\n              if (Settings.autoBackupCustomPath && Settings.autoBackupCustomPath.trim()) {\n                await shellOpen(Settings.autoBackupCustomPath.trim());\n              } else {\n                toast.error(\"未设置自定义备份路径\");\n              }\n            }}\n          >\n            <FolderClock />\n            打开自定义备份文件夹\n          </Item>\n          <Item\n            onClick={async () => {\n              const path = await appCacheDir();\n              await shellOpen(path);\n            }}\n          >\n            <FolderClock />\n            打开默认备份文件夹\n          </Item>\n          <Separator />\n          <Sub>\n            <SubTrigger>\n              <FileInput />\n              {t(\"file.import\")}\n            </SubTrigger>\n            <SubContent>\n              <Item\n                disabled={!activeProject}\n                onClick={async () => {\n                  const path = await open({\n                    title: \"打开文件夹\",\n                    directory: true,\n                    multiple: false,\n                    filters: [],\n                  });\n                  console.log(path);\n                  if (!path) {\n                    return;\n                  }\n                  activeProject!.generateFromFolder.generateFromFolder(path);\n                }}\n              >\n                <FolderTree />\n                {t(\"file.importFromFolder\")}\n              </Item>\n              <Item\n                disabled={!activeProject}\n                onClick={async () => {\n                  const path = await open({\n                    title: \"打开文件夹\",\n                    directory: true,\n                    multiple: false,\n                    filters: [],\n                  });\n                  if (!path) {\n                    return;\n                  }\n                  if (typeof path === \"string\") {\n                    activeProject!.generateFromFolder.generateTreeFromFolder(path);\n                  }\n                }}\n              >\n                <FolderTree />\n                {t(\"file.importTreeFromFolder\")}\n              </Item>\n              <Item\n                disabled={!activeProject}\n                onClick={async () => {\n                  await generateKeyboardLayout(activeProject!);\n                  toast.success(\"键盘布局图已生成\");\n                }}\n              >\n                <Keyboard />\n                {t(\"file.generateKeyboardLayout\")}\n              </Item>\n              <Item\n                disabled={!activeProject}\n                onClick={async () => {\n                  const pathList = await open({\n                    title: \"打开文件\",\n                    directory: false,\n                    multiple: true,\n                    filters: [{ name: \"图片文件\", extensions: [\"png\", \"jpg\", \"jpeg\", \"webp\"] }],\n                  });\n                  console.log(pathList);\n                  if (!pathList) {\n                    return;\n                  }\n                  for (const path of pathList) {\n                    const ext = path.split(\".\").pop()?.toLowerCase() ?? \"png\";\n                    const mimeMap: Record<string, string> = {\n                      png: \"image/png\",\n                      jpg: \"image/jpeg\",\n                      jpeg: \"image/jpeg\",\n                      webp: \"image/webp\",\n                    };\n                    DragFileIntoStageEngine.handleDropImage(activeProject!, path, mimeMap[ext] ?? \"image/png\");\n                  }\n                }}\n              >\n                <Images />\n                导入图片（PNG/JPG/WEBP）\n              </Item>\n              <Item\n                disabled={!activeProject}\n                onClick={async () => {\n                  const pathList = await open({\n                    title: \"打开文件\",\n                    directory: false,\n                    multiple: true,\n                    filters: [{ name: \"*\", extensions: [\"svg\"] }],\n                  });\n                  console.log(pathList);\n                  if (!pathList) {\n                    return;\n                  }\n                  for (const path of pathList) {\n                    DragFileIntoStageEngine.handleDropSvg(activeProject!, path);\n                  }\n                }}\n              >\n                <Images />\n                导入SVG图片\n              </Item>\n              <Separator />\n              <Item disabled className=\"text-sm\">\n                更多导入请访问 顶部导航栏 -\n                <Axe className=\"mx-0.5 inline-block h-3 w-3 align-middle\" />\n                操作 -\n                <Sparkles className=\"mx-0.5 inline-block h-3 w-3 align-middle\" />\n                生成\n              </Item>\n            </SubContent>\n          </Sub>\n\n          {/* 各种导出 */}\n          <Sub>\n            <SubTrigger disabled={!activeProject}>\n              <FileOutput />\n              {t(\"file.export\")}\n            </SubTrigger>\n            <SubContent>\n              <Sub>\n                <SubTrigger>\n                  <FileCode />\n                  SVG\n                </SubTrigger>\n                <SubContent>\n                  <Item\n                    onClick={async () => {\n                      const path = await save({\n                        title: t(\"file.exportAsSVG\"),\n                        filters: [{ name: \"Scalable Vector Graphics\", extensions: [\"svg\"] }],\n                      });\n                      if (!path) return;\n                      await activeProject!.stageExportSvg.exportStageToSVGFile(path);\n                    }}\n                  >\n                    <FileDigit />\n                    {t(\"file.exportAll\")}\n                  </Item>\n                  <Item\n                    onClick={async () => {\n                      const path = await save({\n                        title: t(\"file.exportAsSVG\"),\n                        filters: [{ name: \"Scalable Vector Graphics\", extensions: [\"svg\"] }],\n                      });\n                      if (!path) return;\n                      await activeProject!.stageExportSvg.exportSelectedToSVGFile(path);\n                    }}\n                  >\n                    <MousePointer2 />\n                    {t(\"file.exportSelected\")}\n                  </Item>\n                </SubContent>\n              </Sub>\n              <Sub>\n                <SubTrigger>\n                  <FileImage />\n                  PNG\n                </SubTrigger>\n                <SubContent>\n                  <Item onClick={() => ExportPngWindow.open()}>\n                    <FileImage />\n                    PNG（旧版）\n                  </Item>\n                  <Item\n                    onClick={async () => {\n                      // 导出选中内容为PNG（新版）\n                      const selectedEntities = activeProject!.stageManager.getSelectedEntities();\n                      if (selectedEntities.length === 0) {\n                        toast.warning(\"没有选中任何内容\");\n                        return;\n                      }\n                      NewExportPngWindow.open(\"selected\");\n                    }}\n                  >\n                    <MousePointer2 />\n                    导出选中内容为PNG\n                  </Item>\n                </SubContent>\n              </Sub>\n              {/*<Item>\n                <FileType />\n                Markdown\n              </Item>*/}\n              <Sub>\n                <SubTrigger>\n                  <TextQuote />\n                  {t(\"file.plainText\")}\n                </SubTrigger>\n                <SubContent>\n                  {/* 导出 全部 网状关系 */}\n                  <Item\n                    onClick={() => {\n                      if (!activeProject) {\n                        toast.warning(t(\"file.noProject\"));\n                        return;\n                      }\n                      const entities = activeProject.stageManager.getEntities();\n                      const result = activeProject.stageExport.getPlainTextByEntities(entities);\n                      Dialog.copy(t(\"file.exportSuccess\"), \"\", result);\n                    }}\n                  >\n                    <VectorSquare />\n                    {t(\"file.plainTextType.exportAllNodeGraph\")}\n                  </Item>\n                  {/* 导出 选中 网状关系 */}\n                  <Item\n                    onClick={() => {\n                      if (!activeProject) {\n                        toast.warning(t(\"file.noProject\"));\n                        return;\n                      }\n                      const entities = activeProject.stageManager.getEntities();\n                      const selectedEntities = entities.filter((entity) => entity.isSelected);\n                      const result = activeProject.stageExport.getPlainTextByEntities(selectedEntities);\n                      Dialog.copy(t(\"file.exportSuccess\"), \"\", result);\n                    }}\n                  >\n                    <VectorSquare />\n                    {t(\"file.plainTextType.exportSelectedNodeGraph\")}\n                  </Item>\n                  {/* 导出 选中 树状关系 （纯文本缩进） */}\n                  <Item\n                    onClick={() => {\n                      const textNode = getOneSelectedTextNodeWhenExportingPlainText(activeProject);\n                      if (textNode) {\n                        const result = activeProject!.stageExport.getTabStringByTextNode(textNode);\n                        Dialog.copy(t(\"file.exportSuccess\"), \"\", result);\n                      }\n                    }}\n                  >\n                    <Network />\n                    {t(\"file.plainTextType.exportSelectedNodeTree\")}\n                  </Item>\n                  {/* 导出 选中 树状关系 （Markdown格式） */}\n                  <Item\n                    onClick={() => {\n                      const textNode = getOneSelectedTextNodeWhenExportingPlainText(activeProject);\n                      if (textNode) {\n                        const result = activeProject!.stageExport.getMarkdownStringByTextNode(textNode);\n                        Dialog.copy(t(\"file.exportSuccess\"), \"\", result);\n                      }\n                    }}\n                  >\n                    <Network />\n                    {t(\"file.plainTextType.exportSelectedNodeTreeMarkdown\")}\n                  </Item>\n                  {/* 导出 选中 网状嵌套关系 （mermaid格式） */}\n                  <Item\n                    onClick={() => {\n                      const selectedEntities = activeProject!.stageManager.getSelectedEntities();\n                      const result = activeProject!.stageExport.getMermaidTextByEntites(selectedEntities);\n                      Dialog.copy(t(\"file.exportSuccess\"), \"\", result);\n                    }}\n                  >\n                    <SquareSquare />\n                    {t(\"file.plainTextType.exportSelectedNodeGraphMermaid\")}\n                  </Item>\n                </SubContent>\n              </Sub>\n            </SubContent>\n          </Sub>\n\n          <Separator />\n\n          {/* 附件管理器 */}\n          <Item disabled={!activeProject} onClick={() => AttachmentsWindow.open()}>\n            <Paperclip />\n            {t(\"file.attachments\")}\n          </Item>\n\n          {/* 标签管理器 */}\n          <Item\n            disabled={!activeProject}\n            onClick={() => {\n              TagWindow.open();\n            }}\n          >\n            <Tag />\n            {t(\"file.tags\")}\n          </Item>\n\n          {/* 引用管理器 */}\n          <Item\n            disabled={!activeProject || activeProject.isDraft}\n            onClick={() => {\n              ReferencesWindow.open(activeProject!.uri);\n            }}\n          >\n            <Link />\n            引用管理器\n          </Item>\n\n          {/* 背景管理器 */}\n          <Item\n            disabled={!activeProject}\n            onClick={() => {\n              BackgroundManagerWindow.open();\n            }}\n          >\n            <Images />\n            背景管理器\n          </Item>\n        </Content>\n      </Menu>\n\n      {/* 视野 */}\n      <Menu>\n        <Trigger disabled={!activeProject}>\n          <View />\n          <span className=\"hidden sm:inline\">{t(\"view.title\")}</span>\n        </Trigger>\n        <Content>\n          <Item\n            onClick={() => {\n              activeProject?.camera.reset();\n            }}\n          >\n            <Fullscreen />\n            {t(\"view.resetViewAll\")}\n          </Item>\n          <Item\n            onClick={() => {\n              activeProject?.camera.resetBySelected();\n            }}\n          >\n            <SquareDashedMousePointer />\n            {t(\"view.resetViewSelected\")}\n          </Item>\n          <Item\n            onClick={() => {\n              activeProject?.camera.resetScale();\n            }}\n          >\n            <Scaling />\n            {t(\"view.resetViewScale\")}\n          </Item>\n          <Item\n            onClick={() => {\n              activeProject?.camera.resetLocationToZero();\n            }}\n          >\n            <MapPin />\n            {t(\"view.moveViewToOrigin\")}\n          </Item>\n          <Item\n            onClick={async () => {\n              if (!activeProject) return;\n              let isValid = false;\n              let scale = 1;\n\n              while (!isValid) {\n                const scaleStr = await Dialog.input(\"设置自定义视野大小\", \"请输入缩放比例（推荐范围：0.1-10）\", {\n                  defaultValue: scale.toString(),\n                });\n\n                if (!scaleStr) return; // 用户取消\n\n                const parsedScale = parseFloat(scaleStr);\n                if (isNaN(parsedScale)) {\n                  toast.error(\"请输入有效的数字\");\n                } else if (parsedScale <= 0) {\n                  toast.error(\"缩放比例必须大于0\");\n                } else if (parsedScale > 100) {\n                  toast.error(\"缩放比例不能超过100\");\n                } else {\n                  scale = parsedScale;\n                  isValid = true;\n                }\n              }\n\n              // 直接修改camera内部属性\n              activeProject.camera.targetScale = scale;\n              activeProject.camera.currentScale = scale;\n            }}\n          >\n            <Scaling />\n            自定义视野大小\n          </Item>\n          <Item\n            onClick={async () => {\n              if (!activeProject) return;\n\n              // 获取并验证X坐标\n              let x = 0;\n              let xValid = false;\n              while (!xValid) {\n                const xStr = await Dialog.input(\"设置自定义视野位置\", \"请输入X坐标\", {\n                  defaultValue: x.toString(),\n                });\n\n                if (!xStr) return; // 用户取消\n\n                const parsedX = parseFloat(xStr);\n                if (isNaN(parsedX)) {\n                  toast.error(\"请输入有效的数字\");\n                } else {\n                  x = parsedX;\n                  xValid = true;\n                }\n              }\n\n              // 获取并验证Y坐标\n              let y = 0;\n              let yValid = false;\n              while (!yValid) {\n                const yStr = await Dialog.input(\"设置自定义视野位置\", \"请输入Y坐标\", {\n                  defaultValue: y.toString(),\n                });\n\n                if (!yStr) return; // 用户取消\n\n                const parsedY = parseFloat(yStr);\n                if (isNaN(parsedY)) {\n                  toast.error(\"请输入有效的数字\");\n                } else {\n                  y = parsedY;\n                  yValid = true;\n                }\n              }\n\n              // 直接修改camera内部位置属性\n              activeProject.camera.location.x = x;\n              activeProject.camera.location.y = y;\n            }}\n          >\n            <MapPin />\n            自定义视野位置\n          </Item>\n          <Item\n            onClick={() => {\n              if (!activeProject) return;\n              activeProject.camera.clearMoveCommander();\n              activeProject.camera.speed = Vector.getZero();\n            }}\n          >\n            <OctagonX />\n            停止漂移\n          </Item>\n          <Item\n            onClick={() => {\n              if (!activeProject) return;\n              const entities = activeProject.stage.filter((entity) => entity instanceof Entity);\n              if (entities.length === 0) return;\n              const randomEntity = entities[Math.floor(Math.random() * entities.length)];\n              activeProject.stageManager.clearSelectAll();\n              randomEntity.isSelected = true;\n              activeProject.camera.resetBySelected();\n            }}\n          >\n            <Dices />\n            聚焦到随机实体\n          </Item>\n        </Content>\n      </Menu>\n\n      {/* 操作 */}\n      <Menu>\n        <Trigger disabled={!activeProject}>\n          <Axe />\n          <span className=\"hidden sm:inline\">{t(\"actions.title\")}</span>\n        </Trigger>\n        <Content>\n          <Item\n            onClick={() => {\n              FindWindow.open();\n            }}\n          >\n            <Search />\n            {t(\"actions.search\")}\n          </Item>\n          <Item>\n            <RefreshCcwDot />\n            {t(\"actions.refresh\")}\n          </Item>\n          <Item\n            onClick={() => {\n              activeProject?.historyManager.undo();\n            }}\n          >\n            <Undo />\n            {t(\"actions.undo\")}\n          </Item>\n          <Item\n            onClick={() => {\n              activeProject?.historyManager.redo();\n            }}\n          >\n            <Redo />\n            {t(\"actions.redo\")}\n          </Item>\n          <Item\n            onClick={() => {\n              activeProject?.controller.pressingKeySet.clear();\n            }}\n          >\n            <Keyboard />\n            {t(\"actions.releaseKeys\")}\n          </Item>\n          <Item\n            disabled={subWindows.length === 0}\n            onClick={() => {\n              SubWindow.closeAll();\n            }}\n          >\n            <X />\n            关闭所有子窗口\n          </Item>\n          {/* 生成子菜单 */}\n          <Sub>\n            <SubTrigger>\n              <Sparkles />\n              {t(\"actions.generate.title\")}\n            </SubTrigger>\n            <SubContent>\n              <Item\n                onClick={async () => {\n                  GenerateNodeTree.open();\n                }}\n              >\n                <Network className=\"-rotate-90\" />\n                {t(\"actions.generate.generateNodeTreeByText\")}\n              </Item>\n              <Item\n                onClick={async () => {\n                  GenerateNodeTreeByMarkdown.open();\n                }}\n              >\n                <Network className=\"-rotate-90\" />\n                {t(\"actions.generate.generateNodeTreeByMarkdown\")}\n              </Item>\n              <Item\n                onClick={async () => {\n                  GenerateNodeGraph.open();\n                }}\n              >\n                <GitCompareArrows />\n                {t(\"actions.generate.generateNodeGraphByText\")}\n              </Item>\n              <Item\n                onClick={async () => {\n                  GenerateNodeMermaid.open();\n                }}\n              >\n                <GitCompareArrows />\n                {t(\"actions.generate.generateNodeMermaidByText\")}\n              </Item>\n            </SubContent>\n          </Sub>\n          <Item\n            onClick={() => {\n              LogicNodePanel.open();\n            }}\n          >\n            <Workflow />\n            打开逻辑节点面板\n          </Item>\n          <Item\n            onClick={async () => {\n              const result = await Dialog.confirm(\"详见官网文档：自动计算引擎 部分 即将打开网页，是否继续\");\n              if (result) {\n                shellOpen(\"https://graphif.dev/docs/app/features/feature/compute-engine\");\n              }\n            }}\n          >\n            <BookOpen />\n            逻辑节点详细文档\n          </Item>\n          {/* 清空舞台最不常用，放在最后一个 */}\n          <Item\n            className=\"*:text-destructive! text-destructive!\"\n            onClick={async () => {\n              if (\n                await Dialog.confirm(t(\"actions.confirmClearStage\"), t(\"actions.irreversible\"), { destructive: true })\n              ) {\n                activeProject!.stage = [];\n              }\n            }}\n          >\n            <Radiation />\n            <span className=\"\">{t(\"actions.clearStage\")}</span>\n          </Item>\n        </Content>\n      </Menu>\n\n      {/* 设置 */}\n      <Menu>\n        <Trigger>\n          <SettingsIcon />\n          <span className=\"hidden sm:inline\">{t(\"settings.title\")}</span>\n        </Trigger>\n        <Content>\n          <Item onClick={() => SettingsWindow.open(\"settings\")}>\n            <SettingsIcon />\n            {t(\"settings.title\")}\n          </Item>\n          <Sub>\n            <SubTrigger>\n              <Rabbit />\n              自动化操作设置\n            </SubTrigger>\n            <SubContent>\n              <Item\n                onClick={() => {\n                  Dialog.input(\"设置自动命名\", \"填入参数写法详见设置页面\", {\n                    defaultValue: Settings.autoNamerTemplate,\n                  }).then((result) => {\n                    if (!result) return;\n                    Settings.autoNamerTemplate = result;\n                  });\n                }}\n              >\n                <span>创建节点时填入命名：</span>\n                <span>{Settings.autoNamerTemplate}</span>\n              </Item>\n              <Item\n                onClick={() => {\n                  Dialog.input(\"设置自动框命名\", \"填入参数写法详见设置页面\", {\n                    defaultValue: Settings.autoNamerSectionTemplate,\n                  }).then((result) => {\n                    if (!result) return;\n                    Settings.autoNamerSectionTemplate = result;\n                  });\n                }}\n              >\n                <span>创建框时自动命名：</span>\n                <span>{Settings.autoNamerSectionTemplate}</span>\n              </Item>\n              <Item\n                onClick={() => {\n                  Dialog.confirm(\"确认改变？\", Settings.autoFillNodeColorEnable ? \"即将关闭\" : \"即将开启\").then(() => {\n                    Settings.autoFillNodeColorEnable = !Settings.autoFillNodeColorEnable;\n                  });\n                }}\n              >\n                <span>创建节点时自动上色是否开启：</span>\n                <span>{Settings.autoFillNodeColorEnable ? \"开启\" : \"关闭\"}</span>\n              </Item>\n              <Item\n                onClick={() => {\n                  Dialog.input(\n                    \"设置自动上色\",\n                    \"填入颜色数组式代码[r, g, b, a]，其中a为不透明度，取之范围在0-1之间，例如纯红色[255, 0, 0, 1]\",\n                    {\n                      defaultValue: JSON.stringify(new Color(...Settings.autoFillNodeColor).toArray()),\n                    },\n                  ).then((result) => {\n                    if (!result) return;\n                    // 解析字符串\n                    const colorArray: [number, number, number, number] = JSON.parse(result);\n                    if (colorArray.length !== 4) {\n                      toast.error(\"颜色数组长度必须为4\");\n                      return;\n                    }\n                    const color = new Color(...colorArray);\n                    if (color.a < 0 || color.a > 1) {\n                      toast.error(\"颜色不透明度必须在0-1之间\");\n                      return;\n                    }\n                    Settings.autoFillNodeColor = colorArray;\n                  });\n                }}\n              >\n                <span>创建节点时自动上色：</span>\n                <span>{JSON.stringify(Settings.autoFillNodeColor)}</span>\n              </Item>\n            </SubContent>\n          </Sub>\n          <Item onClick={() => SettingsWindow.open(\"appearance\")}>\n            <Palette />\n            {t(\"settings.appearance\")}\n          </Item>\n          <Item\n            className=\"*:text-destructive! text-destructive!\"\n            onClick={async () => {\n              if (\n                await Dialog.confirm(\n                  \"确认重置全部快捷键\",\n                  \"此操作会将所有快捷键恢复为默认值，无法撤销。\\n\\n是否继续？\",\n                  { destructive: true },\n                )\n              ) {\n                try {\n                  await KeyBindsUI.resetAllKeyBinds();\n                  toast.success(\"所有快捷键已重置为默认值\");\n                } catch (error) {\n                  toast.error(\"重置快捷键失败\");\n                  console.error(\"重置快捷键失败:\", error);\n                }\n              }\n            }}\n          >\n            <Radiation />\n            重置全部快捷键\n          </Item>\n          <Item\n            onClick={async () => {\n              const path = await join(await dataDir(), \"liren.project-graph\");\n              await shellOpen(path);\n            }}\n          >\n            <FolderCog />\n            打开软件配置信息文件夹\n          </Item>\n        </Content>\n      </Menu>\n\n      {/* AI */}\n      <Menu>\n        <Trigger disabled={!activeProject}>\n          <Bot />\n          <span className=\"hidden sm:inline\">{t(\"ai.title\")}</span>\n        </Trigger>\n        <Content>\n          <Item onClick={() => AIWindow.open()}>\n            <ExternalLink />\n            {t(\"ai.openAIPanel\")}\n          </Item>\n          <Item onClick={() => AIToolsWindow.open()}>\n            <Wrench />\n            查看AI所有工具\n          </Item>\n        </Content>\n      </Menu>\n\n      {/* 视图 */}\n      <Menu>\n        <Trigger>\n          <AppWindow />\n          <span className=\"hidden sm:inline\">{t(\"window.title\")}</span>\n        </Trigger>\n        <Content>\n          <Item\n            onClick={() =>\n              getCurrentWindow()\n                .isFullscreen()\n                .then((res) => getCurrentWindow().setFullscreen(!res))\n            }\n          >\n            <Fullscreen />\n            {t(\"window.fullscreen\")}\n          </Item>\n          <Item\n            disabled={!activeProject}\n            onClick={async () => {\n              setIsClassroomMode(!Settings.isClassroomMode);\n              Settings.isClassroomMode = !Settings.isClassroomMode;\n            }}\n          >\n            <Airplay />\n            {activeProject ? (\n              <>\n                {isClassroomMode ? \"退出\" : \"开启\"}\n                {t(\"window.classroomMode\")}（顶部菜单在鼠标移开时透明）\n              </>\n            ) : (\n              \"请先打开工程文件才能使用此功能\"\n            )}\n          </Item>\n          <Item\n            disabled={!activeProject}\n            onClick={() => {\n              if (Settings.protectingPrivacy) {\n                toast.info(\"您已退出隐私模式，再次点击将进入隐私模式\");\n              } else {\n                toast.success(\"您已进入隐私模式，再次点击将退出隐私模式，现在您可以放心地截图、将bug报告给开发者了\");\n              }\n              Settings.protectingPrivacy = !Settings.protectingPrivacy;\n            }}\n          >\n            <VenetianMask />\n            {activeProject ? \"进入/退出 隐私模式\" : \"请先打开工程文件才能使用此功能\"}\n          </Item>\n          <Sub>\n            <SubTrigger>\n              <LayoutGrid />\n              背景网格与坐标设置\n            </SubTrigger>\n            <SubContent>\n              <Item\n                disabled={!activeProject}\n                onClick={() => {\n                  Settings.showBackgroundHorizontalLines = !Settings.showBackgroundHorizontalLines;\n                }}\n              >\n                <Rows4 />\n                {activeProject ? <span>开启/关闭 背景横线</span> : \"请先打开工程文件才能使用此功能\"}\n              </Item>\n              <Item\n                disabled={!activeProject}\n                onClick={() => {\n                  Settings.showBackgroundVerticalLines = !Settings.showBackgroundVerticalLines;\n                }}\n              >\n                <Columns4 />\n                {activeProject ? <span>开启/关闭 背景竖线</span> : \"请先打开工程文件才能使用此功能\"}\n              </Item>\n              <Item\n                disabled={!activeProject}\n                onClick={() => {\n                  Settings.showBackgroundDots = !Settings.showBackgroundDots;\n                }}\n              >\n                <Grip />\n                {activeProject ? <span>开启/关闭 背景洞洞板</span> : \"请先打开工程文件才能使用此功能\"}\n              </Item>\n              <Item\n                disabled={!activeProject}\n                onClick={() => {\n                  Settings.showBackgroundCartesian = !Settings.showBackgroundCartesian;\n                }}\n              >\n                <Move3d />\n                {activeProject ? <span>开启/关闭 坐标轴</span> : \"请先打开工程文件才能使用此功能\"}\n              </Item>\n            </SubContent>\n          </Sub>\n          <Sub>\n            <SubTrigger>\n              <PictureInPicture2 />\n              调整舞台透明度\n            </SubTrigger>\n            <SubContent>\n              <Item\n                disabled={!activeProject}\n                onClick={() => {\n                  Settings.windowBackgroundAlpha = Settings.windowBackgroundAlpha === 0 ? 1 : 0;\n                }}\n              >\n                <PictureInPicture2 />\n                {activeProject ? <span>开启/关闭舞台背景颜色透明</span> : \"请先打开工程文件才能使用此功能\"}\n              </Item>\n              <Item\n                disabled={!activeProject}\n                onClick={() => {\n                  Settings.windowBackgroundAlpha = Math.max(0, Settings.windowBackgroundAlpha - 0.1);\n                }}\n              >\n                <PictureInPicture2 />\n                {activeProject ? <span>降低舞台背景不透明度</span> : \"请先打开工程文件才能使用此功能\"}\n              </Item>\n              <Item\n                disabled={!activeProject}\n                onClick={() => {\n                  Settings.windowBackgroundAlpha = Math.min(1, Settings.windowBackgroundAlpha + 0.1);\n                }}\n              >\n                <PictureInPicture2 />\n                {activeProject ? <span>提高舞台背景不透明度</span> : \"请先打开工程文件才能使用此功能\"}\n              </Item>\n            </SubContent>\n          </Sub>\n          <Item\n            disabled={!activeProject}\n            onClick={() => {\n              Settings.showDebug = !Settings.showDebug;\n            }}\n          >\n            <Bug />\n            {activeProject ? <span>开启/关闭Debug 模式</span> : \"请先打开工程文件才能使用此功能\"}\n          </Item>\n          <Sub>\n            <SubTrigger>\n              <CircleDot />\n              狙击镜设置\n            </SubTrigger>\n            <SubContent>\n              <Item\n                disabled={!activeProject}\n                onClick={() => {\n                  Settings.isStealthModeEnabled = !Settings.isStealthModeEnabled;\n                }}\n              >\n                <CircleDot />\n                {activeProject ? <span>开启/关闭狙击镜</span> : \"请先打开工程文件才能使用此功能\"}\n              </Item>\n              <Item\n                disabled={!activeProject}\n                onClick={() => {\n                  Settings.stealthModeReverseMask = !Settings.stealthModeReverseMask;\n                }}\n              >\n                <CircleDot />\n                {activeProject ? (\n                  <span>{Settings.stealthModeReverseMask ? \"关闭\" : \"开启\"}反向遮罩</span>\n                ) : (\n                  \"请先打开工程文件才能使用此功能\"\n                )}\n              </Item>\n              <Item\n                disabled={!activeProject}\n                onClick={() => {\n                  const newRadius = Math.max(10, Math.min(500, Settings.stealthModeScopeRadius + 50));\n                  Settings.stealthModeScopeRadius = newRadius;\n                }}\n              >\n                <CirclePlus />\n                {activeProject ? <span>放大狙击镜</span> : \"请先打开工程文件才能使用此功能\"}\n              </Item>\n              <Item\n                disabled={!activeProject}\n                onClick={() => {\n                  const newRadius = Math.max(10, Math.min(500, Settings.stealthModeScopeRadius - 50));\n                  Settings.stealthModeScopeRadius = newRadius;\n                }}\n              >\n                <CircleMinus />\n                {activeProject ? <span>减小狙击镜</span> : \"请先打开工程文件才能使用此功能\"}\n              </Item>\n              <Item>提示：可以在设置界面中详细设置大小</Item>\n            </SubContent>\n          </Sub>\n        </Content>\n      </Menu>\n\n      {/* 关于 */}\n      <Menu>\n        <Trigger>\n          <CircleAlert />\n          <span className=\"hidden sm:inline\">{t(\"about.title\")}</span>\n        </Trigger>\n        <Content>\n          <Item onClick={() => SettingsWindow.open(\"about\")}>\n            <MessageCircleWarning />\n            {t(\"about.title\")}\n          </Item>\n\n          <Sub>\n            <SubTrigger>\n              <BookOpenText />\n              图文教程\n            </SubTrigger>\n            <SubContent>\n              <Item\n                onClick={async () => {\n                  toast.promise(\n                    async () => {\n                      const u8a = await AssetsRepository.fetchFile(\"tutorials/tutorial-main-2.9.prg\");\n                      const dir = await tempDir();\n                      const path = await join(dir, `tutorial-${crypto.randomUUID()}.prg`);\n                      await writeFile(path, u8a);\n                      await onOpenFile(URI.file(path), \"功能说明书\");\n                    },\n                    {\n                      loading: \"正在下载功能说明书文件\",\n                      success: \"下载完成\",\n                      error: \"下载失败，请检查网络或联系开发者\",\n                    },\n                  );\n                }}\n              >\n                <FileBadge />\n                {t(\"about.guide\")}\n              </Item>\n              <Item\n                onClick={async () => {\n                  toast.promise(\n                    async () => {\n                      const u8a = await AssetsRepository.fetchFile(\"tutorials/tutorial-shortcut-keys-2.9.prg\");\n                      const dir = await tempDir();\n                      const path = await join(dir, `tutorial-${crypto.randomUUID()}.prg`);\n                      await writeFile(path, u8a);\n                      await onOpenFile(URI.file(path), \"快捷键文档\");\n                    },\n                    {\n                      loading: \"正在下载快捷键文档\",\n                      success: \"下载完成\",\n                      error: \"下载失败，请检查网络或联系开发者\",\n                    },\n                  );\n                }}\n              >\n                <FileSpreadsheet />\n                快捷键文档\n              </Item>\n              <Item\n                onClick={async () => {\n                  toast.promise(\n                    async () => {\n                      const u8a = await AssetsRepository.fetchFile(\"tutorials/tutorial-logic-nodes-2.9.prg\");\n                      const dir = await tempDir();\n                      const path = await join(dir, `tutorial-${crypto.randomUUID()}.prg`);\n                      await writeFile(path, u8a);\n                      await onOpenFile(URI.file(path), \"逻辑节点文档\");\n                    },\n                    {\n                      loading: \"正在下载逻辑节点文档\",\n                      success: \"下载完成\",\n                      error: \"下载失败，请检查网络或联系开发者\",\n                    },\n                  );\n                }}\n              >\n                <FileBox />\n                逻辑节点文档\n              </Item>\n              <Item\n                onClick={() => {\n                  shellOpen(\"https://project-graph.top/docs/app/features/feature/camera\");\n                }}\n              >\n                <Globe />\n                官网文档\n              </Item>\n            </SubContent>\n          </Sub>\n          <Sub>\n            <SubTrigger>\n              <Tv />\n              视频教程\n            </SubTrigger>\n            <SubContent>\n              <Item\n                onClick={() => {\n                  shellOpen(\"https://www.bilibili.com/video/BV1y2xdzUEXa\");\n                }}\n              >\n                <Tv />\n                2.0 版本使用教程\n              </Item>\n              <Item\n                onClick={() => {\n                  shellOpen(\"https://www.bilibili.com/video/BV19B5WzyEiZ\");\n                }}\n              >\n                <Tv />\n                1.6 版本基础教程\n              </Item>\n              <Item\n                onClick={() => {\n                  shellOpen(\"https://www.bilibili.com/video/BV1MM5WzKESm\");\n                }}\n              >\n                <Tv />\n                1.6 版本进阶教程\n              </Item>\n              <Item\n                onClick={() => {\n                  shellOpen(\"https://www.bilibili.com/video/BV1W4k7YqEgU\");\n                }}\n              >\n                <Tv />\n                1.0 版本宣传片\n              </Item>\n              <Item\n                onClick={() => {\n                  shellOpen(\"https://www.bilibili.com/video/BV1VVpEe4EXG\");\n                }}\n              >\n                <Tv />\n                pyqt 版本更新后使用教程（考古用 2024.9）\n              </Item>\n              <Item\n                onClick={() => {\n                  shellOpen(\"https://www.bilibili.com/video/BV1hmHKeDE9D\");\n                }}\n              >\n                <Tv />\n                pyqt 版本使用教程（考古用 2024.8）\n              </Item>\n            </SubContent>\n          </Sub>\n          <Item\n            onClick={() =>\n              Dialog.confirm(\n                \"2.0使用提示\",\n                [\n                  \"1. 底部工具栏移动至右键菜单（在空白处右键，因为在节点上右键是点击式连线）\",\n                  \"2. 文件从json升级为了prg文件，能够内置图片了，打开旧版本json文件时会自动转为prg文件\",\n                  \"3. 快捷键与秘籍键合并了\",\n                  \"4. 节点详细信息不是markdown格式了\",\n                  \"5. 标签面板暂时关闭了，后续会用更高级的功能代替\",\n                ].join(\"\\n\"),\n              )\n            }\n          >\n            <Dumbbell />\n            1.8 至 2.0 升级使用指南\n          </Item>\n        </Content>\n      </Menu>\n\n      {isUnstableVersion && (\n        <Menu>\n          <Trigger className={isDev ? \"text-green-500\" : \"*:text-destructive! text-destructive!\"}>\n            {/* 增加辨识度，让开发者更容易分辨dev和nightly版本 */}\n            {isDev ? <BugPlay /> : <MessageCircleWarning />}\n            <span className=\"hidden sm:inline\">{isDev ? \"本地开发模式\" : t(\"unstable.title\")}</span>\n          </Trigger>\n          <Content>\n            <Item variant=\"destructive\">v{version}</Item>\n            <Item variant=\"destructive\">{t(\"unstable.notRelease\")}</Item>\n            <Item variant=\"destructive\">{t(\"unstable.mayHaveBugs\")}</Item>\n            {/*<Separator />\n            <Item onClick={() => shellOpen(\"https://github.com/graphif/project-graph/issues/487\")}>\n              <Bug />\n              {t(\"unstable.reportBug\")}\n            </Item>*/}\n            <Separator />\n            <Sub>\n              <SubTrigger>\n                <TestTube2 />\n                {t(\"unstable.test\")}\n              </SubTrigger>\n              <SubContent>\n                <Item variant=\"destructive\">仅供开发使用</Item>\n                <Item\n                  onClick={() => {\n                    TestWindow.open();\n                  }}\n                >\n                  测试窗口\n                </Item>\n                <Item\n                  onClick={() => {\n                    const tn1 = new TextNode(activeProject!, { text: \"tn1\" });\n                    const tn2 = new TextNode(activeProject!, { text: \"tn2\" });\n                    const le = LineEdge.fromTwoEntity(activeProject!, tn1, tn2);\n                    console.log(serialize([tn1, tn2, le]));\n                  }}\n                >\n                  serialize\n                </Item>\n                <Item\n                  onClick={() => {\n                    activeProject!.renderer.tick = function () {\n                      throw new Error(\"test\");\n                    };\n                  }}\n                >\n                  trigger bug\n                </Item>\n                <Item\n                  onClick={() => {\n                    activeProject!.stageManager\n                      .getSelectedEntities()\n                      .filter((it) => it instanceof TextNode)\n                      .forEach((it) => {\n                        it.text = \"hello world\";\n                      });\n                  }}\n                >\n                  edit text node\n                </Item>\n                <Item\n                  onClick={() => {\n                    window.location.reload();\n                  }}\n                >\n                  reload\n                </Item>\n                <Item\n                  onClick={async () => {\n                    toast(await getDeviceId());\n                  }}\n                >\n                  get device\n                </Item>\n                <Sub>\n                  <SubTrigger>feature flags</SubTrigger>\n                  <SubContent>\n                    <Item disabled>telemetry = {FeatureFlags.TELEMETRY ? \"true\" : \"false\"}</Item>\n                    <Item disabled>ai = {FeatureFlags.AI ? \"true\" : \"false\"}</Item>\n                    <Item disabled>user = {FeatureFlags.USER ? \"true\" : \"false\"}</Item>\n                  </SubContent>\n                </Sub>\n                <Item onClick={() => NodeDetailsWindow.open()}>plate</Item>\n                <Item\n                  onClick={() => {\n                    console.log(activeProject!.stage);\n                  }}\n                >\n                  在控制台输出舞台内容\n                </Item>\n                <Item\n                  onClick={() => {\n                    const selectedEntity = activeProject!.stageManager.getSelectedEntities();\n                    for (const entity of selectedEntity) {\n                      console.log(entity.details);\n                    }\n                  }}\n                >\n                  输出选中节点的详细信息\n                </Item>\n                <Item\n                  onClick={() => {\n                    const selectedEntity = activeProject!.stageManager.getSelectedEntities();\n                    for (const entity of selectedEntity) {\n                      console.log(entity.detailsManager.getBeSearchingText());\n                    }\n                  }}\n                >\n                  输出选中节点的详细信息转换成Markdown\n                </Item>\n                <Item onClick={() => LoginWindow.open()}>login</Item>\n                <Item onClick={() => UserWindow.open()}>user</Item>\n                <Item onClick={() => OnboardingWindow.open()}>onboarding</Item>\n                <Item\n                  onClick={() => {\n                    // 在原点100范围内随机创建100个节点\n                    for (let i = 0; i < 100; i++) {\n                      const x = Math.random() * 200 - 100;\n                      const y = Math.random() * 200 - 100;\n                      const node = new TextNode(activeProject!, { text: `节点${i + 1}` });\n                      node.moveTo(new Vector(x, y));\n                      activeProject!.stage.push(node);\n                    }\n                  }}\n                >\n                  创建100个节点\n                </Item>\n              </SubContent>\n            </Sub>\n          </Content>\n        </Menu>\n      )}\n    </Menubar>\n  );\n}\n\nexport async function onNewDraft() {\n  const project = Project.newDraft();\n  loadAllServicesBeforeInit(project);\n  await project.init();\n  loadAllServicesAfterInit(project);\n  store.set(projectsAtom, [...store.get(projectsAtom), project]);\n  store.set(activeProjectAtom, project);\n}\n\nexport async function onOpenFile(uri?: URI, source: string = \"unknown\"): Promise<Project | undefined> {\n  if (!uri) {\n    const path = await open({\n      directory: false,\n      multiple: false,\n      filters: [{ name: \"工程文件\", extensions: [\"prg\", \"json\"] }],\n    });\n    if (!path) return;\n    uri = URI.file(path);\n  }\n  let upgraded: ReturnType<typeof ProjectUpgrader.convertVAnyToN1> extends Promise<infer T> ? T : never;\n\n  // 读取文件内容并判断格式\n  const fileData = await readFile(uri.fsPath);\n\n  // 检查是否是以 '{' 开头的 JSON 文件\n  if (fileData[0] === 0x7b) {\n    // 0x7B 是 '{' 的 ASCII 码\n    const content = new TextDecoder().decode(fileData);\n    const json = JSON.parse(content);\n    const t = performance.now();\n    upgraded = await toast\n      .promise(ProjectUpgrader.convertVAnyToN1(json, uri), {\n        loading: \"正在转换旧版项目文件...\",\n        success: () => {\n          const time = performance.now() - t;\n          Telemetry.event(\"转换vany->n1\", { time, length: content.length });\n          return `转换成功，耗时 ${time}ms`;\n        },\n        error: (e) => {\n          Telemetry.event(\"转换vany->n1报错\", { error: String(e) });\n          return `转换失败，已发送错误报告，可在群内联系开发者\\n${String(e)}`;\n        },\n      })\n      .unwrap();\n    toast.info(\"您正在尝试导入旧版的文件！稍后如果点击了保存文件，文件会保存为相同文件夹内的 .prg 后缀的文件\");\n    uri = uri.with({ path: uri.path.replace(/\\.json$/, \".prg\") });\n  }\n  // 检查是否是以 0x91 0x86 开头的 msgpack 数据\n  if (fileData.length >= 2 && fileData[0] === 0x84 && fileData[1] === 0xa7) {\n    const decoder = new Decoder();\n    const decodedData = decoder.decode(fileData);\n    if (typeof decodedData !== \"object\" || decodedData === null) {\n      throw new Error(\"msgpack 解码结果不是有效的对象\");\n    }\n    const t = performance.now();\n    upgraded = await toast\n      .promise(ProjectUpgrader.convertVAnyToN1(decodedData as Record<string, any>, uri), {\n        loading: \"正在转换旧版项目文件...\",\n        success: () => {\n          const time = performance.now() - t;\n          Telemetry.event(\"转换vany->n1\", { time, length: fileData.length });\n          return `转换成功，耗时 ${time}ms`;\n        },\n        error: (e) => {\n          Telemetry.event(\"转换vany->n1报错\", { error: String(e) });\n          return `转换失败，已发送错误报告，可在群内联系开发者\\n${String(e)}`;\n        },\n      })\n      .unwrap();\n    toast.info(\"您正在尝试导入旧版的文件！稍后如果点击了保存文件，文件会保存为相同文件夹内的 .prg 后缀的文件\");\n    uri = uri.with({ path: uri.path.replace(/\\.json$/, \".prg\") });\n  }\n\n  if (store.get(projectsAtom).some((p) => p.uri.toString() === uri.toString())) {\n    store.set(activeProjectAtom, store.get(projectsAtom).find((p) => p.uri.toString() === uri.toString())!);\n    const activeProject = store.get(activeProjectAtom);\n    if (!activeProject) return;\n    activeProject.loop();\n    // 把其他项目pause\n    store\n      .get(projectsAtom)\n      .filter((p) => p.uri.toString() !== uri.toString())\n      .forEach((p) => p.pause());\n    toast.success(\"切换到已打开的标签页\");\n    return activeProject;\n  }\n  const project = new Project(uri);\n  const t = performance.now();\n  loadAllServicesBeforeInit(project);\n  const loadServiceTime = performance.now() - t;\n\n  try {\n    await toast\n      .promise(\n        async () => {\n          await project.init();\n          if (project.state !== ProjectState.Saved) {\n            // 用户取消了升级对话框，不打开文件\n            throw new Error(\"USER_CANCELLED\");\n          }\n          loadAllServicesAfterInit(project);\n        },\n        {\n          loading: \"正在打开文件...\",\n          success: async () => {\n            if (upgraded) {\n              project.stage = deserialize(upgraded.data, project);\n              project.attachments = upgraded.attachments;\n              // 更新引用关系，包括双向线的偏移状态\n              project.stageManager.updateReferences();\n            }\n            const readFileTime = performance.now() - t;\n            store.set(projectsAtom, [...store.get(projectsAtom), project]);\n            store.set(activeProjectAtom, project);\n            setTimeout(() => {\n              project.camera.reset();\n            }, 100);\n            await RecentFileManager.addRecentFileByUri(uri);\n            Telemetry.event(\"打开文件\", {\n              loadServiceTime,\n              readFileTime,\n              source,\n            });\n\n            // 处理同名TXT文件内容（仅在用户直接打开文件且设置项开启时执行，生成双链时跳过）\n            if (\n              Settings.autoImportTxtFileWhenOpenPrg &&\n              source !== \"ReferenceBlockNode跳转打开-prg文件\" &&\n              source !== \"ReferencesWindow跳转打开-prg文件\"\n            ) {\n              setTimeout(async () => {\n                try {\n                  // 构建TXT文件路径\n                  const prgPath = uri.fsPath;\n                  const txtPath = prgPath.replace(/\\.prg$/, \".txt\");\n\n                  // 检查TXT文件是否存在\n                  if (await exists(txtPath)) {\n                    // 读取TXT文件内容\n                    const txtContent = await readFile(txtPath);\n                    const lines = new TextDecoder()\n                      .decode(txtContent)\n                      .split(\"\\n\")\n                      .filter((line) => line.trim() !== \"\");\n\n                    if (lines.length > 0) {\n                      // 获取舞台上所有实体\n                      const entities = project.stageManager.getEntities();\n\n                      // 计算外接矩形\n                      let startY = 0;\n                      if (entities.length > 0) {\n                        const boundingRect = Rectangle.getBoundingRectangle(\n                          entities.map((entity) => entity.collisionBox.getRectangle()),\n                        );\n                        startY = boundingRect.bottom;\n                      }\n\n                      // 创建并添加文本节点\n                      for (let i = 0; i < lines.length; i++) {\n                        const line = lines[i];\n                        const textNode = new TextNode(project, {\n                          text: line,\n                          collisionBox: new CollisionBox([\n                            new Rectangle(new Vector(0, startY + i * 100), new Vector(300, 100)),\n                          ]),\n                          sizeAdjust: \"auto\",\n                        });\n                        project.stageManager.add(textNode);\n                      }\n\n                      // 清空TXT文件内容，避免下次打开时重复吸入\n                      await writeFile(txtPath, new TextEncoder().encode(\"\"));\n\n                      // 显示Toast提示\n                      toast.success(`已从同名TXT文件导入 ${lines.length} 条内容到舞台左下角`);\n\n                      // 发送遥测\n                      Telemetry.event(\"txt_content_imported\", {\n                        line_count: lines.length,\n                      });\n\n                      // 设置项目状态为未保存\n                      project.state = ProjectState.Unsaved;\n                    }\n                  }\n                } catch (e) {\n                  console.warn(\"处理TXT文件时发生错误:\", e);\n                }\n              }, 200);\n            }\n\n            return `耗时 ${readFileTime}ms，共 ${project.stage.length} 个舞台对象，${project.attachments.size} 个附件`;\n          },\n          error: (e) => {\n            if (e instanceof Error && e.message === \"USER_CANCELLED\") {\n              return \"已取消打开文件\";\n            }\n            Telemetry.event(\"打开文件失败\", {\n              error: String(e),\n            });\n            return `读取时发生错误，已发送错误报告，可在群内联系开发者\\n${String(e)}`;\n          },\n        },\n      )\n      .unwrap();\n  } catch (e) {\n    if (e instanceof Error && e.message === \"USER_CANCELLED\") {\n      return undefined; // 用户取消，静默处理\n    }\n    throw e;\n  }\n  return project;\n}\n\n/**\n * 在当前激活的工程文件的同一目录下创建prg文件\n */\nexport async function createFileAtCurrentProjectDir(activeProject: Project | undefined, refresh: () => Promise<void>) {\n  if (!activeProject || activeProject.isDraft) return;\n\n  setTimeout(() => {\n    Dialog.input(\"请输入文件名（不需要输入后缀名）\").then(async (userInput) => {\n      if (userInput === undefined || userInput.trim() === \"\") return;\n\n      // 检查文件名是否合法\n      const invalidChars = /[\\\\/:*?\"<>|]/;\n      if (invalidChars.test(userInput)) {\n        toast.error('文件名不能包含以下字符：\\\\ / : * ? \" < > |');\n        return;\n      }\n\n      // 移除可能存在的.prg后缀\n      let fileName = userInput.trim();\n      if (fileName.endsWith(\".prg\")) {\n        fileName = fileName.slice(0, -4);\n      }\n\n      // 创建新文件路径\n      const currentDir = PathString.dirPath(activeProject.uri.fsPath);\n      const newFilePath = currentDir + \"/\" + fileName + \".prg\";\n\n      // 检查文件是否已存在\n      const fileExists = await exists(newFilePath);\n      if (fileExists) {\n        toast.error(`文件 \"${fileName}.prg\" 已存在，请使用其他文件名`);\n        return;\n      }\n\n      const newUri = URI.file(newFilePath);\n\n      // 创建新项目\n      const newProject = Project.newDraft();\n      newProject.uri = newUri;\n\n      // 初始化项目\n      loadAllServicesBeforeInit(newProject);\n      newProject\n        .init()\n        .then(() => {\n          loadAllServicesAfterInit(newProject);\n          // 在舞台上创建文本节点\n          const newTextNode = new TextNode(newProject, {\n            text: fileName,\n          });\n          newProject.stageManager.add(newTextNode);\n          newTextNode.isSelected = true;\n\n          // 保存文件\n          newProject\n            .save()\n            .then(async () => {\n              // 更新项目列表和活动项目\n              store.set(projectsAtom, [...store.get(projectsAtom), newProject]);\n              store.set(activeProjectAtom, newProject);\n              await RecentFileManager.addRecentFileByUri(newUri);\n              await refresh();\n              toast.success(`成功创建新文件：${fileName}.prg`);\n            })\n            .catch((error) => {\n              toast.error(`保存文件失败：${String(error)}`);\n            });\n        })\n        .catch((error) => {\n          toast.error(`初始化项目失败：${String(error)}`);\n        });\n    });\n  }, 50); // 轻微延迟\n}\n\n/**\n * 获取唯一选中的文本节点，用于导出纯文本时。\n * 如果不符合情况就提前弹窗错误，并返回null\n * @param activeProject\n * @returns\n */\nfunction getOneSelectedTextNodeWhenExportingPlainText(activeProject: Project | undefined): TextNode | null {\n  if (!activeProject) {\n    toast.warning(\"请先打开工程文件\");\n    return null;\n  }\n  const entities = activeProject.stageManager.getEntities();\n  const selectedEntities = entities.filter((entity) => entity.isSelected);\n  if (selectedEntities.length === 0) {\n    toast.warning(\"没有选中节点\");\n    return null;\n  } else if (selectedEntities.length === 1) {\n    const result = selectedEntities[0];\n    if (!(result instanceof TextNode)) {\n      toast.warning(\"必须选中文本节点，而不是其他类型的节点\");\n      return null;\n    }\n    if (!activeProject.graphMethods.isTree(result)) {\n      toast.warning(\"不符合树形结构\");\n      return null;\n    }\n    return result;\n  } else {\n    toast.warning(`只能选择一个节点，你选中了${selectedEntities.length}个节点`);\n    return null;\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/QuickSettingsManager.tsx",
    "content": "import { createStore } from \"@/utils/store\";\nimport { settingsSchema } from \"./Settings\";\nimport { z } from \"zod\";\n\n/**\n * 管理右侧快捷设置项列表\n * 有数据持久化机制\n */\nexport namespace QuickSettingsManager {\n  let store: any;\n\n  /**\n   * 快捷设置项类型\n   */\n  export type QuickSettingItem = {\n    /**\n     * 设置项的 key（必须是布尔类型的设置项）\n     */\n    settingKey: keyof ReturnType<typeof settingsSchema._def.shape>;\n  };\n\n  /**\n   * 默认的快捷设置项列表（9个布尔类型的设置项）\n   */\n  const DEFAULT_QUICK_SETTINGS: QuickSettingItem[] = [\n    { settingKey: \"isStealthModeEnabled\" },\n    { settingKey: \"stealthModeReverseMask\" },\n    { settingKey: \"showTextNodeBorder\" },\n    { settingKey: \"alwaysShowDetails\" },\n    { settingKey: \"showDebug\" },\n    { settingKey: \"enableDragAutoAlign\" },\n    { settingKey: \"reverseTreeMoveMode\" },\n    { settingKey: \"allowMoveCameraByWSAD\" },\n    { settingKey: \"textIntegerLocationAndSizeRender\" },\n  ];\n\n  export async function init() {\n    store = await createStore(\"quick-settings.json\");\n    // 如果存储中没有数据，则使用默认值\n    const existingItems = await getQuickSettings();\n    if (existingItems.length === 0) {\n      await setQuickSettings(DEFAULT_QUICK_SETTINGS);\n    }\n    await store.save();\n  }\n\n  /**\n   * 获取快捷设置项列表\n   */\n  export async function getQuickSettings(): Promise<QuickSettingItem[]> {\n    const data = ((await store.get(\"quickSettings\")) as QuickSettingItem[]) || [];\n    return data;\n  }\n\n  /**\n   * 设置快捷设置项列表\n   */\n  export async function setQuickSettings(items: QuickSettingItem[]): Promise<void> {\n    await store.set(\"quickSettings\", items);\n    await store.save();\n  }\n\n  /**\n   * 添加一个快捷设置项\n   */\n  export async function addQuickSetting(item: QuickSettingItem): Promise<void> {\n    const existingItems = await getQuickSettings();\n    // 检查是否已存在\n    if (!existingItems.some((it) => it.settingKey === item.settingKey)) {\n      existingItems.push(item);\n      await setQuickSettings(existingItems);\n    }\n  }\n\n  /**\n   * 删除一个快捷设置项\n   */\n  export async function removeQuickSetting(\n    settingKey: keyof ReturnType<typeof settingsSchema._def.shape>,\n  ): Promise<void> {\n    const existingItems = await getQuickSettings();\n    const filtered = existingItems.filter((it) => it.settingKey !== settingKey);\n    await setQuickSettings(filtered);\n  }\n\n  /**\n   * 更新快捷设置项的顺序\n   */\n  export async function reorderQuickSettings(newOrder: QuickSettingItem[]): Promise<void> {\n    await setQuickSettings(newOrder);\n  }\n\n  /**\n   * 验证设置项是否为布尔类型\n   */\n  export function isValidBooleanSetting(settingKey: string): boolean {\n    const schema = settingsSchema._def.shape();\n    const fieldSchema = schema[settingKey as keyof typeof schema];\n    if (!fieldSchema) return false;\n\n    // 使用递归函数检查是否为布尔类型\n    const checkIsBoolean = (schema: any): boolean => {\n      if (schema instanceof z.ZodBoolean) return true;\n      if (schema instanceof z.ZodDefault) return checkIsBoolean(schema._def.innerType);\n      if (schema instanceof z.ZodOptional) return checkIsBoolean(schema._def.innerType);\n      return false;\n    };\n\n    return checkIsBoolean(fieldSchema);\n  }\n\n  /**\n   * 获取所有可用的布尔类型设置项\n   */\n  export function getAllAvailableBooleanSettings(): Array<keyof ReturnType<typeof settingsSchema._def.shape>> {\n    const schema = settingsSchema._def.shape();\n    return Object.keys(schema).filter((key) => isValidBooleanSetting(key)) as Array<\n      keyof ReturnType<typeof settingsSchema._def.shape>\n    >;\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/Settings.tsx",
    "content": "import { LazyStore } from \"@tauri-apps/plugin-store\";\nimport { useEffect, useState } from \"react\";\nimport { toast } from \"sonner\";\nimport z from \"zod\";\n\nexport const settingsSchema = z.object({\n  language: z\n    .union([z.literal(\"en\"), z.literal(\"zh_CN\"), z.literal(\"zh_TW\"), z.literal(\"zh_TWC\"), z.literal(\"id\")])\n    .default(\"zh_CN\"),\n  isClassroomMode: z.boolean().default(false),\n  showQuickSettingsToolbar: z.boolean().default(true),\n  windowBackgroundAlpha: z.number().min(0).max(1).default(0.9),\n  windowBackgroundOpacityAfterOpenClickThrough: z.number().min(0).max(1).default(0),\n  windowBackgroundOpacityAfterCloseClickThrough: z.number().min(0).max(1).default(0.5),\n  isRenderCenterPointer: z.boolean().default(false),\n  showBackgroundHorizontalLines: z.boolean().default(true),\n  showBackgroundVerticalLines: z.boolean().default(true),\n  showBackgroundDots: z.boolean().default(false),\n  showBackgroundCartesian: z.boolean().default(true),\n  enableTagTextNodesBigDisplay: z.boolean().default(true),\n  showTextNodeBorder: z.boolean().default(true),\n  showTreeDirectionHint: z.boolean().default(true),\n  lineStyle: z.union([z.literal(\"straight\"), z.literal(\"bezier\"), z.literal(\"vertical\")]).default(\"straight\"),\n  sectionBitTitleRenderType: z.union([z.literal(\"none\"), z.literal(\"top\"), z.literal(\"cover\")]).default(\"cover\"),\n  nodeDetailsPanel: z.union([z.literal(\"small\"), z.literal(\"vditor\")]).default(\"vditor\"),\n  alwaysShowDetails: z.boolean().default(false),\n  entityDetailsFontSize: z.number().min(18).max(36).int().default(18),\n  entityDetailsLinesLimit: z.number().min(1).max(200).int().default(4),\n  entityDetailsWidthLimit: z.number().min(200).max(2000).int().default(200),\n  showDebug: z.boolean().default(false),\n  protectingPrivacy: z.boolean().default(false),\n  protectingPrivacyMode: z.union([z.literal(\"secretWord\"), z.literal(\"caesar\")]).default(\"secretWord\"),\n  windowCollapsingWidth: z.number().min(50).max(2000).int().default(300),\n  windowCollapsingHeight: z.number().min(25).max(2000).int().default(300),\n  limitCameraInCycleSpace: z.boolean().default(false),\n  cameraCycleSpaceSizeX: z.number().min(1000).max(10000).int().default(1000),\n  cameraCycleSpaceSizeY: z.number().min(1000).max(10000).int().default(1000),\n  historySize: z.number().min(1).max(5000).int().default(150),\n  autoRefreshStageByMouseAction: z.boolean().default(true),\n  isPauseRenderWhenManipulateOvertime: z.boolean().default(false),\n  renderOverTimeWhenNoManipulateTime: z.number().min(1).max(10).int().default(5),\n  ignoreTextNodeTextRenderLessThanFontSize: z.number().min(1).max(15).default(5),\n  sectionBigTitleThresholdRatio: z.number().min(0).max(1).default(0.15),\n  sectionBigTitleCameraScaleThreshold: z.number().min(0.01).max(1).default(0.25),\n  sectionBigTitleOpacity: z.number().min(0).max(1).default(0.5),\n  sectionBackgroundFillMode: z.union([z.literal(\"full\"), z.literal(\"titleOnly\")]).default(\"titleOnly\"),\n  cacheTextAsBitmap: z.boolean().default(false),\n  textCacheSize: z.number().default(100),\n  textScalingBehavior: z\n    .union([z.literal(\"temp\"), z.literal(\"nearestCache\"), z.literal(\"cacheEveryTick\")])\n    .default(\"temp\"),\n  antialiasing: z\n    .union([z.literal(\"disabled\"), z.literal(\"low\"), z.literal(\"medium\"), z.literal(\"high\")])\n    .default(\"low\"),\n  textIntegerLocationAndSizeRender: z.boolean().default(false),\n  compatibilityMode: z.boolean().default(false),\n  isEnableEntityCollision: z.boolean().default(false),\n  isEnableSectionCollision: z.boolean().default(true),\n  autoNamerTemplate: z.string().default(\"...\"),\n  autoNamerSectionTemplate: z.string().default(\"Section_{{i}}\"),\n  autoSaveWhenClose: z.boolean().default(false),\n  autoSave: z.boolean().default(true),\n  autoSaveInterval: z.number().min(1).max(60).int().default(10),\n  autoBackup: z.boolean().default(true),\n  autoBackupInterval: z.number().min(60).max(6000).int().default(600),\n  autoBackupLimitCount: z.number().min(1).max(500).int().default(10),\n  autoBackupCustomPath: z.string().default(\"\"),\n  enableDragEdgeRotateStructure: z.boolean().default(true),\n  enableCtrlWheelRotateStructure: z.boolean().default(false),\n  aiApiBaseUrl: z.string().default(\"https://generativelanguage.googleapis.com/v1beta/openai/\"),\n  aiApiKey: z.string().default(\"\"),\n  aiModel: z.string().default(\"gemini-2.5-flash\"),\n  aiShowTokenCount: z.boolean().default(false),\n  mouseRightDragBackground: z.union([z.literal(\"cut\"), z.literal(\"moveCamera\")]).default(\"cut\"),\n  enableSpaceKeyMouseLeftDrag: z.boolean().default(true),\n  enableDragAutoAlign: z.boolean().default(true),\n  reverseTreeMoveMode: z.boolean().default(false),\n  mouseWheelMode: z\n    .union([z.literal(\"zoom\"), z.literal(\"move\"), z.literal(\"moveX\"), z.literal(\"none\")])\n    .default(\"zoom\"),\n  mouseWheelWithShiftMode: z\n    .union([z.literal(\"zoom\"), z.literal(\"move\"), z.literal(\"moveX\"), z.literal(\"none\")])\n    .default(\"moveX\"),\n  mouseWheelWithCtrlMode: z\n    .union([z.literal(\"zoom\"), z.literal(\"move\"), z.literal(\"moveX\"), z.literal(\"none\")])\n    .default(\"none\"),\n  mouseWheelWithAltMode: z\n    .union([z.literal(\"zoom\"), z.literal(\"move\"), z.literal(\"moveX\"), z.literal(\"none\")])\n    .default(\"none\"),\n  doubleClickMiddleMouseButton: z.union([z.literal(\"adjustCamera\"), z.literal(\"none\")]).default(\"adjustCamera\"),\n  mouseSideWheelMode: z\n    .union([\n      z.literal(\"zoom\"),\n      z.literal(\"move\"),\n      z.literal(\"moveX\"),\n      z.literal(\"none\"),\n      z.literal(\"cameraMoveToMouse\"),\n      z.literal(\"adjustWindowOpacity\"),\n      z.literal(\"adjustPenStrokeWidth\"),\n    ])\n    .default(\"cameraMoveToMouse\"),\n  macMouseWheelIsSmoothed: z.boolean().default(false),\n  enableWindowsTouchPad: z.boolean().default(true),\n  autoAdjustLineEndpointsByMouseTrack: z.boolean().default(true),\n  macTrackpadAndMouseWheelDifference: z\n    .union([z.literal(\"trackpadIntAndWheelFloat\"), z.literal(\"tarckpadFloatAndWheelInt\")])\n    .default(\"trackpadIntAndWheelFloat\"),\n  macTrackpadScaleSensitivity: z.number().min(0).max(1).multipleOf(0.001).default(0.5),\n  macEnableControlToCut: z.boolean().default(false),\n  allowMoveCameraByWSAD: z.boolean().default(false),\n  allowGlobalHotKeys: z.boolean().default(true),\n  cameraFollowsSelectedNodeOnArrowKeys: z.boolean().default(false),\n  arrowKeySelectOnlyInViewport: z.boolean().default(false),\n  cameraKeyboardMoveReverse: z.boolean().default(false),\n  cameraKeyboardScaleReverse: z.boolean().default(false),\n  moveAmplitude: z.number().min(0).max(10).default(2),\n  moveFriction: z.number().min(0).max(1).default(0.1),\n  scaleExponent: z.number().min(0).max(1).default(0.11),\n  cameraResetViewPaddingRate: z.number().min(1).max(2).default(1.5),\n  cameraResetMaxScale: z.number().min(0.1).max(10).multipleOf(0.1).default(3),\n  scaleCameraByMouseLocation: z.boolean().default(true),\n  cameraKeyboardScaleRate: z.number().min(0).max(3).default(0.2),\n  rectangleSelectWhenRight: z.union([z.literal(\"intersect\"), z.literal(\"contain\")]).default(\"intersect\"),\n  rectangleSelectWhenLeft: z.union([z.literal(\"intersect\"), z.literal(\"contain\")]).default(\"contain\"),\n  enableRightClickConnect: z.boolean().default(true),\n  textNodeStartEditMode: z\n    .union([\n      z.literal(\"enter\"),\n      z.literal(\"ctrlEnter\"),\n      z.literal(\"altEnter\"),\n      z.literal(\"shiftEnter\"),\n      z.literal(\"space\"),\n    ])\n    .default(\"enter\"),\n  textNodeContentLineBreak: z\n    .union([z.literal(\"enter\"), z.literal(\"ctrlEnter\"), z.literal(\"altEnter\"), z.literal(\"shiftEnter\")])\n    .default(\"shiftEnter\"),\n  textNodeExitEditMode: z\n    .union([z.literal(\"enter\"), z.literal(\"ctrlEnter\"), z.literal(\"altEnter\"), z.literal(\"shiftEnter\")])\n    .default(\"enter\"),\n  textNodeSelectAllWhenStartEditByMouseClick: z.boolean().default(true),\n  textNodeSelectAllWhenStartEditByKeyboard: z.boolean().default(false),\n  textNodeBackspaceDeleteWhenEmpty: z.boolean().default(false),\n  textNodeBigContentThresholdWhenPaste: z.number().min(1).max(1000).int().default(20),\n  textNodePasteSizeAdjustMode: z\n    .union([z.literal(\"auto\"), z.literal(\"manual\"), z.literal(\"autoByLength\")])\n    .default(\"autoByLength\"),\n  allowAddCycleEdge: z.boolean().default(false),\n  autoLayoutWhenTreeGenerate: z.boolean().default(true),\n  textNodeAutoFormatTreeWhenExitEdit: z.boolean().default(false),\n  treeGenerateCameraBehavior: z\n    .union([z.literal(\"none\"), z.literal(\"moveToNewNode\"), z.literal(\"resetToTree\")])\n    .default(\"moveToNewNode\"),\n  enableBackslashGenerateNodeInInput: z.boolean().default(false),\n  gamepadDeadzone: z.number().min(0).max(1).default(0.1),\n  showGrid: z.boolean().default(true),\n  maxFps: z.number().default(60),\n  maxFpsUnfocused: z.number().default(30),\n  effectsPerferences: z.record(z.boolean()).default({}),\n  autoFillNodeColor: z.tuple([z.number(), z.number(), z.number(), z.number()]).default([0, 0, 0, 0]),\n  autoFillNodeColorEnable: z.boolean().default(true),\n  autoFillPenStrokeColor: z.tuple([z.number(), z.number(), z.number(), z.number()]).default([0, 0, 0, 0]),\n  autoFillPenStrokeColorEnable: z.boolean().default(true),\n  autoFillEdgeColor: z.tuple([z.number(), z.number(), z.number(), z.number()]).default([0, 0, 0, 0]),\n  autoOpenPath: z.string().default(\"\"), // 废弃\n  generateTextNodeByStringTabCount: z.number().default(4),\n  enableCollision: z.boolean().default(true),\n  enableDragAlignToGrid: z.boolean().default(false),\n  mouseLeftMode: z\n    .union([z.literal(\"selectAndMove\"), z.literal(\"draw\"), z.literal(\"connectAndCut\")])\n    .default(\"selectAndMove\"),\n  soundEnabled: z.boolean().default(true),\n  cuttingLineStartSoundFile: z.string().default(\"\"),\n  connectLineStartSoundFile: z.string().default(\"\"),\n  connectFindTargetSoundFile: z.string().default(\"\"),\n  cuttingLineReleaseSoundFile: z.string().default(\"\"),\n  alignAndAttachSoundFile: z.string().default(\"\"),\n  packEntityToSectionSoundFile: z.string().default(\"\"),\n  treeGenerateDeepSoundFile: z.string().default(\"\"),\n  treeGenerateBroadSoundFile: z.string().default(\"\"),\n  treeAdjustSoundFile: z.string().default(\"\"),\n  viewAdjustSoundFile: z.string().default(\"\"),\n  entityJumpSoundFile: z.string().default(\"\"),\n  associationAdjustSoundFile: z.string().default(\"\"),\n  uiButtonEnterSoundFile: z.string().default(\"\"),\n  uiButtonClickSoundFile: z.string().default(\"\"),\n  uiSwitchButtonOnSoundFile: z.string().default(\"\"),\n  uiSwitchButtonOffSoundFile: z.string().default(\"\"),\n  githubToken: z.string().default(\"\"),\n  githubUser: z.string().default(\"\"),\n  theme: z.string().default(\"dark-blue\"),\n  themeMode: z.union([z.literal(\"light\"), z.literal(\"dark\")]).default(\"dark\"),\n  lightTheme: z.string().default(\"morandi\"),\n  darkTheme: z.string().default(\"dark\"),\n  telemetry: z.boolean().default(true),\n  historyManagerMode: z.union([z.literal(\"memoryEfficient\"), z.literal(\"timeEfficient\")]).default(\"timeEfficient\"),\n  isStealthModeEnabled: z.boolean().default(false),\n  stealthModeScopeRadius: z.number().min(10).max(500).int().default(150),\n  stealthModeReverseMask: z.boolean().default(false),\n  stealthModeMaskShape: z\n    .union([z.literal(\"circle\"), z.literal(\"square\"), z.literal(\"topLeft\"), z.literal(\"smartContext\")])\n    .default(\"circle\"),\n  clearHistoryWhenManualSave: z.boolean().default(true),\n  soundPitchVariationRange: z.number().min(0).max(1200).int().default(150),\n  autoImportTxtFileWhenOpenPrg: z.boolean().default(false),\n});\n\nexport type Settings = z.infer<typeof settingsSchema>;\n\nconst listeners: Partial<Record<string, ((value: any) => void)[]>> = {};\n\nconst store = new LazyStore(\"settings.json\");\nawait store.init();\n\n// store加载完成后，推送所有listeners初始值\n// for (const key in listeners) {\n//   if (Object.prototype.hasOwnProperty.call(listeners, key)) {\n//     // 取store中的值，如果没有则用默认值\n//     let value = await store.get(key);\n//     if (value === undefined) {\n//       value = settingsSchema._def.shape()[key as keyof Settings]._def.defaultValue();\n//     }\n//     listeners[key]?.forEach((cb) => cb(value));\n//   }\n// }\nlet savedSettings = settingsSchema.parse({});\ntry {\n  console.log(Object.fromEntries(await store.entries()));\n  savedSettings = settingsSchema.parse(Object.fromEntries(await store.entries()));\n} catch (e) {\n  if (e instanceof z.ZodError) {\n    console.error(e);\n    toast.error(`设置文件格式错误\\n${JSON.stringify(e.issues)}`);\n  }\n}\n\nexport const Settings = new Proxy<\n  Settings & {\n    watch: (key: keyof Settings, callback: (value: any) => void) => () => void;\n    use: <T extends keyof Settings>(key: T) => [Settings[T], (newValue: Settings[T]) => void];\n  }\n>(\n  {\n    ...savedSettings,\n    watch: () => () => {},\n    use: () => [undefined as any, () => {}],\n  },\n  {\n    set: (target, key, value, receiver) => {\n      if (typeof key === \"symbol\") {\n        throw new Error(`不能设置symbol属性: ${String(key)}`);\n      }\n      if (!(key in target)) {\n        throw new Error(`没有这个设置项: ${key}`);\n      }\n      store.set(key, value);\n      listeners[key]?.forEach((cb) => cb(value));\n      return Reflect.set(target, key, value, receiver);\n    },\n    get: (target, key, receiver) => {\n      switch (key) {\n        case \"watch\": {\n          return (key: keyof Settings, callback: (value: any) => void) => {\n            if (!listeners[key]) {\n              listeners[key] = [];\n            }\n            listeners[key].push(callback);\n            callback(target[key]);\n            return () => {\n              listeners[key] = listeners[key]?.filter((cb) => cb !== callback);\n            };\n          };\n        }\n        case \"use\": {\n          return <T extends keyof Settings>(key: T) => {\n            const [value, setValue] = useState(target[key]);\n            useEffect(() => {\n              if (!listeners[key]) {\n                listeners[key] = [];\n              }\n              listeners[key].push(setValue);\n              return () => {\n                listeners[key] = listeners[key]?.filter((cb) => cb !== setValue);\n              };\n            }, []);\n            return [\n              value,\n              (newValue: Settings[T]) => {\n                console.log(newValue);\n                store.set(key, newValue);\n                listeners[key]?.forEach((cb) => cb(newValue));\n              },\n            ];\n          };\n        }\n        default: {\n          return Reflect.get(target, key, receiver);\n        }\n      }\n    },\n  },\n);\n"
  },
  {
    "path": "app/src/core/service/SettingsIcons.tsx",
    "content": "import {\n  AlignStartVertical,\n  AppWindow,\n  AppWindowMac,\n  ArrowDownNarrowWide,\n  Blend,\n  Bug,\n  Calculator,\n  CaseSensitive,\n  ChevronUp,\n  CircleDot,\n  Columns4,\n  Crosshair,\n  Database,\n  Delete,\n  FileStack,\n  Folder,\n  Fullscreen,\n  Grab,\n  Grip,\n  Hand,\n  HandMetal,\n  HardDrive,\n  HardDriveDownload,\n  Hourglass,\n  ImageMinus,\n  ImageUpscale,\n  Keyboard,\n  Languages,\n  Layers,\n  LineSquiggle,\n  ListCheck,\n  ListCollapse,\n  ListEnd,\n  ListMusic,\n  ListRestart,\n  ListTree,\n  MemoryStick,\n  Mouse,\n  MousePointerClick,\n  Move,\n  Move3d,\n  MoveHorizontal,\n  MoveVertical,\n  Presentation,\n  Ratio,\n  RefreshCcw,\n  RefreshCcwDot,\n  RotateCw,\n  Rows4,\n  Scaling,\n  ScanEye,\n  ScanText,\n  Skull,\n  Space,\n  Spline,\n  SplinePointer,\n  Square,\n  SquareArrowDownRight,\n  SquareArrowUpLeft,\n  SquareM,\n  Tag,\n  Text,\n  TextCursorInput,\n  Turtle,\n  Undo,\n  Ungroup,\n  VenetianMask,\n  WholeWord,\n  File,\n  Pentagon,\n  Sun,\n  Moon,\n  SunMoon,\n} from \"lucide-react\";\n\nexport const settingsIcons = {\n  autoNamerTemplate: Tag,\n  autoNamerSectionTemplate: Tag,\n  autoSaveWhenClose: HardDriveDownload,\n  autoSave: HardDrive,\n  autoSaveInterval: Hourglass,\n  autoBackup: Database,\n  autoBackupInterval: Hourglass,\n  autoBackupLimitCount: FileStack,\n  autoBackupCustomPath: Folder,\n  mouseRightDragBackground: MousePointerClick,\n  mouseLeftMode: MousePointerClick,\n  enableDragAutoAlign: AlignStartVertical,\n  reverseTreeMoveMode: Move,\n  mouseWheelMode: Mouse,\n  mouseWheelWithShiftMode: Mouse,\n  mouseWheelWithCtrlMode: Mouse,\n  mouseWheelWithAltMode: Mouse,\n  doubleClickMiddleMouseButton: Mouse,\n  mouseSideWheelMode: Grab,\n  macMouseWheelIsSmoothed: Mouse,\n  enableWindowsTouchPad: Hand,\n  macTrackpadAndMouseWheelDifference: Hand,\n  macTrackpadScaleSensitivity: HandMetal,\n  macEnableControlToCut: ChevronUp,\n  allowMoveCameraByWSAD: Keyboard,\n  allowGlobalHotKeys: Keyboard,\n  enableSpaceKeyMouseLeftDrag: Space,\n  cameraFollowsSelectedNodeOnArrowKeys: Crosshair,\n  cameraKeyboardMoveReverse: Keyboard,\n  cameraKeyboardScaleReverse: Keyboard,\n  moveAmplitude: Move,\n  moveFriction: Move,\n  scaleExponent: ScanEye,\n  cameraResetViewPaddingRate: Fullscreen,\n  cameraResetMaxScale: Fullscreen,\n  scaleCameraByMouseLocation: ScanEye,\n  cameraKeyboardScaleRate: ScanEye,\n  rectangleSelectWhenRight: SquareArrowDownRight,\n  rectangleSelectWhenLeft: SquareArrowUpLeft,\n  textNodeStartEditMode: ListRestart,\n  textNodeContentLineBreak: ListEnd,\n  textNodeExitEditMode: ListCheck,\n  textNodeSelectAllWhenStartEditByMouseClick: TextCursorInput,\n  textNodeSelectAllWhenStartEditByKeyboard: TextCursorInput,\n  textNodeBackspaceDeleteWhenEmpty: Delete,\n  textNodeBigContentThresholdWhenPaste: ArrowDownNarrowWide,\n  textNodePasteSizeAdjustMode: Scaling,\n  allowAddCycleEdge: RotateCw,\n  enableDragEdgeRotateStructure: SplinePointer,\n  enableCtrlWheelRotateStructure: RefreshCcw,\n  autoLayoutWhenTreeGenerate: ListTree,\n  enableBackslashGenerateNodeInInput: Keyboard,\n  gamepadDeadzone: Skull,\n  historySize: Undo,\n  compressPastedImages: ImageMinus,\n  maxPastedImageSize: ImageUpscale,\n  autoRefreshStageByMouseAction: RefreshCcwDot,\n  isPauseRenderWhenManipulateOvertime: Hourglass,\n  renderOverTimeWhenNoManipulateTime: Hourglass,\n  ignoreTextNodeTextRenderLessThanFontSize: ScanText,\n  cacheTextAsBitmap: Layers,\n  textCacheSize: MemoryStick,\n  textScalingBehavior: Text,\n  textIntegerLocationAndSizeRender: WholeWord,\n  antialiasing: Calculator,\n  compatibilityMode: Turtle,\n  isEnableEntityCollision: Ungroup,\n  language: Languages,\n  showTipsOnUI: AppWindow,\n  useNativeTitleBar: AppWindowMac,\n  isClassroomMode: Presentation,\n  showQuickSettingsToolbar: Columns4,\n  windowBackgroundAlpha: Blend,\n  windowBackgroundOpacityAfterOpenClickThrough: Blend,\n  windowBackgroundOpacityAfterCloseClickThrough: Blend,\n  isRenderCenterPointer: Crosshair,\n  showBackgroundHorizontalLines: Rows4,\n  showBackgroundVerticalLines: Columns4,\n  showBackgroundDots: Grip,\n  showBackgroundCartesian: Move3d,\n  enableTagTextNodesBigDisplay: Tag,\n  showTextNodeBorder: Square,\n  showTreeDirectionHint: ListTree,\n  lineStyle: Spline,\n  sectionBitTitleRenderType: SquareM,\n  sectionBigTitleThresholdRatio: Ratio,\n  sectionBigTitleCameraScaleThreshold: ScanEye,\n  sectionBigTitleOpacity: Blend,\n  sectionBackgroundFillMode: Layers,\n  nodeDetailsPanel: AppWindow,\n  alwaysShowDetails: ListCollapse,\n  entityDetailsFontSize: CaseSensitive,\n  entityDetailsLinesLimit: ArrowDownNarrowWide,\n  entityDetailsWidthLimit: Space,\n  showDebug: Bug,\n  protectingPrivacy: VenetianMask,\n  protectingPrivacyMode: VenetianMask,\n  windowCollapsingWidth: MoveHorizontal,\n  windowCollapsingHeight: MoveVertical,\n  limitCameraInCycleSpace: Ratio,\n  cameraCycleSpaceSizeX: Scaling,\n  cameraCycleSpaceSizeY: Scaling,\n  autoAdjustLineEndpointsByMouseTrack: LineSquiggle,\n  enableRightClickConnect: MousePointerClick,\n  isStealthModeEnabled: Crosshair,\n  stealthModeScopeRadius: ScanEye,\n  stealthModeReverseMask: CircleDot,\n  clearHistoryWhenManualSave: Undo,\n  historyManagerMode: Undo,\n  soundPitchVariationRange: ListMusic,\n  textNodeAutoFormatTreeWhenExitEdit: ListTree,\n  treeGenerateCameraBehavior: Fullscreen,\n  autoImportTxtFileWhenOpenPrg: File,\n  stealthModeMaskShape: Pentagon,\n  themeMode: SunMoon,\n  lightTheme: Sun,\n  darkTheme: Moon,\n  arrowKeySelectOnlyInViewport: ScanEye,\n};\n"
  },
  {
    "path": "app/src/core/service/SubWindow.tsx",
    "content": "import { store } from \"@/state\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { atom, useAtomValue } from \"jotai\";\nimport { startTransition } from \"react\";\n\nexport namespace SubWindow {\n  // export enum IdEnum {}\n  export interface Window {\n    /** uuid */\n    id: string;\n    title: string;\n    children: React.ReactNode;\n    /** 当大小为(-1,-1)时，则为自适应大小 */\n    rect: Rectangle;\n    /** 开发中 */\n    maximized: boolean;\n    /** 开发中 */\n    minimized: boolean;\n    focused: boolean;\n    zIndex: number;\n    /**\n     * 标题栏区域覆盖在内容之上\n     * 设置为true就不能拖动窗口了\n     * 可以给窗口内元素添加data-pg-drag-region属性，使其成为可拖动区域\n     */\n    titleBarOverlay: boolean;\n    /**\n     * 只是隐藏关闭按钮，不影响下面的closeWhen方法\n     */\n    closable: boolean;\n    closing: boolean;\n    closeWhenClickOutside: boolean;\n    /** @private */\n    _closeWhenClickOutsideListener?: (e: PointerEvent) => void;\n    closeWhenClickInside: boolean;\n  }\n  const subWindowsAtom = atom<Window[]>([]);\n  export const use = () => useAtomValue(subWindowsAtom);\n  function getMaxZIndex() {\n    return store.get(subWindowsAtom).reduce((maxZIndex, window) => Math.max(maxZIndex, window.zIndex), 0);\n  }\n  export function create(options: Partial<Window>): Window {\n    const win: Window = {\n      id: crypto.randomUUID(),\n      title: \"\",\n      children: <></>,\n      rect: new Rectangle(Vector.getZero(), Vector.same(100)),\n      maximized: false,\n      minimized: false,\n      // opacity: 1,\n      focused: false,\n      zIndex: getMaxZIndex() + 1,\n      titleBarOverlay: false,\n      closable: true,\n      closing: false,\n      closeWhenClickOutside: false,\n      closeWhenClickInside: false,\n      ...options,\n    };\n    //检测如果窗口到屏幕外面了，自动调整位置\n    const { x: width, y: height } = win.rect.size;\n    const { innerWidth, innerHeight } = window;\n    if (win.rect.location.x + width > innerWidth) {\n      win.rect.location.x = innerWidth - width;\n    }\n    if (win.rect.location.y + height > innerHeight) {\n      win.rect.location.y = innerHeight - height;\n    }\n    // 窗口创建完成，添加到store中\n    store.set(subWindowsAtom, [...store.get(subWindowsAtom), win]);\n    if (options.closeWhenClickOutside) {\n      win._closeWhenClickOutsideListener = (e: PointerEvent) => {\n        if (e.target instanceof HTMLElement && e.target.closest(`[data-pg-window-id=\"${win.id}\"]`)) {\n          return;\n        }\n        close(win.id);\n      };\n      document.addEventListener(\"pointerdown\", win._closeWhenClickOutsideListener);\n    }\n    return win;\n  }\n  export function update(id: string, options: Partial<Omit<Window, \"id\">>) {\n    store.set(\n      subWindowsAtom,\n      store.get(subWindowsAtom).map((window) => (window.id === id ? { ...window, ...options } : window)),\n    );\n  }\n  export function close(id: string) {\n    if (get(id)?.closeWhenClickOutside) {\n      document.removeEventListener(\"pointerdown\", get(id)._closeWhenClickOutsideListener!);\n    }\n    update(id, { closing: true });\n    setTimeout(() => {\n      // 窗口已经几乎看不见了，可以先把children清空\n      update(id, { children: null });\n    }, 450);\n    setTimeout(() => {\n      startTransition(() => {\n        store.set(\n          subWindowsAtom,\n          store.get(subWindowsAtom).filter((window) => window.id !== id),\n        );\n\n        // 焦点恢复逻辑：关闭窗口后，将焦点移至 z-index 最高的剩余窗口\n        const remainingWindows = store.get(subWindowsAtom);\n        if (remainingWindows.length > 0) {\n          // 找到 z-index 最高的窗口\n          const highestZIndexWindow = remainingWindows.reduce((highest, current) =>\n            current.zIndex > highest.zIndex ? current : highest,\n          );\n          focus(highestZIndexWindow.id);\n        }\n      });\n    }, 500);\n  }\n  export function focus(id: string) {\n    // 先把所有窗口的focused设为false\n    store.set(\n      subWindowsAtom,\n      store.get(subWindowsAtom).map((window) => ({ ...window, focused: false })),\n    );\n    // 再把当前窗口的focused设为true，并且把zIndex设为最大\n    update(id, { focused: true, zIndex: getMaxZIndex() + 1 });\n  }\n  export function get(id: string) {\n    return store.get(subWindowsAtom).find((window) => window.id === id)!;\n  }\n  /**\n   * 关闭所有打开的子窗口\n   * 会遍历所有窗口并调用 close(id) 方法\n   * 每个窗口的关闭动画会独立执行\n   */\n  export function closeAll() {\n    const windows = store.get(subWindowsAtom);\n    // 遍历所有窗口并调用 close 方法\n    // close 方法会自动处理事件监听器的清理\n    windows.forEach((window) => {\n      close(window.id);\n    });\n  }\n  /**\n   * 检查是否有打开的子窗口\n   * @returns 如果有至少一个打开的窗口则返回 true，否则返回 false\n   */\n  export function hasOpenWindows(): boolean {\n    return store.get(subWindowsAtom).length > 0;\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/Telemetry.tsx",
    "content": "import { FeatureFlags } from \"@/core/service/FeatureFlags\";\nimport { getDeviceId } from \"@/utils/otherApi\";\nimport { fetch } from \"@tauri-apps/plugin-http\";\nimport { Settings } from \"./Settings\";\n\nexport namespace Telemetry {\n  let deviceId = \"\";\n\n  /**\n   *\n   * @param event 字符串，原则上不能塞入动态的参数，如文件名、路径、日期、时间等\n   * @param data 任意对象类型\n   * @returns\n   */\n  export async function event(event: string, data: any = {}) {\n    if (import.meta.env.DEV) return; // 本地开发模式就不发了\n    if (!FeatureFlags.TELEMETRY) return;\n    if (!Settings.telemetry) return;\n    if (!deviceId) {\n      deviceId = await getDeviceId();\n    }\n    try {\n      await fetch(import.meta.env.LR_API_BASE_URL + \"/api/telemetry\", {\n        method: \"POST\",\n        headers: {\n          \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n          event,\n          user: deviceId,\n          data,\n        }),\n      });\n    } catch (e) {\n      console.warn(e);\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/Themes.tsx",
    "content": "import { camelCaseToDashCase } from \"@/utils/font\";\nimport { parseYamlWithFrontmatter } from \"@/utils/yaml\";\nimport { appLocalDataDir, join } from \"@tauri-apps/api/path\";\nimport { mkdir, readDir, readTextFile, remove, writeTextFile } from \"@tauri-apps/plugin-fs\";\nimport YAML from \"yaml\";\n\nexport namespace Themes {\n  export type Metadata = {\n    id: string;\n    type: \"light\" | \"dark\";\n    author: Record<string, string>;\n    description: Record<string, string>;\n    name: Record<string, string>;\n  };\n  export type Theme = {\n    metadata: Metadata;\n    content: any;\n  };\n  export const builtinThemes = Object.values(\n    import.meta.glob<string>(\"../../themes/*.pg-theme\", {\n      eager: true,\n      import: \"default\",\n      query: \"?raw\",\n    }),\n  ).map((theme) => {\n    const data = parseYamlWithFrontmatter<Themes.Metadata, any>(theme);\n    return {\n      metadata: data.frontmatter,\n      content: data.content,\n    };\n  });\n\n  export async function getThemeById(id: string) {\n    // 先尝试找内置主题\n    const builtinTheme = builtinThemes.find((theme) => theme.metadata.id === id);\n    if (builtinTheme) return builtinTheme;\n    // 找不到就尝试从外部加载\n    const path = await join(await appLocalDataDir(), \"themes\", `${id}.pg-theme`);\n    const fileContent = await readTextFile(path);\n    const data = parseYamlWithFrontmatter<Themes.Metadata, any>(fileContent);\n    return {\n      metadata: data.frontmatter,\n      content: data.content,\n    };\n  }\n  /**\n   * 把theme.content转换成CSS样式\n   * @param theme getThemeById返回的theme对象中的content属性\n   */\n  export function convertThemeToCSS(theme: any) {\n    function generateCSSVariables(obj: any, prefix: string = \"--\", css: string = \"\"): string {\n      for (const key in obj) {\n        if (typeof obj[key] === \"object\") {\n          // 如果值是对象，递归调用函数，并更新前缀\n          css = generateCSSVariables(obj[key], `${prefix}${camelCaseToDashCase(key)}-`, css);\n        } else {\n          // 否则，生成CSS变量\n          css += `${prefix}${camelCaseToDashCase(key)}: ${obj[key]};\\n`;\n        }\n      }\n      return css;\n    }\n    return generateCSSVariables(theme);\n  }\n  /** 将主题CSS挂载到网页上 */\n  export async function applyThemeById(themeId: string) {\n    await applyTheme((await getThemeById(themeId))?.content);\n  }\n  export async function applyTheme(themeContent: any) {\n    let styleEl = document.querySelector(\"#pg-theme\");\n    if (!styleEl) {\n      styleEl = document.createElement(\"style\");\n      styleEl.id = \"pg-theme\";\n      document.head.appendChild(styleEl);\n    }\n    styleEl.innerHTML = `\n      :root {\n        ${convertThemeToCSS(themeContent)}\n      }\n    `;\n  }\n\n  export async function writeCustomTheme(theme: Theme) {\n    // 创建文件夹\n    await mkdir(await join(await appLocalDataDir(), \"themes\")).catch(() => {});\n    // 写入文件\n    const path = await join(await appLocalDataDir(), \"themes\", `${theme.metadata.id}.pg-theme`);\n    const yamlContent = `---\\n${YAML.stringify(theme.metadata)}---\\n${YAML.stringify(theme.content)}`;\n    await writeTextFile(path, yamlContent);\n  }\n  export async function deleteCustomTheme(themeId: string) {\n    const path = await join(await appLocalDataDir(), \"themes\", `${themeId}.pg-theme`);\n    await remove(path).catch(() => {});\n  }\n\n  export async function ids() {\n    // 列出所有内置和自定义主题\n    const customThemesDir = await join(await appLocalDataDir(), \"themes\");\n    const customThemeFiles = await readDir(customThemesDir).catch(() => []);\n    const customThemeIds = customThemeFiles\n      .filter((file) => file.name?.endsWith(\".pg-theme\"))\n      .map((file) => file.name!.replace(/\\.pg-theme$/, \"\"));\n    return [...builtinThemes.map((theme) => theme.metadata.id), ...customThemeIds];\n  }\n  export async function list() {\n    const ids_ = await ids();\n    const themes = await Promise.all(ids_.map((id) => getThemeById(id)));\n    return themes.filter((theme): theme is Themes.Theme => theme !== undefined);\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/Tourials.tsx",
    "content": "import { createStore } from \"@/utils/store\";\nimport { Store } from \"@tauri-apps/plugin-store\";\n\n/**\n * 教程记录\n */\nexport namespace Tutorials {\n  let store: Store | null = null;\n\n  // 只在最初始时创建一次\n  export async function init() {\n    store = await createStore(\"tourials.json\");\n  }\n\n  export async function finish(tourial: string) {\n    await store?.set(tourial, true);\n    await store?.save();\n  }\n\n  export async function reset() {\n    await store?.clear();\n    await store?.save();\n  }\n\n  export async function tour(tourial: string, fn: () => void | Promise<void>) {\n    if (await store?.get(tourial)) {\n      return;\n    }\n    await fn();\n    await finish(tourial);\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/UserState.tsx",
    "content": "import { Store } from \"@tauri-apps/plugin-store\";\nimport { createStore } from \"@/utils/store\";\nimport { FeatureFlags } from \"@/core/service/FeatureFlags\";\n\nexport namespace UserState {\n  let store: Store;\n\n  export async function init() {\n    if (!FeatureFlags.USER) {\n      return;\n    }\n    store = await createStore(\"user.json\");\n  }\n\n  export async function getToken() {\n    if (!FeatureFlags.USER) {\n      return \"\";\n    }\n    return (await store.get<string>(\"token\")) ?? \"\";\n  }\n  export async function setToken(token: string) {\n    if (!FeatureFlags.USER) {\n      return;\n    }\n    await store.set(\"token\", token);\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/DirectionKeyUtilsEngine/directionKeyUtilsEngine.tsx",
    "content": "import { Vector } from \"@graphif/data-structures\";\nimport { Direction } from \"@/types/directions\";\n\n/**\n * 方向键的通用引擎\n * 例如 WSAD键，上下左右方向键，IKJL键控制的带有加速度的运动\n *\n * 使用时建议继承此类，重写reset方法，在reset方法中初始化相关变量\n */\nexport class DirectionKeyUtilsEngine {\n  location: Vector = Vector.getZero();\n  speed: Vector = Vector.getZero();\n  accelerate: Vector = Vector.getZero();\n  /**\n   * 空气摩擦力系数\n   */\n  frictionCoefficient = 0.1;\n  /**\n   * 可以看成一个九宫格，主要用于处理 方向 按键移动，\n   * 当同时按下w和s，这个值会是(-1,-1)，表示朝着左上移动\n   */\n  accelerateCommander: Vector = Vector.getZero();\n  /**\n   * 每个方向上的动力矢量大小\n   */\n  moveAmplitude = 2;\n  /**\n   * 空气摩擦力速度指数\n   * 指数=2，表示 f = -k * v^2\n   * 指数=1，表示 f = -k * v\n   * 指数越大，速度衰减越快\n   */\n  frictionExponent = 1.5;\n\n  /**\n   * 是否反向\n   */\n  public isDirectionReversed = false;\n\n  constructor() {}\n\n  static keyMap: Record<Direction, Vector> = {\n    [Direction.Up]: new Vector(0, -1),\n    [Direction.Down]: new Vector(0, 1),\n    [Direction.Left]: new Vector(-1, 0),\n    [Direction.Right]: new Vector(1, 0),\n  };\n\n  /**\n   * 在继承的类中重写此方法\n   */\n  protected reset() {}\n\n  /**\n   * 重新设置指定位置并清空速度和加速度\n   * @param location\n   */\n  public resetLocation(location: Vector) {\n    this.location = location;\n    this.speed = Vector.getZero();\n    this.accelerate = Vector.getZero();\n    // this.accelerateCommander = Vector.getZero();\n  }\n\n  /**\n   * 初始化方法，用于绑定快捷键之类的\n   */\n  public init() {}\n\n  // 方向键按下\n\n  public keyPress(direction: Direction) {\n    let addAccelerate = DirectionKeyUtilsEngine.keyMap[direction];\n    // 如果反向\n    if (this.isDirectionReversed) {\n      addAccelerate = addAccelerate.multiply(-1);\n    }\n    // 当按下某一个方向的时候,相当于朝着某个方向赋予一次加速度\n    this.accelerateCommander = this.accelerateCommander.add(addAccelerate).limitX(-1, 1).limitY(-1, 1);\n  }\n\n  // 方向键松开\n\n  public keyRelease(direction: Direction) {\n    let addAccelerate = DirectionKeyUtilsEngine.keyMap[direction];\n    // 如果反向\n    if (this.isDirectionReversed) {\n      addAccelerate = addAccelerate.multiply(-1);\n    }\n    // 当松开某一个方向的时候,相当于停止加速度\n    this.accelerateCommander = this.accelerateCommander.subtract(addAccelerate).limitX(-1, 1).limitY(-1, 1);\n  }\n\n  public logicTick() {\n    if (Number.isNaN(this.location.x) || Number.isNaN(this.location.y)) {\n      this.speed = Vector.getZero();\n      this.reset();\n      return;\n    }\n\n    let friction = Vector.getZero();\n    if (!this.speed.isZero()) {\n      const speedSize = this.speed.magnitude();\n      // 计算摩擦力\n      friction = this.speed\n        .normalize()\n        .multiply(-1)\n        .multiply(this.frictionCoefficient * speedSize ** this.frictionExponent);\n    }\n\n    // 速度 = 速度 + 加速度（各个方向的力之和）\n    this.speed = this.speed.add(this.accelerateCommander.multiply(this.moveAmplitude)).add(friction);\n    this.location = this.location.add(this.speed);\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/MouseLocation.tsx",
    "content": "import { Vector } from \"@graphif/data-structures\";\n\nexport const MouseLocation = {\n  x: 0,\n  y: 0,\n  init() {\n    window.addEventListener(\"pointermove\", (event) => {\n      MouseLocation.x = event.clientX;\n      MouseLocation.y = event.clientY;\n      // this.project.controller.recordManipulate();\n      // 检测是否超出范围\n      // TODO: 优化，给每个 Controller 加一个 pointerMoveOutWindowForcedShutdown 方法\n      // if (this.x < 0 || this.x > window.innerWidth || this.y < 0 || this.y > window.innerHeight) {\n      //   if (this.project.controller.cutting.isUsing) {\n      //     this.project.controller.cutting.pointerMoveOutWindowForcedShutdown(this.vectorObject);\n      //   }\n      //   if (this.project.controller.camera.isUsingMouseGrabMove) {\n      //     this.project.controller.camera.pointerMoveOutWindowForcedShutdown(this.vectorObject);\n      //   }\n      //   if (this.project.controller.rectangleSelect.isUsing) {\n      //     this.project.controller.rectangleSelect.pointerMoveOutWindowForcedShutdown(this.vectorObject);\n      //   }\n      //   this.project.controller.entityClickSelectAndMove.pointerMoveOutWindowForcedShutdown(this.vectorObject);\n      // }\n    });\n  },\n  /**\n   * 返回视野坐标系中的鼠标位置\n   * 注意：此处返回的是 view 坐标系中的位置\n   * @returns\n   */\n  vector(): Vector {\n    return new Vector(this.x, this.y);\n  },\n};\n"
  },
  {
    "path": "app/src/core/service/controlService/autoLayoutEngine/autoLayoutFastTreeMode.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle, Line } from \"@graphif/shapes\";\n\n/**\n * 瞬间树形布局算法\n * 瞬间：一次性直接移动所有节点到合适的位置\n * 树形：此布局算法仅限于树形结构，在代码上游保证\n */\n@service(\"autoLayoutFastTree\")\nexport class AutoLayoutFastTree {\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 获取当前树的外接矩形，注意不要有环，有环就废了\n   * @param node\n   * @returns\n   */\n  private getTreeBoundingRectangle(node: ConnectableEntity): Rectangle {\n    const childList = this.project.graphMethods.nodeChildrenArray(node);\n    const childRectangle = childList.map((child) => this.getTreeBoundingRectangle(child));\n    return Rectangle.getBoundingRectangle(childRectangle.concat([node.collisionBox.getRectangle()]));\n  }\n  /**\n   * 将一个子树 看成一个外接矩形，移动这个外接矩形左上角到某一个位置\n   * @param treeRoot\n   * @param targetLocation\n   */\n  private moveTreeRectTo(treeRoot: ConnectableEntity, targetLocation: Vector) {\n    const treeRect = this.getTreeBoundingRectangle(treeRoot);\n    this.project.entityMoveManager.moveWithChildren(treeRoot, targetLocation.subtract(treeRect.leftTop));\n  }\n\n  /**\n   * 获取根节点的所有第一层子节点，并根据指定方向进行排序\n   * @param node 根节点\n   * @param childNodes 子节点列表\n   * @param direction 排序方向：col表示从上到下，row表示从左到右\n   * @returns 排序后的子节点数组\n   */\n  private getSortedChildNodes(\n    _node: ConnectableEntity,\n    childNodes: ConnectableEntity[],\n    direction: \"col\" | \"row\" = \"col\",\n  ): ConnectableEntity[] {\n    // const childNodes = this.project.graphMethods.nodeChildrenArray(node);\n\n    // 根据方向进行排序\n    if (direction === \"col\") {\n      // 从上到下排序：根据矩形的top属性\n      return childNodes.sort((a, b) => a.collisionBox.getRectangle().top - b.collisionBox.getRectangle().top);\n    } else {\n      // 从左到右排序：根据矩形的left属性\n      return childNodes.sort((a, b) => a.collisionBox.getRectangle().left - b.collisionBox.getRectangle().left);\n    }\n  }\n\n  /**\n   * 排列多个子树，支持从上到下或从左到右排列\n   * 从上到下排列多个子树，除了第一个子树，其他子树都相对于第一个子树的外接矩形进行位置调整\n   * @param trees 要排列的子树数组\n   * @param direction 要排列的是哪一侧的子树群\n   * @param gap 子树之间的间距\n   * @returns\n   */\n  private alignTrees(trees: ConnectableEntity[], direction: \"top\" | \"bottom\" | \"left\" | \"right\", gap = 10) {\n    if (trees.length === 0 || trees.length === 1) {\n      return;\n    }\n    const firstTree = trees[0];\n    const firstTreeRect = this.getTreeBoundingRectangle(firstTree);\n\n    // 根据方向设置初始位置\n    let currentPosition: Vector;\n    if (direction === \"right\") {\n      // ok\n      currentPosition = firstTreeRect.leftBottom.add(new Vector(0, gap));\n      trees.sort((a, b) => a.collisionBox.getRectangle().top - b.collisionBox.getRectangle().top);\n    } else if (direction === \"left\") {\n      currentPosition = firstTreeRect.rightBottom.add(new Vector(0, gap));\n      trees.sort((a, b) => a.collisionBox.getRectangle().top - b.collisionBox.getRectangle().top);\n    } else if (direction === \"bottom\") {\n      // ok\n      currentPosition = firstTreeRect.rightTop.add(new Vector(gap, 0));\n      trees.sort((a, b) => a.collisionBox.getRectangle().left - b.collisionBox.getRectangle().left);\n    } else {\n      // top\n      currentPosition = firstTreeRect.rightBottom.add(new Vector(gap, 0));\n      trees.sort((a, b) => a.collisionBox.getRectangle().left - b.collisionBox.getRectangle().left);\n    }\n\n    for (let i = 1; i < trees.length; i++) {\n      const tree = trees[i];\n\n      // 根据方向更新下一个位置\n      const treeRect = this.getTreeBoundingRectangle(tree);\n      if (direction === \"right\") {\n        this.moveTreeRectTo(tree, currentPosition);\n        currentPosition.y += treeRect.height + gap;\n      } else if (direction === \"bottom\") {\n        this.moveTreeRectTo(tree, currentPosition);\n        currentPosition.x += treeRect.width + gap;\n      } else if (direction === \"left\") {\n        this.moveTreeRectTo(tree, currentPosition.subtract(new Vector(treeRect.width, 0)));\n        currentPosition.y += treeRect.height + gap;\n      } else if (direction === \"top\") {\n        this.moveTreeRectTo(tree, currentPosition.subtract(new Vector(0, treeRect.height)));\n        currentPosition.x += treeRect.width + gap;\n      }\n    }\n  }\n\n  /**\n   * 根据根节点位置，调整子树的位置\n   * @param rootNode 固定位置的根节点\n   * @param childList 需要调整位置的子节点列表\n   * @param gap 根节点与子节点之间的间距\n   * @param position 子节点相对于根节点的位置：rightCenter(右侧中心)、leftCenter(左侧中心)、bottomCenter(下方中心)、topCenter(上方中心)\n   */\n  private adjustChildrenTreesByRootNodeLocation(\n    rootNode: ConnectableEntity,\n    childList: ConnectableEntity[],\n    gap = 100,\n    position: \"rightCenter\" | \"leftCenter\" | \"bottomCenter\" | \"topCenter\" = \"rightCenter\",\n  ) {\n    if (childList.length === 0) {\n      return;\n    }\n\n    const parentRectangle = rootNode.collisionBox.getRectangle();\n\n    // 计算子树的外接矩形\n    const childsRectangle = Rectangle.getBoundingRectangle(childList.map((child) => child.collisionBox.getRectangle()));\n\n    // 计算子树应该移动到的目标位置（使用边缘距离而不是中心位置）\n    let targetLocation: Vector;\n\n    // 根据位置参数计算目标位置\n    switch (position) {\n      case \"rightCenter\":\n        // 右侧：子树位于根节点的右侧，使用右边缘计算\n        targetLocation = new Vector(parentRectangle.right + gap + childsRectangle.width / 2, parentRectangle.center.y);\n        break;\n\n      case \"leftCenter\":\n        // 左侧：子树位于根节点的左侧，使用左边缘计算\n        targetLocation = new Vector(parentRectangle.left - gap - childsRectangle.width / 2, parentRectangle.center.y);\n        break;\n\n      case \"bottomCenter\":\n        // 下方：子树位于根节点的下方，使用底边缘计算\n        targetLocation = new Vector(\n          parentRectangle.center.x,\n          parentRectangle.bottom + gap + childsRectangle.height / 2,\n        );\n        break;\n\n      case \"topCenter\":\n        // 上方：子树位于根节点的上方，使用顶边缘计算\n        targetLocation = new Vector(parentRectangle.center.x, parentRectangle.top - gap - childsRectangle.height / 2);\n        break;\n    }\n\n    // 计算需要移动的偏移量\n    const offset = targetLocation.subtract(childsRectangle.center);\n\n    // 移动所有子节点及其子树\n    for (const child of childList) {\n      this.project.entityMoveManager.moveWithChildren(child, offset);\n    }\n  }\n\n  /**\n   * 检测并解决不同方向子树群之间的重叠问题\n   * @param rootNode 根节点\n   * @param directionGroups 不同方向的子树群\n   */\n  private resolveSubtreeOverlaps(\n    rootNode: ConnectableEntity,\n    directionGroups: {\n      right?: ConnectableEntity[];\n      left?: ConnectableEntity[];\n      bottom?: ConnectableEntity[];\n      top?: ConnectableEntity[];\n    },\n  ) {\n    // 创建方向对进行检查\n    const directionPairs = [\n      { dir1: \"right\" as const, dir2: \"bottom\" as const },\n      { dir1: \"right\" as const, dir2: \"top\" as const },\n      { dir1: \"right\" as const, dir2: \"left\" as const },\n      { dir1: \"bottom\" as const, dir2: \"top\" as const },\n      { dir1: \"bottom\" as const, dir2: \"left\" as const },\n      { dir1: \"top\" as const, dir2: \"left\" as const },\n    ];\n\n    // 检查每对方向是否有重叠\n    for (const { dir1, dir2 } of directionPairs) {\n      const group1 = directionGroups[dir1];\n      const group2 = directionGroups[dir2];\n\n      if (!group1 || !group2 || group1.length === 0 || group2.length === 0) {\n        continue;\n      }\n\n      // 获取子树群的外接矩形\n      const rect1 = Rectangle.getBoundingRectangle(group1.map((child) => this.getTreeBoundingRectangle(child)));\n      const rect2 = Rectangle.getBoundingRectangle(group2.map((child) => this.getTreeBoundingRectangle(child)));\n\n      let pushCount = 0;\n      // 检查是否重叠或连线相交\n      while (this.hasOverlapOrLineIntersection(rootNode, group1, group2, dir1, dir2)) {\n        pushCount++;\n        if (pushCount > 1000) {\n          break; // 防止无限循环\n        }\n        // 确定强势方向\n        const group1Size = group1.length;\n        const group2Size = group2.length;\n        let weakerDir: \"right\" | \"left\" | \"bottom\" | \"top\";\n\n        if (group1Size > group2Size) {\n          weakerDir = dir2;\n        } else if (group2Size > group1Size) {\n          weakerDir = dir1;\n        } else {\n          // 数量相等时，按优先级排序：右侧>下侧>左侧>上侧\n          const priorityOrder = [\"right\", \"bottom\", \"left\", \"top\"] as const;\n          const index1 = priorityOrder.indexOf(dir1);\n          const index2 = priorityOrder.indexOf(dir2);\n          weakerDir = index1 < index2 ? dir2 : dir1;\n        }\n\n        // 移动弱势方向的子树群\n        const weakerGroup = weakerDir === dir1 ? group1 : group2;\n        const moveAmount = 10; // 每次移动10个距离\n\n        // 根据方向确定移动向量\n        let moveVector: Vector;\n        switch (weakerDir) {\n          case \"right\":\n            moveVector = new Vector(moveAmount, 0);\n            break;\n          case \"left\":\n            moveVector = new Vector(-moveAmount, 0);\n            break;\n          case \"bottom\":\n            moveVector = new Vector(0, moveAmount);\n            break;\n          case \"top\":\n            moveVector = new Vector(0, -moveAmount);\n            break;\n        }\n\n        // 移动弱势方向的所有子树\n        for (const child of weakerGroup) {\n          this.project.entityMoveManager.moveWithChildren(child, moveVector);\n        }\n\n        // 更新外接矩形以继续检查\n        if (weakerDir === dir1) {\n          const newRect1 = Rectangle.getBoundingRectangle(group1.map((child) => this.getTreeBoundingRectangle(child)));\n          rect1.location = newRect1.location.clone();\n          rect1.size = newRect1.size.clone();\n        } else {\n          const newRect2 = Rectangle.getBoundingRectangle(group2.map((child) => this.getTreeBoundingRectangle(child)));\n          rect2.location = newRect2.location.clone();\n          rect2.size = newRect2.size.clone();\n        }\n      }\n    }\n  }\n\n  /**\n   * 检查两个方向子树群之间是否有矩形重叠或连线相交\n   * @param rootNode 根节点\n   * @param group1 第一个子树群\n   * @param group2 第二个子树群\n   */\n  private hasOverlapOrLineIntersection(\n    rootNode: ConnectableEntity,\n    group1: ConnectableEntity[],\n    group2: ConnectableEntity[],\n    dir1: \"left\" | \"right\" | \"top\" | \"bottom\",\n    dir2: \"left\" | \"right\" | \"top\" | \"bottom\",\n  ): boolean {\n    // 检查矩形重叠\n    const rect1 = Rectangle.getBoundingRectangle(group1.map((child) => this.getTreeBoundingRectangle(child)));\n    const rect2 = Rectangle.getBoundingRectangle(group2.map((child) => this.getTreeBoundingRectangle(child)));\n\n    if (rect1.isCollideWith(rect2)) {\n      return true;\n    }\n\n    // 根据方向参数进行特定的连线检测\n    const rootRect = rootNode.collisionBox.getRectangle();\n\n    // 1. 右侧和下侧节点群互相检测\n    if ((dir1 === \"right\" && dir2 === \"bottom\") || (dir1 === \"bottom\" && dir2 === \"right\")) {\n      // 确定哪组是右侧，哪组是下侧\n      const rightGroup = dir1 === \"right\" ? group1 : group2;\n      const bottomGroup = dir1 === \"bottom\" ? group1 : group2;\n      const rightRect = dir1 === \"right\" ? rect1 : rect2;\n      const bottomRect = dir1 === \"bottom\" ? rect1 : rect2;\n\n      // 检查子树群是否异常\n      const isRightGroupAbnormal = rightRect.left < rootRect.right; // 右侧子树群整体在根节点右侧的左侧\n      const isBottomGroupAbnormal = bottomRect.top < rootRect.bottom; // 下侧子树群整体在根节点下侧的上侧\n\n      // 如果任意一个子树群异常，跳过连线检查\n      if (isRightGroupAbnormal || isBottomGroupAbnormal) {\n        return false;\n      }\n\n      // 获取右侧节点群最下方节点（数组最后一个元素）\n      if (rightGroup.length > 0 && bottomGroup.length > 0) {\n        const lastRightNode = rightGroup[rightGroup.length - 1];\n        const lastRightNodeRect = lastRightNode.collisionBox.getRectangle();\n\n        // 右侧节点群最下方节点的左边缘中心位置\n        const rightNodeLeftCenter = lastRightNodeRect.leftCenter.clone();\n\n        // 根节点的右侧中心位置\n        const rootRightCenter = rootRect.rightCenter.clone();\n\n        // 构造连线并检查是否与下侧节点群外接矩形重叠\n        const line1 = new Line(rootRightCenter, rightNodeLeftCenter);\n        if (line1.isCollideWithRectangle(bottomRect)) {\n          return true;\n        }\n\n        // 获取下方节点群最右侧节点\n        const lastBottomNode = bottomGroup[bottomGroup.length - 1];\n        const lastBottomNodeRect = lastBottomNode.collisionBox.getRectangle();\n\n        // 下方节点群最右侧节点的上中心位置\n        const bottomNodeTopCenter = lastBottomNodeRect.topCenter.clone();\n\n        // 根节点的下中心位置\n        const rootBottomCenter = rootRect.bottomCenter.clone();\n\n        // 构造连线并检查是否与右侧节点群外接矩形重叠\n        const line2 = new Line(rootBottomCenter, bottomNodeTopCenter);\n        if (line2.isCollideWithRectangle(rightRect)) {\n          return true;\n        }\n      }\n    }\n\n    // 2. 左侧和下侧节点群互相检测\n    else if ((dir1 === \"left\" && dir2 === \"bottom\") || (dir1 === \"bottom\" && dir2 === \"left\")) {\n      const leftGroup = dir1 === \"left\" ? group1 : group2;\n      const bottomGroup = dir1 === \"bottom\" ? group1 : group2;\n      const leftRect = dir1 === \"left\" ? rect1 : rect2;\n      const bottomRect = dir1 === \"bottom\" ? rect1 : rect2;\n\n      // 检查子树群是否异常\n      const isLeftGroupAbnormal = leftRect.right > rootRect.left; // 左侧子树群整体在根节点左侧的右侧\n      const isBottomGroupAbnormal = bottomRect.top < rootRect.bottom; // 下侧子树群整体在根节点下侧的上侧\n\n      // 如果任意一个子树群异常，跳过连线检查\n      if (isLeftGroupAbnormal || isBottomGroupAbnormal) {\n        return false;\n      }\n\n      if (leftGroup.length > 0 && bottomGroup.length > 0) {\n        // 左侧最下方节点\n        const lastLeftNode = leftGroup[leftGroup.length - 1];\n        const lastLeftNodeRect = lastLeftNode.collisionBox.getRectangle();\n\n        // 左侧节点群最下方节点的右边缘中心位置\n        const leftNodeRightCenter = lastLeftNodeRect.rightCenter.clone();\n\n        // 根节点的左侧中心位置\n        const rootLeftCenter = rootRect.leftCenter.clone();\n\n        // 构造连线并检查是否与下侧节点群外接矩形重叠\n        const line1 = new Line(rootLeftCenter, leftNodeRightCenter);\n        if (line1.isCollideWithRectangle(bottomRect)) {\n          return true;\n        }\n\n        // 下方最左侧节点\n        const firstBottomNode = bottomGroup[0];\n        const firstBottomNodeRect = firstBottomNode.collisionBox.getRectangle();\n\n        // 下方节点群最左侧节点的上中心位置\n        const bottomNodeTopCenter = firstBottomNodeRect.topCenter.clone();\n\n        // 根节点的下中心位置\n        const rootBottomCenter = rootRect.bottomCenter.clone();\n\n        // 构造连线并检查是否与左侧节点群外接矩形重叠\n        const line2 = new Line(rootBottomCenter, bottomNodeTopCenter);\n        if (line2.isCollideWithRectangle(leftRect)) {\n          return true;\n        }\n      }\n    }\n\n    // 3. 左侧和上侧节点群互相检测\n    else if ((dir1 === \"left\" && dir2 === \"top\") || (dir1 === \"top\" && dir2 === \"left\")) {\n      const leftGroup = dir1 === \"left\" ? group1 : group2;\n      const topGroup = dir1 === \"top\" ? group1 : group2;\n      const leftRect = dir1 === \"left\" ? rect1 : rect2;\n      const topRect = dir1 === \"top\" ? rect1 : rect2;\n\n      // 检查子树群是否异常\n      const isLeftGroupAbnormal = leftRect.right > rootRect.left; // 左侧子树群整体在根节点左侧的右侧\n      const isTopGroupAbnormal = topRect.bottom > rootRect.top; // 上侧子树群整体在根节点上侧的下侧\n\n      // 如果任意一个子树群异常，跳过连线检查\n      if (isLeftGroupAbnormal || isTopGroupAbnormal) {\n        return false;\n      }\n\n      if (leftGroup.length > 0 && topGroup.length > 0) {\n        // 左侧最上方节点\n        const firstLeftNode = leftGroup[0];\n        const firstLeftNodeRect = firstLeftNode.collisionBox.getRectangle();\n\n        // 左侧节点群最上方节点的右边缘中心位置\n        const leftNodeRightCenter = firstLeftNodeRect.rightCenter.clone();\n\n        // 根节点的左侧中心位置\n        const rootLeftCenter = rootRect.leftCenter.clone();\n\n        // 构造连线并检查是否与上侧节点群外接矩形重叠\n        const line1 = new Line(rootLeftCenter, leftNodeRightCenter);\n        if (line1.isCollideWithRectangle(topRect)) {\n          return true;\n        }\n\n        // 上方最左侧节点\n        const firstTopNode = topGroup[0];\n        const firstTopNodeRect = firstTopNode.collisionBox.getRectangle();\n\n        // 上方节点群最左侧节点的下中心位置\n        const topNodeBottomCenter = firstTopNodeRect.bottomCenter.clone();\n\n        // 根节点的上中心位置\n        const rootTopCenter = rootRect.topCenter.clone();\n\n        // 构造连线并检查是否与左侧节点群外接矩形重叠\n        const line2 = new Line(rootTopCenter, topNodeBottomCenter);\n        if (line2.isCollideWithRectangle(leftRect)) {\n          return true;\n        }\n      }\n    }\n\n    // 4. 右侧和上侧节点群互相检测\n    else if ((dir1 === \"right\" && dir2 === \"top\") || (dir1 === \"top\" && dir2 === \"right\")) {\n      const rightGroup = dir1 === \"right\" ? group1 : group2;\n      const topGroup = dir1 === \"top\" ? group1 : group2;\n      const rightRect = dir1 === \"right\" ? rect1 : rect2;\n      const topRect = dir1 === \"top\" ? rect1 : rect2;\n\n      // 检查子树群是否异常\n      const isRightGroupAbnormal = rightRect.left < rootRect.right; // 右侧子树群整体在根节点右侧的左侧\n      const isTopGroupAbnormal = topRect.bottom > rootRect.top; // 上侧子树群整体在根节点上侧的下侧\n\n      // 如果任意一个子树群异常，跳过连线检查\n      if (isRightGroupAbnormal || isTopGroupAbnormal) {\n        return false;\n      }\n\n      if (rightGroup.length > 0 && topGroup.length > 0) {\n        // 右侧最上方节点\n        const firstRightNode = rightGroup[0];\n        const firstRightNodeRect = firstRightNode.collisionBox.getRectangle();\n\n        // 右侧节点群最上方节点的左边缘中心位置\n        const rightNodeLeftCenter = firstRightNodeRect.leftCenter.clone();\n\n        // 根节点的右侧中心位置\n        const rootRightCenter = rootRect.rightCenter.clone();\n\n        // 构造连线并检查是否与上侧节点群外接矩形重叠\n        const line1 = new Line(rootRightCenter, rightNodeLeftCenter);\n        if (line1.isCollideWithRectangle(topRect)) {\n          return true;\n        }\n\n        // 上方最右侧节点\n        const lastTopNode = topGroup[topGroup.length - 1];\n        const lastTopNodeRect = lastTopNode.collisionBox.getRectangle();\n\n        // 上方节点群最右侧节点的下中心位置\n        const topNodeBottomCenter = lastTopNodeRect.bottomCenter.clone();\n\n        // 根节点的上中心位置\n        const rootTopCenter = rootRect.topCenter.clone();\n\n        // 构造连线并检查是否与右侧节点群外接矩形重叠\n        const line2 = new Line(rootTopCenter, topNodeBottomCenter);\n        if (line2.isCollideWithRectangle(rightRect)) {\n          return true;\n        }\n      }\n    }\n\n    return false;\n  }\n  /**\n   * 快速树形布局\n   * @param rootNode\n   */\n  public autoLayoutFastTreeMode(rootNode: ConnectableEntity) {\n    // 树形结构的根节点 矩形左上角位置固定不动\n    const rootLeftTopLocation = rootNode.collisionBox.getRectangle().leftTop.clone();\n\n    const dfs = (node: ConnectableEntity) => {\n      const outEdges = this.project.graphMethods.getOutgoingEdges(node);\n      const outRightEdges = outEdges.filter((edge) => edge.isLeftToRight());\n      const outLeftEdges = outEdges.filter((edge) => edge.isRightToLeft());\n      const outTopEdges = outEdges.filter((edge) => edge.isBottomToTop());\n      const outBottomEdges = outEdges.filter((edge) => edge.isTopToBottom());\n      const outUnknownEdges = outEdges.filter((edge) => edge.isUnknownDirection());\n\n      // 获取排序后的子节点列表\n      let rightChildList = outRightEdges.map((edge) => edge.target);\n      let leftChildList = outLeftEdges.map((edge) => edge.target);\n      let topChildList = outTopEdges.map((edge) => edge.target);\n      let bottomChildList = outBottomEdges.map((edge) => edge.target);\n      const unknownChildList = outUnknownEdges.map((edge) => edge.target);\n\n      rightChildList = this.getSortedChildNodes(node, rightChildList, \"col\");\n      leftChildList = this.getSortedChildNodes(node, leftChildList, \"col\");\n      topChildList = this.getSortedChildNodes(node, topChildList, \"row\");\n      bottomChildList = this.getSortedChildNodes(node, bottomChildList, \"row\");\n\n      for (const child of rightChildList) {\n        dfs(child); // 递归口\n      }\n      for (const child of topChildList) {\n        dfs(child); // 递归口\n      }\n      for (const child of bottomChildList) {\n        dfs(child); // 递归口\n      }\n      for (const child of leftChildList) {\n        dfs(child); // 递归口\n      }\n      for (const child of unknownChildList) {\n        dfs(child); // 递归口\n      }\n      // 排列这些子节点，然后调整子树位置到根节点旁边\n      this.alignTrees(rightChildList, \"right\", 20);\n      this.adjustChildrenTreesByRootNodeLocation(node, rightChildList, 150, \"rightCenter\");\n\n      this.alignTrees(topChildList, \"top\", 20);\n      // 如果是向上生长且只有一个子节点（唯一子节点），使用较短距离，否则使用150像素\n      const topGap = topChildList.length === 1 ? 50 : 150;\n      this.adjustChildrenTreesByRootNodeLocation(node, topChildList, topGap, \"topCenter\");\n\n      this.alignTrees(bottomChildList, \"bottom\", 20);\n      // 如果是向下生长且只有一个子节点（唯一子节点），使用较短距离，否则使用150像素\n      const bottomGap = bottomChildList.length === 1 ? 50 : 150;\n      this.adjustChildrenTreesByRootNodeLocation(node, bottomChildList, bottomGap, \"bottomCenter\");\n\n      this.alignTrees(leftChildList, \"left\", 20);\n      this.adjustChildrenTreesByRootNodeLocation(node, leftChildList, 150, \"leftCenter\");\n\n      // 检测并解决不同方向子树群之间的重叠问题\n      this.resolveSubtreeOverlaps(node, {\n        right: rightChildList.length > 0 ? rightChildList : undefined,\n        left: leftChildList.length > 0 ? leftChildList : undefined,\n        bottom: bottomChildList.length > 0 ? bottomChildList : undefined,\n        top: topChildList.length > 0 ? topChildList : undefined,\n      });\n    };\n\n    dfs(rootNode);\n\n    // ------- 恢复根节点的位置\n    // 矩形左上角是矩形的标志位\n    const delta = rootLeftTopLocation.subtract(rootNode.collisionBox.getRectangle().leftTop);\n    // 选中根节点\n    this.project.stageManager.clearSelectAll();\n    rootNode.isSelected = true;\n    this.project.entityMoveManager.moveEntitiesWithChildren(delta);\n    // ------- 恢复完毕\n  }\n\n  // ======================= 反转树的位置系列 ====================\n\n  treeReverseX(selectedRootEntity: ConnectableEntity) {\n    this.treeReverse(selectedRootEntity, \"X\");\n  }\n  treeReverseY(selectedRootEntity: ConnectableEntity) {\n    this.treeReverse(selectedRootEntity, \"Y\");\n  }\n  /**\n   * 将树形结构翻转位置\n   * @param selectedRootEntity\n   */\n  private treeReverse(selectedRootEntity: ConnectableEntity, direction: \"X\" | \"Y\") {\n    // 检测树形结构\n    const nodeChildrenArray = this.project.graphMethods.nodeChildrenArray(selectedRootEntity);\n    if (nodeChildrenArray.length <= 1) {\n      return;\n    }\n    // 遍历所有节点，将其位置根据选中的根节点进行镜像位置调整\n    const dfs = (node: ConnectableEntity) => {\n      const childList = this.project.graphMethods.nodeChildrenArray(node);\n      for (const child of childList) {\n        dfs(child); // 递归口\n      }\n      const currentNodeCenter = node.collisionBox.getRectangle().center;\n      const rootNodeCenter = selectedRootEntity.collisionBox.getRectangle().center;\n      if (direction === \"X\") {\n        node.move(new Vector(-((currentNodeCenter.x - rootNodeCenter.x) * 2), 0));\n      } else if (direction === \"Y\") {\n        node.move(new Vector(0, -((currentNodeCenter.y - rootNodeCenter.y) * 2)));\n      }\n    };\n    dfs(selectedRootEntity);\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/autoLayoutEngine/mainTick.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { toast } from \"sonner\";\n\n/**\n * 计算一个节点的半径，半径是一个矩形中心到对角线的距离\n * @param entity\n */\nfunction getEntityRadius(entity: ConnectableEntity): number {\n  const rect = entity.collisionBox.getRectangle();\n  const width = rect.size.x;\n  const height = rect.size.y;\n  const diagonalLength = Math.sqrt(width ** 2 + height ** 2);\n  return diagonalLength / 2;\n}\n/**\n * 一种距离到力的映射函数\n * @param distance\n */\nfunction distanceToForce(distance: number): number {\n  return 1 / (distance ** 2 + 1);\n}\n\n@service(\"autoLayout\")\nexport class AutoLayout {\n  constructor(private readonly project: Project) {}\n\n  private isGravityLayoutStart: boolean = false;\n\n  tick() {\n    // 引力式布局\n    // if (this.project.controller.pressingKeySet.size === 1 && this.project.controller.pressingKeySet.has(\"g\")) {\n    //   this.gravityLayoutTick();\n    // }\n\n    if (this.isGravityLayoutStart) {\n      this.gravityLayoutTick();\n    }\n  }\n\n  public setGravityLayoutStart() {\n    this.isGravityLayoutStart = true;\n  }\n\n  public setGravityLayoutEnd() {\n    this.isGravityLayoutStart = false;\n  }\n\n  /**\n   * DAG布局算法输入数据结构\n   */\n  private getDAGLayoutInput(entities: ConnectableEntity[]): {\n    nodes: Array<{ id: string; rectangle: Rectangle }>;\n    edges: Array<{ from: string; to: string }>;\n  } {\n    // 构建节点映射，使用UUID作为唯一标识\n    const nodeMap = new Map<string, ConnectableEntity>();\n    const nodes = entities.map((entity) => {\n      nodeMap.set(entity.uuid, entity);\n      return {\n        id: entity.uuid,\n        rectangle: entity.collisionBox.getRectangle(),\n      };\n    });\n\n    // 构建边关系\n    const edges: Array<{ from: string; to: string }> = [];\n    for (const entity of entities) {\n      const children = this.project.graphMethods.nodeChildrenArray(entity);\n      for (const child of children) {\n        // 只包含选中实体之间的连接\n        if (nodeMap.has(child.uuid)) {\n          edges.push({\n            from: entity.uuid,\n            to: child.uuid,\n          });\n        }\n      }\n    }\n\n    return { nodes, edges };\n  }\n\n  /**\n   * DAG布局算法接口\n   * @param input 包含节点和边的DAG结构\n   * @returns 每个节点的新位置 { [nodeId: string]: Vector }\n   */\n  private computeDAGLayout(input: {\n    nodes: Array<{ id: string; rectangle: Rectangle }>;\n    edges: Array<{ from: string; to: string }>;\n  }): { [nodeId: string]: Vector } {\n    const { nodes, edges } = input;\n    // 先对节点进行拓扑排序并计算层数\n    const { order: topologicalOrder, levels } = this.topologicalSort(nodes, edges);\n\n    // 根据层数对节点进行分组\n    const nodesByLevel: Map<number, string[]> = new Map();\n    levels.forEach((level, nodeId) => {\n      if (!nodesByLevel.has(level)) {\n        nodesByLevel.set(level, []);\n      }\n      nodesByLevel.get(level)?.push(nodeId);\n    });\n\n    // 创建节点ID到节点信息的映射\n    const nodeMap: Map<string, { id: string; rectangle: Rectangle }> = new Map();\n    nodes.forEach((node) => {\n      nodeMap.set(node.id, node);\n    });\n\n    // 计算每个节点的新位置\n    const newPositions: { [nodeId: string]: Vector } = {};\n\n    // 定义层间距和节点间距\n    const horizontalSpacing = 150; // 层与层之间的基本水平间距\n    const verticalSpacing = 100; // 同一层节点之间的垂直间距\n\n    // 处理第一个节点（拓扑序第一个）\n    if (topologicalOrder.length > 0) {\n      const firstNodeId = topologicalOrder[0];\n      const firstNode = nodeMap.get(firstNodeId)!;\n      // 第一个节点位置保持不变\n      newPositions[firstNodeId] = firstNode.rectangle.location;\n\n      // 获取第一个节点的初始位置，作为所有层第一个元素的水平参考点\n      const firstNodePos = newPositions[firstNodeId];\n      const baseY = firstNodePos.y;\n\n      // 计算每层的最长矩形宽度\n      const maxLevel = Math.max(...Array.from(levels.values()));\n      const levelMaxWidths: Map<number, number> = new Map();\n\n      for (let level = 0; level <= maxLevel; level++) {\n        const nodesInLevel = nodesByLevel.get(level) || [];\n        let maxWidth = 0;\n\n        for (const nodeId of nodesInLevel) {\n          const node = nodeMap.get(nodeId)!;\n          const nodeWidth = node.rectangle.width;\n          if (nodeWidth > maxWidth) {\n            maxWidth = nodeWidth;\n          }\n        }\n\n        levelMaxWidths.set(level, maxWidth);\n      }\n\n      // 计算每层的水平偏移，考虑前一层的最长矩形宽度\n      const levelOffsets: Map<number, number> = new Map();\n      levelOffsets.set(0, firstNodePos.x); // 第一层的偏移就是第一个节点的x坐标\n\n      for (let level = 1; level <= maxLevel; level++) {\n        // 前一层的偏移\n        const prevOffset = levelOffsets.get(level - 1) || 0;\n        // 前一层的最长矩形宽度\n        const prevMaxWidth = levelMaxWidths.get(level - 1) || 0;\n        // 当前层的偏移 = 前一层偏移 + 前一层最长宽度 + 水平间距\n        const currentOffset = prevOffset + prevMaxWidth + horizontalSpacing;\n        levelOffsets.set(level, currentOffset);\n      }\n\n      // 按层数处理所有节点\n      for (let level = 0; level <= maxLevel; level++) {\n        const nodesInLevel = nodesByLevel.get(level) || [];\n        if (nodesInLevel.length === 0) continue;\n\n        // 计算当前层的水平位置\n        const levelX = levelOffsets.get(level) || 0;\n\n        // 处理当前层的所有节点\n        for (let i = 0; i < nodesInLevel.length; i++) {\n          const currentNodeId = nodesInLevel[i];\n\n          // 如果是第一层第一个节点，已经处理过，跳过\n          if (level === 0 && i === 0) continue;\n\n          // 计算垂直位置：基于第一层第一个节点的y坐标加上垂直间距\n          // 同一层的节点在垂直方向上排列\n          const newY = baseY + i * verticalSpacing;\n\n          // 设置节点位置\n          newPositions[currentNodeId] = new Vector(levelX, newY);\n        }\n      }\n    }\n\n    return newPositions;\n  }\n\n  /**\n   * 使用Kahn算法对DAG进行拓扑排序，并计算节点层数\n   * @param nodes 节点数组\n   * @param edges 边数组\n   * @returns 包含拓扑排序结果和节点层数映射的对象\n   */\n  private topologicalSort(\n    nodes: Array<{ id: string; rectangle: Rectangle }>,\n    edges: Array<{ from: string; to: string }>,\n  ): { order: string[]; levels: Map<string, number> } {\n    // 构建邻接表和入度映射\n    const adjacencyList: Map<string, string[]> = new Map();\n    const inDegree: Map<string, number> = new Map();\n    // 用于存储每个节点的层数\n    const levels: Map<string, number> = new Map();\n\n    // 初始化邻接表、入度和层数\n    nodes.forEach((node) => {\n      adjacencyList.set(node.id, []);\n      inDegree.set(node.id, 0);\n      levels.set(node.id, 0); // 初始层数设为0\n    });\n\n    // 填充邻接表和计算入度\n    edges.forEach((edge) => {\n      const { from, to } = edge;\n      // 添加边到邻接表\n      adjacencyList.get(from)?.push(to);\n      // 增加目标节点的入度\n      inDegree.set(to, (inDegree.get(to) || 0) + 1);\n    });\n\n    // 初始化队列，加入所有入度为0的节点\n    const queue: string[] = [];\n    inDegree.forEach((degree, nodeId) => {\n      if (degree === 0) {\n        queue.push(nodeId);\n        levels.set(nodeId, 0); // 入度为0的节点层数为0\n      }\n    });\n\n    const result: string[] = [];\n\n    // 执行拓扑排序\n    while (queue.length > 0) {\n      const current = queue.shift()!;\n      result.push(current);\n      // 获取当前节点的层数\n      const currentLevel = levels.get(current)!;\n\n      // 遍历当前节点的所有邻居\n      const neighbors = adjacencyList.get(current) || [];\n      neighbors.forEach((neighbor) => {\n        // 减少邻居的入度\n        const newDegree = (inDegree.get(neighbor) || 0) - 1;\n        inDegree.set(neighbor, newDegree);\n\n        // 计算邻居节点的层数，取当前计算值与已有值的最大值\n        const neighborLevel = Math.max(levels.get(neighbor) || 0, currentLevel + 1);\n        levels.set(neighbor, neighborLevel);\n\n        // 如果入度变为0，加入队列\n        if (newDegree === 0) {\n          queue.push(neighbor);\n        }\n      });\n    }\n\n    // 检查是否存在环（如果结果长度不等于节点数，说明存在环）\n    if (result.length !== nodes.length) {\n      console.warn(\"DAG布局警告：图中存在环，拓扑排序结果可能不完整\");\n      // 可以选择返回部分结果或抛出错误，这里选择返回部分结果\n    }\n\n    return { order: result, levels };\n  }\n\n  /**\n   * DAG布局主函数\n   * @param entities 选中的实体列表\n   */\n  public autoLayoutDAG(entities: ConnectableEntity[]) {\n    try {\n      // 1. 准备算法输入数据\n      const input = this.getDAGLayoutInput(entities);\n\n      // 2. 调用DAG布局算法计算新位置\n      const newPositions = this.computeDAGLayout(input);\n\n      // 3. 应用计算结果到实际节点\n      const nodeMap = new Map<string, ConnectableEntity>();\n      entities.forEach((entity) => nodeMap.set(entity.uuid, entity));\n\n      // 4. 移动节点到新位置\n      for (const [nodeId, position] of Object.entries(newPositions)) {\n        const entity = nodeMap.get(nodeId);\n        if (entity) {\n          entity.moveTo(position);\n        }\n      }\n\n      // 5. 记录操作步骤，支持撤销\n      this.project.historyManager.recordStep();\n\n      // 6. 显示成功提示\n      toast.success(\"DAG布局已应用\");\n    } catch (error) {\n      // 7. 错误处理\n      console.error(\"DAG布局失败:\", error);\n      toast.error(\"DAG布局失败，请检查控制台日志\");\n    }\n  }\n\n  /**\n   * 引力式布局\n   */\n  gravityLayoutTick() {\n    // 获取所有选中的节点\n    const selectedConnectableEntities = this.project.stageManager\n      .getSelectedEntities()\n      .filter((entity) => entity instanceof ConnectableEntity);\n    // 遍历所有选中的节点，将他们的直接孩子节点拉向自己\n    selectedConnectableEntities.forEach((entity) => {\n      // 计算父向子的关系\n      const children = this.project.graphMethods.nodeChildrenArray(entity);\n      children.forEach((child) => {\n        // 计算子节点到父节点的向量\n        const fatherToChildVector = child.collisionBox\n          .getRectangle()\n          .center.subtract(entity.collisionBox.getRectangle().center);\n        // 计算父亲半径和孩子半径\n        const fatherRadius = getEntityRadius(entity);\n        const childRadius = getEntityRadius(child);\n        const currentDistance = fatherToChildVector.magnitude();\n        if (currentDistance > (fatherRadius + childRadius) * 2) {\n          // 向内拉\n          child.move(fatherToChildVector.normalize().multiply(-1));\n        } else {\n          // 向外排斥\n          child.move(fatherToChildVector.normalize());\n        }\n      });\n      // 二重遍历\n      selectedConnectableEntities.forEach((entity2) => {\n        if (entity === entity2) {\n          return;\n        }\n        // 计算两个节点的距离\n        const vector = entity2.collisionBox.getRectangle().center.subtract(entity.collisionBox.getRectangle().center);\n        const distance = vector.magnitude();\n        // 计算两个节点的半径\n        const radius1 = getEntityRadius(entity);\n        const radius2 = getEntityRadius(entity2);\n        // 计算两个节点的最小距离\n        const minDistance = (radius1 + radius2) * 2;\n        if (distance < minDistance) {\n          entity2.move(vector.normalize().multiply(distanceToForce(distance - minDistance)));\n        } else if (distance > minDistance) {\n          entity2.move(vector.normalize().multiply(-distanceToForce(distance - minDistance)));\n        }\n      });\n    });\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/controller/Controller.tsx",
    "content": "import { Vector } from \"@graphif/data-structures\";\n// import { ControllerKeyboardOnly } from \"@/core/service/controlService/controller/concrete/ControllerKeyboardOnly\";\n// ...\nimport { Project, service } from \"@/core/Project\";\nimport { ControllerAssociationReshapeClass } from \"@/core/service/controlService/controller/concrete/ControllerAssociationReshape\";\nimport { ControllerCameraClass } from \"@/core/service/controlService/controller/concrete/ControllerCamera\";\nimport { ControllerCuttingClass } from \"@/core/service/controlService/controller/concrete/ControllerCutting\";\nimport { ControllerEdgeEditClass } from \"@/core/service/controlService/controller/concrete/ControllerEdgeEdit\";\nimport { ControllerEntityClickSelectAndMoveClass } from \"@/core/service/controlService/controller/concrete/ControllerEntityClickSelectAndMove\";\nimport { ControllerEntityCreateClass } from \"@/core/service/controlService/controller/concrete/ControllerEntityCreate\";\nimport { ControllerLayerMovingClass } from \"@/core/service/controlService/controller/concrete/ControllerEntityLayerMoving\";\nimport { ControllerEntityResizeClass } from \"@/core/service/controlService/controller/concrete/ControllerEntityResize\";\nimport { ControllerNodeConnectionClass } from \"@/core/service/controlService/controller/concrete/ControllerNodeConnection\";\nimport { ControllerNodeEditClass } from \"@/core/service/controlService/controller/concrete/ControllerNodeEdit\";\nimport { ControllerPenStrokeControlClass } from \"@/core/service/controlService/controller/concrete/ControllerPenStrokeControl\";\nimport { ControllerPenStrokeDrawingClass } from \"@/core/service/controlService/controller/concrete/ControllerPenStrokeDrawing\";\nimport { ControllerRectangleSelectClass } from \"@/core/service/controlService/controller/concrete/ControllerRectangleSelect\";\nimport { ControllerSectionEditClass } from \"@/core/service/controlService/controller/concrete/ControllerSectionEdit\";\nimport { CursorNameEnum } from \"@/types/cursors\";\nimport { isMac } from \"@/utils/platform\";\nimport { Settings } from \"../../Settings\";\n\n/**\n * 控制器，控制鼠标、键盘事件\n *\n * 所有具体的控制功能逻辑都封装在控制器对象中\n */\n@service(\"controller\")\nexport class Controller {\n  /**\n   * 在上层接收React提供的state修改函数\n   */\n\n  setCursorNameHook: (_: CursorNameEnum) => void = () => {};\n\n  // 检测正在按下的键\n  readonly pressingKeySet: Set<string> = new Set();\n  pressingKeysString(): string {\n    let res = \"\";\n    for (const key of this.pressingKeySet) {\n      res += `[${key}]` + \" \";\n    }\n    return res;\n  }\n\n  /**\n   * 是否正在进行移动(拖拽旋转)连线的操作\n   */\n\n  isMovingEdge = false;\n  /**\n   * 为移动节点做准备，移动时，记录每上一帧移动的位置\n   */\n\n  lastMoveLocation = Vector.getZero();\n  /**\n   * 当前的鼠标的位置\n   */\n\n  mouseLocation = Vector.getZero();\n\n  /**\n   * 有时需要锁定相机，比如 编辑节点时\n   */\n\n  isCameraLocked = false;\n\n  /**\n   * 上次选中的节点\n   * 仅为 Ctrl交叉选择使用\n   */\n  readonly lastSelectedEntityUUID: Set<string> = new Set();\n  readonly lastSelectedEdgeUUID: Set<string> = new Set();\n\n  touchStartLocation = Vector.getZero();\n  touchStartDistance = 0;\n  touchDelta = Vector.getZero();\n\n  lastClickTime = 0;\n  lastClickLocation = Vector.getZero();\n\n  readonly isMouseDown: boolean[] = [false, false, false];\n\n  private lastManipulateTime = performance.now();\n\n  /**\n   * 重置渲染倒计时器\n   * 触发了一次操作，记录时间\n   */\n  public resetCountdownTimer() {\n    this.lastManipulateTime = performance.now();\n  }\n\n  /**\n   * 检测是否已经有挺长一段时间没有操作了\n   * 进而决定不刷新屏幕\n   */\n  isManipulateOverTime() {\n    return performance.now() - this.lastManipulateTime > Settings.renderOverTimeWhenNoManipulateTime * 1000;\n  }\n\n  /**\n   * 悬浮提示的边缘距离\n   */\n  readonly edgeHoverTolerance = 10;\n\n  /**\n   * 初始化函数在页面挂在的时候调用，将事件绑定到Canvas上\n   * @param this.project.canvas.element\n   */\n  constructor(private readonly project: Project) {\n    // 绑定事件\n    this.project.canvas.element.addEventListener(\"keydown\", this.keydown.bind(this));\n    this.project.canvas.element.addEventListener(\"keyup\", this.keyup.bind(this));\n    this.project.canvas.element.addEventListener(\"pointerdown\", this.mousedown.bind(this));\n    this.project.canvas.element.addEventListener(\"pointerup\", this.mouseup.bind(this));\n    this.project.canvas.element.addEventListener(\"touchstart\", this.touchstart.bind(this), false);\n    this.project.canvas.element.addEventListener(\"touchmove\", this.touchmove.bind(this), false);\n    this.project.canvas.element.addEventListener(\"touchend\", this.touchend.bind(this), false);\n    this.project.canvas.element.addEventListener(\"wheel\", this.mousewheel.bind(this), false);\n    // 所有的具体的功能逻辑封装成控制器对象\n    // 当有新功能时新建控制器对象，并在这里初始化\n    Object.values(import.meta.glob(\"./concrete/*.tsx\", { eager: true }))\n      .map((module) => Object.entries(module as any).find(([k]) => k.includes(\"Class\"))!)\n      .filter(Boolean)\n      .map(([k, v]) => {\n        const inst = new (v as any)(this.project);\n        let id = k.replace(\"Controller\", \"\").replace(\"Class\", \"\");\n        id = id[0].toLowerCase() + id.slice(1);\n        this[id as keyof this] = inst;\n      });\n  }\n  dispose() {\n    Object.values(this).forEach((v) => {\n      if (v instanceof Controller) {\n        v.dispose();\n      }\n    });\n    this.project.canvas.element.removeEventListener(\"keydown\", this.keydown.bind(this));\n    this.project.canvas.element.removeEventListener(\"keyup\", this.keyup.bind(this));\n    this.project.canvas.element.removeEventListener(\"pointerdown\", this.mousedown.bind(this));\n    this.project.canvas.element.removeEventListener(\"pointerup\", this.mouseup.bind(this));\n    this.project.canvas.element.removeEventListener(\"touchstart\", this.touchstart.bind(this), false);\n    this.project.canvas.element.removeEventListener(\"touchmove\", this.touchmove.bind(this), false);\n    this.project.canvas.element.removeEventListener(\"touchend\", this.touchend.bind(this), false);\n    this.project.canvas.element.removeEventListener(\"wheel\", this.mousewheel.bind(this), false);\n  }\n\n  // 以下事件处理函数仅为Controller总控制器修改重要属性使用。不涉及具体的功能逻辑。\n\n  private mousedown(event: MouseEvent) {\n    // event.preventDefault();\n    this.handleMousedown(event.button, event.clientX, event.clientY);\n    this.resetCountdownTimer();\n  }\n\n  private mouseup(event: MouseEvent) {\n    // event.preventDefault();\n    this.handleMouseup(event.button, event.clientX, event.clientY);\n    this.resetCountdownTimer();\n  }\n\n  private mousewheel(event: WheelEvent) {\n    event.preventDefault();\n    // 禁用鼠标滚轮缩放\n    this.resetCountdownTimer();\n  }\n\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  private handleMousedown(button: number, _x: number, _y: number) {\n    this.isMouseDown[button] = true;\n\n    // 左右键按下时移除所有input焦点\n    if (button === 0 || button === 2) {\n      const activeElement = document.activeElement as HTMLElement;\n      if (activeElement && activeElement.blur) {\n        activeElement.blur();\n      }\n    }\n  }\n\n  private handleMouseup(button: number, x: number, y: number) {\n    this.isMouseDown[button] = false;\n    if (Date.now() - this.lastClickTime < 200 && this.lastClickLocation.distance(new Vector(x, y)) < 10) {\n      //\n    }\n    this.lastClickTime = Date.now();\n    this.lastClickLocation = new Vector(x, y);\n  }\n\n  private keydown(event: KeyboardEvent) {\n    this.resetCountdownTimer();\n    // 2025年2月1日\n    // 必须要禁止ctrl f 和ctrl+g的浏览器默认行为，否则会弹出一个框\n    // ctrl r 会刷新页面\n    if (event.ctrlKey && (event.key === \"f\" || event.key === \"g\")) {\n      event.preventDefault();\n    }\n    if (event.key === \"F3\" || event.key === \"F7\" || event.key === \"F5\") {\n      // 禁用F3查找功能，防止浏览器默认行为\n      // F7 插入光标浏览功能\n      event.preventDefault();\n    }\n    if (event.key === \"r\" || event.key === \"R\") {\n      // 禁用r刷新页面功能，防止浏览器默认行为\n      // 如果要在开发中测试刷新，应该在DevTools界面按这个快捷键\n      event.preventDefault();\n    }\n    // 禁止ctrl+shift+g 浏览器默认行为：查找上一个匹配项, ctrl+shift+c 打开控制台\n    if (event.key === \"G\" || event.key === \"C\") {\n      event.preventDefault();\n    }\n    if (event.key === \"p\") {\n      // 禁用p打印功能，防止浏览器默认行为\n      event.preventDefault();\n    }\n    if (event.key === \"j\") {\n      // 禁用弹出下载界面功能\n      event.preventDefault();\n    }\n    const key = event.key.toLowerCase();\n    this.pressingKeySet.add(key);\n  }\n\n  private keyup(event: KeyboardEvent) {\n    const key = event.key.toLowerCase();\n    if (this.pressingKeySet.has(key)) {\n      this.pressingKeySet.delete(key);\n    }\n    if (event.key === \" \" && isMac) {\n      // 停止框选\n      this.project.rectangleSelect.shutDown();\n    }\n    this.resetCountdownTimer();\n  }\n\n  // touch相关的事件有待重构到具体的功能逻辑中\n\n  private touchstart(e: TouchEvent) {\n    e.preventDefault();\n\n    if (e.touches.length === 1) {\n      this.handleMousedown(0, e.touches[0].clientX, e.touches[0].clientY);\n    }\n    if (e.touches.length === 2) {\n      const touch1 = Vector.fromTouch(e.touches[0]);\n      const touch2 = Vector.fromTouch(e.touches[1]);\n      const center = Vector.average(touch1, touch2);\n      this.touchStartLocation = center;\n\n      // 计算初始两指间距离\n      this.touchStartDistance = touch1.distance(touch2);\n    }\n    this.resetCountdownTimer();\n  }\n\n  private touchmove(e: TouchEvent) {\n    this.resetCountdownTimer();\n    e.preventDefault();\n\n    if (e.touches.length === 1) {\n      // HACK: 重构后touch方法就有问题了\n    }\n    if (e.touches.length === 2) {\n      const touch1 = Vector.fromTouch(e.touches[0]);\n      const touch2 = Vector.fromTouch(e.touches[1]);\n      const center = Vector.average(touch1, touch2);\n      this.touchDelta = center.subtract(this.touchStartLocation);\n\n      // 计算当前两指间的距离\n      const currentDistance = touch1.distance(touch2);\n      const scaleRatio = currentDistance / this.touchStartDistance;\n\n      // 缩放画面\n      this.project.camera.targetScale *= scaleRatio;\n      this.touchStartDistance = currentDistance; // 更新距离\n\n      // 更新中心点位置\n      this.touchStartLocation = center;\n\n      // 移动画面\n      this.project.camera.location = this.project.camera.location.subtract(\n        this.touchDelta.multiply(1 / this.project.camera.currentScale),\n      );\n    }\n  }\n\n  private touchend(e: TouchEvent) {\n    this.resetCountdownTimer();\n    e.preventDefault();\n    if (e.changedTouches.length === 1) {\n      // HACK: 重构后touch方法就有问题了\n      this.handleMouseup(0, e.changedTouches[0].clientX, e.changedTouches[0].clientY);\n    }\n    // 移动画面\n    this.project.camera.accelerateCommander = this.touchDelta\n      .multiply(-1)\n      .multiply(this.project.camera.currentScale)\n      .limitX(-1, 1)\n      .limitY(-1, 1);\n    this.touchDelta = Vector.getZero();\n    setTimeout(() => {\n      this.project.camera.accelerateCommander = Vector.getZero();\n    }, 100);\n  }\n}\n\ndeclare module \"./Controller\" {\n  interface Controller {\n    associationReshape: ControllerAssociationReshapeClass;\n    camera: ControllerCameraClass;\n    cutting: ControllerCuttingClass;\n    edgeEdit: ControllerEdgeEditClass;\n    entityClickSelectAndMove: ControllerEntityClickSelectAndMoveClass;\n    entityCreate: ControllerEntityCreateClass;\n    layerMoving: ControllerLayerMovingClass;\n    entityResize: ControllerEntityResizeClass;\n    imageScale: ControllerImageScaleClass;\n    nodeConnection: ControllerNodeConnectionClass;\n    nodeEdit: ControllerNodeEditClass;\n    penStrokeControl: ControllerPenStrokeControlClass;\n    penStrokeDrawing: ControllerPenStrokeDrawingClass;\n    rectangleSelect: ControllerRectangleSelectClass;\n    sectionEdit: ControllerSectionEditClass;\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/controller/ControllerClass.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { ViewOutlineFlashEffect } from \"@/core/service/feedbackService/effectEngine/concrete/ViewOutlineFlashEffect\";\nimport { Vector } from \"@graphif/data-structures\";\n\n/**\n * 控制器类，用于处理事件绑定和解绑\n * 每一个对象都是一个具体的功能\n */\nexport class ControllerClass {\n  constructor(protected readonly project: Project) {\n    // 等一会再开始绑定\n    setTimeout(() => {\n      this.project.canvas.element.addEventListener(\"keydown\", this.keydown.bind(this));\n      this.project.canvas.element.addEventListener(\"keyup\", this.keyup.bind(this));\n      this.project.canvas.element.addEventListener(\"pointerdown\", this.mousedown.bind(this));\n      this.project.canvas.element.addEventListener(\"pointerup\", this._mouseup.bind(this));\n      this.project.canvas.element.addEventListener(\"pointermove\", this.mousemove.bind(this));\n      this.project.canvas.element.addEventListener(\"wheel\", this.mousewheel.bind(this));\n      this.project.canvas.element.addEventListener(\"touchstart\", this._touchstart.bind(this));\n      this.project.canvas.element.addEventListener(\"touchmove\", this._touchmove.bind(this));\n      this.project.canvas.element.addEventListener(\"touchend\", this._touchend.bind(this));\n    }, 10);\n  }\n\n  public lastMoveLocation: Vector = Vector.getZero();\n  private lastClickTime: number = 0;\n  private lastClickLocation: Vector = Vector.getZero();\n\n  public keydown: (event: KeyboardEvent) => void = () => {};\n  public keyup: (event: KeyboardEvent) => void = () => {};\n  public mousedown: (event: PointerEvent) => void = () => {};\n  public mouseup: (event: PointerEvent) => void = () => {};\n  public mousemove: (event: PointerEvent) => void = () => {};\n  public mousewheel: (event: WheelEvent) => void = () => {};\n  public mouseDoubleClick: (event: PointerEvent) => void = () => {};\n  public touchstart: (event: TouchEvent) => void = () => {};\n  public touchmove: (event: TouchEvent) => void = () => {};\n  public touchend: (event: TouchEvent) => void = () => {};\n\n  public dispose() {\n    this.project.canvas.element.removeEventListener(\"keydown\", this.keydown.bind(this));\n    this.project.canvas.element.removeEventListener(\"keyup\", this.keyup.bind(this));\n    this.project.canvas.element.removeEventListener(\"pointerdown\", this.mousedown.bind(this));\n    this.project.canvas.element.removeEventListener(\"pointerup\", this._mouseup.bind(this));\n    this.project.canvas.element.removeEventListener(\"pointermove\", this.mousemove.bind(this));\n    this.project.canvas.element.removeEventListener(\"wheel\", this.mousewheel.bind(this));\n    // this.project.canvas.element.removeEventListener(\"touchstart\", this._touchstart.bind(this));\n    // this.project.canvas.element.removeEventListener(\"touchmove\", this._touchmove.bind(this));\n    // this.project.canvas.element.removeEventListener(\"touchend\", this._touchend.bind(this));\n\n    this.lastMoveLocation = Vector.getZero();\n  }\n\n  // private _mousedown = (event: PointerEvent) => {\n  //   this.mousedown(event);\n  //   // 检测双击\n  //   const now = new Date().getTime();\n  //   if (\n  //     now - this.lastClickTime < 300 &&\n  //     this.lastClickLocation.distance(\n  //       new Vector(event.clientX, event.clientY),\n  //     ) < 5\n  //   ) {\n  //     this.mouseDoubleClick(event);\n  //   }\n  //   this.lastClickTime = now;\n  //   this.lastClickLocation = new Vector(event.clientX, event.clientY);\n  // };\n\n  /**\n   * tips:\n   * 如果把双击函数写在mousedown里\n   * 双击的函数写在mousedown里了之后，双击的过程有四步骤：\n   *  1按下，2抬起，3按下，4抬起\n   *  结果在3按下的时候，瞬间创建了一个Input输入框透明的element\n   *  挡在了canvas上面。导致第四步抬起释放没有监听到了\n   *  进而导致：\n   *  双击创建节点后会有一个框选框吸附在鼠标上\n   *  双击编辑节点之后节点会进入编辑状态后一瞬间回到正常状态，然后节点吸附在了鼠标上\n   * 所以，双击的函数应该写在mouseup里，pc上就没有这个问题了。\n   * ——2024年12月5日\n   * @param event 鼠标事件对象\n   */\n  private _mouseup = (event: PointerEvent) => {\n    this.mouseup(event);\n    // 检测双击\n    const now = new Date().getTime();\n    if (\n      now - this.lastClickTime < 300 &&\n      this.lastClickLocation.distance(new Vector(event.clientX, event.clientY)) < 20\n    ) {\n      this.mouseDoubleClick(event);\n    }\n    this.lastClickTime = now;\n    this.lastClickLocation = new Vector(event.clientX, event.clientY);\n  };\n\n  private _touchstart = (event: TouchEvent) => {\n    // event.preventDefault();\n    const touch = {\n      ...(event.touches[event.touches.length - 1] as unknown as PointerEvent),\n      button: 0, // 通过对象展开实现相对安全的属性合并\n\n      // 尝试修复华为触摸屏的笔记本报错问题\n      clientX: event.touches[event.touches.length - 1].clientX,\n      clientY: event.touches[event.touches.length - 1].clientY,\n    } as PointerEvent;\n    if (event.touches.length > 1) {\n      this.project.controller.rectangleSelect.shutDown();\n    }\n    this.mousedown(touch);\n  };\n\n  private _touchmove = (event: TouchEvent) => {\n    // event.preventDefault();\n    this.onePointTouchMoveLocation = new Vector(\n      event.touches[event.touches.length - 1].clientX,\n      event.touches[event.touches.length - 1].clientY,\n    );\n    const touch = {\n      ...(event.touches[event.touches.length - 1] as unknown as PointerEvent),\n      button: 0, // 通过对象展开实现相对安全的属性合并\n\n      // 尝试修复华为触摸屏的笔记本报错问题\n      clientX: this.onePointTouchMoveLocation.x,\n      clientY: this.onePointTouchMoveLocation.y,\n    } as PointerEvent;\n    this.mousemove(touch);\n  };\n\n  // 由于touchend事件没有位置检测，所以只能延用touchmove的位置\n  private onePointTouchMoveLocation: Vector = Vector.getZero();\n\n  private _touchend = (event: TouchEvent) => {\n    // event.preventDefault();\n    const touch = {\n      ...(event.touches[event.touches.length - 1] as unknown as PointerEvent),\n      button: 0, // 通过对象展开实现相对安全的属性合并\n\n      // 尝试修复华为触摸屏的笔记本报错问题\n      clientX: this.onePointTouchMoveLocation.x,\n      clientY: this.onePointTouchMoveLocation.y,\n    } as PointerEvent;\n    this._mouseup(touch);\n  };\n\n  /**\n   * 鼠标移出窗口越界，强行停止功能\n   * @param _outsideLocation\n   */\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  public mouseMoveOutWindowForcedShutdown(_outsideLocation: Vector) {\n    this.project.effects.addEffect(\n      ViewOutlineFlashEffect.short(this.project.stageStyleManager.currentStyle.effects.warningShadow),\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/controller/concrete/ControllerAssociationReshape.tsx",
    "content": "import { Settings } from \"@/core/service/Settings\";\nimport { ControllerClass } from \"@/core/service/controlService/controller/ControllerClass\";\nimport { MultiTargetUndirectedEdge } from \"@/core/stage/stageObject/association/MutiTargetUndirectedEdge\";\nimport { CursorNameEnum } from \"@/types/cursors\";\nimport { isMac } from \"@/utils/platform\";\nimport { Vector } from \"@graphif/data-structures\";\n\n/**\n * 关系的重新塑性控制器\n *\n * 曾经：旋转图的节点控制器\n * 鼠标按住Ctrl旋转节点\n * 或者拖拽连线旋转\n *\n * 有向边的嫁接\n */\nexport class ControllerAssociationReshapeClass extends ControllerClass {\n  public mousewheel: (event: WheelEvent) => void = (event: WheelEvent) => {\n    // 只有当旋转设置启用、按下了正确的键，并且鼠标悬停在文本节点上时，才处理旋转事件\n    if (\n      Settings.enableCtrlWheelRotateStructure &&\n      (isMac\n        ? this.project.controller.pressingKeySet.has(\"meta\")\n        : this.project.controller.pressingKeySet.has(\"control\"))\n    ) {\n      const location = this.project.renderer.transformView2World(new Vector(event.clientX, event.clientY));\n      const hoverNode = this.project.stageManager.findTextNodeByLocation(location);\n      if (hoverNode !== null) {\n        // 旋转节点\n        if (event.deltaY > 0) {\n          this.project.stageNodeRotate.rotateNodeDfs(hoverNode, hoverNode, 10, []);\n        } else {\n          this.project.stageNodeRotate.rotateNodeDfs(hoverNode, hoverNode, -10, []);\n        }\n        // 处理完旋转事件后返回\n        return;\n      }\n    }\n    // 否则，不处理该事件，让相机控制器处理\n    // 这样当旋转设置关闭，或者鼠标不在节点上时，Ctrl+鼠标滚轮可以正常滚动视野\n  };\n\n  public lastMoveLocation: Vector = Vector.getZero();\n\n  public mousedown: (event: MouseEvent) => void = (event: MouseEvent) => {\n    if (Settings.mouseLeftMode !== \"selectAndMove\") {\n      return;\n    }\n    if (event.button !== 0) {\n      return;\n    }\n    if (this.project.controller.camera.isPreGrabbingWhenSpace) {\n      return;\n    }\n    const pressWorldLocation = this.project.renderer.transformView2World(new Vector(event.clientX, event.clientY));\n    // 点击\n    const clickedAssociation = this.project.stageManager.findAssociationByLocation(pressWorldLocation);\n    if (clickedAssociation === null) {\n      return;\n    }\n    const isHaveEntitySelected = this.project.stageManager.getEntities().some((entity) => entity.isSelected);\n    if (isHaveEntitySelected) {\n      // 如果有实体被选中的情况下，不能拖动关系\n      // 这是为了防止：移动质点时，很容易带动边的选中 的情况\n      return;\n    }\n    const isHaveLineEdgeSelected = this.project.stageManager.getLineEdges().some((edge) => edge.isSelected);\n    const isHaveMultiTargetEdgeSelected = this.project.stageManager\n      .getSelectedAssociations()\n      .some((association) => association instanceof MultiTargetUndirectedEdge);\n\n    this.lastMoveLocation = pressWorldLocation.clone();\n\n    if (isHaveLineEdgeSelected) {\n      this.project.controller.isMovingEdge = true;\n\n      if (clickedAssociation.isSelected) {\n        // E1\n        this.project.stageManager.getLineEdges().forEach((edge) => {\n          edge.isSelected = false;\n        });\n      } else {\n        // E2\n        this.project.stageManager.getLineEdges().forEach((edge) => {\n          edge.isSelected = false;\n        });\n      }\n      clickedAssociation.isSelected = true;\n    } else if (isHaveMultiTargetEdgeSelected) {\n      // 点击了多源无向边\n      clickedAssociation.isSelected = true;\n    } else {\n      // F\n      clickedAssociation.isSelected = true;\n    }\n    this.project.controller.setCursorNameHook(CursorNameEnum.Move);\n  };\n\n  public mousemove: (event: MouseEvent) => void = (event: MouseEvent) => {\n    if (Settings.mouseLeftMode !== \"selectAndMove\") {\n      return;\n    }\n    if (this.project.controller.rectangleSelect.isUsing || this.project.controller.cutting.isUsing) {\n      return;\n    }\n    const worldLocation = this.project.renderer.transformView2World(new Vector(event.clientX, event.clientY));\n    if (this.project.controller.isMouseDown[0]) {\n      const isControlPressed = isMac\n        ? this.project.controller.pressingKeySet.has(\"meta\")\n        : this.project.controller.pressingKeySet.has(\"control\");\n      const isShiftPressed = this.project.controller.pressingKeySet.has(\"shift\");\n\n      if (isControlPressed && isShiftPressed) {\n        // 更改Edge的源节点 (Control+Shift 组合)\n        const entity = this.project.stageManager.findConnectableEntityByLocation(worldLocation);\n        if (entity !== null) {\n          // 找到目标，更改源节点\n          this.project.nodeConnector.changeSelectedEdgeSource(entity);\n        }\n      } else if (isControlPressed) {\n        // 更改Edge的目标节点 (仅 Control)\n        const entity = this.project.stageManager.findConnectableEntityByLocation(worldLocation);\n        if (entity !== null) {\n          // 找到目标，更改目标\n          this.project.nodeConnector.changeSelectedEdgeTarget(entity);\n        }\n      } else {\n        const diffLocation = worldLocation.subtract(this.lastMoveLocation);\n        // 拖拽Edge\n        this.project.controller.isMovingEdge = true;\n        this.project.stageNodeRotate.moveEdges(this.lastMoveLocation, diffLocation);\n        this.project.multiTargetEdgeMove.moveMultiTargetEdge(diffLocation);\n      }\n      this.lastMoveLocation = worldLocation.clone();\n    }\n  };\n\n  public mouseup: (event: MouseEvent) => void = (event: MouseEvent) => {\n    if (Settings.mouseLeftMode !== \"selectAndMove\") {\n      return;\n    }\n    if (event.button !== 0) {\n      return;\n    }\n\n    // 如果是空格键拖拽视野，不要记录历史\n    if (this.project.controller.camera.isPreGrabbingWhenSpace || this.project.controller.pressingKeySet.has(\" \")) {\n      this.project.controller.isMovingEdge = false;\n      this.project.controller.setCursorNameHook(CursorNameEnum.Default);\n      return;\n    }\n\n    if (this.project.controller.isMovingEdge) {\n      this.project.historyManager.recordStep(); // 鼠标抬起了，移动结束，记录历史过程\n      this.project.controller.isMovingEdge = false;\n    }\n    this.project.controller.setCursorNameHook(CursorNameEnum.Default);\n  };\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/controller/concrete/ControllerCamera/mac.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { MouseTipFeedbackEffect } from \"@/core/service/feedbackService/effectEngine/concrete/MouseTipFeedbackEffect\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { isMac } from \"@/utils/platform\";\nimport { Vector } from \"@graphif/data-structures\";\n\nexport class ControllerCameraMac {\n  constructor(protected readonly project: Project) {}\n\n  /**\n   * 在mac系统下，判断是否是鼠标滚轮事件\n   * @param event 事件对象\n   */\n  isMouseWheel(event: WheelEvent) {\n    // 这里mac暂不考虑侧边横向滚轮。\n    if (event.deltaX !== 0 && event.deltaY !== 0) {\n      // 斜向滚动肯定不是鼠标滚轮。因为滚轮只有横向滚轮和竖向滚轮\n      return false;\n    } else {\n      // 垂直方向滚动\n      const distance = Math.abs(event.deltaY);\n      // 在mac系统下\n\n      // 测试者“雨幕”反馈数据：\n      // 鼠标滚轮：移动距离是整数\n      // 触摸板：小数\n\n      // 测试者“大道”反馈数据：\n      // 鼠标滚动一格：整数，会显示好多小数字，4 5 6 7 6 5 4这样的。\n\n      // M4 mackbook实测：\n      // 鼠标滚动一格，会显示一格数字 4.63535543\n      // 反而是触摸版，会显示 (1, 4), (0, 3) .... 很多小整数向量\n      if (Settings.macTrackpadAndMouseWheelDifference === \"tarckpadFloatAndWheelInt\") {\n        if (Number.isInteger(distance)) {\n          // 整数距离，是鼠标滚轮\n          return true;\n        } else {\n          // 小数距离，是触摸板\n          return false;\n        }\n      } else if (Settings.macTrackpadAndMouseWheelDifference === \"trackpadIntAndWheelFloat\") {\n        if (Number.isInteger(distance)) {\n          return false;\n        }\n        return true;\n      }\n      // 无法检测出逻辑\n      return false;\n    }\n  }\n\n  private readonly FINGER_SCALE_MIN_DETECT_TIME = 2; // s\n  // 上次检测时间\n  private lastDetectTime = Date.now();\n\n  // 默认鼠标滚轮，否则mac鼠标用户一打开文件会缩放过于灵敏\n  private currentWheelMode: \"fingerScale\" | \"mouseWheel\" = \"mouseWheel\";\n  /**\n   * 检测识别双指缩放\n   * @param event\n   */\n  isTouchPadTwoFingerScale(event: WheelEvent): boolean {\n    // 上游已经筛选出了斜向移动，现在只需要区分 上下滚动是双指缩放触发的，还是滚轮触发的\n    /**\n     * 区分逻辑：\n     * 双指缩放的第一个触发大小的y通常不到1，极小概率是大于1的（很剧烈的双指缩放的时候）\n     * 鼠标滚轮滚动的触发大小的y通常是4.xxxxx一个小数（不开启平滑滚动）\n     *\n     * 如果触发了一个事件移动不到1，那么后续连续触发的多个事件中都按双指缩放算\n     */\n    const y = event.deltaY;\n    if (Math.abs(y) < 4) {\n      // 一定是双指缩放！\n      this.currentWheelMode = \"fingerScale\";\n      this.lastDetectTime = Date.now();\n      return true;\n    } else {\n      // 可能是滚轮，也可能是双指缩放的中间过程\n      const currentTime = Date.now();\n      const diffTime = currentTime - this.lastDetectTime;\n      if (diffTime > this.FINGER_SCALE_MIN_DETECT_TIME * 1000) {\n        // 间隔过大，认为是滚轮\n        this.currentWheelMode = \"mouseWheel\";\n        this.lastDetectTime = currentTime;\n        return false;\n      } else {\n        this.lastDetectTime = currentTime;\n        // 间隔时间太短，按照上一次的模式判断\n        if (this.currentWheelMode === \"fingerScale\") {\n          return true;\n        } else {\n          return false;\n        }\n      }\n    }\n  }\n\n  /**\n   * mac 触发在触摸板上双指缩放的事件\n   * @param event\n   */\n  handleTwoFingerScale(event: WheelEvent) {\n    // 获取触发滚轮的鼠标位置\n    const mouseLocation = new Vector(event.clientX, event.clientY);\n    // 计算鼠标位置在视野中的位置\n    const worldLocation = this.project.renderer.transformView2World(mouseLocation);\n    this.project.camera.targetLocationByScale = worldLocation;\n\n    // 构建幂函数 y = a ^ x\n    // const power = 1.02; // 1.05 有点敏感，1.01 有点迟钝\n    const power = Settings.macTrackpadScaleSensitivity * 0.14 + 1.01;\n    // y 是 camera 的currentScale\n    // 通过y反解x\n    const currnetCameraScale = this.project.camera.currentScale;\n    const x = Math.log(currnetCameraScale) / Math.log(power);\n    // x 根据滚轮事件来变化\n    const diffX = event.deltaY * -1;\n    const newX = x + diffX;\n    // 求解新的 camera scale\n    const newCameraScale = Math.pow(power, newX);\n    // this.project.camera.currentScale = newCameraScale;\n    this.project.camera.targetScale = newCameraScale;\n    // this.project.camera.setAllowScaleFollowMouseLocationTicks(2 * 60);\n  }\n\n  moveCameraByTouchPadTwoFingerMove(event: WheelEvent) {\n    // 过滤 -0\n    if (Math.abs(event.deltaX) < 0.01 && Math.abs(event.deltaY) < 0.01) {\n      return;\n    }\n    if (this.project.controller.pressingKeySet.has(\" \")) {\n      this.handleRectangleSelectByTwoFingerMove(event);\n      return;\n    } else if (this.project.controller.pressingKeySet.has(\"meta\") && isMac) {\n      this.handleDrageMoveEntityByTwoFingerMove(event);\n      return;\n    }\n    const dx = event.deltaX / 400;\n    const dy = event.deltaY / 400;\n    const diffLocation = new Vector(dx, dy).multiply((Settings.moveAmplitude * 50) / this.project.camera.currentScale);\n    this.project.camera.location = this.project.camera.location.add(diffLocation);\n    this.project.effects.addEffect(MouseTipFeedbackEffect.directionObject(diffLocation));\n  }\n\n  private handleRectangleSelectByTwoFingerMove(event: WheelEvent) {\n    const dx = event.deltaX;\n    const dy = event.deltaY;\n    // TODO: 调用矩形框选\n    const rectangle = this.project.rectangleSelect.getRectangle();\n    if (rectangle) {\n      // 正在框选中\n      const selectEndLocation = this.project.rectangleSelect.getSelectEndLocation();\n      this.project.rectangleSelect.moveSelecting(\n        selectEndLocation.add(new Vector(-dx, -dy).divide(this.project.camera.currentScale)),\n      );\n    } else {\n      // 开始框选\n      const mouseLocation = new Vector(event.clientX, event.clientY);\n      const worldLocation = this.project.renderer.transformView2World(mouseLocation);\n      this.project.rectangleSelect.startSelecting(worldLocation);\n    }\n  }\n\n  private handleDrageMoveEntityByTwoFingerMove(event: WheelEvent) {\n    const dx = event.deltaX;\n    const dy = event.deltaY;\n    const diffLocation = new Vector(-dx, -dy).divide(this.project.camera.currentScale);\n    this.project.entityMoveManager.moveSelectedEntities(diffLocation);\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/controller/concrete/ControllerCamera.tsx",
    "content": "/**\n * 存放具体的控制器实例\n */\n\nimport { ArrayFunctions } from \"@/core/algorithm/arrayFunctions\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { ControllerClass } from \"@/core/service/controlService/controller/ControllerClass\";\nimport { ControllerCameraMac } from \"@/core/service/controlService/controller/concrete/ControllerCamera/mac\";\nimport { MouseTipFeedbackEffect } from \"@/core/service/feedbackService/effectEngine/concrete/MouseTipFeedbackEffect\";\nimport { CursorNameEnum } from \"@/types/cursors\";\nimport { openBrowserOrFileByEntity } from \"@/utils/externalOpen\";\nimport { isIpad, isMac } from \"@/utils/platform\";\nimport { LimitLengthQueue, Vector } from \"@graphif/data-structures\";\n\n/**\n *\n * 处理键盘按下事件\n * @param event - 键盘事件\n */\nexport class ControllerCameraClass extends ControllerClass {\n  // 按键映射\n  private static keyMap: { [key: string]: Vector } = {\n    w: new Vector(0, -1),\n    s: new Vector(0, 1),\n    a: new Vector(-1, 0),\n    d: new Vector(1, 0),\n  };\n\n  // 是否正在使用\n  public isUsingMouseGrabMove = false;\n  private lastMousePressLocation: Vector[] = [Vector.getZero(), Vector.getZero(), Vector.getZero()];\n  private isPressingCtrlOrMeta = false;\n  /**\n   * 是否正在使用空格+左键 拖动视野\n   */\n  public isPreGrabbingWhenSpace = false;\n\n  private mac = new ControllerCameraMac(this.project);\n\n  public keydown: (event: KeyboardEvent) => void = (event: KeyboardEvent) => {\n    if (this.project.controller.isCameraLocked) {\n      return;\n    }\n    const key = event.key.toLowerCase();\n\n    // 视野缩放：根据设置决定起飞和降落的方向\n    if (key === \"[\" || key === \"【\") {\n      if (Settings.cameraKeyboardScaleReverse) {\n        // 反转模式：[=起飞（缩小）\n        this.project.camera.isStartZoomOut = true;\n      } else {\n        // 正常模式：[=降落（放大）\n        this.project.camera.isStartZoomIn = true;\n      }\n      this.project.camera.addScaleFollowMouseLocationTime(1);\n      return;\n    }\n    if (key === \"]\" || key === \"】\") {\n      if (Settings.cameraKeyboardScaleReverse) {\n        // 反转模式：]=降落（放大）\n        this.project.camera.isStartZoomIn = true;\n      } else {\n        // 正常模式：]=起飞（缩小）\n        this.project.camera.isStartZoomOut = true;\n      }\n      this.project.camera.addScaleFollowMouseLocationTime(1);\n      return;\n    }\n\n    if (ControllerCameraClass.keyMap[key] && Settings.allowMoveCameraByWSAD) {\n      if (this.project.controller.pressingKeySet.has(\"control\") || this.project.controller.pressingKeySet.has(\"meta\")) {\n        // ctrl按下时，可能在按 ctrl+s 保存，防止出现冲突\n        this.isPressingCtrlOrMeta = true;\n        return;\n      }\n\n      let addAccelerate = ControllerCameraClass.keyMap[key];\n\n      if (Settings.cameraKeyboardMoveReverse) {\n        addAccelerate = addAccelerate.multiply(-1);\n      }\n      // 当按下某一个方向的时候,相当于朝着某个方向赋予一次加速度\n      this.project.camera.accelerateCommander = this.project.camera.accelerateCommander\n        .add(addAccelerate)\n        .limitX(-1, 1)\n        .limitY(-1, 1);\n    }\n    if (key === \" \" && Settings.enableSpaceKeyMouseLeftDrag) {\n      if (!this.isPreGrabbingWhenSpace) {\n        this.isPreGrabbingWhenSpace = true;\n        this.project.controller.setCursorNameHook(CursorNameEnum.Grab);\n      }\n    }\n  };\n\n  /**\n   * 处理键盘松开事件\n   * @param event - 键盘事件\n   */\n  public keyup: (event: KeyboardEvent) => void = (event: KeyboardEvent) => {\n    if (this.project.controller.isCameraLocked) {\n      return;\n    }\n    const key = event.key.toLowerCase();\n\n    // 解决ctrl+s 冲突\n    if (isMac ? key === \"meta\" : key === \"control\") {\n      setTimeout(() => {\n        this.isPressingCtrlOrMeta = false;\n      }, 500);\n    }\n    // ------\n\n    // 停止视野缩放：根据设置决定起飞和降落的方向\n    if (key === \"[\" || key === \"【\") {\n      if (Settings.cameraKeyboardScaleReverse) {\n        // 反转模式：[=起飞（缩小）\n        this.project.camera.isStartZoomOut = false;\n      } else {\n        // 正常模式：[=降落（放大）\n        this.project.camera.isStartZoomIn = false;\n      }\n      this.project.camera.addScaleFollowMouseLocationTime(5);\n      return;\n    }\n    // 停止视野缩放：起飞\n    if (key === \"]\" || key === \"】\") {\n      if (Settings.cameraKeyboardScaleReverse) {\n        // 反转模式：]=降落（放大）\n        this.project.camera.isStartZoomIn = false;\n      } else {\n        // 正常模式：]=起飞（缩小）\n        this.project.camera.isStartZoomOut = false;\n      }\n      this.project.camera.addScaleFollowMouseLocationTime(5);\n      return;\n    }\n\n    if (ControllerCameraClass.keyMap[key] && Settings.allowMoveCameraByWSAD) {\n      if (this.isPressingCtrlOrMeta) {\n        // ctrl按下时，可能在按 ctrl+s 保存，防止出现冲突\n        return;\n      }\n      let addAccelerate = ControllerCameraClass.keyMap[key];\n\n      if (Settings.cameraKeyboardMoveReverse) {\n        addAccelerate = addAccelerate.multiply(-1);\n      }\n      // 当松开某一个方向的时候,相当于停止加速度\n      this.project.camera.accelerateCommander = this.project.camera.accelerateCommander\n        .subtract(addAccelerate)\n        .limitX(-1, 1)\n        .limitY(-1, 1);\n    }\n    if (key === \" \") {\n      if (this.isPreGrabbingWhenSpace) {\n        this.isPreGrabbingWhenSpace = false;\n        this.project.controller.setCursorNameHook(CursorNameEnum.Default);\n      }\n    }\n  };\n\n  public mousedown = (event: MouseEvent) => {\n    if (this.project.controller.isCameraLocked) {\n      return;\n    }\n    if (event.button === 0 && this.project.controller.pressingKeySet.has(\" \") && Settings.enableSpaceKeyMouseLeftDrag) {\n      this.project.controller.setCursorNameHook(CursorNameEnum.Grabbing);\n      this.isUsingMouseGrabMove = true;\n    }\n    if (event.button === 1 && Settings.mouseRightDragBackground !== \"moveCamera\") {\n      // 中键按下\n      this.isUsingMouseGrabMove = true;\n    }\n    if (Settings.mouseRightDragBackground === \"moveCamera\" && event.button === 2) {\n      // 右键按下\n      this.isUsingMouseGrabMove = true;\n    }\n    const pressWorldLocation = this.project.renderer.transformView2World(new Vector(event.x, event.y));\n    // 获取左右中键\n    this.lastMousePressLocation[event.button] = pressWorldLocation;\n\n    if (this.isUsingMouseGrabMove && Settings.autoRefreshStageByMouseAction) {\n      // 开始刷新舞台\n      this.project.stageManager.refreshAllStageObjects();\n    }\n\n    // 2025年4月28日：实验性内容\n    if (event.button === 4) {\n      // 前侧键按下\n      this.project.camera.resetBySelected();\n    } else if (event.button === 3) {\n      // 后侧键按下\n      this.project.camera.reset();\n    }\n  };\n\n  /**\n   * 处理鼠标移动事件\n   * @param event - 鼠标事件\n   */\n  public mousemove: (event: MouseEvent) => void = (event: MouseEvent) => {\n    if (this.project.controller.isCameraLocked) {\n      return;\n    }\n    if (!this.isUsingMouseGrabMove) {\n      return;\n    }\n    // 空格+左键 拖动视野\n    if (\n      this.project.controller.pressingKeySet.has(\" \") &&\n      this.project.controller.isMouseDown[0] &&\n      Settings.enableSpaceKeyMouseLeftDrag\n    ) {\n      this.moveCameraByMouseMove(event.clientX, event.clientY, 0);\n      return;\n    }\n    // 中键按下拖动视野\n    if (this.project.controller.isMouseDown[1] && Settings.mouseRightDragBackground !== \"moveCamera\") {\n      if (event.ctrlKey) {\n        // ctrl键按下时,不允许移动视野\n        return;\n      }\n      this.moveCameraByMouseMove(event.clientX, event.clientY, 1);\n      this.project.controller.setCursorNameHook(CursorNameEnum.Grabbing);\n    }\n    // 侧键按下拖动视野\n    if (this.project.controller.isMouseDown[4]) {\n      this.moveCameraByMouseMove(event.clientX, event.clientY, 4);\n      this.project.controller.setCursorNameHook(CursorNameEnum.Grabbing);\n    }\n    if (Settings.mouseRightDragBackground === \"moveCamera\" && this.project.controller.isMouseDown[2]) {\n      // 还要保证这个鼠标位置没有悬浮在什么东西上\n      const mouseLocation = new Vector(event.clientX, event.clientY);\n      const worldLocation = this.project.renderer.transformView2World(mouseLocation);\n      const entity = this.project.stageManager.findEntityByLocation(worldLocation);\n      if (this.project.controller.nodeConnection.isUsing) {\n        return;\n      }\n      if (entity !== null) {\n        return;\n      }\n      this.moveCameraByMouseMove(event.clientX, event.clientY, 2);\n      this.project.controller.setCursorNameHook(CursorNameEnum.Grabbing);\n    }\n  };\n\n  public mouseMoveOutWindowForcedShutdown(vectorObject: Vector) {\n    super.mouseMoveOutWindowForcedShutdown(vectorObject);\n    this.isUsingMouseGrabMove = false;\n    this.project.controller.setCursorNameHook(CursorNameEnum.Default);\n  }\n\n  /**\n   * 处理鼠标松开事件\n   * @param event - 鼠标事件\n   */\n  public mouseup = (event: MouseEvent) => {\n    if (this.project.controller.isCameraLocked) {\n      return;\n    }\n    if (event.button === 4) {\n      // this.project.camera.currentScale = this.recordCameraScale;\n      // this.project.camera.currentScale = this.recordCameraScale;\n      // // this.project.camera.location = this.recordCameraLocation.clone();\n    }\n    if (!this.isUsingMouseGrabMove) {\n      return;\n    }\n    if (event.button === 0 && this.project.controller.pressingKeySet.has(\" \")) {\n      if (this.isPreGrabbingWhenSpace) {\n        this.project.controller.setCursorNameHook(CursorNameEnum.Grab);\n      }\n    }\n    if (event.button === 1) {\n      // 中键松开\n      this.project.controller.setCursorNameHook(CursorNameEnum.Default);\n    }\n    if (event.button === 4) {\n      this.project.controller.setCursorNameHook(CursorNameEnum.Default);\n    }\n    if (event.button === 2) {\n      this.project.controller.setCursorNameHook(CursorNameEnum.Default);\n    }\n    this.isUsingMouseGrabMove = false;\n  };\n\n  /**\n   * 处理鼠标滚轮事件\n   * @param event - 滚轮事件\n   */\n  public mousewheel = (event: WheelEvent) => {\n    if (this.dealStealthMode(event)) {\n      return;\n    }\n    if (this.project.controller.isCameraLocked) {\n      return;\n    }\n    // 涂鸦模式下的量角器，禁止滚动\n    if (Settings.mouseLeftMode === \"draw\" && this.project.controller.pressingKeySet.has(\"shift\")) {\n      return;\n    }\n    // 禁用触控板在这里的滚动\n    const isUsingTouchPad = !this.isMouseWheel(event);\n    if (!Settings.enableWindowsTouchPad) {\n      if (isUsingTouchPad) {\n        // 禁止使用触摸板\n        // this.project.effects.addEffect(\n        //   TextRiseEffect.default(`已禁用触控板滚动，（${event.deltaX}, ${event.deltaY}）`),\n        // );\n        return;\n      }\n    }\n    // 👇下面都是允许使用触控板的操作\n    if (isUsingTouchPad) {\n      // 是触控板\n      // zoomCameraByTouchPadTwoFingerMove(event);\n      this.moveCameraByTouchPadTwoFingerMove(event);\n      return;\n    }\n    if (isMac) {\n      // 检测一下是否是双指缩放\n      if (this.mac.isTouchPadTwoFingerScale(event)) {\n        // 双指缩放\n        this.mac.handleTwoFingerScale(event);\n        return;\n      }\n    }\n\n    this.mousewheelFunction(event);\n  };\n\n  private dealStealthMode(event: WheelEvent) {\n    if (Settings.isStealthModeEnabled && this.project.controller.pressingKeySet.has(\"shift\")) {\n      console.log(event);\n      let delta = 0;\n\n      if (isMac) {\n        delta = event.deltaX > 0 ? -10 : 10;\n      } else {\n        delta = event.deltaY > 0 ? -10 : 10;\n      }\n      const newRadius = Math.max(10, Math.min(500, Settings.stealthModeScopeRadius + delta));\n      Settings.stealthModeScopeRadius = newRadius;\n      this.project.effects.addEffect(MouseTipFeedbackEffect.default(delta > 0 ? \"expand\" : \"shrink\"));\n      return true;\n    }\n    return false;\n  }\n\n  /**\n   * 在上游代码已经确认是鼠标滚轮事件，这里进行处理\n   * @param event\n   * @returns\n   */\n  private mousewheelFunction(event: WheelEvent) {\n    // 获取触发滚轮的鼠标位置\n    const mouseLocation = new Vector(event.clientX, event.clientY);\n    // 计算鼠标位置在视野中的位置\n    const worldLocation = this.project.renderer.transformView2World(mouseLocation);\n    this.project.camera.targetLocationByScale = worldLocation;\n\n    if (this.project.controller.pressingKeySet.has(\"shift\")) {\n      if (Settings.mouseWheelWithShiftMode === \"zoom\") {\n        this.zoomCameraByMouseWheel(event);\n      } else if (Settings.mouseWheelWithShiftMode === \"move\") {\n        this.moveYCameraByMouseWheel(event);\n      } else if (Settings.mouseWheelWithShiftMode === \"moveX\") {\n        this.moveXCameraByMouseWheel(event);\n      } else if (Settings.mouseWheelWithShiftMode === \"none\") {\n        return;\n      }\n    } else if (\n      this.project.controller.pressingKeySet.has(\"control\") ||\n      this.project.controller.pressingKeySet.has(\"meta\")\n    ) {\n      // 不要在节点上滚动\n      if (Settings.mouseWheelWithCtrlMode === \"zoom\") {\n        this.zoomCameraByMouseWheel(event);\n      } else if (Settings.mouseWheelWithCtrlMode === \"move\") {\n        this.moveYCameraByMouseWheel(event);\n      } else if (Settings.mouseWheelWithCtrlMode === \"moveX\") {\n        this.moveXCameraByMouseWheel(event);\n      } else if (Settings.mouseWheelWithCtrlMode === \"none\") {\n        return;\n      }\n    } else if (this.project.controller.pressingKeySet.has(\"alt\")) {\n      if (Settings.mouseWheelWithAltMode === \"zoom\") {\n        this.zoomCameraByMouseWheel(event);\n      } else if (Settings.mouseWheelWithAltMode === \"move\") {\n        this.moveYCameraByMouseWheel(event);\n      } else if (Settings.mouseWheelWithAltMode === \"moveX\") {\n        this.moveXCameraByMouseWheel(event);\n      } else if (Settings.mouseWheelWithAltMode === \"none\") {\n        return;\n      }\n    } else {\n      if (Settings.mouseWheelMode === \"zoom\") {\n        this.zoomCameraByMouseWheel(event);\n      } else if (Settings.mouseWheelMode === \"move\") {\n        this.moveYCameraByMouseWheel(event);\n      } else if (Settings.mouseWheelMode === \"moveX\") {\n        this.moveXCameraByMouseWheel(event);\n      } else if (Settings.mouseWheelMode === \"none\") {\n        return;\n      }\n    }\n\n    // 滚轮横向滚动是水平移动\n    this.moveCameraByMouseSideWheel(event);\n  }\n\n  /**\n   * 处理鼠标双击事件\n   * @param event - 鼠标事件\n   */\n  public mouseDoubleClick: (event: MouseEvent) => void = (event: MouseEvent) => {\n    if (Settings.doubleClickMiddleMouseButton === \"none\") {\n      return;\n    }\n    if (event.button === 1 && !this.project.controller.isCameraLocked) {\n      if (event.ctrlKey) {\n        return;\n      }\n\n      // 中键双击\n      const pressLocation = this.project.renderer.transformView2World(new Vector(event.clientX, event.clientY));\n      const clickedEntity = this.project.stageManager.findEntityByLocation(pressLocation);\n      if (clickedEntity !== null) {\n        // 双击实体可以直接跳转\n        openBrowserOrFileByEntity(clickedEntity, this.project);\n      } else {\n        this.project.camera.resetBySelected();\n      }\n    }\n  };\n\n  /**\n   * 根据鼠标移动位置移动摄像机\n   * @param x - 鼠标在X轴的坐标\n   * @param y - 鼠标在Y轴的坐标\n   * @param mouseIndex - 鼠标按钮索引\n   */\n  private moveCameraByMouseMove(x: number, y: number, mouseIndex: number) {\n    const currentMouseMoveLocation = this.project.renderer.transformView2World(new Vector(x, y));\n    const diffLocation = currentMouseMoveLocation.subtract(this.lastMousePressLocation[mouseIndex]);\n    this.project.effects.addEffect(MouseTipFeedbackEffect.default(\"drag\"));\n    this.project.camera.location = this.project.camera.location.subtract(diffLocation);\n  }\n\n  private moveCameraByTouchPadTwoFingerMove(event: WheelEvent) {\n    if (isMac) {\n      this.mac.moveCameraByTouchPadTwoFingerMove(event);\n      return;\n    }\n    // 过滤 -0\n    if (Math.abs(event.deltaX) < 0.01 && Math.abs(event.deltaY) < 0.01) {\n      return;\n    }\n    const dx = event.deltaX / 500;\n    const dy = event.deltaY / 500;\n    const diffLocation = new Vector(dx, dy).multiply((Settings.moveAmplitude * 50) / this.project.camera.currentScale);\n    this.project.effects.addEffect(MouseTipFeedbackEffect.directionObject(diffLocation));\n    this.project.camera.location = this.project.camera.location.add(diffLocation);\n  }\n\n  private zoomCameraByMouseWheel(event: WheelEvent) {\n    if (isMac) {\n      // mac电脑滚动一格滚轮会触发很多次事件。这个列表里是每个事件的deltaY\n      // [7, 7, 7, 7, 6, 7, 7, 6, 5, 5, 4, 4, 3, 3, 3, 2, 2, 1, 1, 1, 1, 1]\n      if (Settings.macMouseWheelIsSmoothed) {\n        // 盲猜是开了平滑滚动了\n        const deltaY = event.deltaY;\n        this.project.camera.targetScale *= 1 + deltaY / 500;\n      } else {\n        // 如果没有开平滑滚动\n        if (event.deltaY > 0) {\n          this.project.camera.targetScale *= 0.8;\n          this.project.effects.addEffect(MouseTipFeedbackEffect.default(\"shrink\"));\n        } else if (event.deltaY < 0) {\n          this.project.camera.targetScale *= 1.2;\n          this.project.effects.addEffect(MouseTipFeedbackEffect.default(\"expand\"));\n        }\n      }\n    } else {\n      if (event.deltaY > 0) {\n        this.project.camera.targetScale *= 0.8;\n        this.project.effects.addEffect(MouseTipFeedbackEffect.default(\"shrink\"));\n      } else if (event.deltaY < 0) {\n        this.project.camera.targetScale *= 1.2;\n        this.project.effects.addEffect(MouseTipFeedbackEffect.default(\"expand\"));\n      }\n    }\n  }\n\n  private moveYCameraByMouseWheel(event: WheelEvent) {\n    this.project.camera.bombMove(\n      this.project.camera.location.add(\n        new Vector(0, (Settings.moveAmplitude * event.deltaY * 0.5) / this.project.camera.currentScale),\n      ),\n    );\n    if (event.deltaY > 0) {\n      this.project.effects.addEffect(MouseTipFeedbackEffect.default(\"moveDown\"));\n    } else if (event.deltaY < 0) {\n      this.project.effects.addEffect(MouseTipFeedbackEffect.default(\"moveUp\"));\n    }\n  }\n\n  private moveCameraByMouseSideWheel(event: WheelEvent) {\n    if (event.deltaX === 0) {\n      return;\n    }\n    if (Settings.mouseSideWheelMode === \"zoom\") {\n      this.zoomCameraByMouseSideWheel(event);\n    } else if (Settings.mouseSideWheelMode === \"move\") {\n      this.moveYCameraByMouseSideWheel(event);\n    } else if (Settings.mouseSideWheelMode === \"moveX\") {\n      this.moveXCameraByMouseSideWheel(event);\n    } else if (Settings.mouseSideWheelMode === \"none\") {\n      return;\n    } else if (Settings.mouseSideWheelMode === \"cameraMoveToMouse\") {\n      // 先测试性的加一个，将准星向鼠标位置移动\n      const mouseLocation = new Vector(event.clientX, event.clientY);\n      const mouseWorldLocation = this.project.renderer.transformView2World(mouseLocation);\n      let diffLocation = mouseWorldLocation.subtract(this.project.camera.location).multiply(0.75);\n      if (event.deltaX < 0) {\n        diffLocation = diffLocation.multiply(-1);\n        this.project.effects.addEffect(MouseTipFeedbackEffect.default(\"cameraBackToMouse\"));\n      } else {\n        // 正常\n        this.project.effects.addEffect(MouseTipFeedbackEffect.default(\"cameraMoveToMouse\"));\n      }\n      const moveToLocation = this.project.camera.location.add(diffLocation);\n      this.project.camera.bombMove(moveToLocation);\n    } else if (Settings.mouseSideWheelMode === \"adjustWindowOpacity\") {\n      const currentValue = Settings.windowBackgroundAlpha;\n      if (event.deltaX < 0) {\n        Settings.windowBackgroundAlpha = Math.min(1, currentValue + 0.1);\n      } else {\n        Settings.windowBackgroundAlpha = Math.max(0, currentValue - 0.1);\n      }\n    } else if (Settings.mouseSideWheelMode === \"adjustPenStrokeWidth\") {\n      if (Settings.mouseLeftMode !== \"draw\") {\n        return;\n      }\n      // TODO: 调整笔画粗细\n      // if (event.deltaX < 0) {\n      //   const newWidth = this.project.controller.penStrokeDrawing.currentStrokeWidth + 1;\n      //   this.project.controller.penStrokeDrawing.currentStrokeWidth = Math.max(1, Math.min(newWidth, 1000));\n      //   toast(`画笔粗细: ${this.project.controller.penStrokeDrawing.currentStrokeWidth}px`);\n      // } else {\n      //   const newWidth = this.project.controller.penStrokeDrawing.currentStrokeWidth - 1;\n      //   this.project.controller.penStrokeDrawing.currentStrokeWidth = Math.max(1, Math.min(newWidth, 1000));\n      //   toast(`画笔粗细: ${this.project.controller.penStrokeDrawing.currentStrokeWidth}px`);\n      // }\n    }\n  }\n\n  private zoomCameraByMouseSideWheel(event: WheelEvent) {\n    if (event.deltaX > 0) {\n      this.project.camera.targetScale *= 0.8;\n      this.project.effects.addEffect(MouseTipFeedbackEffect.default(\"shrink\"));\n    } else if (event.deltaX < 0) {\n      this.project.camera.targetScale *= 1.2;\n      this.project.effects.addEffect(MouseTipFeedbackEffect.default(\"expand\"));\n    }\n  }\n\n  private moveYCameraByMouseSideWheel(event: WheelEvent) {\n    this.project.camera.location = this.project.camera.location.add(\n      new Vector(0, (Settings.moveAmplitude * event.deltaX * 0.5) / this.project.camera.currentScale),\n    );\n    if (event.deltaX > 0) {\n      this.project.effects.addEffect(MouseTipFeedbackEffect.default(\"moveDown\"));\n    } else if (event.deltaX < 0) {\n      this.project.effects.addEffect(MouseTipFeedbackEffect.default(\"moveUp\"));\n    }\n  }\n\n  private moveXCameraByMouseWheel(event: WheelEvent) {\n    this.project.camera.bombMove(\n      this.project.camera.location.add(\n        new Vector((Settings.moveAmplitude * event.deltaY * 0.5) / this.project.camera.currentScale, 0),\n      ),\n    );\n    if (event.deltaY > 0) {\n      this.project.effects.addEffect(MouseTipFeedbackEffect.default(\"moveRight\"));\n    } else if (event.deltaY < 0) {\n      this.project.effects.addEffect(MouseTipFeedbackEffect.default(\"moveLeft\"));\n    }\n  }\n\n  private moveXCameraByMouseSideWheel(event: WheelEvent) {\n    this.project.camera.bombMove(\n      this.project.camera.location.add(\n        new Vector((Settings.moveAmplitude * event.deltaX * 0.5) / this.project.camera.currentScale, 0),\n      ),\n    );\n    if (event.deltaX > 0) {\n      this.project.effects.addEffect(MouseTipFeedbackEffect.default(\"moveRight\"));\n    } else if (event.deltaX < 0) {\n      this.project.effects.addEffect(MouseTipFeedbackEffect.default(\"moveLeft\"));\n    }\n  }\n\n  /**\n   *\n   * 区分滚轮和触摸板的核心函数\n   * 返回true：是鼠标滚轮事件\n   * 返回false：是触摸板事件\n   * @param event\n   * @returns\n   */\n  private isMouseWheel(event: WheelEvent): boolean {\n    if (isIpad || isMac) {\n      return this.mac.isMouseWheel(event);\n    }\n\n    // 不是mac系统 ======\n\n    if (event.deltaX !== 0 && event.deltaY !== 0) {\n      // 斜向滚动肯定不是鼠标滚轮。因为滚轮只有横向滚轮和竖向滚轮\n      return false;\n    }\n    if (event.deltaX === 0 && event.deltaY === 0) {\n      // 无意义的滚动事件\n      return false;\n    }\n\n    // 纯竖向滚动\n    if (event.deltaX === 0 && event.deltaY !== 0) {\n      const distance = Math.abs(event.deltaY);\n      if (distance < 20) {\n        // 缓慢滚动是触摸板\n        return false;\n      }\n      if (this.addDistanceNumberAndDetect(distance)) {\n        return true;\n      }\n    }\n\n    // 纯横向滚动\n    if (event.deltaX !== 0 && event.deltaY === 0) {\n      const distance = Math.abs(event.deltaX);\n      if (distance < 20) {\n        // 缓慢滚动是触摸板\n        return false;\n      }\n      if (this.addDistanceNumberAndDetect(distance)) {\n        return true;\n      }\n    }\n    return false;\n  }\n\n  private addDistanceNumberAndDetect(distance: number): boolean {\n    // 开始序列化检测\n    this.detectDeltaY.enqueue(distance);\n    const multiArray = this.detectDeltaY.multiGetTail(4);\n    if (multiArray.length >= 4) {\n      if (ArrayFunctions.isSame(multiArray)) {\n        // 检测到关键数字\n        this.importantNumbers.add(distance);\n        // 连续4个都一样，说明是滚轮\n        // 实测发现连续三个都一样，用滚轮极小概率触发。四个都一样几乎不太可能了\n        return true;\n      }\n    } else {\n      // 长度还不足 说明刚打开软件，可能拨动了两下滚轮，也可能滑动了一下触摸板\n      // 先按滚轮算\n      return true;\n    }\n\n    // 是整数倍\n    for (const importNumber of this.importantNumbers) {\n      if (distance % importNumber === 0) {\n        return true;\n      }\n    }\n    return false;\n  }\n\n  private detectDeltaY: LimitLengthQueue<number> = new LimitLengthQueue<number>(100);\n  private importantNumbers: Set<number> = new Set<number>([]); // 100, 133, 138, 166\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/controller/concrete/ControllerContextMenu.tsx",
    "content": "import { Vector } from \"@graphif/data-structures\";\nimport { ControllerClass } from \"../ControllerClass\";\nimport { Settings } from \"@/core/service/Settings\";\n\nexport class ControllerContextMenuClass extends ControllerClass {\n  /** view */\n  private mouseDownLocation: Vector = new Vector(0, 0);\n\n  public mousedown = (event: MouseEvent) => {\n    if (event.button !== 2) {\n      return; // Only handle right-clicks\n    }\n\n    this.mouseDownLocation = new Vector(event.clientX, event.clientY);\n  };\n  public mouseup = (event: MouseEvent) => {\n    if (event.button !== 2) {\n      return; // Only handle right-clicks\n    }\n\n    const mouseUpLocation = new Vector(event.clientX, event.clientY);\n    const distance = this.mouseDownLocation.distance(mouseUpLocation);\n\n    // 检查是否启用了右键点击连线功能并且有实体被选中\n    const hasSelectedConnectableEntities = this.project.stageManager\n      .getConnectableEntity()\n      .some((entity) => entity.isSelected);\n\n    // 转换鼠标位置到世界坐标系\n    const worldLocation = this.project.renderer.transformView2World(mouseUpLocation);\n\n    // 检查点击位置是否在可连接对象上\n    const clickedConnectableEntity = this.project.stageManager.findConnectableEntityByLocation(worldLocation);\n\n    // 如果启用了右键点击连线功能、有实体被选中，并且点击位置在可连接对象上，并且点击的对象未选中，则触发连接，不触发右键菜单\n    if (Settings.enableRightClickConnect && hasSelectedConnectableEntities && clickedConnectableEntity !== null) {\n      if (!clickedConnectableEntity.isSelected) {\n        return;\n      }\n    }\n\n    if (distance < 5) {\n      this.project.emit(\"contextmenu\", mouseUpLocation);\n    }\n  };\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/controller/concrete/ControllerCutting.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { ControllerClass } from \"@/core/service/controlService/controller/ControllerClass\";\nimport { MouseLocation } from \"@/core/service/controlService/MouseLocation\";\nimport { CircleFlameEffect } from \"@/core/service/feedbackService/effectEngine/concrete/CircleFlameEffect\";\nimport { LineCuttingEffect } from \"@/core/service/feedbackService/effectEngine/concrete/LineCuttingEffect\";\nimport { PenStrokeDeletedEffect } from \"@/core/service/feedbackService/effectEngine/concrete/PenStrokeDeletedEffect\";\nimport { RectangleSplitTwoPartEffect } from \"@/core/service/feedbackService/effectEngine/concrete/RectangleSplitTwoPartEffect\";\nimport { SoundService } from \"@/core/service/feedbackService/SoundService\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { Association } from \"@/core/stage/stageObject/abstract/Association\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { Edge } from \"@/core/stage/stageObject/association/Edge\";\n// import { LineEdge } from \"@/core/stage/stageObject/association/LineEdge\";\nimport { ConnectPoint } from \"@/core/stage/stageObject/entity/ConnectPoint\";\nimport { ImageNode } from \"@/core/stage/stageObject/entity/ImageNode\";\nimport { PenStroke } from \"@/core/stage/stageObject/entity/PenStroke\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { CursorNameEnum } from \"@/types/cursors\";\nimport { isMac } from \"@/utils/platform\";\nimport { Color, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Line } from \"@graphif/shapes\";\n\nexport class ControllerCuttingClass extends ControllerClass {\n  private _controlKeyEventRegistered = false;\n  private _isControlKeyDown = false;\n  // mac 特性功能\n  private onControlKeyDown = (event: KeyboardEvent) => {\n    if (isMac && event.key === \"Control\" && Settings.macEnableControlToCut && !this._isControlKeyDown) {\n      this._isControlKeyDown = true;\n      this.project.controller.isMouseDown[2] = true;\n      // 模拟鼠标按下事件\n      const fakeMouseEvent = new MouseEvent(\"mousedown\", {\n        button: 2,\n        clientX: MouseLocation.vector().x,\n        clientY: MouseLocation.vector().y,\n      });\n      this.mousedown(fakeMouseEvent);\n    }\n  };\n\n  // mac 特性功能\n  private onControlKeyUp = (event: KeyboardEvent) => {\n    if (isMac && event.key === \"Control\" && Settings.macEnableControlToCut && this._isControlKeyDown) {\n      this._isControlKeyDown = false;\n      this.project.controller.isMouseDown[2] = false;\n      // 模拟鼠标松开事件\n      const fakeMouseEvent = new MouseEvent(\"mouseup\", {\n        button: 2,\n        clientX: MouseLocation.vector().x,\n        clientY: MouseLocation.vector().y,\n      });\n      this.mouseup(fakeMouseEvent);\n    }\n  };\n\n  private registerControlKeyEvents() {\n    if (!this._controlKeyEventRegistered) {\n      window.addEventListener(\"keydown\", this.onControlKeyDown);\n      window.addEventListener(\"keyup\", this.onControlKeyUp);\n      this._controlKeyEventRegistered = true;\n    }\n  }\n\n  private unregisterControlKeyEvents() {\n    if (this._controlKeyEventRegistered) {\n      window.removeEventListener(\"keydown\", this.onControlKeyDown);\n      window.removeEventListener(\"keyup\", this.onControlKeyUp);\n      this._controlKeyEventRegistered = false;\n    }\n  }\n  constructor(protected readonly project: Project) {\n    super(project);\n    this.registerControlKeyEvents();\n  }\n\n  dispose() {\n    super.dispose();\n    this.unregisterControlKeyEvents();\n  }\n\n  public cuttingLine: Line = new Line(Vector.getZero(), Vector.getZero());\n  public lastMoveLocation = Vector.getZero();\n  public warningEntity: Entity[] = [];\n  public warningSections: Section[] = [];\n  public warningAssociations: Association[] = [];\n  // 是否正在使用\n  public isUsing = false;\n\n  /**\n   * 切割时与实体相交的两点\n   */\n  private twoPointsMap: Record<string, Vector[]> = {};\n\n  /**\n   * 开始绘制斩断线的起点位置\n   */\n  private cuttingStartLocation = Vector.getZero();\n\n  public mousedown: (event: MouseEvent) => void = (event: MouseEvent) => {\n    if (!(event.button == 2 || event.button == 0 || event.button == 1)) {\n      return;\n    }\n\n    // 左键按下的\n    if (event.button === 0 && Settings.mouseLeftMode === \"connectAndCut\") {\n      this.mouseDownEvent(event);\n      return;\n    }\n    // 右键按下的\n    if (event.button === 2 && Settings.mouseRightDragBackground === \"cut\") {\n      this.mouseDownEvent(event);\n      return;\n    }\n    // 中键按下的\n    if (event.button === 1 && Settings.mouseRightDragBackground === \"moveCamera\") {\n      this.mouseDownEvent(event);\n      return;\n    }\n  };\n\n  private mouseDownEvent(event: MouseEvent) {\n    const pressWorldLocation = this.project.renderer.transformView2World(new Vector(event.clientX, event.clientY));\n    this.lastMoveLocation = pressWorldLocation.clone();\n\n    const isClickedEntity = this.project.stageManager.isEntityOnLocation(pressWorldLocation);\n    const isClickedAssociation = this.project.stageManager.isAssociationOnLocation(pressWorldLocation);\n\n    if (!isClickedEntity && !isClickedAssociation) {\n      // 开始绘制切断线\n      this.isUsing = true;\n      this.cuttingStartLocation = pressWorldLocation.clone();\n      this.cuttingLine = new Line(this.cuttingStartLocation, this.cuttingStartLocation.clone());\n      // 添加音效提示\n      SoundService.play.cuttingLineStart();\n      // 鼠标提示\n      this.project.controller.setCursorNameHook(CursorNameEnum.Crosshair);\n    } else {\n      this.isUsing = false;\n    }\n  }\n\n  public mousemove: (event: MouseEvent) => void = (event: MouseEvent) => {\n    if (!this.isUsing) {\n      return;\n    }\n    if (this.project.controller.penStrokeControl.isAdjusting) {\n      this.isUsing = false;\n      return;\n    }\n    // 正在切断线\n    this.cuttingLine = new Line(this.cuttingStartLocation, this.lastMoveLocation);\n\n    this.updateWarningObjectByCuttingLine();\n    this.lastMoveLocation = this.project.renderer.transformView2World(\n      new Vector(event.clientX, event.clientY), // 鼠标位置\n    );\n    // 渲染器需要\n    this.project.controller.lastMoveLocation = this.lastMoveLocation.clone();\n  };\n\n  // 删除孤立质点\n  private clearIsolationPoint() {\n    // 待检测的质点集\n    const connectedPoints: ConnectPoint[] = [];\n    for (const edge of this.warningAssociations) {\n      if (edge instanceof Edge) {\n        if (edge.source instanceof ConnectPoint) {\n          connectedPoints.push(edge.source);\n        }\n        if (edge.target instanceof ConnectPoint) {\n          connectedPoints.push(edge.target);\n        }\n      }\n    }\n    // 检测所有待检测的质点是否是孤立状态\n    const prepareDeleteConnectPoints: ConnectPoint[] = [];\n    for (const point of connectedPoints) {\n      const childCount = this.project.graphMethods.nodeChildrenArray(point).length;\n      const parentCount = this.project.graphMethods.nodeParentArray(point).length;\n      if (childCount === 0 && parentCount === 0) {\n        prepareDeleteConnectPoints.push(point);\n      }\n    }\n    // 开始删除孤立质点\n    this.project.stageManager.deleteEntities(prepareDeleteConnectPoints);\n  }\n\n  public mouseUpFunction(mouseUpWindowLocation: Vector) {\n    this.isUsing = false;\n    // 最后再更新一下鼠标位置\n    this.lastMoveLocation = this.project.renderer.transformView2World(\n      mouseUpWindowLocation, // 鼠标位置\n    );\n    this.updateWarningObjectByCuttingLine();\n    // 鼠标提示解除\n    this.project.controller.setCursorNameHook(CursorNameEnum.Default);\n    // 删除连线\n    for (const edge of this.warningAssociations) {\n      this.project.stageManager.deleteAssociation(edge);\n\n      // 2.0 先暂时关闭这个动画，它出bug了，会闪出一条很长的跨越屏幕的直线\n\n      // if (edge instanceof Edge) {\n      //   if (edge instanceof LineEdge) {\n      //     const effects = this.project.edgeRenderer.getCuttingEffects(edge);\n      //     console.log(effects);\n      //     this.project.effects.addEffects(effects);\n      //   }\n      // }\n    }\n    // 删除实体 （消耗性功能）\n    this.project.stageManager.deleteEntities(this.warningEntity);\n    // 删除产生的孤立质点（消耗性能）\n    this.clearIsolationPoint();\n    // 特效\n    this.addEffectByWarningEntity();\n\n    if (this.warningEntity.length !== 0 || this.warningAssociations.length !== 0) {\n      this.project.stageManager.updateReferences();\n    }\n\n    this.warningEntity = [];\n    this.warningSections = [];\n\n    this.warningAssociations = [];\n\n    this.project.effects.addEffect(\n      new LineCuttingEffect(\n        new ProgressNumber(0, 15),\n        this.cuttingStartLocation,\n        this.lastMoveLocation,\n        this.project.stageStyleManager.currentStyle.effects.warningShadow,\n        this.project.stageStyleManager.currentStyle.effects.warningShadow,\n        this.cuttingStartLocation.distance(this.lastMoveLocation) / 10,\n      ),\n    );\n\n    // 声音提示\n    SoundService.play.cuttingLineRelease();\n  }\n\n  public mouseup: (event: MouseEvent) => void = (event: MouseEvent) => {\n    if (!(event.button == 2 || event.button == 0 || event.button == 1)) {\n      return;\n    }\n    if (!this.isUsing) {\n      return;\n    }\n    this.mouseUpFunction(new Vector(event.clientX, event.clientY));\n  };\n\n  public mouseMoveOutWindowForcedShutdown(outsideLocation: Vector) {\n    super.mouseMoveOutWindowForcedShutdown(outsideLocation);\n    this.project.controller.cutting.mouseUpFunction(outsideLocation);\n  }\n\n  // private clearWarningObject() {\n  //   this.warningEntity = [];\n  //   this.warningSections = [];\n  //   this.warningEdges = [];\n  // }\n\n  /**\n   * 更新斩断线经过的所有鼠标对象\n   *\n   * 目前的更新是直接清除所有然后再重新遍历所有对象，后续可以优化\n   * 此函数会在鼠标移动被频繁调用，所以需要优化\n   */\n  private updateWarningObjectByCuttingLine() {\n    this.warningEntity = [];\n\n    this.twoPointsMap = {};\n\n    for (const entity of this.project.stageManager.getEntities()) {\n      // if (entity instanceof Section) {\n      //   continue; // Section的碰撞箱比较特殊\n      // }\n      if (entity.isHiddenBySectionCollapse) {\n        continue; // 隐藏的节点不参与碰撞检测\n      }\n      // 检查实体是否是背景图片\n      if (entity instanceof ImageNode && entity.isBackground) {\n        continue; // 背景图片不参与碰撞检测\n      }\n\n      // 检查实体是否在锁定的 section 内或本身是锁定的 section\n      if (this.project.sectionMethods.isObjectBeLockedBySection(entity)) {\n        continue; // 锁定的 section 内的节点不参与碰撞检测\n      }\n      if (entity.collisionBox.isIntersectsWithLine(this.cuttingLine)) {\n        this.warningEntity.push(entity);\n      }\n\n      // 特效\n      const collidePoints = entity.collisionBox.getRectangle().getCollidePointsWithLine(this.cuttingLine);\n\n      if (collidePoints.length === 2) {\n        this.twoPointsMap[entity.uuid] = collidePoints;\n      }\n\n      // 增加两点特效\n      for (const collidePoint of collidePoints) {\n        this.project.effects.addEffect(\n          new CircleFlameEffect(new ProgressNumber(0, 5), collidePoint, 10, new Color(255, 255, 255, 1)),\n        );\n      }\n    }\n    this.warningSections = [];\n    for (const section of this.project.stageManager.getSections()) {\n      if (section.isHiddenBySectionCollapse) {\n        continue; // 隐藏的节点不参与碰撞检测\n      }\n      if (section.collisionBox.isIntersectsWithLine(this.cuttingLine)) {\n        this.warningSections.push(section);\n      }\n    }\n\n    this.warningAssociations = [];\n    for (const edge of this.project.stageManager.getAssociations()) {\n      if (edge instanceof Edge && edge.isHiddenBySectionCollapse) {\n        continue; // 连线被隐藏了\n      }\n\n      // 检查连线是否连接了锁定的 section 内的物体\n      if (this.project.sectionMethods.isObjectBeLockedBySection(edge)) {\n        continue; // 连接了锁定 section 内物体的连线不参与碰撞检测\n      }\n\n      if (edge.collisionBox.isIntersectsWithLine(this.cuttingLine)) {\n        this.warningAssociations.push(edge);\n      }\n    }\n  }\n\n  /**\n   * 用于在释放的时候添加特效\n   */\n  private addEffectByWarningEntity() {\n    // 裂开特效\n    for (const entity of this.warningEntity) {\n      const collidePoints = this.twoPointsMap[entity.uuid];\n      if (collidePoints) {\n        let fillColor = Color.Transparent;\n        if (entity instanceof TextNode) {\n          fillColor = entity.color.clone();\n        } else if (entity instanceof Section) {\n          fillColor = entity.color.clone();\n        }\n\n        if (entity instanceof PenStroke) {\n          this.project.effects.addEffect(PenStrokeDeletedEffect.fromPenStroke(entity));\n        } else {\n          this.project.effects.addEffect(\n            new RectangleSplitTwoPartEffect(\n              entity.collisionBox.getRectangle(),\n              collidePoints,\n              50,\n              fillColor,\n              this.project.stageStyleManager.currentStyle.StageObjectBorder,\n              2,\n            ),\n          );\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/controller/concrete/ControllerEdgeEdit.tsx",
    "content": "import { Dialog } from \"@/components/ui/dialog\";\nimport { ControllerClass } from \"@/core/service/controlService/controller/ControllerClass\";\n\n/**\n * 包含编辑节点文字，编辑详细信息等功能的控制器\n *\n * 当有节点编辑时，会把摄像机锁定住\n */\nexport class ControllerEdgeEditClass extends ControllerClass {\n  mouseDoubleClick = (event: MouseEvent) => {\n    if (event.button !== 0) {\n      return;\n    }\n    if (this.project.controller.camera.isPreGrabbingWhenSpace) {\n      return;\n    }\n    const firstHoverEdge = this.project.mouseInteraction.firstHoverEdge;\n    const firstHoverMultiTargetEdge = this.project.mouseInteraction.firstHoverMultiTargetEdge;\n    if (!(firstHoverEdge || firstHoverMultiTargetEdge)) {\n      return;\n    }\n    if (firstHoverEdge) {\n      // 编辑边上的文字\n      this.project.controllerUtils.editEdgeText(firstHoverEdge);\n    }\n    if (firstHoverMultiTargetEdge) {\n      this.project.controllerUtils.editMultiTargetEdgeText(firstHoverMultiTargetEdge);\n    }\n\n    return;\n  };\n\n  keydown = (event: KeyboardEvent) => {\n    if (event.key === \"Enter\") {\n      const selectedEdges = this.project.stageManager.getLineEdges().filter((edge) => edge.isSelected);\n      if (selectedEdges.length === 1) {\n        setTimeout(() => {\n          this.project.controllerUtils.editEdgeText(selectedEdges[0]);\n        }); // delay 默认 1ms，防止多输入一个回车\n      } else if (selectedEdges.length === 0) {\n        return;\n      } else {\n        Dialog.input(\"编辑所有选中的边\").then((result) => {\n          if (!result) return;\n          selectedEdges.forEach((edge) => {\n            edge.rename(result);\n          });\n        });\n      }\n    }\n  };\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/controller/concrete/ControllerEntityClickSelectAndMove.tsx",
    "content": "import { ControllerClass } from \"@/core/service/controlService/controller/ControllerClass\";\nimport { RectangleNoteEffect } from \"@/core/service/feedbackService/effectEngine/concrete/RectangleNoteEffect\";\nimport { RectangleRenderEffect } from \"@/core/service/feedbackService/effectEngine/concrete/RectangleRenderEffect\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { isMac } from \"@/utils/platform\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\n\n/**\n * 拖拽节点使其移动的控制器\n *\n */\nexport class ControllerEntityClickSelectAndMoveClass extends ControllerClass {\n  private isMovingEntity = false;\n  private mouseDownViewLocation = Vector.getZero();\n\n  public mousedown: (event: MouseEvent) => void = (event: MouseEvent) => {\n    if (event.button !== 0) {\n      return;\n    }\n    if (Settings.mouseLeftMode !== \"selectAndMove\") {\n      return;\n    }\n    if (this.project.controller.camera.isPreGrabbingWhenSpace) {\n      return;\n    }\n    this.mouseDownViewLocation = new Vector(event.clientX, event.clientY);\n\n    const pressWorldLocation = this.project.renderer.transformView2World(this.mouseDownViewLocation);\n    this.lastMoveLocation = pressWorldLocation.clone();\n\n    // 检查是否点击了缩放控制点，如果是，就不要触发移动事件\n    if (this.project.controllerUtils.isClickedResizeRect(pressWorldLocation)) {\n      return;\n    }\n\n    const clickedStageObject = this.project.controllerUtils.getClickedStageObject(pressWorldLocation);\n\n    // 检查点击的物体是否在锁定的 section 内\n    if (clickedStageObject && clickedStageObject instanceof Entity) {\n      if (clickedStageObject instanceof Section) {\n        // 不管section本身是否锁定，只要有锁定的祖先section，就重定向到最外层锁定祖先（支持任意深度嵌套）\n        const ancestorSections = this.project.sectionMethods.getFatherSectionsList(clickedStageObject);\n        const lockedAncestors = ancestorSections.filter((s) => s.locked);\n        const outermostLockedAncestor = lockedAncestors.find(\n          (candidate) =>\n            !lockedAncestors.some(\n              (other) => other !== candidate && this.project.sectionMethods.isEntityInSection(candidate, other),\n            ),\n        );\n        if (outermostLockedAncestor) {\n          this.project.stageManager.getStageObjects().forEach((obj) => {\n            obj.isSelected = false;\n          });\n          outermostLockedAncestor.isSelected = true;\n          this.isMovingEntity = true;\n          return;\n        }\n      } else {\n        // 对于其他实体：如果有锁定的祖先section，转而选中并拖动最外层锁定section\n        if (this.project.sectionMethods.isObjectBeLockedBySection(clickedStageObject)) {\n          const ancestorSections = this.project.sectionMethods.getFatherSectionsList(clickedStageObject);\n          const lockedAncestors = ancestorSections.filter((s) => s.locked);\n          const outermostLockedSection = lockedAncestors.find(\n            (candidate) =>\n              !lockedAncestors.some(\n                (other) => other !== candidate && this.project.sectionMethods.isEntityInSection(candidate, other),\n              ),\n          );\n          if (outermostLockedSection) {\n            this.project.stageManager.getStageObjects().forEach((obj) => {\n              obj.isSelected = false;\n            });\n            outermostLockedSection.isSelected = true;\n            this.isMovingEntity = true;\n          }\n          return;\n        }\n      }\n    }\n\n    // 防止跳跃式移动的时候改变选中内容\n    if (this.project.controller.pressingKeySet.has(\"alt\")) {\n      return;\n    }\n\n    // 单击选中\n    if (clickedStageObject !== null) {\n      this.isMovingEntity = true;\n\n      if (\n        this.project.controller.pressingKeySet.has(\"shift\") &&\n        (isMac\n          ? this.project.controller.pressingKeySet.has(\"meta\")\n          : this.project.controller.pressingKeySet.has(\"control\"))\n      ) {\n        // ctrl + shift 同时按下\n        clickedStageObject.isSelected = !clickedStageObject.isSelected;\n      } else if (this.project.controller.pressingKeySet.has(\"shift\")) {\n        // shift 按下，只选中节点\n        clickedStageObject.isSelected = true;\n        // 没有实体被选中则return\n        if (this.project.stageManager.getSelectedEntities().length === 0) return;\n        const rectangles = this.project.stageManager\n          .getSelectedEntities()\n          .map((entity) => entity.collisionBox.getRectangle());\n        const boundingRectangle = Rectangle.getBoundingRectangle(rectangles);\n        this.project.effects.addEffect(RectangleRenderEffect.fromShiftClickSelect(boundingRectangle));\n        this.project.effects.addEffect(RectangleNoteEffect.fromShiftClickSelect(this.project, boundingRectangle));\n        for (const entity of this.project.stageManager.getStageObjects()) {\n          if (entity.collisionBox.isIntersectsWithRectangle(boundingRectangle)) {\n            entity.isSelected = true;\n          }\n        }\n      } else if (\n        isMac\n          ? this.project.controller.pressingKeySet.has(\"meta\")\n          : this.project.controller.pressingKeySet.has(\"control\")\n      ) {\n        // ctrl 按下，只选中节点，不能模仿windows文件管理器设置成反选，否则会和直接移动节点子树冲突\n        clickedStageObject.isSelected = true;\n      } else {\n        // 直接点击\n        if (!clickedStageObject.isSelected) {\n          // 清空所有其他节点的选中状态\n          this.project.stageManager.getStageObjects().forEach((stageObject) => {\n            if (stageObject === clickedStageObject) {\n              return;\n            }\n            stageObject.isSelected = false;\n          });\n        }\n\n        // 选中点击节点的状态\n        clickedStageObject.isSelected = true;\n      }\n    } else {\n      // 未点击到节点\n    }\n  };\n\n  public mousemove: (event: MouseEvent) => void = (event: MouseEvent) => {\n    if (\n      this.project.controller.rectangleSelect.isUsing ||\n      this.project.controller.cutting.isUsing ||\n      this.project.controller.pressingKeySet.has(\"alt\")\n    ) {\n      return;\n    }\n    if (Settings.mouseLeftMode !== \"selectAndMove\") {\n      return;\n    }\n    if (!this.isMovingEntity) {\n      return;\n    }\n    const worldLocation = this.project.renderer.transformView2World(new Vector(event.clientX, event.clientY));\n    const diffLocation = worldLocation.subtract(this.lastMoveLocation);\n\n    if (this.project.stageManager.isHaveEntitySelected()) {\n      // 移动节点\n      this.isMovingEntity = true;\n      // 暂不监听alt键。因为windows下切换窗口时，alt键释放监听不到\n      const isControlPressed = isMac\n        ? this.project.controller.pressingKeySet.has(\"meta\")\n        : this.project.controller.pressingKeySet.has(\"control\");\n\n      // 根据模式选择移动方式\n      if (Settings.reverseTreeMoveMode) {\n        // 反转模式：默认树形移动，按住Ctrl键单一移动\n        if (isControlPressed) {\n          this.project.entityMoveManager.moveSelectedEntities(diffLocation);\n        } else {\n          this.project.entityMoveManager.moveEntitiesWithChildren(diffLocation);\n        }\n      } else {\n        // 正常模式：默认单一移动，按住Ctrl键树形移动\n        if (isControlPressed) {\n          this.project.entityMoveManager.moveEntitiesWithChildren(diffLocation);\n        } else {\n          this.project.entityMoveManager.moveSelectedEntities(diffLocation);\n        }\n      }\n\n      // 预瞄反馈\n      if (Settings.enableDragAutoAlign) {\n        this.project.autoAlign.preAlignAllSelected();\n      }\n\n      this.lastMoveLocation = worldLocation.clone();\n    }\n  };\n\n  public mouseup: (event: MouseEvent) => void = (event: MouseEvent) => {\n    if (event.button !== 0) {\n      return;\n    }\n    if (Settings.mouseLeftMode !== \"selectAndMove\") {\n      return;\n    }\n\n    const mouseUpViewLocation = new Vector(event.clientX, event.clientY);\n    const diffLocation = mouseUpViewLocation.subtract(this.mouseDownViewLocation);\n    if (diffLocation.magnitude() > 5) {\n      // 判定为有效吸附的拖拽操作\n      if (this.isMovingEntity) {\n        // 这个时候可以触发对齐吸附事件\n        if (Settings.enableDragAutoAlign) {\n          this.project.autoAlign.alignAllSelected();\n        }\n        if (Settings.enableDragAlignToGrid) {\n          this.project.autoAlign.alignAllSelectedToGrid();\n        }\n\n        this.project.historyManager.recordStep(); // 记录一次历史\n      }\n    }\n\n    this.isMovingEntity = false;\n  };\n\n  public mouseMoveOutWindowForcedShutdown(_outsideLocation: Vector): void {\n    super.mouseMoveOutWindowForcedShutdown(_outsideLocation);\n    this.isMovingEntity = false;\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/controller/concrete/ControllerEntityCreate.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { ControllerClass } from \"@/core/service/controlService/controller/ControllerClass\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { Vector } from \"@graphif/data-structures\";\n\n/**\n * 创建节点的控制器\n */\nexport class ControllerEntityCreateClass extends ControllerClass {\n  constructor(protected readonly project: Project) {\n    super(project);\n  }\n\n  mouseDoubleClick = (event: MouseEvent) => {\n    // 双击只能在左键\n    if (!(event.button === 0)) {\n      return;\n    }\n    if (Settings.mouseLeftMode === \"draw\") {\n      // 绘制模式不能使用创建节点\n      return;\n    }\n    if (this.project.controller.camera.isPreGrabbingWhenSpace) {\n      return;\n    }\n\n    this.project.rectangleSelect.shutDown();\n\n    const pressLocation = this.project.renderer.transformView2World(new Vector(event.clientX, event.clientY));\n\n    // 排除：在实体上双击或者在线上双击\n    if (\n      this.project.stageManager.isEntityOnLocation(pressLocation) ||\n      this.project.stageManager.isAssociationOnLocation(pressLocation)\n    ) {\n      return;\n    }\n\n    // 是否是在Section内部双击\n    const sections = this.project.sectionMethods.getSectionsByInnerLocation(pressLocation);\n\n    if (this.project.controller.pressingKeySet.has(\"`\") || this.project.controller.pressingKeySet.has(\"·\")) {\n      this.createConnectPoint(pressLocation, sections);\n    } else {\n      // 双击创建节点\n      this.project.controllerUtils.addTextNodeByLocation(pressLocation, true);\n    }\n  };\n\n  createConnectPoint(pressLocation: Vector, addToSections: Section[]) {\n    this.project.nodeAdder.addConnectPoint(pressLocation, addToSections);\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/controller/concrete/ControllerEntityLayerMoving.tsx",
    "content": "import { ControllerClass } from \"@/core/service/controlService/controller/ControllerClass\";\nimport { EntityJumpMoveEffect } from \"@/core/service/feedbackService/effectEngine/concrete/EntityJumpMoveEffect\";\nimport { EntityShakeEffect } from \"@/core/service/feedbackService/effectEngine/concrete/EntityShakeEffect\";\nimport { RectanglePushInEffect } from \"@/core/service/feedbackService/effectEngine/concrete/RectanglePushInEffect\";\nimport { SoundService } from \"@/core/service/feedbackService/SoundService\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { toast } from \"sonner\";\n\n/**\n * 创建节点层级移动控制器\n */\n\nexport class ControllerLayerMovingClass extends ControllerClass {\n  public get isEnabled(): boolean {\n    if (Settings.mouseLeftMode === \"draw\") {\n      return false;\n    }\n    return true;\n  }\n\n  public mousemove: (event: MouseEvent) => void = (event: MouseEvent) => {\n    if (!this.project.controller.pressingKeySet.has(\"alt\")) {\n      return;\n    }\n    if (this.isEnabled === false) {\n      return;\n    }\n    if (this.project.stageManager.getSelectedEntities().length === 0) {\n      return;\n    }\n    this.project.controller.mouseLocation = this.project.renderer.transformView2World(\n      new Vector(event.clientX, event.clientY),\n    );\n  };\n\n  public mouseup: (event: MouseEvent) => void = (event: MouseEvent) => {\n    if (!this.project.controller.pressingKeySet.has(\"alt\")) {\n      return;\n    }\n    if (this.isEnabled === false) {\n      return;\n    }\n    if (this.project.stageManager.getSelectedEntities().length === 0) {\n      return;\n    }\n    const mouseLocation = this.project.renderer.transformView2World(new Vector(event.clientX, event.clientY));\n\n    // 提前检查点击的位置是否有一个TextNode，如果有，则转换成Section\n    const entity = this.project.stageManager.findEntityByLocation(mouseLocation);\n    if (entity && entity instanceof TextNode) {\n      // 防止无限循环嵌套：当跳入的实体是选中的所有内容当中任意一个Section的内部时，禁止触发该操作\n      const selectedEntities = this.project.stageManager.getSelectedEntities();\n      for (const selectedEntity of selectedEntities) {\n        if (\n          selectedEntity instanceof Section &&\n          this.project.sectionMethods.isEntityInSection(entity, selectedEntity)\n        ) {\n          this.project.effects.addEffect(EntityShakeEffect.fromEntity(entity));\n          this.project.effects.addEffect(EntityShakeEffect.fromEntity(selectedEntity));\n          toast.error(\"禁止将框套入自身内部\");\n          return;\n        }\n      }\n\n      const newSection = this.project.sectionPackManager.targetTextNodeToSection(entity);\n      if (newSection && selectedEntities.length > 0) {\n        // 获取所有选中实体的外接矩形的中心点，以便计算移动距离\n        const centerLocation = Rectangle.getBoundingRectangle(\n          selectedEntities.map((entity) => entity.collisionBox.getRectangle()),\n        ).center;\n        // 最后让所有选中的实体移动\n        for (const selectedEntity of selectedEntities) {\n          const delta = mouseLocation.subtract(centerLocation);\n          selectedEntity.move(delta);\n        }\n        this.project.sectionInOutManager.goInSections(this.project.stageManager.getSelectedEntities(), [newSection]);\n      }\n\n      return; // 这个return必须写\n    }\n\n    // 即将跳入的sections区域\n    const targetSections = this.project.sectionMethods.getSectionsByInnerLocation(mouseLocation);\n    const selectedEntities = this.project.stageManager.getSelectedEntities();\n\n    // 检查目标位置是否在锁定的 section 内（包括祖先section的锁定状态）\n    const hasLockedTargetSection = targetSections.some((section) =>\n      this.project.sectionMethods.isObjectBeLockedBySection(section),\n    );\n    if (hasLockedTargetSection) {\n      toast.error(\"不能跳入已锁定的 Section\");\n      return;\n    }\n\n    // 检查选中的实体是否在锁定的 section 内（包括祖先section的锁定状态）\n    for (const selectedEntity of selectedEntities) {\n      if (selectedEntity instanceof Section) {\n        // 对于section实体：如果本身被锁定，允许移动；如果未被锁定但有锁定的祖先section，阻止移动\n        if (!selectedEntity.locked) {\n          const ancestorSections = this.project.sectionMethods.getFatherSectionsList(selectedEntity);\n          if (ancestorSections.some((section) => section.locked)) {\n            toast.error(\"不能移动已锁定的 Section 中的物体\");\n            return;\n          }\n        }\n      } else {\n        // 对于其他实体：如果有锁定的祖先section，阻止移动\n        if (this.project.sectionMethods.isObjectBeLockedBySection(selectedEntity)) {\n          toast.error(\"不能移动已锁定的 Section 中的物体\");\n          return;\n        }\n      }\n    }\n\n    // 防止无限循环嵌套：当跳入的实体是选中的所有内容当中任意一个Section的内部时，禁止触发该操作\n    for (const selectedEntity of selectedEntities) {\n      if (selectedEntity instanceof Section) {\n        for (const targetSection of targetSections) {\n          if (this.project.sectionMethods.isEntityInSection(targetSection, selectedEntity)) {\n            this.project.effects.addEffect(EntityShakeEffect.fromEntity(targetSection));\n            toast.error(\"禁止将框套入自身内部\");\n            return;\n          }\n        }\n      }\n    }\n\n    // 移动位置\n\n    // 1 计算所有节点应该移动的 delta\n    // 1.0 计算当前框选的所有实体的中心位置\n\n    const delta = mouseLocation.subtract(\n      Rectangle.getBoundingRectangle(\n        selectedEntities.map((entity) => {\n          return entity.collisionBox.getRectangle();\n        }),\n      ).leftTop,\n    );\n    // 4 特效(要先加特效，否则位置已经被改了)\n    for (const entity of selectedEntities) {\n      this.project.effects.addEffect(new EntityJumpMoveEffect(15, entity.collisionBox.getRectangle(), delta));\n    }\n\n    // 改变层级\n    if (targetSections.length === 0) {\n      // 代表想要走到最外层空白位置\n      for (const entity of selectedEntities) {\n        const currentFatherSections = this.project.sectionMethods.getFatherSections(entity);\n        for (const currentFatherSection of currentFatherSections) {\n          this.project.stageManager.goOutSection([entity], currentFatherSection);\n\n          // 特效\n          setTimeout(() => {\n            this.project.effects.addEffect(\n              RectanglePushInEffect.sectionGoInGoOut(\n                entity.collisionBox.getRectangle(),\n                currentFatherSection.collisionBox.getRectangle(),\n                true,\n              ),\n            );\n          });\n        }\n      }\n    } else {\n      // 跑到了别的层级之中\n\n      this.project.sectionInOutManager.goInSections(selectedEntities, targetSections);\n\n      for (const section of targetSections) {\n        // this.project.stageManager.goInSection(selectedEntities, section);\n\n        // 特效\n        setTimeout(() => {\n          for (const entity of selectedEntities) {\n            this.project.effects.addEffect(\n              RectanglePushInEffect.sectionGoInGoOut(\n                entity.collisionBox.getRectangle(),\n                section.collisionBox.getRectangle(),\n              ),\n            );\n          }\n        });\n      }\n    }\n\n    // 3 移动所有选中的实体 的位置\n    this.project.entityMoveManager.moveSelectedEntities(delta);\n    // 播放跳跃音效\n    SoundService.play.entityJumpSoundFile();\n  };\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/controller/concrete/ControllerEntityResize.tsx",
    "content": "import { Vector } from \"@graphif/data-structures\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { ControllerClass } from \"@/core/service/controlService/controller/ControllerClass\";\nimport { ImageNode } from \"@/core/stage/stageObject/entity/ImageNode\";\nimport { SvgNode } from \"@/core/stage/stageObject/entity/SvgNode\";\nimport { ReferenceBlockNode } from \"@/core/stage/stageObject/entity/ReferenceBlockNode\";\n\nexport class ControllerEntityResizeClass extends ControllerClass {\n  private changeSizeEntity: Entity | null = null;\n  public mousedown: (event: MouseEvent) => void = (event) => {\n    if (!(event.button == 0)) {\n      return;\n    }\n    // 检查是否有选中的物体\n    const selectedEntities = this.project.stageManager.getSelectedEntities();\n    if (selectedEntities.length === 0) {\n      return;\n    }\n    const pressWorldLocation = this.project.renderer.transformView2World(new Vector(event.clientX, event.clientY));\n    this.lastMoveLocation = pressWorldLocation.clone();\n    for (const selectedEntity of selectedEntities) {\n      // 检查是否是支持缩放的实体类型\n      if (\n        selectedEntity instanceof TextNode ||\n        selectedEntity instanceof ImageNode ||\n        selectedEntity instanceof SvgNode ||\n        selectedEntity instanceof ReferenceBlockNode\n      ) {\n        // 对TextNode进行特殊处理，只在手动模式下允许缩放\n        if (selectedEntity instanceof TextNode && selectedEntity.sizeAdjust === \"auto\") {\n          continue;\n        }\n\n        // 检查是否点击了缩放控制点\n        const resizeRect = selectedEntity.getResizeHandleRect();\n        if (resizeRect.isPointIn(pressWorldLocation)) {\n          // 点中了扩大缩小的东西\n          this.changeSizeEntity = selectedEntity;\n          break;\n        }\n      }\n    }\n  };\n\n  public mousemove: (event: MouseEvent) => void = (event) => {\n    if (this.changeSizeEntity === null) {\n      return;\n    }\n    const pressWorldLocation = this.project.renderer.transformView2World(new Vector(event.clientX, event.clientY));\n    const diff = pressWorldLocation.subtract(this.lastMoveLocation);\n\n    // 对所有支持缩放的实体类型调用resizeHandle方法\n    if (\n      this.changeSizeEntity instanceof TextNode ||\n      this.changeSizeEntity instanceof ImageNode ||\n      this.changeSizeEntity instanceof SvgNode ||\n      this.changeSizeEntity instanceof ReferenceBlockNode\n    ) {\n      this.changeSizeEntity.resizeHandle(diff);\n    }\n\n    this.lastMoveLocation = pressWorldLocation.clone();\n  };\n\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  public mouseup: (event: MouseEvent) => void = (_event) => {\n    if (this.changeSizeEntity === null) {\n      return;\n    }\n    // if (this.changeSizeEntity instanceof TextNode) {\n    //   this.project.effects.addEffect(new EntityDashTipEffect(50, this.changeSizeEntity.getResizeHandleRect()));\n    // }\n    this.changeSizeEntity = null;\n    // const pressWorldLocation = this.project.renderer.transformView2World(new Vector(event.clientX, event.clientY));\n  };\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/controller/concrete/ControllerImageScale.tsx",
    "content": "import { ControllerClass } from \"@/core/service/controlService/controller/ControllerClass\";\nimport { ImageNode } from \"@/core/stage/stageObject/entity/ImageNode\";\nimport { ReferenceBlockNode } from \"@/core/stage/stageObject/entity/ReferenceBlockNode\";\nimport { SvgNode } from \"@/core/stage/stageObject/entity/SvgNode\";\nimport { isMac } from \"@/utils/platform\";\nimport { Vector } from \"@graphif/data-structures\";\n\nexport class ControllerImageScaleClass extends ControllerClass {\n  mousewheel = (event: WheelEvent) => {\n    if (\n      isMac ? this.project.controller.pressingKeySet.has(\"meta\") : this.project.controller.pressingKeySet.has(\"control\")\n    ) {\n      const location = this.project.renderer.transformView2World(new Vector(event.clientX, event.clientY));\n      const hoverEntity = this.project.stageManager.findEntityByLocation(location);\n      if (hoverEntity === null) {\n        return;\n      }\n      if (\n        hoverEntity instanceof ImageNode ||\n        hoverEntity instanceof SvgNode ||\n        hoverEntity instanceof ReferenceBlockNode\n      ) {\n        // 需要注意缩放逻辑和视野缩放逻辑保持一致性\n        for (const entity of this.project.stageManager.getSelectedEntities()) {\n          if (entity instanceof ImageNode || entity instanceof SvgNode || entity instanceof ReferenceBlockNode) {\n            if (event.deltaY > 0) {\n              // 放大图片\n              entity.scaleUpdate(-0.1);\n            } else if (event.deltaY < 0) {\n              entity.scaleUpdate(+0.1);\n            }\n          }\n        }\n      }\n    }\n  };\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/controller/concrete/ControllerNodeConnection.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { ControllerClass } from \"@/core/service/controlService/controller/ControllerClass\";\nimport { MouseLocation } from \"@/core/service/controlService/MouseLocation\";\nimport { LineEffect } from \"@/core/service/feedbackService/effectEngine/concrete/LineEffect\";\nimport { RectangleNoteEffect } from \"@/core/service/feedbackService/effectEngine/concrete/RectangleNoteEffect\";\nimport { SoundService } from \"@/core/service/feedbackService/SoundService\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { ConnectPoint } from \"@/core/stage/stageObject/entity/ConnectPoint\";\nimport { ImageNode } from \"@/core/stage/stageObject/entity/ImageNode\";\nimport { CursorNameEnum } from \"@/types/cursors\";\nimport { Direction } from \"@/types/directions\";\nimport { isMac } from \"@/utils/platform\";\nimport { ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Line } from \"@graphif/shapes\";\n\n/**\n * 连线控制器\n * 目前的连接方式：\n * 拖连（可多重）、\n * 左右键点连：右键有点问题\n * 折连、\n * 拖拽再生连（可多重）、\n */\nexport class ControllerNodeConnectionClass extends ControllerClass {\n  private _isControlKeyDown = false;\n  private _controlKeyEventRegistered = false;\n\n  private onControlKeyDown = (event: KeyboardEvent) => {\n    if (isMac && event.key === \"Control\" && !this._isControlKeyDown && Settings.macEnableControlToCut) {\n      this._isControlKeyDown = true;\n      // 模拟鼠标按下事件\n      const fakeMouseEvent = new MouseEvent(\"mousedown\", {\n        button: 2,\n        clientX: MouseLocation.vector().x,\n        clientY: MouseLocation.vector().y,\n      });\n      this.mousedown(fakeMouseEvent);\n      this.project.controller.isMouseDown[2] = true;\n    }\n  };\n\n  private onControlKeyUp = (event: KeyboardEvent) => {\n    if (isMac && event.key === \"Control\" && this._isControlKeyDown && Settings.macEnableControlToCut) {\n      this._isControlKeyDown = false;\n      // 模拟鼠标松开事件\n      const fakeMouseEvent = new MouseEvent(\"mouseup\", {\n        button: 2,\n        clientX: MouseLocation.vector().x,\n        clientY: MouseLocation.vector().y,\n      });\n      this.mouseup(fakeMouseEvent);\n      this.project.controller.isMouseDown[2] = false;\n    }\n  };\n\n  private registerControlKeyEvents() {\n    if (!this._controlKeyEventRegistered) {\n      window.addEventListener(\"keydown\", this.onControlKeyDown);\n      window.addEventListener(\"keyup\", this.onControlKeyUp);\n      this._controlKeyEventRegistered = true;\n    }\n  }\n\n  private unregisterControlKeyEvents() {\n    if (this._controlKeyEventRegistered) {\n      window.removeEventListener(\"keydown\", this.onControlKeyDown);\n      window.removeEventListener(\"keyup\", this.onControlKeyUp);\n      this._controlKeyEventRegistered = false;\n    }\n  }\n  /**\n   * 仅限在当前文件中使用的记录\n   * 右键点击的位置，仅用于连接检测按下位置和抬起位置是否重叠\n   */\n  private _lastRightMousePressLocation: Vector = new Vector(0, 0);\n\n  private _isUsing: boolean = false;\n  public get isUsing(): boolean {\n    return this._isUsing;\n  }\n\n  constructor(protected readonly project: Project) {\n    super(project);\n    this.registerControlKeyEvents();\n  }\n\n  dispose() {\n    super.dispose();\n    this.unregisterControlKeyEvents();\n  }\n  /**\n   * 用于多重连接\n   */\n  public connectFromEntities: ConnectableEntity[] = [];\n  public connectToEntity: ConnectableEntity | null = null;\n\n  private mouseLocations: Vector[] = [];\n  public getMouseLocationsPoints(): Vector[] {\n    return this.mouseLocations;\n  }\n\n  /**\n   * 拖拽时左键生成质点\n   * @param pressWorldLocation\n   */\n  public createConnectPointWhenConnect() {\n    const pressWorldLocation = this.project.renderer.transformView2World(MouseLocation.vector().clone());\n    // 如果是左键，则检查是否在连接的过程中按下\n    if (!this.isConnecting()) {\n      return;\n    }\n    if (this.project.stageManager.findConnectableEntityByLocation(pressWorldLocation) !== null) {\n      return;\n    }\n    // 是否是在Section内部双击\n    const sections = this.project.sectionMethods.getSectionsByInnerLocation(pressWorldLocation);\n\n    const pointUUID = this.project.nodeAdder.addConnectPoint(pressWorldLocation, sections);\n    const connectPoint = this.project.stageManager.getConnectableEntityByUUID(pointUUID) as ConnectPoint;\n\n    // 连向新质点\n    for (const fromEntity of this.connectFromEntities) {\n      this.project.stageManager.connectEntity(fromEntity, connectPoint);\n      this.addConnectEffect(fromEntity, connectPoint);\n    }\n    this.connectFromEntities = [connectPoint];\n\n    // 选中这个质点\n    this.project.stageManager.clearSelectAll();\n    // connectPoint.isSelected = true;\n  }\n\n  public mousedown: (event: MouseEvent) => void = (event) => {\n    if (!(event.button === 2 || event.button === 0)) {\n      return;\n    }\n    if (event.button === 0 && Settings.mouseLeftMode === \"connectAndCut\") {\n      // 把鼠标左键切换为连线模式的情况\n      this.onMouseDown(event);\n    } else if (event.button === 0 && Settings.mouseLeftMode !== \"connectAndCut\") {\n      // 右键拖拽连线的时候点击左键\n      this.createConnectPointWhenConnect();\n    } else if (event.button === 2) {\n      // if (Stage.mouseRightDragBackground === \"moveCamera\") {\n      //   return;\n      // }\n      // 正常右键按下\n      this.onMouseDown(event);\n    }\n  };\n\n  // 记录拖拽起始点在图片上的精确位置\n  private _startImageLocation: Map<string, Vector> = new Map();\n  // 记录拖拽结束点在图片上的精确位置\n  private _endImageLocation: Vector | null = null;\n\n  private onMouseDown(event: MouseEvent) {\n    const pressWorldLocation = this.project.renderer.transformView2World(new Vector(event.clientX, event.clientY));\n\n    this._lastRightMousePressLocation = pressWorldLocation.clone();\n\n    // 清空之前的轨迹记录\n    this.mouseLocations = [pressWorldLocation.clone()];\n\n    const clickedConnectableEntity: ConnectableEntity | null =\n      this.project.stageManager.findConnectableEntityByLocation(pressWorldLocation);\n    if (clickedConnectableEntity === null) {\n      return;\n    }\n\n    // 检查点击的实体是否是背景图片\n    if (clickedConnectableEntity instanceof ImageNode && (clickedConnectableEntity as ImageNode).isBackground) {\n      return;\n    }\n\n    // 记录起始点在图片或引用块上的精确位置\n    if (\n      clickedConnectableEntity instanceof ImageNode ||\n      clickedConnectableEntity.constructor.name === \"ReferenceBlockNode\"\n    ) {\n      const rect = clickedConnectableEntity.collisionBox.getRectangle();\n      // 计算鼠标在内部的相对位置 (0-1之间)\n      const relativeX = (pressWorldLocation.x - rect.location.x) / rect.size.x;\n      const relativeY = (pressWorldLocation.y - rect.location.y) / rect.size.y;\n      this._startImageLocation.set(clickedConnectableEntity.uuid, new Vector(relativeX, relativeY));\n    } else {\n      this._startImageLocation.clear();\n    }\n\n    // 右键点击了某个节点\n    this.connectFromEntities = [];\n    for (const node of this.project.stageManager.getConnectableEntity()) {\n      if (node.isSelected) {\n        this.connectFromEntities.push(node);\n      }\n    }\n    /**\n     * 有两种情况：\n     * 1. 从框选的节点开始右键拖拽连线，此时需要触发多重连接\n     * 2. 从没有框选的节点开始右键拖拽连线，此时不需要触发多重连接\n     * ┌───┐┌───┐       ┌───┐┌───┐\n     * │┌─┐││┌─┐│ ┌─┐   │┌─┐││┌─┐│ ┌─┐\n     * │└─┘││└─┘│ └─┘   │└─┘││└─┘│ └┬┘\n     * └─┬─┘└───┘       └───┘└───┘  │\n     *   │                          │\n     *   │                          │\n     *   └──►┌─┐              ┌─┐◄──┘\n     *       └─┘              └─┘\n     * 右边的方法还是有用的，减少了一步提前框选的操作。\n     */\n    if (this.connectFromEntities.includes(clickedConnectableEntity)) {\n      // 多重连接\n      for (const node of this.project.stageManager.getConnectableEntity()) {\n        if (node.isSelected) {\n          // 特效\n          this.project.effects.addEffect(\n            new RectangleNoteEffect(\n              new ProgressNumber(0, 15),\n              node.collisionBox.getRectangle().clone(),\n              this.project.stageStyleManager.currentStyle.effects.successShadow.clone(),\n            ),\n          );\n        }\n      }\n    } else {\n      // 不触发多重连接\n      // 只触发一次连接\n      this.connectFromEntities = [clickedConnectableEntity];\n      // 特效\n      this.project.effects.addEffect(\n        new RectangleNoteEffect(\n          new ProgressNumber(0, 15),\n          clickedConnectableEntity.collisionBox.getRectangle().clone(),\n          this.project.stageStyleManager.currentStyle.effects.successShadow.clone(),\n        ),\n      );\n    }\n    // 播放音效\n    SoundService.play.connectLineStart();\n    this._isUsing = true;\n    this.project.controller.setCursorNameHook(CursorNameEnum.Crosshair);\n  }\n\n  /**\n   * 在mousemove的过程中，是否鼠标悬浮在了目标节点上\n   */\n  private isMouseHoverOnTarget = false;\n\n  public mousemove: (event: MouseEvent) => void = (event) => {\n    if (this.project.controller.rectangleSelect.isUsing || this.project.controller.cutting.isUsing) {\n      return;\n    }\n    if (!this._isUsing) {\n      return;\n    }\n    if (this.project.controller.isMouseDown[0] && Settings.mouseLeftMode === \"connectAndCut\") {\n      this.mouseMove(event);\n    }\n    if (this.project.controller.isMouseDown[2]) {\n      this.mouseMove(event);\n    }\n  };\n\n  private mouseMove(event: MouseEvent) {\n    const worldLocation = this.project.renderer.transformView2World(new Vector(event.clientX, event.clientY));\n    // 添加轨迹\n    if (\n      this.mouseLocations.length === 0 ||\n      this.mouseLocations[this.mouseLocations.length - 1].distance(worldLocation) > 5\n    ) {\n      this.mouseLocations.push(worldLocation.clone());\n    }\n    // 连接线\n    let isFindConnectToNode = false;\n    for (const entity of this.project.stageManager.getConnectableEntity()) {\n      // 检查实体是否是背景图片\n      if (entity instanceof ImageNode && (entity as ImageNode).isBackground) {\n        continue;\n      }\n      if (entity.collisionBox.isContainsPoint(worldLocation)) {\n        // 找到了连接的节点，吸附上去\n        this.connectToEntity = entity;\n        isFindConnectToNode = true;\n        if (!this.isMouseHoverOnTarget) {\n          SoundService.play.connectFindTarget();\n        }\n        this.isMouseHoverOnTarget = true;\n        break;\n      }\n    }\n    if (!isFindConnectToNode) {\n      this.connectToEntity = null;\n      this.isMouseHoverOnTarget = false;\n    }\n    // 由于连接线要被渲染器绘制，所以需要更新总控制里的lastMoveLocation\n    this.project.controller.lastMoveLocation = worldLocation.clone();\n  }\n\n  public mouseup: (event: MouseEvent) => void = (event) => {\n    if (!(event.button === 2 || event.button === 0)) {\n      return;\n    }\n    if (!this.isConnecting()) {\n      return;\n    }\n    if (event.button === 0 && Settings.mouseLeftMode === \"connectAndCut\") {\n      this.mouseUp(event);\n    } else if (event.button === 2) {\n      this.mouseUp(event);\n    }\n  };\n\n  private mouseUp(event: MouseEvent) {\n    const releaseWorldLocation = this.project.renderer.transformView2World(new Vector(event.clientX, event.clientY));\n    const releaseTargetEntity = this.project.stageManager.findConnectableEntityByLocation(releaseWorldLocation);\n\n    // 检查释放的实体是否是背景图片\n    if (releaseTargetEntity instanceof ImageNode && (releaseTargetEntity as ImageNode).isBackground) {\n      this.clear();\n      this.project.controller.setCursorNameHook(CursorNameEnum.Default);\n      return;\n    }\n\n    // 记录结束点在图片或引用块上的精确位置\n    this._endImageLocation = null;\n    if (\n      releaseTargetEntity &&\n      (releaseTargetEntity instanceof ImageNode || releaseTargetEntity.constructor.name === \"ReferenceBlockNode\")\n    ) {\n      const rect = releaseTargetEntity.collisionBox.getRectangle();\n      // 计算鼠标在内部的相对位置 (0-1之间)\n      const relativeX = (releaseWorldLocation.x - rect.location.x) / rect.size.x;\n      const relativeY = (releaseWorldLocation.y - rect.location.y) / rect.size.y;\n      this._endImageLocation = new Vector(relativeX, relativeY);\n    }\n\n    // 根据轨迹判断方向\n    let sourceDirection: Direction | null = null;\n    let targetDirection: Direction | null = null;\n\n    // 如果开启了根据鼠标轨迹自动调整端点位置的功能，则获取方向信息\n    if (Settings.autoAdjustLineEndpointsByMouseTrack) {\n      [sourceDirection, targetDirection] = this.getConnectDirectionByMouseTrack();\n    }\n\n    // 结束连线\n    if (releaseTargetEntity !== null) {\n      if (releaseTargetEntity.isSelected) {\n        // 如果鼠标释放的节点上是已经选中的节点\n        // 区分是拖拽松开连线还是点击松开连线\n        if (releaseWorldLocation.distance(this._lastRightMousePressLocation) < 5) {\n          // 距离过近，说明是点击事件，不触发连接，让右键菜单触发\n        } else {\n          // 距离足够远，说明是拖拽事件，完成连线\n          if (this.connectToEntity) {\n            this.dragMultiConnect(this.connectToEntity, sourceDirection, targetDirection);\n          }\n        }\n      } else {\n        // 在目标节点上弹起\n\n        // 区分是拖拽松开连线还是点击松开连线\n        if (releaseWorldLocation.distance(this._lastRightMousePressLocation) < 5) {\n          // 距离过近，说明是点击事件，而不是拖拽事件\n          // 这个可能歪打误撞地被用户触发\n          this.clickMultiConnect(releaseWorldLocation);\n        } else {\n          // 鼠标在待连接节点上抬起\n          if (this.connectToEntity) {\n            this.dragMultiConnect(this.connectToEntity, sourceDirection, targetDirection);\n          }\n        }\n      }\n    } else {\n      // 鼠标在空白位置抬起\n      // 额外复制一个数组，因为回调函数执行前，这个数组已经被清空了\n      const newConnectFromEntities = this.connectFromEntities;\n\n      this.project.controllerUtils.addTextNodeByLocation(releaseWorldLocation, true).then((uuid) => {\n        const createdNode = this.project.stageManager.getTextNodeByUUID(uuid) as ConnectableEntity;\n\n        // 让这个新建的节点进入编辑状态\n        this.project.controllerUtils.textNodeInEditModeByUUID(uuid);\n\n        for (const fromEntity of newConnectFromEntities) {\n          const connectResult = this.project.stageManager.connectEntity(fromEntity, createdNode);\n          if (connectResult) {\n            this.addConnectEffect(fromEntity, createdNode);\n          }\n        }\n      });\n    }\n    this.clear();\n    this.project.controller.setCursorNameHook(CursorNameEnum.Default);\n  }\n\n  /**\n   * // 判断轨迹\n   * // 根据点状数组生成折线段\n   * @returns\n   */\n  private getConnectDirectionByMouseTrack(): [Direction | null, Direction | null] {\n    const lines = [];\n    for (let i = 0; i < this.mouseLocations.length - 1; i++) {\n      const start = this.mouseLocations[i];\n      const end = this.mouseLocations[i + 1];\n      lines.push(new Line(start, end));\n    }\n    // 根据折线段，判断，从选中的实体到目标实体经过的折线段与其交点位置\n    let sourceDirection: Direction | null = null;\n    let targetDirection: Direction | null = null;\n\n    for (const line of lines) {\n      // 寻找源头端点位置\n      for (const fromEntity of this.connectFromEntities) {\n        if (fromEntity.collisionBox.isContainsPoint(line.start) && !fromEntity.collisionBox.isContainsPoint(line.end)) {\n          // 找到了出去的一小段线段\n          const rect = fromEntity.collisionBox.getRectangle();\n          const intersectionPoint = rect.getLineIntersectionPoint(line);\n          // 找到交点，判断交点在哪个方位上\n          if (intersectionPoint.y === rect.top) {\n            // 从顶部发出\n            sourceDirection = Direction.Up;\n          } else if (intersectionPoint.y === rect.bottom) {\n            // 从底部发出\n            sourceDirection = Direction.Down;\n          } else if (intersectionPoint.x === rect.left) {\n            // 从左侧发出\n            sourceDirection = Direction.Left;\n          } else if (intersectionPoint.x === rect.right) {\n            // 从右侧发出\n            sourceDirection = Direction.Right;\n          }\n        }\n      }\n      // 寻找目标端点位置\n      if (\n        this.connectToEntity &&\n        this.connectToEntity.collisionBox.isContainsPoint(line.end) &&\n        !this.connectToEntity.collisionBox.isContainsPoint(line.start)\n      ) {\n        // 找到了入来的一小段线段\n        const rect = this.connectToEntity.collisionBox.getRectangle();\n        const intersectionPoint = rect.getLineIntersectionPoint(line);\n        // 找到交点，判断交点在哪个方位上\n        if (intersectionPoint.y === rect.top) {\n          // 到达顶部\n          targetDirection = Direction.Up;\n        } else if (intersectionPoint.y === rect.bottom) {\n          // 到达底部\n          targetDirection = Direction.Down;\n        } else if (intersectionPoint.x === rect.left) {\n          // 到达左侧\n          targetDirection = Direction.Left;\n        } else if (intersectionPoint.x === rect.right) {\n          // 到达右侧\n          targetDirection = Direction.Right;\n        }\n      }\n    }\n    return [sourceDirection, targetDirection];\n  }\n\n  /**\n   * 一种更快捷的连接方法: 节点在选中状态下右键其它节点直接连接，不必拖动\n   * issue #135\n   * @param releaseWorldLocation\n   */\n  private clickMultiConnect(releaseWorldLocation: Vector) {\n    // 检查是否启用了右键点击连线功能\n    if (!Settings.enableRightClickConnect) {\n      return;\n    }\n\n    // 右键点击位置和抬起位置重叠，说明是右键单击事件，没有发生拖拽现象\n    const releaseTargetEntity = this.project.stageManager.findConnectableEntityByLocation(releaseWorldLocation);\n    if (!releaseTargetEntity) {\n      return;\n    }\n\n    // 检查目标实体是否是背景图片\n    if (releaseTargetEntity instanceof ImageNode && (releaseTargetEntity as ImageNode).isBackground) {\n      return;\n    }\n    const selectedEntities = this.project.stageManager.getConnectableEntity().filter((entity) => entity.isSelected);\n    // 还要保证当前舞台有节点被选中\n    // 连线\n    this.project.stageManager.connectMultipleEntities(selectedEntities, releaseTargetEntity);\n\n    for (const selectedEntity of selectedEntities) {\n      this.addConnectEffect(selectedEntity, releaseTargetEntity);\n    }\n  }\n\n  private clear() {\n    // 重置状态\n    this.connectFromEntities = [];\n    this.connectToEntity = null;\n    this._isUsing = false;\n    this._startImageLocation.clear();\n    this._endImageLocation = null;\n  }\n\n  private dragMultiConnect(\n    connectToEntity: ConnectableEntity,\n    sourceDirection: Direction | null = null,\n    targetDirection: Direction | null = null,\n  ) {\n    // 鼠标在待连接节点上抬起\n    // let isHaveConnectResult = false; // 在多重链接的情况下，是否有连接成功\n\n    const isPressC = this.project.controller.pressingKeySet.has(\"c\");\n    let sourceRectRate: [number, number] = [0.5, 0.5];\n\n    // 如果是从图片或引用块节点发出，使用精确位置\n    const isSingleImageOrReferenceSource =\n      this.connectFromEntities.length === 1 &&\n      (this.connectFromEntities[0] instanceof ImageNode ||\n        this.connectFromEntities[0].constructor.name === \"ReferenceBlockNode\");\n\n    if (isSingleImageOrReferenceSource) {\n      const fromEntity = this.connectFromEntities[0];\n      const startPos = this._startImageLocation.get(fromEntity.uuid);\n      if (startPos) {\n        sourceRectRate = [startPos.x, startPos.y];\n      }\n    } else {\n      // 非图片或引用块节点或多重连接，使用方向计算\n      switch (sourceDirection) {\n        case Direction.Left:\n          sourceRectRate = [0.01, 0.5];\n          break;\n        case Direction.Right:\n          sourceRectRate = [0.99, 0.5];\n          break;\n        case Direction.Up:\n          sourceRectRate = [0.5, 0.01];\n          break;\n        case Direction.Down:\n          sourceRectRate = [0.5, 0.99];\n          break;\n      }\n    }\n\n    // 计算目标位置\n    let targetRectRate: [number, number] = [0.5, 0.5];\n    // 如果是连接到图片或引用块节点，使用精确位置\n    if (\n      (connectToEntity instanceof ImageNode || connectToEntity.constructor.name === \"ReferenceBlockNode\") &&\n      this._endImageLocation\n    ) {\n      targetRectRate = [this._endImageLocation.x, this._endImageLocation.y];\n    } else {\n      // 否则使用方向计算\n      switch (targetDirection) {\n        case Direction.Left:\n          targetRectRate = [0.01, 0.5];\n          break;\n        case Direction.Right:\n          targetRectRate = [0.99, 0.5];\n          break;\n        case Direction.Up:\n          targetRectRate = [0.5, 0.01];\n          break;\n        case Direction.Down:\n          targetRectRate = [0.5, 0.99];\n          break;\n      }\n    }\n\n    // 连线\n    this.project.stageManager.connectMultipleEntities(\n      this.connectFromEntities,\n      connectToEntity,\n      isPressC,\n      sourceRectRate,\n      targetRectRate,\n    );\n\n    // 添加连接特效\n    for (const entity of this.connectFromEntities) {\n      this.addConnectEffect(entity, connectToEntity);\n    }\n\n    // 如果端点位置被调整，添加高亮特效\n    if (sourceDirection !== null) {\n      for (const entity of this.connectFromEntities) {\n        const rect = entity.collisionBox.getRectangle();\n        let fromLocation: Vector;\n        let toLocation: Vector;\n\n        switch (sourceDirection) {\n          case Direction.Left:\n            fromLocation = new Vector(rect.left, rect.top);\n            toLocation = new Vector(rect.left, rect.bottom);\n            break;\n          case Direction.Right:\n            fromLocation = new Vector(rect.right, rect.top);\n            toLocation = new Vector(rect.right, rect.bottom);\n            break;\n          case Direction.Up:\n            fromLocation = new Vector(rect.left, rect.top);\n            toLocation = new Vector(rect.right, rect.top);\n            break;\n          case Direction.Down:\n            fromLocation = new Vector(rect.left, rect.bottom);\n            toLocation = new Vector(rect.right, rect.bottom);\n            break;\n        }\n\n        this.project.effects.addEffect(LineEffect.rectangleEdgeTip(fromLocation, toLocation));\n      }\n    }\n\n    if (targetDirection !== null) {\n      const rect = connectToEntity.collisionBox.getRectangle();\n      let fromLocation: Vector;\n      let toLocation: Vector;\n\n      switch (targetDirection) {\n        case Direction.Left:\n          fromLocation = new Vector(rect.left, rect.top);\n          toLocation = new Vector(rect.left, rect.bottom);\n          break;\n        case Direction.Right:\n          fromLocation = new Vector(rect.right, rect.top);\n          toLocation = new Vector(rect.right, rect.bottom);\n          break;\n        case Direction.Up:\n          fromLocation = new Vector(rect.left, rect.top);\n          toLocation = new Vector(rect.right, rect.top);\n          break;\n        case Direction.Down:\n          fromLocation = new Vector(rect.left, rect.bottom);\n          toLocation = new Vector(rect.right, rect.bottom);\n          break;\n      }\n\n      this.project.effects.addEffect(LineEffect.rectangleEdgeTip(fromLocation, toLocation));\n    }\n  }\n\n  private isConnecting() {\n    return this.connectFromEntities.length > 0 && this._isUsing;\n  }\n\n  private addConnectEffect(from: ConnectableEntity, to: ConnectableEntity) {\n    for (const effect of this.project.edgeRenderer.getConnectedEffects(from, to)) {\n      this.project.effects.addEffect(effect);\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/controller/concrete/ControllerNodeEdit.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { ControllerClass } from \"@/core/service/controlService/controller/ControllerClass\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { UrlNode } from \"@/core/stage/stageObject/entity/UrlNode\";\nimport { ReferenceBlockNode } from \"@/core/stage/stageObject/entity/ReferenceBlockNode\";\nimport { isMac } from \"@/utils/platform\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { open } from \"@tauri-apps/plugin-shell\";\nimport { MouseLocation } from \"../../MouseLocation\";\n/**\n * 包含编辑节点文字，编辑详细信息等功能的控制器\n *\n * 当有节点编辑时，会把摄像机锁定住\n */\nexport class ControllerNodeEditClass extends ControllerClass {\n  constructor(protected readonly project: Project) {\n    super(project);\n  }\n\n  mouseDoubleClick = async (event: MouseEvent) => {\n    if (event.button !== 0) {\n      return;\n    }\n    if (this.project.controller.camera.isPreGrabbingWhenSpace) {\n      return;\n    }\n\n    const pressLocation = this.project.renderer.transformView2World(new Vector(event.clientX, event.clientY));\n    const clickedEntity = this.project.stageManager.findEntityByLocation(pressLocation);\n\n    if (clickedEntity === null) {\n      return;\n    }\n\n    if (this.project.controller.pressingKeySet.has(isMac ? \"meta\" : \"control\")) {\n      this.project.controllerUtils.editNodeDetails(clickedEntity);\n      return;\n    }\n\n    if (clickedEntity instanceof TextNode) {\n      this.project.controllerUtils.editTextNode(clickedEntity, Settings.textNodeSelectAllWhenStartEditByMouseClick);\n    } else if (clickedEntity instanceof UrlNode) {\n      const diffNodeLeftTopLocation = pressLocation.subtract(clickedEntity.rectangle.leftTop);\n      if (diffNodeLeftTopLocation.y < UrlNode.titleHeight) {\n        this.project.controllerUtils.editUrlNodeTitle(clickedEntity);\n      } else {\n        // 跳转链接\n        open(clickedEntity.url);\n      }\n    } else if (clickedEntity instanceof ReferenceBlockNode) {\n      // 双击引用块跳转到源头\n      clickedEntity.goToSource();\n    }\n  };\n\n  mouseup = (event: MouseEvent) => {\n    if (event.button !== 0) {\n      return;\n    }\n    if (this.project.controller.camera.isPreGrabbingWhenSpace) {\n      return;\n    }\n\n    const pressLocation = this.project.renderer.transformView2World(new Vector(event.clientX, event.clientY));\n    for (const entity of this.project.stageManager.getEntities()) {\n      // 必须有详细信息才显示详细信息按钮，进而点进去，否则会误触\n      if (entity.isMouseInDetailsButton(pressLocation) && entity.details.length > 0) {\n        this.project.controllerUtils.editNodeDetails(entity);\n        return;\n      }\n    }\n    // 处理引用按钮点击事件\n    this.project.referenceManager.onClickReferenceNumber(\n      this.project.renderer.transformView2World(MouseLocation.vector()),\n    );\n  };\n\n  mousemove = (event: MouseEvent) => {\n    this.project.controller.resetCountdownTimer();\n    /**\n     * 如果一直显示详细信息，则不显示鼠标悬停效果\n     */\n    if (Settings.alwaysShowDetails) {\n      return;\n    }\n\n    const location = this.project.renderer.transformView2World(new Vector(event.clientX, event.clientY));\n    for (const node of this.project.stageManager.getTextNodes()) {\n      node.isMouseHover = false;\n      if (node.collisionBox.isContainsPoint(location)) {\n        node.isMouseHover = true;\n      }\n    }\n  };\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/controller/concrete/ControllerPenStrokeControl.tsx",
    "content": "import { Settings } from \"@/core/service/Settings\";\nimport { ControllerClass } from \"@/core/service/controlService/controller/ControllerClass\";\nimport { Vector } from \"@graphif/data-structures\";\n\n/**\n * 所有和笔迹控制特定的逻辑都在这里\n */\nexport class ControllerPenStrokeControlClass extends ControllerClass {\n  public isAdjusting = false;\n  /**\n   * Alt键右键按下时的位置\n   */\n  public startAdjustWidthLocation: Vector = Vector.getZero();\n  /**\n   * 在右键移动的过程中，记录上一次的位置\n   */\n  public lastAdjustWidthLocation: Vector = Vector.getZero();\n\n  public mousedown: (event: MouseEvent) => void = (event) => {\n    if (!(event.button === 2 && Settings.mouseLeftMode === \"draw\")) {\n      return;\n    }\n    if (this.project.controller.camera.isPreGrabbingWhenSpace) {\n      return;\n    }\n    const pressWorldLocation = this.project.renderer.transformView2World(new Vector(event.clientX, event.clientY));\n    if (event.button === 2 && this.project.controller.pressingKeySet.has(\"alt\")) {\n      // 右键按下时，开始调整笔刷粗细\n      this.startAdjustWidthLocation = pressWorldLocation.clone();\n      this.isAdjusting = true;\n      this.lastAdjustWidthLocation = pressWorldLocation.clone();\n    }\n  };\n\n  public mousemove: (event: MouseEvent) => void = (event) => {\n    if (Settings.mouseLeftMode === \"selectAndMove\") {\n      // 检查鼠标是否悬浮在笔迹上\n      const location = this.project.renderer.transformView2World(new Vector(event.clientX, event.clientY));\n      for (const node of this.project.stageManager.getPenStrokes()) {\n        node.isMouseHover = false;\n        if (node.collisionBox.isContainsPoint(location)) {\n          node.isMouseHover = true;\n        }\n      }\n    }\n    if (Settings.mouseLeftMode === \"draw\") {\n      if (this.project.controller.pressingKeySet.has(\"alt\") && this.project.controller.isMouseDown[2]) {\n        this.onMouseMoveWhenAdjusting(event);\n        return;\n      }\n    }\n  };\n\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  public mouseup: (event: MouseEvent) => void = (_event) => {\n    if (Settings.mouseLeftMode === \"draw\") {\n      if (this.isAdjusting) {\n        this.isAdjusting = false;\n      }\n    }\n  };\n\n  // public mousewheel: (event: WheelEvent) => void = (event) => {\n  //   if (Settings.mouseLeftMode !== \"draw\") {\n  //     return;\n  //   }\n  //   if (this.project.controller.pressingKeySet.has(\"control\")) {\n  //     // 控制放大缩小\n  //     if (isMac) {\n  //       // mac暂不支持滚轮缩放大小\n  //       return;\n  //     } else {\n  //       let newWidth;\n  //       if (event.deltaY > 0) {\n  //         newWidth = Stage.drawingMachine.currentStrokeWidth + 1;\n  //       } else {\n  //         newWidth = Stage.drawingMachine.currentStrokeWidth - 1;\n  //       }\n  //       Stage.drawingMachine.currentStrokeWidth = Math.max(1, Math.min(newWidth, 1000));\n  //     }\n  //   }\n  // };\n\n  private onMouseMoveWhenAdjusting = (event: MouseEvent) => {\n    // 更改宽度，检测鼠标上下移动的距离（模仿PS的笔刷粗细调整）\n    const currentWorldLocation = this.project.renderer.transformView2World(new Vector(event.clientX, event.clientY));\n\n    // const delta = this.startAdjustWidthLocation.distance(currentWorldLocation);\n    let change = 1;\n    if (currentWorldLocation.y > this.lastAdjustWidthLocation.y) {\n      change *= -1;\n    }\n    // let delta = this.lastAdjustWidthLocation.distance(currentWorldLocation);\n    // // 如果鼠标在往下走，就减小\n    // if (currentWorldLocation.y > this.lastAdjustWidthLocation.y) {\n    //   delta = -delta;\n    // }\n    const lastWidth = this.project.controller.penStrokeDrawing.currentStrokeWidth;\n    // this.project.effects.addEffect(LineEffect.default(this.startAdjustWidthLocation, worldLocation.clone()));\n    const newWidth = Math.round(lastWidth + change);\n\n    // 限制宽度范围\n    this.project.controller.penStrokeDrawing.currentStrokeWidth = Math.max(1, Math.min(newWidth, 1000));\n\n    // 记录上一次位置\n    this.lastAdjustWidthLocation = currentWorldLocation.clone();\n  };\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/controller/concrete/ControllerPenStrokeDrawing.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { ControllerClass } from \"@/core/service/controlService/controller/ControllerClass\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { PenStroke, PenStrokeSegment } from \"@/core/stage/stageObject/entity/PenStroke\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { isMac } from \"@/utils/platform\";\nimport { Color, mixColors, Vector } from \"@graphif/data-structures\";\nimport { toast } from \"sonner\";\n\n/**\n * 涂鸦功能\n */\nexport class ControllerPenStrokeDrawingClass extends ControllerClass {\n  private _isUsing: boolean = false;\n\n  /** 在移动的过程中，记录这一笔画的笔迹 */\n  public currentSegments: PenStrokeSegment[] = [];\n  /** 当前是否是在绘制直线 */\n  public isDrawingLine = false;\n\n  /**\n   * 初始化函数\n   */\n  constructor(protected readonly project: Project) {\n    super(project);\n  }\n\n  public mousedown = (event: PointerEvent) => {\n    if (event.button !== 0) {\n      return;\n    }\n    if (Settings.mouseLeftMode !== \"draw\" && event.pointerType !== \"pen\") {\n      return;\n    }\n    if (this.project.controller.camera.isPreGrabbingWhenSpace) {\n      return;\n    }\n    this._isUsing = true;\n\n    const pressWorldLocation = this.project.renderer.transformView2World(new Vector(event.clientX, event.clientY));\n    if (this.project.controller.pressingKeySet.has(\"shift\")) {\n      this.isDrawingLine = true;\n    }\n    this.lastMoveLocation = pressWorldLocation.clone();\n  };\n\n  public mousemove = (event: PointerEvent) => {\n    if (!this._isUsing) return;\n    if (!this.project.controller.isMouseDown[0] && Settings.mouseLeftMode === \"draw\") return;\n    if (this.project.controller.isMouseDown[0] && Settings.mouseLeftMode !== \"draw\") return;\n    const events = \"getCoalescedEvents\" in event ? event.getCoalescedEvents() : [event];\n    for (const e of events) {\n      const isPen = e.pointerType === \"pen\";\n      const worldLocation = this.project.renderer.transformView2World(new Vector(e.clientX, e.clientY));\n      this.currentSegments.push(new PenStrokeSegment(worldLocation, isPen ? e.pressure : 1));\n    }\n  };\n\n  public mouseup = (event: MouseEvent) => {\n    if (!this._isUsing) return;\n    if (!(event.button === 0 && Settings.mouseLeftMode === \"draw\")) {\n      return;\n    }\n    if (this.currentSegments.length <= 2) {\n      toast.warning(\"涂鸦太短了，触发点点儿上色功能\");\n      // 涂鸦太短，认为是点上色节点\n      const releaseWorldLocation = this.project.renderer.transformView2World(new Vector(event.clientX, event.clientY));\n      const entity = this.project.stageManager.findEntityByLocation(releaseWorldLocation);\n      if (entity) {\n        if (entity instanceof TextNode) {\n          if (this.project.controller.pressingKeySet.has(\"shift\")) {\n            const entityColor = entity.color.clone();\n            entity.color = mixColors(entityColor, this.getCurrentStrokeColor().clone(), 0.1);\n          } else {\n            entity.color = this.getCurrentStrokeColor().clone();\n          }\n        }\n      }\n      this.releaseMouseAndClear();\n      return;\n    }\n    // 正常的划过一段距离\n    // 生成笔触\n    if (this.project.controller.pressingKeySet.has(\"shift\")) {\n      // 直线\n      const from = this.currentSegments[0].location.clone();\n      const to = this.currentSegments[this.currentSegments.length - 1].location.clone();\n\n      if (this.project.controller.pressingKeySet.has(isMac ? \"meta\" : \"control\")) {\n        // 垂直于坐标轴的直线\n        const dy = Math.abs(to.y - from.y);\n        const dx = Math.abs(to.x - from.x);\n        if (dy > dx) {\n          // 垂直\n          to.x = from.x;\n        } else {\n          // 水平\n          to.y = from.y;\n        }\n      }\n      const startX = from.x;\n      const startY = from.y;\n      const endX = to.x;\n      const endY = to.y;\n\n      const stroke = new PenStroke(this.project, {\n        segments: [\n          new PenStrokeSegment(new Vector(startX, startY), 1),\n          new PenStrokeSegment(new Vector(endX, endY), 1),\n        ],\n        color: this.getCurrentStrokeColor(),\n      });\n      this.project.stageManager.add(stroke);\n    } else {\n      // 普通笔迹\n      const stroke = new PenStroke(this.project, {\n        segments: this.currentSegments,\n        color: this.getCurrentStrokeColor(),\n      });\n      this.project.stageManager.add(stroke);\n    }\n    this.project.historyManager.recordStep();\n\n    this.releaseMouseAndClear();\n  };\n\n  private releaseMouseAndClear() {\n    // 清理\n    this.currentSegments = [];\n    this._isUsing = false;\n    this.isDrawingLine = false;\n  }\n\n  public mousewheel: (event: WheelEvent) => void = (event: WheelEvent) => {\n    if (!this.project.controller.pressingKeySet.has(\"shift\")) {\n      return;\n    }\n    if (Settings.mouseLeftMode !== \"draw\") {\n      // 涂鸦模式下才能看到量角器，或者转动量角器\n      return;\n    }\n    if (event.deltaY > 0) {\n      this.project.drawingControllerRenderer.rotateUpAngle();\n    } else {\n      this.project.drawingControllerRenderer.rotateDownAngle();\n    }\n  };\n\n  public getCurrentStrokeColor() {\n    if (Settings.autoFillPenStrokeColorEnable) {\n      return new Color(...Settings.autoFillPenStrokeColor);\n    } else {\n      return Color.Transparent;\n    }\n  }\n\n  public changeCurrentStrokeColorAlpha(dAlpha: number) {\n    if (Settings.autoFillPenStrokeColorEnable) {\n      const newAlpha = Math.max(Math.min(new Color(...Settings.autoFillPenStrokeColor).a + dAlpha, 1), 0.01);\n      Settings.autoFillPenStrokeColor = new Color(...Settings.autoFillPenStrokeColor).toNewAlpha(newAlpha).toArray();\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/controller/concrete/ControllerRectangleSelect.tsx",
    "content": "import { Settings } from \"@/core/service/Settings\";\nimport { ControllerClass } from \"@/core/service/controlService/controller/ControllerClass\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\n\nexport class ControllerRectangleSelectClass extends ControllerClass {\n  private _isUsing: boolean = false;\n  /**\n   * 框选框\n   * 这里必须一开始为null，否则报错，can not asses \"Rectangle\"\n   * 这个框选框是基于世界坐标的。\n   * 此变量会根据两个点的位置自动更新。\n   */\n  public selectingRectangle: Rectangle | null = null;\n\n  public get isUsing() {\n    return this._isUsing;\n  }\n\n  public shutDown() {\n    this.project.rectangleSelect.shutDown();\n    this._isUsing = false;\n  }\n\n  public mouseMoveOutWindowForcedShutdown(mouseLocation: Vector) {\n    super.mouseMoveOutWindowForcedShutdown(mouseLocation);\n    this.shutDown();\n  }\n\n  public mousedown: (event: MouseEvent) => void = (event) => {\n    if (this.project.controller.pressingKeySet.has(\"alt\")) {\n      // layer moving mode\n      return;\n    }\n    if (Settings.mouseLeftMode !== \"selectAndMove\") {\n      return;\n    }\n    const button = event.button;\n    if (button !== 0) {\n      return;\n    }\n    if (this.project.controller.camera.isPreGrabbingWhenSpace) {\n      return;\n    }\n    const pressWorldLocation = this.project.renderer.transformView2World(new Vector(event.clientX, event.clientY));\n\n    if (this.project.controllerUtils.getClickedStageObject(pressWorldLocation) !== null) {\n      // 不是点击在空白处\n      return;\n    }\n    if (this.project.controllerUtils.isClickedResizeRect(pressWorldLocation)) {\n      return;\n    }\n\n    this._isUsing = true;\n\n    this.project.rectangleSelect.startSelecting(pressWorldLocation);\n\n    const clickedAssociation = this.project.stageManager.findAssociationByLocation(pressWorldLocation);\n    if (clickedAssociation !== null) {\n      // 在连线身上按下\n      this._isUsing = false;\n    }\n    this.project.controller.rectangleSelect.lastMoveLocation = pressWorldLocation.clone();\n  };\n\n  public mousemove: (event: MouseEvent) => void = (event) => {\n    if (Settings.mouseLeftMode !== \"selectAndMove\") {\n      return;\n    }\n    if (!this._isUsing) {\n      return;\n    }\n\n    if (!this.project.controller.isMouseDown[0]) {\n      return;\n    }\n    const worldLocation = this.project.renderer.transformView2World(new Vector(event.clientX, event.clientY));\n\n    this.project.rectangleSelect.moveSelecting(worldLocation);\n\n    this.project.controller.rectangleSelect.lastMoveLocation = worldLocation.clone();\n  };\n\n  /**\n   * 当前的框选框的方向\n   */\n  private isSelectDirectionRight = false;\n  // 获取此时此刻应该的框选逻辑\n  public getSelectMode(): \"contain\" | \"intersect\" {\n    if (this.isSelectDirectionRight) {\n      return Settings.rectangleSelectWhenLeft;\n    } else {\n      return Settings.rectangleSelectWhenLeft;\n    }\n  }\n\n  public mouseup = (event: MouseEvent) => {\n    if (event.button !== 0) {\n      return;\n    }\n    if (Settings.mouseLeftMode !== \"selectAndMove\") {\n      return;\n    }\n    // 左键松开\n    this._isUsing = false;\n\n    // 代替\n    this.project.rectangleSelect.endSelecting();\n  };\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/controller/concrete/ControllerSectionEdit.tsx",
    "content": "import { Dialog } from \"@/components/ui/dialog\";\nimport { Project } from \"@/core/Project\";\nimport { ControllerClass } from \"@/core/service/controlService/controller/ControllerClass\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { toast } from \"sonner\";\n\n/**\n * 包含编辑节点文字，编辑详细信息等功能的控制器\n *\n * 当有节点编辑时，会把摄像机锁定住\n */\nexport class ControllerSectionEditClass extends ControllerClass {\n  constructor(protected readonly project: Project) {\n    super(project);\n  }\n\n  mouseDoubleClick = (event: MouseEvent) => {\n    if (event.button !== 0) {\n      return;\n    }\n    if (this.project.controller.camera.isPreGrabbingWhenSpace) {\n      return;\n    }\n    const firstHoverSection = this.project.mouseInteraction.firstHoverSection;\n    if (!firstHoverSection) {\n      return;\n    }\n\n    // 编辑文字\n    this.project.controllerUtils.editSectionTitle(firstHoverSection);\n    return;\n  };\n\n  mousemove = (event: MouseEvent) => {\n    const worldLocation = this.project.renderer.transformView2World(new Vector(event.clientX, event.clientY));\n    this.project.mouseInteraction.updateByMouseMove(worldLocation);\n  };\n\n  keydown = (event: KeyboardEvent) => {\n    if (event.key === \"Enter\") {\n      const selectedSections = this.project.stageManager.getSections().filter((section) => section.isSelected);\n      if (selectedSections.length === 0) {\n        return;\n      }\n      // 检查是否有选中的section被锁定（包括祖先section的锁定状态）\n      const lockedSections = selectedSections.filter((section) =>\n        this.project.sectionMethods.isObjectBeLockedBySection(section),\n      );\n      if (lockedSections.length > 0) {\n        toast.error(\"无法编辑已锁定的section\");\n        return;\n      }\n      Dialog.input(\"重命名 Section\").then((value) => {\n        if (value) {\n          for (const section of selectedSections) {\n            section.rename(value);\n          }\n        }\n      });\n    }\n  };\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/controller/concrete/README.md",
    "content": "单一职责原则（Single Responsibility Principle, SRP）：\n每个控制器对象只负责一部分功能的实现，这符合单一职责原则，即一个类应该只有一个引起它变化的原因。这样可以使代码更加健壮，更容易理解和维护。\n\n模块化（Modularization）：\n将不同的功能划分为独立的控制器对象，这使得每个部分都可以单独开发、测试和维护。这种做法符合模块化的原则，提高了代码的可读性和可管理性。\n\n目前注册的监听函数可能过多了，如果有一种机制可以只注册函数体非空的监听函数，可能能提高一些性能，虽然现在性能没有明显下降。\n"
  },
  {
    "path": "app/src/core/service/controlService/controller/concrete/utilsControl.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { Renderer } from \"@/core/render/canvas2d/renderer\";\nimport { LogicNodeNameToRenderNameMap } from \"@/core/service/dataGenerateService/autoComputeEngine/logicNodeNameEnum\";\nimport { CrossFileContentQuery } from \"@/core/service/dataGenerateService/crossFileContentQuery\";\nimport Fuse from \"fuse.js\";\nimport { EntityCreateFlashEffect } from \"@/core/service/feedbackService/effectEngine/concrete/EntityCreateFlashEffect\";\nimport { SubWindow } from \"@/core/service/SubWindow\";\nimport { RecentFileManager } from \"@/core/service/dataFileService/RecentFileManager\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { StageObject } from \"@/core/stage/stageObject/abstract/StageObject\";\nimport { Edge } from \"@/core/stage/stageObject/association/Edge\";\nimport { LineEdge } from \"@/core/stage/stageObject/association/LineEdge\";\nimport { MultiTargetUndirectedEdge } from \"@/core/stage/stageObject/association/MutiTargetUndirectedEdge\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { UrlNode } from \"@/core/stage/stageObject/entity/UrlNode\";\nimport { ImageNode } from \"@/core/stage/stageObject/entity/ImageNode\";\nimport { SvgNode } from \"@/core/stage/stageObject/entity/SvgNode\";\nimport { ReferenceBlockNode } from \"@/core/stage/stageObject/entity/ReferenceBlockNode\";\nimport AutoCompleteWindow from \"@/sub/AutoCompleteWindow\";\nimport NodeDetailsWindow from \"@/sub/NodeDetailsWindow\";\nimport { Direction } from \"@/types/directions\";\nimport { isDesktop } from \"@/utils/platform\";\nimport { Color, colorInvert, Vector } from \"@graphif/data-structures\";\nimport { toast } from \"sonner\";\nimport { PathString } from \"@/utils/pathString\";\nimport { DateChecker } from \"@/utils/dateChecker\";\nimport { TextNodeSmartTools } from \"@/core/service/dataManageService/textNodeSmartTools\";\nimport { ReferenceManager } from \"@/core/stage/stageManager/concreteMethods/StageReferenceManager\";\nimport _ from \"lodash\";\nimport { Settings } from \"@/core/service/Settings\";\n\n/**\n * 这里是专门存放代码相同的地方\n *    因为有可能多个控制器公用同一个代码，\n */\n@service(\"controllerUtils\")\nexport class ControllerUtils {\n  private currentAutoCompleteWindowId: string | undefined;\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 编辑节点\n   * @param clickedNode\n   */\n  editTextNode(clickedNode: TextNode, selectAll = true) {\n    this.project.controller.isCameraLocked = true;\n    // 停止摄像机漂移\n    this.project.camera.stopImmediately();\n    const rectWorld = clickedNode.collisionBox.getRectangle();\n    const rectView = this.project.renderer.transformWorld2View(rectWorld);\n    const fontColor = (\n      clickedNode.color.a === 1\n        ? colorInvert(clickedNode.color)\n        : colorInvert(this.project.stageStyleManager.currentStyle.Background)\n    ).toHexStringWithoutAlpha();\n    // 编辑节点\n    clickedNode.isEditing = true;\n    // RectangleElement.div(rectView, this.project.stageStyleManager.currentStyle.CollideBoxSelected);\n    let lastAutoCompleteWindowId: string;\n    this.project.inputElement\n      .textarea(\n        clickedNode.text,\n        // \"\",\n        async (text, ele) => {\n          if (lastAutoCompleteWindowId) {\n            SubWindow.close(lastAutoCompleteWindowId);\n          }\n          // 自动补全逻辑\n          await this.handleAutoComplete(text, clickedNode, ele, (value) => {\n            lastAutoCompleteWindowId = value;\n          });\n          // onChange\n          clickedNode?.rename(text);\n          const rectWorld = clickedNode.collisionBox.getRectangle();\n          const rectView = this.project.renderer.transformWorld2View(rectWorld);\n          ele.style.height = \"auto\";\n          ele.style.height = `${rectView.height.toFixed(2) + 8}px`;\n          // 自动改变宽度\n          if (clickedNode.sizeAdjust === \"manual\") {\n            ele.style.width = \"auto\";\n            ele.style.width = `${rectView.width.toFixed(2) + 8}px`;\n          } else if (clickedNode.sizeAdjust === \"auto\") {\n            ele.style.width = \"100vw\";\n          }\n          // 自动调整它的外层框的大小\n          const fatherSections = this.project.sectionMethods.getFatherSectionsList(clickedNode);\n          for (const section of fatherSections) {\n            section.adjustLocationAndSize();\n          }\n\n          this.finishChangeTextNode(clickedNode);\n        },\n        {\n          position: \"fixed\",\n          resize: \"none\",\n          boxSizing: \"border-box\",\n          overflow: \"hidden\",\n          whiteSpace: \"pre-wrap\",\n          wordBreak: \"break-all\",\n          left: `${rectView.left.toFixed(2)}px`,\n          top: `${rectView.top.toFixed(2)}px`,\n          // ====\n          width: clickedNode.sizeAdjust === \"manual\" ? `${rectView.width.toFixed(2)}px` : \"100vw\",\n          // maxWidth: `${rectView.width.toFixed(2)}px`,\n          minWidth: `${rectView.width.toFixed(2)}px`,\n          minHeight: `${rectView.height.toFixed(2)}px`,\n          // height: `${rectView.height.toFixed(2)}px`,\n          padding: Renderer.NODE_PADDING * this.project.camera.currentScale + \"px\",\n          fontSize: clickedNode.getFontSize() * this.project.camera.currentScale + \"px\",\n          backgroundColor: \"transparent\",\n          color: fontColor,\n          outline: `solid ${1 * this.project.camera.currentScale}px ${this.project.stageStyleManager.currentStyle.effects.successShadow.toNewAlpha(0.1).toString()}`,\n          borderRadius: `${Renderer.NODE_ROUNDED_RADIUS * this.project.camera.currentScale}px`,\n        },\n        selectAll,\n        // rectWorld.width * this.project.camera.currentScale, // limit width\n      )\n      .then(async () => {\n        SubWindow.close(lastAutoCompleteWindowId);\n        clickedNode!.isEditing = false;\n        this.project.controller.isCameraLocked = false;\n        this.project.historyManager.recordStep();\n\n        // 实验\n        this.finishChangeTextNode(clickedNode);\n        await this.autoChangeTextNodeToReferenceBlock(this.project, clickedNode);\n        // 文本节点退出编辑模式后，检查是否需要自动格式化树形结构\n        if (Settings.textNodeAutoFormatTreeWhenExitEdit) {\n          // 格式化树形结构\n          this.project.keyboardOnlyTreeEngine.adjustTreeNode(clickedNode, false);\n        }\n      });\n  }\n\n  editEdgeText(clickedLineEdge: Edge, selectAll = true) {\n    this.project.controller.isCameraLocked = true;\n    // 停止摄像机漂移\n    this.project.camera.stopImmediately();\n\n    // clickedLineEdge.isEditing = true;\n    const textAreaLocation = this.project.renderer\n      .transformWorld2View(clickedLineEdge.textRectangle.location)\n      .add(Vector.same(Renderer.NODE_PADDING).multiply(this.project.camera.currentScale));\n    this.project.inputElement\n      .textarea(\n        clickedLineEdge.text,\n        (text) => {\n          clickedLineEdge?.rename(text);\n        },\n        {\n          position: \"fixed\",\n          resize: \"none\",\n          boxSizing: \"border-box\",\n          overflow: \"hidden\",\n          whiteSpace: \"pre-wrap\",\n          wordBreak: \"break-all\",\n          left: `${textAreaLocation.x.toFixed(2)}px`,\n          top: `${textAreaLocation.y.toFixed(2)}px`,\n          fontSize: Renderer.FONT_SIZE * this.project.camera.currentScale + \"px\",\n          backgroundColor: this.project.stageStyleManager.currentStyle.Background.toString(),\n          color: this.project.stageStyleManager.currentStyle.StageObjectBorder.toString(),\n          outline: \"solid 1px rgba(255,255,255,0.1)\",\n          // marginTop: -8 * this.project.camera.currentScale + \"px\",\n        },\n        selectAll,\n      )\n      .then(() => {\n        // clickedLineEdge!.isEditing = false;\n        // 因为这里用的是不透明文本框，所以不需要停止节点上文字的渲染\n        this.project.controller.isCameraLocked = false;\n        this.project.historyManager.recordStep();\n      });\n  }\n  editMultiTargetEdgeText(clickedEdge: MultiTargetUndirectedEdge, selectAll = true) {\n    this.project.controller.isCameraLocked = true;\n    // 停止摄像机漂移\n    this.project.camera.stopImmediately();\n\n    // clickedLineEdge.isEditing = true;\n    const textAreaLocation = this.project.renderer\n      .transformWorld2View(clickedEdge.textRectangle.location)\n      .add(Vector.same(Renderer.NODE_PADDING).multiply(this.project.camera.currentScale));\n    this.project.inputElement\n      .textarea(\n        clickedEdge.text,\n        (text) => {\n          clickedEdge?.rename(text);\n        },\n        {\n          position: \"fixed\",\n          resize: \"none\",\n          boxSizing: \"border-box\",\n          overflow: \"hidden\",\n          whiteSpace: \"pre-wrap\",\n          wordBreak: \"break-all\",\n          left: `${textAreaLocation.x.toFixed(2)}px`,\n          top: `${textAreaLocation.y.toFixed(2)}px`,\n          fontSize: Renderer.FONT_SIZE * this.project.camera.currentScale + \"px\",\n          backgroundColor: this.project.stageStyleManager.currentStyle.Background.toString(),\n          color: this.project.stageStyleManager.currentStyle.StageObjectBorder.toString(),\n          outline: \"solid 1px rgba(255,255,255,0.1)\",\n          // marginTop: -8 * this.project.camera.currentScale + \"px\",\n        },\n        selectAll,\n      )\n      .then(() => {\n        // clickedLineEdge!.isEditing = false;\n        // 因为这里用的是不透明文本框，所以不需要停止节点上文字的渲染\n        this.project.controller.isCameraLocked = false;\n        this.project.historyManager.recordStep();\n      });\n  }\n\n  editUrlNodeTitle(clickedUrlNode: UrlNode) {\n    this.project.controller.isCameraLocked = true;\n    // 停止摄像机漂移\n    this.project.camera.stopImmediately();\n    // 编辑节点\n    clickedUrlNode.isEditingTitle = true;\n    this.project.inputElement\n      .input(\n        this.project.renderer\n          .transformWorld2View(clickedUrlNode.rectangle.location)\n          .add(Vector.same(Renderer.NODE_PADDING).multiply(this.project.camera.currentScale)),\n        clickedUrlNode.title,\n        (text) => {\n          clickedUrlNode?.rename(text);\n        },\n        {\n          fontSize: Renderer.FONT_SIZE * this.project.camera.currentScale + \"px\",\n          backgroundColor: \"transparent\",\n          color: this.project.stageStyleManager.currentStyle.StageObjectBorder.toString(),\n          outline: \"none\",\n          marginTop: -8 * this.project.camera.currentScale + \"px\",\n          width: \"100vw\",\n        },\n      )\n      .then(() => {\n        clickedUrlNode!.isEditingTitle = false;\n        this.project.controller.isCameraLocked = false;\n        this.project.historyManager.recordStep();\n      });\n  }\n\n  editSectionTitle(section: Section) {\n    // 检查section是否被锁定（包括祖先section的锁定状态）\n    if (this.project.sectionMethods.isObjectBeLockedBySection(section)) {\n      toast.error(\"无法编辑已锁定的section\");\n      return;\n    }\n    this.project.controller.isCameraLocked = true;\n    // 停止摄像机漂移\n    this.project.camera.stopImmediately();\n    // 编辑节点\n    section.isEditingTitle = true;\n    this.project.inputElement\n      .input(\n        this.project.renderer\n          .transformWorld2View(section.rectangle.location)\n          .add(Vector.same(Renderer.NODE_PADDING).multiply(this.project.camera.currentScale)),\n        section.text,\n        (text) => {\n          section.rename(text);\n        },\n        {\n          position: \"fixed\",\n          resize: \"none\",\n          boxSizing: \"border-box\",\n          fontSize: Renderer.FONT_SIZE * this.project.camera.currentScale + \"px\",\n          backgroundColor: \"transparent\",\n          color: this.project.stageStyleManager.currentStyle.StageObjectBorder.toString(),\n          outline: `solid ${2 * this.project.camera.currentScale}px ${this.project.stageStyleManager.currentStyle.effects.successShadow.toNewAlpha(0.25).toString()}`,\n          marginTop: -8 * this.project.camera.currentScale + \"px\",\n        },\n      )\n      .then(() => {\n        section.isEditingTitle = false;\n        this.project.controller.isCameraLocked = false;\n        this.project.historyManager.recordStep();\n      });\n  }\n\n  /**\n   * 通过快捷键的方式来打开Entity的详细信息编辑\n   */\n  editNodeDetailsByKeyboard() {\n    const nodes = this.project.stageManager.getEntities().filter((node) => node.isSelected);\n    if (nodes.length === 0) {\n      toast.error(\"请先选择一个节点，才能编辑详细信息\");\n      return;\n    }\n    this.editNodeDetails(nodes[0]);\n  }\n\n  editNodeDetails(clickedNode: Entity) {\n    // this.project.controller.isCameraLocked = true;\n    // 编辑节点详细信息的视野移动锁定解除，——用户：快深频\n    console.log();\n    NodeDetailsWindow.open(clickedNode.details, (value) => {\n      clickedNode.details = value;\n    });\n  }\n\n  async addTextNodeByLocation(location: Vector, selectCurrent: boolean = false, autoEdit: boolean = false) {\n    const sections = this.project.sectionMethods.getSectionsByInnerLocation(location);\n    // 新建节点\n    const uuid = await this.project.nodeAdder.addTextNodeByClick(location, sections, selectCurrent);\n    if (autoEdit) {\n      // 自动进入编辑模式\n      this.textNodeInEditModeByUUID(uuid);\n    }\n    return uuid;\n  }\n  createConnectPoint(location: Vector) {\n    const sections = this.project.sectionMethods.getSectionsByInnerLocation(location);\n    this.project.nodeAdder.addConnectPoint(location, sections);\n  }\n\n  addTextNodeFromCurrentSelectedNode(direction: Direction, selectCurrent = false) {\n    this.project.nodeAdder.addTextNodeFromCurrentSelectedNode(direction, [], selectCurrent).then((uuid) => {\n      setTimeout(() => {\n        this.textNodeInEditModeByUUID(uuid);\n      });\n    });\n  }\n\n  textNodeInEditModeByUUID(uuid: string) {\n    const createNode = this.project.stageManager.getTextNodeByUUID(uuid);\n    if (createNode === null) {\n      // 说明 创建了立刻删掉了\n      return;\n    }\n    // 整特效\n    this.project.effects.addEffect(EntityCreateFlashEffect.fromCreateEntity(createNode));\n    if (isDesktop) {\n      this.editTextNode(createNode);\n    }\n  }\n\n  /**\n   * 检测鼠标是否点击到了某个stage对象上\n   * @param clickedLocation\n   */\n  getClickedStageObject(clickedLocation: Vector) {\n    let clickedStageObject: StageObject | null = this.project.stageManager.findEntityByLocation(clickedLocation);\n    // 补充：在宏观视野下，框应该被很轻松的点击\n    if (clickedStageObject === null && this.project.camera.currentScale < Section.bigTitleCameraScale) {\n      const clickedSections = this.project.sectionMethods.getSectionsByInnerLocation(clickedLocation);\n      if (clickedSections.length > 0) {\n        clickedStageObject = clickedSections[0];\n      }\n    }\n    if (clickedStageObject === null) {\n      for (const association of this.project.stageManager.getAssociations()) {\n        if (association instanceof LineEdge) {\n          if (association.target.isHiddenBySectionCollapse && association.source.isHiddenBySectionCollapse) {\n            continue;\n          }\n        }\n        if (association.collisionBox.isContainsPoint(clickedLocation)) {\n          clickedStageObject = association;\n          break;\n        }\n      }\n    }\n    return clickedStageObject;\n  }\n\n  /**\n   * 鼠标是否点击在了调整大小的小框上\n   * @param clickedLocation\n   */\n  isClickedResizeRect(clickedLocation: Vector): boolean {\n    const selectedEntities = this.project.stageManager.getSelectedStageObjects();\n\n    for (const selectedEntity of selectedEntities) {\n      // 检查是否是支持缩放的实体类型\n      if (\n        selectedEntity instanceof TextNode ||\n        selectedEntity instanceof ImageNode ||\n        selectedEntity instanceof SvgNode ||\n        selectedEntity instanceof ReferenceBlockNode\n      ) {\n        // 对TextNode进行特殊处理，只在手动模式下允许缩放\n        if (selectedEntity instanceof TextNode && selectedEntity.sizeAdjust === \"auto\") {\n          continue;\n        }\n\n        const resizeRect = selectedEntity.getResizeHandleRect();\n        if (resizeRect.isPointIn(clickedLocation)) {\n          // 点中了扩大缩小的东西\n          return true;\n        }\n      }\n    }\n    return false;\n  }\n\n  /**\n   * 将选中的内容标准化，如果选中了外层的section，也选中了内层的物体，则取消选中内部的物体\n   */\n  public selectedEntityNormalizing() {\n    const selectedEntities = this.project.stageManager.getSelectedEntities();\n    const shallowerSections = this.project.sectionMethods.shallowerSection(\n      selectedEntities.filter((entity) => entity instanceof Section),\n    );\n    const shallowerEntities = this.project.sectionMethods.shallowerNotSectionEntities(selectedEntities);\n    for (const entity of selectedEntities) {\n      if (entity instanceof Section) {\n        if (!shallowerSections.includes(entity)) {\n          entity.isSelected = false;\n        }\n      } else {\n        if (!shallowerEntities.includes(entity)) {\n          entity.isSelected = false;\n        }\n      }\n    }\n  }\n\n  /**\n   * 处理自动补全逻辑\n   * @param text 当前输入的文本\n   * @param node 当前编辑的文本节点\n   * @param ele 输入框元素\n   * @param setWindowId 设置自动补全窗口ID的回调函数\n   */\n  private async handleAutoComplete(\n    text: string,\n    node: TextNode,\n    ele: HTMLTextAreaElement,\n    setWindowId: (id: string) => void,\n  ) {\n    // 处理#开头的逻辑节点补全\n    if (text.startsWith(\"#\")) {\n      this.handleAutoCompleteLogic(text, node, ele, setWindowId);\n      // 处理[[格式的补全\n    } else if (text.startsWith(\"[[\")) {\n      this.handleAutoCompleteReferenceDebounced(text, node, ele, setWindowId);\n    }\n  }\n  private handleAutoCompleteReferenceDebounced = _.debounce(\n    (text: string, node: TextNode, ele: HTMLTextAreaElement, setWindowId: (id: string) => void) => {\n      this.handleAutoCompleteReference(text, node, ele, setWindowId);\n      console.log(\"ref匹配执行了\");\n    },\n    500,\n  );\n\n  private handleAutoCompleteLogic(\n    text: string,\n    node: TextNode,\n    ele: HTMLTextAreaElement,\n    setWindowId: (id: string) => void,\n  ) {\n    // 提取搜索文本，去掉所有#\n    const searchText = text.replaceAll(\"#\", \"\").toLowerCase();\n\n    const logicNodeEntries = Object.entries(LogicNodeNameToRenderNameMap).map(([key, renderName]) => ({\n      key,\n      name: key.replaceAll(\"#\", \"\").toLowerCase(),\n      renderName,\n    }));\n\n    const fuse = new Fuse(logicNodeEntries, {\n      keys: [\"name\"],\n      threshold: 0.3, // (0 = exact, 1 = very fuzzy)\n    });\n\n    const searchResults = fuse.search(searchText);\n    const matchingNodes = searchResults.map((result) => [result.item.key, result.item.renderName]);\n\n    // 打开自动补全窗口\n    if (this.currentAutoCompleteWindowId) {\n      SubWindow.close(this.currentAutoCompleteWindowId);\n    }\n    if (matchingNodes.length > 0) {\n      const windowId = AutoCompleteWindow.open(\n        this.project.renderer.transformWorld2View(node.rectangle).leftBottom,\n        Object.fromEntries(matchingNodes),\n        (value) => {\n          ele.value = value;\n        },\n      ).id;\n      this.currentAutoCompleteWindowId = windowId;\n      setWindowId(windowId);\n    } else {\n      const windowId = AutoCompleteWindow.open(\n        this.project.renderer.transformWorld2View(node.rectangle).leftBottom,\n        {\n          tip:\n            searchText === \"\" ? \"暂无匹配的逻辑节点名称，请输入全大写字母\" : `暂无匹配的逻辑节点名称【${searchText}】`,\n        },\n        (value) => {\n          ele.value = value;\n        },\n      ).id;\n      this.currentAutoCompleteWindowId = windowId;\n      setWindowId(windowId);\n    }\n  }\n\n  private async handleAutoCompleteReference(\n    text: string,\n    node: TextNode,\n    ele: HTMLTextAreaElement,\n    setWindowId: (id: string) => void,\n  ) {\n    // 提取搜索文本，去掉开头的[[\n    const searchText = text.slice(2).toLowerCase().replace(\"]]\", \"\");\n    // 检查是否包含#\n    const hasHash = searchText.includes(\"#\");\n\n    if (!hasHash) {\n      // 获取最近文件列表\n      const recentFiles = await RecentFileManager.getRecentFiles();\n\n      // 处理最近文件列表，提取文件名\n      const fileEntries = recentFiles.map((file) => {\n        // 提取文件名（不含扩展名）\n        const fileName = PathString.getFileNameFromPath(file.uri.path);\n        return { name: fileName, time: file.time }; // 使用对象格式以便Fuse.js搜索\n      });\n\n      const fuse = new Fuse(fileEntries, {\n        keys: [\"name\"], // 搜索name属性\n        threshold: 0.3,\n      });\n\n      const searchResults = fuse.search(searchText);\n      const matchingFiles = searchResults.map((result) => [\n        result.item.name,\n        DateChecker.formatRelativeTime(result.item.time),\n      ]); // 转换为相对时间格式\n\n      // 打开自动补全窗口\n      if (this.currentAutoCompleteWindowId) {\n        SubWindow.close(this.currentAutoCompleteWindowId);\n      }\n      if (matchingFiles.length > 0) {\n        const windowId = AutoCompleteWindow.open(\n          this.project.renderer.transformWorld2View(node.rectangle).leftBottom,\n          Object.fromEntries(matchingFiles),\n          (value) => {\n            // 用户选择后，需要保留[[前缀并添加选择的文件名\n            ele.value = `[[${value}`;\n          },\n        ).id;\n        this.currentAutoCompleteWindowId = windowId;\n        setWindowId(windowId);\n      } else {\n        const windowId = AutoCompleteWindow.open(\n          this.project.renderer.transformWorld2View(node.rectangle).leftBottom,\n          {\n            tip: searchText === \"\" ? \"暂无最近文件\" : `暂无匹配的最近文件【${searchText}】`,\n          },\n          (value) => {\n            ele.value = `[[${value}`;\n          },\n        ).id;\n        this.currentAutoCompleteWindowId = windowId;\n        setWindowId(windowId);\n      }\n    } else {\n      // 包含#，拆分文件名和section名称\n      const [fileName, sectionName] = searchText.split(\"#\", 2);\n\n      // 获取该文件中的所有section\n      const sections = await CrossFileContentQuery.getSectionsByFileName(fileName);\n\n      // 将section名称转换为对象数组，以便Fuse.js搜索\n      const sectionObjects = sections.map((section) => ({ name: section }));\n      let searchResults;\n\n      // 当section名称为空时，显示所有section（最多20个）\n      if (!sectionName?.trim()) {\n        // 取前20个section\n        searchResults = sectionObjects.slice(0, 20).map((item) => ({ item }));\n      } else {\n        // 创建Fuse搜索器，对section名称进行模糊匹配\n        const fuse = new Fuse(sectionObjects, { keys: [\"name\"], threshold: 0.3 });\n        searchResults = fuse.search(sectionName);\n      }\n\n      const matchingSections = searchResults.map((result) => [result.item.name, \"\"]);\n\n      // 打开自动补全窗口\n      if (this.currentAutoCompleteWindowId) {\n        SubWindow.close(this.currentAutoCompleteWindowId);\n      }\n      if (matchingSections.length > 0) {\n        const windowId = AutoCompleteWindow.open(\n          this.project.renderer.transformWorld2View(node.rectangle).leftBottom,\n          Object.fromEntries(matchingSections),\n          (value) => {\n            // 用户选择后，需要保留[[前缀、文件名和#，并添加选择的section名称\n            ele.value = `[[${fileName}#${value}`;\n          },\n        ).id;\n        this.currentAutoCompleteWindowId = windowId;\n        setWindowId(windowId);\n      } else {\n        const windowId = AutoCompleteWindow.open(\n          this.project.renderer.transformWorld2View(node.rectangle).leftBottom,\n          {\n            tip: sectionName === \"\" ? `这个文件中没有section，无法创建引用` : `暂无匹配的section【${sectionName}】`,\n          },\n          (value) => {\n            ele.value = `[[${fileName}#${value}`;\n          },\n        ).id;\n        this.currentAutoCompleteWindowId = windowId;\n        setWindowId(windowId);\n      }\n    }\n  }\n\n  // 完成编辑节点的操作\n  public finishChangeTextNode(textNode: TextNode) {\n    this.syncChangeTextNode(textNode);\n  }\n\n  private async autoChangeTextNodeToReferenceBlock(project: Project, textNode: TextNode) {\n    if (textNode.text.startsWith(\"[[\") && textNode.text.endsWith(\"]]\")) {\n      textNode.isSelected = true;\n      // 要加一个前置判断，防止用户输入本来就没有的东西\n\n      const recentFiles = await RecentFileManager.getRecentFiles();\n      const parserResult = ReferenceManager.referenceBlockTextParser(textNode.text);\n      if (!parserResult.isValid) {\n        toast.error(parserResult.invalidReason);\n        return;\n      }\n      if (!recentFiles.map((item) => PathString.getFileNameFromPath(item.uri.fsPath)).includes(parserResult.fileName)) {\n        toast.error(`文件【${parserResult.fileName}】不在“最近打开的文件”中，不能创建引用`);\n        return;\n      }\n      if (parserResult.sectionName) {\n        // 用户输入了#，需要检查section是否存在\n        const sections = await CrossFileContentQuery.getSectionsByFileName(parserResult.fileName);\n        if (!sections.includes(parserResult.sectionName)) {\n          toast.error(`文件【${parserResult.fileName}】中没有section【${parserResult.sectionName}】，不能创建引用`);\n          return;\n        }\n      }\n      await TextNodeSmartTools.changeTextNodeToReferenceBlock(project);\n    }\n  }\n\n  // 同步更改孪生节点\n  private syncChangeTextNode(textNode: TextNode) {\n    // 查找所有无向边，如果无向边的颜色 = (11, 45, 14, 0)，那么就找到了一个关联\n\n    const otherUUID: Set<string> = new Set();\n\n    // 直接和这个节点相连的所有超边\n    this.project.stageManager\n      .getAssociations()\n      .filter((association) => association instanceof MultiTargetUndirectedEdge)\n      .filter((association) => association.color.equals(new Color(11, 45, 14, 0)))\n      .filter((association) => association.associationList.includes(textNode))\n      .forEach((association) => {\n        association.associationList.forEach((node) => {\n          if (node instanceof TextNode) {\n            otherUUID.add(node.uuid);\n          }\n        });\n      });\n\n    otherUUID.forEach((uuid) => {\n      const node = this.project.stageManager.getTextNodeByUUID(uuid);\n      if (node) {\n        // node.text = textNode.text;\n        node.rename(textNode.text);\n        node.color = textNode.color;\n      }\n    });\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/keyboardOnlyEngine/README.md",
    "content": "# 说明\n\n此文件夹专门处理各种纯键盘操作的功能。\n\n此纯键盘操作的最终理想是设计一套不需要鼠标就能完成大部分操作的方案。像xmind那样\n\n## 方案设计\n\n### 实体选择\n\n上下左右方向键移动当前选中的唯一实体\n\n如果当前没有选中实体，则按下任意方向键会寻找距离屏幕中心准星直线距离最近的实体，并选中该实体\n\n这里的实体必须是ConnectableEntity\n\n如果当前选中了多个实体，则先取消选择所有实体，再重复上述流程。\n\n### 生长TextNode\n\n按下Tab键不松开，会有一个虚拟位置出现，再松开Tab键，实体会生长到该位置。\n\n如果虚拟生长位置在一个节点上，则会生成连线。\n\n如果虚拟生长位置在一个空白位置，则会生成一个新的节点。\n"
  },
  {
    "path": "app/src/core/service/controlService/keyboardOnlyEngine/keyboardOnlyDirectionController.tsx",
    "content": "import { Vector } from \"@graphif/data-structures\";\nimport { Direction } from \"@/types/directions\";\nimport { DirectionKeyUtilsEngine } from \"@/core/service/controlService/DirectionKeyUtilsEngine/directionKeyUtilsEngine\";\n\n/**\n * 纯键盘控制引擎内部的 生成节点位置的方向控制内核\n */\nexport class KeyboardOnlyDirectionController extends DirectionKeyUtilsEngine {\n  protected reset(): void {\n    console.warn(\"重启位置\");\n  }\n\n  public clearSpeedAndAcc(): void {\n    this.speed = Vector.getZero();\n    this.accelerate = Vector.getZero();\n    this.accelerateCommander = Vector.getZero();\n  }\n\n  override init(): void {\n    window.addEventListener(\"keydown\", (event) => {\n      if (event.key === \"i\") {\n        this.keyPress(Direction.Up);\n      } else if (event.key === \"k\") {\n        this.keyPress(Direction.Down);\n      } else if (event.key === \"j\") {\n        this.keyPress(Direction.Left);\n      } else if (event.key === \"l\") {\n        this.keyPress(Direction.Right);\n      }\n    });\n\n    window.addEventListener(\"keyup\", (event) => {\n      if (event.key === \"i\") {\n        this.keyRelease(Direction.Up);\n      } else if (event.key === \"k\") {\n        this.keyRelease(Direction.Down);\n      } else if (event.key === \"j\") {\n        this.keyRelease(Direction.Left);\n      } else if (event.key === \"l\") {\n        this.keyRelease(Direction.Right);\n      }\n    });\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/keyboardOnlyEngine/keyboardOnlyEngine.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { EntityDashTipEffect } from \"@/core/service/feedbackService/effectEngine/concrete/EntityDashTipEffect\";\nimport { EntityShakeEffect } from \"@/core/service/feedbackService/effectEngine/concrete/EntityShakeEffect\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { getEnterKey } from \"@/utils/keyboardFunctions\";\nimport { toast } from \"sonner\";\n\n/**\n * 纯键盘控制的相关引擎\n */\n@service(\"keyboardOnlyEngine\")\nexport class KeyboardOnlyEngine {\n  constructor(private readonly project: Project) {\n    this.project.canvas.element.addEventListener(\"keydown\", this.onKeyDown);\n    this.project.canvas.element.addEventListener(\"keyup\", this.onKeyUp);\n  }\n\n  /**\n   * 只有在某些面板打开的时候，这个引擎才会禁用，防止误触\n   */\n  private openning = true;\n  setOpenning(value: boolean) {\n    this.openning = value;\n  }\n  isOpenning() {\n    return this.openning;\n  }\n\n  public dispose() {\n    // 销毁服务\n    this.project.canvas.element.removeEventListener(\"keydown\", this.onKeyDown);\n    this.project.canvas.element.removeEventListener(\"keyup\", this.onKeyUp);\n  }\n\n  private startEditNode = (event: KeyboardEvent, selectedNode: TextNode) => {\n    event.preventDefault(); // 这个prevent必须开启，否则会立刻在刚创建的输入框里输入一个换行符。\n    this.addSuccessEffect();\n    // 编辑节点\n    setTimeout(() => {\n      this.project.controllerUtils.editTextNode(selectedNode, Settings.textNodeSelectAllWhenStartEditByKeyboard);\n    }, 1); // 上面的prevent似乎不生效了，但这里加个1毫秒就能解决了\n  };\n\n  private onKeyUp = (event: KeyboardEvent) => {\n    // 把空格键进入节点编辑状态的时机绑定到keyup上，这样就巧妙的解决了退出编辑状态后左键框选和点击失灵的问题。\n    if (event.key === \" \") {\n      if (Settings.textNodeStartEditMode === \"space\") {\n        // 用户设置了空格键进入节点编辑状态（3群用户：神奈川）\n        const selectedNode = this.project.stageManager.getTextNodes().find((node) => node.isSelected);\n        if (!selectedNode) return;\n        if (this.project.controller.isMouseDown[0]) {\n          // 不要在可能拖动节点的情况下按空格\n          toast.warning(\"请不要在拖动节点的过程中按空格\");\n          return;\n        }\n        this.startEditNode(event, selectedNode);\n      }\n    }\n  };\n\n  private onKeyDown = (event: KeyboardEvent) => {\n    if (event.key === \"Enter\") {\n      const enterKeyDetail = getEnterKey(event);\n      if (Settings.textNodeStartEditMode === enterKeyDetail) {\n        // 这个还必须在down的位置上，因为在up上会导致无限触发\n        const selectedNode = this.project.stageManager.getTextNodes().find((node) => node.isSelected);\n        if (!selectedNode) return;\n        this.startEditNode(event, selectedNode);\n      } else {\n        // 用户可能记错了快捷键\n        this.addFailEffect();\n      }\n    } else if (event.key === \"Escape\") {\n      // 取消全部选择\n      for (const stageObject of this.project.stageManager.getStageObjects()) {\n        stageObject.isSelected = false;\n      }\n    } else if (event.key === \"F2\") {\n      const selectedNode = this.project.stageManager.getTextNodes().find((node) => node.isSelected);\n      if (!selectedNode) return;\n      // 编辑节点\n      this.project.controllerUtils.editTextNode(selectedNode);\n    } else {\n      // SelectChangeEngine.listenKeyDown(event);\n    }\n  };\n\n  private addSuccessEffect() {\n    const textNodes = this.project.stageManager.getTextNodes().filter((textNode) => textNode.isSelected);\n    for (const textNode of textNodes) {\n      this.project.effects.addEffect(new EntityDashTipEffect(50, textNode.collisionBox.getRectangle()));\n    }\n  }\n\n  private addFailEffect() {\n    const textNodes = this.project.stageManager.getTextNodes().filter((textNode) => textNode.isSelected);\n    for (const textNode of textNodes) {\n      this.project.effects.addEffect(EntityShakeEffect.fromEntity(textNode));\n    }\n    // 这里就不显示提示文字了。因为用户“快深频”说总是误弹出。\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/keyboardOnlyEngine/keyboardOnlyGraphEngine.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { KeyboardOnlyDirectionController } from \"@/core/service/controlService/keyboardOnlyEngine/keyboardOnlyDirectionController\";\nimport { NewTargetLocationSelector } from \"@/core/service/controlService/keyboardOnlyEngine/newTargetLocationSelector\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { toast } from \"sonner\";\n\n/**\n * 纯键盘创建图论型的引擎\n */\n@service(\"keyboardOnlyGraphEngine\")\nexport class KeyboardOnlyGraphEngine {\n  /**\n   * 虚拟目标位置控制器\n   */\n  private targetLocationController = new KeyboardOnlyDirectionController();\n\n  virtualTargetLocation(): Vector {\n    return this.targetLocationController.location;\n  }\n\n  tick() {\n    this.targetLocationController.logicTick();\n  }\n\n  constructor(private readonly project: Project) {\n    this.targetLocationController.init();\n  }\n  /**\n   * 是否达到了按下Tab键的前置条件\n   */\n  isEnableVirtualCreate(): boolean {\n    // 确保只有一个节点被选中\n    const selectConnectableEntities = this.project.stageManager\n      .getConnectableEntity()\n      .filter((node) => node.isSelected);\n    if (selectConnectableEntities.length !== 1) {\n      return false;\n    }\n    return true;\n  }\n\n  private _isCreating = false;\n  /**\n   * 当前是否是按下Tab键不松开的情况\n   * @returns\n   */\n  isCreating(): boolean {\n    return this._isCreating;\n  }\n\n  /**\n   * 按下Tab键开始创建\n   * @returns\n   */\n  createStart(): void {\n    if (!this.project.keyboardOnlyEngine.isOpenning()) {\n      return;\n    }\n    if (this.isCreating()) {\n      // 已经在创建状态，不要重复创建\n      return;\n    }\n    this._isCreating = true;\n    // 记录上一次按下Tab键的时间\n    this.lastPressTabTime = Date.now();\n    // 计算并更新虚拟目标位置\n    const selectConnectableEntities = this.project.stageManager\n      .getConnectableEntity()\n      .filter((node) => node.isSelected);\n\n    // 如果只有一个节点被选中，则生成到右边的位置\n    if (selectConnectableEntities.length === 1) {\n      // 更新方向控制器的位置\n      this.targetLocationController.resetLocation(\n        selectConnectableEntities[0].collisionBox.getRectangle().center.add(NewTargetLocationSelector.diffLocation),\n      );\n      // 清空加速度和速度\n      this.targetLocationController.clearSpeedAndAcc();\n      // 最后更新虚拟目标位置\n      NewTargetLocationSelector.onTabDown(selectConnectableEntities[0]);\n    }\n  }\n  private lastPressTabTime = 0;\n\n  /**\n   * 返回按下Tab键的时间完成率，0-1之间，0表示刚刚按下Tab键，1表示已经达到可以松开Tab键的状态\n   * @returns\n   */\n  getPressTabTimeInterval(): number {\n    // 计算距离上次按下Tab键的时间间隔\n    const now = Date.now();\n    const interval = now - this.lastPressTabTime;\n    return interval;\n  }\n\n  async createFinished() {\n    this._isCreating = false;\n    if (this.getPressTabTimeInterval() < 100) {\n      toast.error(\"节点生长快捷键松开过快\");\n      return;\n    }\n\n    // 获取当前选择的所有节点\n    const selectConnectableEntities = this.project.stageManager\n      .getConnectableEntity()\n      .filter((node) => node.isSelected);\n    if (this.isTargetLocationHaveEntity()) {\n      // 连接到之前的节点\n      const entity = this.project.stageManager.findEntityByLocation(this.virtualTargetLocation());\n      if (entity && entity instanceof ConnectableEntity) {\n        // 连接到之前的节点\n        for (const selectedEntity of selectConnectableEntities) {\n          this.project.stageManager.connectEntity(selectedEntity, entity);\n          this.project.effects.addEffects(this.project.edgeRenderer.getConnectedEffects(selectedEntity, entity));\n        }\n        // 选择到新创建的节点\n        entity.isSelected = true;\n        // 取消选择之前的节点\n        for (const selectedEntity of selectConnectableEntities) {\n          selectedEntity.isSelected = false;\n        }\n        // 鹿松狸 ：不要移动视野更好\n        // 视野移动到新创建的节点\n        // Camera.location = virtualTargetLocation().clone();\n      }\n    } else {\n      // 更新diffLocation\n      NewTargetLocationSelector.onTabUp(selectConnectableEntities[0], this.virtualTargetLocation());\n      // 创建一个新的节点\n      const newNodeUUID = await this.project.nodeAdder.addTextNodeByClick(this.virtualTargetLocation().clone(), []);\n      const newNode = this.project.stageManager.getTextNodeByUUID(newNodeUUID);\n      if (!newNode) return;\n      // 连接到之前的节点\n      for (const entity of selectConnectableEntities) {\n        this.project.stageManager.connectEntity(entity, newNode);\n        this.project.effects.addEffects(this.project.edgeRenderer.getConnectedEffects(entity, newNode));\n      }\n      // 选择到新创建的节点\n      newNode.isSelected = true;\n      // 取消选择之前的节点\n      for (const entity of selectConnectableEntities) {\n        entity.isSelected = false;\n      }\n      // 视野移动到新创建的节点\n      // Camera.location = virtualTargetLocation().clone();\n      this.project.controllerUtils.editTextNode(newNode);\n    }\n  }\n\n  moveVirtualTarget(delta: Vector): void {\n    this.targetLocationController.resetLocation(this.virtualTargetLocation().add(delta));\n  }\n\n  /**\n   * 取消创建\n   */\n  createCancel(): void {\n    // do nothing\n    this._isCreating = false;\n  }\n\n  /**\n   * 是否有实体在虚拟目标位置\n   * @returns\n   */\n  isTargetLocationHaveEntity(): boolean {\n    const entities = this.project.stageManager.getConnectableEntity();\n    for (const entity of entities) {\n      if (entity.collisionBox.isContainsPoint(this.virtualTargetLocation())) {\n        return true;\n      }\n    }\n    return false;\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/keyboardOnlyEngine/keyboardOnlyTreeEngine.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { Edge } from \"@/core/stage/stageObject/association/Edge\";\nimport { toast } from \"sonner\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { Direction } from \"@/types/directions\";\nimport { ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { v4 } from \"uuid\";\nimport { LineEffect } from \"../../feedbackService/effectEngine/concrete/LineEffect\";\nimport { RectangleRenderEffect } from \"../../feedbackService/effectEngine/concrete/RectangleRenderEffect\";\nimport { SoundService } from \"../../feedbackService/SoundService\";\nimport { Settings } from \"../../Settings\";\n\n/**\n * 专用于Xmind式的树形结构的键盘操作引擎\n */\n@service(\"keyboardOnlyTreeEngine\")\nexport class KeyboardOnlyTreeEngine {\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 获取节点的“预方向”\n   * 如果有缓存，则拿缓存中的值，没有缓存，根据节点的入度线的方向，来判断“预方向”\n   * @param node\n   * @returns\n   */\n  public getNodePreDirection(node: ConnectableEntity): \"right\" | \"left\" | \"down\" | \"up\" {\n    if (this.preDirectionCacheMap.has(node.uuid)) {\n      const direction = this.preDirectionCacheMap.get(node.uuid)!;\n      return direction;\n    }\n    const incomingEdges = this.project.graphMethods.getIncomingEdges(node);\n    if (incomingEdges.length === 0) {\n      return \"right\";\n    }\n    const directionCount: Record<string, number> = { right: 0, left: 0, down: 0, up: 0 };\n    incomingEdges.forEach((edge) => {\n      if (edge.isRightToLeft()) {\n        directionCount.left++;\n      } else if (edge.isLeftToRight()) {\n        directionCount.right++;\n      } else if (edge.isBottomToTop()) {\n        directionCount.up++;\n      } else if (edge.isTopToBottom()) {\n        directionCount.down++;\n      }\n    });\n    let maxCount = 0;\n    let direction: \"right\" | \"left\" | \"down\" | \"up\" = \"right\";\n    Object.entries(directionCount).forEach(([dir, count]) => {\n      if (count > maxCount) {\n        maxCount = count;\n        direction = dir as \"right\" | \"left\" | \"down\" | \"up\";\n      }\n    });\n    return direction;\n  }\n\n  private preDirectionCacheMap: Map<string, \"right\" | \"left\" | \"down\" | \"up\"> = new Map();\n\n  /**\n   * 改变节点的“预方向”\n   * @param nodes\n   * @param direction\n   */\n  public changePreDirection(nodes: ConnectableEntity[], direction: \"right\" | \"left\" | \"down\" | \"up\"): void {\n    for (const node of nodes) {\n      this.preDirectionCacheMap.set(node.uuid, direction);\n      // 添加特效提示\n      this.addNodeEffectByPreDirection(node);\n    }\n  }\n\n  /**\n   * 根据节点的“预方向”，添加特效提示\n   * @param node\n   */\n  public addNodeEffectByPreDirection(node: ConnectableEntity): void {\n    const direction = this.getNodePreDirection(node);\n    const rect = node.collisionBox.getRectangle();\n    if (direction === \"up\") {\n      this.project.effects.addEffect(LineEffect.rectangleEdgeTip(rect.leftTop, rect.rightTop));\n    } else if (direction === \"down\") {\n      this.project.effects.addEffect(LineEffect.rectangleEdgeTip(rect.leftBottom, rect.rightBottom));\n    } else if (direction === \"left\") {\n      this.project.effects.addEffect(LineEffect.rectangleEdgeTip(rect.leftTop, rect.leftBottom));\n    } else if (direction === \"right\") {\n      this.project.effects.addEffect(LineEffect.rectangleEdgeTip(rect.rightTop, rect.rightBottom));\n    }\n  }\n\n  /**\n   * 树形深度生长节点\n   * @returns\n   */\n  onDeepGenerateNode(defaultText = \"新节点\", selectAll = true) {\n    if (!this.project.keyboardOnlyEngine.isOpenning()) {\n      return;\n    }\n    const rootNode = this.project.stageManager.getConnectableEntity().find((node) => node.isSelected);\n    if (!rootNode) return;\n    this.project.camera.clearMoveCommander();\n    this.project.camera.speed = Vector.getZero();\n    // 确定创建方向：默认向右\n    const direction = this.getNodePreDirection(rootNode);\n    // 先找到自己所有的第一层后继节点\n    const childSet = this.project.graphMethods.getOneStepSuccessorSet(rootNode);\n\n    // 寻找创建位置\n    let createLocation: Vector;\n    if (childSet.length === 0) {\n      // 没有子节点时，在相应方向的正方向创建\n      const rect = rootNode.collisionBox.getRectangle();\n      switch (direction) {\n        case \"right\":\n          createLocation = rect.rightCenter.add(new Vector(100, 0));\n          break;\n        case \"left\":\n          createLocation = rect.leftCenter.add(new Vector(-100, 0));\n          break;\n        case \"down\":\n          createLocation = rect.bottomCenter.add(new Vector(0, 25));\n          break;\n        case \"up\":\n          createLocation = rect.topCenter.add(new Vector(0, -25));\n          break;\n      }\n    } else {\n      // 有子节点时，在相应方向的最后一个子节点的外侧创建\n      // 根据方向对已有的子节点进行排序\n      switch (direction) {\n        case \"right\":\n        case \"left\":\n          // 垂直方向排序\n          childSet.sort((a, b) => a.collisionBox.getRectangle().top - b.collisionBox.getRectangle().top);\n          break;\n        case \"up\":\n        case \"down\":\n          // 水平方向排序\n          childSet.sort((a, b) => a.collisionBox.getRectangle().left - b.collisionBox.getRectangle().left);\n          break;\n      }\n\n      const lastChild = childSet[childSet.length - 1];\n      const lastChildRect = lastChild.collisionBox.getRectangle();\n\n      switch (direction) {\n        case \"right\":\n          createLocation = lastChildRect.bottomCenter.add(new Vector(0, 10));\n          break;\n        case \"left\":\n          createLocation = lastChildRect.bottomCenter.add(new Vector(0, 10));\n          break;\n        case \"down\":\n          createLocation = lastChildRect.rightCenter.add(new Vector(10, 0));\n          break;\n        case \"up\":\n          createLocation = lastChildRect.rightCenter.add(new Vector(10, 0));\n          break;\n      }\n    }\n\n    // 计算新节点的字体大小\n    const newFontScaleLevel = this.calculateNewNodeFontScaleLevel(rootNode, direction);\n\n    // 创建位置寻找完毕\n    const newNode = new TextNode(this.project, {\n      text: defaultText,\n      collisionBox: new CollisionBox([\n        new Rectangle(\n          createLocation,\n          new Vector(rootNode instanceof TextNode ? rootNode.collisionBox.getRectangle().width : 100, 100),\n        ),\n      ]),\n      sizeAdjust: (rootNode instanceof TextNode ? rootNode.sizeAdjust : \"auto\") as \"auto\" | \"manual\",\n    });\n\n    // 设置新节点的字体大小\n    newNode.setFontScaleLevel(newFontScaleLevel);\n    this.project.stageManager.add(newNode);\n\n    // 如果是在框里，则把新生长的节点也纳入到框里\n    const fatherSections = this.project.sectionMethods.getFatherSections(rootNode);\n    for (const section of fatherSections) {\n      section.children.push(newNode);\n    }\n\n    // 连接节点\n    this.project.stageManager.connectEntity(rootNode, newNode);\n    const newEdges = this.project.graphMethods.getEdgesBetween(rootNode, newNode);\n\n    // 根据方向设置边的连接位置\n    switch (direction) {\n      case \"right\":\n        this.project.stageManager.changeEdgesConnectLocation(newEdges, Direction.Right, true);\n        this.project.stageManager.changeEdgesConnectLocation(newEdges, Direction.Left);\n        break;\n      case \"left\":\n        this.project.stageManager.changeEdgesConnectLocation(newEdges, Direction.Left, true);\n        this.project.stageManager.changeEdgesConnectLocation(newEdges, Direction.Right);\n        break;\n      case \"down\":\n        this.project.stageManager.changeEdgesConnectLocation(newEdges, Direction.Down, true);\n        this.project.stageManager.changeEdgesConnectLocation(newEdges, Direction.Up);\n        break;\n      case \"up\":\n        this.project.stageManager.changeEdgesConnectLocation(newEdges, Direction.Up, true);\n        this.project.stageManager.changeEdgesConnectLocation(newEdges, Direction.Down);\n        break;\n    }\n\n    // 继承父节点颜色\n    if (rootNode instanceof TextNode) {\n      newNode.color = rootNode.color.clone();\n    }\n\n    // 重新排列树形节点\n    const rootNodeParents = this.project.graphMethods.getRoots(rootNode);\n    if (rootNodeParents.length === 1) {\n      const rootNodeParent = rootNodeParents[0];\n      if (this.project.graphMethods.isTree(rootNodeParent)) {\n        if (Settings.autoLayoutWhenTreeGenerate) {\n          this.project.autoAlign.autoLayoutSelectedFastTreeMode(rootNodeParent);\n        }\n        // 更新选择状态\n        rootNodeParent.isSelected = false;\n        newNode.isSelected = true;\n        rootNode.isSelected = false;\n      } else {\n        if (Settings.autoLayoutWhenTreeGenerate) {\n          toast.warning(\"当前结构不符合树形结构，无法触发自动布局\");\n        }\n      }\n    } else {\n      if (Settings.autoLayoutWhenTreeGenerate) {\n        toast.warning(\"当前结构不符合树形结构，无法触发自动布局\");\n      }\n    }\n\n    // 特效\n    this.project.effects.addEffects(this.project.edgeRenderer.getConnectedEffects(rootNode, newNode));\n    SoundService.play.treeGenerateDeepSoundFile();\n    setTimeout(\n      () => {\n        // 防止把反引号给输入进去\n        this.project.controllerUtils.editTextNode(newNode, selectAll);\n      },\n      (1000 / 60) * 6,\n    );\n    // 根据设置决定镜头行为\n    switch (Settings.treeGenerateCameraBehavior) {\n      case \"none\":\n        // 镜头不动\n        break;\n      case \"moveToNewNode\":\n        // 镜头移动向新创建的节点\n        this.project.camera.bombMove(newNode.collisionBox.getRectangle().center, 5);\n        break;\n      case \"resetToTree\":\n        // 重置视野，使视野覆盖当前树形结构的外接矩形\n        if (rootNodeParents.length === 1) {\n          const rootNodeParent = rootNodeParents[0];\n          const allNodes = this.project.graphMethods.getSuccessorSet(rootNodeParent, true);\n          const treeBoundingRect = Rectangle.getBoundingRectangle(\n            allNodes.map((node) => node.collisionBox.getRectangle()),\n            10, // 添加一些 padding\n          );\n          this.project.camera.resetByRectangle(treeBoundingRect);\n        }\n        break;\n    }\n  }\n\n  /**\n   * 树形广度生长节点\n   * @returns\n   */\n  onBroadGenerateNode() {\n    if (!this.project.keyboardOnlyEngine.isOpenning()) {\n      return;\n    }\n    const currentSelectNode = this.project.stageManager.getConnectableEntity().find((node) => node.isSelected);\n    if (!currentSelectNode) return;\n    this.project.camera.clearMoveCommander();\n    this.project.camera.speed = Vector.getZero();\n    // 找到自己的父节点\n    const parents = this.project.graphMethods.nodeParentArray(currentSelectNode);\n    if (parents.length === 0) return;\n    if (parents.length !== 1) return;\n    const parent = parents[0];\n    // 获取预方向\n    const preDirection = this.getNodePreDirection(parent);\n    // 自动命名新节点（如果当前选中的同级节点有标号特征。）\n    let nextNodeName = \"新节点\";\n    let isAddNewNumberName = false;\n    if (currentSelectNode instanceof TextNode) {\n      const newName = extractNumberAndReturnNext(currentSelectNode.text);\n      if (newName) {\n        nextNodeName = newName;\n        isAddNewNumberName = true;\n      }\n    }\n    // 当前选择的节点的正下方创建一个节点\n    // 找到创建点\n    const newLocation = currentSelectNode.collisionBox.getRectangle().leftBottom.add(new Vector(0, 1));\n\n    // 计算新节点的字体大小\n    const newFontScaleLevel = this.calculateNewNodeFontScaleLevel(parent, preDirection);\n\n    const newNode = new TextNode(this.project, {\n      text: nextNodeName,\n      details: [],\n      uuid: v4(),\n      collisionBox: new CollisionBox([\n        new Rectangle(\n          newLocation.clone(),\n          new Vector(parent instanceof TextNode ? parent.collisionBox.getRectangle().width : 100, 100),\n        ),\n      ]),\n      sizeAdjust: parent instanceof TextNode ? (parent.sizeAdjust as \"auto\" | \"manual\") : \"auto\",\n    });\n\n    // 设置新节点的字体大小\n    newNode.setFontScaleLevel(newFontScaleLevel);\n    this.project.stageManager.add(newNode);\n    // 如果是在框里，则把新生长的节点也纳入到框里\n    const fatherSections = this.project.sectionMethods.getFatherSections(parent);\n    for (const section of fatherSections) {\n      section.children.push(newNode);\n    }\n    // 连接节点\n    this.project.stageManager.connectEntity(parent, newNode);\n\n    const newEdges = this.project.graphMethods.getEdgesBetween(parent, newNode);\n\n    if (preDirection === \"right\") {\n      // 右侧发出 左侧接收\n      this.project.stageManager.changeEdgesConnectLocation(newEdges, Direction.Right, true);\n      this.project.stageManager.changeEdgesConnectLocation(newEdges, Direction.Left);\n    } else if (preDirection === \"left\") {\n      this.project.stageManager.changeEdgesConnectLocation(newEdges, Direction.Left, true);\n      this.project.stageManager.changeEdgesConnectLocation(newEdges, Direction.Right);\n    } else if (preDirection === \"down\") {\n      this.project.stageManager.changeEdgesConnectLocation(newEdges, Direction.Down, true);\n      this.project.stageManager.changeEdgesConnectLocation(newEdges, Direction.Up);\n    } else if (preDirection === \"up\") {\n      this.project.stageManager.changeEdgesConnectLocation(newEdges, Direction.Up, true);\n      this.project.stageManager.changeEdgesConnectLocation(newEdges, Direction.Down);\n    }\n\n    // 继承父节点颜色\n    if (parent instanceof TextNode) {\n      newNode.color = parent.color.clone();\n    }\n    // 重新排列树形节点\n    const rootNodeParents = this.project.graphMethods.getRoots(parent);\n    if (rootNodeParents.length === 1) {\n      const rootNodeParent = rootNodeParents[0];\n      if (this.project.graphMethods.isTree(rootNodeParent)) {\n        if (Settings.autoLayoutWhenTreeGenerate) {\n          this.project.autoAlign.autoLayoutSelectedFastTreeMode(rootNodeParent);\n        }\n        // 更新选择状态\n        rootNodeParent.isSelected = false;\n        newNode.isSelected = true;\n        currentSelectNode.isSelected = false;\n      } else {\n        if (Settings.autoLayoutWhenTreeGenerate) {\n          toast.warning(\"当前结构不符合树形结构，无法触发自动布局\");\n        }\n      }\n    } else {\n      if (Settings.autoLayoutWhenTreeGenerate) {\n        toast.warning(\"当前结构不符合树形结构，无法触发自动布局\");\n      }\n    }\n    this.project.effects.addEffects(this.project.edgeRenderer.getConnectedEffects(parent, newNode));\n    SoundService.play.treeGenerateBroadSoundFile();\n    setTimeout(\n      () => {\n        // 防止把反引号给输入进去\n        this.project.controllerUtils.editTextNode(newNode, !isAddNewNumberName);\n      },\n      (1000 / 60) * 6,\n    );\n    // 根据设置决定镜头行为\n    switch (Settings.treeGenerateCameraBehavior) {\n      case \"none\":\n        // 镜头不动\n        break;\n      case \"moveToNewNode\":\n        // 镜头移动向新创建的节点\n        this.project.camera.bombMove(newNode.collisionBox.getRectangle().center, 5);\n        break;\n      case \"resetToTree\":\n        // 重置视野，使视野覆盖当前树形结构的外接矩形\n        if (rootNodeParents.length === 1) {\n          const rootNodeParent = rootNodeParents[0];\n          const allNodes = this.project.graphMethods.getSuccessorSet(rootNodeParent, true);\n          const treeBoundingRect = Rectangle.getBoundingRectangle(\n            allNodes.map((node) => node.collisionBox.getRectangle()),\n            10, // 添加一些 padding\n          );\n          this.project.camera.resetByRectangle(treeBoundingRect);\n        }\n        break;\n    }\n  }\n\n  /**\n   * 根据某个已经选中的节点，调整其所在树的结构\n   * @param entity\n   */\n  adjustTreeNode(entity: ConnectableEntity, withEffect = true) {\n    const rootNodeParents = this.project.graphMethods.getRoots(entity);\n    const rootNode = rootNodeParents[0];\n    this.project.autoAlign.autoLayoutSelectedFastTreeMode(rootNode);\n    SoundService.play.treeAdjustSoundFile();\n\n    // 添加闪烁特效：树形结构的外接矩形和根节点\n    const allNodes = this.project.graphMethods.getSuccessorSet(rootNode, true);\n    const treeBoundingRect = Rectangle.getBoundingRectangle(\n      allNodes.map((node) => node.collisionBox.getRectangle()),\n      10, // 添加一些 padding\n    );\n    const rootNodeRect = rootNode.collisionBox.getRectangle();\n\n    if (withEffect) {\n      // 使用成功阴影颜色作为闪烁特效颜色\n      const flashColor = this.project.stageStyleManager.currentStyle.effects.successShadow;\n\n      // 为树的外接矩形添加闪烁特效\n      this.project.effects.addEffect(\n        new RectangleRenderEffect(\n          new ProgressNumber(0, 60),\n          treeBoundingRect,\n          flashColor.toTransparent(),\n          flashColor,\n          3,\n        ),\n      );\n\n      // 为根节点添加闪烁特效\n      this.project.effects.addEffect(\n        new RectangleRenderEffect(new ProgressNumber(0, 60), rootNodeRect, flashColor.toTransparent(), flashColor, 4),\n      );\n    }\n\n    // 恢复选择状态\n    rootNode.isSelected = false;\n    entity.isSelected = true;\n  }\n\n  /**\n   * 删除当前的节点\n   */\n  onDeleteCurrentNode() {\n    // TODO\n  }\n\n  /**\n   * 计算新节点的字体大小\n   * @param parentNode 父节点\n   * @param preDirection 预方向\n   * @returns 新节点的字体缩放级别\n   */\n  private calculateNewNodeFontScaleLevel(\n    parentNode: ConnectableEntity,\n    preDirection: \"right\" | \"left\" | \"down\" | \"up\",\n  ): number {\n    // 默认值\n    let newFontScaleLevel = 0;\n\n    // 如果父节点是文本节点，先使用父节点的字体大小\n    if (parentNode instanceof TextNode) {\n      newFontScaleLevel = parentNode.fontScaleLevel;\n    }\n\n    // 获取父节点的出边\n    const parentOutEdges = this.project.graphMethods.getOutgoingEdges(parentNode);\n    // 根据预方向过滤出同方向的兄弟节点\n    let sameDirectionSiblings: ConnectableEntity[] = [];\n    switch (preDirection) {\n      case \"right\":\n        sameDirectionSiblings = parentOutEdges\n          .filter((edge) => edge instanceof Edge && edge.isLeftToRight())\n          .map((edge) => edge.target);\n        break;\n      case \"left\":\n        sameDirectionSiblings = parentOutEdges\n          .filter((edge) => edge instanceof Edge && edge.isRightToLeft())\n          .map((edge) => edge.target);\n        break;\n      case \"down\":\n        sameDirectionSiblings = parentOutEdges\n          .filter((edge) => edge instanceof Edge && edge.isTopToBottom())\n          .map((edge) => edge.target);\n        break;\n      case \"up\":\n        sameDirectionSiblings = parentOutEdges\n          .filter((edge) => edge instanceof Edge && edge.isBottomToTop())\n          .map((edge) => edge.target);\n        break;\n    }\n\n    // 过滤出文本节点类型的兄弟节点\n    const textNodeSiblings = sameDirectionSiblings.filter((sibling) => sibling instanceof TextNode) as TextNode[];\n\n    // 检查兄弟节点的字体大小是否一致\n    if (textNodeSiblings.length > 0) {\n      const firstSiblingFontScale = textNodeSiblings[0].fontScaleLevel;\n      const allSame = textNodeSiblings.every((sibling) => sibling.fontScaleLevel === firstSiblingFontScale);\n\n      // 如果所有同方向兄弟节点字体大小一致，使用相同大小\n      if (allSame) {\n        newFontScaleLevel = firstSiblingFontScale;\n      }\n    }\n\n    return newFontScaleLevel;\n  }\n}\n\n/**\n * 提取字符串中的标号格式并返回下一标号字符串\n * 例如：\n * 输入：\"1 xxxx\"\n * 返回：\"2 \"\n *\n * 输入：\"1. xxxx\"\n * 返回：\"2. \"\n *\n * 输入：\"[1] xxx\"\n * 返回：\"[2] \"\n *\n * 输入：\"1) xxx\"\n * 返回：\"2) \"\n *\n * 输入：\"(1) xxx\"\n * 返回：\"(2) \"\n *\n * 类似的括号格式可能还有：\n * 【1】\n * 1:\n * 1、\n * 1,\n * 1，\n *\n * 总之，返回的序号总比输入的序号大1\n * 输入的序号后面可能会有标题内容，返回的内容中会不带标题内容，自动过滤\n * @param str\n */\nfunction extractNumberAndReturnNext(str: string): string {\n  const s = (str ?? \"\").trimStart();\n  if (!s) return \"\";\n\n  // 成对括号映射：ASCII + 常见全角/中文括号（用 Unicode 转义避免混淆）\n  const BRACKET_PAIRS: Record<string, string> = {\n    \"(\": \")\",\n    \"[\": \"]\",\n    \"{\": \"}\",\n    \"\\uFF08\": \"\\uFF09\", // （ ）\n    \"\\u3010\": \"\\u3011\", // 【 】\n    \"\\u3014\": \"\\u3015\", // 〔 〕\n    \"\\u3016\": \"\\u3017\", // 〖 〗\n  };\n\n  // 常见分隔符集合：半角 + 全角（用 Unicode 转义避免混淆）\n  const DELIMS = new Set<string>([\n    \".\",\n    \")\",\n    \":\",\n    \",\",\n    \"\\uFF1A\", // ：\n    \"\\u3001\", // 、\n    \"\\uFF0C\", // ，\n    \"\\uFF0E\", // ．\n    \"\\u3002\", // 。\n  ]);\n\n  // 1) 括号包裹的数字：例如 (1)、[1]、{1}、（1）、【1】、〔1〕、〖1〗\n  const open = s[0];\n  const close = BRACKET_PAIRS[open];\n  if (close) {\n    let i = 1;\n\n    // 跳过括号后的空白\n    while (i < s.length && /\\s/.test(s[i])) i++;\n\n    // 读取连续数字\n    const numStart = i;\n    while (i < s.length && s.charCodeAt(i) >= 48 && s.charCodeAt(i) <= 57) i++;\n    if (i === numStart) return \"\"; // 括号里没数字\n\n    // 跳过数字后的空白\n    while (i < s.length && /\\s/.test(s[i])) i++;\n\n    // 必须紧跟对应闭括号\n    if (s[i] === close) {\n      const n = parseInt(s.slice(numStart, i), 10) + 1;\n      return `${open}${n}${close} `;\n    }\n    return \"\";\n  }\n\n  // 2) 数字起始的情况：1. / 1) / 1: / 1、 / 1, / 1，/ 1． / 1。/ 或纯数字“1 ”\n  const m = s.match(/^(\\d+)/);\n  if (m) {\n    const numStr = m[1];\n    let i = numStr.length;\n\n    // 跳过数字后的空白\n    while (i < s.length && /\\s/.test(s[i])) i++;\n\n    const delim = s[i];\n    const next = String(parseInt(numStr, 10) + 1);\n\n    if (delim && DELIMS.has(delim)) {\n      return `${next}${delim} `;\n    }\n    return `${next} `;\n  }\n\n  // 未匹配到已知格式\n  return \"\";\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/keyboardOnlyEngine/newTargetLocationSelector.tsx",
    "content": "import { Vector } from \"@graphif/data-structures\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\n\n/**\n * 仅在keyboardOnlyEngine模块中使用\n * 纯键盘操作，按下Tab自动生长节点时，自动选择一开始瞬间出现的初始目标位置\n * 的功能模块\n *\n * 以尽可能减少用户按方向键更改目标位置的操作，提高效率\n */\nexport const NewTargetLocationSelector = {\n  diffLocation: new Vector(150, 0),\n\n  /**\n   *\n   * @param selectedNode 当前选择的是哪个节点\n   * @returns 返回最佳的目标位置\n   */\n  onTabDown(selectedNode: ConnectableEntity): Vector {\n    return selectedNode.collisionBox.getRectangle().center.add(this.diffLocation);\n  },\n\n  /**\n   * 在Tab键抬起时\n   * @param selectedNode 当前选择的是哪个节点\n   * @param finalChoiceLocation 最终用户选择生成的位置\n   */\n  onTabUp(selectedNode: ConnectableEntity, finalChoiceLocation: Vector): void {\n    this.diffLocation = finalChoiceLocation.subtract(selectedNode.collisionBox.getRectangle().center);\n  },\n};\n"
  },
  {
    "path": "app/src/core/service/controlService/keyboardOnlyEngine/selectChangeEngine.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { LineCuttingEffect } from \"@/core/service/feedbackService/effectEngine/concrete/LineCuttingEffect\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Line, Rectangle } from \"@graphif/shapes\";\nimport { Settings } from \"../../Settings\";\n\n/**\n * 仅在keyboardOnlyEngine中使用，用于处理select change事件\n */\n@service(\"selectChangeEngine\")\nexport class SelectChangeEngine {\n  private lastSelectNodeByKeyboardUUID = \"\";\n\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 向上选择节点\n   * @param addSelect\n   * @returns\n   */\n  selectUp(addSelect = false) {\n    if (!this.project.keyboardOnlyEngine.isOpenning()) {\n      return;\n    }\n    const selectedNode = this.getCurrentSelectedNode();\n    if (selectedNode === null) {\n      return;\n    }\n    const newSelectedConnectableEntity = this.getMostNearConnectableEntity(\n      this.collectTopNodes(selectedNode),\n      selectedNode.collisionBox.getRectangle().center,\n    );\n    this.afterSelect(selectedNode, newSelectedConnectableEntity, !addSelect);\n  }\n\n  /**\n   * 向下选择节点\n   * @param addSelect\n   * @returns\n   */\n  selectDown(addSelect = false) {\n    if (!this.project.keyboardOnlyEngine.isOpenning()) {\n      return;\n    }\n    const selectedNode = this.getCurrentSelectedNode();\n    if (selectedNode === null) {\n      return;\n    }\n    const newSelectedConnectableEntity = this.getMostNearConnectableEntity(\n      this.collectBottomNodes(selectedNode),\n      selectedNode.collisionBox.getRectangle().center,\n    );\n    this.afterSelect(selectedNode, newSelectedConnectableEntity, !addSelect);\n  }\n\n  /**\n   * 向左选择\n   * @param addSelect\n   * @returns\n   */\n  selectLeft(addSelect = false) {\n    if (!this.project.keyboardOnlyEngine.isOpenning()) {\n      return;\n    }\n    const selectedNode = this.getCurrentSelectedNode();\n    if (selectedNode === null) {\n      return;\n    }\n    const newSelectedConnectableEntity = this.getMostNearConnectableEntity(\n      this.collectLeftNodes(selectedNode),\n      selectedNode.collisionBox.getRectangle().center,\n    );\n    this.afterSelect(selectedNode, newSelectedConnectableEntity, !addSelect);\n  }\n\n  /**\n   * 向右选择\n   * @param addSelect\n   * @returns\n   */\n  selectRight(addSelect = false) {\n    if (!this.project.keyboardOnlyEngine.isOpenning()) {\n      return;\n    }\n    const selectedNode = this.getCurrentSelectedNode();\n    if (selectedNode === null) {\n      return;\n    }\n    const newSelectedConnectableEntity = this.getMostNearConnectableEntity(\n      this.collectRightNodes(selectedNode),\n      selectedNode.collisionBox.getRectangle().center,\n    );\n    this.afterSelect(selectedNode, newSelectedConnectableEntity, !addSelect);\n  }\n\n  /**\n   * 扩散选择（根据连线）\n   * @param isKeepExpand 扩散后是否保持原有的选择\n   * @param reversed 是否反向扩散\n   * @returns\n   */\n  expandSelect(isKeepExpand = false, reversed: boolean = false) {\n    if (!this.project.keyboardOnlyEngine.isOpenning()) {\n      return;\n    }\n\n    const selectedEntities = this.project.stageManager\n      .getSelectedEntities()\n      .filter((entity) => entity instanceof ConnectableEntity);\n    // 当前选择的节点\n    const selectedEntitiesUUIDSet = new Set<string>();\n    selectedEntities.map((entity) => selectedEntitiesUUIDSet.add(entity.uuid));\n    if (selectedEntities.length === 0) {\n      return;\n    }\n    // 第一步后继节点集合 或 前驱节点集合\n    const expandUUIDSet: Set<string> = new Set();\n\n    if (reversed) {\n      // 反向扩散\n      for (const selectedEntity of selectedEntities) {\n        this.project.graphMethods.nodeParentArray(selectedEntity).map((entity) => expandUUIDSet.add(entity.uuid));\n      }\n    } else {\n      // 收集所有第一步后继节点\n      for (const selectedEntity of selectedEntities) {\n        this.project.graphMethods\n          .getOneStepSuccessorSet(selectedEntity)\n          .map((entity) => expandUUIDSet.add(entity.uuid));\n      }\n    }\n    if (isKeepExpand) {\n      // 保留原有的选择 的扩散\n      const combinedUUIDSet = new Set([...selectedEntitiesUUIDSet, ...expandUUIDSet]);\n      for (const newUUID of combinedUUIDSet) {\n        const newEntity = this.project.stageManager.getConnectableEntityByUUID(newUUID);\n        if (newEntity) {\n          newEntity.isSelected = true;\n        }\n      }\n    } else {\n      // 全新的扩散\n      for (const newUUID of expandUUIDSet) {\n        const newEntity = this.project.stageManager.getConnectableEntityByUUID(newUUID);\n        if (newEntity) {\n          newEntity.isSelected = true;\n        }\n      }\n      for (const oldUUID of selectedEntitiesUUIDSet) {\n        const oldEntity = this.project.stageManager.getConnectableEntityByUUID(oldUUID);\n        if (oldEntity) {\n          oldEntity.isSelected = false;\n        }\n      }\n    }\n  }\n\n  /**\n   * 按下选择方向键后的善后工作\n   * @param selectedNodeRect\n   * @param newSelectedConnectableEntity\n   * @param clearOldSelect\n   * @returns\n   */\n  private afterSelect(\n    selectedNodeRect: ConnectableEntity,\n    newSelectedConnectableEntity: ConnectableEntity | null,\n    clearOldSelect = true,\n  ) {\n    if (newSelectedConnectableEntity === null) {\n      return;\n    }\n    newSelectedConnectableEntity.isSelected = true;\n    this.lastSelectNodeByKeyboardUUID = newSelectedConnectableEntity.uuid;\n    const newSelectNodeRect = newSelectedConnectableEntity.collisionBox.getRectangle();\n\n    if (Settings.cameraFollowsSelectedNodeOnArrowKeys) {\n      this.project.camera.bombMove(newSelectNodeRect.center);\n    }\n    if (clearOldSelect) {\n      selectedNodeRect.isSelected = false;\n    }\n\n    this.addEffect(selectedNodeRect.collisionBox.getRectangle(), newSelectNodeRect);\n    // 添加特效提示\n    this.project.keyboardOnlyTreeEngine.addNodeEffectByPreDirection(newSelectedConnectableEntity);\n  }\n\n  private getCurrentSelectedNode(): ConnectableEntity | null {\n    const selectedEntities = this.project.stageManager\n      .getSelectedEntities()\n      .filter((entity) => entity instanceof ConnectableEntity);\n    let selectedNode: ConnectableEntity | null = null;\n    if (selectedEntities.length === 0) {\n      // 如果没有，则选中距离屏幕中心最近的节点\n      const nearestNode = this.selectMostNearLocationNode(this.project.camera.location);\n      if (nearestNode) {\n        nearestNode.isSelected = true;\n      }\n      // throw new Error(\"未选中任何节点\");\n      return null;\n    } else if (selectedEntities.length === 1) {\n      selectedNode = selectedEntities[0];\n    } else {\n      const lastSelectNodeArr = this.project.stageManager\n        .getEntitiesByUUIDs([this.lastSelectNodeByKeyboardUUID])\n        .filter((entity) => entity instanceof ConnectableEntity);\n      if (lastSelectNodeArr.length !== 0) {\n        selectedNode = lastSelectNodeArr[0];\n      } else {\n        selectedNode = selectedEntities[0];\n      }\n    }\n    return selectedNode;\n  }\n\n  private addEffect(selectedNodeRect: Rectangle, newSelectNodeRect: Rectangle) {\n    const color = this.project.stageStyleManager.currentStyle.effects.successShadow;\n    // 节点切换移动的特效有待专门写一个\n    this.project.effects.addEffects([\n      new LineCuttingEffect(\n        new ProgressNumber(0, 20),\n        selectedNodeRect.leftTop,\n        newSelectNodeRect.leftTop,\n        color,\n        color,\n      ),\n    ]);\n    this.project.effects.addEffects([\n      new LineCuttingEffect(\n        new ProgressNumber(0, 20),\n        selectedNodeRect.rightTop,\n        newSelectNodeRect.rightTop,\n        color,\n        color,\n      ),\n    ]);\n    this.project.effects.addEffects([\n      new LineCuttingEffect(\n        new ProgressNumber(0, 20),\n        selectedNodeRect.rightBottom,\n        newSelectNodeRect.rightBottom,\n        color,\n        color,\n      ),\n    ]);\n    this.project.effects.addEffects([\n      new LineCuttingEffect(\n        new ProgressNumber(0, 20),\n        selectedNodeRect.leftBottom,\n        newSelectNodeRect.leftBottom,\n        color,\n        color,\n      ),\n    ]);\n  }\n\n  private getMostNearConnectableEntity(nodes: ConnectableEntity[], location: Vector): ConnectableEntity | null {\n    if (nodes.length === 0) return null;\n    let currentMinDistance = Infinity;\n    let currentNearestNode: ConnectableEntity | null = null;\n    for (const node of nodes) {\n      const entityCenter = node.collisionBox.getRectangle().center;\n      const intersectLocation = node.collisionBox\n        .getRectangle()\n        .getLineIntersectionPoint(new Line(location, entityCenter));\n      const distance = intersectLocation.distance(location);\n      if (distance < currentMinDistance) {\n        currentMinDistance = distance;\n        currentNearestNode = node;\n      }\n    }\n    return currentNearestNode;\n  }\n\n  /**\n   * 获取距离某个点最近的节点\n   * 距离的计算是实体的外接矩形的中心与点的连线的交点\n   * 然后这个交点到目标的距离\n   * @param location\n   */\n  private selectMostNearLocationNode(location: Vector): ConnectableEntity | null {\n    let currentMinDistance = Infinity;\n    let currentNearestNode: ConnectableEntity | null = null;\n    for (const node of this.project.stageManager.getConnectableEntity()) {\n      const entityCenter = node.collisionBox.getRectangle().center;\n      const intersectLocation = node.collisionBox\n        .getRectangle()\n        .getLineIntersectionPoint(new Line(location, entityCenter));\n      const distance = intersectLocation.distance(location);\n      if (distance < currentMinDistance) {\n        currentMinDistance = distance;\n        currentNearestNode = node;\n      }\n    }\n    return currentNearestNode;\n  }\n\n  /**\n   * 选中一个节点上方45度范围内的所有节点\n   * 无视连线关系，只看位置\n   * @param node 当前所在的节点\n   */\n  collectTopNodes(node: ConnectableEntity): ConnectableEntity[] {\n    const topNodes: ConnectableEntity[] = [];\n    const limitToViewport = Settings.arrowKeySelectOnlyInViewport;\n    const viewportRect = limitToViewport ? this.project.renderer.getCoverWorldRectangle() : null;\n\n    for (const otherNode of this.project.stageManager.getConnectableEntity()) {\n      if (otherNode.uuid === node.uuid) continue;\n      const otherNodeRect = otherNode.collisionBox.getRectangle();\n      const nodeRect = node.collisionBox.getRectangle();\n\n      // 如果开启了视野限制，检查节点是否在视野内\n      if (limitToViewport && viewportRect && !viewportRect.isCollideWith(otherNodeRect)) {\n        continue;\n      }\n\n      if (otherNodeRect.center.y < nodeRect.center.y) {\n        // 先保证是在上方，然后计算相对的角度\n        const direction = otherNodeRect.center.subtract(nodeRect.center).normalize();\n        const angle = direction.angleTo(new Vector(0, -1));\n        if (angle < 45) {\n          topNodes.push(otherNode);\n        }\n      }\n    }\n    return topNodes;\n  }\n\n  collectBottomNodes(node: ConnectableEntity): ConnectableEntity[] {\n    const bottomNodes: ConnectableEntity[] = [];\n    const limitToViewport = Settings.arrowKeySelectOnlyInViewport;\n    const viewportRect = limitToViewport ? this.project.renderer.getCoverWorldRectangle() : null;\n\n    for (const otherNode of this.project.stageManager.getConnectableEntity()) {\n      if (otherNode.uuid === node.uuid) continue;\n      const otherNodeRect = otherNode.collisionBox.getRectangle();\n      const nodeRect = node.collisionBox.getRectangle();\n\n      // 如果开启了视野限制，检查节点是否在视野内\n      if (limitToViewport && viewportRect && !viewportRect.isCollideWith(otherNodeRect)) {\n        continue;\n      }\n\n      if (otherNodeRect.center.y > nodeRect.center.y) {\n        // 先保证是在下方，然后计算相对的角度\n        const direction = otherNodeRect.center.subtract(nodeRect.center).normalize();\n        const angle = direction.angleTo(new Vector(0, 1));\n        if (angle < 45) {\n          bottomNodes.push(otherNode);\n        }\n      }\n    }\n    return bottomNodes;\n  }\n\n  collectLeftNodes(node: ConnectableEntity): ConnectableEntity[] {\n    const leftNodes: ConnectableEntity[] = [];\n    const limitToViewport = Settings.arrowKeySelectOnlyInViewport;\n    const viewportRect = limitToViewport ? this.project.renderer.getCoverWorldRectangle() : null;\n\n    for (const otherNode of this.project.stageManager.getConnectableEntity()) {\n      if (otherNode.uuid === node.uuid) continue;\n      const otherNodeRect = otherNode.collisionBox.getRectangle();\n      const nodeRect = node.collisionBox.getRectangle();\n\n      // 如果开启了视野限制，检查节点是否在视野内\n      if (limitToViewport && viewportRect && !viewportRect.isCollideWith(otherNodeRect)) {\n        continue;\n      }\n\n      if (otherNodeRect.center.x < nodeRect.center.x) {\n        // 先保证是在左边，然后计算相对的角度\n        const direction = otherNodeRect.center.subtract(nodeRect.center).normalize();\n        const angle = direction.angleTo(new Vector(-1, 0));\n        if (angle < 45) {\n          leftNodes.push(otherNode);\n        }\n      }\n    }\n    return leftNodes;\n  }\n\n  collectRightNodes(node: ConnectableEntity): ConnectableEntity[] {\n    const rightNodes: ConnectableEntity[] = [];\n    const limitToViewport = Settings.arrowKeySelectOnlyInViewport;\n    const viewportRect = limitToViewport ? this.project.renderer.getCoverWorldRectangle() : null;\n\n    for (const otherNode of this.project.stageManager.getConnectableEntity()) {\n      if (otherNode.uuid === node.uuid) continue;\n      const otherNodeRect = otherNode.collisionBox.getRectangle();\n      const nodeRect = node.collisionBox.getRectangle();\n\n      // 如果开启了视野限制，检查节点是否在视野内\n      if (limitToViewport && viewportRect && !viewportRect.isCollideWith(otherNodeRect)) {\n        continue;\n      }\n\n      if (otherNodeRect.center.x > nodeRect.center.x) {\n        // 先保证是在右边，然后计算相对的角度\n        const direction = otherNodeRect.center.subtract(nodeRect.center).normalize();\n        const angle = direction.angleTo(new Vector(1, 0));\n        if (angle < 45) {\n          rightNodes.push(otherNode);\n        }\n      }\n    }\n    return rightNodes;\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/rectangleSelectEngine/rectangleSelectEngine.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { StageObject } from \"@/core/stage/stageObject/abstract/StageObject\";\nimport { Edge } from \"@/core/stage/stageObject/association/Edge\";\nimport { ImageNode } from \"@/core/stage/stageObject/entity/ImageNode\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { isMac } from \"@/utils/platform\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\n\n/**\n * 框选引擎\n * 因为不止鼠标会用到框选，mac下的空格+双指移动可能也用到框选功能\n * 所以框选功能单独抽离成一个引擎，提供API被其他地方调用\n */\n@service(\"rectangleSelect\")\nexport class RectangleSelect {\n  constructor(private readonly project: Project) {}\n  // 开始点\n  private selectStartLocation = Vector.getZero();\n  // 结束点\n  private selectEndLocation = Vector.getZero();\n  public getSelectStartLocation(): Vector {\n    return this.selectStartLocation.clone();\n  }\n  public getSelectEndLocation(): Vector {\n    return this.selectEndLocation.clone();\n  }\n  private selectingRectangle: Rectangle | null = null;\n  // 将框选框限制在某个section内\n  private limitSection: Section | null = null;\n\n  private isSelectDirectionRight = true;\n\n  getRectangle(): Rectangle | null {\n    return this.selectingRectangle;\n  }\n  public shutDown() {\n    this.selectingRectangle = null;\n  }\n\n  startSelecting(worldLocation: Vector) {\n    const isHaveEdgeSelected = this.project.stageManager\n      .getAssociations()\n      .some((association) => association.isSelected);\n    const isHaveEntitySelected = this.project.stageManager.getEntities().some((entity) => entity.isSelected);\n\n    const sections = this.project.sectionMethods.getSectionsByInnerLocation(worldLocation);\n    if (sections.length === 0) {\n      // 没有在任何section里按下\n      this.limitSection = null;\n    } else {\n      this.limitSection = sections[0];\n    }\n\n    if (isHaveEntitySelected || isHaveEdgeSelected) {\n      // A\n      if (\n        this.project.controller.pressingKeySet.has(\"shift\") ||\n        (isMac\n          ? this.project.controller.pressingKeySet.has(\"meta\")\n          : this.project.controller.pressingKeySet.has(\"control\"))\n      ) {\n        // 不取消选择\n      } else {\n        // 取消选择所\n        this.project.stageManager.getStageObjects().forEach((stageObject) => {\n          stageObject.isSelected = false;\n        });\n      }\n    }\n    // 更新矩形状态\n    this.selectingRectangle = new Rectangle(worldLocation.clone(), Vector.getZero());\n    this.selectStartLocation = worldLocation.clone();\n    this.selectEndLocation = worldLocation.clone();\n  }\n\n  moveSelecting(newEndLocation: Vector) {\n    if (!this.selectingRectangle) {\n      return;\n    }\n    this.selectEndLocation = newEndLocation.clone();\n\n    // 更新框选框\n    this.selectingRectangle = Rectangle.fromTwoPoints(this.selectStartLocation, this.selectEndLocation);\n    // 更新框选方向\n    this.isSelectDirectionRight = this.selectStartLocation.x < this.selectEndLocation.x;\n\n    // 框选框在 section框中的限制情况\n    if (this.limitSection !== null) {\n      this.selectingRectangle = Rectangle.getIntersectionRectangle(\n        this.selectingRectangle,\n        this.limitSection.rectangle.expandFromCenter(-10),\n      );\n    }\n\n    this.updateStageObjectByMove();\n    this.project.controller.isMovingEdge = false;\n  }\n\n  /**\n   * 相当于鼠标松开释放\n   */\n  endSelecting() {\n    // 将所有选择到的增加到上次选择的节点中\n    this.project.controller.lastSelectedEntityUUID.clear();\n    for (const node of this.project.stageManager.getEntities()) {\n      if (node.isSelected) {\n        this.project.controller.lastSelectedEntityUUID.add(node.uuid);\n      }\n    }\n\n    this.project.controller.lastSelectedEdgeUUID.clear();\n    for (const edge of this.project.stageManager.getLineEdges()) {\n      if (edge.isSelected) {\n        this.project.controller.lastSelectedEdgeUUID.add(edge.uuid);\n      }\n    }\n    this.selectingRectangle = null;\n  }\n\n  private updateStageObjectByMove() {\n    if (\n      this.project.controller.pressingKeySet.has(\"shift\") ||\n      (isMac\n        ? this.project.controller.pressingKeySet.has(\"meta\")\n        : this.project.controller.pressingKeySet.has(\"control\"))\n    ) {\n      // 移动过程中不先暴力清除\n    } else {\n      // 先清空所有已经选择了的\n      this.project.stageManager.getStageObjects().forEach((stageObject) => {\n        stageObject.isSelected = false;\n      });\n    }\n\n    if (\n      isMac ? this.project.controller.pressingKeySet.has(\"meta\") : this.project.controller.pressingKeySet.has(\"control\")\n    ) {\n      // 交叉选择，没的变有，有的变没\n      for (const entity of this.project.stageManager.getEntities()) {\n        if (entity.isHiddenBySectionCollapse) {\n          continue;\n        }\n        // 检查实体是否是背景图片\n        if (entity instanceof ImageNode && (entity as ImageNode).isBackground) {\n          continue;\n        }\n        if (this.isSelectWithEntity(entity)) {\n          if (this.project.controller.lastSelectedEntityUUID.has(entity.uuid)) {\n            entity.isSelected = false;\n          } else {\n            entity.isSelected = true;\n          }\n        }\n      }\n      for (const association of this.project.stageManager.getAssociations()) {\n        if (this.isSelectWithEntity(association)) {\n          if (this.project.controller.lastSelectedEdgeUUID.has(association.uuid)) {\n            association.isSelected = false;\n          } else {\n            association.isSelected = true;\n          }\n        }\n      }\n    } else {\n      let isHaveEntity = false;\n      // 框选逻辑优先级：\n      // Entity > Edge\n\n      // Entity\n      if (!isHaveEntity) {\n        for (const otherEntities of this.project.stageManager.getEntities()) {\n          // if (otherEntities instanceof Section) {\n          //   continue;\n          // }\n          if (otherEntities.isHiddenBySectionCollapse) {\n            continue;\n          }\n\n          // 检查实体是否是背景图片\n          if (otherEntities instanceof ImageNode && (otherEntities as ImageNode).isBackground) {\n            continue;\n          }\n\n          if (this.isSelectWithEntity(otherEntities)) {\n            otherEntities.isSelected = true;\n            isHaveEntity = true;\n          }\n        }\n      }\n\n      // Edge\n      if (!isHaveEntity) {\n        // 如果已经有节点被选择了，则不能再选择边了\n        for (const edge of this.project.stageManager.getAssociations()) {\n          if (edge instanceof Edge && edge.isHiddenBySectionCollapse) {\n            continue;\n          }\n          if (this.isSelectWithEntity(edge)) {\n            edge.isSelected = true;\n          }\n        }\n      }\n    }\n    this.project.controllerUtils.selectedEntityNormalizing();\n  }\n\n  /**\n   * 判断当前的框选框是否选中了某个实体\n   * @param entity\n   */\n  private isSelectWithEntity(entity: StageObject) {\n    if (entity.collisionBox && this.selectingRectangle) {\n      const mode = this.getSelectMode();\n      if (mode === \"intersect\") {\n        return entity.collisionBox.isIntersectsWithRectangle(this.selectingRectangle);\n      } else {\n        return entity.collisionBox.isContainedByRectangle(this.selectingRectangle);\n      }\n    }\n    return false;\n  }\n\n  // 获取此时此刻应该的框选逻辑\n  public getSelectMode(): \"contain\" | \"intersect\" {\n    if (this.isSelectDirectionRight) {\n      return Settings.rectangleSelectWhenRight;\n    } else {\n      return Settings.rectangleSelectWhenLeft;\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/shortcutKeysEngine/GlobalShortcutManager.tsx",
    "content": "import { register, unregisterAll, isRegistered, unregister } from \"@tauri-apps/plugin-global-shortcut\";\nimport { getCurrentWindow } from \"@tauri-apps/api/window\";\nimport { Settings } from \"../../Settings\";\nimport { toast } from \"sonner\";\n\n// 定义全局快捷键类型\ninterface GlobalShortcut {\n  key: string;\n  id: string;\n  description: string;\n  onPress: () => Promise<void>;\n}\n\nexport class GlobalShortcutManager {\n  private shortcuts: GlobalShortcut[] = [];\n  private isInitialized = false;\n  private isClickThroughEnabled = false;\n  private settingsUnsubscribe: (() => void) | null = null;\n\n  constructor() {\n    this.initializeShortcuts();\n  }\n\n  // 初始化快捷键定义\n  private initializeShortcuts() {\n    this.shortcuts = [\n      {\n        key: \"Alt+2\",\n        id: \"toggle-click-through\",\n        description: \"开启/关闭窗口穿透点击\",\n        onPress: this.handleToggleClickThrough.bind(this),\n      },\n      {\n        key: \"Alt+1\",\n        id: \"show-window\",\n        description: \"呼出软件窗口\",\n        onPress: this.handleShowWindow.bind(this),\n      },\n    ];\n  }\n\n  // 初始化服务\n  public async init() {\n    if (this.isInitialized) {\n      return;\n    }\n\n    this.isInitialized = true;\n    Settings.watch(\"allowGlobalHotKeys\", (v) => {\n      // v 是变化后的值\n      if (v) {\n        this.updateShortcuts();\n      } else {\n        unregisterAll();\n      }\n    });\n  }\n\n  // 更新快捷键注册状态\n  public async updateShortcuts() {\n    // 先注销所有快捷键\n    await unregisterAll();\n    for (const shortcut of this.shortcuts) {\n      await this.registerShortcut(shortcut);\n    }\n  }\n\n  // 注册单个快捷键\n  private async registerShortcut(shortcut: GlobalShortcut) {\n    try {\n      // 检查是否已注册\n      const alreadyRegistered = await isRegistered(shortcut.key);\n      if (alreadyRegistered) {\n        await unregister(shortcut.key);\n      }\n\n      // 注册快捷键\n      await register(shortcut.key, async (event) => {\n        if (event.state === \"Pressed\") {\n          await shortcut.onPress();\n        }\n      });\n    } catch (error) {\n      toast.error(`注册全局快捷键 ${shortcut.key} 失败: ${error}`);\n    }\n  }\n\n  // 处理穿透点击切换\n  private async handleToggleClickThrough() {\n    const window = getCurrentWindow();\n\n    if (!this.isClickThroughEnabled) {\n      // 开启了穿透点击\n      Settings.windowBackgroundAlpha = Settings.windowBackgroundOpacityAfterOpenClickThrough;\n      await window.setAlwaysOnTop(true);\n    } else {\n      // 关闭了穿透点击\n      Settings.windowBackgroundAlpha = Settings.windowBackgroundOpacityAfterCloseClickThrough;\n      await window.setAlwaysOnTop(false);\n    }\n\n    this.isClickThroughEnabled = !this.isClickThroughEnabled;\n    await window.setIgnoreCursorEvents(this.isClickThroughEnabled);\n  }\n\n  // 处理显示窗口\n  private async handleShowWindow() {\n    console.log(\"开始呼出窗口\");\n    const window = getCurrentWindow();\n    await window.show();\n    await window.setSkipTaskbar(false);\n    await window.setFocus();\n  }\n\n  // 清理资源\n  public async dispose() {\n    // 注销所有快捷键\n    await unregisterAll();\n\n    // 移除设置监听器\n    if (this.settingsUnsubscribe) {\n      this.settingsUnsubscribe();\n      this.settingsUnsubscribe = null;\n    }\n\n    this.isInitialized = false;\n    console.log(\"全局快捷键管理器已清理\");\n  }\n\n  // 获取所有快捷键信息\n  public getShortcuts() {\n    return this.shortcuts;\n  }\n}\n\n// 创建单例实例\nexport const globalShortcutManager = new GlobalShortcutManager();\n"
  },
  {
    "path": "app/src/core/service/controlService/shortcutKeysEngine/KeyBinds.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { matchEmacsKeyPress, transEmacsKeyWinToMac } from \"@/utils/emacs\";\nimport { isMac } from \"@/utils/platform\";\nimport { createStore } from \"@/utils/store\";\nimport { Queue } from \"@graphif/data-structures\";\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport { isKeyBindHasRelease } from \"./shortcutKeysRegister\";\n\n/**\n * 快捷键配置接口\n */\ninterface KeyBindConfig {\n  key: string;\n  isEnabled: boolean;\n}\n\n/**\n * 用于管理快捷键绑定\n */\n@service(\"keyBinds\")\nexport class KeyBinds {\n  private store: Store | null = null;\n\n  constructor(private readonly project: Project) {\n    (async () => {\n      this.store = await createStore(\"keybinds2.json\");\n      await this.project.keyBindsRegistrar.registerAllKeyBinds();\n      // 不再重置store，支持新的数据结构\n    })();\n  }\n\n  async set(id: string, config: KeyBindConfig) {\n    if (!this.store) {\n      throw new Error(\"Store not initialized.\");\n    }\n    await this.store.set(id, config);\n    if (this.callbacks[id]) {\n      this.callbacks[id].forEach((callback) => callback(config));\n    }\n  }\n\n  /**\n   * 获取快捷键配置\n   * @param id\n   * @returns\n   */\n  async get(id: string): Promise<KeyBindConfig | null> {\n    if (!this.store) {\n      throw new Error(\"Store not initialized.\");\n    }\n    const data = await this.store.get<KeyBindConfig | string>(id);\n    if (!data) {\n      return null;\n    }\n    // 兼容旧数据结构\n    if (typeof data === \"string\") {\n      return {\n        key: data,\n        isEnabled: true,\n      };\n    }\n    return data;\n  }\n\n  /**\n   * 切换快捷键启用状态\n   * @param id\n   * @returns\n   */\n  async toggleEnabled(id: string): Promise<boolean> {\n    const config = await this.get(id);\n    if (!config) {\n      return true;\n    }\n    const newConfig = {\n      ...config,\n      isEnabled: !config.isEnabled,\n    };\n    await this.set(id, newConfig);\n    return newConfig.isEnabled;\n  }\n\n  /**\n   * 让某一个快捷键开始监听\n   * @param id\n   * @param callback\n   * @returns\n   */\n  watch(key: string, callback: (value: KeyBindConfig) => void) {\n    if (!this.callbacks[key]) {\n      this.callbacks[key] = [];\n    }\n    this.callbacks[key].push(callback);\n    if (this.store) {\n      this.get(key).then((value) => {\n        if (!value) return;\n        callback(value);\n      });\n    }\n    return () => {\n      this.callbacks[key] = this.callbacks[key].filter((cb) => cb !== callback);\n    };\n  }\n\n  private callbacks: {\n    [key: string]: Array<(value: any) => void>;\n  } = {};\n\n  /**\n   * 获取所有快捷键绑定\n   * @returns [[key, value], [key, value], ...], 具体来说是 [[\"copy\", {key: \"C-c\", isEnabled: true}], ...]\n   */\n  async entries() {\n    if (!this.store) {\n      throw new Error(\"Keybind Store not initialized.\");\n    }\n    return await this.store.entries<KeyBindConfig | string>();\n  }\n\n  // 仅用于初始化软件时注册快捷键\n  registeredIdSet: Set<string> = new Set();\n  bindSet: Set<_Bind> = new Set();\n\n  /**\n   * 注册快捷键，注意：Mac会自动将此进行替换\n   * 此函数内部会判断用户是否已经有自定义的快捷键了\n   * 如果用户已经自定义了快捷键，则 defaultKey 函数就没有效果了。\n   * @param id 确保唯一的描述性字符串\n   * @param defaultKey 例如 \"C-A-S-t\" 表示 Ctrl+Alt+Shift+t，如果是mac，会自动将C-和M-互换！！\n   * @param onPress 按下后的执行函数\n   * @param onReleaes 松开按键后执行的函数\n   * @param defaultEnabled 默认是否启用\n   * @returns\n   */\n  async create(\n    id: string,\n    defaultKey: string,\n    onPress = () => {},\n    onReleaes?: () => void,\n    defaultEnabled: boolean = true,\n  ): Promise<_Bind> {\n    if (this.registeredIdSet.has(id)) {\n      throw new Error(`Keybind ${id} 已经注册过了`);\n    }\n    if (isMac) {\n      defaultKey = transEmacsKeyWinToMac(defaultKey);\n    }\n    this.registeredIdSet.add(id);\n    let userConfig = await this.get(id);\n    if (!userConfig) {\n      // 注册新的快捷键\n      const newConfig: KeyBindConfig = {\n        key: defaultKey,\n        isEnabled: defaultEnabled,\n      };\n      await this.set(id, newConfig);\n      userConfig = newConfig;\n    } else if (typeof userConfig === \"string\") {\n      // 兼容旧数据结构\n      const newConfig: KeyBindConfig = {\n        key: userConfig,\n        isEnabled: defaultEnabled,\n      };\n      await this.set(id, newConfig);\n      userConfig = newConfig;\n    }\n    const obj = new _Bind(this.project, id, userConfig.key, userConfig.isEnabled, onPress, onReleaes);\n    // 将绑定对象添加到集合中，以便后续清理\n    this.bindSet.add(obj);\n    // 监听快捷键变化\n    this.watch(id, (value) => {\n      obj.key = value.key;\n      obj.isEnabled = value.isEnabled;\n    });\n    return obj;\n  }\n\n  dispose() {\n    this.bindSet.forEach((bind) => bind.dispose());\n    this.bindSet.clear();\n    this.registeredIdSet.clear();\n    this.callbacks = {};\n  }\n\n  /**\n   * 重置所有快捷键为默认值\n   */\n  async resetAllKeyBinds() {\n    if (!this.store) {\n      throw new Error(\"Store not initialized.\");\n    }\n    // 清除已注册ID集合和资源\n    this.dispose();\n    // 清空存储\n    await this.store.clear();\n    // 重新注册所有快捷键\n    await this.project.keyBindsRegistrar.registerAllKeyBinds();\n  }\n}\n\n/**\n * 快捷键绑定对象，一个此对象表示一个 快捷键功能绑定\n */\nclass _Bind {\n  public button: number = -1;\n  // @ts-expect-error // TODO: dblclick\n  private lastMatch: number = 0;\n  private events = new Queue<MouseEvent | KeyboardEvent | WheelEvent>();\n  // 是否启用\n  public isEnabled: boolean;\n\n  private enqueue(event: MouseEvent | KeyboardEvent | WheelEvent) {\n    // 队列里面最多20个（因为秘籍键长度最大20）\n    // 这里改成40 是因为可能有松开按键事件混入\n    while (this.events.length >= 40) {\n      this.events.dequeue();\n    }\n    this.events.enqueue(event);\n  }\n  /**\n   * 每发生一个事件，都会调用这个函数\n   */\n  private check() {\n    // 如果快捷键未启用，直接返回\n    if (!this.isEnabled) {\n      return;\n    }\n\n    if (\n      matchEmacsKeyPress(\n        this.key,\n        this.events.arrayList.filter((event) => !(event instanceof KeyboardEvent && event.type === \"keyup\")),\n      )\n    ) {\n      this.onPress();\n      // 执行了快捷键之后，清空队列\n      if (isKeyBindHasRelease(this.key)) {\n        // 不清空\n      } else {\n        this.events.clear();\n      }\n    }\n\n    if (\n      matchEmacsKeyPress(\n        this.key,\n        this.events.arrayList.filter((event) => !(event instanceof KeyboardEvent && event.type === \"keydown\")),\n      )\n    ) {\n      if (this.onRelease) {\n        this.onRelease();\n      }\n      // 执行了快捷键之后，清空队列\n      this.events.clear();\n    }\n  }\n\n  constructor(\n    private readonly project: Project,\n    public id: string,\n    public key: string,\n    isEnabled: boolean,\n    private readonly onPress: () => void,\n    private readonly onRelease?: () => void,\n  ) {\n    this.isEnabled = isEnabled;\n    // 有任意事件时，管它是什么，都放进队列\n    this.project.canvas.element.addEventListener(\"mousedown\", this.onMouseDown);\n    this.project.canvas.element.addEventListener(\"keydown\", this.onKeyDown);\n    this.project.canvas.element.addEventListener(\"keyup\", this.onKeyUp);\n    this.project.canvas.element.addEventListener(\"wheel\", this.onWheel, { passive: true });\n  }\n\n  onMouseDown = (event: MouseEvent) => {\n    this.button = event.button;\n    this.enqueue(event);\n    this.check();\n  };\n  onKeyDown = (event: KeyboardEvent) => {\n    if ([\"control\", \"alt\", \"shift\", \"meta\"].includes(event.key.toLowerCase())) return;\n    this.enqueue(event);\n    this.check();\n  };\n  onKeyUp = (event: KeyboardEvent) => {\n    if ([\"control\", \"alt\", \"shift\", \"meta\"].includes(event.key.toLowerCase())) return;\n    this.enqueue(event);\n    this.check();\n  };\n  onWheel = (event: WheelEvent) => {\n    this.enqueue(event);\n    this.check();\n  };\n\n  dispose() {\n    this.project.canvas.element.removeEventListener(\"mousedown\", this.onMouseDown);\n    this.project.canvas.element.removeEventListener(\"keydown\", this.onKeyDown);\n    this.project.canvas.element.removeEventListener(\"wheel\", this.onWheel);\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/shortcutKeysEngine/KeyBindsUI.tsx",
    "content": "import { matchEmacsKeyPress, transEmacsKeyWinToMac } from \"@/utils/emacs\";\nimport { isMac } from \"@/utils/platform\";\nimport { createStore } from \"@/utils/store\";\nimport { Queue } from \"@graphif/data-structures\";\nimport { allKeyBinds } from \"./shortcutKeysRegister\";\nimport { activeProjectAtom, store } from \"@/state\";\nimport { Project } from \"@/core/Project\";\n\n/**\n * UI级别的快捷键管理\n */\nexport namespace KeyBindsUI {\n  const userEventQueue = new Queue<KeyboardEvent | MouseEvent | WheelEvent>();\n\n  function enqueue(event: MouseEvent | KeyboardEvent | WheelEvent) {\n    // 队列里面最多20个（因为秘籍键长度最大20）\n    while (userEventQueue.length >= 20) {\n      userEventQueue.dequeue();\n    }\n    userEventQueue.enqueue(event);\n  }\n\n  interface UIKeyBind {\n    id: string;\n    key: string;\n    isEnabled: boolean;\n    onPress: (project?: Project) => void;\n    onRelease?: (project?: Project) => void;\n  }\n\n  let allUIKeyBinds: UIKeyBind[] = [];\n\n  const registerSet = new Set<string>();\n\n  /**\n   * 注册所有非全局快捷键\n   * 会先检查是否已经存下来了，如果已经存下来了，先注册存下来的\n   * 否则再注册默认快捷键\n   */\n  export async function registerAllUIKeyBinds() {\n    const store = await createStore(\"keybinds2.json\");\n    for (const keybind of allKeyBinds.filter((keybindItem) => !keybindItem.isGlobal)) {\n      const savedData = await store.get<any>(keybind.id);\n      let key: string;\n      let isEnabled: boolean;\n\n      if (!savedData) {\n        // 没有保存过，走默认设置\n        key = keybind.defaultKey;\n        if (isMac) {\n          key = transEmacsKeyWinToMac(key);\n        }\n        isEnabled = keybind.defaultEnabled !== false;\n        await store.set(keybind.id, { key, isEnabled });\n      } else if (typeof savedData === \"string\") {\n        // 兼容旧数据结构\n        key = savedData;\n        isEnabled = keybind.defaultEnabled !== false;\n        await store.set(keybind.id, { key, isEnabled });\n      } else {\n        // 已经保存过完整配置\n        key = savedData.key;\n        isEnabled = savedData.isEnabled !== false;\n      }\n\n      KeyBindsUI.registerOneUIKeyBind(keybind.id, key, isEnabled, keybind.onPress, keybind.onRelease);\n    }\n    await store.save();\n  }\n  /**\n   * 注册一个非全局快捷键\n   * 只会在软件启动的时候注册一次\n   * 其他情况下，只会在修改快捷键的时候进行重新修改值\n   */\n  export async function registerOneUIKeyBind(\n    id: string,\n    key: string,\n    isEnabled: boolean = true,\n    onPress = () => {},\n    onRelease?: () => void,\n  ) {\n    if (registerSet.has(id)) {\n      // 防止开发时热更新重复注册\n      console.warn(`Keybind ${id} 已经注册过了`);\n      return;\n    }\n    registerSet.add(id);\n    allUIKeyBinds.push({ id, key, isEnabled, onPress, onRelease });\n  }\n\n  /**\n   * 用于修改快捷键\n   * @param id\n   * @param key\n   */\n  export async function changeOneUIKeyBind(id: string, key: string) {\n    allUIKeyBinds = allUIKeyBinds.map((it) => {\n      if (it.id === id) {\n        return { ...it, key };\n      }\n      return it;\n    });\n\n    const store = await createStore(\"keybinds2.json\");\n    const currentConfig = await store.get<any>(id);\n    await store.set(id, {\n      key,\n      isEnabled: currentConfig?.isEnabled !== false,\n    });\n    await store.save();\n  }\n\n  /**\n   * 用于切换快捷键启用状态\n   * @param id\n   * @returns 新的启用状态\n   */\n  export async function toggleEnabled(id: string): Promise<boolean> {\n    let newEnabledState = true;\n\n    allUIKeyBinds = allUIKeyBinds.map((it) => {\n      if (it.id === id) {\n        newEnabledState = !it.isEnabled;\n        return { ...it, isEnabled: newEnabledState };\n      }\n      return it;\n    });\n\n    const store = await createStore(\"keybinds2.json\");\n    const currentConfig = await store.get<any>(id);\n    const keybind = allKeyBinds.find((kb) => kb.id === id);\n    await store.set(id, {\n      key: currentConfig?.key || keybind?.defaultKey || \"\",\n      isEnabled: newEnabledState,\n    });\n    await store.save();\n\n    return newEnabledState;\n  }\n\n  /**\n   * 重置所有快捷键为默认值（包括快捷键值和启用状态）\n   */\n  export async function resetAllKeyBinds() {\n    const store = await createStore(\"keybinds2.json\");\n    // 清空存储\n    await store.clear();\n    // 清空已注册的快捷键\n    registerSet.clear();\n    allUIKeyBinds = [];\n    // 重新注册所有快捷键\n    await registerAllUIKeyBinds();\n  }\n\n  /**\n   * 仅重置所有快捷键的启用状态为默认值\n   */\n  export async function resetAllKeyBindsEnabledState() {\n    const store = await createStore(\"keybinds2.json\");\n\n    // 遍历所有非全局快捷键\n    for (const keybind of allKeyBinds.filter((keybindItem) => !keybindItem.isGlobal)) {\n      const currentConfig = await store.get<any>(keybind.id);\n\n      // 如果存在当前配置，只重置isEnabled字段，保留key字段\n      if (currentConfig) {\n        await store.set(keybind.id, {\n          key: currentConfig.key,\n          isEnabled: keybind.defaultEnabled !== false,\n        });\n      } else {\n        // 如果不存在配置，使用默认值创建\n        let defaultValue = keybind.defaultKey;\n        if (isMac) {\n          defaultValue = transEmacsKeyWinToMac(defaultValue);\n        }\n        await store.set(keybind.id, {\n          key: defaultValue,\n          isEnabled: keybind.defaultEnabled !== false,\n        });\n      }\n    }\n\n    await store.save();\n\n    // 更新内存中的快捷键配置\n    for (const uiKeyBind of allUIKeyBinds) {\n      const keybind = allKeyBinds.find((kb) => kb.id === uiKeyBind.id);\n      if (keybind) {\n        uiKeyBind.isEnabled = keybind.defaultEnabled !== false;\n      }\n    }\n  }\n\n  /**\n   * 仅重置所有快捷键的值为默认值，保留启用状态\n   */\n  export async function resetAllKeyBindsValues() {\n    const store = await createStore(\"keybinds2.json\");\n\n    // 遍历所有非全局快捷键\n    for (const keybind of allKeyBinds.filter((keybindItem) => !keybindItem.isGlobal)) {\n      const currentConfig = await store.get<any>(keybind.id);\n\n      // 应用Mac键位转换\n      let defaultValue = keybind.defaultKey;\n      if (isMac) {\n        defaultValue = transEmacsKeyWinToMac(defaultValue);\n      }\n\n      // 如果存在当前配置，只重置key字段，保留isEnabled字段\n      if (currentConfig) {\n        await store.set(keybind.id, {\n          key: defaultValue,\n          isEnabled: currentConfig.isEnabled !== false,\n        });\n      } else {\n        // 如果不存在配置，使用默认值创建\n        await store.set(keybind.id, {\n          key: defaultValue,\n          isEnabled: keybind.defaultEnabled !== false,\n        });\n      }\n    }\n\n    await store.save();\n\n    // 更新内存中的快捷键配置\n    for (const uiKeyBind of allUIKeyBinds) {\n      const keybind = allKeyBinds.find((kb) => kb.id === uiKeyBind.id);\n      if (keybind) {\n        let defaultValue = keybind.defaultKey;\n        if (isMac) {\n          defaultValue = transEmacsKeyWinToMac(defaultValue);\n        }\n        uiKeyBind.key = defaultValue;\n      }\n    }\n  }\n\n  // 跟踪当前按下的单键快捷键\n  const pressedSingleKeyBinds = new Set<string>();\n\n  export function uiStartListen() {\n    window.addEventListener(\"mousedown\", onMouseDown);\n    window.addEventListener(\"keydown\", onKeyDown);\n    window.addEventListener(\"keyup\", onKeyUp);\n    window.addEventListener(\"wheel\", onWheel, { passive: true });\n  }\n\n  export function uiStopListen() {\n    window.removeEventListener(\"mousedown\", onMouseDown);\n    window.removeEventListener(\"keydown\", onKeyDown);\n    window.removeEventListener(\"keyup\", onKeyUp);\n    window.removeEventListener(\"wheel\", onWheel);\n    pressedSingleKeyBinds.clear();\n  }\n\n  /**\n   * 检查是否应该处理键盘事件\n   * 当有文本输入元素获得焦点时，不处理键盘事件\n   */\n  function shouldProcessKeyboardEvent() {\n    return !(\n      document.activeElement?.tagName === \"INPUT\" ||\n      document.activeElement?.tagName === \"TEXTAREA\" ||\n      document.activeElement?.getAttribute(\"contenteditable\") === \"true\"\n    );\n  }\n\n  function check() {\n    // 如果有文本输入元素获得焦点，不处理键盘事件\n    if (!shouldProcessKeyboardEvent()) {\n      // 清空队列，防止事件积累\n      userEventQueue.clear();\n      return;\n    }\n    const activeProject = store.get(activeProjectAtom);\n    let executed = false;\n    for (const uiKeyBind of allUIKeyBinds) {\n      // 如果快捷键未启用，跳过\n      if (!uiKeyBind.isEnabled) {\n        continue;\n      }\n      if (matchEmacsKeyPress(uiKeyBind.key, userEventQueue.arrayList)) {\n        uiKeyBind.onPress(activeProject);\n        // 如果是单键快捷键且有onRelease回调，记录为已按下状态\n        if (uiKeyBind.onRelease && uiKeyBind.key.length === 1) {\n          pressedSingleKeyBinds.add(uiKeyBind.key);\n        }\n        executed = true;\n      }\n    }\n    // 执行了快捷键之后，清空队列\n    if (executed) {\n      userEventQueue.clear();\n    }\n  }\n\n  function onMouseDown(event: MouseEvent) {\n    enqueue(event);\n    check();\n  }\n  function onKeyDown(event: KeyboardEvent) {\n    // 如果有文本输入元素获得焦点，不处理键盘事件\n    if (!shouldProcessKeyboardEvent()) {\n      // 清空队列，防止事件积累\n      userEventQueue.clear();\n      return;\n    }\n    if ([\"control\", \"alt\", \"shift\", \"meta\"].includes(event.key.toLowerCase())) return;\n    enqueue(event);\n    check();\n  }\n  function onKeyUp(event: KeyboardEvent) {\n    // 如果有文本输入元素获得焦点，不处理键盘事件\n    if (!isMac && !shouldProcessKeyboardEvent()) {\n      return;\n    }\n    const activeProject = store.get(activeProjectAtom);\n    const key = event.key;\n\n    // 检查是否有对应的单键快捷键需要处理松开事件\n    for (const uiKeyBind of allUIKeyBinds) {\n      // 如果快捷键未启用，跳过\n      if (!uiKeyBind.isEnabled) {\n        continue;\n      }\n      if (uiKeyBind.onRelease && uiKeyBind.key === key && pressedSingleKeyBinds.has(key)) {\n        uiKeyBind.onRelease(activeProject);\n        pressedSingleKeyBinds.delete(key);\n      }\n    }\n  }\n  function onWheel(event: WheelEvent) {\n    enqueue(event);\n    check();\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/shortcutKeysEngine/ShortcutKeyFixer.tsx",
    "content": "import { createStore } from \"@/utils/store\";\nimport { allKeyBinds } from \"./shortcutKeysRegister\";\nimport { isMac } from \"@/utils/platform\";\nimport { Dialog } from \"@/components/ui/dialog\";\n\n/**\n * 修复F11快捷键：如果toggleFullscreen快捷键被设置为单独的F11，则自动添加Ctrl修饰键\n * 这是为了避免系统级F11快捷键冲突导致软件无法使用\n * 主要用于修复老用户的缓存问题，在软件启动时调用一次\n */\nexport async function fixF11ShortcutInStorage(): Promise<void> {\n  const store = await createStore(\"keybinds2.json\");\n\n  // 查找toggleFullscreen快捷键的定义\n  const toggleFullscreenKeybind = allKeyBinds.find((kb) => kb.id === \"toggleFullscreen\");\n  if (!toggleFullscreenKeybind) {\n    // 未找到toggleFullscreen快捷键定义\n    return;\n  }\n\n  const savedData = await store.get<any>(\"toggleFullscreen\");\n  if (!savedData) {\n    // 没有保存过配置，不需要修复\n    return;\n  }\n\n  let key: string;\n  let isEnabled: boolean;\n  let needsUpdate = false;\n\n  if (typeof savedData === \"string\") {\n    // 兼容旧数据结构\n    key = savedData;\n    isEnabled = toggleFullscreenKeybind.defaultEnabled !== false;\n    needsUpdate = true;\n  } else {\n    // 新数据结构\n    key = savedData.key;\n    isEnabled = savedData.isEnabled !== false;\n  }\n\n  // 检查是否为单独的F11（不区分大小写）\n  const normalizedKey = key.toLowerCase();\n  if (normalizedKey === \"f11\") {\n    // 根据平台添加正确的修饰键\n    key = isMac ? \"M-F11\" : \"C-F11\";\n    needsUpdate = true;\n  }\n\n  // 如果需要更新，保存修正后的配置\n  if (needsUpdate) {\n    await store.set(\"toggleFullscreen\", { key, isEnabled });\n    await store.save();\n    await Dialog.buttons(\n      \"检测到存在引起bug的快捷键F11（全屏）\",\n      `全屏快捷键已自动修复为：${key}。\\n\\nBug现象：使用鼠标中键拖动视野时，会频繁触发全屏和取消全屏的操作，影响正常使用。\\n\\n请重启软件生效。`,\n      [{ id: \"close\", label: \"了解，稍后关闭\", variant: \"ghost\" }],\n    );\n  }\n}\n\n/**\n * 检查并修复所有快捷键配置中的问题\n * 目前只修复F11快捷键问题\n */\nexport async function checkAndFixShortcutStorage(): Promise<void> {\n  await fixF11ShortcutInStorage();\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/shortcutKeysEngine/shortcutKeysRegister.tsx",
    "content": "import { Dialog } from \"@/components/ui/dialog\";\nimport { Project, ProjectState, service } from \"@/core/Project\";\nimport { MouseLocation } from \"@/core/service/controlService/MouseLocation\";\nimport { RectangleSlideEffect } from \"@/core/service/feedbackService/effectEngine/concrete/RectangleSlideEffect\";\nimport { ViewFlashEffect } from \"@/core/service/feedbackService/effectEngine/concrete/ViewFlashEffect\";\nimport { ViewOutlineFlashEffect } from \"@/core/service/feedbackService/effectEngine/concrete/ViewOutlineFlashEffect\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { SubWindow } from \"@/core/service/SubWindow\";\nimport { Themes } from \"@/core/service/Themes\";\nimport { PenStrokeMethods } from \"@/core/stage/stageManager/basicMethods/PenStrokeMethods\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { MultiTargetUndirectedEdge } from \"@/core/stage/stageObject/association/MutiTargetUndirectedEdge\";\nimport { ImageNode } from \"@/core/stage/stageObject/entity/ImageNode\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { activeProjectAtom, isWindowMaxsizedAtom, projectsAtom, store } from \"@/state\";\nimport { getCurrentWindow } from \"@tauri-apps/api/window\";\nimport { LogicalSize } from \"@tauri-apps/api/dpi\";\n// import ColorWindow from \"@/sub/ColorWindow\";\nimport FindWindow from \"@/sub/FindWindow\";\n// import KeyboardRecentFilesWindow from \"@/sub/KeyboardRecentFilesWindow\";\nimport ColorWindow from \"@/sub/ColorWindow\";\nimport RecentFilesWindow from \"@/sub/RecentFilesWindow\";\nimport SettingsWindow from \"@/sub/SettingsWindow\";\nimport TagWindow from \"@/sub/TagWindow\";\nimport { Direction } from \"@/types/directions\";\nimport { openBrowserOrFile } from \"@/utils/externalOpen\";\nimport { isMac } from \"@/utils/platform\";\nimport { Color, Vector } from \"@graphif/data-structures\";\nimport { toast } from \"sonner\";\nimport { RecentFileManager } from \"../../dataFileService/RecentFileManager\";\nimport { ColorSmartTools } from \"../../dataManageService/colorSmartTools\";\nimport { ConnectNodeSmartTools } from \"../../dataManageService/connectNodeSmartTools\";\nimport { TextNodeSmartTools } from \"../../dataManageService/textNodeSmartTools\";\nimport { createFileAtCurrentProjectDir, onNewDraft, onOpenFile } from \"../../GlobalMenu\";\n\ninterface KeyBindItem {\n  id: string;\n  defaultKey: string;\n  onPress: (project?: Project) => void;\n  onRelease?: (project?: Project) => void;\n  // 全局快捷键\n  isGlobal?: boolean;\n  // UI级别快捷键\n  isUI?: boolean;\n  // 默认是否启用\n  defaultEnabled?: boolean;\n}\n\nexport const allKeyBinds: KeyBindItem[] = [\n  {\n    id: \"test\",\n    defaultKey: \"C-A-S-t\",\n    onPress: () =>\n      Dialog.buttons(\"测试快捷键\", \"您按下了自定义的测试快捷键，这一功能是测试开发所用，可在设置中更改触发方式\", [\n        { id: \"close\", label: \"关闭\" },\n      ]),\n    isUI: true,\n  },\n\n  /*------- 窗口管理 -------*/\n  {\n    id: \"closeAllSubWindows\",\n    defaultKey: \"Escape\",\n    onPress: () => {\n      if (!SubWindow.hasOpenWindows()) return;\n      SubWindow.closeAll();\n    },\n    isUI: true,\n  },\n  {\n    id: \"toggleFullscreen\",\n    defaultKey: \"C-F11\",\n    onPress: async () => {\n      const window = getCurrentWindow();\n      // 如果当前已经是最大化的状态，设置为非最大化\n      if (await window.isMaximized()) {\n        store.set(isWindowMaxsizedAtom, false);\n      }\n      // 切换全屏状态\n      const isFullscreen = await window.isFullscreen();\n      await window.setFullscreen(!isFullscreen);\n    },\n    isUI: true,\n  },\n  {\n    id: \"setWindowToMiniSize\",\n    defaultKey: \"A-S-m\",\n    onPress: async () => {\n      const window = getCurrentWindow();\n      // 如果当前是最大化状态，先取消最大化\n      if (await window.isMaximized()) {\n        await window.unmaximize();\n        store.set(isWindowMaxsizedAtom, false);\n      }\n      // 如果当前是全屏状态，先退出全屏\n      if (await window.isFullscreen()) {\n        await window.setFullscreen(false);\n      }\n      // 设置窗口大小为设置中的迷你窗口大小\n      const width = Settings.windowCollapsingWidth;\n      const height = Settings.windowCollapsingHeight;\n      await window.setSize(new LogicalSize(width, height));\n    },\n    isUI: true,\n  },\n\n  /*------- 基础编辑 -------*/\n  {\n    id: \"undo\",\n    defaultKey: \"C-z\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.historyManager.undo();\n    },\n  },\n  {\n    id: \"redo\",\n    defaultKey: \"C-y\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.historyManager.redo();\n    },\n  },\n  {\n    id: \"reload\",\n    defaultKey: \"C-f5\",\n    onPress: async () => {\n      if (\n        await Dialog.confirm(\n          \"危险操作：重新加载应用\",\n          \"此快捷键用于在废档了或软件卡住了的情况下重启，您按下了重新加载应用快捷键，是否要重新加载应用？这会导致您丢失所有未保存的工作。\",\n          { destructive: true },\n        )\n      ) {\n        window.location.reload();\n      }\n    },\n    isUI: true,\n    defaultEnabled: false,\n  },\n\n  /*------- 课堂/专注模式 -------*/\n  {\n    id: \"checkoutClassroomMode\",\n    defaultKey: \"F5\",\n    onPress: async () => {\n      if (Settings.isClassroomMode) {\n        toast.info(\"已经退出专注模式，点击一下更新状态\");\n      } else {\n        toast.info(\"进入专注模式，点击一下更新状态\");\n      }\n      Settings.isClassroomMode = !Settings.isClassroomMode;\n    },\n    isUI: true,\n    defaultEnabled: false,\n  },\n\n  /*------- 相机/视图 -------*/\n  {\n    id: \"resetView\",\n    defaultKey: \"F\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.camera.saveCameraState();\n      project!.camera.resetBySelected();\n    },\n  },\n  {\n    id: \"restoreCameraState\",\n    defaultKey: \"S-F\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.camera.restoreCameraState();\n    },\n  },\n  {\n    id: \"resetCameraScale\",\n    defaultKey: \"C-A-r\",\n    onPress: (project) => project!.camera.resetScale(),\n  },\n\n  /*------- 相机分页移动（Win） -------*/\n  // 注意：实际运行时会根据 isMac 注册其一，这里两份都列出方便查阅\n  {\n    id: \"CameraPageMoveUp\",\n    defaultKey: isMac ? \"S-i\" : \"pageup\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.camera.pageMove(Direction.Up);\n    },\n  },\n  {\n    id: \"CameraPageMoveDown\",\n    defaultKey: isMac ? \"S-k\" : \"pagedown\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.camera.pageMove(Direction.Down);\n    },\n  },\n  {\n    id: \"CameraPageMoveLeft\",\n    defaultKey: isMac ? \"S-j\" : \"home\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.camera.pageMove(Direction.Left);\n    },\n  },\n  {\n    id: \"CameraPageMoveRight\",\n    defaultKey: isMac ? \"S-l\" : \"end\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.camera.pageMove(Direction.Right);\n    },\n  },\n\n  /*------- 章节/折叠/打包 -------*/\n  {\n    id: \"folderSection\",\n    defaultKey: \"C-t\",\n    onPress: (project) => project!.stageManager.sectionSwitchCollapse(),\n  },\n  {\n    id: \"packEntityToSection\",\n    defaultKey: \"C-g\",\n    onPress: (project) => {\n      // 检查是否有框选框并且舞台上没有选中任何物体\n      const rectangleSelect = project!.rectangleSelect;\n      const hasActiveRectangle = rectangleSelect.getRectangle() !== null;\n      const hasSelectedEntities = project!.stageManager.getEntities().some((entity) => entity.isSelected);\n      const hasSelectedEdges = project!.stageManager.getAssociations().some((edge) => edge.isSelected);\n      if (hasActiveRectangle && !hasSelectedEntities && !hasSelectedEdges) {\n        // 如果有框选框且没有选中任何物体，则在框选区域创建Section\n        project!.sectionPackManager.createSectionFromSelectionRectangle();\n      } else {\n        // 否则执行原来的打包功能\n        project!.sectionPackManager.packSelectedEntitiesToSection();\n      }\n    },\n  },\n  {\n    id: \"toggleSectionLock\",\n    defaultKey: \"C-l\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      const selectedSections = project!.stageManager.getSelectedEntities().filter((it) => it instanceof Section);\n      for (const section of selectedSections) {\n        section.locked = !section.locked;\n        project!.sectionRenderer.render(section);\n      }\n      // 记录历史步骤\n      project!.historyManager.recordStep();\n    },\n    defaultEnabled: false,\n  },\n\n  /*------- 边反向 -------*/\n  {\n    id: \"reverseEdges\",\n    defaultKey: \"C-t\",\n    onPress: (project) => project!.stageManager.reverseSelectedEdges(),\n  },\n  {\n    id: \"reverseSelectedNodeEdge\",\n    defaultKey: \"C-t\",\n    onPress: (project) => project!.stageManager.reverseSelectedNodeEdge(),\n  },\n\n  /*------- 创建无向边 -------*/\n  {\n    id: \"createUndirectedEdgeFromEntities\",\n    defaultKey: \"S-g\",\n    onPress: (project) => {\n      const selectedNodes = project!.stageManager\n        .getSelectedEntities()\n        .filter((node) => node instanceof ConnectableEntity);\n      if (selectedNodes.length <= 1) {\n        toast.error(\"至少选择两个可连接节点\");\n        return;\n      }\n      const multiTargetUndirectedEdge = MultiTargetUndirectedEdge.createFromSomeEntity(project!, selectedNodes);\n      project!.stageManager.add(multiTargetUndirectedEdge);\n    },\n  },\n\n  /*------- 删除 -------*/\n  {\n    id: \"deleteSelectedStageObjects\",\n    defaultKey: isMac ? \"backspace\" : \"delete\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.stageManager.deleteSelectedStageObjects();\n    },\n  },\n\n  /*------- 新建文本节点（多种方式） -------*/\n  {\n    id: \"createTextNodeFromCameraLocation\",\n    defaultKey: \"insert\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.camera.clearMoveCommander();\n      project!.camera.speed = Vector.getZero();\n      project!.controllerUtils.addTextNodeByLocation(project!.camera.location, true, true);\n    },\n  },\n  {\n    id: \"createTextNodeFromMouseLocation\",\n    defaultKey: \"S-insert\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.camera.clearMoveCommander();\n      project!.camera.speed = Vector.getZero();\n      project!.controllerUtils.addTextNodeByLocation(\n        project!.renderer.transformView2World(MouseLocation.vector()),\n        true,\n        true,\n      );\n    },\n  },\n  {\n    id: \"createTextNodeFromSelectedTop\",\n    defaultKey: \"A-arrowup\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.controllerUtils.addTextNodeFromCurrentSelectedNode(Direction.Up, true);\n    },\n  },\n  {\n    id: \"createTextNodeFromSelectedRight\",\n    defaultKey: \"A-arrowright\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.controllerUtils.addTextNodeFromCurrentSelectedNode(Direction.Right, true);\n    },\n  },\n  {\n    id: \"createTextNodeFromSelectedLeft\",\n    defaultKey: \"A-arrowleft\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.controllerUtils.addTextNodeFromCurrentSelectedNode(Direction.Left, true);\n    },\n  },\n  {\n    id: \"createTextNodeFromSelectedDown\",\n    defaultKey: \"A-arrowdown\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.controllerUtils.addTextNodeFromCurrentSelectedNode(Direction.Down, true);\n    },\n  },\n\n  /*------- 选择（单选/多选） -------*/\n  {\n    id: \"selectUp\",\n    defaultKey: \"arrowup\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.selectChangeEngine.selectUp();\n    },\n  },\n  {\n    id: \"selectDown\",\n    defaultKey: \"arrowdown\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.selectChangeEngine.selectDown();\n    },\n  },\n  {\n    id: \"selectLeft\",\n    defaultKey: \"arrowleft\",\n    onPress: (project) => project!.selectChangeEngine.selectLeft(),\n  },\n  {\n    id: \"selectRight\",\n    defaultKey: \"arrowright\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.selectChangeEngine.selectRight();\n    },\n  },\n  {\n    id: \"selectAdditionalUp\",\n    defaultKey: \"S-arrowup\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.selectChangeEngine.selectUp(true);\n    },\n  },\n  {\n    id: \"selectAdditionalDown\",\n    defaultKey: \"S-arrowdown\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.selectChangeEngine.selectDown(true);\n    },\n  },\n  {\n    id: \"selectAdditionalLeft\",\n    defaultKey: \"S-arrowleft\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.selectChangeEngine.selectLeft(true);\n    },\n  },\n  {\n    id: \"selectAdditionalRight\",\n    defaultKey: \"S-arrowright\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.selectChangeEngine.selectRight(true);\n    },\n  },\n\n  /*------- 移动选中实体 -------*/\n  {\n    id: \"moveUpSelectedEntities\",\n    defaultKey: \"C-arrowup\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      const entities = project!.stageManager.getEntities().filter((e) => e.isSelected);\n      if (entities.length > 0) {\n        const rect = entities[0].collisionBox.getRectangle();\n        const newRect = rect.clone();\n        newRect.location.y -= 100;\n        project!.effects.addEffect(\n          RectangleSlideEffect.verticalSlide(\n            rect,\n            newRect,\n            project!.stageStyleManager.currentStyle.effects.successShadow,\n          ),\n        );\n      }\n      project!.entityMoveManager.moveSelectedEntities(new Vector(0, -100));\n    },\n  },\n  {\n    id: \"moveDownSelectedEntities\",\n    defaultKey: \"C-arrowdown\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      const entities = project!.stageManager.getEntities().filter((e) => e.isSelected);\n      if (entities.length > 0) {\n        const rect = entities[0].collisionBox.getRectangle();\n        const newRect = rect.clone();\n        newRect.location.y += 100;\n        project!.effects.addEffect(\n          RectangleSlideEffect.verticalSlide(\n            rect,\n            newRect,\n            project!.stageStyleManager.currentStyle.effects.successShadow,\n          ),\n        );\n      }\n      project!.entityMoveManager.moveSelectedEntities(new Vector(0, 100));\n    },\n  },\n  {\n    id: \"moveLeftSelectedEntities\",\n    defaultKey: \"C-arrowleft\",\n    onPress: (project) => {\n      const entities = project!.stageManager.getEntities().filter((e) => e.isSelected);\n      if (entities.length > 0) {\n        const rect = entities[0].collisionBox.getRectangle();\n        const newRect = rect.clone();\n        newRect.location.x -= 100;\n        project!.effects.addEffect(\n          RectangleSlideEffect.horizontalSlide(\n            rect,\n            newRect,\n            project!.stageStyleManager.currentStyle.effects.successShadow,\n          ),\n        );\n      }\n      project!.entityMoveManager.moveSelectedEntities(new Vector(-100, 0));\n    },\n  },\n  {\n    id: \"moveRightSelectedEntities\",\n    defaultKey: \"C-arrowright\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      const entities = project!.stageManager.getEntities().filter((e) => e.isSelected);\n      if (entities.length > 0) {\n        const rect = entities[0].collisionBox.getRectangle();\n        const newRect = rect.clone();\n        newRect.location.x += 100;\n        project!.effects.addEffect(\n          RectangleSlideEffect.horizontalSlide(\n            rect,\n            newRect,\n            project!.stageStyleManager.currentStyle.effects.successShadow,\n          ),\n        );\n      }\n      project!.entityMoveManager.moveSelectedEntities(new Vector(100, 0));\n    },\n  },\n\n  /*------- 跳跃移动 -------*/\n  {\n    id: \"jumpMoveUpSelectedEntities\",\n    defaultKey: \"C-A-arrowup\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.entityMoveManager.jumpMoveSelectedConnectableEntities(new Vector(0, -100));\n    },\n  },\n  {\n    id: \"jumpMoveDownSelectedEntities\",\n    defaultKey: \"C-A-arrowdown\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.entityMoveManager.jumpMoveSelectedConnectableEntities(new Vector(0, 100));\n    },\n  },\n  {\n    id: \"jumpMoveLeftSelectedEntities\",\n    defaultKey: \"C-A-arrowleft\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.entityMoveManager.jumpMoveSelectedConnectableEntities(new Vector(-100, 0));\n    },\n  },\n  {\n    id: \"jumpMoveRightSelectedEntities\",\n    defaultKey: \"C-A-arrowright\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.entityMoveManager.jumpMoveSelectedConnectableEntities(new Vector(100, 0));\n    },\n  },\n\n  /*------- 编辑/详情 -------*/\n  {\n    id: \"editEntityDetails\",\n    defaultKey: \"C-enter\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.controllerUtils.editNodeDetailsByKeyboard();\n    },\n  },\n\n  /*------- 面板/窗口 -------*/\n  {\n    id: \"openColorPanel\",\n    defaultKey: \"F6\",\n    onPress: () => ColorWindow.open(),\n    isUI: true,\n  },\n  {\n    id: \"switchDebugShow\",\n    defaultKey: \"F3\",\n    onPress: async () => {\n      Settings.showDebug = !Settings.showDebug;\n    },\n    isUI: true,\n  },\n  {\n    id: \"selectAll\",\n    defaultKey: \"C-a\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.stageManager.selectAll();\n      toast.success(\n        <div>\n          <h2>已全选所有元素</h2>\n          <p>\n            {project!.stageManager.getSelectedEntities().length}个实体+\n            {project!.stageManager.getSelectedAssociations().length}个关系=\n            {project!.stageManager.getSelectedStageObjects().length}个舞台对象\n          </p>\n        </div>,\n      );\n      project!.effects.addEffect(ViewOutlineFlashEffect.normal(Color.Green.toNewAlpha(0.2)));\n    },\n  },\n\n  /*------- 章节打包/解包 -------*/\n  {\n    id: \"textNodeToSection\",\n    defaultKey: \"C-S-g\",\n    onPress: (project) => project!.sectionPackManager.textNodeToSection(),\n  },\n  {\n    id: \"unpackEntityFromSection\",\n    defaultKey: \"C-S-g\",\n    onPress: (project) => project!.sectionPackManager.unpackSelectedSections(),\n  },\n\n  /*------- 隐私模式 -------*/\n  {\n    id: \"checkoutProtectPrivacy\",\n    defaultKey: \"C-2\",\n    onPress: async () => {\n      Settings.protectingPrivacy = !Settings.protectingPrivacy;\n    },\n    isUI: true,\n  },\n\n  /*------- 搜索/外部打开 -------*/\n  {\n    id: \"searchText\",\n    defaultKey: \"C-f\",\n    onPress: () => FindWindow.open(),\n  },\n  {\n    id: \"openTextNodeByContentExternal\",\n    defaultKey: \"C-e\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project?.controller.pressingKeySet.clear(); // 防止打开prg文件时，ctrl+E持续按下\n      openBrowserOrFile(project!);\n    },\n  },\n\n  /*------- 顶部菜单窗口, UI操作 -------*/\n  {\n    id: \"clickAppMenuSettingsButton\",\n    defaultKey: \"S-!\",\n    onPress: () => SettingsWindow.open(\"settings\"),\n    isUI: true,\n  },\n  {\n    id: \"clickAppMenuRecentFileButton\",\n    defaultKey: \"S-#\",\n    onPress: () => RecentFilesWindow.open(),\n    isUI: true,\n  },\n  {\n    id: \"clickTagPanelButton\",\n    defaultKey: \"S-@\",\n    onPress: () => TagWindow.open(),\n    isUI: true,\n  },\n  {\n    id: \"switchActiveProject\",\n    defaultKey: \"C-tab\",\n    onPress: () => {\n      //\n\n      const projects = store.get(projectsAtom);\n      if (projects.length <= 1) {\n        toast.error(\"至少打开两个项目才能切换项目\");\n        return;\n      }\n      const activeProject = store.get(activeProjectAtom);\n      if (!activeProject) {\n        toast.error(\"当前没有活动项目，无法切换项目\");\n        return;\n      }\n      let activeProjectIndex = -1;\n      for (const p of projects) {\n        activeProjectIndex++;\n        if (p === activeProject) {\n          break;\n        }\n      }\n      const nextActiveProjectIndex = (activeProjectIndex + 1) % projects.length;\n      store.set(activeProjectAtom, projects[nextActiveProjectIndex]);\n    },\n    isUI: true,\n  },\n  {\n    id: \"switchActiveProjectReversed\",\n    defaultKey: \"C-S-tab\",\n    onPress: () => {\n      const projects = store.get(projectsAtom);\n      if (projects.length <= 1) {\n        toast.error(\"至少打开两个项目才能切换项目\");\n        return;\n      }\n      const activeProject = store.get(activeProjectAtom);\n      if (!activeProject) {\n        toast.error(\"当前没有活动项目，无法切换项目\");\n        return;\n      }\n      let activeProjectIndex = -1;\n      for (const p of projects) {\n        activeProjectIndex++;\n        if (p === activeProject) {\n          break;\n        }\n      }\n      const mod = (n: number, m: number) => {\n        return ((n % m) + m) % m;\n      };\n      const nextActiveProjectIndex = mod(activeProjectIndex - 1, projects.length);\n      store.set(activeProjectAtom, projects[nextActiveProjectIndex]);\n    },\n    isUI: true,\n  },\n  {\n    id: \"closeCurrentProjectTab\",\n    defaultKey: \"A-S-q\",\n    defaultEnabled: false,\n    onPress: async () => {\n      const project = store.get(activeProjectAtom);\n      if (!project) {\n        toast.error(\"当前没有打开的项目标签页\");\n        return;\n      }\n      const projects = store.get(projectsAtom);\n      if (project.state === ProjectState.Stashed) {\n        toast(\"文件还没有保存，但已经暂存，在“最近打开的文件”中可恢复文件\");\n      } else if (project.state === ProjectState.Unsaved) {\n        const response = await Dialog.buttons(\"是否保存更改？\", decodeURI(project.uri.toString()), [\n          { id: \"cancel\", label: \"取消\", variant: \"ghost\" },\n          { id: \"discard\", label: \"不保存\", variant: \"destructive\" },\n          { id: \"save\", label: \"保存\" },\n        ]);\n        if (response === \"save\") {\n          await project.save();\n        } else if (response === \"cancel\") {\n          return;\n        }\n      }\n      await project.dispose();\n      const result = projects.filter((p) => p.uri.toString() !== project.uri.toString());\n      const activeProjectIndex = projects.findIndex((p) => p.uri.toString() === project.uri.toString());\n      if (result.length > 0) {\n        if (activeProjectIndex === projects.length - 1) {\n          store.set(activeProjectAtom, result[activeProjectIndex - 1]);\n        } else {\n          store.set(activeProjectAtom, result[activeProjectIndex]);\n        }\n      } else {\n        store.set(activeProjectAtom, undefined);\n      }\n      store.set(projectsAtom, result);\n    },\n    isUI: true,\n  },\n\n  /*------- 文件操作 -------*/\n  {\n    id: \"saveFile\",\n    defaultKey: \"C-s\",\n    onPress: () => {\n      const activeProject = store.get(activeProjectAtom);\n      if (activeProject) {\n        activeProject.camera.clearMoveCommander();\n        activeProject.save();\n        if (Settings.clearHistoryWhenManualSave) {\n          activeProject.historyManager.clearHistory();\n        }\n        RecentFileManager.addRecentFileByUri(activeProject.uri);\n      }\n    },\n    isUI: true,\n  },\n  {\n    id: \"newDraft\",\n    defaultKey: \"C-n\",\n    onPress: () => onNewDraft(),\n    isUI: true,\n  },\n  {\n    id: \"newFileAtCurrentProjectDir\",\n    defaultKey: \"C-S-n\",\n    onPress: () => {\n      //\n      const activeProject = store.get(activeProjectAtom);\n      if (!activeProject) {\n        toast.error(\"当前没有激活的项目，无法在当前工程文件目录下创建新文件\");\n        return;\n      }\n      if (activeProject.isDraft) {\n        toast.error(\"当前为草稿状态，无法在当前工程文件目录下创建新文件\");\n        return;\n      }\n      createFileAtCurrentProjectDir(activeProject, async () => {});\n    },\n    isUI: true,\n    defaultEnabled: false,\n  },\n  {\n    id: \"openFile\",\n    defaultKey: \"C-o\",\n    onPress: () => onOpenFile(),\n    isUI: true,\n  },\n\n  /*------- 窗口透明度 -------*/\n  {\n    id: \"checkoutWindowOpacityMode\",\n    defaultKey: \"C-0\",\n    onPress: async () => {\n      Settings.windowBackgroundAlpha = Settings.windowBackgroundAlpha === 0 ? 1 : 0;\n    },\n    isUI: true,\n  },\n  {\n    id: \"windowOpacityAlphaIncrease\",\n    defaultKey: \"C-A-S-+\",\n    onPress: async (project) => {\n      const currentValue = Settings.windowBackgroundAlpha;\n      if (currentValue === 1) {\n        // 已经不能再大了\n        project!.effects.addEffect(ViewOutlineFlashEffect.short(project!.stageStyleManager.currentStyle.effects.flash));\n      } else {\n        Settings.windowBackgroundAlpha = Math.min(1, currentValue + 0.2);\n      }\n    },\n  },\n  {\n    id: \"windowOpacityAlphaDecrease\",\n    defaultKey: \"C-A-S--\",\n    onPress: async (project) => {\n      const currentValue = Settings.windowBackgroundAlpha;\n      if (currentValue === 0) {\n        // 已经不能再小了\n        project!.effects.addEffect(ViewOutlineFlashEffect.short(project!.stageStyleManager.currentStyle.effects.flash));\n      } else {\n        Settings.windowBackgroundAlpha = Math.max(0, currentValue - 0.2);\n      }\n    },\n  },\n\n  /*------- 复制粘贴 -------*/\n  {\n    id: \"copy\",\n    defaultKey: \"C-c\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.copyEngine.copy();\n    },\n  },\n  {\n    id: \"paste\",\n    defaultKey: \"C-v\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.copyEngine.paste();\n    },\n  },\n  {\n    id: \"pasteWithOriginLocation\",\n    defaultKey: \"C-S-v\",\n    onPress: () => toast(\"todo\"),\n  },\n\n  /*------- 鼠标模式切换 -------*/\n  {\n    id: \"checkoutLeftMouseToSelectAndMove\",\n    defaultKey: \"v v v\",\n    onPress: async (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      Settings.mouseLeftMode = \"selectAndMove\";\n      toast(\"当前鼠标左键已经切换为框选/移动模式\");\n    },\n  },\n  {\n    id: \"checkoutLeftMouseToDrawing\",\n    defaultKey: \"b b b\",\n    onPress: async (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      Settings.mouseLeftMode = \"draw\";\n      toast(\"当前鼠标左键已经切换为画笔模式\");\n    },\n  },\n  {\n    id: \"checkoutLeftMouseToConnectAndCutting\",\n    defaultKey: \"c c c\",\n    onPress: async (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      Settings.mouseLeftMode = \"connectAndCut\";\n      toast(\"当前鼠标左键已经切换为连接/切割模式\");\n    },\n  },\n\n  /*------- 笔选/扩展选择 -------*/\n  {\n    id: \"selectEntityByPenStroke\",\n    defaultKey: \"C-w\",\n    onPress: (project) => {\n      // 现在不生效了，不过也没啥用\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      PenStrokeMethods.selectEntityByPenStroke(project!);\n    },\n  },\n  {\n    id: \"expandSelectEntity\",\n    defaultKey: \"C-w\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.selectChangeEngine.expandSelect(false, false);\n    },\n  },\n  {\n    id: \"expandSelectEntityReversed\",\n    defaultKey: \"C-S-w\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.selectChangeEngine.expandSelect(false, true);\n    },\n  },\n  {\n    id: \"expandSelectEntityKeepLastSelected\",\n    defaultKey: \"C-A-w\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.selectChangeEngine.expandSelect(true, false);\n    },\n  },\n  {\n    id: \"expandSelectEntityReversedKeepLastSelected\",\n    defaultKey: \"C-A-S-w\",\n    onPress: async (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.selectChangeEngine.expandSelect(true, true);\n    },\n  },\n\n  /*------- 树/图 生成 -------*/\n  {\n    id: \"generateNodeTreeWithDeepMode\",\n    defaultKey: \"tab\",\n    onPress: async (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.keyboardOnlyTreeEngine.onDeepGenerateNode();\n    },\n  },\n  {\n    id: \"generateNodeTreeWithBroadMode\",\n    defaultKey: \"\\\\\",\n    onPress: async (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.keyboardOnlyTreeEngine.onBroadGenerateNode();\n    },\n  },\n  {\n    id: \"generateNodeGraph\",\n    defaultKey: \"`\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      if (project!.keyboardOnlyGraphEngine.isCreating()) {\n        project!.keyboardOnlyGraphEngine.createFinished();\n      } else {\n        if (project!.keyboardOnlyGraphEngine.isEnableVirtualCreate()) {\n          project!.keyboardOnlyGraphEngine.createStart();\n        }\n      }\n    },\n  },\n\n  /*------- 手刹/刹车 -------*/\n  // TODO: 这俩有点问题\n  {\n    id: \"masterBrakeControl\",\n    defaultKey: \"pause\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.camera.clearMoveCommander();\n      project!.camera.speed = Vector.getZero();\n    },\n  },\n  {\n    id: \"masterBrakeCheckout\",\n    defaultKey: \"space\",\n    onPress: async (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.camera.clearMoveCommander();\n      project!.camera.speed = Vector.getZero();\n      Settings.allowMoveCameraByWSAD = !Settings.allowMoveCameraByWSAD;\n    },\n  },\n\n  /*------- 树形调整 -------*/\n  {\n    id: \"treeGraphAdjust\",\n    defaultKey: \"A-S-f\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      const entities = project!.stageManager\n        .getSelectedEntities()\n        .filter((entity) => entity instanceof ConnectableEntity);\n      for (const entity of entities) {\n        project!.keyboardOnlyTreeEngine.adjustTreeNode(entity);\n      }\n      project?.controller.pressingKeySet.clear(); // 解决 mac 按下后容易卡键\n    },\n  },\n  {\n    id: \"treeGraphAdjustSelectedAsRoot\",\n    defaultKey: \"C-A-S-f\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      const entities = project!.stageManager\n        .getSelectedEntities()\n        .filter((entity) => entity instanceof ConnectableEntity);\n      for (const entity of entities) {\n        // 直接以选中节点为根节点进行格式化，不查找整个树的根节点\n        project!.autoAlign.autoLayoutSelectedFastTreeMode(entity);\n      }\n      project?.controller.pressingKeySet.clear(); // 解决 mac 按下后容易卡键\n    },\n  },\n  /*------- DAG调整 -------*/\n  {\n    id: \"dagGraphAdjust\",\n    defaultKey: \"A-S-d\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      const entities = project!.stageManager\n        .getSelectedEntities()\n        .filter((entity) => entity instanceof ConnectableEntity);\n      if (entities.length >= 2) {\n        if (project!.graphMethods.isDAGByNodes(entities)) {\n          project!.autoLayout.autoLayoutDAG(entities);\n        } else {\n          toast.error(\"选中的节点不构成有向无环图（DAG）\");\n        }\n        project?.controller.pressingKeySet.clear(); // 解决 mac 按下后容易卡键\n      }\n    },\n  },\n  {\n    id: \"gravityLayout\",\n    defaultKey: \"g\",\n    onPress: (project) => {\n      project?.autoLayout.setGravityLayoutStart();\n    },\n    onRelease: (project) => {\n      project?.autoLayout.setGravityLayoutEnd();\n    },\n  },\n  {\n    id: \"setNodeTreeDirectionUp\",\n    defaultKey: \"W W\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      const entities = project!.stageManager\n        .getSelectedEntities()\n        .filter((entity) => entity instanceof ConnectableEntity);\n      project?.keyboardOnlyTreeEngine.changePreDirection(entities, \"up\");\n    },\n  },\n  {\n    id: \"setNodeTreeDirectionDown\",\n    defaultKey: \"S S\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      const entities = project!.stageManager\n        .getSelectedEntities()\n        .filter((entity) => entity instanceof ConnectableEntity);\n      project?.keyboardOnlyTreeEngine.changePreDirection(entities, \"down\");\n    },\n  },\n  {\n    id: \"setNodeTreeDirectionLeft\",\n    defaultKey: \"A A\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      const entities = project!.stageManager\n        .getSelectedEntities()\n        .filter((entity) => entity instanceof ConnectableEntity);\n      project?.keyboardOnlyTreeEngine.changePreDirection(entities, \"left\");\n    },\n  },\n  {\n    id: \"setNodeTreeDirectionRight\",\n    defaultKey: \"D D\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      const entities = project!.stageManager\n        .getSelectedEntities()\n        .filter((entity) => entity instanceof ConnectableEntity);\n      project?.keyboardOnlyTreeEngine.changePreDirection(entities, \"right\");\n    },\n  },\n\n  /*------- 彩蛋/秘籍键 -------*/\n  {\n    // TODO 不触发了\n    id: \"screenFlashEffect\",\n    defaultKey: \"arrowup arrowup arrowdown arrowdown arrowleft arrowright arrowleft arrowright b a\",\n    onPress: (project) => project!.effects.addEffect(ViewFlashEffect.SaveFile()),\n  },\n  {\n    id: \"alignNodesToInteger\",\n    defaultKey: \"i n t j\",\n    onPress: (project) => {\n      const entities = project!.stageManager.getConnectableEntity();\n      for (const entity of entities) {\n        const leftTopLocation = entity.collisionBox.getRectangle().location;\n        const IntLocation = new Vector(Math.round(leftTopLocation.x), Math.round(leftTopLocation.y));\n        entity.moveTo(IntLocation);\n      }\n    },\n  },\n  {\n    id: \"toggleCheckmarkOnTextNodes\",\n    defaultKey: \"o k k\",\n    onPress: (project) => TextNodeSmartTools.okk(project!),\n  },\n  {\n    id: \"toggleCheckErrorOnTextNodes\",\n    defaultKey: \"e r r\",\n    onPress: (project) => TextNodeSmartTools.err(project!),\n  },\n  {\n    id: \"reverseImageColors\",\n    defaultKey: \"r r r\",\n    onPress: (project) => {\n      const selectedImageNodes: ImageNode[] = project!.stageManager\n        .getSelectedEntities()\n        .filter((node) => node instanceof ImageNode);\n      for (const node of selectedImageNodes) {\n        node.reverseColors();\n      }\n      if (selectedImageNodes.length > 0) {\n        toast(`已反转 ${selectedImageNodes.length} 张图片的颜色`);\n      }\n      project?.historyManager.recordStep();\n    },\n  },\n\n  /*------- 主题切换 -------*/\n  {\n    id: \"switchToDarkTheme\",\n    defaultKey: \"b l a c k k\",\n    onPress: () => {\n      toast.info(\"切换到暗黑主题\");\n      Settings.theme = \"dark\";\n      Themes.applyThemeById(\"dark\");\n    },\n    isUI: true,\n  },\n  {\n    id: \"switchToLightTheme\",\n    defaultKey: \"w h i t e e\",\n    onPress: () => {\n      toast.info(\"切换到明亮主题\");\n      Settings.theme = \"light\";\n      Themes.applyThemeById(\"light\");\n    },\n    isUI: true,\n  },\n  {\n    id: \"switchToParkTheme\",\n    defaultKey: \"p a r k k\",\n    onPress: () => {\n      toast.info(\"切换到公园主题\");\n      Settings.theme = \"park\";\n      Themes.applyThemeById(\"park\");\n    },\n    isUI: true,\n  },\n  {\n    id: \"switchToMacaronTheme\",\n    defaultKey: \"m k l m k l\",\n    onPress: () => {\n      toast.info(\"切换到马卡龙主题\");\n      Settings.theme = \"macaron\";\n      Themes.applyThemeById(\"macaron\");\n    },\n    isUI: true,\n  },\n  {\n    id: \"switchToMorandiTheme\",\n    defaultKey: \"m l d m l d\",\n    onPress: () => {\n      toast.info(\"切换到莫兰迪主题\");\n      Settings.theme = \"morandi\";\n      Themes.applyThemeById(\"morandi\");\n    },\n    isUI: true,\n  },\n\n  /*------- 画笔透明度 -------*/\n  {\n    id: \"increasePenAlpha\",\n    defaultKey: \"p s a + +\",\n    onPress: async (project) => project!.controller.penStrokeDrawing.changeCurrentStrokeColorAlpha(0.1),\n  },\n  {\n    id: \"decreasePenAlpha\",\n    defaultKey: \"p s a - -\",\n    onPress: async (project) => project!.controller.penStrokeDrawing.changeCurrentStrokeColorAlpha(-0.1),\n  },\n\n  /*------- 对齐 -------*/\n  {\n    id: \"alignTop\",\n    defaultKey: \"8 8\",\n    onPress: (project) => {\n      project!.layoutManager.alignTop();\n      project!.stageManager.changeSelectedEdgeConnectLocation(Direction.Up, true);\n      project!.stageManager.changeSelectedEdgeConnectLocation(Direction.Down);\n    },\n  },\n  {\n    id: \"alignBottom\",\n    defaultKey: \"2 2\",\n    onPress: (project) => {\n      project!.layoutManager.alignBottom();\n      project!.stageManager.changeSelectedEdgeConnectLocation(Direction.Down, true);\n      project!.stageManager.changeSelectedEdgeConnectLocation(Direction.Up);\n    },\n  },\n  {\n    id: \"alignLeft\",\n    defaultKey: \"4 4\",\n    onPress: (project) => {\n      project!.layoutManager.alignLeft();\n      project!.stageManager.changeSelectedEdgeConnectLocation(Direction.Left, true);\n      project!.stageManager.changeSelectedEdgeConnectLocation(Direction.Right);\n    },\n  },\n  {\n    id: \"alignRight\",\n    defaultKey: \"6 6\",\n    onPress: (project) => {\n      project!.layoutManager.alignRight();\n      project!.stageManager.changeSelectedEdgeConnectLocation(Direction.Right, true);\n      project!.stageManager.changeSelectedEdgeConnectLocation(Direction.Left);\n    },\n  },\n  {\n    id: \"alignHorizontalSpaceBetween\",\n    defaultKey: \"4 6 4 6\",\n    onPress: (project) => project!.layoutManager.alignHorizontalSpaceBetween(),\n  },\n  {\n    id: \"alignVerticalSpaceBetween\",\n    defaultKey: \"8 2 8 2\",\n    onPress: (project) => project!.layoutManager.alignVerticalSpaceBetween(),\n  },\n  {\n    id: \"alignCenterHorizontal\",\n    defaultKey: \"5 4 6\",\n    onPress: (project) => project!.layoutManager.alignCenterHorizontal(),\n  },\n  {\n    id: \"alignCenterVertical\",\n    defaultKey: \"5 8 2\",\n    onPress: (project) => project!.layoutManager.alignCenterVertical(),\n  },\n  {\n    id: \"alignLeftToRightNoSpace\",\n    defaultKey: \"4 5 6\",\n    onPress: (project) => project!.layoutManager.alignLeftToRightNoSpace(),\n  },\n  {\n    id: \"alignTopToBottomNoSpace\",\n    defaultKey: \"8 5 2\",\n    onPress: (project) => project!.layoutManager.alignTopToBottomNoSpace(),\n  },\n\n  /*------- 连接 -------*/\n  {\n    id: \"createConnectPointWhenDragConnecting\",\n    defaultKey: \"1\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      project!.controller.nodeConnection.createConnectPointWhenConnect();\n    },\n  },\n  {\n    id: \"connectAllSelectedEntities\",\n    defaultKey: \"- - a l l\",\n    onPress: (project) => ConnectNodeSmartTools.connectAll(project!),\n  },\n  {\n    id: \"connectLeftToRight\",\n    defaultKey: \"- - r i g h t\",\n    onPress: (project) => ConnectNodeSmartTools.connectRight(project!),\n  },\n  {\n    id: \"connectTopToBottom\",\n    defaultKey: \"- - d o w n\",\n    onPress: (project) => ConnectNodeSmartTools.connectDown(project!),\n  },\n\n  /*------- 选择所有可见边 -------*/\n  {\n    id: \"selectAllEdges\",\n    defaultKey: \"+ e d g e\",\n    onPress: (project) => {\n      const selectedEdges = project!.stageManager.getAssociations();\n      const viewRect = project!.renderer.getCoverWorldRectangle();\n      for (const edge of selectedEdges) {\n        if (project!.renderer.isOverView(viewRect, edge)) continue;\n        edge.isSelected = true;\n      }\n    },\n  },\n\n  /*------- 快速着色 -------*/\n  {\n    id: \"colorSelectedRed\",\n    defaultKey: \"; r e d\",\n    onPress: (project) => {\n      const selectedStageObject = project!.stageManager.getStageObjects().filter((obj) => obj.isSelected);\n      for (const obj of selectedStageObject) {\n        if (obj instanceof TextNode) {\n          obj.color = new Color(239, 68, 68);\n        }\n      }\n    },\n  },\n  {\n    id: \"increaseBrightness\",\n    defaultKey: \"b .\",\n    onPress: (project) => ColorSmartTools.increaseBrightness(project!),\n  },\n  {\n    id: \"decreaseBrightness\",\n    defaultKey: \"b ,\",\n    onPress: (project) => ColorSmartTools.decreaseBrightness(project!),\n  },\n  {\n    id: \"gradientColor\",\n    defaultKey: \"; ,\",\n    onPress: (project) => ColorSmartTools.gradientColor(project!),\n  },\n  {\n    id: \"changeColorHueUp\",\n    defaultKey: \"A-S-arrowup\",\n    onPress: (project) => ColorSmartTools.changeColorHueUp(project!),\n  },\n  {\n    id: \"changeColorHueDown\",\n    defaultKey: \"A-S-arrowdown\",\n    onPress: (project) => ColorSmartTools.changeColorHueDown(project!),\n  },\n  {\n    id: \"changeColorHueMajorUp\",\n    defaultKey: \"A-S-home\",\n    onPress: (project) => ColorSmartTools.changeColorHueMajorUp(project!),\n  },\n  {\n    id: \"changeColorHueMajorDown\",\n    defaultKey: \"A-S-end\",\n    onPress: (project) => ColorSmartTools.changeColorHueMajorDown(project!),\n  },\n\n  /*------- 文本节点工具 -------*/\n  {\n    id: \"toggleTextNodeSizeMode\",\n    defaultKey: \"t t t\",\n    onPress: (project) => TextNodeSmartTools.ttt(project!),\n  },\n  {\n    id: \"splitTextNodes\",\n    defaultKey: \"k e i\",\n    onPress: (project) => TextNodeSmartTools.kei(project!),\n  },\n  {\n    id: \"mergeTextNodes\",\n    defaultKey: \"r u a\",\n    onPress: (project) => TextNodeSmartTools.rua(project!),\n  },\n  {\n    id: \"swapTextAndDetails\",\n    defaultKey: \"e e e e e\",\n    onPress: (project) => TextNodeSmartTools.exchangeTextAndDetails(project!),\n  },\n\n  /*------- 潜行模式 -------*/\n  {\n    id: \"switchStealthMode\",\n    defaultKey: \"j a c k a l\",\n    onPress: () => {\n      Settings.isStealthModeEnabled = !Settings.isStealthModeEnabled;\n      toast(Settings.isStealthModeEnabled ? \"已开启潜行模式\" : \"已关闭潜行模式\");\n    },\n    isUI: true,\n  },\n\n  /*------- 拆分字符 -------*/\n  {\n    id: \"removeFirstCharFromSelectedTextNodes\",\n    defaultKey: \"C-backspace\",\n    onPress: (project) => TextNodeSmartTools.removeFirstCharFromSelectedTextNodes(project!),\n  },\n  {\n    id: \"removeLastCharFromSelectedTextNodes\",\n    defaultKey: \"C-delete\",\n    onPress: (project) => TextNodeSmartTools.removeLastCharFromSelectedTextNodes(project!),\n  },\n\n  /*------- 交换两实体位置 -------*/\n  {\n    id: \"swapTwoSelectedEntitiesPositions\",\n    defaultKey: \"S-r\",\n    onPress: (project) => {\n      // 这个东西废了，直接触发了软件刷新\n      // 这个东西没啥用，感觉得下掉\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      const selectedEntities = project!.stageManager.getSelectedEntities();\n      if (selectedEntities.length !== 2) return;\n      project!.historyManager.recordStep();\n      const [e1, e2] = selectedEntities;\n      const p1 = e1.collisionBox.getRectangle().location.clone();\n      const p2 = e2.collisionBox.getRectangle().location.clone();\n      e1.moveTo(p2);\n      e2.moveTo(p1);\n    },\n  },\n\n  /*------- 字体大小调整 -------*/\n  {\n    id: \"decreaseFontSize\",\n    defaultKey: \"C--\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      const selectedTextNodes = project!.stageManager\n        .getSelectedEntities()\n        .filter((node) => node instanceof TextNode) as TextNode[];\n      if (selectedTextNodes.length === 0) return;\n      project!.historyManager.recordStep();\n      for (const node of selectedTextNodes) {\n        node.decreaseFontSize(TextNodeSmartTools.getAnchorRateForTextNode(project!, node));\n      }\n    },\n  },\n  {\n    id: \"increaseFontSize\",\n    defaultKey: \"C-=\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      const selectedTextNodes = project!.stageManager\n        .getSelectedEntities()\n        .filter((node) => node instanceof TextNode) as TextNode[];\n      if (selectedTextNodes.length === 0) return;\n      project!.historyManager.recordStep();\n      for (const node of selectedTextNodes) {\n        node.increaseFontSize(TextNodeSmartTools.getAnchorRateForTextNode(project!, node));\n      }\n    },\n  },\n\n  /*------- 节点相关 -------*/\n  {\n    id: \"graftNodeToTree\",\n    defaultKey: \"q e\",\n    onPress: (project) => {\n      ConnectNodeSmartTools.insertNodeToTree(project!);\n    },\n  },\n  {\n    id: \"removeNodeFromTree\",\n    defaultKey: \"q r\",\n    onPress: (project) => {\n      ConnectNodeSmartTools.removeNodeFromTree(project!);\n    },\n  },\n  {\n    id: \"selectAtCrosshair\",\n    defaultKey: \"q q\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      const worldLocation = project!.camera.location.clone();\n      const entity = project!.stageManager.findEntityByLocation(worldLocation);\n      if (entity) {\n        if (!project!.sectionMethods.isObjectBeLockedBySection(entity)) {\n          // 单一选择：先取消所有选中\n          project!.stageManager.clearSelectAll();\n          entity.isSelected = true;\n        }\n      }\n    },\n  },\n  {\n    id: \"addSelectAtCrosshair\",\n    defaultKey: \"S-q\",\n    onPress: (project) => {\n      if (!project!.keyboardOnlyEngine.isOpenning()) return;\n      const worldLocation = project!.camera.location.clone();\n      const entity = project!.stageManager.findEntityByLocation(worldLocation);\n      if (entity) {\n        if (!project!.sectionMethods.isObjectBeLockedBySection(entity)) {\n          // 添加选择：切换选中状态\n          entity.isSelected = !entity.isSelected;\n        }\n      }\n    },\n  },\n];\n\n/**\n * 快捷键注册函数\n * 现在所有非全局快捷键都由KeyBindsUI类在应用启动时统一注册\n * 这个东西现在没用了\n */\n@service(\"keyBindsRegistrar\")\nexport class KeyBindsRegistrar {\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 注册所有快捷键\n   * 现在所有非全局快捷键都由KeyBindsUI类在应用启动时统一注册\n   */\n  async registerAllKeyBinds() {\n    // 所有非全局快捷键都由KeyBindsUI类在应用启动时统一注册\n    // 这里不再需要注册项目级快捷键\n  }\n}\n\nexport function getKeyBindTypeById(id: string): \"global\" | \"ui\" | \"project\" {\n  for (const keyBind of allKeyBinds) {\n    if (keyBind.id === id) {\n      return keyBind.isGlobal ? \"global\" : keyBind.isUI ? \"ui\" : \"project\";\n    }\n  }\n  return \"project\";\n}\n\nexport function isKeyBindHasRelease(id: string) {\n  for (const keyBind of allKeyBinds) {\n    if (keyBind.id === id) {\n      if (keyBind.onRelease) {\n        return true;\n      }\n    }\n  }\n  return false;\n}\n"
  },
  {
    "path": "app/src/core/service/controlService/stageMouseInteractionCore/stageMouseInteractionCore.tsx",
    "content": "import { Vector } from \"@graphif/data-structures\";\nimport { Project, service } from \"@/core/Project\";\nimport { Edge } from \"@/core/stage/stageObject/association/Edge\";\nimport { MultiTargetUndirectedEdge } from \"@/core/stage/stageObject/association/MutiTargetUndirectedEdge\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { ConnectPoint } from \"@/core/stage/stageObject/entity/ConnectPoint\";\n\n@service(\"mouseInteraction\")\nexport class MouseInteraction {\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 鼠标悬浮的边\n   */\n  private _hoverEdges: Edge[] = [];\n\n  /** 鼠标悬浮的框 */\n  // 2.0.22 开始，取消section悬浮状态，因为感觉没什么必要\n  // 不不不，有必要，在界面缩小的时候点击式连线，能很方便的看到section的碰撞箱\n  private _hoverSections: Section[] = [];\n\n  // 2.0.22 开始，增加质点的悬浮状态\n  private _hoverConnectPoints: ConnectPoint[] = [];\n  /**\n   * 鼠标悬浮的多边形边\n   */\n  private _hoverMultiTargetEdges: MultiTargetUndirectedEdge[] = [];\n\n  get hoverEdges(): Edge[] {\n    return this._hoverEdges;\n  }\n\n  get firstHoverEdge(): Edge | undefined {\n    return this._hoverEdges.length > 0 ? this._hoverEdges[0] : undefined;\n  }\n\n  get hoverSections(): Section[] {\n    return this._hoverSections;\n  }\n\n  get hoverConnectPoints(): ConnectPoint[] {\n    return this._hoverConnectPoints;\n  }\n\n  get firstHoverSection(): Section | undefined {\n    return this._hoverSections.length > 0 ? this._hoverSections[0] : undefined;\n  }\n  get hoverMultiTargetEdges(): MultiTargetUndirectedEdge[] {\n    return this._hoverMultiTargetEdges;\n  }\n\n  get firstHoverMultiTargetEdge(): MultiTargetUndirectedEdge | undefined {\n    return this._hoverMultiTargetEdges.length > 0 ? this._hoverMultiTargetEdges[0] : undefined;\n  }\n\n  /**\n   * mousemove 事件触发此函数\n   * 要确保此函数只会被外界的一个地方调用，因为mousemove事件会频繁触发\n   * @param mouseWorldLocation\n   */\n  public updateByMouseMove(mouseWorldLocation: Vector): void {\n    // 更新 Edge状态\n    this._hoverEdges = [];\n    for (const edge of this.project.stageManager.getEdges()) {\n      if (edge.isHiddenBySectionCollapse) {\n        continue;\n      }\n      if (edge.collisionBox.isContainsPoint(mouseWorldLocation)) {\n        this._hoverEdges.push(edge);\n      }\n    }\n    // 更新 MultiTargetUndirectedEdge状态\n    this._hoverMultiTargetEdges = [];\n    for (const edge of this.project.stageManager\n      .getAssociations()\n      .filter((association) => association instanceof MultiTargetUndirectedEdge)) {\n      if (edge.collisionBox.isContainsPoint(mouseWorldLocation)) {\n        this._hoverMultiTargetEdges.push(edge);\n      }\n    }\n\n    // 更新 Section状态\n    this._hoverSections = [];\n    const sections = this.project.stageManager.getSections();\n\n    for (const section of sections) {\n      if (section.collisionBox.isContainsPoint(mouseWorldLocation)) {\n        this._hoverSections.push(section);\n      }\n    }\n\n    // 更新质点的状态\n    this._hoverConnectPoints = [];\n    for (const connectPoint of this.project.stageManager.getConnectPoints()) {\n      if (connectPoint.collisionBox.isContainsPoint(mouseWorldLocation)) {\n        this._hoverConnectPoints.push(connectPoint);\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataFileService/AutoSaveBackupService.tsx",
    "content": "import { Project, ProjectState, service } from \"@/core/Project\";\nimport { appCacheDir } from \"@tauri-apps/api/path\";\nimport { join } from \"@tauri-apps/api/path\";\nimport { exists, writeFile, readDir, stat, remove, mkdir } from \"@tauri-apps/plugin-fs\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { toast } from \"sonner\";\nimport { PathString } from \"@/utils/pathString\";\n\n/**\n * 自动保存与备份系统\n *\n * 自动备份：\n * 超过限制时删除老文件\n * 保存在 C:\\Users\\{userName}\\AppData\\Local\\liren.project-graph\\备份文件夹 下\n */\n@service(\"autoSaveBackup\")\nexport class AutoSaveBackupService {\n  // 上次备份时间\n  private lastBackupTime = 0;\n  // 上次备份内容的哈希值\n  private lastBackupHash = \"\";\n\n  private lastSaveTime = 0;\n\n  constructor(private readonly project: Project) {\n    this.lastBackupTime = Date.now();\n  }\n\n  /**\n   * 高频率调用的tick函数，内部实现降频操作\n   */\n  tick() {\n    const now = Date.now();\n\n    // 检查是否达到备份间隔时间（转换为毫秒）\n    if (Settings.autoBackup) {\n      if (now - this.lastBackupTime >= Settings.autoBackupInterval * 1000) {\n        this.lastBackupTime = now;\n        this.autoBackup();\n      }\n    }\n    if (Settings.autoSave) {\n      if (now - this.lastSaveTime >= Settings.autoSaveInterval * 1000) {\n        this.lastSaveTime = now;\n        this.autoSave();\n      }\n    }\n  }\n\n  private async autoSave() {\n    if (!this.project.uri || this.project.isDraft) {\n      // 临时草稿先不备份\n      return;\n    }\n    if (this.project.state === ProjectState.Unsaved) {\n      this.project.save();\n    }\n  }\n\n  /**\n   * 执行自动备份操作\n   */\n  private async autoBackup() {\n    try {\n      const currentHash = this.project.stageHash;\n      // 检查是否与上次备份有差异\n      if (currentHash === this.lastBackupHash) {\n        return;\n      }\n\n      // 确定备份目录路径\n      let backupDir;\n\n      // 检查是否设置了自定义备份路径\n      if (Settings.autoBackupCustomPath) {\n        try {\n          // 使用自定义备份路径，为每个项目创建子目录\n          backupDir = await join(Settings.autoBackupCustomPath, PathString.fileNameSafity(this.getOriginalFileName()));\n          if (!(await exists(backupDir))) {\n            try {\n              await mkdir(backupDir, { recursive: true });\n            } catch (err) {\n              // 创建失败，显示错误提示并使用默认路径\n              toast.error(`无法在自定义路径创建备份目录: ${err}`);\n              // 重置为使用默认路径\n              backupDir = await join(\n                await appCacheDir(),\n                \"auto-backup-v2\",\n                PathString.fileNameSafity(this.getOriginalFileName()),\n              );\n            }\n          }\n        } catch (err) {\n          // 使用自定义路径出错，回退到默认路径\n          toast.error(`使用自定义备份路径出错: ${err}`);\n          backupDir = await join(\n            await appCacheDir(),\n            \"auto-backup-v2\",\n            PathString.fileNameSafity(this.getOriginalFileName()),\n          );\n        }\n      } else {\n        // 使用默认备份路径\n        backupDir = await join(\n          await appCacheDir(),\n          \"auto-backup-v2\",\n          PathString.fileNameSafity(this.getOriginalFileName()),\n        );\n      }\n\n      await this.backupCurrentProject(backupDir);\n\n      // 更新上次备份的哈希值\n      this.lastBackupHash = currentHash;\n\n      // 管理备份文件数量\n      await this.manageBackupFiles(backupDir);\n    } catch (err) {\n      toast.error(\"自动备份过程中发生错误:\" + err);\n    }\n  }\n\n  public async manualBackup() {\n    try {\n      const backupDir = await join(await appCacheDir(), \"manual-backup-v2\");\n      await this.backupCurrentProject(backupDir);\n    } catch (err) {\n      toast.error(\"备份过程中发生错误:\" + err);\n    }\n  }\n\n  private async backupCurrentProject(backupDir: string) {\n    // 确保备份目录存在\n    if (!(await exists(backupDir))) {\n      try {\n        // 创建备份目录\n        await mkdir(backupDir);\n      } catch (err) {\n        toast.error(`创建备份目录失败: ${err}`);\n        return;\n      }\n    }\n\n    // 生成备份文件名\n    const fileName = this.generateBackupFileName();\n    const backupFilePath = await join(backupDir, fileName);\n\n    // 创建备份文件\n    await this.createBackupFile(backupFilePath);\n  }\n\n  /**\n   * 生成备份文件名\n   */\n  private generateBackupFileName(): string {\n    const now = new Date();\n    const timestamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, \"0\")}-${String(now.getDate()).padStart(2, \"0\")}_${String(now.getHours()).padStart(2, \"0\")}-${String(now.getMinutes()).padStart(2, \"0\")}-${String(now.getSeconds()).padStart(2, \"0\")}`;\n\n    // 获取原始文件名（不包含扩展名）\n    const originalFileName = this.getOriginalFileName();\n\n    return `${originalFileName}-${timestamp}.prg`;\n  }\n\n  /**\n   * 获取原始文件名（不包含扩展名）\n   */\n  private getOriginalFileName(): string {\n    if (!this.project.uri || this.project.isDraft) {\n      return \"Draft\";\n    }\n    try {\n      const uriStr = decodeURI(this.project.uri.toString());\n      const nameWithoutExt = PathString.getFileNameFromPath(uriStr);\n      return nameWithoutExt || \"unnamed\";\n    } catch {\n      return \"unnamed\";\n    }\n  }\n\n  /**\n   * 创建备份文件\n   */\n  private async createBackupFile(backupFilePath: string): Promise<void> {\n    try {\n      // 复制项目保存逻辑，但写入到备份文件路径\n      const fileContent = await this.project.getFileContent();\n\n      // 写入备份文件\n      await writeFile(backupFilePath, fileContent);\n      toast.success(`备份成功：${backupFilePath}`);\n    } catch (err) {\n      toast.error(\"创建备份文件失败:\" + err);\n      throw err;\n    }\n  }\n\n  /**\n   * 管理备份文件数量，删除过旧的备份文件\n   */\n  private async manageBackupFiles(backupDir: string): Promise<void> {\n    try {\n      // 获取备份目录中的所有文件\n      const files = await readDir(backupDir);\n\n      // 过滤出.prg文件并获取文件信息\n      const prgFiles = [];\n      for (const file of files) {\n        if (file.name.endsWith(\".prg\")) {\n          try {\n            const fileStat = await stat(await join(backupDir, file.name));\n            prgFiles.push({\n              name: file.name,\n              mtime: fileStat.mtime,\n            });\n          } catch {\n            // 忽略无法获取状态的文件\n          }\n        }\n      }\n\n      // 按修改时间排序（最新的在前）\n      prgFiles.sort((a, b) => {\n        const dateA = a.mtime ? new Date(a.mtime).getTime() : 0;\n        const dateB = b.mtime ? new Date(b.mtime).getTime() : 0;\n        return dateB - dateA;\n      });\n\n      // 获取设置的备份数量限制\n      const maxBackupCount = Settings.autoBackupLimitCount;\n\n      // 删除超出限制的旧备份\n      if (prgFiles.length > maxBackupCount) {\n        const filesToDelete = prgFiles.slice(maxBackupCount);\n        for (const fileToDelete of filesToDelete) {\n          try {\n            await remove(await join(backupDir, fileToDelete.name));\n          } catch (err) {\n            toast.error(`删除旧备份文件 ${fileToDelete.name} 失败: ${err}`);\n            // 继续尝试删除其他文件\n          }\n        }\n      }\n    } catch (err) {\n      toast.error(`管理备份文件失败: ${err}`);\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataFileService/RecentFileManager.tsx",
    "content": "import { createStore } from \"@/utils/store\";\nimport { exists } from \"@tauri-apps/plugin-fs\";\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport { URI } from \"vscode-uri\";\n\n/**\n * 管理最近打开的文件列表\n * 有数据持久化机制\n */\nexport namespace RecentFileManager {\n  let store: Store;\n\n  export type RecentFile = {\n    uri: URI;\n    /**\n     * 上次保存或打开的时间戳\n     */\n    time: number;\n  };\n\n  export async function init() {\n    store = await createStore(\"recent-files2.json\");\n    await store.save();\n  }\n\n  /**\n   * 增加一个最近打开的文件\n   * @param file\n   */\n  export async function addRecentFile(file: RecentFile) {\n    // 如果已经有了，则先删除\n    const existingFiles = await getRecentFiles();\n    const existingIndex = existingFiles.findIndex((f) => f.uri.toString() === file.uri.toString());\n    if (existingIndex >= 0) {\n      existingFiles.splice(existingIndex, 1); // 删除已有记录\n    }\n\n    existingFiles.push(file); // 添加新文件\n\n    await store.set(\n      \"recentFiles\",\n      existingFiles.map((f) => ({ ...f, uri: f.uri.toString() })),\n    ); // 更新存储\n    await store.save();\n  }\n\n  export async function addRecentFileByUri(uri: URI) {\n    await addRecentFile({\n      uri: uri,\n      time: new Date().getTime(),\n    });\n  }\n\n  export async function addRecentFilesByUris(uris: URI[]) {\n    // 先去重\n    const uniqueUris = Array.from(new Set(uris.map((u) => u.toString()))).map((str) => URI.parse(str));\n    const existingFiles = await getRecentFiles();\n    for (const uri of uniqueUris) {\n      const addFile = {\n        uri: uri,\n        time: new Date().getTime(),\n      };\n      if (!existingFiles.some((f) => f.uri.toString() === addFile.uri.toString())) {\n        existingFiles.push(addFile); // 添加新文件\n      }\n    }\n    await store.set(\n      \"recentFiles\",\n      existingFiles.map((f) => ({ ...f, uri: f.uri.toString() })),\n    ); // 更新存储\n    await store.save();\n  }\n\n  /**\n   * 删除一条历史记录\n   * @param path\n   */\n  export async function removeRecentFileByUri(uri: URI) {\n    const existingFiles = await getRecentFiles();\n    const existingIndex = existingFiles.findIndex((f) => f.uri.toString() === uri.toString());\n    if (existingIndex >= 0) {\n      existingFiles.splice(existingIndex, 1); // 删除已有记录\n      await store.set(\n        \"recentFiles\",\n        existingFiles.map((f) => ({ ...f, uri: f.uri.toString() })),\n      ); // 更新存储\n      await store.save();\n      return true;\n    }\n    return false;\n  }\n\n  /**\n   * 清空所有历史记录\n   */\n  export async function clearAllRecentFiles() {\n    await store.set(\"recentFiles\", []); // 清空列表\n    await store.save();\n  }\n\n  /**\n   * 获取最近打开的文件列表\n   */\n  export async function getRecentFiles(): Promise<RecentFile[]> {\n    const data = ((await store.get(\"recentFiles\")) as any[]) || [];\n    // 恢复为Uri对象\n    return data.map((f) => ({\n      ...f,\n      uri: typeof f.uri === \"string\" ? URI.parse(f.uri) : f.uri,\n    }));\n  }\n\n  /**\n   * 刷新最近打开的文件列表\n   * 从缓存中读取每个文件的路径，检查文件是否存在\n   * 如果不存在，则删除该条记录\n   */\n  export async function validAndRefreshRecentFiles() {\n    const recentFiles = await getRecentFiles();\n    const recentFilesValid: RecentFile[] = [];\n\n    // 是否存在文件丢失情况\n    let isFileLost = false;\n\n    for (const file of recentFiles) {\n      try {\n        const isExists = await exists(file.uri.toString());\n        if (isExists) {\n          recentFilesValid.push(file); // 存在则保留\n        } else {\n          isFileLost = true;\n        }\n      } catch (e) {\n        console.error(\"无法检测文件是否存在：\", file.uri.toString());\n        console.error(e);\n      }\n    }\n    if (isFileLost) {\n      await store.set(\n        \"recentFiles\",\n        recentFilesValid.map((f) => ({ ...f, uri: f.uri.toString() })),\n      ); // 更新存储\n    }\n  }\n\n  /**\n   * 最终按时间戳排序，最近的在最前面\n   */\n  export async function sortTimeRecentFiles() {\n    const recentFiles = await getRecentFiles();\n    // 新的在前面\n    recentFiles.sort((a, b) => b.time - a.time);\n    await store.set(\n      \"recentFiles\",\n      recentFiles.map((f) => ({ ...f, uri: f.uri.toString() })),\n    ); // 更新存储\n    await store.save();\n  }\n\n  /**\n   * 清空最近打开的文件列表，用户手动清除\n   */\n  export async function clearRecentFiles() {\n    await store.set(\"recentFiles\", []); // 清空列表\n    await store.save();\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataFileService/StartFilesManager.tsx",
    "content": "import { exists } from \"@tauri-apps/plugin-fs\";\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport { createStore } from \"@/utils/store\";\n\nexport namespace StartFilesManager {\n  let store: Store;\n\n  export type StartFile = {\n    /**\n     * 绝对路径\n     */\n    path: string;\n    /**\n     * 上次改动或打开的时间戳\n     */\n    time: number;\n  };\n\n  export async function init() {\n    store = await createStore(\"start-files.json\");\n    store.save();\n  }\n\n  export async function clearStartFiles() {\n    await store.set(\"startFiles\", []);\n    await store.set(\"currentStartFile\", \"\");\n    store.save();\n    return true;\n  }\n\n  export async function getStartFiles(): Promise<StartFile[]> {\n    const data = ((await store.get(\"startFiles\")) as StartFile[]) || [];\n    return data; // 返回最近文件列表\n  }\n  export async function getCurrentStartFile(): Promise<string> {\n    return ((await store.get(\"currentStartFile\")) as string) || \"\"; // 返回当前打开的文件\n  }\n\n  export async function setCurrentStartFile(filePath: string) {\n    if (filePath === \"\") {\n      return false; // 空路径不处理\n    }\n    let isFind = false;\n    for (const file of await getStartFiles()) {\n      if (file.path === filePath) {\n        isFind = true;\n        break;\n      }\n    }\n    if (!isFind) {\n      return false;\n    }\n    await store.set(\"currentStartFile\", filePath);\n    await store.save();\n    return true;\n  }\n\n  export async function addStartFile(filePath: string) {\n    const existingFiles = await getStartFiles();\n    for (const file of existingFiles) {\n      if (file.path === filePath) {\n        return false; // 文件已存在，不再添加\n      }\n    }\n    existingFiles.push({\n      path: filePath,\n      time: Date.now(),\n    });\n    await store.set(\"startFiles\", existingFiles);\n    await store.save();\n    return true;\n  }\n\n  export async function removeStartFile(filePath: string) {\n    const existingFiles = await getStartFiles();\n    // 先检测有没有\n    let isFind = false;\n    for (const file of existingFiles) {\n      if (file.path === filePath) {\n        isFind = true;\n        break;\n      }\n    }\n    if (!isFind) {\n      return false;\n    }\n    // 开始删除\n    // 看看删掉的是不是已经选择的\n    const currentFile = await getCurrentStartFile();\n    if (currentFile === filePath) {\n      await store.set(\"currentStartFile\", \"\");\n    }\n    const newFiles = existingFiles.filter((file) => file.path !== filePath);\n    await store.set(\"startFiles\", newFiles);\n    await store.save();\n    return true;\n  }\n\n  export async function validateAndRefreshStartFiles() {\n    const startFiles = await getStartFiles();\n    const startFilesValid: StartFile[] = [];\n\n    // 是否存在文件丢失情况\n    let isFileLost = false;\n\n    for (const file of startFiles) {\n      try {\n        const isExists = await exists(file.path);\n        if (isExists) {\n          startFilesValid.push(file); // 存在则保留\n        } else {\n          isFileLost = true;\n        }\n      } catch (e) {\n        console.error(\"无法检测文件是否存在：\", file.path);\n        console.error(e);\n      }\n    }\n    if (isFileLost) {\n      await store.set(\"startFiles\", startFilesValid); // 更新存储\n      await store.save();\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataGenerateService/autoComputeEngine/AutoComputeUtils.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { ProgramFunctions } from \"@/core/service/dataGenerateService/autoComputeEngine/functions/programLogic\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { Color, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { v4 as uuidv4 } from \"uuid\";\n\n/**\n * 一些在自动计算引擎中\n * 常用的工具函数\n */\n@service(\"autoComputeUtils\")\nexport class AutoComputeUtils {\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 获取一个节点的所有直接父节点，按x坐标排序\n   * @param node\n   * @returns\n   */\n  getParentTextNodes(node: TextNode): TextNode[] {\n    const parents = this.project.graphMethods.nodeParentArray(node).filter((node) => node instanceof TextNode);\n    // 将parents按x的坐标排序，小的在前面\n    parents.sort((a, b) => {\n      return a.collisionBox.getRectangle().location.x - b.collisionBox.getRectangle().location.x;\n    });\n    return parents;\n  }\n\n  getParentEntities(node: TextNode): ConnectableEntity[] {\n    const parents = this.project.graphMethods.nodeParentArray(node);\n    // 将parents按x的坐标排序，小的在前面\n    parents.sort((a, b) => {\n      return a.collisionBox.getRectangle().location.x - b.collisionBox.getRectangle().location.x;\n    });\n    return parents;\n  }\n\n  /**\n   * 获取一个节点的所有直接子节点，按x坐标排序\n   * @param node\n   * @returns\n   */\n  getChildTextNodes(node: TextNode): TextNode[] {\n    return this.project.graphMethods\n      .nodeChildrenArray(node)\n      .filter((node) => node instanceof TextNode)\n      .sort((a, b) => a.collisionBox.getRectangle().location.x - b.collisionBox.getRectangle().location.x);\n  }\n\n  /**\n   * 更改一个TextNode节点的所有子节点名字，如果没有子节点，则新建一个节点\n   * @param node\n   * @param resultText\n   */\n  getNodeOneResult(node: TextNode, resultText: string) {\n    const childrenList = this.project.graphMethods.nodeChildrenArray(node).filter((node) => node instanceof TextNode);\n    if (childrenList.length > 0) {\n      for (const child of childrenList) {\n        child.rename(resultText);\n      }\n    } else {\n      // 新建一个节点生长出去\n      const rect = node.collisionBox.getRectangle();\n      const newNode = new TextNode(this.project, {\n        uuid: uuidv4(),\n        text: resultText,\n        // 将 location 和 size 合并到 collisionBox 中\n        collisionBox: new CollisionBox([\n          new Rectangle(\n            new Vector(rect.location.x, rect.location.y + 100), // 原来的 location\n            new Vector(100, 100), // 原来的 size\n          ),\n        ]),\n        // 将 color 数组转换为 Color 对象\n        color: Color.Transparent.clone(), // [0, 0, 0, 0] 对应 Color.Transparent\n      });\n      this.project.stageManager.add(newNode);\n      this.project.stageManager.connectEntity(node, newNode);\n    }\n  }\n\n  /**\n   * 更改一个section节点的所有子节点名字，如果没有子节点，则新建一个节点\n   * @param section\n   * @param resultText\n   */\n  getSectionOneResult(section: Section, resultText: string) {\n    const childrenList = this.project.graphMethods\n      .nodeChildrenArray(section)\n      .filter((node) => node instanceof TextNode);\n    if (childrenList.length > 0) {\n      for (const child of childrenList) {\n        child.rename(resultText);\n      }\n    } else {\n      // 新建一个节点生长出去\n      const rect = section.collisionBox.getRectangle();\n      const newNode = new TextNode(this.project, {\n        uuid: uuidv4(),\n        text: resultText,\n        collisionBox: new CollisionBox([\n          new Rectangle(new Vector(rect.location.x, rect.bottom + 100), new Vector(100, 100)),\n        ]),\n        color: Color.Transparent.clone(),\n      });\n      this.project.stageManager.add(newNode);\n      this.project.stageManager.connectEntity(section, newNode);\n    }\n  }\n\n  getSectionMultiResult(section: Section, resultTextList: string[]) {\n    let childrenList = this.project.graphMethods.nodeChildrenArray(section).filter((node) => node instanceof TextNode);\n    if (childrenList.length < resultTextList.length) {\n      // 子节点数量不够，需要新建节点\n      const needCount = resultTextList.length - childrenList.length;\n      for (let j = 0; j < needCount; j++) {\n        const rect = section.collisionBox.getRectangle();\n        const newNode = new TextNode(this.project, {\n          uuid: uuidv4(),\n          text: \"\",\n          collisionBox: new CollisionBox([\n            new Rectangle(\n              new Vector(rect.location.x, rect.bottom + 100 + j * 100), // 原来的 location\n              new Vector(100, 100),\n            ),\n          ]),\n          color: Color.Transparent.clone(),\n        });\n        this.project.stageManager.add(newNode);\n        this.project.stageManager.connectEntity(section, newNode);\n      }\n    }\n    // 子节点数量够了，直接修改，顺序是从上到下\n    childrenList = this.project.graphMethods\n      .nodeChildrenArray(section)\n      .filter((node) => node instanceof TextNode)\n      .sort(\n        (node1, node2) => node1.collisionBox.getRectangle().location.y - node2.collisionBox.getRectangle().location.y,\n      );\n    // 开始修改\n    for (let i = 0; i < resultTextList.length; i++) {\n      childrenList[i].rename(resultTextList[i]);\n    }\n  }\n\n  /**\n   * 生成一个节点的多个结果\n   * 如果子节点数量不够，则新建节点\n   * 如果子节点数量超过，则不修改多余节点\n   * @param node\n   * @param resultTextList\n   */\n  generateMultiResult(node: TextNode, resultTextList: string[]) {\n    if (resultTextList.length === 0) {\n      return;\n    }\n    // 先把子节点数量凑够\n    let childrenList = this.project.graphMethods.nodeChildrenArray(node).filter((node) => node instanceof TextNode);\n    if (childrenList.length < resultTextList.length) {\n      // 子节点数量不够，需要新建节点\n      const needCount = resultTextList.length - childrenList.length;\n      for (let j = 0; j < needCount; j++) {\n        const rect = node.collisionBox.getRectangle();\n        const newNode = new TextNode(this.project, {\n          uuid: uuidv4(),\n          text: \"\",\n          collisionBox: new CollisionBox([\n            new Rectangle(\n              new Vector(rect.location.x, rect.location.y + 100 + j * 100), // 原来的 location\n              new Vector(100, 100),\n            ),\n          ]),\n          color: Color.Transparent.clone(),\n        });\n        this.project.stageManager.add(newNode);\n        this.project.stageManager.connectEntity(node, newNode);\n      }\n    }\n    // 子节点数量够了，直接修改，顺序是从上到下\n    childrenList = this.project.graphMethods\n      .nodeChildrenArray(node)\n      .filter((node) => node instanceof TextNode)\n      .sort(\n        (node1, node2) => node1.collisionBox.getRectangle().location.y - node2.collisionBox.getRectangle().location.y,\n      );\n    // 开始修改\n    for (let i = 0; i < resultTextList.length; i++) {\n      childrenList[i].rename(resultTextList[i]);\n    }\n  }\n\n  /**\n   * 将字符串转换为数字\n   * @param str\n   * @returns\n   */\n  stringToNumber(str: string) {\n    if (ProgramFunctions.isHaveVar(this.project, str)) {\n      return parseFloat(ProgramFunctions.getVarInCore(this.project, str));\n    }\n    return parseFloat(str);\n  }\n\n  /**\n   * 判断一个节点是否和逻辑节点直接相连\n   * 同时判断是否有逻辑节点的父节点或子节点\n   * @param node\n   */\n  isNodeConnectedWithLogicNode(node: ConnectableEntity): boolean {\n    for (const fatherNode of this.project.graphMethods.nodeParentArray(node)) {\n      if (fatherNode instanceof TextNode && this.isNameIsLogicNode(fatherNode.text)) {\n        return true;\n      }\n    }\n    for (const childNode of this.project.graphMethods.nodeChildrenArray(node)) {\n      if (childNode instanceof TextNode && this.isNameIsLogicNode(childNode.text)) {\n        return true;\n      }\n    }\n    return false;\n  }\n\n  /**\n   * 判断一个节点的名字格式是否符合逻辑节点的格式\n   * 1：以#开头，以#结尾，总共只能有两个#\n   * 2：中间只有数字、大写字母、下划线\n   * @param name\n   */\n  isNameIsLogicNode(name: string): boolean {\n    const reg = /^#[a-zA-Z0-9_]+#$/;\n    return reg.test(name);\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataGenerateService/autoComputeEngine/functions/compareLogic.tsx",
    "content": "/**\n * 这里存放所有比较逻辑函数\n * 本来是二元比较，但是扩展成多元比较了\n */\n\nexport namespace CompareFunctions {\n  /**\n   * 判断是否是严格递减序列\n   * 如果是，返回[1] 否则返回[0]\n   * @param numbers\n   * @returns\n   */\n  export function lessThan(numbers: number[]): number[] {\n    for (let i = 0; i < numbers.length - 1; i++) {\n      if (numbers[i] >= numbers[i + 1]) {\n        return [0];\n      }\n    }\n    return [1];\n  }\n\n  /**\n   * 判断是否是严格递增序列\n   * 如果是，返回[1] 否则返回[0]\n   * @param numbers\n   * @returns\n   */\n  export function greaterThan(numbers: number[]): number[] {\n    for (let i = 0; i < numbers.length - 1; i++) {\n      if (numbers[i] <= numbers[i + 1]) {\n        return [0];\n      }\n    }\n    return [1];\n  }\n\n  /**\n   * 判断是否是非严格单调递增序列\n   * 如果是，返回[1] 否则返回[0]\n   * @param numbers\n   * @returns\n   */\n  export function isIncreasing(numbers: number[]): number[] {\n    for (let i = 0; i < numbers.length - 1; i++) {\n      if (numbers[i] > numbers[i + 1]) {\n        return [0];\n      }\n    }\n    return [1];\n  }\n\n  /**\n   * 判断是否是非严格单调递减序列\n   * 如果是，返回[1] 否则返回[0]\n   * @param numbers\n   * @returns\n   */\n  export function isDecreasing(numbers: number[]): number[] {\n    for (let i = 0; i < numbers.length - 1; i++) {\n      if (numbers[i] < numbers[i + 1]) {\n        return [0];\n      }\n    }\n    return [1];\n  }\n\n  /**\n   * a == b == c ... == n\n   * @param numbers\n   * @returns\n   */\n  export function isSame(numbers: number[]): number[] {\n    for (let i = 0; i < numbers.length - 1; i++) {\n      if (numbers[i] !== numbers[i + 1]) {\n        return [0];\n      }\n    }\n    return [1];\n  }\n\n  /**\n   * 是否每个都不一样\n   * @param numbers\n   * @returns\n   */\n  export function isDistinct(numbers: number[]): number[] {\n    for (let i = 0; i < numbers.length - 1; i++) {\n      for (let j = i + 1; j < numbers.length; j++) {\n        if (numbers[i] === numbers[j]) {\n          return [0];\n        }\n      }\n    }\n    return [1];\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataGenerateService/autoComputeEngine/functions/mathLogic.tsx",
    "content": "import { NumberFunctions } from \"@/core/algorithm/numberFunctions\";\nimport { Random } from \"@/core/algorithm/random\";\nimport { Decimal } from \"decimal.js\";\n\n/**\n * 存放和数学逻辑有关的函数\n */\nexport namespace MathFunctions {\n  export function add(numbers: number[]): number[] {\n    return [numbers.reduce((acc, cur) => new Decimal(acc).plus(new Decimal(cur)).toNumber(), 0)];\n  }\n\n  export function subtract(numbers: number[]): number[] {\n    if (numbers.length === 0) {\n      return [0];\n    }\n    if (numbers.length === 1) {\n      return [-numbers[0]];\n    }\n    // 累减\n    let result = new Decimal(numbers[0]);\n    for (let i = 1; i < numbers.length; i++) {\n      result = result.sub(new Decimal(numbers[i]));\n    }\n    return [result.toNumber()];\n  }\n\n  export function multiply(numbers: number[]): number[] {\n    return [numbers.reduce((acc, cur) => new Decimal(acc).times(new Decimal(cur)).toNumber(), 1)];\n  }\n\n  export function divide(numbers: number[]): number[] {\n    if (numbers.length === 0) {\n      return [1];\n    }\n    if (numbers.length === 1) {\n      return [1 / numbers[0]];\n    }\n    let result = new Decimal(numbers[0]);\n    for (let i = 1; i < numbers.length; i++) {\n      result = result.div(new Decimal(numbers[i]));\n    }\n    return [result.toNumber()];\n  }\n\n  export function modulo(numbers: number[]): number[] {\n    if (numbers.length === 0) {\n      return [0];\n    }\n    if (numbers.length === 1) {\n      return [0];\n    }\n    let result = numbers[0];\n    for (let i = 1; i < numbers.length; i++) {\n      result %= numbers[i];\n    }\n    return [result];\n  }\n\n  export function power(numbers: number[]): number[] {\n    if (numbers.length === 0) {\n      return [1];\n    }\n    if (numbers.length === 1) {\n      return [Math.pow(numbers[0], 1)];\n    }\n    let result = new Decimal(numbers[0]);\n    for (let i = 1; i < numbers.length; i++) {\n      result = result.pow(new Decimal(numbers[i]));\n    }\n    return [result.toNumber()];\n  }\n\n  export function factorial(numbers: number[]): number[] {\n    return numbers.map((n) => _factorial(n));\n  }\n\n  export function sqrt(numbers: number[]): number[] {\n    return numbers.map((n) => new Decimal(n).sqrt().toNumber());\n  }\n\n  export function abs(numbers: number[]): number[] {\n    return numbers.map((n) => new Decimal(n).abs().toNumber());\n  }\n\n  export function log(numbers: number[]): number[] {\n    if (numbers.length === 0) {\n      return [0];\n    }\n    if (numbers.length === 1) {\n      return [Math.log(numbers[0])];\n    }\n\n    return [NumberFunctions.logBase(numbers[1], numbers[0])];\n  }\n\n  export function ln(numbers: number[]): number[] {\n    return numbers.map((n) => new Decimal(n).ln().toNumber());\n  }\n\n  export function exp(numbers: number[]): number[] {\n    return numbers.map((n) => new Decimal(n).exp().toNumber());\n  }\n\n  export function sin(numbers: number[]): number[] {\n    return numbers.map((n) => new Decimal(n).sin().toNumber());\n  }\n\n  export function cos(numbers: number[]): number[] {\n    return numbers.map((n) => new Decimal(n).cos().toNumber());\n  }\n\n  export function tan(numbers: number[]): number[] {\n    return numbers.map((n) => new Decimal(n).tan().toNumber());\n  }\n\n  export function asin(numbers: number[]): number[] {\n    return numbers.map((n) => new Decimal(n).asin().toNumber());\n  }\n\n  export function acos(numbers: number[]): number[] {\n    return numbers.map((n) => new Decimal(n).acos().toNumber());\n  }\n\n  export function atan(numbers: number[]): number[] {\n    return numbers.map((n) => new Decimal(n).atan().toNumber());\n  }\n\n  export function sinh(numbers: number[]): number[] {\n    return numbers.map((n) => new Decimal(n).sinh().toNumber());\n  }\n\n  export function cosh(numbers: number[]): number[] {\n    return numbers.map((n) => new Decimal(n).cosh().toNumber());\n  }\n\n  export function tanh(numbers: number[]): number[] {\n    return numbers.map((n) => new Decimal(n).tanh().toNumber());\n  }\n  export function max(numbers: number[]): number[] {\n    return [Math.max(...numbers)];\n  }\n  export function min(numbers: number[]): number[] {\n    return [Math.min(...numbers)];\n  }\n\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  export function random(_: number[]): number[] {\n    return [Math.random()];\n  }\n  export function randomInt(numbers: number[]): number[] {\n    return [Random.randomInt(numbers[0], numbers[1])];\n  }\n  export function randomFloat(numbers: number[]): number[] {\n    return [Random.randomFloat(numbers[0], numbers[1])];\n  }\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  export function randomBoolean(_: number[]): number[] {\n    return [Random.randomBoolean() ? 1 : 0];\n  }\n  export function randomPoisson(numbers: number[]): number[] {\n    return [Random.poissonRandom(numbers[0])];\n  }\n  export function round(numbers: number[]): number[] {\n    return numbers.map((n) => Math.round(n));\n  }\n  export function floor(numbers: number[]): number[] {\n    return numbers.map((n) => Math.floor(n));\n  }\n  export function ceil(numbers: number[]): number[] {\n    return numbers.map((n) => Math.ceil(n));\n  }\n  // 逻辑门\n  export function and(numbers: number[]): number[] {\n    return [numbers.every((n) => n === 1) ? 1 : 0];\n  }\n  export function or(numbers: number[]): number[] {\n    return [numbers.some((n) => n === 1) ? 1 : 0];\n  }\n  export function not(numbers: number[]): number[] {\n    return [numbers[0] === 0 ? 1 : 0];\n  }\n  export function xor(numbers: number[]): number[] {\n    // 只要有不一样的，就返回1，如果全是一样的内容，就返回0\n    const set = new Set(numbers);\n    return [set.size === 1 ? 0 : 1];\n  }\n  // 统计\n  export function count(numbers: number[]): number[] {\n    return [numbers.length];\n  }\n  /**\n   * 平均值\n   * @param numbers\n   * @returns\n   */\n  export function average(numbers: number[]): number[] {\n    return [new Decimal(add(numbers)[0]).div(numbers.length).toNumber()];\n  }\n  /**\n   * 中位数\n   * @param numbers\n   * @returns\n   */\n  export function median(numbers: number[]): number[] {\n    const sorted = numbers.sort((a, b) => a - b);\n    const mid = Math.floor(sorted.length / 2);\n    return [sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2];\n  }\n\n  /**\n   * 众数\n   * 若没有众数，则只返回第一个数\n   * @param numbers\n   * @returns\n   */\n  export function mode(numbers: number[]): number[] {\n    const countMap = new Map<number, number>();\n    for (const num of numbers) {\n      if (countMap.has(num)) {\n        countMap.set(num, countMap.get(num)! + 1);\n      } else {\n        countMap.set(num, 1);\n      }\n    }\n    let maxCount = 0;\n    let modeNum = 0;\n    for (const [num, count] of countMap) {\n      if (count > maxCount) {\n        maxCount = count;\n        modeNum = num;\n      }\n    }\n    return [modeNum];\n  }\n\n  /**\n   * 方差\n   * @param numbers\n   * @returns\n   */\n  export function variance(numbers: number[]): number[] {\n    // 计算数组的平均值\n    const mean = (nums: number[]): number => {\n      return nums.reduce((acc, curr) => acc + curr, 0) / nums.length;\n    };\n\n    const varianceValue =\n      numbers.map((n) => Math.pow(n - mean(numbers), 2)).reduce((acc, curr) => acc + curr, 0) / numbers.length;\n    return [varianceValue];\n  }\n\n  /**\n   * 标准差\n   * @param n\n   * @returns\n   */\n  export function standardDeviation(numbers: number[]): number[] {\n    const results = variance(numbers);\n    return [Math.sqrt(results[0])];\n  }\n\n  // 辅助函数\n\n  function _factorial(n: number): number {\n    if (n === 0) {\n      return 1;\n    }\n    return n * _factorial(n - 1);\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataGenerateService/autoComputeEngine/functions/nodeLogic.tsx",
    "content": "/* eslint-disable @typescript-eslint/no-unused-vars */\nimport { Project } from \"@/core/Project\";\nimport { MouseLocation } from \"@/core/service/controlService/MouseLocation\";\nimport { PenStrokeDeletedEffect } from \"@/core/service/feedbackService/effectEngine/concrete/PenStrokeDeletedEffect\";\nimport { SoundService } from \"@/core/service/feedbackService/SoundService\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { ConnectPoint } from \"@/core/stage/stageObject/entity/ConnectPoint\";\nimport { PenStroke } from \"@/core/stage/stageObject/entity/PenStroke\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { Color, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\n\n/**\n * 直接获取输入节点和下游输出节点\n * 然后直接通过函数来进行更改\n *\n * 这些函数需要普遍遵守签名：\n * fatherNodes: 父节点数组\n * childNodes: 子节点数组\n * 返回值：额外生成的节点数组，如果当前子节点数量不够则自主创建\n */\nexport namespace NodeLogic {\n  export const delayStates: Map<string, Record<number, string>> = new Map();\n  // step 是一个计数器，每当逻辑引擎实际执行一次时，step 就会加一\n  // TODO: 可以考虑把 step 放到逻辑引擎层面，甚至可以出一个节点获取当前步数，可以加一个每次只运行一步的快捷键\n  /**\n   * 输入三个数字节点，并将所有的孩子节点更改为相应的颜色\n   * @param fatherNodes\n   * @param childNodes\n   * @returns\n   */\n  export function setColorByRGB(\n    _project: Project,\n    fatherNodes: ConnectableEntity[],\n    childNodes: ConnectableEntity[],\n  ): string[] {\n    if (fatherNodes.length !== 3) {\n      return [];\n    }\n    const fatherNode1 = fatherNodes[0];\n    const fatherNode2 = fatherNodes[1];\n    const fatherNode3 = fatherNodes[2];\n    if (fatherNode1 instanceof TextNode && fatherNode2 instanceof TextNode && fatherNode3 instanceof TextNode) {\n      const r = parseInt(fatherNode1.text);\n      const g = parseInt(fatherNode2.text);\n      const b = parseInt(fatherNode3.text);\n      childNodes.forEach((node) => {\n        if (node instanceof TextNode) {\n          node.color = new Color(r, g, b);\n        }\n      });\n    }\n    return [];\n  }\n\n  export function setColorByRGBA(\n    _project: Project,\n    fatherNodes: ConnectableEntity[],\n    childNodes: ConnectableEntity[],\n  ): string[] {\n    if (fatherNodes.length !== 4) {\n      return [];\n    }\n    const fatherNode1 = fatherNodes[0];\n    const fatherNode2 = fatherNodes[1];\n    const fatherNode3 = fatherNodes[2];\n    const fatherNode4 = fatherNodes[3];\n    if (\n      fatherNode1 instanceof TextNode &&\n      fatherNode2 instanceof TextNode &&\n      fatherNode3 instanceof TextNode &&\n      fatherNode4 instanceof TextNode\n    ) {\n      const r = parseInt(fatherNode1.text);\n      const g = parseInt(fatherNode2.text);\n      const b = parseInt(fatherNode3.text);\n      const a = parseFloat(fatherNode4.text);\n      childNodes.forEach((node) => {\n        if (node instanceof TextNode) {\n          node.color = new Color(r, g, b, a);\n        }\n      });\n    }\n    return [];\n  }\n\n  export function getLocation(\n    _project: Project,\n    fatherNodes: ConnectableEntity[],\n    _childNodes: ConnectableEntity[],\n  ): string[] {\n    // 只获取第一个父节点元素\n    if (fatherNodes.length < 1) {\n      return [];\n    }\n    const fatherNode = fatherNodes[0];\n    const location = fatherNode.collisionBox.getRectangle().location;\n    return [location.x.toString(), location.y.toString()];\n  }\n\n  export function setLocation(\n    _project: Project,\n    fatherNodes: ConnectableEntity[],\n    childNodes: ConnectableEntity[],\n  ): string[] {\n    if (fatherNodes.length < 2) {\n      return [];\n    }\n    const fatherNode1 = fatherNodes[0];\n    const fatherNode2 = fatherNodes[1];\n    if (fatherNode1 instanceof TextNode && fatherNode2 instanceof TextNode && childNodes.length > 0) {\n      const x = parseFloat(fatherNode1.text);\n      const y = parseFloat(fatherNode2.text);\n      if (Number.isFinite(x) && Number.isFinite(y)) {\n        childNodes.forEach((node) => {\n          if (node instanceof TextNode) {\n            node.moveTo(new Vector(x, y));\n          }\n        });\n      }\n    }\n    return [];\n  }\n\n  export function setLocationByUUID(\n    project: Project,\n    fatherNodes: ConnectableEntity[],\n    _childNodes: ConnectableEntity[],\n  ): string[] {\n    if (fatherNodes.length < 3) {\n      return [];\n    }\n    const fatherNode1 = fatherNodes[0];\n    const fatherNode2 = fatherNodes[1];\n    const fatherNode3 = fatherNodes[2];\n    if (fatherNode1 instanceof TextNode && fatherNode2 instanceof TextNode && fatherNode3 instanceof TextNode) {\n      const findEntity = project.stageManager.getEntitiesByUUIDs([fatherNode1.text])[0];\n      if (!findEntity) {\n        return [\"Error: cannot find entity by uuid\"];\n      }\n      // 找到了实体\n      if (findEntity instanceof TextNode || findEntity instanceof ConnectPoint) {\n        const x = parseFloat(fatherNode2.text);\n        const y = parseFloat(fatherNode3.text);\n        if (Number.isFinite(x) && Number.isFinite(y)) {\n          findEntity.moveTo(new Vector(x, y));\n          return [\"success\"];\n        } else {\n          return [\"Error: input x and y value is not a number\"];\n        }\n      }\n    }\n    return [];\n  }\n\n  export function getLocationByUUID(\n    project: Project,\n    fatherNodes: ConnectableEntity[],\n    _childNodes: ConnectableEntity[],\n  ): string[] {\n    const fatherNode1 = fatherNodes[0];\n    if (fatherNode1 instanceof TextNode) {\n      const findEntity = project.stageManager.getEntitiesByUUIDs([fatherNode1.text])[0];\n      if (!findEntity) {\n        return [\"Error: cannot find entity by uuid\"];\n      }\n      // 找到了实体\n      const leftTop = findEntity.collisionBox.getRectangle().location;\n      return [leftTop.x.toString(), leftTop.y.toString()];\n    }\n    return [\"输入不是TextNode节点\"];\n  }\n\n  export function getSize(\n    _project: Project,\n    fatherNodes: ConnectableEntity[],\n    _childNodes: ConnectableEntity[],\n  ): string[] {\n    // 只获取第一个父节点元素\n    if (fatherNodes.length < 1) {\n      return [];\n    }\n    const fatherNode = fatherNodes[0];\n    const size = fatherNode.collisionBox.getRectangle().size;\n    return [size.x.toString(), size.y.toString()];\n  }\n\n  export function getMouseLocation(\n    _project: Project,\n    _fatherNodes: ConnectableEntity[],\n    _childNodes: ConnectableEntity[],\n  ): string[] {\n    const mouseLocation = MouseLocation.vector();\n    return [mouseLocation.x.toString(), mouseLocation.y.toString()];\n  }\n\n  export function getMouseWorldLocation(\n    project: Project,\n    _fatherNodes: ConnectableEntity[],\n    _childNodes: ConnectableEntity[],\n  ): string[] {\n    const mouseLocation = MouseLocation.vector();\n    const worldLocation = project.renderer.transformView2World(mouseLocation);\n    return [worldLocation.x.toString(), worldLocation.y.toString()];\n  }\n\n  export function getCameraLocation(\n    project: Project,\n    _fatherNodes: ConnectableEntity[],\n    _childNodes: ConnectableEntity[],\n  ): string[] {\n    const cameraLocation = project.camera.location;\n    return [cameraLocation.x.toString(), cameraLocation.y.toString()];\n  }\n\n  export function setCameraLocation(\n    project: Project,\n    fatherNodes: ConnectableEntity[],\n    _childNodes: ConnectableEntity[],\n  ): string[] {\n    if (fatherNodes.length < 2) {\n      return [];\n    }\n    const fatherNode1 = fatherNodes[0];\n    const fatherNode2 = fatherNodes[1];\n    if (fatherNode1 instanceof TextNode && fatherNode2 instanceof TextNode) {\n      const x = parseFloat(fatherNode1.text);\n      const y = parseFloat(fatherNode2.text);\n      project.camera.location = new Vector(x, y);\n    }\n    return [];\n  }\n\n  export function getCameraScale(\n    project: Project,\n    _fatherNodes: ConnectableEntity[],\n    _childNodes: ConnectableEntity[],\n  ): string[] {\n    const cameraScale = project.camera.currentScale;\n    return [cameraScale.toString()];\n  }\n\n  export function setCameraScale(\n    project: Project,\n    fatherNodes: ConnectableEntity[],\n    _childNodes: ConnectableEntity[],\n  ): string[] {\n    if (fatherNodes.length < 1) {\n      return [];\n    }\n    const fatherNode = fatherNodes[0];\n    if (fatherNode instanceof TextNode) {\n      const scale = parseFloat(fatherNode.text);\n      project.camera.targetScale = scale;\n    }\n    return [];\n  }\n\n  export function isCollision(\n    _project: Project,\n    fatherNodes: ConnectableEntity[],\n    childNodes: ConnectableEntity[],\n  ): string[] {\n    if (fatherNodes.length < 1) {\n      return [\"0\"];\n    }\n    const fatherNode = fatherNodes[0];\n    const isCollision = childNodes.some((node) => {\n      return node.collisionBox.isIntersectsWithRectangle(fatherNode.collisionBox.getRectangle());\n    });\n    return [isCollision ? \"1\" : \"0\"];\n  }\n\n  export function getTime(\n    _project: Project,\n    _fatherNodes: ConnectableEntity[],\n    _childNodes: ConnectableEntity[],\n  ): string[] {\n    const time = new Date().getTime();\n    return [time.toString()];\n  }\n\n  export function getDateTime(\n    _project: Project,\n    _fatherNodes: ConnectableEntity[],\n    _childNodes: ConnectableEntity[],\n  ): string[] {\n    const date = new Date();\n    const result = [\n      date.getFullYear(),\n      date.getMonth() + 1, // 月份从0开始，需+1转为1-12[1,3](@ref)\n      date.getDate(), // 直接获取日期（1-31）\n      date.getDay() || 7, // 将周日（0）转为7，其他保持1-6[4,5](@ref)\n      date.getHours(),\n      date.getMinutes(),\n      date.getSeconds(),\n    ];\n    return result.map((value) => {\n      return value.toString();\n    });\n  }\n\n  export function addDateTime(\n    _project: Project,\n    fatherNodes: ConnectableEntity[],\n    _childNodes: ConnectableEntity[],\n  ): string[] {\n    if (fatherNodes.length < 8) {\n      return [\"Error: input node contains less than 7 nodes\"];\n    } else {\n      if (\n        fatherNodes[0] instanceof TextNode &&\n        fatherNodes[1] instanceof TextNode &&\n        fatherNodes[2] instanceof TextNode &&\n        fatherNodes[3] instanceof TextNode &&\n        fatherNodes[4] instanceof TextNode &&\n        fatherNodes[5] instanceof TextNode &&\n        fatherNodes[6] instanceof TextNode &&\n        fatherNodes[7] instanceof TextNode\n      ) {\n        const year = parseInt(fatherNodes[0].text);\n        const month = parseInt(fatherNodes[1].text);\n        const day = parseInt(fatherNodes[2].text);\n        // const _dayOfWeek = parseInt(fatherNodes[3].text);\n        const hours = parseInt(fatherNodes[4].text);\n        const minutes = parseInt(fatherNodes[5].text);\n        const seconds = parseInt(fatherNodes[6].text);\n        const timestamp = parseInt(fatherNodes[7].text);\n\n        // 1. 将前7个参数转换为Date对象（注意月份需-1）\n        const originalDate = new Date(year, month - 1, day, hours, minutes, seconds);\n\n        // 2. 添加时间戳增量（假设timestamp单位为毫秒）\n        const newTimestamp = originalDate.getTime() + timestamp;\n        const newDate = new Date(newTimestamp);\n\n        // 3. 返回新的日期时间数组\n        const result = [\n          newDate.getFullYear(),\n          newDate.getMonth() + 1, // 修正月份为1-12\n          newDate.getDate(),\n          newDate.getDay() || 7, // 转换周日0为7\n          newDate.getHours(),\n          newDate.getMinutes(),\n          newDate.getSeconds(),\n        ];\n        return result.map((value) => {\n          return value.toString();\n        });\n      } else {\n        return [\"输入节点的类型中含有非TextNode节点\"];\n      }\n    }\n  }\n\n  /**\n   * 播放音效\n   * 接收一个路径文件，以及一个布尔值数字，如果为1则播放一下。否则不播放\n   * @param fatherNodes\n   * @param _childNodes\n   */\n  export function playSound(\n    _project: Project,\n    fatherNodes: ConnectableEntity[],\n    _childNodes: ConnectableEntity[],\n  ): string[] {\n    if (fatherNodes.length < 1) {\n      return [];\n    }\n    const fatherNode = fatherNodes[0];\n    const fatherNode1 = fatherNodes[1];\n    if (fatherNode instanceof TextNode && fatherNode1 instanceof TextNode) {\n      const path = fatherNode.text;\n      const isPlay = parseInt(fatherNode1.text);\n      if (isPlay === 1) {\n        SoundService.playSoundByFilePath(path);\n      }\n    }\n    return [];\n  }\n  export function getFps(\n    project: Project,\n    _fatherNodes: ConnectableEntity[],\n    _childNodes: ConnectableEntity[],\n  ): string[] {\n    return [project.renderer.fps.toString()];\n  }\n\n  export function collectNodeNameByRGBA(\n    project: Project,\n    fatherNodes: ConnectableEntity[],\n    _childNodes: ConnectableEntity[],\n  ): string[] {\n    if (fatherNodes.length < 4) {\n      return [\"Error: input node contains less than 4 nodes\"];\n    }\n    if (\n      fatherNodes[0] instanceof TextNode &&\n      fatherNodes[1] instanceof TextNode &&\n      fatherNodes[2] instanceof TextNode &&\n      fatherNodes[3] instanceof TextNode\n    ) {\n      const r = parseInt(fatherNodes[0].text);\n      const g = parseInt(fatherNodes[1].text);\n      const b = parseInt(fatherNodes[2].text);\n      const a = parseFloat(fatherNodes[3].text);\n      const matchColor = new Color(r, g, b, a);\n      const matchNodes: TextNode[] = [];\n      for (const node of project.stageManager.getTextNodes()) {\n        // 避开与逻辑节点相连的节点\n        if (project.autoComputeUtils.isNodeConnectedWithLogicNode(node)) {\n          continue;\n        }\n        if (node.text.trim() === \"\") {\n          continue;\n        }\n        // 匹配颜色\n        if (node.color.equals(matchColor)) {\n          matchNodes.push(node);\n        }\n      }\n      // 将这些节点的名字拿出来\n      return matchNodes.map((node) => {\n        return `${node.text}`;\n      });\n    }\n    return [\"Error: input node is not valid\"];\n  }\n\n  /**\n   * 通过RGBA四个数字来收集颜色匹配的节点\n   * @param fatherNodes\n   * @param _childNodes\n   */\n  export function collectNodeDetailsByRGBA(\n    project: Project,\n    fatherNodes: ConnectableEntity[],\n    _childNodes: ConnectableEntity[],\n  ): string[] {\n    if (fatherNodes.length < 4) {\n      return [\"Error: input node contains less than 4 nodes\"];\n    }\n    if (\n      fatherNodes[0] instanceof TextNode &&\n      fatherNodes[1] instanceof TextNode &&\n      fatherNodes[2] instanceof TextNode &&\n      fatherNodes[3] instanceof TextNode\n    ) {\n      const r = parseInt(fatherNodes[0].text);\n      const g = parseInt(fatherNodes[1].text);\n      const b = parseInt(fatherNodes[2].text);\n      const a = parseFloat(fatherNodes[3].text);\n      const matchColor = new Color(r, g, b, a);\n      const matchNodes: TextNode[] = [];\n      for (const node of project.stageManager.getTextNodes()) {\n        // 避开与逻辑节点相连的节点\n        if (project.autoComputeUtils.isNodeConnectedWithLogicNode(node)) {\n          continue;\n        }\n        if (node.details.length === 0) {\n          continue;\n        }\n        // 匹配颜色\n        if (node.color.equals(matchColor)) {\n          matchNodes.push(node);\n        }\n      }\n      // 将这些节点的详细信息拿出来\n      return matchNodes.map((node) => {\n        return `${node.details}`;\n      });\n    }\n    return [\"Error: input node is not valid\"];\n  }\n\n  export function getNodeRGBA(\n    _project: Project,\n    fatherNodes: ConnectableEntity[],\n    _childNodes: ConnectableEntity[],\n  ): string[] {\n    if (fatherNodes.length < 1) {\n      return [\"Error: input node contains less than 1 nodes\"];\n    }\n    if (fatherNodes[0] instanceof TextNode) {\n      const fatherNode = fatherNodes[0];\n      const color = fatherNode.color;\n      return [`${color.r}`, `${color.g}`, `${color.b}`, `${color.a}`];\n    }\n    return [\"Error: input node is not valid\"];\n  }\n\n  export function getNodeUUID(\n    _project: Project,\n    fatherNodes: ConnectableEntity[],\n    _childNodes: ConnectableEntity[],\n  ): string[] {\n    if (fatherNodes.length < 1) {\n      return [\"Error: input node contains less than 1 nodes\"];\n    }\n    const fatherNode = fatherNodes[0];\n    const uuid = fatherNode.uuid;\n    return [uuid];\n  }\n\n  /**\n   * 在固定的某点创建一个文本节点，输入的位置是左上角坐标位置\n   * @param fatherNodes\n   * @param _childNodes\n   * @returns\n   */\n  export function createTextNodeOnLocation(\n    project: Project,\n    fatherNodes: ConnectableEntity[],\n    _childNodes: ConnectableEntity[],\n  ): string[] {\n    if (fatherNodes.length < 4) {\n      return [\"Error: input node contains less than 4 nodes\"];\n    }\n    const fatherNode1 = fatherNodes[0];\n    const fatherNode2 = fatherNodes[1];\n    const fatherNode3 = fatherNodes[2];\n    const fatherNode4 = fatherNodes[3];\n    if (\n      fatherNode1 instanceof TextNode &&\n      fatherNode2 instanceof TextNode &&\n      fatherNode3 instanceof TextNode &&\n      fatherNode4 instanceof TextNode\n    ) {\n      const b = parseInt(fatherNode4.text);\n      if (b === 1) {\n        const x = parseFloat(fatherNode1.text);\n        const y = parseFloat(fatherNode2.text);\n        const textNode = new TextNode(project, {\n          collisionBox: new CollisionBox([new Rectangle(new Vector(x, y), new Vector(100, 50))]),\n          text: fatherNode3.text,\n        });\n        project.stageManager.add(textNode);\n        return [textNode.uuid];\n      } else {\n        return [\"暂停创建节点\"];\n      }\n    } else {\n      return [\"输入的节点格式必须都是TextNode\"];\n    }\n  }\n\n  /**\n   * 检测某点是否含有实体\n   * @param fatherNodes\n   * @param _childNodes\n   */\n  export function isHaveEntityOnLocation(\n    project: Project,\n    fatherNodes: ConnectableEntity[],\n    _childNodes: ConnectableEntity[],\n  ): string[] {\n    if (fatherNodes.length < 2) {\n      return [\"Error: input node contains less than 2 nodes\"];\n    }\n    const fatherNode1 = fatherNodes[0];\n    const fatherNode2 = fatherNodes[1];\n    if (fatherNode1 instanceof TextNode && fatherNode2 instanceof TextNode) {\n      const x = parseFloat(fatherNode1.text);\n      const y = parseFloat(fatherNode2.text);\n      if (Number.isFinite(x) && Number.isFinite(y)) {\n        const entity = project.stageManager.isEntityOnLocation(new Vector(x, y));\n        if (entity) {\n          return [\"1\"];\n        } else {\n          return [\"0\"];\n        }\n      } else {\n        return [\"输入的坐标格式不正确\"];\n      }\n    } else {\n      return [\"输入的节点格式必须都是TextNode\"];\n    }\n  }\n\n  export function replaceGlobalContent(\n    project: Project,\n    fatherNodes: ConnectableEntity[],\n    _childNodes: ConnectableEntity[],\n  ): string[] {\n    if (fatherNodes.length !== 2) {\n      return [\"输入数量不正确\"];\n    }\n    let replacedCount = 0;\n    if (fatherNodes[0] instanceof TextNode && fatherNodes[0].text.trim() !== \"\" && fatherNodes[1] instanceof TextNode) {\n      const content = fatherNodes[0].text;\n      const newString = fatherNodes[1].text;\n      for (const node of project.stageManager.getTextNodes()) {\n        // 避开与逻辑节点相连的节点\n        if (project.autoComputeUtils.isNodeConnectedWithLogicNode(node)) {\n          continue;\n        }\n        if (node.text.trim() !== \"\" && node.text.includes(content)) {\n          node.rename(node.text.replace(content, newString));\n          replacedCount++;\n        }\n      }\n    }\n    return [`替换了${replacedCount}处内容`];\n  }\n\n  /**\n   * 搜索内容\n   * @param fatherNodes 被搜索字符串，是否大小写敏感\n   * @param _childNodes\n   */\n  export function searchContent(\n    project: Project,\n    fatherNodes: ConnectableEntity[],\n    _childNodes: ConnectableEntity[],\n  ): string[] {\n    if (fatherNodes.length !== 2) {\n      return [\"输入数量不正确，第一个参数为被搜索字符串，第二个是否大小写敏感（0/1）\"];\n    }\n    if (fatherNodes[0] instanceof TextNode && fatherNodes[1] instanceof TextNode) {\n      const searchString = fatherNodes[0].text;\n      const isCaseSensitive = parseInt(fatherNodes[1].text);\n      if (!(isCaseSensitive === 0 || isCaseSensitive === 1)) {\n        return [\"第二个参数只能输入 0/1\"];\n      }\n      const searchResultNodes: TextNode[] = [];\n      for (const node of project.stageManager.getTextNodes()) {\n        if (isCaseSensitive) {\n          if (node.text.includes(searchString)) {\n            searchResultNodes.push(node);\n          }\n        } else {\n          if (node.text.toLowerCase().includes(searchString.toLowerCase())) {\n            searchResultNodes.push(node);\n          }\n        }\n      }\n      return searchResultNodes.map((node) => node.uuid);\n    }\n    return [\"输入的节点格式必须都是TextNode\"];\n  }\n\n  export function deletePenStrokeByColor(\n    project: Project,\n    fatherNodes: ConnectableEntity[],\n    _childNodes: ConnectableEntity[],\n  ): string[] {\n    if (fatherNodes.length < 4) {\n      return [\"Error: input node contains less than 4 nodes\"];\n    }\n    if (\n      fatherNodes[0] instanceof TextNode &&\n      fatherNodes[1] instanceof TextNode &&\n      fatherNodes[2] instanceof TextNode &&\n      fatherNodes[3] instanceof TextNode\n    ) {\n      const r = parseInt(fatherNodes[0].text);\n      const g = parseInt(fatherNodes[1].text);\n      const b = parseInt(fatherNodes[2].text);\n      const a = parseFloat(fatherNodes[3].text);\n      const collectPenStrokes: PenStroke[] = [];\n      for (const penStroke of project.stageManager.getPenStrokes()) {\n        if (penStroke.color.equals(new Color(r, g, b, a))) {\n          collectPenStrokes.push(penStroke);\n        }\n      }\n      for (const penStroke of collectPenStrokes) {\n        project.effects.addEffect(PenStrokeDeletedEffect.fromPenStroke(penStroke));\n        project.stageManager.delete(penStroke);\n      }\n    }\n    return [];\n  }\n  /**\n   * 延迟复制函数，用于在指定的延迟时间后输出指定的字符串。\n   *\n   * @param fatherNodes - 父节点数组，包含至少4个节点，最后一个节点是当前逻辑节点本身。\n   * @param _childNodes - 子节点数组（当前未使用）。\n   * @returns - 返回一个字符串数组，包含以下可能的结果：\n   *   - 如果延迟时间为0，立即返回输入字符串。\n   *   - 如果当前步数有对应的输出，返回该输出字符串。\n   *   - 如果当前步数没有输出，返回默认字符串。\n   */\n  export function delayCopy(\n    _project: Project,\n    fatherNodes: ConnectableEntity[],\n    _childNodes: ConnectableEntity[],\n  ): string[] {\n    if (fatherNodes.length < 4) {\n      // 多了一个逻辑节点本身，所以实际进来的节点比延迟复制需要的节点节点多一个\n      return [\"Error: input node contains less than 3 nodes\"];\n    }\n    if (\n      fatherNodes[0] instanceof TextNode &&\n      fatherNodes[1] instanceof TextNode &&\n      fatherNodes[2] instanceof TextNode &&\n      fatherNodes[3] instanceof TextNode\n    ) {\n      const str = fatherNodes[0].text;\n      const defaultStr = fatherNodes[1].text;\n      const delayTime = parseInt(fatherNodes[2].text);\n      const selfUUID = fatherNodes[3].uuid;\n      if (delayTime < 0) {\n        return [\"延迟时间不能为负数\"];\n      }\n      if (delayTime > 256) {\n        return [\"延迟时间不能超过256刻\"];\n      }\n      if (delayTime === 0) {\n        return [str];\n      }\n      // state 是当前逻辑节点本身存储的状态\n      let state = delayStates.get(selfUUID);\n      if (state === undefined) {\n        delayStates.set(selfUUID, []);\n        state = [];\n      }\n      // 在未来的(step + delayTime)刻时把str输出\n      // TODO: step\n      // state[step + delayTime] = str;\n      // if (state[step] !== undefined) {\n      //   const result = state[step];\n      //   delete state[step];\n      //   return [result];\n      // }\n      return [defaultStr];\n    }\n    return [\"输入的节点格式必须都是TextNode\"];\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataGenerateService/autoComputeEngine/functions/programLogic.tsx",
    "content": "import { Project } from \"@/core/Project\";\n\nexport namespace ProgramFunctions {\n  /**\n   * 核心代码的获取变量值的方法\n   * @param varName\n   * @returns\n   */\n  export function getVarInCore(project: Project, varName: string): string {\n    return project.autoCompute.variables.get(varName) || \"NaN\";\n  }\n\n  export function isHaveVar(project: Project, varName: string): boolean {\n    return project.autoCompute.variables.has(varName);\n  }\n\n  /**\n   * 设置变量，变量名不能是逻辑节点名称\n   * @param args\n   * @returns\n   */\n  export function setVar(project: Project, args: string[]): string[] {\n    if (args.length !== 2) {\n      return [\"error\", \"参数数量错误，必须保证两个\"];\n    }\n    const varName = args[0];\n    if (varName.includes(\" \")) {\n      return [\"error\", \"变量名不能包含空格\"];\n    }\n    // 变量名不能以数字开头\n    if (/^\\d/.test(varName)) {\n      return [\"error\", \"变量名不能以数字开头\"];\n    }\n    project.autoCompute.variables.set(varName, args[1]);\n    return [\"success\"];\n  }\n\n  /**\n   * 获取现存变量，如果没有，则返回NaN\n   * @param args\n   */\n  export function getVar(project: Project, args: string[]): string[] {\n    if (args.length === 1) {\n      const varName = args[0];\n      return [project.autoCompute.variables.get(varName) || \"NaN\"];\n    }\n    return [\"error\", \"参数数量错误\"];\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataGenerateService/autoComputeEngine/functions/stringLogic.tsx",
    "content": "/**\n * 存放和字符串相关的函数\n * 全部的函数都应该做成\n * 输入 string[] 输出 string[]\n */\nexport namespace StringFunctions {\n  export function upper(strings: string[]): string[] {\n    return strings.map((str) => str.toUpperCase());\n  }\n\n  export function lower(strings: string[]): string[] {\n    return strings.map((str) => str.toLowerCase());\n  }\n\n  export function capitalize(strings: string[]): string[] {\n    return strings.map((str) => str.charAt(0).toUpperCase() + str.slice(1));\n  }\n\n  export function len(strings: string[]): string[] {\n    return strings.map((str) => str.length.toString());\n  }\n\n  export function copy(strings: string[]): string[] {\n    return strings.map((str) => str);\n  }\n\n  export function connect(strings: string[]): string[] {\n    return [strings.join(\"\")];\n  }\n\n  /**\n   * 举例：\n   * 输入 [\"hello\", \"world\", \"-\"]\n   * 输出 [\"hello-world\"]\n   * @param strings\n   * @returns\n   */\n  export function join(strings: string[]): string[] {\n    if (strings.length < 2) return [\"\"];\n    const sep = strings[strings.length - 1];\n    return [strings.slice(0, -1).join(sep)];\n  }\n\n  export function replace(strings: string[]): string[] {\n    if (strings.length < 3) return [\"length less than 3\"];\n    const str = strings[0];\n    const old = strings[1];\n    const newStr = strings[2];\n    return [str.replaceAll(old, newStr)];\n  }\n\n  export function trim(strings: string[]): string[] {\n    return strings.map((str) => str.trim());\n  }\n\n  /**\n   * 举例：\n   * 输入 [\"hello world\", \"0\", \"5\"]\n   * 输出 [\"hello\"]\n   * @param strings\n   */\n  export function slice(strings: string[]): string[] {\n    if (strings.length < 2) return [\"\"];\n    const str = strings[0];\n    const start = parseInt(strings[1]);\n    const end = strings.length > 2 ? parseInt(strings[2]) : undefined;\n    return [str.slice(start, end)];\n  }\n\n  /**\n   * 按照某个分隔符分割字符串\n   * 举例：\n   * 输入 [\"hello,world\", \",\"]\n   * 输出 [\"hello\", \"world\"]\n   * 但也可以是多个分隔符\n   * 举例：\n   * 输入 [\"hello,world-my name is lisa\", \",\", \" \", \"-\"]\n   * 输出 [\"hello\", \"world\", \"my\", \"name\", \"is\", \"lisa\"]\n   */\n  export function split(strings: string[]): string[] {\n    if (strings.length < 2) return [\"length less than 2\"];\n    const str = strings[0];\n    const seps = strings.slice(1);\n\n    // 转义正则特殊字符\n    const escapedSeps = seps.map((sep) => sep.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\"));\n    // 创建正则表达式，支持多个分隔符同时分割\n    const regex = new RegExp(escapedSeps.join(\"|\"), \"g\");\n    // 分割后过滤空字符串\n    return str.split(regex).filter((item) => item !== \"\");\n  }\n\n  /**\n   * 计算所有字符串的长度总和\n   * @param strings\n   * @returns\n   */\n  export function length(strings: string[]): string[] {\n    const sum = strings.reduce((acc, cur) => acc + cur.length, 0);\n    return [sum.toString()];\n  }\n\n  /**\n   * 计算一个字符串中是否包含另一个字符串\n   * @param strings\n   * @returns\n   */\n  export function contains(strings: string[]): string[] {\n    if (strings.length < 2) return [\"0\"];\n    const str = strings[0];\n    const substr = strings[1];\n    return [str.includes(substr).toString() ? \"1\" : \"0\"];\n  }\n\n  /**\n   * 计算一个字符串是否以另一个字符串开头\n   * @param strings\n   * @returns\n   */\n  export function startsWith(strings: string[]): string[] {\n    if (strings.length < 2) return [\"0\"];\n    const str = strings[0];\n    const prefix = strings[1];\n    return [str.startsWith(prefix) ? \"1\" : \"0\"];\n  }\n\n  /**\n   * 计算一个字符串是否以另一个字符串结尾\n   * @param strings\n   * @param suffix\n   * @returns\n   */\n  export function endsWith(strings: string[]): string[] {\n    if (strings.length < 2) return [\"0\"];\n    const str = strings[0];\n    const suffix = strings[1];\n    return [str.endsWith(suffix) ? \"1\" : \"0\"];\n  }\n\n  /**\n   * 检查正则匹配\n   * 参数数量必须>=2, 最后一个参数为正则表达式\n   * 举例：\n   * 输入 [\"hello world\", \"^[a-zA-Z]+$\"]\n   * 输出 [\"1\"]\n   * 当输入多个待检查字符串参数时，分别检查每个参数是否匹配正则表达式\n   * 举例\n   * 输入 [\"hello world\", \"world\", \"^[a-zA-Z]+$\"]\n   * 输出 [\"1\", \"0\"]\n   * @param strings\n   */\n  export function checkRegexMatch(strings: string[]): string[] {\n    if (strings.length < 2) return [\"0\"];\n    try {\n      const regex = new RegExp(strings[strings.length - 1]);\n      const results = strings.slice(0, -1).map((str) => (regex.test(str) ? \"1\" : \"0\"));\n      return results;\n    } catch (e: any) {\n      return [e.toString()];\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataGenerateService/autoComputeEngine/logicNodeNameEnum.tsx",
    "content": "/**\n * 所有逻辑节点的枚举\n */\nexport enum LogicNodeNameEnum {\n  // 逻辑运算符\n  AND = \"#AND#\",\n  OR = \"#OR#\",\n  NOT = \"#NOT#\",\n  XOR = \"#XOR#\",\n  // 测试\n  TEST = \"#TEST#\",\n  // 数学运算\n  ADD = \"#ADD#\",\n  SUBTRACT = \"#SUB#\",\n  MULTIPLY = \"#MUL#\",\n  DIVIDE = \"#DIV#\",\n  MODULO = \"#MOD#\",\n  FLOOR = \"#FLOOR#\",\n  CEIL = \"#CEIL#\",\n  ROUND = \"#ROUND#\",\n  SQRT = \"#SQRT#\",\n  POWER = \"#POW#\",\n  LOG = \"#LOG#\",\n  ABS = \"#ABS#\",\n  // 概率论\n  RANDOM = \"#RANDOM#\",\n  RANDOM_INT = \"#RANDOM_INT#\",\n  RANDOM_FLOAT = \"#RANDOM_FLOAT#\",\n  RANDOM_ITEM = \"#RANDOM_ITEM#\",\n  RANDOM_ITEMS = \"#RANDOM_ITEMS#\",\n  RANDOM_POISSON = \"#RANDOM_POISSON#\",\n  // 数组\n  // 数学一元函数\n  SIN = \"#SIN#\",\n  COS = \"#COS#\",\n  TAN = \"#TAN#\",\n  ASIN = \"#ASIN#\",\n  ACOS = \"#ACOS#\",\n  ATAN = \"#ATAN#\",\n  LN = \"#LN#\",\n  EXP = \"#EXP#\",\n  // 取值运算\n  MAX = \"#MAX#\",\n  MIN = \"#MIN#\",\n  // 比较运算\n  LT = \"#LT#\",\n  GT = \"#GT#\",\n  LTE = \"#LTE#\",\n  GTE = \"#GTE#\",\n  EQ = \"#EQ#\",\n  NEQ = \"#NEQ#\",\n\n  // 字符串\n  UPPER = \"#UPPER#\",\n  LOWER = \"#LOWER#\",\n  LEN = \"#LEN#\",\n  COPY = \"#COPY#\",\n  SPLIT = \"#SPLIT#\",\n  REPLACE = \"#REPLACE#\",\n  CONNECT = \"#CONNECT#\",\n  CHECK_REGEX_MATCH = \"#CHECK_REGEX_MATCH#\",\n  // 统计\n  COUNT = \"#COUNT#\",\n  AVE = \"#AVE#\",\n  MEDIAN = \"#MEDIAN#\",\n  MODE = \"#MODE#\",\n  VARIANCE = \"#VARIANCE#\",\n  STANDARD_DEVIATION = \"#STANDARD_DEVIATION#\",\n  // 编程类\n  SET_VAR = \"#SET_VAR#\",\n  GET_VAR = \"#GET_VAR#\",\n  // 其他\n  RGB = \"#RGB#\",\n  RGBA = \"#RGBA#\",\n  GET_LOCATION = \"#GET_LOCATION#\",\n  SET_LOCATION = \"#SET_LOCATION#\",\n  SET_LOCATION_BY_UUID = \"#SET_LOCATION_BY_UUID#\",\n  GET_LOCATION_BY_UUID = \"#GET_LOCATION_BY_UUID#\",\n  GET_SIZE = \"#GET_SIZE#\",\n  GET_MOUSE_LOCATION = \"#GET_MOUSE_LOCATION#\",\n  GET_MOUSE_WORLD_LOCATION = \"#GET_MOUSE_WORLD_LOCATION#\",\n  GET_CAMERA_LOCATION = \"#GET_CAMERA_LOCATION#\",\n  SET_CAMERA_LOCATION = \"#SET_CAMERA_LOCATION#\",\n  GET_CAMERA_SCALE = \"#GET_CAMERA_SCALE#\",\n  SET_CAMERA_SCALE = \"#SET_CAMERA_SCALE#\",\n  IS_COLLISION = \"#IS_COLLISION#\",\n  GET_TIME = \"#GET_TIME#\",\n  GET_DATE_TIME = \"#GET_DATE_TIME#\",\n  ADD_DATE_TIME = \"#ADD_DATE_TIME#\",\n  PLAY_SOUND = \"#PLAY_SOUND#\",\n  GET_NODE_UUID = \"#GET_NODE_UUID#\",\n  GET_NODE_RGBA = \"#GET_NODE_RGBA#\",\n  COLLECT_NODE_DETAILS_BY_RGBA = \"#COLLECT_NODE_DETAILS_BY_RGBA#\",\n  COLLECT_NODE_NAME_BY_RGBA = \"#COLLECT_NODE_NAME_BY_RGBA#\",\n  FPS = \"#FPS#\",\n  CREATE_TEXT_NODE_ON_LOCATION = \"#CREATE_TEXT_NODE_ON_LOCATION#\",\n  IS_HAVE_ENTITY_ON_LOCATION = \"#IS_HAVE_ENTITY_ON_LOCATION#\",\n  REPLACE_GLOBAL_CONTENT = \"#REPLACE_GLOBAL_CONTENT#\",\n  SEARCH_CONTENT = \"#SEARCH_CONTENT#\",\n  DELETE_PEN_STROKE_BY_COLOR = \"#DELETE_PEN_STROKE_BY_COLOR#\",\n  DELAY_COPY = \"#DELAY_COPY#\",\n}\nexport const LogicNodeNameToRenderNameMap: {\n  [key in LogicNodeNameEnum]: string;\n} = {\n  [LogicNodeNameEnum.AND]: \"and\",\n  [LogicNodeNameEnum.OR]: \"or\",\n  [LogicNodeNameEnum.NOT]: \"not\",\n  [LogicNodeNameEnum.XOR]: \"xor\",\n  [LogicNodeNameEnum.TEST]: \"测试\",\n  [LogicNodeNameEnum.ADD]: \"+\",\n  [LogicNodeNameEnum.SUBTRACT]: \"-\",\n  [LogicNodeNameEnum.MULTIPLY]: \"×\",\n  [LogicNodeNameEnum.DIVIDE]: \"÷\",\n  [LogicNodeNameEnum.MODULO]: \"%\",\n  [LogicNodeNameEnum.FLOOR]: \"⌊ ⌋\",\n  [LogicNodeNameEnum.CEIL]: \"⌈ ⌉\",\n  [LogicNodeNameEnum.ROUND]: \"round\",\n  [LogicNodeNameEnum.SQRT]: \"√\",\n  [LogicNodeNameEnum.POWER]: \"幂\",\n  [LogicNodeNameEnum.LOG]: \"log\",\n  [LogicNodeNameEnum.ABS]: \"| |\",\n  [LogicNodeNameEnum.RANDOM]: \"Random\",\n  [LogicNodeNameEnum.SIN]: \"sin\",\n  [LogicNodeNameEnum.COS]: \"cos\",\n  [LogicNodeNameEnum.ASIN]: \"arcsin\",\n  [LogicNodeNameEnum.ACOS]: \"arccos\",\n  [LogicNodeNameEnum.ATAN]: \"arctan\",\n  [LogicNodeNameEnum.LN]: \"ln\",\n  [LogicNodeNameEnum.EXP]: \"exp\",\n  [LogicNodeNameEnum.TAN]: \"tan\",\n  [LogicNodeNameEnum.MAX]: \"Max\",\n  [LogicNodeNameEnum.MIN]: \"Min\",\n  [LogicNodeNameEnum.LT]: \"<\",\n  [LogicNodeNameEnum.GT]: \">\",\n  [LogicNodeNameEnum.LTE]: \"≤\",\n  [LogicNodeNameEnum.GTE]: \"≥\",\n  [LogicNodeNameEnum.EQ]: \"==\",\n  [LogicNodeNameEnum.NEQ]: \"≠\",\n  [LogicNodeNameEnum.UPPER]: \"大写\",\n  [LogicNodeNameEnum.LOWER]: \"小写\",\n  [LogicNodeNameEnum.LEN]: \"字符长度\",\n  [LogicNodeNameEnum.COPY]: \"复制\",\n  [LogicNodeNameEnum.SPLIT]: \"分割\",\n  [LogicNodeNameEnum.REPLACE]: \"替换\",\n  [LogicNodeNameEnum.CONNECT]: \"连接\",\n  [LogicNodeNameEnum.CHECK_REGEX_MATCH]: \"正则匹配\",\n  [LogicNodeNameEnum.COUNT]: \"count\",\n  [LogicNodeNameEnum.AVE]: \"平均值\",\n  [LogicNodeNameEnum.MEDIAN]: \"中位数\",\n  [LogicNodeNameEnum.MODE]: \"众数\",\n  [LogicNodeNameEnum.VARIANCE]: \"方差\",\n  [LogicNodeNameEnum.STANDARD_DEVIATION]: \"标准差\",\n  [LogicNodeNameEnum.RANDOM_FLOAT]: \"随机浮点数\",\n  [LogicNodeNameEnum.RANDOM_INT]: \"随机整数\",\n  [LogicNodeNameEnum.RANDOM_ITEM]: \"随机选项\",\n  [LogicNodeNameEnum.RANDOM_ITEMS]: \"随机选项组\",\n  [LogicNodeNameEnum.RANDOM_POISSON]: \"泊松分布随机数\",\n\n  [LogicNodeNameEnum.RGB]: \"通过RGB设置节点颜色\",\n  [LogicNodeNameEnum.RGBA]: \"通过RGBA设置节点颜色\",\n  [LogicNodeNameEnum.GET_LOCATION]: \"获取节点位置\",\n  [LogicNodeNameEnum.SET_LOCATION]: \"设置节点位置\",\n  [LogicNodeNameEnum.SET_LOCATION_BY_UUID]: \"根据UUID设置节点位置\",\n  [LogicNodeNameEnum.GET_LOCATION_BY_UUID]: \"根据UUID获得节点位置\",\n  [LogicNodeNameEnum.GET_SIZE]: \"获取节点大小\",\n  [LogicNodeNameEnum.GET_MOUSE_LOCATION]: \"获取鼠标窗口位置\",\n  [LogicNodeNameEnum.GET_MOUSE_WORLD_LOCATION]: \"获取鼠标世界位置\",\n  [LogicNodeNameEnum.GET_CAMERA_LOCATION]: \"获取相机位置\",\n  [LogicNodeNameEnum.SET_CAMERA_LOCATION]: \"设置相机位置\",\n  [LogicNodeNameEnum.GET_CAMERA_SCALE]: \"获取相机缩放\",\n  [LogicNodeNameEnum.SET_CAMERA_SCALE]: \"设置相机缩放\",\n  [LogicNodeNameEnum.IS_COLLISION]: \"碰撞检测\",\n  [LogicNodeNameEnum.GET_TIME]: \"获取当前时间戳\",\n  [LogicNodeNameEnum.GET_DATE_TIME]: \"获取当前日期时间\",\n  [LogicNodeNameEnum.ADD_DATE_TIME]: \"增加当前日期时间\",\n  [LogicNodeNameEnum.PLAY_SOUND]: \"播放声音\",\n  [LogicNodeNameEnum.GET_NODE_UUID]: \"获取节点UUID\",\n  [LogicNodeNameEnum.GET_NODE_RGBA]: \"获取节点颜色\",\n  [LogicNodeNameEnum.COLLECT_NODE_DETAILS_BY_RGBA]: \"根据颜色收集节点详情\",\n  [LogicNodeNameEnum.COLLECT_NODE_NAME_BY_RGBA]: \"根据颜色收集节点名称\",\n  [LogicNodeNameEnum.FPS]: \"FPS\",\n  [LogicNodeNameEnum.CREATE_TEXT_NODE_ON_LOCATION]: \"在指定位置创建节点\",\n  [LogicNodeNameEnum.IS_HAVE_ENTITY_ON_LOCATION]: \"判断某位置是否存在实体\",\n  [LogicNodeNameEnum.REPLACE_GLOBAL_CONTENT]: \"全局替换内容\",\n  [LogicNodeNameEnum.SEARCH_CONTENT]: \"搜索内容\",\n  [LogicNodeNameEnum.DELETE_PEN_STROKE_BY_COLOR]: \"删除画笔颜色的笔画\",\n  [LogicNodeNameEnum.DELAY_COPY]: \"延迟复制\",\n\n  [LogicNodeNameEnum.SET_VAR]: \"设置变量\",\n  [LogicNodeNameEnum.GET_VAR]: \"获取变量\",\n};\n\n/**\n * 逻辑节点的输入参数提示文本信息\n */\nexport const LogicNodeNameToArgsTipsMap: {\n  [key in LogicNodeNameEnum]: string;\n} = {\n  [LogicNodeNameEnum.AND]: \"a0 && a1 && a2 &&...\",\n  [LogicNodeNameEnum.OR]: \"a0 || a1 || a2 || ...\",\n  [LogicNodeNameEnum.NOT]: \"a0\",\n  [LogicNodeNameEnum.XOR]: \"a0 ^ a1 ^ a2 ^ ...\",\n  [LogicNodeNameEnum.TEST]: \"无输入\",\n  [LogicNodeNameEnum.ADD]: \"a0 + a1 + a2 + ...\",\n  [LogicNodeNameEnum.SUBTRACT]: \"a0 - a1 - a2 - ...\",\n  [LogicNodeNameEnum.MULTIPLY]: \"a0 × a1 × a2 × ...\",\n  [LogicNodeNameEnum.DIVIDE]: \"a0 ÷ a1 ÷ a2 ÷ ...\",\n  [LogicNodeNameEnum.MODULO]: \"a0 % a1 % a2 % ...\",\n  [LogicNodeNameEnum.FLOOR]: \"⌊a0⌋, ⌊a1⌋, ⌊a2⌋, ...\",\n  [LogicNodeNameEnum.CEIL]: \"⌈a0⌉, ⌈a1⌉, ⌈a2⌉, ...\",\n  [LogicNodeNameEnum.ROUND]: \"round(a0), round(a1), round(a2), ...\",\n  [LogicNodeNameEnum.SQRT]: \"√a0, √a1, √a2, ...\",\n  [LogicNodeNameEnum.POWER]: \"a0 ** a1 ** a2 ** ...\",\n  [LogicNodeNameEnum.LOG]: \"a0: base, a1: number\",\n  [LogicNodeNameEnum.ABS]: \"|a0|, |a1|, |a2|, ...\",\n  [LogicNodeNameEnum.RANDOM]: \"无输入\",\n  [LogicNodeNameEnum.RANDOM_INT]: \"a0: 最小值, a1: 最大值\",\n  [LogicNodeNameEnum.RANDOM_FLOAT]: \"a0: 最小值, a1: 最大值\",\n  [LogicNodeNameEnum.RANDOM_ITEM]: \"随机选项\",\n  [LogicNodeNameEnum.RANDOM_ITEMS]: \"随机选项组\",\n  [LogicNodeNameEnum.RANDOM_POISSON]: \"a0: lambda\",\n  [LogicNodeNameEnum.SIN]: \"sin(a0), sin(a1), sin(a2), ...\",\n  [LogicNodeNameEnum.COS]: \"cos(a0), cos(a1), cos(a2), ...\",\n  [LogicNodeNameEnum.ASIN]: \"arcsin(a0), arcsin(a1), arcsin(a2), ...\",\n  [LogicNodeNameEnum.ACOS]: \"arccos(a0), arccos(a1), arccos(a2), ...\",\n  [LogicNodeNameEnum.ATAN]: \"arctan(a0), arctan(a1), arctan(a2), ...\",\n  [LogicNodeNameEnum.LN]: \"ln(a0), ln(a1), ln(a2), ...\",\n  [LogicNodeNameEnum.EXP]: \"exp(a0), exp(a1), exp(a2), ...\",\n  [LogicNodeNameEnum.TAN]: \"tan(a0), tan(a1), tan(a2), ...\",\n  [LogicNodeNameEnum.MAX]: \"Max(a0, a1, a2, ...)\",\n  [LogicNodeNameEnum.MIN]: \"Min(a0, a1, a2, ...)\",\n  [LogicNodeNameEnum.LT]: \"a0 < a1 < a2 < ...\",\n  [LogicNodeNameEnum.GT]: \"a0 > a1 > a2 > ...\",\n  [LogicNodeNameEnum.LTE]: \"a0 ≤ a1 ≤ a2 ≤ ...\",\n  [LogicNodeNameEnum.GTE]: \"a0 ≥ a1 ≥ a2 ≥ ...\",\n  [LogicNodeNameEnum.EQ]: \"a0 == a1 == a2 == ...\",\n  [LogicNodeNameEnum.NEQ]: \"a0 ≠ a1 ≠ a2 ≠ ...\",\n  [LogicNodeNameEnum.UPPER]: \"a0: string, 将字符串转为大写\",\n  [LogicNodeNameEnum.LOWER]: \"a0: string, 将字符串转为小写\",\n  [LogicNodeNameEnum.LEN]: \"a0: string, 获取字符串长度\",\n  [LogicNodeNameEnum.COPY]: \"a0: string, 复制字符串\",\n  [LogicNodeNameEnum.SPLIT]: \"a0: string, a1: separator, a2: separator2, a3: ...\",\n  [LogicNodeNameEnum.REPLACE]: \"a0: string, a1: old, a2: new, 替换字符串\",\n  [LogicNodeNameEnum.CONNECT]: \"a0 + a1 + a2 + ... 连接字符串\",\n  [LogicNodeNameEnum.CHECK_REGEX_MATCH]: \"正则匹配\",\n  [LogicNodeNameEnum.COUNT]: \"a0, a1, ... 统计元素个数\",\n  [LogicNodeNameEnum.AVE]: \"a0, a1, ... \",\n  [LogicNodeNameEnum.MEDIAN]: \"a0, a1, ... \",\n  [LogicNodeNameEnum.MODE]: \"a0, a1, ... \",\n  [LogicNodeNameEnum.VARIANCE]: \"a0, a1, ... \",\n  [LogicNodeNameEnum.STANDARD_DEVIATION]: \"a0, a1, ... \",\n  [LogicNodeNameEnum.RGB]: \"a0: red, a1: green, a2: blue\",\n  [LogicNodeNameEnum.RGBA]: \"a0: red, a1: green, a2: blue, a3: alpha\",\n  [LogicNodeNameEnum.GET_LOCATION]: \"a0: node\",\n  [LogicNodeNameEnum.SET_LOCATION]: \"a0: x, a1: y\",\n  [LogicNodeNameEnum.SET_LOCATION_BY_UUID]: \"a0: uuid, a1: x, a2: y\",\n  [LogicNodeNameEnum.GET_LOCATION_BY_UUID]: \"a0: uuid\",\n  [LogicNodeNameEnum.GET_SIZE]: \"a0: node\",\n  [LogicNodeNameEnum.GET_MOUSE_LOCATION]: \"无输入\",\n  [LogicNodeNameEnum.GET_MOUSE_WORLD_LOCATION]: \"无输入\",\n  [LogicNodeNameEnum.GET_CAMERA_LOCATION]: \"无输入\",\n  [LogicNodeNameEnum.SET_CAMERA_LOCATION]: \"a0: x, a1: y\",\n  [LogicNodeNameEnum.GET_CAMERA_SCALE]: \"无输入\",\n  [LogicNodeNameEnum.SET_CAMERA_SCALE]: \"a0: number\",\n  [LogicNodeNameEnum.IS_COLLISION]: \"a0: node1, a1: node2, a2, ...\",\n  [LogicNodeNameEnum.GET_TIME]: \"无输入\",\n  [LogicNodeNameEnum.GET_DATE_TIME]: \"无输入\",\n  [LogicNodeNameEnum.ADD_DATE_TIME]: \"无输入\",\n  [LogicNodeNameEnum.PLAY_SOUND]: \"a0: filePath, a1: 0/1\",\n  [LogicNodeNameEnum.GET_NODE_UUID]: \"a0: node\",\n  [LogicNodeNameEnum.GET_NODE_RGBA]: \"a0: node\",\n  [LogicNodeNameEnum.COLLECT_NODE_DETAILS_BY_RGBA]: \"a0: red, a1: green, a2: blue, a3: alpha\",\n  [LogicNodeNameEnum.COLLECT_NODE_NAME_BY_RGBA]: \"a0: red, a1: green, a2: blue, a3: alpha\",\n  [LogicNodeNameEnum.FPS]: \"无输入\",\n  [LogicNodeNameEnum.CREATE_TEXT_NODE_ON_LOCATION]: \"a0: x, a1: y, a2: text, a3: 0/1\",\n  [LogicNodeNameEnum.IS_HAVE_ENTITY_ON_LOCATION]: \"a0: x, a1: y\",\n  [LogicNodeNameEnum.REPLACE_GLOBAL_CONTENT]: \"a0: 被替换内容, a1: 新内容\",\n  [LogicNodeNameEnum.SEARCH_CONTENT]: \"a0: 被搜索内容, a1: 是否大小写敏感0/1\",\n  [LogicNodeNameEnum.DELETE_PEN_STROKE_BY_COLOR]: \"a0: red, a1: green, a2: blue, a3: alpha\",\n  [LogicNodeNameEnum.DELAY_COPY]: \"a0: 被复制内容, a1: 默认输出内容, a2: 延迟时间(刻)\",\n\n  [LogicNodeNameEnum.SET_VAR]: \"a0: name, a1: value\",\n  [LogicNodeNameEnum.GET_VAR]: \"a0: name\",\n};\n\n/**\n * 获取逻辑节点的渲染名称\n * 如果输入的不是名称，则返回原值\n * @param name\n * @returns\n */\nexport function getLogicNodeRenderName(name: LogicNodeNameEnum): string {\n  // 使用名称作为键来索引 LogicNodeNameToRenderNameMap 对象\n  const renderName = LogicNodeNameToRenderNameMap[name];\n  return renderName !== undefined ? renderName : name; // 如果找不到对应的渲染名称，则返回原值\n}\n\n/**\n * 简化的符号\n * 用于连线\n */\nexport enum LogicNodeSimpleOperatorEnum {\n  ADD = \"+\",\n  SUBTRACT = \"-\",\n  MULTIPLY = \"*\",\n  DIVIDE = \"/\",\n  MODULO = \"%\",\n  POWER = \"**\",\n  // 比较\n  LT = \"<\",\n  GT = \">\",\n  LTE = \"<=\",\n  GTE = \">=\",\n  EQ = \"==\",\n  NEQ = \"!=\",\n  // 逻辑\n  AND = \"&&\",\n  OR = \"||\",\n  NOT = \"!\",\n  XOR = \"^\",\n}\n"
  },
  {
    "path": "app/src/core/service/dataGenerateService/autoComputeEngine/mainTick.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { CompareFunctions } from \"@/core/service/dataGenerateService/autoComputeEngine/functions/compareLogic\";\nimport { MathFunctions } from \"@/core/service/dataGenerateService/autoComputeEngine/functions/mathLogic\";\nimport { NodeLogic } from \"@/core/service/dataGenerateService/autoComputeEngine/functions/nodeLogic\";\nimport { ProgramFunctions } from \"@/core/service/dataGenerateService/autoComputeEngine/functions/programLogic\";\nimport { StringFunctions } from \"@/core/service/dataGenerateService/autoComputeEngine/functions/stringLogic\";\nimport {\n  LogicNodeNameEnum,\n  LogicNodeSimpleOperatorEnum,\n} from \"@/core/service/dataGenerateService/autoComputeEngine/logicNodeNameEnum\";\nimport { RectangleLittleNoteEffect } from \"@/core/service/feedbackService/effectEngine/concrete/RectangleLittleNoteEffect\";\nimport { TextRaiseEffectLocated } from \"@/core/service/feedbackService/effectEngine/concrete/TextRaiseEffectLocated\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { LineEdge } from \"@/core/stage/stageObject/association/LineEdge\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\n\ntype MathFunctionType = (args: number[]) => number[];\ntype StringFunctionType = (args: string[]) => string[];\ntype OtherFunctionType = (\n  project: Project,\n  fatherNodes: ConnectableEntity[],\n  childNodes: ConnectableEntity[],\n) => string[];\ntype VariableFunctionType = (project: Project, args: string[]) => string[];\ntype StringFunctionMap = Record<string, StringFunctionType>;\ntype OtherFunctionMap = Record<string, OtherFunctionType>;\ntype VariableFunctionMap = Record<string, VariableFunctionType>;\n\n@service(\"autoCompute\")\nexport class AutoCompute {\n  /**\n   *\n   * 简单符号与函数的映射\n   */\n  MapOperationNameFunction: StringFunctionMap = {\n    [LogicNodeSimpleOperatorEnum.ADD]: this.funcTypeTrans(MathFunctions.add),\n    [LogicNodeSimpleOperatorEnum.SUBTRACT]: this.funcTypeTrans(MathFunctions.subtract),\n    [LogicNodeSimpleOperatorEnum.MULTIPLY]: this.funcTypeTrans(MathFunctions.multiply),\n    [LogicNodeSimpleOperatorEnum.DIVIDE]: this.funcTypeTrans(MathFunctions.divide),\n    [LogicNodeSimpleOperatorEnum.MODULO]: this.funcTypeTrans(MathFunctions.modulo),\n    [LogicNodeSimpleOperatorEnum.POWER]: this.funcTypeTrans(MathFunctions.power),\n    // 比较\n    [LogicNodeSimpleOperatorEnum.LT]: this.funcTypeTrans(CompareFunctions.lessThan),\n    [LogicNodeSimpleOperatorEnum.GT]: this.funcTypeTrans(CompareFunctions.greaterThan),\n    [LogicNodeSimpleOperatorEnum.LTE]: this.funcTypeTrans(CompareFunctions.isIncreasing),\n    [LogicNodeSimpleOperatorEnum.GTE]: this.funcTypeTrans(CompareFunctions.isDecreasing),\n    [LogicNodeSimpleOperatorEnum.EQ]: this.funcTypeTrans(CompareFunctions.isSame),\n    [LogicNodeSimpleOperatorEnum.NEQ]: this.funcTypeTrans(CompareFunctions.isDistinct),\n    // 逻辑门\n    [LogicNodeSimpleOperatorEnum.AND]: this.funcTypeTrans(MathFunctions.and),\n    [LogicNodeSimpleOperatorEnum.OR]: this.funcTypeTrans(MathFunctions.or),\n    [LogicNodeSimpleOperatorEnum.NOT]: this.funcTypeTrans(MathFunctions.not),\n    [LogicNodeSimpleOperatorEnum.XOR]: this.funcTypeTrans(MathFunctions.xor),\n  };\n\n  /**\n   * 双井号格式的名字与函数的映射\n   */\n  MapNameFunction: StringFunctionMap = {\n    // 数学一元运算\n    [LogicNodeNameEnum.ABS]: this.funcTypeTrans(MathFunctions.abs),\n    [LogicNodeNameEnum.FLOOR]: this.funcTypeTrans(MathFunctions.floor),\n    [LogicNodeNameEnum.CEIL]: this.funcTypeTrans(MathFunctions.ceil),\n    [LogicNodeNameEnum.ROUND]: this.funcTypeTrans(MathFunctions.round),\n    [LogicNodeNameEnum.SQRT]: this.funcTypeTrans(MathFunctions.sqrt),\n    // 数学二元运算\n    [LogicNodeNameEnum.ADD]: this.funcTypeTrans(MathFunctions.add),\n    [LogicNodeNameEnum.SUBTRACT]: this.funcTypeTrans(MathFunctions.subtract),\n    [LogicNodeNameEnum.MULTIPLY]: this.funcTypeTrans(MathFunctions.multiply),\n    [LogicNodeNameEnum.DIVIDE]: this.funcTypeTrans(MathFunctions.divide),\n    [LogicNodeNameEnum.MODULO]: this.funcTypeTrans(MathFunctions.modulo),\n    [LogicNodeNameEnum.MAX]: this.funcTypeTrans(MathFunctions.max),\n    [LogicNodeNameEnum.MIN]: this.funcTypeTrans(MathFunctions.min),\n    [LogicNodeNameEnum.POWER]: this.funcTypeTrans(MathFunctions.power),\n    [LogicNodeNameEnum.LOG]: this.funcTypeTrans(MathFunctions.log),\n    // 数学一元函数\n    [LogicNodeNameEnum.SIN]: this.funcTypeTrans(MathFunctions.sin),\n    [LogicNodeNameEnum.COS]: this.funcTypeTrans(MathFunctions.cos),\n    [LogicNodeNameEnum.TAN]: this.funcTypeTrans(MathFunctions.tan),\n    [LogicNodeNameEnum.ASIN]: this.funcTypeTrans(MathFunctions.asin),\n    [LogicNodeNameEnum.ACOS]: this.funcTypeTrans(MathFunctions.acos),\n    [LogicNodeNameEnum.ATAN]: this.funcTypeTrans(MathFunctions.atan),\n    [LogicNodeNameEnum.EXP]: this.funcTypeTrans(MathFunctions.exp),\n    [LogicNodeNameEnum.LN]: this.funcTypeTrans(MathFunctions.ln),\n    // 比较\n    [LogicNodeNameEnum.LT]: this.funcTypeTrans(CompareFunctions.lessThan),\n    [LogicNodeNameEnum.GT]: this.funcTypeTrans(CompareFunctions.greaterThan),\n    [LogicNodeNameEnum.LTE]: this.funcTypeTrans(CompareFunctions.isIncreasing),\n    [LogicNodeNameEnum.GTE]: this.funcTypeTrans(CompareFunctions.isDecreasing),\n    [LogicNodeNameEnum.EQ]: this.funcTypeTrans(CompareFunctions.isSame),\n    [LogicNodeNameEnum.NEQ]: this.funcTypeTrans(CompareFunctions.isDistinct),\n    // 概率论与统计\n    [LogicNodeNameEnum.COUNT]: this.funcTypeTrans(MathFunctions.count),\n    [LogicNodeNameEnum.AVE]: this.funcTypeTrans(MathFunctions.average),\n    [LogicNodeNameEnum.MEDIAN]: this.funcTypeTrans(MathFunctions.median),\n    [LogicNodeNameEnum.MODE]: this.funcTypeTrans(MathFunctions.mode),\n    [LogicNodeNameEnum.VARIANCE]: this.funcTypeTrans(MathFunctions.variance),\n    [LogicNodeNameEnum.STANDARD_DEVIATION]: this.funcTypeTrans(MathFunctions.standardDeviation),\n    [LogicNodeNameEnum.RANDOM]: this.funcTypeTrans(MathFunctions.random),\n    [LogicNodeNameEnum.RANDOM_INT]: this.funcTypeTrans(MathFunctions.randomInt),\n    [LogicNodeNameEnum.RANDOM_FLOAT]: this.funcTypeTrans(MathFunctions.randomFloat),\n    [LogicNodeNameEnum.RANDOM_POISSON]: this.funcTypeTrans(MathFunctions.randomPoisson),\n    // 逻辑门\n    [LogicNodeNameEnum.AND]: this.funcTypeTrans(MathFunctions.and),\n    [LogicNodeNameEnum.OR]: this.funcTypeTrans(MathFunctions.or),\n    [LogicNodeNameEnum.NOT]: this.funcTypeTrans(MathFunctions.not),\n    [LogicNodeNameEnum.XOR]: this.funcTypeTrans(MathFunctions.xor),\n    // 字符串类计算\n    [LogicNodeNameEnum.UPPER]: StringFunctions.upper,\n    [LogicNodeNameEnum.LOWER]: StringFunctions.lower,\n    [LogicNodeNameEnum.LEN]: StringFunctions.len,\n    [LogicNodeNameEnum.COPY]: StringFunctions.copy,\n    [LogicNodeNameEnum.SPLIT]: StringFunctions.split,\n    [LogicNodeNameEnum.REPLACE]: StringFunctions.replace,\n    [LogicNodeNameEnum.CONNECT]: StringFunctions.connect,\n    [LogicNodeNameEnum.CHECK_REGEX_MATCH]: StringFunctions.checkRegexMatch,\n  };\n\n  MapVariableFunction: VariableFunctionMap = {\n    // 编程类功能\n    [LogicNodeNameEnum.SET_VAR]: ProgramFunctions.setVar,\n    [LogicNodeNameEnum.GET_VAR]: ProgramFunctions.getVar,\n  };\n\n  /**\n   * 其他特殊功能的函数\n   */\n  MapOtherFunction: OtherFunctionMap = {\n    [LogicNodeNameEnum.RGB]: NodeLogic.setColorByRGB,\n    [LogicNodeNameEnum.RGBA]: NodeLogic.setColorByRGBA,\n    [LogicNodeNameEnum.GET_LOCATION]: NodeLogic.getLocation,\n    [LogicNodeNameEnum.SET_LOCATION]: NodeLogic.setLocation,\n    [LogicNodeNameEnum.SET_LOCATION_BY_UUID]: NodeLogic.setLocationByUUID,\n    [LogicNodeNameEnum.GET_LOCATION_BY_UUID]: NodeLogic.getLocationByUUID,\n    [LogicNodeNameEnum.GET_SIZE]: NodeLogic.getSize,\n    [LogicNodeNameEnum.GET_MOUSE_LOCATION]: NodeLogic.getMouseLocation,\n    [LogicNodeNameEnum.GET_MOUSE_WORLD_LOCATION]: NodeLogic.getMouseWorldLocation,\n    [LogicNodeNameEnum.GET_CAMERA_LOCATION]: NodeLogic.getCameraLocation,\n    [LogicNodeNameEnum.SET_CAMERA_LOCATION]: NodeLogic.setCameraLocation,\n    [LogicNodeNameEnum.GET_CAMERA_SCALE]: NodeLogic.getCameraScale,\n    [LogicNodeNameEnum.SET_CAMERA_SCALE]: NodeLogic.setCameraScale,\n    [LogicNodeNameEnum.IS_COLLISION]: NodeLogic.isCollision,\n    [LogicNodeNameEnum.GET_TIME]: NodeLogic.getTime,\n    [LogicNodeNameEnum.GET_DATE_TIME]: NodeLogic.getDateTime,\n    [LogicNodeNameEnum.ADD_DATE_TIME]: NodeLogic.addDateTime,\n    [LogicNodeNameEnum.PLAY_SOUND]: NodeLogic.playSound,\n    [LogicNodeNameEnum.FPS]: NodeLogic.getFps,\n    [LogicNodeNameEnum.GET_NODE_RGBA]: NodeLogic.getNodeRGBA,\n    [LogicNodeNameEnum.GET_NODE_UUID]: NodeLogic.getNodeUUID,\n    [LogicNodeNameEnum.COLLECT_NODE_DETAILS_BY_RGBA]: NodeLogic.collectNodeDetailsByRGBA,\n    [LogicNodeNameEnum.COLLECT_NODE_NAME_BY_RGBA]: NodeLogic.collectNodeNameByRGBA,\n    [LogicNodeNameEnum.CREATE_TEXT_NODE_ON_LOCATION]: NodeLogic.createTextNodeOnLocation,\n    [LogicNodeNameEnum.IS_HAVE_ENTITY_ON_LOCATION]: NodeLogic.isHaveEntityOnLocation,\n    [LogicNodeNameEnum.REPLACE_GLOBAL_CONTENT]: NodeLogic.replaceGlobalContent,\n    [LogicNodeNameEnum.SEARCH_CONTENT]: NodeLogic.searchContent,\n    [LogicNodeNameEnum.DELETE_PEN_STROKE_BY_COLOR]: NodeLogic.deletePenStrokeByColor,\n    [LogicNodeNameEnum.DELAY_COPY]: NodeLogic.delayCopy,\n  };\n\n  variables = new Map<string, string>();\n\n  constructor(private readonly project: Project) {}\n\n  private tickNumber = 0;\n\n  /**\n   *\n   * @param tickNumber 帧号\n   * @returns\n   */\n  tick() {\n    // debug 只有在按下x键才会触发\n    if (!this.project.controller.pressingKeySet.has(\"x\")) {\n      return;\n    }\n    if (this.project.controller.pressingKeySet.has(\"shift\")) {\n      if (this.tickNumber % 60 !== 0) {\n        return;\n      }\n    }\n\n    // 用于显示逻辑节点执行顺序标号\n    let i = 0;\n\n    let nodes = this.project.stageManager.getTextNodes().filter((node) => this.isTextNodeLogic(node));\n    nodes = this.sortEntityByLocation(nodes) as TextNode[];\n\n    // 自动计算引擎功能\n\n    for (const node of nodes) {\n      this.computeTextNode(node);\n      this.project.effects.addEffect(TextRaiseEffectLocated.fromDebugLogicNode(i, node.geometryCenter));\n      i++;\n    }\n    // region 计算section\n    let sections = this.project.stageManager\n      .getSections()\n      .filter((section) => this.isSectionLogic(section) && section.text.length > 0);\n    sections = this.sortEntityByLocation(sections) as Section[];\n\n    for (const section of sections) {\n      this.computeSection(section);\n    }\n    // region 根据Edge计算\n    for (const edge of this.project.stageManager\n      .getLineEdges()\n      .sort(\n        (a, b) => a.source.collisionBox.getRectangle().location.x - b.source.collisionBox.getRectangle().location.x,\n      )) {\n      this.computeEdge(edge);\n    }\n    // NodeLogic.step++;\n    // TODO: 逻辑引擎执行一步，计数器+1\n  }\n\n  /**\n   * 将 MathFunctionType 转换为 StringFunctionType\n   * @param mF\n   * @returns\n   */\n  funcTypeTrans(mF: MathFunctionType): StringFunctionType {\n    return (args: string[]): string[] => {\n      const numbers = args.map((arg) => this.project.autoComputeUtils.stringToNumber(arg));\n      const result = mF(numbers);\n      return result.map((num) => String(num));\n    };\n  }\n\n  isTextNodeLogic(node: TextNode): boolean {\n    const names = [\n      ...Object.keys(this.MapNameFunction),\n      ...Object.keys(this.MapOtherFunction),\n      ...Object.keys(this.MapVariableFunction),\n    ];\n    return names.includes(node.text);\n  }\n\n  private isSectionLogic(section: Section): boolean {\n    for (const name of Object.keys(this.MapNameFunction)) {\n      if (section.text === name) {\n        return true;\n      }\n    }\n    return false;\n  }\n\n  /**\n   * 按y轴从上到下排序，如果y轴相同，则按照x轴从左到右排序\n   * @param entities\n   * @returns\n   */\n  private sortEntityByLocation(entities: ConnectableEntity[]): ConnectableEntity[] {\n    // 按照y坐标排序\n    // 太草了，2025.1.18 周六晚上littlefean发现y轴排序不能只传递一个对象，要传递两个对象然后相互减\n    // 否则就拍了个寂寞……\n    return entities.sort((a, b) => {\n      const yDiff = a.collisionBox.getRectangle().location.y - b.collisionBox.getRectangle().location.y;\n      if (yDiff === 0) {\n        return a.collisionBox.getRectangle().location.x - b.collisionBox.getRectangle().location.x;\n      }\n      return yDiff;\n    });\n  }\n\n  /**\n   * 运行一个节点的计算\n   * @param node\n   */\n  private computeTextNode(node: TextNode) {\n    for (const name of Object.keys(this.MapNameFunction)) {\n      if (node.text === name) {\n        // 发现了一个逻辑节点\n        this.project.effects.addEffect(RectangleLittleNoteEffect.fromUtilsLittleNote(node));\n\n        const result = this.MapNameFunction[name](\n          this.project.autoComputeUtils.getParentTextNodes(node).map((p) => p.text),\n        );\n        this.project.autoComputeUtils.generateMultiResult(node, result);\n      }\n    }\n    // 特殊类型计算\n    for (const name of Object.keys(this.MapOtherFunction)) {\n      if (node.text === name) {\n        // 发现了一个特殊节点\n        if (name === LogicNodeNameEnum.DELAY_COPY) {\n          // 延迟复制要传逻辑节点本身的uuid\n          const result = this.MapOtherFunction[name](\n            this.project,\n            [...this.project.autoComputeUtils.getParentEntities(node), node],\n            this.project.autoComputeUtils.getChildTextNodes(node),\n          );\n          this.project.autoComputeUtils.generateMultiResult(node, result);\n          continue;\n        }\n        const result = this.MapOtherFunction[name](\n          this.project,\n          this.project.autoComputeUtils.getParentEntities(node),\n          this.project.autoComputeUtils.getChildTextNodes(node),\n        );\n        this.project.autoComputeUtils.generateMultiResult(node, result);\n      }\n    }\n    // 变量计算\n    for (const name of Object.keys(this.MapVariableFunction)) {\n      if (node.text === name) {\n        // 发现了一个变量节点\n        const result = this.MapVariableFunction[name](\n          this.project,\n          this.project.autoComputeUtils.getParentTextNodes(node).map((p) => p.text),\n        );\n        this.project.autoComputeUtils.generateMultiResult(node, result);\n      }\n    }\n  }\n\n  private computeSection(section: Section) {\n    for (const name of Object.keys(this.MapNameFunction)) {\n      if (section.text === name) {\n        // 发现了一个逻辑Section\n        const inputStringList: string[] = [];\n        for (const child of section.children.sort(\n          (a, b) => a.collisionBox.getRectangle().location.x - b.collisionBox.getRectangle().location.x,\n        )) {\n          if (child instanceof TextNode) {\n            inputStringList.push(child.text);\n          }\n        }\n        const result = this.MapNameFunction[name](inputStringList);\n        this.project.autoComputeUtils.getSectionMultiResult(section, result);\n      }\n    }\n  }\n\n  private computeEdge(edge: LineEdge) {\n    for (const name of Object.keys(this.MapOperationNameFunction)) {\n      if (edge.text === name) {\n        // 发现了一个逻辑Edge\n        const source = edge.source;\n        const target = edge.target;\n        if (source instanceof TextNode && target instanceof TextNode) {\n          const inputStringList: string[] = [source.text, target.text];\n\n          const result = this.MapOperationNameFunction[name](inputStringList);\n          this.project.autoComputeUtils.getNodeOneResult(target, result[0]);\n        }\n      }\n      // 更加简化的Edge计算\n      if (edge.text.includes(name)) {\n        // 检测 '+5' '/2' 这样的情况，提取后面的数字\n        const num = Number(edge.text.replace(name, \"\"));\n        if (num) {\n          const source = edge.source;\n          const target = edge.target;\n          if (source instanceof TextNode && target instanceof TextNode) {\n            const inputStringList: string[] = [source.text, num.toString()];\n            const result = this.MapOperationNameFunction[name](inputStringList);\n            target.rename(result[0]);\n          }\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataGenerateService/crossFileContentQuery.tsx",
    "content": "import { loadAllServicesBeforeInit } from \"@/core/loadAllServices\";\nimport { Project } from \"@/core/Project\";\nimport { PathString } from \"@/utils/pathString\";\nimport { RecentFileManager } from \"../dataFileService/RecentFileManager\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\n\n/**\n * 跨文件内容查询服务\n * 用于获取其他prg文件中的内容\n */\nexport namespace CrossFileContentQuery {\n  // 缓存表，key是文件名，value是{sections: Section[], timestamp: number}\n  const sectionCache: Map<string, { sections: string[]; timestamp: number }> = new Map();\n  // 缓存时间，单位：毫秒\n  const CACHE_TIME = 10000;\n\n  /**\n   * 获取指定.prg 文件中的所有的 Section框名称 (有缓存机制)\n   * @param fileName 文件名\n   * @returns Section框名称数组\n   */\n  export async function getSectionsByFileName(fileName: string): Promise<string[]> {\n    // 检查缓存是否存在且未过期\n    const cached = sectionCache.get(fileName);\n    if (cached && Date.now() - cached.timestamp < CACHE_TIME) {\n      return cached.sections;\n    }\n\n    try {\n      // 1. 根据文件名查找并加载prg文件\n      const recentFiles = await RecentFileManager.getRecentFiles();\n      const file = recentFiles.find(\n        (file) =>\n          PathString.getFileNameFromPath(file.uri.path) === fileName ||\n          PathString.getFileNameFromPath(file.uri.fsPath) === fileName,\n      );\n      if (!file) {\n        // 如果文件不存在，返回空数组\n        sectionCache.set(fileName, { sections: [], timestamp: Date.now() });\n        return [];\n      }\n\n      const fileUri = file.uri;\n      const project = new Project(fileUri);\n      loadAllServicesBeforeInit(project);\n      await project.init();\n\n      // 2. 查找所有Section\n      const sections = project.stage\n        .filter((obj) => obj instanceof Section && obj.text)\n        .map((section) => (section as Section).text);\n\n      // 3. 缓存结果\n      sectionCache.set(fileName, { sections, timestamp: Date.now() });\n\n      // 4. 清理资源\n      project.dispose();\n\n      return sections;\n    } catch (error) {\n      console.error(\"获取文件中的Section框失败\", error);\n      // 错误时也缓存空结果，避免频繁重试\n      sectionCache.set(fileName, { sections: [], timestamp: Date.now() });\n      return [];\n    }\n  }\n\n  /**\n   * 清除指定文件的缓存\n   * @param fileName 文件名\n   */\n  export function clearCache(fileName?: string): void {\n    if (fileName) {\n      sectionCache.delete(fileName);\n    } else {\n      sectionCache.clear();\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataGenerateService/dataTransferEngine/dataTransferEngine.tsx",
    "content": "/**\n * 专门用于其他软件中的数据类型转换\n */\nexport namespace DataTransferEngine {\n  /**\n   * 将xmind改成zip后，\n   * 内部的content.json转换成四空格缩进文本\n   * @param xmindContentJson\n   */\n  export function xmindToString(xmindContentJson: any): string {\n    try {\n      const sheetObject = xmindContentJson[0];\n      const root = sheetObject[\"rootTopic\"];\n\n      const dfs = (obj: XmindNode, indentLevel: number): string => {\n        let result = \"\";\n        if (obj.children) {\n          // 有子节点\n\n          // 添加标题\n          result += \"    \".repeat(indentLevel) + obj.title + \"\\n\";\n          for (const children of obj.children.attached) {\n            result += dfs(children, indentLevel + 1);\n          }\n        } else {\n          // 没有子节点\n          result += \"    \".repeat(indentLevel) + obj.title + \"\\n\";\n        }\n        return result;\n      };\n\n      return dfs(root, 0);\n    } catch {\n      throw Error(\"e\");\n    }\n  }\n}\n\ninterface XmindNode {\n  id: string;\n  title: string;\n  children?: { attached: XmindNode[] };\n}\n"
  },
  {
    "path": "app/src/core/service/dataGenerateService/generateFromFolderEngine/GenerateFromFolderEngine.tsx",
    "content": "import { Color, Vector } from \"@graphif/data-structures\";\nimport { Project, service } from \"@/core/Project\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { DetailsManager } from \"@/core/stage/stageObject/tools/entityDetailsManager\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { LineEdge } from \"@/core/stage/stageObject/association/LineEdge\";\nimport { parseSingleEmacsKey } from \"@/utils/emacs\";\nimport { allKeyBinds } from \"@/core/service/controlService/shortcutKeysEngine/shortcutKeysRegister\";\n// 快捷键分组定义（从SettingsWindow/keybinds.tsx复制）\n\nimport { Renderer } from \"@/core/render/canvas2d/renderer\";\nimport { getMultiLineTextSize } from \"@/utils/font\";\nimport i18next from \"i18next\";\nimport { shortcutKeysGroups } from \"@/sub/SettingsWindow/keybinds\";\n\n@service(\"generateFromFolder\")\nexport class GenerateFromFolder {\n  constructor(private readonly project: Project) {}\n\n  async generateFromFolder(folderPath: string): Promise<void> {\n    const folderStructure = await readFolderStructure(folderPath);\n    // 当前的放置点位\n    const currentLocation = this.project.camera.location.clone();\n    const dfs = (fEntry: FolderEntry, currentSection: Section | null = null) => {\n      if (fEntry.is_file) {\n        // 是文件，创建文本节点\n        const textNode = new TextNode(this.project, {\n          text: fEntry.name,\n          details: DetailsManager.markdownToDetails(fEntry.path),\n          collisionBox: new CollisionBox([new Rectangle(currentLocation.clone(), Vector.getZero())]),\n          color: this.getColorByPath(fEntry.path),\n        });\n        this.project.stageManager.add(textNode);\n        if (currentSection) {\n          this.project.stageManager.goInSection([textNode], currentSection);\n        }\n        return textNode;\n      } else {\n        // 是文件夹，先创建一个Section\n        const section = new Section(this.project, {\n          text: fEntry.name,\n          details: DetailsManager.markdownToDetails(fEntry.path),\n          collisionBox: new CollisionBox([new Rectangle(currentLocation.clone(), Vector.getZero())]),\n        });\n        this.project.stageManager.add(section);\n        if (currentSection) {\n          this.project.stageManager.goInSection([section], currentSection);\n        }\n        // 然后递归处理子文件夹\n        if (fEntry.children) {\n          for (const child of fEntry.children) {\n            dfs(child, section);\n          }\n        }\n        return section;\n      }\n    };\n    const rootEntity = dfs(folderStructure);\n    this.project.stageManager.clearSelectAll();\n    rootEntity.isSelected = true;\n  }\n\n  async generateTreeFromFolder(folderPath: string): Promise<void> {\n    const folderStructure = await readFolderStructure(folderPath);\n    // 当前的放置点位\n    const currentLocation = this.project.camera.location.clone();\n\n    let yIndex = 0;\n\n    const dfs = (fEntry: FolderEntry, depth: number, parentNode: TextNode | null) => {\n      // 无论是文件还是文件夹，都创建一个TextNode\n      const position = currentLocation.add(new Vector(depth * 150, yIndex * 50)); // x间距150，y间距50\n      yIndex++;\n\n      const node = new TextNode(this.project, {\n        text: fEntry.name,\n        details: DetailsManager.markdownToDetails(fEntry.path),\n        collisionBox: new CollisionBox([new Rectangle(position, Vector.getZero())]),\n        color: this.getColorByPath(fEntry.path),\n      });\n      // 如果是文件夹，且没有颜色（getColorByPath返回透明），可以给个默认颜色区分？\n      // 暂时保持一致，文件夹透明，文件有颜色\n\n      this.project.stageManager.add(node);\n      // 自动调整大小\n      node.forceAdjustSizeByText();\n\n      if (parentNode) {\n        // 创建连线\n        const edge = new LineEdge(this.project, {\n          associationList: [parentNode, node],\n          sourceRectangleRate: new Vector(0.99, 0.5), // 父节点右侧\n          targetRectangleRate: new Vector(0.01, 0.5), // 子节点左侧\n        });\n        this.project.stageManager.add(edge);\n      }\n\n      if (fEntry.children) {\n        for (const child of fEntry.children) {\n          dfs(child, depth + 1, node);\n        }\n      }\n      return node;\n    };\n\n    dfs(folderStructure, 0, null);\n    this.project.historyManager.recordStep();\n  }\n\n  private getColorByPath(path: string): Color {\n    if (path.includes(\".\")) {\n      const ext = path.split(\".\").pop() as string;\n      if (ext in GenerateFromFolder.fileExtColorMap) {\n        return Color.fromHex(GenerateFromFolder.fileExtColorMap[ext]);\n      } else {\n        return Color.Transparent;\n      }\n    } else {\n      return Color.Transparent;\n    }\n  }\n\n  static fileExtColorMap: Record<string, string> = {\n    txt: \"#000000\",\n    md: \"#000000\",\n    html: \"#4ec9b0\",\n    css: \"#da70cb\",\n    js: \"#dcdcaa\",\n    mp4: \"#181818\",\n    mp3: \"#ca64ea\",\n    png: \"#7a9a81\",\n    psd: \"#001d26\",\n    jpg: \"#49644e\",\n    jpeg: \"#49644e\",\n    gif: \"#ffca28\",\n  };\n}\n\n/**\n * 文件结构类型\n */\nexport type FolderEntry = {\n  name: string;\n  path: string;\n  is_file: boolean;\n  children?: FolderEntry[];\n};\n\nfunction readFolderStructure(path: string): Promise<FolderEntry> {\n  // 不可能是isWeb的情况了\n  return invoke(\"read_folder_structure\", { path });\n}\n\n/**\n * 根据当前快捷键配置生成键盘布局图\n * @param project 项目实例\n */\nexport async function generateKeyboardLayout(project: Project): Promise<void> {\n  // 标准QWERTY键盘布局\n  const keyboardLayout: {\n    row: number;\n    col: number;\n    key: string;\n    width?: number; // 默认1，特殊键可能更宽\n  }[] = [\n    // 第一行：数字行\n    { row: 0, col: 0, key: \"`\" },\n    { row: 0, col: 1, key: \"1\" },\n    { row: 0, col: 2, key: \"2\" },\n    { row: 0, col: 3, key: \"3\" },\n    { row: 0, col: 4, key: \"4\" },\n    { row: 0, col: 5, key: \"5\" },\n    { row: 0, col: 6, key: \"6\" },\n    { row: 0, col: 7, key: \"7\" },\n    { row: 0, col: 8, key: \"8\" },\n    { row: 0, col: 9, key: \"9\" },\n    { row: 0, col: 10, key: \"0\" },\n    { row: 0, col: 11, key: \"-\" },\n    { row: 0, col: 12, key: \"=\" },\n    { row: 0, col: 13, key: \"backspace\", width: 2 },\n    // 第二行：QWERTY行\n    { row: 1, col: 0, key: \"tab\", width: 1.5 },\n    { row: 1, col: 1.5, key: \"q\" },\n    { row: 1, col: 2.5, key: \"w\" },\n    { row: 1, col: 3.5, key: \"e\" },\n    { row: 1, col: 4.5, key: \"r\" },\n    { row: 1, col: 5.5, key: \"t\" },\n    { row: 1, col: 6.5, key: \"y\" },\n    { row: 1, col: 7.5, key: \"u\" },\n    { row: 1, col: 8.5, key: \"i\" },\n    { row: 1, col: 9.5, key: \"o\" },\n    { row: 1, col: 10.5, key: \"p\" },\n    { row: 1, col: 11.5, key: \"[\" },\n    { row: 1, col: 12.5, key: \"]\" },\n    { row: 1, col: 13.5, key: \"\\\\\", width: 1.5 },\n    // 第三行：ASDF行\n    { row: 2, col: 0, key: \"capslock\", width: 1.75 },\n    { row: 2, col: 1.75, key: \"a\" },\n    { row: 2, col: 2.75, key: \"s\" },\n    { row: 2, col: 3.75, key: \"d\" },\n    { row: 2, col: 4.75, key: \"f\" },\n    { row: 2, col: 5.75, key: \"g\" },\n    { row: 2, col: 6.75, key: \"h\" },\n    { row: 2, col: 7.75, key: \"j\" },\n    { row: 2, col: 8.75, key: \"k\" },\n    { row: 2, col: 9.75, key: \"l\" },\n    { row: 2, col: 10.75, key: \";\" },\n    { row: 2, col: 11.75, key: \"'\" },\n    { row: 2, col: 12.75, key: \"enter\", width: 2.25 },\n    // 第四行：ZXCV行\n    { row: 3, col: 0, key: \"shift\", width: 2.25 },\n    { row: 3, col: 2.25, key: \"z\" },\n    { row: 3, col: 3.25, key: \"x\" },\n    { row: 3, col: 4.25, key: \"c\" },\n    { row: 3, col: 5.25, key: \"v\" },\n    { row: 3, col: 6.25, key: \"b\" },\n    { row: 3, col: 7.25, key: \"n\" },\n    { row: 3, col: 8.25, key: \"m\" },\n    { row: 3, col: 9.25, key: \",\" },\n    { row: 3, col: 10.25, key: \".\" },\n    { row: 3, col: 11.25, key: \"/\" },\n    { row: 3, col: 12.25, key: \"shift\", width: 2.75 },\n    // 第五行：空格行\n    { row: 4, col: 0, key: \"ctrl\", width: 1.25 },\n    { row: 4, col: 1.25, key: \"alt\", width: 1.25 },\n    { row: 4, col: 2.5, key: \" \", width: 6 }, // 空格键\n    { row: 4, col: 8.5, key: \"alt\", width: 1.25 },\n    { row: 4, col: 9.75, key: \"ctrl\", width: 1.25 },\n  ];\n\n  const keyWidth = 60; // 标准键宽度\n  const keyHeight = 60; // 标准键高度\n  const keyGap = 30; // 键之间的间距（增大）\n  const rowGap = 40; // 行之间的间距（增大）\n\n  // 获取当前快捷键配置\n  const keyBindEntries = await project.keyBinds.entries();\n  const keyBindMap = new Map<string, { key: string; isEnabled: boolean }>();\n\n  for (const [id, config] of keyBindEntries) {\n    if (typeof config === \"string\") {\n      keyBindMap.set(id, { key: config, isEnabled: true });\n    } else {\n      keyBindMap.set(id, { key: config.key, isEnabled: config.isEnabled !== false });\n    }\n  }\n\n  // 获取所有快捷键的翻译信息\n  const keyBindInfoMap = new Map<string, { title: string; description: string; keySequence: string }>();\n  for (const keyBind of allKeyBinds.filter((kb) => !kb.isGlobal)) {\n    const config = keyBindMap.get(keyBind.id);\n    if (config && config.isEnabled) {\n      // 获取翻译文本\n      const translation = i18next.t(`${keyBind.id}`, { ns: \"keyBinds\", returnObjects: true }) as {\n        title?: string;\n        description?: string;\n      };\n      const title = translation?.title || keyBind.id;\n      const description = translation?.description || \"\";\n\n      keyBindInfoMap.set(keyBind.id, {\n        title,\n        description,\n        keySequence: config.key,\n      });\n    }\n  }\n\n  // 创建键节点映射\n  const keyNodeMap = new Map<string, TextNode>();\n  const currentLocation = project.camera.location.clone();\n\n  // 计算键盘总宽度和起始位置\n  const maxCol = Math.max(...keyboardLayout.map((k) => k.col + (k.width || 1)));\n  const keyboardWidth = maxCol * (keyWidth + keyGap);\n  const startX = currentLocation.x - keyboardWidth / 2;\n  const startY = currentLocation.y;\n\n  // 创建所有键节点\n  for (const keyInfo of keyboardLayout) {\n    const key = keyInfo.key;\n    const width = (keyInfo.width || 1) * keyWidth + (keyInfo.width || 1 - 1) * keyGap;\n    const height = keyHeight;\n    const x = startX + keyInfo.col * (keyWidth + keyGap);\n    const y = startY + keyInfo.row * (keyHeight + rowGap);\n\n    const keyNode = new TextNode(project, {\n      text: formatKeyDisplay(key),\n      collisionBox: new CollisionBox([new Rectangle(new Vector(x, y), new Vector(width, height))]),\n      color: Color.Transparent,\n    });\n    project.stageManager.add(keyNode);\n\n    // 使用唯一标识符：row-col-key 来避免重复键名的问题\n    const uniqueKey = `${keyInfo.row}-${keyInfo.col}-${key.toLowerCase()}`;\n    keyNodeMap.set(uniqueKey, keyNode);\n    // 同时也用键名映射，方便查找\n    if (!keyNodeMap.has(key.toLowerCase())) {\n      keyNodeMap.set(key.toLowerCase(), keyNode);\n    }\n  }\n\n  // 为每个已使用的快捷键创建连接和标注\n  const usedKeys = new Set<string>();\n  const keyBindGroups = new Map<string, string[]>(); // 键 -> 快捷键ID列表\n\n  for (const [keyBindId, config] of keyBindMap.entries()) {\n    if (!config.isEnabled) continue;\n\n    // 解析快捷键序列（可能包含多个按键，如 \"C-k C-t\"）\n    const keySequence = config.key.split(\" \");\n    for (const keyStr of keySequence) {\n      const parsed = parseSingleEmacsKey(keyStr);\n      const baseKey = parsed.key.toLowerCase();\n\n      // 跳过特殊键（鼠标、滚轮等）\n      if (baseKey.startsWith(\"<\") && baseKey.endsWith(\">\")) {\n        continue;\n      }\n\n      // 记录使用的键\n      usedKeys.add(baseKey);\n\n      // 记录快捷键信息\n      if (!keyBindGroups.has(baseKey)) {\n        keyBindGroups.set(baseKey, []);\n      }\n      keyBindGroups.get(baseKey)!.push(keyBindId);\n    }\n  }\n\n  // 为使用的键添加颜色标记\n  for (const [key, node] of keyNodeMap.entries()) {\n    if (usedKeys.has(key)) {\n      node.color = new Color(100, 200, 100, 255); // 浅绿色表示已使用\n    }\n  }\n\n  // 计算键盘整体的外接矩形\n  const keyboardNodes = Array.from(keyNodeMap.values());\n  const keyboardBoundingRect = Rectangle.getBoundingRectangle(\n    keyboardNodes.map((node) => node.collisionBox.getRectangle()),\n  );\n\n  // 按照分组组织快捷键\n  const keyboardGap = 100; // 键盘和标注区域之间的间距\n  const sectionSpacingX = 50; // Section之间的水平间距\n  const sectionSpacingY = 50; // Section之间的垂直间距\n  const nodeSpacingX = 20; // Section内节点之间的水平间距\n  const nodeSpacingY = 20; // Section内节点之间的垂直间距\n  const sectionPadding = 40; // Section内边距\n\n  // 标注区域的起始位置（键盘右侧）\n  let currentSectionX = keyboardBoundingRect.right + keyboardGap;\n  let currentSectionY = keyboardBoundingRect.top;\n\n  // 存储所有标注节点和连接信息\n  const annotationNodes: Array<{ node: TextNode; keyNode: TextNode }> = [];\n\n  // 获取所有已分组的快捷键ID\n  const groupedKeyBindIds = new Set<string>();\n  for (const group of shortcutKeysGroups) {\n    for (const keyBindId of group.keys) {\n      groupedKeyBindIds.add(keyBindId);\n    }\n  }\n\n  // 获取未分类的快捷键\n  const ungroupedKeyBindIds: string[] = [];\n  for (const keyBind of allKeyBinds.filter((kb) => !kb.isGlobal)) {\n    const config = keyBindMap.get(keyBind.id);\n    if (config && config.isEnabled && !groupedKeyBindIds.has(keyBind.id)) {\n      ungroupedKeyBindIds.push(keyBind.id);\n    }\n  }\n\n  // 创建所有分组（包括未分类的）\n  const allGroups = [\n    ...shortcutKeysGroups,\n    ...(ungroupedKeyBindIds.length > 0 ? [{ title: \"otherKeys\", keys: ungroupedKeyBindIds }] : []),\n  ];\n\n  // 遍历每个分组\n  for (const group of allGroups) {\n    // 收集该分组中已启用的快捷键\n    const groupKeyBinds: Array<{\n      keyNode: TextNode;\n      keyBindId: string;\n      info: { title: string; description: string; keySequence: string };\n    }> = [];\n\n    for (const keyBindId of group.keys) {\n      const config = keyBindMap.get(keyBindId);\n      if (!config || !config.isEnabled) continue;\n\n      // 找到该快捷键使用的键\n      const keySequence = config.key.split(\" \");\n      for (const keyStr of keySequence) {\n        const parsed = parseSingleEmacsKey(keyStr);\n        const baseKey = parsed.key.toLowerCase();\n\n        // 跳过特殊键（鼠标、滚轮等）\n        if (baseKey.startsWith(\"<\") && baseKey.endsWith(\">\")) {\n          continue;\n        }\n\n        const keyNode = keyNodeMap.get(baseKey);\n        if (keyNode) {\n          const info = keyBindInfoMap.get(keyBindId) || { title: keyBindId, description: \"\", keySequence: \"\" };\n          groupKeyBinds.push({ keyNode, keyBindId, info });\n          break; // 每个快捷键只记录一次\n        }\n      }\n    }\n\n    // 如果该分组没有已启用的快捷键，跳过\n    if (groupKeyBinds.length === 0) continue;\n\n    // 计算Section内节点的布局\n    const nodesPerRow = Math.ceil(Math.sqrt(groupKeyBinds.length * 1.5)); // 稍微宽一点\n    let maxRowHeight = 0;\n    // let currentRow = 0;\n    let currentCol = 0;\n    let currentX = currentSectionX + sectionPadding;\n    let currentY = currentSectionY + sectionPadding;\n\n    const sectionNodes: TextNode[] = [];\n\n    for (const { keyNode, info } of groupKeyBinds) {\n      // 创建标注节点文本\n      const annotationText = `${info.title}\\n${info.keySequence}`;\n\n      // 计算文本实际大小\n      const fontSize = Renderer.FONT_SIZE;\n      const textSize = getMultiLineTextSize(annotationText, fontSize, 1.5);\n      const nodeSize = textSize.add(Vector.same(Renderer.NODE_PADDING).multiply(2));\n\n      // 计算节点位置\n      const nodeX = currentX;\n      const nodeY = currentY;\n\n      // 创建标注节点（不传颜色参数，使用默认透明色）\n      const annotationNode = new TextNode(project, {\n        text: annotationText,\n        details: info.description ? DetailsManager.markdownToDetails(info.description) : [],\n        collisionBox: new CollisionBox([new Rectangle(new Vector(nodeX, nodeY), nodeSize)]),\n      });\n\n      // 强制调整大小以确保正确\n      annotationNode.forceAdjustSizeByText();\n\n      // 获取实际大小\n      const actualSize = annotationNode.collisionBox.getRectangle().size;\n      maxRowHeight = Math.max(maxRowHeight, actualSize.y);\n\n      project.stageManager.add(annotationNode);\n      sectionNodes.push(annotationNode);\n      annotationNodes.push({ node: annotationNode, keyNode });\n\n      // 移动到下一个位置\n      currentCol++;\n      if (currentCol >= nodesPerRow) {\n        currentCol = 0;\n        // currentRow++;\n        currentX = currentSectionX + sectionPadding;\n        currentY += maxRowHeight + nodeSpacingY;\n        maxRowHeight = 0;\n      } else {\n        currentX += actualSize.x + nodeSpacingX;\n      }\n    }\n\n    // 创建Section框\n    const sectionHeight = currentY + maxRowHeight - currentSectionY + sectionPadding;\n    const sectionWidth =\n      Math.max(...sectionNodes.map((node) => node.collisionBox.getRectangle().right)) -\n      currentSectionX +\n      sectionPadding;\n\n    const section = new Section(project, {\n      text: group.title,\n      collisionBox: new CollisionBox([\n        new Rectangle(new Vector(currentSectionX, currentSectionY), new Vector(sectionWidth, sectionHeight)),\n      ]),\n      children: [],\n    });\n    project.stageManager.add(section);\n\n    // 将节点添加到Section中\n    project.stageManager.goInSection(sectionNodes, section);\n\n    // 调整Section大小\n    section.adjustLocationAndSize();\n\n    // 移动到下一个Section位置\n    currentSectionX = section.collisionBox.getRectangle().right + sectionSpacingX;\n    if (currentSectionX > keyboardBoundingRect.right + keyboardGap + 800) {\n      // 如果太宽，换行\n      currentSectionX = keyboardBoundingRect.right + keyboardGap;\n      currentSectionY = section.collisionBox.getRectangle().bottom + sectionSpacingY;\n    }\n  }\n\n  // 创建连接线（绿色，alpha 0.2）\n  const edgeColor = new Color(0, 255, 0, 0.2); // 绿色，alpha 0.2\n  for (const { node: annotationNode, keyNode } of annotationNodes) {\n    const edge = new LineEdge(project, {\n      associationList: [keyNode, annotationNode],\n      sourceRectangleRate: new Vector(0.99, 0.5), // 从键节点右侧中心\n      targetRectangleRate: new Vector(0.01, 0.5), // 到标注节点左侧中心\n      color: edgeColor,\n    });\n    project.stageManager.add(edge);\n  }\n\n  // 记录历史步骤\n  project.historyManager.recordStep();\n}\n\n/**\n * 格式化键的显示文本\n */\nfunction formatKeyDisplay(key: string): string {\n  const displayMap: Record<string, string> = {\n    \" \": \"Space\",\n    backspace: \"Backspace\",\n    tab: \"Tab\",\n    capslock: \"Caps\",\n    enter: \"Enter\",\n    shift: \"Shift\",\n    ctrl: \"Ctrl\",\n    alt: \"Alt\",\n  };\n  return displayMap[key.toLowerCase()] || key.toUpperCase();\n}\n"
  },
  {
    "path": "app/src/core/service/dataGenerateService/generateScreenshot.tsx",
    "content": "import { loadAllServicesBeforeInit } from \"@/core/loadAllServices\";\nimport { Project } from \"@/core/Project\";\nimport { PathString } from \"@/utils/pathString\";\nimport { RecentFileManager } from \"../dataFileService/RecentFileManager\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { sleep } from \"@/utils/sleep\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { toast } from \"sonner\";\nimport { Vector } from \"@graphif/data-structures\";\n\n/**\n * 从一个文件中生成截图\n */\nexport namespace GenerateScreenshot {\n  /**\n   * 创建临时Canvas并渲染Project\n   * @param project 项目实例\n   * @param targetRect 目标矩形区域\n   * @param maxDimension 自定义最大边长度，默认为1920\n   * @returns 截图的Blob对象\n   */\n  async function renderProjectToBlob(\n    project: Project,\n    targetRect: Rectangle,\n    maxDimension: number = 1920,\n  ): Promise<Blob> {\n    // 计算缩放比例，确保最终截图宽高不超过maxDimension\n    let scaleFactor = 1;\n    if (targetRect.width > maxDimension || targetRect.height > maxDimension) {\n      const widthRatio = maxDimension / targetRect.width;\n      const heightRatio = maxDimension / targetRect.height;\n      scaleFactor = Math.min(widthRatio, heightRatio);\n    }\n    project.camera.currentScale = scaleFactor;\n    project.camera.targetScale = scaleFactor;\n\n    // 设置相机位置到目标矩形的中心\n    project.camera.location = targetRect.center;\n\n    // 创建临时Canvas\n    const tempCanvas = document.createElement(\"canvas\");\n    const deviceScale = window.devicePixelRatio;\n    const canvasWidth = Math.min(targetRect.width * scaleFactor + 2, maxDimension + 2);\n    const canvasHeight = Math.min(targetRect.height * scaleFactor + 2, maxDimension + 2);\n    tempCanvas.width = canvasWidth * deviceScale;\n    tempCanvas.height = canvasHeight * deviceScale;\n    tempCanvas.style.width = `${canvasWidth}px`;\n    tempCanvas.style.height = `${canvasHeight}px`;\n    const tempCtx = tempCanvas.getContext(\"2d\")!;\n    tempCtx.scale(deviceScale, deviceScale);\n\n    // 保存原Canvas和渲染器尺寸\n    const originalCanvas = project.canvas.element;\n    const originalRendererWidth = project.renderer.w;\n    const originalRendererHeight = project.renderer.h;\n\n    try {\n      // 设置临时Canvas\n      project.canvas.element = tempCanvas;\n      project.canvas.ctx = tempCtx;\n      // 更新渲染器尺寸\n      project.renderer.w = canvasWidth;\n      project.renderer.h = canvasHeight;\n\n      // 渲染\n      project.loop();\n      await sleep(1000); // 1s\n      project.pause();\n\n      // 将Canvas内容转换为Blob\n      const blob = await new Promise<Blob>((resolve) => {\n        tempCanvas.toBlob((blob) => {\n          if (blob) {\n            resolve(blob);\n          } else {\n            resolve(new Blob());\n          }\n        }, \"image/png\");\n      });\n\n      return blob;\n    } finally {\n      // 恢复原Canvas\n      project.canvas.element = originalCanvas;\n      project.canvas.ctx = originalCanvas.getContext(\"2d\")!;\n      // 恢复渲染器尺寸\n      project.renderer.w = originalRendererWidth;\n      project.renderer.h = originalRendererHeight;\n\n      // 清理临时资源\n      tempCanvas.remove();\n    }\n  }\n\n  /**\n   * 根据文件名和Section框名生成截图\n   * @param fileName 文件名\n   * @param sectionName Section框名\n   * @param maxDimension 自定义最大边长度，默认为1920\n   * @returns 截图的Blob对象\n   */\n  export async function generateSection(\n    fileName: string,\n    sectionName: string,\n    maxDimension: number = 1920,\n  ): Promise<Blob | undefined> {\n    try {\n      // 加载项目\n      const recentFiles = await RecentFileManager.getRecentFiles();\n      const file = recentFiles.find((file) => PathString.getFileNameFromPath(file.uri.fsPath) === fileName);\n      if (!file) {\n        return undefined;\n      }\n\n      const project = new Project(file.uri);\n      loadAllServicesBeforeInit(project);\n      await project.init();\n\n      // 查找指定名称的Section\n      const targetSection = project.stage.find((obj) => obj instanceof Section && obj.text === sectionName);\n      if (!targetSection) {\n        console.error(`Section框 【${sectionName}】 没有发现 in file ${fileName}`);\n        return undefined;\n      }\n\n      // 调整相机位置到Section\n      const sectionRect = targetSection.collisionBox.getRectangle();\n      project.camera.location = sectionRect.center;\n\n      // 渲染并获取截图\n      const blob = await renderProjectToBlob(project, sectionRect, maxDimension);\n\n      project.dispose();\n      return blob;\n    } catch (error) {\n      console.error(\"根据Section生成截图失败\", error);\n      return undefined;\n    }\n  }\n\n  /**\n   * 生成整个文件内容的广视野截图\n   * @param fileName 文件名\n   * @param maxDimension 自定义最大边长度，默认为1920\n   * @returns 截图的Blob对象\n   */\n  export async function generateFullView(fileName: string, maxDimension: number = 1920): Promise<Blob | undefined> {\n    try {\n      // 加载项目\n      const recentFiles = await RecentFileManager.getRecentFiles();\n      const file = recentFiles.find((file) => PathString.getFileNameFromPath(file.uri.fsPath) === fileName);\n      if (!file) {\n        return undefined;\n      }\n\n      const project = new Project(file.uri);\n      loadAllServicesBeforeInit(project);\n      await project.init();\n\n      // 使用相机的reset方法重置视野，以适应所有内容\n      project.camera.reset();\n\n      // 获取整个舞台的边界矩形\n      const stageSize = project.stageManager.getSize();\n      const stageCenter = project.stageManager.getCenter();\n      const fullRect = new Rectangle(stageCenter.subtract(stageSize.divide(2)), stageSize);\n\n      // 渲染并获取截图\n      const blob = await renderProjectToBlob(project, fullRect, maxDimension);\n\n      project.dispose();\n      return blob;\n    } catch (error) {\n      console.error(\"生成广视野截图失败\", error);\n      return undefined;\n    }\n  }\n\n  /**\n   * 从当前活动项目生成截图\n   * @param project 当前活动项目\n   * @param targetRect 目标矩形区域\n   * @param maxDimension 自定义最大边长度，默认为1920\n   * @returns 截图的Blob对象\n   */\n  export async function generateFromActiveProject(\n    project: Project,\n    targetRect: Rectangle,\n    maxDimension: number = 1920,\n  ): Promise<Blob | undefined> {\n    try {\n      // 保存原始相机状态\n      const originalScale = project.camera.currentScale;\n      const originalTargetScale = project.camera.targetScale;\n      const originalLocation = project.camera.location.clone();\n\n      try {\n        // 添加40px的外边距留白\n        const margin = 40;\n        const expandedRect = new Rectangle(\n          targetRect.location.subtract(new Vector(margin, margin)),\n          targetRect.size.add(new Vector(margin * 2, margin * 2)),\n        );\n\n        // 渲染并获取截图\n        const blob = await renderProjectToBlob(project, expandedRect, maxDimension);\n        return blob;\n      } finally {\n        // 恢复原始相机状态\n        project.camera.currentScale = originalScale;\n        project.camera.targetScale = originalTargetScale;\n        project.camera.location = originalLocation;\n      }\n    } catch (error) {\n      toast.error(\"从当前活动项目生成截图失败\" + JSON.stringify(error));\n      return undefined;\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataGenerateService/stageExportEngine/BaseExporter.tsx",
    "content": "import type { Project } from \"@/core/Project\";\nimport type { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\n\n/**\n * 导出器基类，包含共享的工具方法\n */\nexport abstract class BaseExporter {\n  constructor(protected readonly project: Project) {}\n\n  /**\n   * 树形遍历节点\n   * @param textNode\n   * @param nodeToStringFunc\n   * @returns\n   */\n  protected getTreeTypeString(textNode: TextNode, nodeToStringFunc: (node: TextNode, level: number) => string) {\n    let content = \"\";\n    const visitedUUID = new Set<string>();\n\n    const dfs = (node: TextNode, level: number) => {\n      if (visitedUUID.has(node.uuid)) {\n        return;\n      }\n      visitedUUID.add(node.uuid);\n      content += nodeToStringFunc(node, level);\n      const children = this.getNodeChildrenArray(node).filter((v) => v instanceof TextNode);\n      for (const child of children) {\n        dfs(child, level + 1);\n      }\n    };\n\n    dfs(textNode, 1);\n    return content;\n  }\n\n  /**\n   * issue: #276 【细节优化】导出功能的排序逻辑，从连接顺序变为角度判断\n   * @param node\n   */\n  protected getNodeChildrenArray(node: TextNode): ConnectableEntity[] {\n    const result = this.project.graphMethods.nodeChildrenArray(node);\n    // 如果全都在右侧或者左侧\n    if (\n      result.every((v) => v.geometryCenter.x > node.geometryCenter.x) ||\n      result.every((v) => v.geometryCenter.x < node.geometryCenter.x)\n    ) {\n      // 则按从上到下的顺序排序\n      return result.sort((a, b) => a.geometryCenter.y - b.geometryCenter.y);\n    }\n    // 如果全都在上侧或者下侧\n    if (\n      result.every((v) => v.geometryCenter.y > node.geometryCenter.y) ||\n      result.every((v) => v.geometryCenter.y < node.geometryCenter.y)\n    ) {\n      // 则按从左到右的顺序排序\n      return result.sort((a, b) => a.geometryCenter.x - b.geometryCenter.x);\n    }\n    // 按角度排序\n    return result.sort((a, b) => {\n      const angleA = Math.atan2(a.geometryCenter.y - node.geometryCenter.y, a.geometryCenter.x - node.geometryCenter.x);\n      const angleB = Math.atan2(b.geometryCenter.y - node.geometryCenter.y, b.geometryCenter.x - node.geometryCenter.x);\n      return angleA - angleB;\n    });\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataGenerateService/stageExportEngine/MarkdownExporter.tsx",
    "content": "import type { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { DetailsManager } from \"@/core/stage/stageObject/tools/entityDetailsManager\";\nimport { BaseExporter } from \"./BaseExporter\";\n\n/**\n * Markdown 格式导出器\n * 将节点导出为带标题层级的层次化 Markdown 格式\n */\nexport class MarkdownExporter extends BaseExporter {\n  /**\n   * 将文本节点及其子节点导出为 Markdown 格式\n   * @param textNode 要导出的根文本节点\n   * @returns Markdown 格式字符串\n   */\n  public export(textNode: TextNode): string {\n    return this.getTreeTypeString(textNode, this.getNodeMarkdown.bind(this));\n  }\n\n  /**\n   * 将单个节点转换为 Markdown 格式\n   * @param node 文本节点\n   * @param level 标题层级 (1-6)\n   * @returns 该节点的 Markdown 字符串\n   */\n  private getNodeMarkdown(node: TextNode, level: number): string {\n    let stringResult = \"\";\n    if (level < 6) {\n      stringResult += `${\"#\".repeat(level)} ${node.text}\\n\\n`;\n    } else {\n      stringResult += `**${node.text}**\\n\\n`;\n    }\n    if (!node.detailsManager.isEmpty()) {\n      stringResult += `${DetailsManager.detailsToMarkdown(node.details)}\\n\\n`;\n    }\n    return stringResult;\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataGenerateService/stageExportEngine/MermaidExporter.tsx",
    "content": "import type { Project } from \"@/core/Project\";\nimport type { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { LineEdge } from \"@/core/stage/stageObject/association/LineEdge\";\nimport { CopyEngineUtils } from \"../../dataManageService/copyEngine/copyEngineUtils\";\n\n/**\n * Mermaid 图表导出器\n *\n * 格式：\n * ```mermaid\n * graph TD\n * A --> B\n * A --> C\n * B -- 连线文字 --> C\n * ```\n *\n * (TD) 表示自上而下，LR表示自左而右\n * 使用 subgraph ... end 来定义子图。\n */\nexport class MermaidExporter {\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 将实体导出为 Mermaid 图表格式\n   * @param entities 要导出的实体\n   * @returns Mermaid 图表字符串\n   */\n  public export(entities: Entity[]): string {\n    const stageObjects = CopyEngineUtils.getAllStageObjectFromEntities(this.project, entities);\n    const allNodes = stageObjects.filter((v) => v instanceof TextNode || v instanceof Section) as (\n      | TextNode\n      | Section\n    )[];\n    const allLinks = stageObjects.filter((v) => v instanceof LineEdge) as LineEdge[];\n\n    // 创建节点集合，用于快速查找\n    const nodeSet = new Set(allNodes.map((n) => n.uuid));\n\n    // 过滤出有效的连线（source 和 target 都在节点集合中）\n    const validLinks = allLinks.filter((link) => nodeSet.has(link.source.uuid) && nodeSet.has(link.target.uuid));\n\n    // 生成节点 ID 映射（uuid -> mermaid ID）\n    // 使用顺序编号 id0/id1/... 作为节点 ID，避免文本中特殊字符（~、-、()等）引起的 Mermaid 词法错误\n    const nodeIdMap = new Map<string, string>();\n\n    const getNodeId = (node: TextNode | Section): string => {\n      if (nodeIdMap.has(node.uuid)) {\n        return nodeIdMap.get(node.uuid)!;\n      }\n      const id = `id${nodeIdMap.size}`;\n      nodeIdMap.set(node.uuid, id);\n      return id;\n    };\n\n    // 转义 Mermaid 文本中的特殊字符\n    const escapeMermaidText = (text: string): string => {\n      // Mermaid 中的特殊字符需要转义或使用引号\n      return text.replace(/\"/g, \"&quot;\").replace(/\\n/g, \"<br>\");\n    };\n\n    // 找出所有 Section\n    const sections = allNodes.filter((n) => n instanceof Section) as Section[];\n\n    // 找出每个节点所在的 Section（只考虑最内层的 Section，即直接包含它的 Section）\n    const nodeToSectionMap = new Map<string, Section>();\n\n    // 找出每个 TextNode 所在的最内层 Section（直接包含它的 Section）\n    for (const node of allNodes) {\n      if (node instanceof Section) continue;\n\n      // 找出所有包含该节点的 Section\n      const containingSections: Section[] = [];\n      for (const section of sections) {\n        if (this.project.sectionMethods.isEntityInSection(node, section)) {\n          containingSections.push(section);\n        }\n      }\n\n      // 找出最内层的 Section（不被其他包含该节点的 Section 包含的 Section）\n      if (containingSections.length > 0) {\n        let innermostSection = containingSections[0];\n        for (const section of containingSections) {\n          // 如果 innermostSection 包含 section，则 section 是更内层的\n          if (this.project.sectionMethods.isEntityInSection(section, innermostSection)) {\n            innermostSection = section;\n          }\n        }\n        nodeToSectionMap.set(node.uuid, innermostSection);\n      }\n    }\n\n    // 找出每个 Section 所在的父 Section（最内层的父 Section，即直接包含它的 Section）\n    const sectionToParentMap = new Map<Section, Section>();\n    for (const section of sections) {\n      // 找出所有包含该 Section 的父 Section\n      const parentSections: Section[] = [];\n      for (const s of sections) {\n        if (s !== section && this.project.sectionMethods.isEntityInSection(section, s)) {\n          parentSections.push(s);\n        }\n      }\n\n      if (parentSections.length > 0) {\n        // 找出最内层的父 Section（不被其他父 Section 包含的父 Section）\n        let innermostParent = parentSections[0];\n        for (const s of parentSections) {\n          // 如果 innermostParent 包含 s，则 s 是更内层的\n          if (this.project.sectionMethods.isEntityInSection(s, innermostParent)) {\n            innermostParent = s;\n          }\n        }\n        sectionToParentMap.set(section, innermostParent);\n      }\n    }\n\n    // 按 Section 分组节点\n    const sectionToNodesMap = new Map<Section, (TextNode | Section)[]>();\n    const nodesWithoutSection: (TextNode | Section)[] = [];\n\n    for (const node of allNodes) {\n      if (node instanceof Section) {\n        // Section 本身：如果它在其他 Section 内，则放在父 Section 中\n        const parentSection = sectionToParentMap.get(node);\n        if (parentSection) {\n          if (!sectionToNodesMap.has(parentSection)) {\n            sectionToNodesMap.set(parentSection, []);\n          }\n          sectionToNodesMap.get(parentSection)!.push(node);\n        } else {\n          // 最外层的 Section，直接添加到根节点列表\n          nodesWithoutSection.push(node);\n        }\n      } else {\n        // TextNode：如果它在 Section 内，则放在对应的 Section 中\n        const section = nodeToSectionMap.get(node.uuid);\n        if (section) {\n          if (!sectionToNodesMap.has(section)) {\n            sectionToNodesMap.set(section, []);\n          }\n          sectionToNodesMap.get(section)!.push(node);\n        } else {\n          // 不在任何 Section 中的节点\n          nodesWithoutSection.push(node);\n        }\n      }\n    }\n\n    // 生成 Mermaid 字符串\n    let result = \"graph TD\\n\";\n\n    // 递归生成节点和子图\n    const generateNodes = (nodes: (TextNode | Section)[], indent: string = \"\"): void => {\n      for (const node of nodes) {\n        if (node instanceof Section) {\n          // 生成子图（始终使用 id[\"label\"] 格式，避免特殊字符问题）\n          const sectionId = getNodeId(node);\n          const sectionTitle = escapeMermaidText(node.text || \"Section\");\n          result += `${indent}subgraph ${sectionId}[\"${sectionTitle}\"]\\n`;\n\n          // 生成子图内的节点\n          const innerNodes = sectionToNodesMap.get(node) || [];\n          generateNodes(innerNodes, indent + \"  \");\n\n          result += `${indent}end\\n`;\n        } else {\n          // 生成普通节点（有文本时使用 id[\"label\"] 格式，空文本时只输出 id）\n          const nodeId = getNodeId(node);\n          const nodeText = escapeMermaidText(node.text || \"\");\n          if (nodeText) {\n            result += `${indent}${nodeId}[\"${nodeText}\"]\\n`;\n          } else {\n            result += `${indent}${nodeId}\\n`;\n          }\n        }\n      }\n    };\n\n    // 生成所有节点（包括不在 Section 中的节点和 Section 本身）\n    generateNodes(nodesWithoutSection);\n\n    // 根据 lineType 返回对应的 Mermaid 箭头语法\n    const getArrow = (lineType: string): string => {\n      if (lineType === \"dashed\") return \"-.->\";\n      if (lineType === \"double\") return \"==>\";\n      return \"-->\";\n    };\n\n    // 根据 lineType 返回带文本的 Mermaid 箭头语法\n    const getArrowWithText = (lineType: string, text: string): string => {\n      if (lineType === \"dashed\") return `-. \"${text}\" .->`;\n      if (lineType === \"double\") return `== \"${text}\" ==>`;\n      return `-- \"${text}\" -->`;\n    };\n\n    // 生成连线\n    for (const link of validLinks) {\n      const sourceId = getNodeId(link.source as TextNode | Section);\n      const targetId = getNodeId(link.target as TextNode | Section);\n      const lineType = link.lineType || \"solid\";\n\n      if (link.text && link.text.trim()) {\n        const linkText = escapeMermaidText(link.text.trim());\n        result += `${sourceId} ${getArrowWithText(lineType, linkText)} ${targetId}\\n`;\n      } else {\n        result += `${sourceId} ${getArrow(lineType)} ${targetId}\\n`;\n      }\n    }\n\n    // 生成连线颜色样式（仅对设置了非透明颜色的连线输出 linkStyle 语句）\n    validLinks.forEach((link, index) => {\n      if (link.color.a > 0) {\n        result += `linkStyle ${index} stroke:${link.color.toHexStringWithoutAlpha()},stroke-opacity:${link.color.a}\\n`;\n      }\n    });\n\n    // 生成节点颜色样式（仅对设置了非透明颜色的节点输出 style 语句）\n    for (const node of allNodes) {\n      if (node.color.a > 0) {\n        const nodeId = getNodeId(node);\n        result += `style ${nodeId} fill:${node.color.toHexStringWithoutAlpha()},fill-opacity:${node.color.a}\\n`;\n      }\n    }\n\n    return result.trim();\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataGenerateService/stageExportEngine/PlainTextExporter.tsx",
    "content": "import type { Project } from \"@/core/Project\";\nimport type { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { DetailsManager } from \"@/core/stage/stageObject/tools/entityDetailsManager\";\n\n/**\n * 纯文本格式导出器\n *\n * 格式：\n * A\n * B\n * C\n *\n * A --> B\n * A --> C\n * B -xx-> C\n */\nexport class PlainTextExporter {\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 将实体导出为纯文本格式\n   * @param nodes 要导出的选中实体\n   * @returns 纯文本表示\n   */\n  public export(nodes: Entity[]): string {\n    let nodesContent = \"\";\n    let linksContent = \"\";\n    for (const node of nodes) {\n      if (!(node instanceof TextNode)) {\n        continue;\n      }\n      nodesContent += node.text + \"\\n\";\n      if (!node.detailsManager.isEmpty()) {\n        nodesContent += \"\\t\" + DetailsManager.detailsToMarkdown(node.details) + \"\\n\";\n      }\n      const childTextNodes = this.project.graphMethods\n        .nodeChildrenArray(node)\n        .filter((node) => node instanceof TextNode)\n        .filter((node) => nodes.includes(node));\n      for (const child of childTextNodes) {\n        const link = this.project.graphMethods.getEdgeFromTwoEntity(node, child);\n        if (link) {\n          linksContent += `${node.text} -${link.text}-> ${child.text}\\n`;\n        } else {\n          linksContent += `${node.text} --> ${child.text}\\n`;\n        }\n      }\n    }\n    return nodesContent + \"\\n\" + linksContent;\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataGenerateService/stageExportEngine/StageExportPng.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { sleep } from \"@/utils/sleep\";\nimport { Vector } from \"@graphif/data-structures\";\nimport EventEmitter from \"events\";\n\ninterface EventMap {\n  progress: [progress: number];\n  complete: [blob: Blob];\n  error: [error: Error];\n}\n\n@service(\"stageExportPng\")\nexport class StageExportPng {\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 将整个舞台导出为png图片\n   */\n  private async exportStage_(emitter: EventEmitter<EventMap>, signal: AbortSignal, sleepTime: number) {\n    // 创建一个新的画布\n    const resultCanvas = this.generateCanvasNode();\n    const resultCtx = resultCanvas.getContext(\"2d\")!;\n    // 创建完毕\n    const stageRect = this.project.stageManager.getBoundingRectangle();\n    const topLeft = stageRect.leftTop.subtract(new Vector(100, 100));\n    const bottomRight = stageRect.rightBottom.add(new Vector(100, 100));\n    const viewRect = this.project.renderer.getCoverWorldRectangle();\n    const leftTopLocList: { x: number; y: number }[] = [];\n    // 遍历xy，xy是切割分块后的目标视野矩形的左上角\n    for (let y = topLeft.y; y <= bottomRight.y; y += viewRect.size.y) {\n      for (let x = topLeft.x; x <= bottomRight.x; x += viewRect.size.x) {\n        leftTopLocList.push({ x, y });\n      }\n    }\n    let i = 0;\n    let lastFrame = this.project.renderer.frameIndex;\n    while (i < leftTopLocList.length) {\n      const { x, y } = leftTopLocList[i];\n      // 先移动再暂停等待\n      await sleep(sleepTime);\n      this.project.camera.location = new Vector(x + viewRect.size.x / 2, y + viewRect.size.y / 2);\n      await sleep(sleepTime);\n      if (this.project.renderer.frameIndex - lastFrame < 2) {\n        continue;\n      }\n      lastFrame = this.project.renderer.frameIndex;\n      const imageData = this.project.canvas.ctx.getImageData(\n        0,\n        0,\n        viewRect.size.x * devicePixelRatio,\n        viewRect.size.y * devicePixelRatio,\n      );\n      resultCtx.putImageData(\n        imageData,\n        (x - topLeft.x) * devicePixelRatio * this.project.camera.targetScale,\n        (y - topLeft.y) * devicePixelRatio * this.project.camera.targetScale,\n      );\n      i++;\n      // 计算进度\n      const progress = (i + 1) / leftTopLocList.length;\n      emitter.emit(\"progress\", progress);\n      signal.throwIfAborted();\n    }\n    const blob = await new Promise<Blob>((resolve) => {\n      resultCanvas.toBlob((blob) => {\n        if (blob) {\n          resolve(blob);\n        } else {\n          resolve(new Blob());\n        }\n      }, \"image/png\");\n    });\n    emitter.emit(\"complete\", blob);\n    // 移除画布\n    resultCanvas.remove();\n  }\n\n  exportStage(signal: AbortSignal, sleepTime: number = 2) {\n    const emitter = new EventEmitter<EventMap>();\n    this.exportStage_(emitter, signal, sleepTime).catch((err) => emitter.emit(\"error\", err));\n    return emitter;\n  }\n\n  generateCanvasNode(): HTMLCanvasElement {\n    const resultCanvas = document.createElement(\"canvas\");\n    const stageSize = this.project.stageManager.getSize().add(new Vector(100 * 2, 100 * 2));\n    // 设置大小\n    resultCanvas.width = stageSize.x * devicePixelRatio * this.project.camera.targetScale;\n    resultCanvas.height = stageSize.y * devicePixelRatio * this.project.camera.targetScale;\n    resultCanvas.style.width = `${stageSize.x * 1 * this.project.camera.targetScale}px`;\n    resultCanvas.style.height = `${stageSize.y * 1 * this.project.camera.targetScale}px`;\n    const ctx = resultCanvas.getContext(\"2d\")!;\n    ctx.scale(devicePixelRatio, devicePixelRatio);\n    return resultCanvas;\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataGenerateService/stageExportEngine/StageExportSvg.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { Renderer } from \"@/core/render/canvas2d/renderer\";\nimport { SvgUtils } from \"@/core/render/svg/SvgUtils\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { LineEdge } from \"@/core/stage/stageObject/association/LineEdge\";\nimport { ImageNode } from \"@/core/stage/stageObject/entity/ImageNode\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { Path } from \"@/utils/path\";\nimport { Color, colorInvert, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport React from \"react\";\nimport ReactDOMServer from \"react-dom/server\";\nimport { writeFile } from \"@tauri-apps/plugin-fs\";\nimport mime from \"mime\";\n\nexport interface SvgExportConfig {\n  imageMode: \"absolutePath\" | \"relativePath\" | \"base64\";\n}\n\n/**\n * 将舞台当前内容导出为SVG\n *\n *\n */\n@service(\"stageExportSvg\")\nexport class StageExportSvg {\n  constructor(private readonly project: Project) {}\n\n  private svgConfig: SvgExportConfig = {\n    imageMode: \"relativePath\",\n  };\n\n  private exportContext: {\n    outputDir: string;\n    imageMap: Map<string, string>; // attachmentId -> relative file path\n  } | null = null;\n\n  setConfig(config: SvgExportConfig) {\n    this.svgConfig = config;\n  }\n\n  dumpNode(node: TextNode) {\n    if (node.isHiddenBySectionCollapse) {\n      return <></>;\n    }\n    return (\n      <>\n        {SvgUtils.rectangle(\n          node.rectangle,\n          node.color,\n          this.project.stageStyleManager.currentStyle.StageObjectBorder,\n          2,\n        )}\n\n        {SvgUtils.multiLineTextFromLeftTop(\n          node.text,\n          node.rectangle.leftTop.add(\n            // 2025年1月23日 晚上，对这个地方进行了微调，但还没弄懂原理，只是看上去像是加了点偏移\n            // 着急发布节点多行文本的功能，所以先这样吧\n            new Vector(0, Renderer.NODE_PADDING + Renderer.FONT_SIZE / 4),\n          ),\n          Renderer.FONT_SIZE,\n          node.color.a === 1\n            ? colorInvert(node.color)\n            : colorInvert(this.project.stageStyleManager.currentStyle.Background),\n        )}\n      </>\n    );\n  }\n\n  /**\n   * 渲染Section顶部颜色\n   * @param section\n   * @returns\n   */\n  dumpSection(section: Section) {\n    if (section.isHiddenBySectionCollapse) {\n      return <></>;\n    }\n    return (\n      <>\n        {SvgUtils.rectangle(\n          section.rectangle,\n          Color.Transparent,\n          this.project.stageStyleManager.currentStyle.StageObjectBorder,\n          2,\n        )}\n        {SvgUtils.textFromLeftTop(\n          section.text,\n          section.rectangle.leftTop,\n          Renderer.FONT_SIZE,\n          this.project.stageStyleManager.currentStyle.StageObjectBorder,\n        )}\n      </>\n    );\n  }\n\n  /**\n   * 只渲染Section的底部颜色\n   * @param section\n   * @returns\n   */\n  dumpSectionBase(section: Section) {\n    if (section.isHiddenBySectionCollapse) {\n      return <></>;\n    }\n    return <>{SvgUtils.rectangle(section.rectangle, section.color, Color.Transparent, 0)}</>;\n  }\n\n  dumpEdge(edge: LineEdge): React.ReactNode {\n    return this.project.edgeRenderer.getEdgeSvg(edge);\n  }\n  /**\n   *\n   * @param node\n   * @param svgConfigObject 配置对象\n   * @returns\n   */\n  dumpImageNode(node: ImageNode, svgConfigObject: SvgExportConfig) {\n    if (node.isHiddenBySectionCollapse) {\n      return <></>;\n    }\n    let href = \"\";\n    const attachmentId = node.attachmentId;\n    if (attachmentId) {\n      // 检查是否有导出上下文\n      if (this.exportContext && this.exportContext.imageMap.has(attachmentId)) {\n        // 使用导出的图片相对路径\n        href = this.exportContext.imageMap.get(attachmentId)!;\n      } else {\n        const blob = this.project.attachments.get(attachmentId);\n        if (blob) {\n          if (svgConfigObject.imageMode === \"base64\") {\n            // 转换为base64数据URI\n            // 暂时返回空，后续实现\n            href = \"\";\n          } else {\n            // 相对路径或绝对路径模式，但没有导出上下文，无法生成有效路径\n            // 返回透明矩形占位符\n            return (\n              <>\n                {SvgUtils.rectangle(\n                  node.rectangle,\n                  Color.Transparent,\n                  this.project.stageStyleManager.currentStyle.StageObjectBorder,\n                  2,\n                )}\n              </>\n            );\n          }\n        }\n      }\n    }\n    // 如果href为空，则返回一个透明矩形占位符\n    if (!href) {\n      return (\n        <>\n          {SvgUtils.rectangle(\n            node.rectangle,\n            Color.Transparent,\n            this.project.stageStyleManager.currentStyle.StageObjectBorder,\n            2,\n          )}\n        </>\n      );\n    }\n\n    return (\n      <>\n        {SvgUtils.rectangle(\n          node.rectangle,\n          Color.Transparent,\n          this.project.stageStyleManager.currentStyle.StageObjectBorder,\n          2,\n        )}\n        <image\n          href={href}\n          x={node.rectangle.leftTop.x}\n          y={node.rectangle.leftTop.y}\n          width={node.rectangle.size.x}\n          height={node.rectangle.size.y}\n        />\n      </>\n    );\n  }\n\n  private getEntitiesOuterRectangle(entities: Entity[], padding: number): Rectangle {\n    let minX = Infinity;\n    let minY = Infinity;\n    let maxX = -Infinity;\n    let maxY = -Infinity;\n    for (const entity of entities) {\n      if (entity.collisionBox.getRectangle().location.x < minX) {\n        minX = entity.collisionBox.getRectangle().location.x - padding;\n      }\n      if (entity.collisionBox.getRectangle().location.y < minY) {\n        minY = entity.collisionBox.getRectangle().location.y - padding;\n      }\n      if (entity.collisionBox.getRectangle().location.x + entity.collisionBox.getRectangle().size.x > maxX) {\n        maxX = entity.collisionBox.getRectangle().location.x + entity.collisionBox.getRectangle().size.x + padding;\n      }\n      if (entity.collisionBox.getRectangle().location.y + entity.collisionBox.getRectangle().size.y > maxY) {\n        maxY = entity.collisionBox.getRectangle().location.y + entity.collisionBox.getRectangle().size.y + padding;\n      }\n    }\n    return new Rectangle(new Vector(minX, minY), new Vector(maxX - minX, maxY - minY));\n  }\n\n  private dumpSelected(): React.ReactNode {\n    const selectedEntities = this.project.stageManager.getSelectedEntities();\n    if (selectedEntities.length === 0) {\n      return \"\";\n    }\n    const padding = 30; // 留白\n    const viewRectangle = this.getEntitiesOuterRectangle(selectedEntities, padding);\n    // 计算画布的大小\n    const width = viewRectangle.size.x;\n    const height = viewRectangle.size.y;\n    // 计算画布的 viewBox\n    const viewBox = `${viewRectangle.location.x} ${viewRectangle.location.y} ${width} ${height}`;\n    // fix:bug section选中了，但是内部的东西没有追加进入\n    const newEntities = this.project.sectionMethods.getAllEntitiesInSelectedSectionsOrEntities(selectedEntities);\n    // 合并两个数组并更新\n    for (const entity of newEntities) {\n      if (selectedEntities.indexOf(entity) === -1) {\n        selectedEntities.push(entity);\n      }\n    }\n    // 所有实际包含的uuid集合\n    const selectedEntitiesUUIDSet = new Set<string>();\n    for (const entity of selectedEntities) {\n      selectedEntitiesUUIDSet.add(entity.uuid);\n    }\n\n    return (\n      <svg\n        xmlns=\"http://www.w3.org/2000/svg\"\n        width={width}\n        height={height}\n        viewBox={viewBox}\n        style={{\n          backgroundColor: this.project.stageStyleManager.currentStyle.Background.toString(),\n        }}\n      >\n        {/* 选中的部分 */}\n        {this.project.sectionMethods\n          .getSortedSectionsByZ(selectedEntities.filter((entity) => entity instanceof Section))\n          .map((entity) => {\n            if (entity instanceof Section) {\n              return this.dumpSectionBase(entity);\n            }\n          })}\n        {selectedEntities.map((entity) => {\n          if (entity instanceof TextNode) {\n            return this.dumpNode(entity);\n          } else if (entity instanceof LineEdge) {\n            return this.dumpEdge(entity);\n          } else if (entity instanceof Section) {\n            return this.dumpSection(entity);\n          } else if (entity instanceof ImageNode) {\n            return this.dumpImageNode(entity, this.svgConfig);\n          }\n        })}\n\n        {/* 构建连线 */}\n        {this.project.stageManager\n          .getLineEdges()\n          .filter(\n            (edge) => selectedEntitiesUUIDSet.has(edge.source.uuid) && selectedEntitiesUUIDSet.has(edge.target.uuid),\n          )\n          .map((edge) => this.dumpEdge(edge))}\n      </svg>\n    );\n  }\n\n  private dumpStage(): React.ReactNode {\n    // 如果没有任何节点，则抛出一个异常\n    if (this.project.stageManager.isNoEntity()) {\n      throw new Error(\"No nodes in stage.\");\n    }\n    const padding = 30; // 留白\n    const viewRectangle = this.getEntitiesOuterRectangle(this.project.stageManager.getEntities(), padding);\n    // 计算画布的大小\n    const width = viewRectangle.size.x;\n    const height = viewRectangle.size.y;\n    // 计算画布的 viewBox\n    const viewBox = `${viewRectangle.location.x} ${viewRectangle.location.y} ${width} ${height}`;\n\n    return (\n      <svg\n        xmlns=\"http://www.w3.org/2000/svg\"\n        width={width}\n        height={height}\n        viewBox={viewBox}\n        style={{\n          backgroundColor: this.project.stageStyleManager.currentStyle.Background.toString(),\n        }}\n      >\n        {this.project.sectionMethods\n          .getSortedSectionsByZ(this.project.stageManager.getSections())\n          .map((section) => this.dumpSectionBase(section))}\n        {this.project.stageManager.getTextNodes().map((node) => this.dumpNode(node))}\n        {this.project.stageManager.getLineEdges().map((edge) => this.dumpEdge(edge))}\n        {this.project.stageManager.getSections().map((section) => this.dumpSection(section))}\n        {this.project.stageManager.getImageNodes().map((imageNode) => this.dumpImageNode(imageNode, this.svgConfig))}\n      </svg>\n    );\n  }\n\n  /**\n   * 将整个舞台导出为SVG字符串\n   * @returns\n   */\n  dumpStageToSVGString(): string {\n    return ReactDOMServer.renderToStaticMarkup(this.dumpStage());\n  }\n\n  /**\n   * 将选中的节点导出为SVG字符串\n   * @returns\n   */\n  dumpSelectedToSVGString(): string {\n    return ReactDOMServer.renderToStaticMarkup(this.dumpSelected());\n  }\n\n  /**\n   * 将整个舞台导出为SVG文件，并导出所有图片附件\n   * @param filePath SVG文件保存路径\n   */\n  async exportStageToSVGFile(filePath: string): Promise<void> {\n    const outputDir = new Path(filePath).parent.toString();\n    const imageNodes = this.project.stageManager.getImageNodes();\n    const imageMap = new Map<string, string>();\n\n    // 导出所有图片附件\n    for (const imageNode of imageNodes) {\n      const attachmentId = imageNode.attachmentId;\n      if (!attachmentId || imageMap.has(attachmentId)) continue;\n\n      const blob = this.project.attachments.get(attachmentId);\n      if (!blob) continue;\n\n      // 生成文件名\n      const extension = mime.getExtension(blob.type) || \"bin\";\n      const fileName = `${attachmentId}.${extension}`;\n      const relativePath = fileName;\n      const outputPath = new Path(outputDir).join(fileName).toString();\n\n      // 写入文件\n      const arrayBuffer = await blob.arrayBuffer();\n      await writeFile(outputPath, new Uint8Array(arrayBuffer));\n\n      imageMap.set(attachmentId, relativePath);\n    }\n\n    // 设置导出上下文\n    this.exportContext = {\n      outputDir,\n      imageMap,\n    };\n\n    try {\n      // 生成SVG字符串并保存\n      const svgString = this.dumpStageToSVGString();\n      await writeFile(filePath, new TextEncoder().encode(svgString));\n    } finally {\n      // 清除导出上下文\n      this.exportContext = null;\n    }\n  }\n\n  /**\n   * 将选中的节点导出为SVG文件，并导出相关图片附件\n   * @param filePath SVG文件保存路径\n   */\n  async exportSelectedToSVGFile(filePath: string): Promise<void> {\n    const outputDir = new Path(filePath).parent.toString();\n    const selectedEntities = this.project.stageManager.getSelectedEntities();\n    const imageNodes = selectedEntities.filter((entity): entity is ImageNode => entity instanceof ImageNode);\n    const imageMap = new Map<string, string>();\n\n    // 导出所有图片附件\n    for (const imageNode of imageNodes) {\n      const attachmentId = imageNode.attachmentId;\n      if (!attachmentId || imageMap.has(attachmentId)) continue;\n\n      const blob = this.project.attachments.get(attachmentId);\n      if (!blob) continue;\n\n      // 生成文件名\n      const extension = mime.getExtension(blob.type) || \"bin\";\n      const fileName = `${attachmentId}.${extension}`;\n      const relativePath = fileName;\n      const outputPath = new Path(outputDir).join(fileName).toString();\n\n      // 写入文件\n      const arrayBuffer = await blob.arrayBuffer();\n      await writeFile(outputPath, new Uint8Array(arrayBuffer));\n\n      imageMap.set(attachmentId, relativePath);\n    }\n\n    // 设置导出上下文\n    this.exportContext = {\n      outputDir,\n      imageMap,\n    };\n\n    try {\n      // 生成SVG字符串并保存\n      const svgString = this.dumpSelectedToSVGString();\n      await writeFile(filePath, new TextEncoder().encode(svgString));\n    } finally {\n      // 清除导出上下文\n      this.exportContext = null;\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataGenerateService/stageExportEngine/TabExporter.tsx",
    "content": "import { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { DetailsManager } from \"@/core/stage/stageObject/tools/entityDetailsManager\";\nimport { BaseExporter } from \"./BaseExporter\";\n\n/**\n * Tab 缩进格式导出器\n * 将节点导出为层次化的 Tab 缩进格式\n */\nexport class TabExporter extends BaseExporter {\n  /**\n   * 将文本节点及其子节点导出为 Tab 缩进格式\n   * @param textNode 要导出的根文本节点\n   * @returns Tab 缩进格式字符串\n   */\n  public export(textNode: TextNode): string {\n    return this.getTreeTypeString(textNode, this.getTabText.bind(this));\n  }\n\n  /**\n   * 将单个节点转换为 Tab 缩进格式\n   * @param node 文本节点\n   * @param level 缩进层级\n   * @returns 该节点的 Tab 缩进字符串\n   */\n  private getTabText(node: TextNode, level: number): string {\n    let stringResult = \"\";\n    stringResult += `${\"\\t\".repeat(Math.max(level - 1, 0))}${node.text}\\n`;\n    if (!node.detailsManager.isEmpty()) {\n      stringResult += `${\"\\t\".repeat(level)}${DetailsManager.detailsToMarkdown(node.details)}\\n`;\n    }\n    return stringResult;\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataGenerateService/stageExportEngine/stageExportEngine.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { PlainTextExporter } from \"./PlainTextExporter\";\nimport { MarkdownExporter } from \"./MarkdownExporter\";\nimport { TabExporter } from \"./TabExporter\";\nimport { MermaidExporter } from \"./MermaidExporter\";\n\n/**\n * 专注于导出各种格式内容的引擎\n * （除了svg）\n */\n@service(\"stageExport\")\nexport class StageExport {\n  private readonly plainTextExporter: PlainTextExporter;\n  private readonly markdownExporter: MarkdownExporter;\n  private readonly tabExporter: TabExporter;\n  private readonly mermaidExporter: MermaidExporter;\n\n  constructor(private readonly project: Project) {\n    this.plainTextExporter = new PlainTextExporter(project);\n    this.markdownExporter = new MarkdownExporter(project);\n    this.tabExporter = new TabExporter(project);\n    this.mermaidExporter = new MermaidExporter(project);\n  }\n\n  /**\n   * 格式：\n   * A\n   * B\n   * C\n   *\n   * A --> B\n   * A --> C\n   * B -xx-> C\n   *\n   * @param nodes 传入的是选中了的节点\n   * @returns\n   */\n  public getPlainTextByEntities(nodes: Entity[]) {\n    return this.plainTextExporter.export(nodes);\n  }\n\n  public getMarkdownStringByTextNode(textNode: TextNode) {\n    return this.markdownExporter.export(textNode);\n  }\n\n  public getTabStringByTextNode(textNode: TextNode) {\n    return this.tabExporter.export(textNode);\n  }\n\n  /**\n   * 格式：\n   * ```mermaid\n   * graph TD\n   * A --> B\n   * A --> C\n   * B -- 连线文字 --> C\n   * ```\n   *\n   * （TD）表示自上而下，LR表示自左而右\n   * 使用 subgraph ... end 来定义子图。\n   */\n  public getMermaidTextByEntites(entities: Entity[]): string {\n    return this.mermaidExporter.export(entities);\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataGenerateService/stageImportEngine/BaseImporter.tsx",
    "content": "import type { Project } from \"@/core/Project\";\n\n/**\n * 导入器基类，包含共享的工具方法\n */\nexport abstract class BaseImporter {\n  constructor(protected readonly project: Project) {}\n}\n"
  },
  {
    "path": "app/src/core/service/dataGenerateService/stageImportEngine/GraphImporter.tsx",
    "content": "import type { Project } from \"@/core/Project\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { BaseImporter } from \"./BaseImporter\";\n\n/**\n * 图结构导入器\n * 支持通过纯文本生成网状结构\n * 格式：\n * - A --> B （连线上无文字）\n * - A -label-> B （连线上有文字）\n * - A （单独的节点）\n */\nexport class GraphImporter extends BaseImporter {\n  constructor(project: Project) {\n    super(project);\n  }\n\n  /**\n   * 导入图结构文本并生成节点\n   * 这个函数不稳定，可能会随时throw错误\n   * @param text 网状结构的格式文本\n   * @param diffLocation 偏移位置\n   */\n  public import(text: string, diffLocation: Vector = Vector.getZero()): void {\n    const lines = text.split(\"\\n\");\n\n    if (lines.length === 0) {\n      return;\n    }\n\n    const randomRadius = 40 * lines.length;\n    const nodeDict = new Map<string, TextNode>();\n\n    const createNodeByName = (name: string) => {\n      const node = new TextNode(this.project, {\n        text: name,\n        collisionBox: new CollisionBox([\n          new Rectangle(\n            diffLocation.add(new Vector(randomRadius * Math.random(), randomRadius * Math.random())),\n            Vector.same(100),\n          ),\n        ]),\n      });\n      this.project.stageManager.add(node);\n      nodeDict.set(name, node);\n      return node;\n    };\n\n    for (const line of lines) {\n      if (line.trim() === \"\") {\n        continue;\n      }\n      if (line.includes(\"-->\") || (line.includes(\"-\") && line.includes(\"->\"))) {\n        // 这一行是一个关系行\n        if (line.includes(\"-->\")) {\n          // 连线上无文字\n          // 解析\n          const names = line.split(\"-->\");\n          if (names.length !== 2) {\n            throw new Error(`解析时出现错误: \"${line}\"，应该只有两个名称`);\n          }\n          const startName = names[0].trim();\n          const endName = names[1].trim();\n          if (startName === \"\" || endName === \"\") {\n            throw new Error(`解析时出现错误: \"${line}\"，名称不能为空`);\n          }\n          let startNode = nodeDict.get(startName);\n          let endNode = nodeDict.get(endName);\n          if (!startNode) {\n            startNode = createNodeByName(startName);\n          }\n          if (!endNode) {\n            endNode = createNodeByName(endName);\n          }\n          this.project.nodeConnector.connectEntityFast(startNode, endNode);\n        } else {\n          // 连线上有文字\n          // 解析\n          // A -xx-> B\n          const names = line.split(\"->\");\n          if (names.length !== 2) {\n            throw new Error(`解析时出现错误: \"${line}\"，应该只有两个名称`);\n          }\n          const leftContent = names[0].trim();\n          const endName = names[1].trim();\n          if (leftContent === \"\" || endName === \"\") {\n            throw new Error(`解析时出现错误: \"${line}\"，名称不能为空`);\n          }\n          let endNode = nodeDict.get(endName);\n          if (!endNode) {\n            // 没有endNode，临时创建一下\n            endNode = createNodeByName(endName);\n          }\n          const leftContentList = leftContent.split(\"-\");\n          if (leftContentList.length !== 2) {\n            if (leftContentList.length === 1) {\n              throw new Error(\n                `解析时出现错误: \"${line}\"，此行被识别为连线上有文字的行，中间的连接线应该是 \"-->\"，而不是 \"->\"`,\n              );\n            } else {\n              throw new Error(\n                `解析时出现错误: \"${line}\"，此行被识别为连线上有文字的行，短横线 \"-\" 左侧内容应该确保只有两个名称`,\n              );\n            }\n          }\n          const startName = leftContentList[0].trim();\n          const edgeText = leftContentList[1].trim();\n          if (startName === \"\" || edgeText === \"\") {\n            throw new Error(`解析时出现错误: \"${line}\"，名称不能为空`);\n          }\n          let startNode = nodeDict.get(startName);\n          if (!startNode) {\n            // 临时创建一下\n            startNode = createNodeByName(startName);\n          }\n          this.project.nodeConnector.connectEntityFast(startNode, endNode, edgeText);\n        }\n      } else {\n        // 这一行是一个节点行\n        // 获取节点名称，创建节点\n        const nodeName = line.trim();\n        createNodeByName(nodeName);\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataGenerateService/stageImportEngine/MarkdownImporter.tsx",
    "content": "import type { Project } from \"@/core/Project\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { LineEdge } from \"@/core/stage/stageObject/association/LineEdge\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { DetailsManager } from \"@/core/stage/stageObject/tools/entityDetailsManager\";\nimport { MonoStack } from \"@graphif/data-structures\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { parseMarkdownToJSON, type MarkdownNode } from \"@/utils/markdownParse\";\nimport { BaseImporter } from \"./BaseImporter\";\nimport { Rectangle } from \"@graphif/shapes\";\n\n/**\n * Markdown 导入器\n * 将 Markdown 格式文本转换为节点树结构\n * 支持标题层级（#, ##, ###）\n */\nexport class MarkdownImporter extends BaseImporter {\n  constructor(project: Project) {\n    super(project);\n  }\n\n  /**\n   * 导入 Markdown 文本并生成节点树\n   * @param markdownText Markdown 格式文本\n   * @param diffLocation 偏移位置\n   * @param autoLayout 是否自动应用树形布局（默认为 true，自动整理为向右的树状结构）\n   */\n  public import(markdownText: string, diffLocation: Vector = Vector.getZero(), autoLayout = true): void {\n    const markdownJson = parseMarkdownToJSON(markdownText);\n\n    const monoStack = new MonoStack<TextNode>();\n    const rootNode = new TextNode(this.project, {\n      text: \"root\",\n      collisionBox: new CollisionBox([new Rectangle(diffLocation, Vector.same(100))]),\n    });\n    monoStack.push(rootNode, -1);\n    this.project.stageManager.add(rootNode);\n\n    let yIndex = 0;\n\n    const visitFunction = (markdownNode: MarkdownNode, deepLevel: number) => {\n      const node = new TextNode(this.project, {\n        text: markdownNode.title,\n        details: DetailsManager.markdownToDetails(markdownNode.content),\n        collisionBox: new CollisionBox([\n          new Rectangle(diffLocation.add(new Vector(deepLevel * 50, yIndex * 100)), Vector.same(100)),\n        ]),\n      });\n      this.project.stageManager.add(node);\n      yIndex++;\n\n      // 检查栈，保持一个严格单调栈\n      if (monoStack.peek()) {\n        monoStack.push(node, deepLevel);\n        const fatherNode = monoStack.unsafeGet(monoStack.length - 2);\n        // 创建从父节点右侧到子节点左侧的连线\n        const newEdge = new LineEdge(this.project, {\n          associationList: [fatherNode, node],\n          targetRectangleRate: new Vector(0.01, 0.5), // 目标节点左侧边缘\n          sourceRectangleRate: new Vector(0.99, 0.5), // 源节点右侧边缘\n        });\n        this.project.stageManager.add(newEdge);\n      }\n    };\n\n    // 递归遍历 Markdown 节点树\n    const dfsMarkdownNode = (markdownNode: MarkdownNode, deepLevel: number) => {\n      // 访问当前节点\n      visitFunction(markdownNode, deepLevel);\n      // 递归访问子节点\n      for (const child of markdownNode.children) {\n        dfsMarkdownNode(child, deepLevel + 1);\n      }\n    };\n\n    // 遍历所有根节点\n    for (const markdownNode of markdownJson) {\n      dfsMarkdownNode(markdownNode, 0);\n    }\n\n    // 自动应用树形布局（如果启用）\n    if (autoLayout) {\n      this.project.autoAlign.autoLayoutSelectedFastTreeMode(rootNode);\n    }\n\n    // 记录历史\n    this.project.historyManager.recordStep();\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataGenerateService/stageImportEngine/MermaidImporter.tsx",
    "content": "import type { Project } from \"@/core/Project\";\nimport type { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { BaseImporter } from \"./BaseImporter\";\n\n/**\n * Mermaid 节点标记类型\n */\ntype MermaidNodeToken = {\n  id: string;\n  label?: string;\n  shape: \"rectangle\" | \"round\" | \"circle\" | \"rhombus\" | \"stadium\" | \"other\";\n};\n\n/**\n * Mermaid 图导入器\n * 支持根据 mermaid 文本生成框嵌套网状结构\n * 支持 graph TD 格式的 mermaid 文本\n * 支持 subgraph 嵌套\n * 解析节点形状和标签\n * 处理各种连线类型\n */\nexport class MermaidImporter extends BaseImporter {\n  constructor(project: Project) {\n    super(project);\n  }\n\n  /**\n   * 导入 Mermaid 文本并生成节点\n   * @param text Mermaid 格式文本\n   * @param diffLocation 偏移位置\n   * @example\n   * graph TD;\n   *   A[Section A] --> B[Section B];\n   *   A --> C[C];\n   *   B --> D[D];\n   */\n  public import(text: string, diffLocation: Vector = Vector.getZero()): void {\n    const lines = text\n      .replace(/\\r/g, \"\")\n      .split(\"\\n\")\n      .map((line) => line.trim())\n      .filter(\n        (line) =>\n          line.length > 0 &&\n          !line.startsWith(\"```\") &&\n          !line.startsWith(\"%%\") &&\n          !line.toLowerCase().startsWith(\"style \") &&\n          !line.toLowerCase().startsWith(\"linkstyle \") &&\n          !line.toLowerCase().startsWith(\"classdef \"),\n      );\n\n    if (lines.length === 0) {\n      return;\n    }\n\n    const entityMap = new Map<string, ConnectableEntity>();\n    const entityParentMap = new Map<ConnectableEntity, Section>();\n    const sectionChildrenMap = new Map<Section, ConnectableEntity[]>();\n    const sectionStack: Section[] = [];\n    const createdEntities = new Set<ConnectableEntity>();\n    const pendingEdges: Array<{ source: ConnectableEntity; target: ConnectableEntity; label?: string }> = [];\n\n    const ensureSectionChild = (section: Section, child: ConnectableEntity) => {\n      if (section === child) {\n        return;\n      }\n      if (!sectionChildrenMap.has(section)) {\n        sectionChildrenMap.set(section, []);\n      }\n      const childList = sectionChildrenMap.get(section)!;\n      if (!childList.includes(child)) {\n        childList.push(child);\n      }\n      if (!section.children.includes(child)) {\n        section.children.push(child);\n      }\n      entityParentMap.set(child, section);\n    };\n\n    const shouldTreatAsSection = (label: string | undefined, forceSection: boolean): boolean => {\n      if (forceSection) {\n        return true;\n      }\n      if (!label) {\n        return false;\n      }\n      return /(section|章节|组|容器)/i.test(label);\n    };\n\n    const createDefaultRectangle = (size: Vector) =>\n      new Rectangle(diffLocation.add(new Vector(Math.random() * 40, Math.random() * 40)), size);\n\n    const ensureEntity = (\n      token: string,\n      options: { forceSection?: boolean; displayText?: string } = {},\n    ): ConnectableEntity => {\n      const parsed = this.parseNodeToken(token);\n      const baseId = parsed.id;\n      if (!baseId) {\n        throw new Error(`无法解析节点标识: \"${token}\"`);\n      }\n\n      const existing = entityMap.get(baseId);\n      const finalLabel = options.displayText ?? parsed.label;\n      const forceSection = options.forceSection ?? false;\n      const treatAsSection = shouldTreatAsSection(finalLabel, forceSection);\n\n      if (existing) {\n        if (finalLabel) {\n          if (existing instanceof Section) {\n            if (existing.text !== finalLabel) {\n              existing.rename(finalLabel);\n            }\n          } else if (existing instanceof TextNode) {\n            if (existing.text !== finalLabel) {\n              existing.rename(finalLabel);\n            }\n          }\n        }\n        if (sectionStack.length > 0) {\n          const currentSection = sectionStack[sectionStack.length - 1];\n          ensureSectionChild(currentSection, existing);\n        }\n        return existing;\n      }\n\n      let entity: ConnectableEntity;\n      if (treatAsSection) {\n        const section = new Section(this.project, {\n          text: finalLabel ?? baseId,\n          collisionBox: new CollisionBox([createDefaultRectangle(new Vector(240, 180))]),\n          children: [],\n        });\n        entity = section;\n        sectionChildrenMap.set(section, sectionChildrenMap.get(section) ?? []);\n      } else {\n        entity = new TextNode(this.project, {\n          text: finalLabel ?? baseId,\n          collisionBox: new CollisionBox([createDefaultRectangle(Vector.same(120))]),\n        });\n      }\n\n      this.project.stageManager.add(entity);\n      entityMap.set(baseId, entity);\n      createdEntities.add(entity);\n\n      if (sectionStack.length > 0) {\n        const currentSection = sectionStack[sectionStack.length - 1];\n        ensureSectionChild(currentSection, entity);\n      }\n\n      return entity;\n    };\n\n    for (const rawLine of lines) {\n      const line = this.normalizeLine(rawLine);\n      if (line.length === 0) {\n        continue;\n      }\n\n      const lowerLine = line.toLowerCase();\n      if (lowerLine.startsWith(\"graph \")) {\n        continue;\n      }\n\n      if (lowerLine.startsWith(\"subgraph \")) {\n        const token = line.slice(\"subgraph \".length).trim();\n        const sectionEntity = ensureEntity(token, { forceSection: true });\n        if (sectionEntity instanceof Section) {\n          sectionStack.push(sectionEntity);\n        }\n        continue;\n      }\n\n      if (lowerLine === \"end\" || lowerLine.startsWith(\"end \")) {\n        sectionStack.pop();\n        continue;\n      }\n\n      const arrowIndex = line.indexOf(\"-->\");\n      if (arrowIndex !== -1) {\n        const leftPart = line.slice(0, arrowIndex).trim();\n        const rightPart = line.slice(arrowIndex + 3).trim();\n\n        if (!rightPart) {\n          continue;\n        }\n\n        let sourceToken = leftPart;\n        let edgeLabel: string | undefined;\n\n        const labelIndex = leftPart.indexOf(\"--\");\n        if (labelIndex !== -1) {\n          sourceToken = leftPart.slice(0, labelIndex).trim();\n          const rawLabel = leftPart.slice(labelIndex + 2).trim();\n          edgeLabel = this.sanitizeLabel(rawLabel);\n        }\n\n        const sourceEntity = ensureEntity(sourceToken);\n        const targetEntity = ensureEntity(rightPart);\n\n        pendingEdges.push({ source: sourceEntity, target: targetEntity, label: edgeLabel });\n        continue;\n      }\n\n      ensureEntity(line);\n    }\n\n    const layoutGroup = (entities: ConnectableEntity[], origin: Vector, spacing: Vector) => {\n      if (entities.length === 0) {\n        return;\n      }\n      const columns = Math.max(1, Math.ceil(Math.sqrt(entities.length)));\n      for (let index = 0; index < entities.length; index++) {\n        const entity = entities[index];\n        const row = Math.floor(index / columns);\n        const col = index % columns;\n        const target = origin.add(new Vector(col * spacing.x, row * spacing.y));\n\n        if (entity instanceof Section) {\n          layoutSection(entity, target);\n        } else {\n          entity.moveTo(target);\n          if (entity instanceof TextNode) {\n            entity.forceAdjustSizeByText();\n          }\n        }\n      }\n    };\n\n    const layoutSection = (section: Section, origin: Vector) => {\n      const children = sectionChildrenMap.get(section) ?? [];\n      if (children.length === 0) {\n        section.moveTo(origin);\n        section.adjustLocationAndSize();\n        section.moveTo(origin);\n        return;\n      }\n\n      section.moveTo(origin);\n      layoutGroup(children, origin.add(new Vector(40, 120)), new Vector(200, 160));\n      section.adjustLocationAndSize();\n      section.moveTo(origin);\n    };\n\n    const rootEntities: ConnectableEntity[] = [];\n    for (const entity of entityMap.values()) {\n      if (!entityParentMap.has(entity)) {\n        rootEntities.push(entity);\n      }\n    }\n\n    layoutGroup(rootEntities, diffLocation, new Vector(260, 200));\n\n    for (const { source, target, label } of pendingEdges) {\n      if (label) {\n        this.project.nodeConnector.connectEntityFast(source, target, label);\n      } else {\n        this.project.nodeConnector.connectEntityFast(source, target);\n      }\n    }\n\n    for (const section of sectionChildrenMap.keys()) {\n      section.adjustLocationAndSize();\n    }\n\n    if (createdEntities.size > 0 || pendingEdges.length > 0) {\n      this.project.historyManager.recordStep();\n    }\n  }\n\n  /**\n   * 规范化行，去除尾部分号\n   */\n  private normalizeLine(line: string): string {\n    return line.trim().replace(/;$/, \"\");\n  }\n\n  /**\n   * 解码 Mermaid 文本中的特殊字符\n   */\n  private decodeMermaidText(value: string): string {\n    return value.replace(/&quot;/g, '\"').replace(/<br\\s*\\/?>/gi, \"\\n\");\n  }\n\n  /**\n   * 清理标签文本\n   */\n  private sanitizeLabel(raw: string | undefined): string | undefined {\n    if (!raw) {\n      return undefined;\n    }\n    let result = raw.trim();\n    if ((result.startsWith('\"') && result.endsWith('\"')) || (result.startsWith(\"'\") && result.endsWith(\"'\"))) {\n      result = result.slice(1, -1);\n    }\n    result = this.decodeMermaidText(result);\n    result = result.trim();\n    return result.length > 0 ? result : undefined;\n  }\n\n  /**\n   * 解析节点标记，提取节点ID、标签和形状\n   */\n  private parseNodeToken(token: string): MermaidNodeToken {\n    const content = this.normalizeLine(token);\n\n    const bracketMatch = content.match(/^([^[]+)\\[(.*)\\]$/);\n    if (bracketMatch) {\n      return {\n        id: this.decodeMermaidText(bracketMatch[1].trim()),\n        label: this.sanitizeLabel(bracketMatch[2]),\n        shape: \"rectangle\",\n      };\n    }\n\n    const quotedBracketMatch = content.match(/^([^[]+)\\[\"(.*)\"\\]$/);\n    if (quotedBracketMatch) {\n      return {\n        id: this.decodeMermaidText(quotedBracketMatch[1].trim()),\n        label: this.sanitizeLabel(`\"${quotedBracketMatch[2]}\"`),\n        shape: \"rectangle\",\n      };\n    }\n\n    const doubleRoundMatch = content.match(/^([^(]+)\\(\\((.*)\\)\\)$/);\n    if (doubleRoundMatch) {\n      return {\n        id: this.decodeMermaidText(doubleRoundMatch[1].trim()),\n        label: this.sanitizeLabel(doubleRoundMatch[2]),\n        shape: \"circle\",\n      };\n    }\n\n    const roundMatch = content.match(/^([^(]+)\\((.*)\\)$/);\n    if (roundMatch) {\n      return {\n        id: this.decodeMermaidText(roundMatch[1].trim()),\n        label: this.sanitizeLabel(roundMatch[2]),\n        shape: \"round\",\n      };\n    }\n\n    const rhombusMatch = content.match(/^([^{}]+)\\{(.*)\\}$/);\n    if (rhombusMatch) {\n      return {\n        id: this.decodeMermaidText(rhombusMatch[1].trim()),\n        label: this.sanitizeLabel(rhombusMatch[2]),\n        shape: \"rhombus\",\n      };\n    }\n\n    const stadiumMatch = content.match(/^([^[]+)\\[\\((.*)\\)\\]$/);\n    if (stadiumMatch) {\n      return {\n        id: this.decodeMermaidText(stadiumMatch[1].trim()),\n        label: this.sanitizeLabel(stadiumMatch[2]),\n        shape: \"stadium\",\n      };\n    }\n\n    const cleanId = this.sanitizeLabel(content) ?? this.decodeMermaidText(content);\n    return {\n      id: cleanId,\n      shape: \"other\",\n    };\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataGenerateService/stageImportEngine/TreeImporter.tsx",
    "content": "import type { Project } from \"@/core/Project\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { LineEdge } from \"@/core/stage/stageObject/association/LineEdge\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { MonoStack, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { BaseImporter } from \"./BaseImporter\";\n\n/**\n * 树形结构导入器\n * 支持通过带有缩进格式的文本来增加节点\n * 格式：基于缩进的树形文本\n * 使用栈处理父子关系\n * 自动连接父子节点\n */\nexport class TreeImporter extends BaseImporter {\n  constructor(project: Project) {\n    super(project);\n  }\n\n  /**\n   * 导入树形结构文本并生成节点\n   * @param text 树形结构的格式文本\n   * @param indention 缩进大小（空格数或Tab数）\n   * @param diffLocation 偏移位置\n   */\n  public import(text: string, indention: number, diffLocation: Vector = Vector.getZero()): void {\n    // 将本文转换成字符串数组，按换行符分割\n    const lines = text.split(\"\\n\");\n\n    // 准备好栈和根节点\n    const rootNode = new TextNode(this.project, {\n      text: \"root\",\n      collisionBox: new CollisionBox([new Rectangle(diffLocation, Vector.same(100))]),\n    });\n    const nodeStack = new MonoStack<TextNode>();\n    nodeStack.push(rootNode, -1);\n    this.project.stageManager.add(rootNode);\n    // 遍历每一行\n    for (let yIndex = 0; yIndex < lines.length; yIndex++) {\n      const line = lines[yIndex];\n      // 跳过空行\n      if (line.trim() === \"\") {\n        continue;\n      }\n      // 解析缩进格式\n      const indent = this.getIndentLevel(line, indention);\n      // 解析文本内容\n      const textContent = line.trim();\n\n      const node = new TextNode(this.project, {\n        text: textContent.replaceAll(\"\\\\t\", \"\\t\").replaceAll(\"\\\\n\", \"\\n\"),\n        collisionBox: new CollisionBox([\n          new Rectangle(diffLocation.add(new Vector(indent * 50, yIndex * 100)), Vector.same(100)),\n        ]),\n      });\n      this.project.stageManager.add(node);\n\n      // 检查栈\n      // 保持一个严格单调栈\n      if (nodeStack.peek()) {\n        nodeStack.push(node, indent);\n        const fatherNode = nodeStack.unsafeGet(nodeStack.length - 2);\n        // 创建从父节点右侧到子节点左侧的连线\n        const newEdge = new LineEdge(this.project, {\n          associationList: [fatherNode, node],\n          targetRectangleRate: new Vector(0.01, 0.5), // 目标节点左侧边缘\n          sourceRectangleRate: new Vector(0.99, 0.5), // 源节点右侧边缘\n        });\n        this.project.stageManager.add(newEdge);\n      }\n    }\n    // 运行树形布局格式化\n    this.project.autoLayoutFastTree.autoLayoutFastTreeMode(rootNode);\n  }\n\n  /**\n   * 从指定节点开始导入树形结构文本并生成节点\n   * @param uuid 根节点的UUID\n   * @param text 树形结构的格式文本\n   * @param indention 缩进大小（空格数或Tab数）\n   * @returns 导入结果对象\n   */\n  public importFromNode(\n    uuid: string,\n    text: string,\n    indention: number,\n  ): { success: boolean; error?: string; nodeCount?: number } {\n    // 获取指定节点作为根节点\n    const rootNode = this.project.stageManager.getConnectableEntityByUUID(uuid);\n    if (!rootNode) {\n      return { success: false, error: \"节点不存在\" };\n    }\n    if (!(rootNode instanceof TextNode)) {\n      return { success: false, error: \"节点不是TextNode类型\" };\n    }\n\n    // 将本文转换成字符串数组，按换行符分割\n    const lines = text.split(\"\\n\");\n\n    // 准备好栈，使用现有节点作为根节点\n    const nodeStack = new MonoStack<TextNode>();\n    nodeStack.push(rootNode, -1);\n\n    let nodeCount = 0;\n\n    // 遍历每一行\n    for (let yIndex = 0; yIndex < lines.length; yIndex++) {\n      const line = lines[yIndex];\n      // 跳过空行\n      if (line.trim() === \"\") {\n        continue;\n      }\n      // 解析缩进格式\n      const indent = this.getIndentLevel(line, indention);\n      // 解析文本内容\n      const textContent = line.trim();\n\n      const node = new TextNode(this.project, {\n        text: textContent.replaceAll(\"\\\\t\", \"\\t\").replaceAll(\"\\\\n\", \"\\n\"),\n        collisionBox: new CollisionBox([\n          new Rectangle(\n            rootNode.collisionBox.getRectangle().location.add(new Vector(indent * 50, (yIndex + 1) * 100)),\n            Vector.same(100),\n          ),\n        ]),\n      });\n      this.project.stageManager.add(node);\n      nodeCount++;\n\n      // 检查栈\n      // 保持一个严格单调栈\n      if (nodeStack.peek()) {\n        nodeStack.push(node, indent);\n        const fatherNode = nodeStack.unsafeGet(nodeStack.length - 2);\n        // 创建从父节点右侧到子节点左侧的连线\n        const newEdge = new LineEdge(this.project, {\n          associationList: [fatherNode, node],\n          targetRectangleRate: new Vector(0.01, 0.5), // 目标节点左侧边缘\n          sourceRectangleRate: new Vector(0.99, 0.5), // 源节点右侧边缘\n        });\n        this.project.stageManager.add(newEdge);\n      }\n    }\n\n    if (nodeCount > 0) {\n      // 运行树形布局格式化\n      this.project.autoLayoutFastTree.autoLayoutFastTreeMode(rootNode);\n      return { success: true, nodeCount };\n    } else {\n      return { success: true, nodeCount: 0 }; // 文本为空或只有空行\n    }\n  }\n\n  /**\n   * 计算缩进层级\n   * @param line 文本行\n   * @param indention 缩进大小\n   * @returns 缩进层级\n   * @example\n   * 'a' -> 0\n   * '    a' -> 1\n   * '\\t\\ta' -> 2\n   */\n  private getIndentLevel(line: string, indention: number): number {\n    let indent = 0;\n    for (let i = 0; i < line.length; i++) {\n      if (line[i] === \" \") {\n        indent++;\n      } else if (line[i] === \"\\t\") {\n        indent += indention;\n      } else {\n        break;\n      }\n    }\n    return Math.floor(indent / indention);\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataGenerateService/stageImportEngine/stageImportEngine.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { GraphImporter } from \"./GraphImporter\";\nimport { TreeImporter } from \"./TreeImporter\";\nimport { MermaidImporter } from \"./MermaidImporter\";\nimport { MarkdownImporter } from \"./MarkdownImporter\";\n\n/**\n * 专注于从各种格式导入并生成节点的引擎\n */\n@service(\"stageImport\")\nexport class StageImport {\n  private readonly graphImporter: GraphImporter;\n  private readonly treeImporter: TreeImporter;\n  private readonly mermaidImporter: MermaidImporter;\n  private readonly markdownImporter: MarkdownImporter;\n\n  constructor(readonly project: Project) {\n    this.graphImporter = new GraphImporter(project);\n    this.treeImporter = new TreeImporter(project);\n    this.mermaidImporter = new MermaidImporter(project);\n    this.markdownImporter = new MarkdownImporter(project);\n  }\n\n  /**\n   * 通过纯文本生成网状结构\n   * 格式：\n   * - A --> B （连线上无文字）\n   * - A -label-> B （连线上有文字）\n   * - A （单独的节点）\n   * @param text 网状结构的格式文本\n   * @param diffLocation 偏移位置\n   */\n  public addNodeGraphByText(text: string, diffLocation: Vector = Vector.getZero()) {\n    return this.graphImporter.import(text, diffLocation);\n  }\n\n  /**\n   * 通过带有缩进格式的文本来增加节点\n   * 格式：基于缩进的树形文本\n   * @param text 树形结构的格式文本\n   * @param indention 缩进大小（空格数或Tab数）\n   * @param diffLocation 偏移位置\n   */\n  public addNodeTreeByText(text: string, indention: number, diffLocation: Vector = Vector.getZero()) {\n    return this.treeImporter.import(text, indention, diffLocation);\n  }\n\n  /**\n   * 从指定节点开始根据文本生成树形结构\n   * @param uuid 根节点的UUID\n   * @param text 树形结构的格式文本\n   * @param indention 缩进大小（空格数或Tab数）\n   * @returns 导入结果对象\n   */\n  public addNodeTreeByTextFromNode(\n    uuid: string,\n    text: string,\n    indention: number,\n  ): { success: boolean; error?: string; nodeCount?: number } {\n    return this.treeImporter.importFromNode(uuid, text, indention);\n  }\n\n  /**\n   * 根据 mermaid 文本生成框嵌套网状结构\n   * 支持 graph TD 格式的 mermaid 文本\n   * @param text Mermaid 格式文本\n   * @param diffLocation 偏移位置\n   * @example\n   * graph TD;\n   *   A[Section A] --> B[Section B];\n   *   A --> C[C];\n   *   B --> D[D];\n   */\n  public addNodeMermaidByText(text: string, diffLocation: Vector = Vector.getZero()) {\n    return this.mermaidImporter.import(text, diffLocation);\n  }\n\n  /**\n   * 根据 Markdown 文本生成节点树结构\n   * 支持 Markdown 标题层级（#, ##, ###）\n   * @param markdownText Markdown 格式文本\n   * @param diffLocation 偏移位置\n   * @param autoLayout 是否自动应用树形布局（默认为 true）\n   * @example\n   * # 标题1\n   * ## 子标题1.1\n   * ## 子标题1.2\n   * # 标题2\n   */\n  public addNodeByMarkdown(markdownText: string, diffLocation: Vector = Vector.getZero(), autoLayout = true) {\n    return this.markdownImporter.import(markdownText, diffLocation, autoLayout);\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataManageService/ComplexityDetector.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { LineEdge } from \"@/core/stage/stageObject/association/LineEdge\";\nimport { ConnectPoint } from \"@/core/stage/stageObject/entity/ConnectPoint\";\nimport { ImageNode } from \"@/core/stage/stageObject/entity/ImageNode\";\nimport { PenStroke } from \"@/core/stage/stageObject/entity/PenStroke\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { UrlNode } from \"@/core/stage/stageObject/entity/UrlNode\";\n\nexport interface CountResultObject {\n  textNodeWordCount: number;\n  associationWordCount: number;\n  entityDetailsWordCount: number;\n  textCharSize: number;\n\n  averageWordCountPreTextNode: number;\n\n  entityCount: number;\n\n  sectionCount: number;\n  textNodeCount: number;\n  penStrokeCount: number;\n  imageCount: number;\n  urlCount: number;\n  connectPointCount: number;\n  isolatedConnectPointCount: number;\n\n  noTransparentEntityColorCount: number;\n  transparentEntityColorCount: number;\n  entityColorTypeCount: number;\n  noTransparentEdgeColorCount: number;\n  transparentEdgeColorCount: number;\n  edgeColorTypeCount: number;\n\n  stageWidth: number;\n  stageHeight: number;\n  stageArea: number;\n\n  associationCount: number;\n  selfLoopCount: number;\n  isolatedConnectableEntityCount: number;\n  multiEdgeCount: number;\n\n  entityDensity: number;\n  entityOverlapCount: number;\n\n  crossEntityCount: number;\n  maxSectionDepth: number;\n  emptySetCount: number;\n}\n/**\n * 舞台场景复杂度检测器\n */\n@service(\"complexityDetector\")\nexport class ComplexityDetector {\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 检测当前舞台\n   */\n  detectorCurrentStage(): CountResultObject {\n    // 统计字数\n    // 统计各种类型节点数量\n    const entities = this.project.stageManager.getEntities();\n    const associations = this.project.stageManager.getAssociations();\n\n    const countResultObject: CountResultObject = {\n      // 小白统计\n      textNodeWordCount: 0,\n      associationWordCount: 0,\n      entityDetailsWordCount: 0,\n      averageWordCountPreTextNode: 0,\n      textCharSize: 0,\n\n      // 各种实体\n      entityCount: entities.length,\n\n      sectionCount: 0,\n      textNodeCount: 0,\n      penStrokeCount: 0,\n      imageCount: 0,\n      urlCount: 0,\n      connectPointCount: 0,\n      isolatedConnectPointCount: 0,\n\n      // 颜色统计\n      noTransparentEntityColorCount: 0,\n      transparentEntityColorCount: 0,\n      entityColorTypeCount: 0,\n      noTransparentEdgeColorCount: 0,\n      transparentEdgeColorCount: 0,\n      edgeColorTypeCount: 0,\n\n      // 舞台尺寸相关\n      stageWidth: 0,\n      stageHeight: 0,\n      stageArea: 0,\n\n      // 图论相关\n      associationCount: associations.length,\n      // 自环数量\n      selfLoopCount: 0,\n      // 孤立节点数量\n      isolatedConnectableEntityCount: 0,\n      multiEdgeCount: 0, // 多条边数量\n\n      // 节点密度\n      entityDensity: NaN,\n      // 节点重叠数量\n      entityOverlapCount: 0,\n\n      // 集合论相关\n      // 空集数量\n      emptySetCount: 0,\n      // 交叉元素数量\n      crossEntityCount: 0,\n      // 最大深度\n      maxSectionDepth: 0,\n    };\n\n    // 各种实体统计\n    for (const entity of entities) {\n      countResultObject.entityDetailsWordCount += entity.details.length;\n\n      if (entity instanceof TextNode) {\n        countResultObject.textNodeWordCount += entity.text.length;\n        countResultObject.averageWordCountPreTextNode += entity.text.length;\n        countResultObject.textNodeCount++;\n        if (entity.color.a === 0) {\n          countResultObject.transparentEntityColorCount++;\n        } else {\n          countResultObject.noTransparentEntityColorCount++;\n        }\n      } else if (entity instanceof ImageNode) {\n        countResultObject.imageCount++;\n      } else if (entity instanceof UrlNode) {\n        countResultObject.urlCount++;\n      } else if (entity instanceof ConnectPoint) {\n        countResultObject.connectPointCount++;\n        if (\n          this.project.graphMethods.nodeChildrenArray(entity).length === 0 &&\n          this.project.graphMethods.nodeParentArray(entity).length === 0\n        ) {\n          countResultObject.isolatedConnectPointCount++;\n        }\n      } else if (entity instanceof PenStroke) {\n        countResultObject.penStrokeCount++;\n      } else if (entity instanceof Section) {\n        countResultObject.sectionCount++;\n        if (entity.color.a === 0) {\n          countResultObject.transparentEntityColorCount++;\n        } else {\n          countResultObject.noTransparentEntityColorCount++;\n        }\n      }\n    }\n    countResultObject.averageWordCountPreTextNode /= countResultObject.textNodeCount;\n    countResultObject.averageWordCountPreTextNode = Math.round(countResultObject.averageWordCountPreTextNode);\n\n    const worldViewRectangle = this.project.renderer.getCoverWorldRectangle();\n    countResultObject.stageWidth = worldViewRectangle.width;\n    countResultObject.stageHeight = worldViewRectangle.height;\n    countResultObject.stageArea = worldViewRectangle.width * worldViewRectangle.height;\n\n    // 遍历关系\n    for (const association of associations) {\n      if (association instanceof LineEdge) {\n        if (association.source === association.target) {\n          countResultObject.selfLoopCount++;\n        } else {\n          // 检测是否有多重边\n          const edges = this.project.graphMethods.getEdgesBetween(association.source, association.target);\n          if (edges.length > 1) {\n            countResultObject.multiEdgeCount++;\n          }\n        }\n        countResultObject.associationWordCount += association.text.length;\n      }\n    }\n\n    const connectableEntities = this.project.stageManager.getConnectableEntity();\n\n    // 孤立节点数量\n    for (const entity of connectableEntities) {\n      if (\n        this.project.graphMethods.nodeChildrenArray(entity).length === 0 &&\n        this.project.graphMethods.nodeParentArray(entity).length === 0\n      ) {\n        countResultObject.isolatedConnectableEntityCount++;\n      }\n      const edges = this.project.graphMethods.getEdgesBetween(entity, entity);\n      if (edges.length > 1) {\n        countResultObject.multiEdgeCount++;\n      }\n    }\n\n    // 节点密度\n    countResultObject.entityDensity = countResultObject.entityCount / (countResultObject.stageArea / 10000);\n\n    // 节点重叠数量\n    for (const entity of entities) {\n      if (entity instanceof Section) {\n        continue;\n      }\n      for (const otherEntity of entities) {\n        if (entity === otherEntity || otherEntity instanceof Section) {\n          continue;\n        }\n        if (entity.collisionBox.isIntersectsWithRectangle(otherEntity.collisionBox.getRectangle())) {\n          countResultObject.entityOverlapCount++;\n          break;\n        }\n      }\n    }\n    // 色彩统计\n    const entityColorStringSet = new Set();\n    for (const entity of entities) {\n      if (entity instanceof TextNode || entity instanceof Section) {\n        entityColorStringSet.add(entity.color.toString());\n      }\n    }\n    countResultObject.entityColorTypeCount = entityColorStringSet.size;\n\n    const edgeColorStringSet = new Set();\n    for (const lineEdge of this.project.stageManager.getLineEdges()) {\n      if (lineEdge.color.a === 0) {\n        countResultObject.transparentEdgeColorCount++;\n      } else {\n        countResultObject.noTransparentEdgeColorCount++;\n      }\n      edgeColorStringSet.add(lineEdge.color.toString());\n    }\n    countResultObject.edgeColorTypeCount = edgeColorStringSet.size;\n    // 集合论相关\n    for (const entity of entities) {\n      const fatherSections = this.project.sectionMethods.getFatherSections(entity);\n      if (fatherSections.length > 1) {\n        countResultObject.crossEntityCount++;\n      }\n    }\n    for (const section of this.project.stageManager.getSections()) {\n      // this.project.sectionMethods.isTreePack(section);\n      countResultObject.maxSectionDepth = Math.max(\n        countResultObject.maxSectionDepth,\n        this.project.sectionMethods.getSectionMaxDeep(section),\n      );\n      if (section.children.length === 0) {\n        countResultObject.emptySetCount++;\n      }\n    }\n    return countResultObject;\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataManageService/aiEngine/AIEngine.tsx",
    "content": "import { service } from \"@/core/Project\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { AITools } from \"@/core/service/dataManageService/aiEngine/AITools\";\nimport { fetch } from \"@tauri-apps/plugin-http\";\nimport OpenAI from \"openai\";\n\n@service(\"aiEngine\")\nexport class AIEngine {\n  private openai = new OpenAI({\n    apiKey: \"\",\n    dangerouslyAllowBrowser: true,\n    fetch,\n  });\n\n  // constructor(private readonly project: Project) {}\n\n  async updateConfig() {\n    this.openai.baseURL = Settings.aiApiBaseUrl;\n    this.openai.apiKey = Settings.aiApiKey;\n  }\n\n  async chat(messages: OpenAI.ChatCompletionMessageParam[]) {\n    await this.updateConfig();\n    const stream = await this.openai.chat.completions.create({\n      messages,\n      model: Settings.aiModel,\n      stream: true,\n      stream_options: {\n        include_usage: true,\n      },\n      tools: AITools.tools,\n    });\n    return stream;\n  }\n\n  async getModels() {\n    await this.updateConfig();\n    const resp = await this.openai.models.list();\n    return resp.data.map((it) => it.id.replaceAll(\"models/\", \"\"));\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataManageService/aiEngine/AITools.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { Edge } from \"@/core/stage/stageObject/association/Edge\";\nimport { Color, Vector } from \"@graphif/data-structures\";\nimport { serialize } from \"@graphif/serializer\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport OpenAI from \"openai\";\nimport z from \"zod/v4\";\n\nexport namespace AITools {\n  export const tools: OpenAI.ChatCompletionTool[] = [];\n  export const handlers: Map<string, (project: Project, data: any) => any> = new Map();\n\n  function addTool<A extends z.ZodObject>(\n    name: string,\n    description: string,\n    parameters: A,\n    fn: (project: Project, data: z.infer<A>) => any,\n  ) {\n    tools.push({\n      type: \"function\",\n      function: {\n        name,\n        description,\n        parameters: z.toJSONSchema(parameters),\n        strict: true,\n      },\n    });\n    handlers.set(name, fn);\n  }\n\n  addTool(\"get_all_nodes\", \"获取舞台上所有节点以及uuid\", z.object({}), (project) => serialize(project.stage));\n  addTool(\"delete_node\", \"根据uuid删除节点\", z.object({ uuid: z.string() }), (project, { uuid }) => {\n    project.stageManager.delete(project.stageManager.get(uuid)!);\n    project.historyManager.recordStep();\n  });\n  addTool(\n    \"delete_nodes_by_uuids\",\n    \"批量删除指定uuid数组对应的节点\",\n    z.object({\n      uuids: z.array(z.string()).describe(\"要删除的节点UUID数组\"),\n    }),\n    (project, { uuids }) => {\n      let deletedCount = 0;\n      for (const uuid of uuids) {\n        const obj = project.stageManager.get(uuid);\n        if (obj) {\n          project.stageManager.delete(obj);\n          deletedCount++;\n        }\n      }\n      if (deletedCount > 0) {\n        project.historyManager.recordStep();\n      }\n      return { deletedCount };\n    },\n  );\n  addTool(\"delete_selected_nodes\", \"删除当前所有选中的节点\", z.object({}), (project) => {\n    const selected = project.stageManager.getSelectedEntities();\n    const count = selected.length;\n    for (const entity of [...selected]) {\n      project.stageManager.delete(entity);\n    }\n    if (count > 0) {\n      project.historyManager.recordStep();\n    }\n    return { deletedCount: count };\n  });\n  addTool(\"delete_all_nodes\", \"删除舞台上所有的节点和连线（清空舞台）\", z.object({}), (project) => {\n    const entities = [...project.stageManager.getEntities()];\n    const associations = [...project.stageManager.getAssociations()];\n    for (const assoc of associations) {\n      project.stageManager.delete(assoc);\n    }\n    for (const entity of entities) {\n      project.stageManager.delete(entity);\n    }\n    const total = entities.length + associations.length;\n    if (total > 0) {\n      project.historyManager.recordStep();\n    }\n    return { deletedEntities: entities.length, deletedAssociations: associations.length };\n  });\n  addTool(\n    \"edit_text_node\",\n    \"根据uuid编辑TextNode\",\n    z.object({\n      uuid: z.string(),\n      data: z.object({\n        text: z.string().optional(),\n        color: z.array(z.number()).optional().describe(\"[255,255,255,1]\"),\n        x: z.number().optional(),\n        y: z.number().optional(),\n        width: z.number().optional(),\n        sizeAdjust: z\n          .union([\n            z.string(\"auto\").describe(\"自动调整宽度\"),\n            z.string(\"manual\").describe(\"宽度由width字段定义，文本自动换行\"),\n          ])\n          .optional()\n          .default(\"auto\"),\n      }),\n    }),\n    (project, { uuid, data }) => {\n      const node = project.stageManager.get(uuid);\n      if (!(node instanceof TextNode)) return;\n      node.text = data.text ?? node.text;\n      node.color = data.color ? new Color(...(data.color as [number, number, number, number])) : node.color;\n      node.collisionBox.updateShapeList([\n        new Rectangle(\n          new Vector(\n            data.x ?? node.collisionBox.getRectangle().location.x,\n            data.y ?? node.collisionBox.getRectangle().location.y,\n          ),\n          new Vector(data.width ?? node.collisionBox.getRectangle().size.x, node.collisionBox.getRectangle().size.y),\n        ),\n      ]);\n      node.sizeAdjust = data.sizeAdjust ?? node.sizeAdjust;\n      node.forceAdjustSizeByText();\n      project.historyManager.recordStep();\n    },\n  );\n  addTool(\n    \"create_text_node\",\n    \"创建TextNode\",\n    z.object({\n      text: z.string(),\n      color: z.array(z.number()).describe(\"[R,G,B,A]，RGB为0~255，A为0~1，正常情况下为透明[0,0,0,0]\"),\n      x: z.number(),\n      y: z.number().describe(\"文本框默认高度=75\"),\n      width: z.number().describe(\"如果sizeAdjust为manual，则定义文本框宽度，否则可以写0\"),\n      sizeAdjust: z\n        .union([\n          z.string(\"auto\").describe(\"自动调整宽度\"),\n          z.string(\"manual\").describe(\"宽度由width字段定义，文本自动换行\"),\n        ])\n        .optional()\n        .describe(\"建议用auto\"),\n    }),\n    (project, { text, color, x, y, width, sizeAdjust }) => {\n      const node = new TextNode(project, {\n        text,\n        color: new Color(...(color as [number, number, number, number])),\n        collisionBox: new CollisionBox([new Rectangle(new Vector(x, y), new Vector(width, 50))]),\n        sizeAdjust: (sizeAdjust ?? \"auto\") as \"auto\" | \"manual\",\n      });\n      project.stageManager.add(node);\n      project.historyManager.recordStep();\n      return { uuid: node.uuid };\n    },\n  );\n  addTool(\n    \"generate_node_tree_by_text\",\n    \"根据纯文本缩进结构生成树状节点\",\n    z.object({\n      text: z\n        .string()\n        .describe(\"包含缩进结构的文本，每一层缩进2个空格，例如：'root\\\\n  child1\\\\n  child2\\\\n    grandchild'\"),\n    }),\n    (project, { text }) => {\n      project.stageManager.generateNodeTreeByText(text, 2);\n    },\n  );\n  addTool(\n    \"expand_node_tree_from_node\",\n    \"从指定节点开始进行树形扩展，传入一个uuid和缩进文本，在该节点下生成树状子节点\",\n    z.object({\n      uuid: z.string().describe(\"根节点的UUID\"),\n      text: z.string().describe(\"包含缩进结构的文本，每一层缩进2个空格，例如：'child1\\\\n  grandchild\\\\nchild2'\"),\n    }),\n    (project, { uuid, text }) => {\n      const result = project.stageImport.addNodeTreeByTextFromNode(uuid, text, 2);\n      if (result.success && result.nodeCount && result.nodeCount > 0) {\n        project.historyManager.recordStep();\n      }\n      return result;\n    },\n  );\n  addTool(\n    \"search_text_nodes_by_regex\",\n    \"根据正则表达式搜索文本节点\",\n    z.object({\n      regex: z.string().describe(\"正则表达式字符串\"),\n    }),\n    (project, { regex }) => {\n      const results: { text: string; uuid: string }[] = [];\n      const regexObj = new RegExp(regex);\n      for (const entity of project.stageManager.getEntities()) {\n        if (entity instanceof TextNode && regexObj.test(entity.text)) {\n          results.push({ text: entity.text, uuid: entity.uuid });\n        }\n      }\n      return results;\n    },\n  );\n  addTool(\n    \"get_children_by_uuid\",\n    \"通过UUID获取一个节点的所有第一层子集节点（基于连接关系）\",\n    z.object({\n      uuid: z.string(),\n    }),\n    (project, { uuid }) => {\n      const node = project.stageManager.getConnectableEntityByUUID(uuid);\n      if (!node) return [];\n      const children = project.graphMethods.nodeChildrenArray(node);\n      const results: { text: string; uuid: string }[] = [];\n      for (const child of children) {\n        if (child instanceof TextNode) {\n          results.push({ text: child.text, uuid: child.uuid });\n        }\n      }\n      return results;\n    },\n  );\n  addTool(\n    \"get_parents_by_uuid\",\n    \"通过UUID获取一个节点的所有父级节点（基于连接关系）\",\n    z.object({\n      uuid: z.string(),\n    }),\n    (project, { uuid }) => {\n      const node = project.stageManager.getConnectableEntityByUUID(uuid);\n      if (!node) return [];\n      const parents = project.graphMethods.nodeParentArray(node);\n      const results: { text: string; uuid: string }[] = [];\n      for (const parent of parents) {\n        if (parent instanceof TextNode) {\n          results.push({ text: parent.text, uuid: parent.uuid });\n        }\n      }\n      return results;\n    },\n  );\n  addTool(\n    \"batch_change_color\",\n    \"批量给物体更改颜色\",\n    z.object({\n      uuids: z.array(z.string()).describe(\"UUID数组\"),\n      color: z.array(z.number()).describe(\"[R,G,B,A]，RGB为0~255，A为0~1\"),\n    }),\n    (project, { uuids, color }) => {\n      const colorObj = new Color(...(color as [number, number, number, number]));\n      let changedCount = 0;\n      for (const uuid of uuids) {\n        const obj = project.stageManager.get(uuid);\n        if (obj && \"color\" in obj && obj.color instanceof Color) {\n          obj.color = colorObj;\n          changedCount++;\n        }\n      }\n      if (changedCount > 0) {\n        project.historyManager.recordStep();\n      }\n      return { changedCount };\n    },\n  );\n  addTool(\n    \"get_serialized_info\",\n    \"通过uuid数组获取对应内容的详细序列化信息\",\n    z.object({\n      uuids: z.array(z.string()).describe(\"UUID数组\"),\n    }),\n    (project, { uuids }) => {\n      const results: { uuid: string; serialized: any }[] = [];\n      for (const uuid of uuids) {\n        const obj = project.stageManager.get(uuid);\n        if (obj) {\n          results.push({\n            uuid,\n            serialized: serialize(obj),\n          });\n        }\n      }\n      return results;\n    },\n  );\n  addTool(\n    \"check_connections\",\n    \"检查节点是否是通过Edge直接连接的\",\n    z.object({\n      pairs: z.array(z.array(z.string()).length(2)).describe(\"UUID对儿数组，例如[[uuid1, uuid2], [uuid3, uuid4]]\"),\n    }),\n    (project, { pairs }) => {\n      const results: { from: string; to: string; connected: boolean }[] = [];\n      for (const [fromUuid, toUuid] of pairs) {\n        const fromNode = project.stageManager.getConnectableEntityByUUID(fromUuid);\n        const toNode = project.stageManager.getConnectableEntityByUUID(toUuid);\n        if (fromNode && toNode) {\n          const connected = project.graphMethods.isConnected(fromNode, toNode);\n          results.push({ from: fromUuid, to: toUuid, connected });\n        } else {\n          results.push({ from: fromUuid, to: toUuid, connected: false });\n        }\n      }\n      return results;\n    },\n  );\n  addTool(\n    \"create_edges\",\n    \"创建一些连线连接多个物体\",\n    z.object({\n      edges: z.array(\n        z.object({\n          sourceUuid: z.string(),\n          targetUuid: z.string(),\n          text: z.string().optional().default(\"\"),\n        }),\n      ),\n    }),\n    (project, { edges }) => {\n      const results: Array<{\n        sourceUuid: string;\n        targetUuid: string;\n        success: boolean;\n        edgeUuid?: string;\n        error?: string;\n      }> = [];\n      for (const edgeData of edges) {\n        const sourceNode = project.stageManager.getConnectableEntityByUUID(edgeData.sourceUuid);\n        const targetNode = project.stageManager.getConnectableEntityByUUID(edgeData.targetUuid);\n        if (!sourceNode) {\n          results.push({\n            sourceUuid: edgeData.sourceUuid,\n            targetUuid: edgeData.targetUuid,\n            success: false,\n            error: `源节点不存在或不是可连接对象`,\n          });\n          continue;\n        }\n        if (!targetNode) {\n          results.push({\n            sourceUuid: edgeData.sourceUuid,\n            targetUuid: edgeData.targetUuid,\n            success: false,\n            error: `目标节点不存在或不是可连接对象`,\n          });\n          continue;\n        }\n        try {\n          project.nodeConnector.connectConnectableEntity(sourceNode, targetNode, edgeData.text || \"\");\n          // 获取新创建的边的UUID（可能需要通过查找最新的边）\n          const newEdge = project.stageManager\n            .getAssociations()\n            .find((edge) => edge instanceof Edge && edge.source === sourceNode && edge.target === targetNode);\n          if (newEdge) {\n            results.push({\n              sourceUuid: edgeData.sourceUuid,\n              targetUuid: edgeData.targetUuid,\n              success: true,\n              edgeUuid: newEdge.uuid,\n            });\n          } else {\n            results.push({\n              sourceUuid: edgeData.sourceUuid,\n              targetUuid: edgeData.targetUuid,\n              success: false,\n              error: `连线创建失败，未知原因`,\n            });\n          }\n        } catch (error) {\n          results.push({\n            sourceUuid: edgeData.sourceUuid,\n            targetUuid: edgeData.targetUuid,\n            success: false,\n            error: error instanceof Error ? error.message : \"连线创建失败\",\n          });\n        }\n      }\n      if (results.some((r) => r.success)) {\n        project.historyManager.recordStep();\n      }\n      return results;\n    },\n  );\n  addTool(\n    \"change_edge_text\",\n    \"更改连线上的文字\",\n    z.object({\n      edgeUuid: z.string(),\n      text: z.string(),\n    }),\n    (project, { edgeUuid, text }) => {\n      const edge = project.stageManager.get(edgeUuid);\n      if (!(edge instanceof Edge)) {\n        return { success: false, error: \"连线不存在或不是Edge类型\" };\n      }\n      edge.rename(text);\n      project.historyManager.recordStep();\n      return { success: true };\n    },\n  );\n  addTool(\n    \"select_objects\",\n    \"通过一些UUID，选中一些舞台对象\",\n    z.object({\n      uuids: z.array(z.string()).describe(\"要选中的对象UUID数组\"),\n      clearOthers: z.boolean().optional().default(false).describe(\"是否清除其他对象的选中状态\"),\n    }),\n    (project, { uuids, clearOthers }) => {\n      if (clearOthers) {\n        // 清除所有对象的选中状态\n        for (const obj of project.stageManager.getEntities()) {\n          obj.isSelected = false;\n        }\n        for (const assoc of project.stageManager.getAssociations()) {\n          assoc.isSelected = false;\n        }\n      }\n      let selectedCount = 0;\n      for (const uuid of uuids) {\n        const obj = project.stageManager.get(uuid);\n        if (obj) {\n          obj.isSelected = true;\n          selectedCount++;\n        }\n      }\n      if (selectedCount > 0) {\n        project.historyManager.recordStep();\n      }\n      return { selectedCount };\n    },\n  );\n  addTool(\"get_selected_nodes\", \"获取用户当前所有选中的节点的详细信息\", z.object({}), (project) => {\n    const results: Array<{\n      uuid: string;\n      type: string;\n      text?: string;\n      position: { x: number; y: number };\n      size: { width: number; height: number };\n    }> = [];\n\n    for (const entity of project.stageManager.getSelectedEntities()) {\n      const rect = entity.collisionBox.getRectangle();\n      const info = {\n        uuid: entity.uuid,\n        type: entity.constructor.name,\n        position: { x: rect.location.x, y: rect.location.y },\n        size: { width: rect.size.x, height: rect.size.y },\n      };\n      if (entity instanceof TextNode) {\n        (info as any).text = entity.text;\n      }\n      results.push(info);\n    }\n\n    for (const assoc of project.stageManager.getSelectedAssociations()) {\n      const rect = assoc.collisionBox.getRectangle();\n      const info = {\n        uuid: assoc.uuid,\n        type: assoc.constructor.name,\n        position: { x: rect.location.x, y: rect.location.y },\n        size: { width: rect.size.x, height: rect.size.y },\n      };\n      if (assoc instanceof Edge) {\n        (info as any).sourceUuid = assoc.source.uuid;\n        (info as any).targetUuid = assoc.target.uuid;\n        (info as any).text = assoc.text;\n      }\n      results.push(info);\n    }\n\n    return { nodes: results };\n  });\n\n  addTool(\"get_nodes_in_viewport\", \"获取当前视野范围中被完全覆盖住的节点\", z.object({}), (project) => {\n    const viewRect = project.renderer.getCoverWorldRectangle();\n    const results: Array<{\n      uuid: string;\n      type: string;\n      text?: string;\n      position: { x: number; y: number };\n      size: { width: number; height: number };\n    }> = [];\n\n    for (const entity of project.stageManager.getEntities()) {\n      const rect = entity.collisionBox.getRectangle();\n      if (rect.isAbsoluteIn(viewRect)) {\n        const info = {\n          uuid: entity.uuid,\n          type: entity.constructor.name,\n          position: { x: rect.location.x, y: rect.location.y },\n          size: { width: rect.size.x, height: rect.size.y },\n        };\n        if (entity instanceof TextNode) {\n          (info as any).text = entity.text;\n        }\n        results.push(info);\n      }\n    }\n\n    return { nodes: results };\n  });\n  addTool(\"get_selected_uuids\", \"获取用户当前所有选中的物体的uuid们\", z.object({}), (project) => {\n    const selectedEntities = project.stageManager.getSelectedEntities();\n    const selectedAssociations = project.stageManager.getSelectedAssociations();\n    const uuids = [...selectedEntities.map((e) => e.uuid), ...selectedAssociations.map((a) => a.uuid)];\n    return { uuids };\n  });\n\n  addTool(\n    \"breadth_expand_node\",\n    \"广度扩展一个节点，传入一个uuid和一个字符串数组，自动根据字符串数组给这个节点添加一层子节点\",\n    z.object({\n      uuid: z.string().describe(\"源节点的UUID\"),\n      texts: z.array(z.string()).describe(\"要添加的子节点文本数组\"),\n    }),\n    (project, { uuid, texts }) => {\n      const sourceNode = project.stageManager.getConnectableEntityByUUID(uuid);\n      if (!sourceNode) {\n        return { success: false, error: \"源节点不存在或不是可连接对象\" };\n      }\n\n      const sourceRect = sourceNode.collisionBox.getRectangle();\n      const startX = sourceRect.location.x + sourceRect.size.x + 100; // 右侧100像素\n      const startY = sourceRect.location.y;\n      const verticalSpacing = 60;\n\n      const results: Array<{ text: string; uuid: string; success: boolean; error?: string }> = [];\n\n      for (let i = 0; i < texts.length; i++) {\n        const text = texts[i];\n        try {\n          const node = new TextNode(project, {\n            text,\n            color: new Color(0, 0, 0, 0), // 透明\n            collisionBox: new CollisionBox([\n              new Rectangle(new Vector(startX, startY + i * verticalSpacing), new Vector(100, 50)),\n            ]),\n            sizeAdjust: \"auto\" as \"auto\" | \"manual\",\n          });\n          project.stageManager.add(node);\n\n          // 创建连线\n          project.nodeConnector.connectConnectableEntity(sourceNode, node, \"\");\n\n          results.push({ text, uuid: node.uuid, success: true });\n        } catch (error) {\n          results.push({\n            text,\n            uuid: \"\",\n            success: false,\n            error: error instanceof Error ? error.message : \"创建节点失败\",\n          });\n        }\n      }\n\n      if (results.some((r) => r.success)) {\n        project.historyManager.recordStep();\n      }\n\n      return { results };\n    },\n  );\n\n  addTool(\n    \"depth_expand_node\",\n    \"深度扩展一个节点，传入一个uuid作为根节点，根据字符串数组在这个节点上扩展出一个链式结构\",\n    z.object({\n      uuid: z.string().describe(\"根节点的UUID\"),\n      texts: z.array(z.string()).describe(\"要添加的链式节点文本数组\"),\n    }),\n    (project, { uuid, texts }) => {\n      const rootNode = project.stageManager.getConnectableEntityByUUID(uuid);\n      if (!rootNode) {\n        return { success: false, error: \"根节点不存在或不是可连接对象\" };\n      }\n\n      const results: Array<{ text: string; uuid: string; success: boolean; error?: string }> = [];\n      let currentNode = rootNode;\n      const horizontalSpacing = 150;\n\n      for (let i = 0; i < texts.length; i++) {\n        const text = texts[i];\n        try {\n          const currentRect = currentNode.collisionBox.getRectangle();\n          const node = new TextNode(project, {\n            text,\n            color: new Color(0, 0, 0, 0), // 透明\n            collisionBox: new CollisionBox([\n              new Rectangle(\n                new Vector(currentRect.location.x + horizontalSpacing, currentRect.location.y),\n                new Vector(100, 50),\n              ),\n            ]),\n            sizeAdjust: \"auto\" as \"auto\" | \"manual\",\n          });\n          project.stageManager.add(node);\n\n          // 创建连线：从前一个节点连接到新节点\n          project.nodeConnector.connectConnectableEntity(currentNode, node, \"\");\n\n          results.push({ text, uuid: node.uuid, success: true });\n          currentNode = node; // 更新当前节点为新建的节点，继续链式扩展\n        } catch (error) {\n          results.push({\n            text,\n            uuid: \"\",\n            success: false,\n            error: error instanceof Error ? error.message : \"创建节点失败\",\n          });\n          break; // 链式结构中一旦失败就停止\n        }\n      }\n\n      if (results.some((r) => r.success)) {\n        project.historyManager.recordStep();\n      }\n\n      return { results };\n    },\n  );\n\n  addTool(\n    \"sort_selected_nodes_by_y\",\n    \"对选中的所有文本节点按照从上到下的顺序重新排列位置（y轴方向）。AI调用前需先用get_selected_nodes获取当前选中节点信息，按y坐标从小到大排列得到current_order，再根据用户期望得到desired_order。\",\n    z.object({\n      current_order: z.array(z.string()).describe(\"当前选中文本节点的文本内容数组，按y坐标从上到下（从小到大）排列\"),\n      desired_order: z\n        .array(z.string())\n        .describe(\"期望排列的文本内容顺序数组，从上到下，必须与current_order包含完全相同的元素\"),\n    }),\n    (project, { current_order, desired_order }) => {\n      // 获取所有选中的TextNode\n      const selectedTextNodes = project.stageManager\n        .getSelectedEntities()\n        .filter((e): e is TextNode => e instanceof TextNode);\n\n      // 检查重复名称\n      const textCounts = new Map<string, number>();\n      for (const node of selectedTextNodes) {\n        textCounts.set(node.text, (textCounts.get(node.text) ?? 0) + 1);\n      }\n      const duplicates = [...textCounts.entries()].filter(([, count]) => count > 1).map(([text]) => text);\n      if (duplicates.length > 0) {\n        return {\n          success: false,\n          error: `排序功能不能有重复名称的文本节点，重复的内容：${duplicates.join(\", \")}`,\n        };\n      }\n\n      // 校验 current_order 与 desired_order 元素一致\n      const currentSet = new Set(current_order);\n      const desiredSet = new Set(desired_order);\n      if (current_order.length !== desired_order.length || [...currentSet].some((t) => !desiredSet.has(t))) {\n        return { success: false, error: \"current_order 与 desired_order 包含的元素不一致\" };\n      }\n\n      // 构建 text -> node 映射\n      const textToNode = new Map<string, TextNode>();\n      for (const node of selectedTextNodes) {\n        textToNode.set(node.text, node);\n      }\n\n      // 校验 current_order 是否覆盖了所有选中节点\n      for (const text of current_order) {\n        if (!textToNode.has(text)) {\n          return { success: false, error: `current_order 中的 \"${text}\" 在选中节点中未找到` };\n        }\n      }\n\n      // 以 current_order 第一个节点（最顶部）的 y 坐标作为起始 y\n      const startNode = textToNode.get(current_order[0])!;\n      let currentY = startNode.collisionBox.getRectangle().location.y;\n\n      // 按 desired_order 顺序从上到下重新排列，保持原 x 坐标\n      for (const text of desired_order) {\n        const node = textToNode.get(text)!;\n        const rect = node.collisionBox.getRectangle();\n        node.collisionBox.updateShapeList([new Rectangle(new Vector(rect.location.x, currentY), rect.size)]);\n        node.forceAdjustSizeByText();\n        // 下一个节点从当前节点底部开始\n        currentY += node.collisionBox.getRectangle().size.y;\n      }\n\n      project.historyManager.recordStep();\n      return { success: true, movedCount: desired_order.length };\n    },\n  );\n\n  addTool(\n    \"sort_selected_nodes_by_x\",\n    \"对选中的所有文本节点按照从左到右的顺序重新排列位置（x轴方向）。AI调用前需先用get_selected_nodes获取当前选中节点信息，按x坐标从小到大排列得到current_order，再根据用户期望得到desired_order。\",\n    z.object({\n      current_order: z.array(z.string()).describe(\"当前选中文本节点的文本内容数组，按x坐标从左到右（从小到大）排列\"),\n      desired_order: z\n        .array(z.string())\n        .describe(\"期望排列的文本内容顺序数组，从左到右，必须与current_order包含完全相同的元素\"),\n    }),\n    (project, { current_order, desired_order }) => {\n      // 获取所有选中的TextNode\n      const selectedTextNodes = project.stageManager\n        .getSelectedEntities()\n        .filter((e): e is TextNode => e instanceof TextNode);\n\n      // 检查重复名称\n      const textCounts = new Map<string, number>();\n      for (const node of selectedTextNodes) {\n        textCounts.set(node.text, (textCounts.get(node.text) ?? 0) + 1);\n      }\n      const duplicates = [...textCounts.entries()].filter(([, count]) => count > 1).map(([text]) => text);\n      if (duplicates.length > 0) {\n        return {\n          success: false,\n          error: `排序功能不能有重复名称的文本节点，重复的内容：${duplicates.join(\", \")}`,\n        };\n      }\n\n      // 校验 current_order 与 desired_order 元素一致\n      const currentSet = new Set(current_order);\n      const desiredSet = new Set(desired_order);\n      if (current_order.length !== desired_order.length || [...currentSet].some((t) => !desiredSet.has(t))) {\n        return { success: false, error: \"current_order 与 desired_order 包含的元素不一致\" };\n      }\n\n      // 构建 text -> node 映射\n      const textToNode = new Map<string, TextNode>();\n      for (const node of selectedTextNodes) {\n        textToNode.set(node.text, node);\n      }\n\n      // 校验 current_order 是否覆盖了所有选中节点\n      for (const text of current_order) {\n        if (!textToNode.has(text)) {\n          return { success: false, error: `current_order 中的 \"${text}\" 在选中节点中未找到` };\n        }\n      }\n\n      // 以 current_order 第一个节点（最左侧）的 x 坐标作为起始 x\n      const startNode = textToNode.get(current_order[0])!;\n      let currentX = startNode.collisionBox.getRectangle().location.x;\n\n      // 按 desired_order 顺序从左到右重新排列，保持原 y 坐标\n      for (const text of desired_order) {\n        const node = textToNode.get(text)!;\n        const rect = node.collisionBox.getRectangle();\n        node.collisionBox.updateShapeList([new Rectangle(new Vector(currentX, rect.location.y), rect.size)]);\n        node.forceAdjustSizeByText();\n        // 下一个节点从当前节点右侧开始\n        currentX += node.collisionBox.getRectangle().size.x;\n      }\n\n      project.historyManager.recordStep();\n      return { success: true, movedCount: desired_order.length };\n    },\n  );\n}\n"
  },
  {
    "path": "app/src/core/service/dataManageService/colorSmartTools.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { Color } from \"@graphif/data-structures\";\n\nexport namespace ColorSmartTools {\n  export function increaseBrightness(project: Project) {\n    const selectedStageObject = project.stageManager.getStageObjects().filter((obj) => obj.isSelected);\n    for (const obj of selectedStageObject) {\n      if (obj instanceof TextNode) {\n        if (obj.color.a === 0) continue;\n        obj.color = new Color(\n          Math.min(255, obj.color.r + 20),\n          Math.min(255, obj.color.b + 20),\n          Math.min(255, obj.color.g + 20),\n          obj.color.a,\n        );\n      }\n    }\n  }\n\n  export function decreaseBrightness(project: Project) {\n    const selectedStageObject = project.stageManager.getStageObjects().filter((obj) => obj.isSelected);\n    for (const obj of selectedStageObject) {\n      if (obj instanceof TextNode) {\n        if (obj.color.a === 0) continue;\n        obj.color = new Color(\n          Math.max(0, obj.color.r - 20),\n          Math.max(0, obj.color.b - 20),\n          Math.max(0, obj.color.g - 20),\n          obj.color.a,\n        );\n      }\n    }\n  }\n\n  export function gradientColor(project: Project) {\n    const selectedStageObject = project.stageManager.getStageObjects().filter((obj) => obj.isSelected);\n    for (const obj of selectedStageObject) {\n      if (obj instanceof TextNode) {\n        if (obj.color.a === 0) continue;\n        const oldColor = obj.color.clone();\n        obj.color = new Color(Math.max(oldColor.a - 20, 0), Math.min(255, oldColor.g + 20), oldColor.b, oldColor.a);\n      }\n    }\n  }\n\n  export function changeColorHueUp(project: Project) {\n    const selectedStageObject = project.stageManager.getStageObjects().filter((obj) => obj.isSelected);\n    for (const obj of selectedStageObject) {\n      if (obj instanceof TextNode) {\n        if (obj.color.a === 0) continue;\n        const oldColor = obj.color.clone();\n        obj.color = oldColor.changeHue(30);\n      }\n    }\n  }\n\n  export function changeColorHueDown(project: Project) {\n    const selectedStageObject = project.stageManager.getStageObjects().filter((obj) => obj.isSelected);\n    for (const obj of selectedStageObject) {\n      if (obj instanceof TextNode) {\n        if (obj.color.a === 0) continue;\n        const oldColor = obj.color.clone();\n        console.log(obj.color);\n        obj.color = oldColor.changeHue(-30);\n        console.log(obj.color);\n      }\n    }\n  }\n\n  export function changeColorHueMajorUp(project: Project) {\n    const selectedStageObject = project.stageManager.getStageObjects().filter((obj) => obj.isSelected);\n    for (const obj of selectedStageObject) {\n      if (obj instanceof TextNode) {\n        if (obj.color.a === 0) continue;\n        const oldColor = obj.color.clone();\n        obj.color = oldColor.changeHue(90);\n      }\n    }\n  }\n\n  export function changeColorHueMajorDown(project: Project) {\n    const selectedStageObject = project.stageManager.getStageObjects().filter((obj) => obj.isSelected);\n    for (const obj of selectedStageObject) {\n      if (obj instanceof TextNode) {\n        if (obj.color.a === 0) continue;\n        const oldColor = obj.color.clone();\n        console.log(obj.color);\n        obj.color = oldColor.changeHue(-90);\n        console.log(obj.color);\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataManageService/connectNodeSmartTools.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { LineEdge } from \"@/core/stage/stageObject/association/LineEdge\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { toast } from \"sonner\";\n\n/**\n * 和连接相关的巧妙操作\n */\nexport namespace ConnectNodeSmartTools {\n  /**\n   * 向下连接\n   * @param project\n   * @returns\n   */\n  export function connectDown(project: Project) {\n    const selectedNodes = project.stageManager\n      .getSelectedEntities()\n      .filter((entity) => entity instanceof ConnectableEntity);\n    if (selectedNodes.length <= 1) return;\n    selectedNodes.sort((a, b) => a.collisionBox.getRectangle().location.y - b.collisionBox.getRectangle().location.y);\n    for (let i = 0; i < selectedNodes.length - 1; i++) {\n      const fromNode = selectedNodes[i];\n      const toNode = selectedNodes[i + 1];\n      if (fromNode === toNode) continue;\n      project.stageManager.connectEntity(fromNode, toNode, false);\n    }\n  }\n\n  // 向右连接\n  export function connectRight(project: Project) {\n    const selectedNodes = project.stageManager\n      .getSelectedEntities()\n      .filter((entity) => entity instanceof ConnectableEntity);\n    if (selectedNodes.length <= 1) return;\n    selectedNodes.sort((a, b) => a.collisionBox.getRectangle().location.x - b.collisionBox.getRectangle().location.x);\n    for (let i = 0; i < selectedNodes.length - 1; i++) {\n      const fromNode = selectedNodes[i];\n      const toNode = selectedNodes[i + 1];\n      if (fromNode === toNode) continue;\n      project.stageManager.connectEntity(fromNode, toNode, false);\n    }\n  }\n\n  // 全连接\n  export function connectAll(project: Project) {\n    const selectedNodes = project.stageManager.getSelectedEntities();\n    for (let i = 0; i < selectedNodes.length; i++) {\n      for (let j = 0; j < selectedNodes.length; j++) {\n        const fromNode = selectedNodes[i];\n        const toNode = selectedNodes[j];\n        if (fromNode === toNode) continue;\n        if (fromNode instanceof ConnectableEntity && toNode instanceof ConnectableEntity) {\n          project.stageManager.connectEntity(fromNode, toNode, false);\n        }\n      }\n    }\n  }\n\n  /**\n   * 把节点叠在父节点下游的一堆连线上，使用这个方法，就能把节点给插入这个地方\n   * @param project\n   */\n  export function insertNodeToTree(project: Project) {\n    const selectedEntities = project.stageManager\n      .getSelectedEntities()\n      .filter((node) => node instanceof ConnectableEntity);\n    if (selectedEntities.length !== 1) {\n      toast.error(\"树形接入时，选中的节点数量必须为1\");\n      return;\n    }\n    const selectedNode = selectedEntities[0];\n    // 遍历所有LineEdge，检测碰撞\n    const collideEdges: LineEdge[] = [];\n    for (const lineEdge of project.stageManager.getLineEdges()) {\n      if (lineEdge.collisionBox.isIntersectsWithRectangle(selectedNode.collisionBox.getRectangle())) {\n        collideEdges.push(lineEdge);\n      }\n    }\n    // 再检测一下，收集到的所有LineEdge是否是同一个\n    const sourceUUIDList = collideEdges.map((edge) => edge.source.uuid);\n    if (new Set(sourceUUIDList).size === 1) {\n      // const sourceNode = collideEdges[0].source;\n      // 检测是否有多个连线从同一个源节点出发\n      const sourceNode = collideEdges[0].source;\n      const isMultiEdgesFromSameSource = collideEdges.every((edge) => edge.source.uuid === sourceNode.uuid);\n\n      // 保存原连线的属性\n      const originalEdges = collideEdges.map((edge) => ({\n        targetNode: edge.target,\n        sourceRectangleRate: edge.sourceRectangleRate,\n        targetRectangleRate: edge.targetRectangleRate,\n        text: edge.text,\n        color: edge.color,\n      }));\n      // 删除所有已有的连线\n      collideEdges.forEach((edge) => project.stageManager.deleteAssociation(edge));\n\n      if (isMultiEdgesFromSameSource) {\n        // 从同一个源节点出发的多条连线，只创建一条源节点到新节点的连线\n        project.stageManager.add(\n          new LineEdge(project, {\n            associationList: [sourceNode, selectedNode],\n            text: originalEdges[0].text, // 使用第一条连线的文本\n            sourceRectangleRate: originalEdges[0].sourceRectangleRate.clone(), // 继承源节点的端点格式\n            targetRectangleRate: originalEdges[0].targetRectangleRate.clone(), // 左端点接收\n            color: originalEdges[0].color.clone(),\n          }),\n        );\n\n        // 创建从新节点到各个目标节点的连线\n        originalEdges.forEach((originalEdge) => {\n          project.stageManager.add(\n            new LineEdge(project, {\n              associationList: [selectedNode, originalEdge.targetNode],\n              text: originalEdge.text,\n              sourceRectangleRate: originalEdge.sourceRectangleRate.clone(),\n              targetRectangleRate: originalEdge.targetRectangleRate.clone(), // 继承原连线的target端点格式\n              color: originalEdge.color.clone(),\n            }),\n          );\n        });\n      } else {\n        // 处理不同源节点的情况\n        originalEdges.forEach((originalEdge) => {\n          // source -> selected：保持原连线的source端点格式，target端使用左端点接收\n          project.stageManager.add(\n            new LineEdge(project, {\n              associationList: [sourceNode, selectedNode],\n              text: originalEdge.text,\n              sourceRectangleRate: originalEdge.sourceRectangleRate.clone(),\n              targetRectangleRate: originalEdge.targetRectangleRate.clone(),\n              color: originalEdge.color.clone(),\n            }),\n          );\n          // selected -> target：source端使用右端点发出，保持原连线的target端点格式\n          project.stageManager.add(\n            new LineEdge(project, {\n              associationList: [selectedNode, originalEdge.targetNode],\n              text: originalEdge.text,\n              sourceRectangleRate: originalEdge.sourceRectangleRate.clone(),\n              targetRectangleRate: originalEdge.targetRectangleRate.clone(),\n              color: originalEdge.color.clone(),\n            }),\n          );\n        });\n      }\n\n      project.historyManager.recordStep();\n    } else {\n      toast.error(\"树形接入时，这个选中的节点没有与任何连线相碰，或者所有相碰的连线源头不唯一\");\n    }\n  }\n\n  /**\n   * 将选中的节点从树中移除，并重新连接其前后节点\n   * @param project\n   */\n  export function removeNodeFromTree(project: Project) {\n    const selectedEntities = project.stageManager\n      .getSelectedEntities()\n      .filter((node) => node instanceof ConnectableEntity);\n    if (selectedEntities.length !== 1) {\n      toast.error(\"树形摘除时，选中的节点数量必须为1\");\n      return;\n    }\n    const selectedNode = selectedEntities[0];\n\n    // 找到选中节点的所有入边和出边\n    const inEdges: LineEdge[] = project.stageManager.getLineEdges().filter((edge) => edge.target === selectedNode);\n    const outEdges: LineEdge[] = project.stageManager.getLineEdges().filter((edge) => edge.source === selectedNode);\n\n    if (inEdges.length === 0) {\n      toast.error(\"树形摘除时，选中的节点没有入边\");\n      return;\n    }\n\n    // 保存入边的源节点和出边的目标节点及属性\n    const sourceNodes = inEdges.map((edge) => ({\n      node: edge.source,\n      sourceRectangleRate: edge.sourceRectangleRate,\n      text: edge.text,\n      color: edge.color,\n    }));\n    const targetNodes = outEdges.map((edge) => ({\n      node: edge.target,\n      targetRectangleRate: edge.targetRectangleRate,\n      text: edge.text,\n      color: edge.color,\n    }));\n\n    // 删除所有入边和出边\n    [...inEdges, ...outEdges].forEach((edge) => project.stageManager.deleteAssociation(edge));\n\n    // 将入边的源节点直接连接到出边的目标节点\n    sourceNodes.forEach((source) => {\n      targetNodes.forEach((target) => {\n        project.stageManager.add(\n          new LineEdge(project, {\n            associationList: [source.node, target.node],\n            text: source.text || target.text,\n            sourceRectangleRate: source.sourceRectangleRate,\n            targetRectangleRate: target.targetRectangleRate,\n            color: source.color || target.color,\n          }),\n        );\n      });\n    });\n\n    // 将选中的节点从连线中跳出来，向上移动，移动距离等于节点高度\n    const rectangle = selectedNode.collisionBox.getRectangle();\n    const originalLocation = rectangle.location.clone();\n    // 计算新位置：原位置向上移动节点高度，使新位置的底部边缘对齐原位置的顶部边缘\n    const newLocation = new Vector(originalLocation.x, originalLocation.y - rectangle.size.y);\n    selectedNode.moveTo(newLocation);\n    project.historyManager.recordStep();\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataManageService/contentSearchEngine/contentSearchEngine.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { RectangleNoteEffect } from \"@/core/service/feedbackService/effectEngine/concrete/RectangleNoteEffect\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { StageObject } from \"@/core/stage/stageObject/abstract/StageObject\";\nimport { Edge } from \"@/core/stage/stageObject/association/Edge\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { UrlNode } from \"@/core/stage/stageObject/entity/UrlNode\";\nimport { Color, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { toast } from \"sonner\";\n\n/**\n * 搜索范围枚举\n */\nexport enum SearchScope {\n  /**\n   * 搜索整个舞台\n   */\n  ALL = \"all\",\n  /**\n   * 只搜索选中的内容\n   */\n  SELECTED = \"selected\",\n  /**\n   * 搜索选中内容的外接矩形范围内的所有实体\n   */\n  SELECTED_BOUNDS = \"selectedBounds\",\n}\n\n@service(\"contentSearch\")\nexport class ContentSearch {\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 搜索结果\n   */\n  public searchResultNodes: StageObject[] = [];\n\n  /**\n   * 是否忽略大小写\n   */\n  public isCaseSensitive = false;\n\n  /**\n   * 搜索范围\n   */\n  public searchScope = SearchScope.ALL;\n\n  /**\n   * 搜索结果的索引\n   */\n  public currentSearchResultIndex = 0;\n\n  /**\n   * 抽取一个舞台对象的被搜索文本\n   * @param stageObject\n   * @returns\n   */\n  public getStageObjectText(stageObject: StageObject): string {\n    if (stageObject instanceof TextNode) {\n      return stageObject.text + \"　\" + stageObject.detailsManager.getBeSearchingText();\n    } else if (stageObject instanceof Section) {\n      return stageObject.text + \"　\" + stageObject.detailsManager.getBeSearchingText();\n    } else if (stageObject instanceof UrlNode) {\n      return stageObject.title + \"　\" + stageObject.detailsManager.getBeSearchingText() + \"　\" + stageObject.url;\n    }\n    // 任何实体上都可能会写details\n    if (stageObject instanceof Entity) {\n      // 不对，这样还是返回\"[Object object]\" 字符串，但仅仅只是能防止一下报错\n      return stageObject.detailsManager.getBeSearchingText();\n    }\n    // 线上的字\n    if (stageObject instanceof Edge) {\n      return stageObject.text;\n    }\n    return \"\";\n  }\n\n  /**\n   * 获取选中对象的外接矩形\n   * @returns 外接矩形，如果没有选中对象则返回null\n   */\n  private getSelectedObjectsBounds(): Rectangle | null {\n    const selectedObjects = this.project.stageManager.getStageObjects().filter((obj) => obj.isSelected);\n    if (selectedObjects.length === 0) {\n      return null;\n    }\n\n    let minX = Number.MAX_VALUE;\n    let minY = Number.MAX_VALUE;\n    let maxX = Number.MIN_VALUE;\n    let maxY = Number.MIN_VALUE;\n\n    for (const obj of selectedObjects) {\n      const rect = obj.collisionBox.getRectangle();\n      minX = Math.min(minX, rect.location.x);\n      minY = Math.min(minY, rect.location.y);\n      maxX = Math.max(maxX, rect.location.x + rect.size.x);\n      maxY = Math.max(maxY, rect.location.y + rect.size.y);\n    }\n\n    return new Rectangle(new Vector(minX, minY), new Vector(maxX - minX, maxY - minY));\n  }\n\n  /**\n   * 判断对象是否在指定范围内\n   * @param obj 要判断的对象\n   * @param bounds 范围矩形\n   * @returns 是否在范围内\n   */\n  private isObjectInBounds(obj: StageObject, bounds: Rectangle): boolean {\n    const objRect = obj.collisionBox.getRectangle();\n    return (\n      objRect.location.x >= bounds.location.x &&\n      objRect.location.y >= bounds.location.y &&\n      objRect.location.x + objRect.size.x <= bounds.location.x + bounds.size.x &&\n      objRect.location.y + objRect.size.y <= bounds.location.y + bounds.size.y\n    );\n  }\n\n  public startSearch(searchString: string, autoFocus = true): boolean {\n    // 开始搜索\n    this.searchResultNodes = [];\n    if (searchString === \"\") {\n      return false;\n    }\n\n    // 获取要搜索的对象列表\n    let objectsToSearch: StageObject[] = [];\n\n    switch (this.searchScope) {\n      case SearchScope.SELECTED:\n        // 只搜索选中的对象\n        objectsToSearch = this.project.stageManager.getStageObjects().filter((obj) => obj.isSelected);\n        break;\n\n      case SearchScope.SELECTED_BOUNDS: {\n        // 搜索选中对象外接矩形范围内的所有对象\n        const bounds = this.getSelectedObjectsBounds();\n        if (bounds) {\n          objectsToSearch = this.project.stageManager\n            .getStageObjects()\n            .filter((obj) => this.isObjectInBounds(obj, bounds));\n        } else {\n          // 如果没有选中对象，搜索所有对象\n          objectsToSearch = this.project.stageManager.getStageObjects();\n        }\n        break;\n      }\n\n      case SearchScope.ALL:\n      default:\n        // 搜索所有对象\n        objectsToSearch = this.project.stageManager.getStageObjects();\n        break;\n    }\n\n    // 执行搜索\n    for (const node of objectsToSearch) {\n      const text = this.getStageObjectText(node);\n      if (this.isCaseSensitive) {\n        if (text.includes(searchString)) {\n          this.searchResultNodes.push(node);\n        }\n      } else {\n        if (text.toLowerCase().includes(searchString.toLowerCase())) {\n          this.searchResultNodes.push(node);\n        }\n      }\n    }\n    this.currentSearchResultIndex = 0;\n\n    if (this.searchResultNodes.length > 0) {\n      if (autoFocus) {\n        // 选择第一个搜索结果节点\n        const currentNode = this.searchResultNodes[this.currentSearchResultIndex];\n        // currentNode.isSelected = true;\n        this.project.effects.addEffect(\n          new RectangleNoteEffect(new ProgressNumber(0, 50), currentNode.collisionBox.getRectangle(), Color.Green),\n        );\n        // 摄像机对准现在的节点\n        this.project.camera.location = currentNode.collisionBox.getRectangle().center.clone();\n      }\n\n      return true;\n    }\n    return false;\n  }\n\n  /**\n   * 切换下一个\n   */\n  public nextSearchResult() {\n    if (this.currentSearchResultIndex < this.searchResultNodes.length - 1) {\n      this.currentSearchResultIndex++;\n    } else {\n      toast(\"已经到底了\");\n      return;\n    }\n    // 取消选择所有节点\n    for (const node of this.project.stageManager.getTextNodes()) {\n      node.isSelected = false;\n    }\n    // 选择当前搜索结果节点\n    const currentNode = this.searchResultNodes[this.currentSearchResultIndex];\n    if (currentNode) {\n      this.project.effects.addEffect(\n        new RectangleNoteEffect(new ProgressNumber(0, 50), currentNode.collisionBox.getRectangle(), Color.Green),\n      );\n      // 摄像机对准现在的节点\n      this.project.camera.location = currentNode.collisionBox.getRectangle().center.clone();\n    }\n  }\n\n  /**\n   * 切换上一个\n   */\n  public previousSearchResult() {\n    if (this.currentSearchResultIndex > 0) {\n      this.currentSearchResultIndex--;\n    } else {\n      toast(\"已经到头了\");\n    }\n    // 取消选择所有节点\n    for (const node of this.project.stageManager.getTextNodes()) {\n      node.isSelected = false;\n    }\n    // 选择当前搜索结果节点\n    const currentNode = this.searchResultNodes[this.currentSearchResultIndex];\n    if (currentNode) {\n      this.project.effects.addEffect(\n        new RectangleNoteEffect(new ProgressNumber(0, 50), currentNode.collisionBox.getRectangle(), Color.Green),\n      );\n      // 摄像机对准现在的节点\n      this.project.camera.location = currentNode.collisionBox.getRectangle().center.clone();\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataManageService/copyEngine/VirtualClipboard.tsx",
    "content": "/**\n * 独立于系统剪贴板之外的虚拟剪贴板\n * 因为tauri的剪贴板插件用的是arboard\n * 而arboard不能写入自定义mime type的数据\n * 所以复制舞台对象要用虚拟剪贴板\n */\nexport namespace VirtualClipboard {\n  let data: any = null;\n\n  export function copy(newData: any) {\n    data = newData;\n  }\n  export function paste() {\n    return data;\n  }\n  export function clear() {\n    data = null;\n  }\n  export function hasData() {\n    return data !== null;\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataManageService/copyEngine/copyEngine.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { isMac } from \"@/utils/platform\";\nimport { ConnectableAssociation } from \"@/core/stage/stageObject/abstract/Association\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { StageObject } from \"@/core/stage/stageObject/abstract/StageObject\";\nimport { Edge } from \"@/core/stage/stageObject/association/Edge\";\nimport { MultiTargetUndirectedEdge } from \"@/core/stage/stageObject/association/MutiTargetUndirectedEdge\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { ImageNode } from \"@/core/stage/stageObject/entity/ImageNode\";\nimport { SvgNode } from \"@/core/stage/stageObject/entity/SvgNode\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { Serialized } from \"@/types/node\";\nimport { Color, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { deserialize, serialize } from \"@graphif/serializer\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { Image as TauriImage } from \"@tauri-apps/api/image\";\nimport { readText, writeImage, writeText } from \"@tauri-apps/plugin-clipboard-manager\";\nimport { toast } from \"sonner\";\nimport { v4 } from \"uuid\";\nimport { RectangleNoteEffect } from \"../../feedbackService/effectEngine/concrete/RectangleNoteEffect\";\nimport { RectangleNoteReversedEffect } from \"../../feedbackService/effectEngine/concrete/RectangleNoteReversedEffect\";\nimport { VirtualClipboard } from \"./VirtualClipboard\";\nimport { CopyEngineImage } from \"./copyEngineImage\";\nimport { CopyEngineText } from \"./copyEngineText\";\nimport { CopyEngineUtils } from \"./copyEngineUtils\";\n\n/**\n * 专门用来管理节点复制的引擎\n */\n@service(\"copyEngine\")\nexport class CopyEngine {\n  private copyEngineImage: CopyEngineImage;\n  private copyEngineText: CopyEngineText;\n\n  constructor(private readonly project: Project) {\n    this.copyEngineImage = new CopyEngineImage(project);\n    this.copyEngineText = new CopyEngineText(project);\n  }\n\n  /**\n   * 用户按下了ctrl+c，\n   * 将当前选中的节点复制到虚拟粘贴板\n   * 也要将选中的部分复制到系统粘贴板\n   */\n  async copy() {\n    // 获取所有选中的实体，不能包含关系\n    const selectedEntities = this.project.stageManager.getSelectedEntities();\n    if (selectedEntities.length === 0) {\n      // 如果没有选中东西，就是清空虚拟粘贴板\n      VirtualClipboard.clear();\n      toast.info(\"当前没有选中任何实体，已清空了虚拟剪贴板\");\n      return;\n    }\n    const copiedStageObjects = CopyEngineUtils.getAllStageObjectFromEntities(this.project, selectedEntities);\n\n    // 收集所有需要复制的附件（ImageNode 和 SvgNode 的附件）\n    const attachmentMap = await CopyEngineUtils.collectAttachmentFromStageObjects(this.project, copiedStageObjects);\n\n    // 深拷贝一下数据，只有在粘贴的时候才刷新uuid\n    const serializedCopiedStageObjects = serialize(copiedStageObjects);\n    console.log(serializedCopiedStageObjects);\n\n    // 将舞台对象和附件一起存储到虚拟粘贴板\n    VirtualClipboard.copy({\n      stageObjects: serialize(serializedCopiedStageObjects),\n      attachments: Object.fromEntries(attachmentMap),\n    });\n\n    const rect = Rectangle.getBoundingRectangle(selectedEntities.map((it) => it.collisionBox.getRectangle()));\n    this.project.effects.addEffect(new RectangleNoteReversedEffect(new ProgressNumber(0, 100), rect, Color.Green));\n\n    // 更新系统剪贴板\n    // 如果只有一张图片就直接复制图片\n    if (selectedEntities.length === 1 && selectedEntities[0] instanceof ImageNode) {\n      const imageNode = selectedEntities[0] as ImageNode;\n      const blob = this.project.attachments.get(imageNode.attachmentId);\n      if (blob) {\n        blob.arrayBuffer().then(TauriImage.fromBytes).then(writeImage);\n        toast.success(\"已将选中的图片复制到系统剪贴板\");\n      }\n    } else {\n      // 否则复制全部文本节点，用两个换行分割\n      const textNodes = selectedEntities.filter((it) => it instanceof TextNode) as TextNode[];\n      if (textNodes.length > 0) {\n        const text = textNodes.map((it) => it.text).join(\"\\n\\n\");\n        writeText(text);\n        toast.success(\"已将选中的文本复制到系统剪贴板\");\n      }\n    }\n    // 最后清空所有选择\n    this.project.stageManager.clearSelectAll();\n  }\n\n  /**\n   * 用户按下了ctrl+v，将粘贴板数据粘贴到画布上\n   */\n  paste() {\n    // 如果有虚拟粘贴板数据，则优先粘贴虚拟粘贴板上的东西\n    if (VirtualClipboard.hasData()) {\n      this.virtualClipboardPaste();\n    } else {\n      this.readSystemClipboardAndPaste();\n    }\n  }\n\n  virtualClipboardPaste() {\n    // 获取虚拟粘贴板上数据的外接矩形\n    const clipboardData = VirtualClipboard.paste();\n\n    // 兼容旧格式：如果直接是序列化数据，则没有附件\n    let pastDataSerialized: any;\n    let attachmentsData: Record<string, { data: ArrayBuffer; type: string }> | undefined;\n\n    if (clipboardData && typeof clipboardData === \"object\" && \"stageObjects\" in clipboardData) {\n      // 新格式：包含附件数据\n      pastDataSerialized = clipboardData.stageObjects;\n      attachmentsData = clipboardData.attachments;\n    } else {\n      // 旧格式：只有序列化数据\n      pastDataSerialized = clipboardData;\n    }\n\n    const pasteData: StageObject[] = deserialize(pastDataSerialized, this.project);\n\n    // 处理附件：将附件添加到新项目中，并建立 oldAttachmentId -> newAttachmentId 的映射\n    const attachmentIdMap = new Map<string, string>();\n    if (attachmentsData) {\n      for (const [oldAttachmentId, attachmentInfo] of Object.entries(attachmentsData)) {\n        // 将 ArrayBuffer 转换回 Blob\n        const blob = new Blob([attachmentInfo.data], { type: attachmentInfo.type });\n        // 添加到新项目并生成新的 UUID\n        const newAttachmentId = this.project.addAttachment(blob);\n        attachmentIdMap.set(oldAttachmentId, newAttachmentId);\n      }\n    }\n\n    // 粘贴的时候刷新UUID\n    for (const stageObject of pasteData) {\n      if (stageObject instanceof Entity) {\n        // @ts-expect-error 没办法，只能这么做了，否则会出现移动速度2倍甚至n倍的bug\n        stageObject.project = this.project;\n        const newUUID = v4();\n        const oldUUID = stageObject.uuid;\n        stageObject.uuid = newUUID;\n\n        // 更新附件ID（如果是 ImageNode 或 SvgNode）\n        if (stageObject instanceof ImageNode) {\n          const oldAttachmentId = stageObject.attachmentId;\n          if (oldAttachmentId && attachmentIdMap.has(oldAttachmentId)) {\n            const newAttachmentId = attachmentIdMap.get(oldAttachmentId)!;\n            stageObject.attachmentId = newAttachmentId;\n            // 重新加载图片附件\n            const blob = this.project.attachments.get(newAttachmentId);\n            if (blob) {\n              createImageBitmap(blob).then((bitmap) => {\n                stageObject.bitmap = bitmap;\n                stageObject.state = \"success\";\n                // 设置碰撞箱\n                stageObject.scaleUpdate(0);\n              });\n            }\n          }\n        } else if (stageObject instanceof SvgNode) {\n          const oldAttachmentId = stageObject.attachmentId;\n          if (oldAttachmentId && attachmentIdMap.has(oldAttachmentId)) {\n            const newAttachmentId = attachmentIdMap.get(oldAttachmentId)!;\n            stageObject.attachmentId = newAttachmentId;\n            // 重新加载 SVG 附件\n            const blob = this.project.attachments.get(newAttachmentId);\n            if (blob) {\n              const url = URL.createObjectURL(blob);\n              stageObject.image = new Image();\n              stageObject.image.src = url;\n              stageObject.image.onload = () => {\n                stageObject.originalSize = new Vector(stageObject.image.naturalWidth, stageObject.image.naturalHeight);\n                stageObject.collisionBox = new CollisionBox([\n                  new Rectangle(\n                    stageObject.collisionBox.getRectangle().location,\n                    stageObject.originalSize.multiply(stageObject.scale),\n                  ),\n                ]);\n              };\n            }\n          }\n        }\n\n        // 开始遍历所有关联，更新uuid\n        for (const stageObject2 of pasteData) {\n          if (stageObject2 instanceof ConnectableAssociation) {\n            // 更新这个关系对象本身的uuid,因为目前还没有关系的关系，所以可以直接更新。\n            stageObject2.uuid = v4();\n\n            if (stageObject2 instanceof Edge) {\n              if (stageObject2.source.uuid === oldUUID) {\n                stageObject2.source.uuid = newUUID;\n              }\n              if (stageObject2.target.uuid === oldUUID) {\n                stageObject2.target.uuid = newUUID;\n              }\n            } else if (stageObject2 instanceof MultiTargetUndirectedEdge) {\n              for (const associationListItem of stageObject2.associationList) {\n                if (associationListItem.uuid === oldUUID) {\n                  associationListItem.uuid = newUUID;\n                }\n              }\n            }\n          }\n        }\n      }\n    }\n    // 将pasteData设为选中状态\n    const shouldSelectedEntities = this.project.sectionMethods.shallowerNotSectionEntities(\n      pasteData.filter((it) => it instanceof Entity) as Entity[],\n    );\n    shouldSelectedEntities.forEach((it) => (it.isSelected = true));\n    // 粘贴到舞台上（必须先粘贴到舞台上，再运行选择标准化、移动函数）\n    this.project.stage.push(...pasteData);\n    // 选中标准化\n    this.project.controllerUtils.selectedEntityNormalizing();\n\n    // 将所有选中的实体，往右下角移动一点\n    const rect = Rectangle.getBoundingRectangle(pasteData.map((it: StageObject) => it.collisionBox.getRectangle()));\n    this.project.entityMoveManager.moveSelectedEntities(new Vector(0, rect.height));\n    // 加特效\n    const effectRect = Rectangle.getBoundingRectangle(\n      shouldSelectedEntities.map((it) => it.collisionBox.getRectangle()),\n    );\n    this.project.effects.addEffect(new RectangleNoteEffect(new ProgressNumber(0, 50), effectRect, Color.Green));\n    toast.success(\n      <div>\n        <h2 className=\"text-lg\">粘贴成功</h2>\n        <p className=\"text-xs\">粘贴位置在{effectRect.leftTop.toString()}，如果您是跨文档粘贴，请注意调整位置</p>\n        <p className=\"text-xs\">已帮您自动选中该内容，按下默认快捷键 `F` 即可快速聚焦到该内容</p>\n      </div>,\n    );\n\n    // 清空虚拟粘贴板\n    VirtualClipboard.clear(); // TODO: 先暂时清空吧。连续两次ctrl + v会导致重叠问题，待排查\n  }\n\n  /**\n   * 剪切\n   * 复制，然后删除选中的舞台对象\n   */\n  async cut() {\n    await this.copy();\n    this.project.stageManager.deleteSelectedStageObjects();\n  }\n\n  async readSystemClipboardAndPaste() {\n    if (isMac) {\n      // macOS 专用：优先使用 Web API 读取文本剪贴板（主线程安全），\n      // 避免 Tauri clipboard plugin 在 tokio worker 线程读取 NSPasteboard 时\n      // 与 WKWebView 主线程的并发访问导致 SIGSEGV 崩溃。\n      // 参见: https://github.com/tauri-apps/plugins-workspace/issues/3205\n      try {\n        const text = await navigator.clipboard.readText();\n        if (text && text.length > 0) {\n          this.copyEngineText.copyEnginePastePlainText(text);\n        } else {\n          // 文本为空则尝试图片\n          await this.copyEngineImage.processClipboardImage();\n        }\n        setTimeout(() => {\n          // 粘贴完成后清除按键状态，防止 Web API 弹出的 paste 按钮导致卡键\n          this.project.controller.pressingKeySet.clear();\n        });\n      } catch (err) {\n        // Web API 失败（权限拒绝等），清除按键状态后 fallback 到图片粘贴\n        this.project.controller.pressingKeySet.clear();\n        console.warn(\"macOS Web API readText 失败，尝试粘贴图片\", err);\n        try {\n          await this.copyEngineImage.processClipboardImage();\n        } catch (err) {\n          console.error(\"粘贴图片时发生错误:\", err);\n        }\n      }\n    } else {\n      // Linux / Windows：直接使用 Tauri 插件，没有 NSPasteboard 线程安全问题\n      try {\n        const text = await readText();\n        this.copyEngineText.copyEnginePastePlainText(text);\n      } catch (err) {\n        console.warn(\"文本剪贴板是空的\", err);\n        try {\n          await this.copyEngineImage.processClipboardImage();\n        } catch (err) {\n          console.error(\"粘贴图片时发生错误:\", err);\n          console.error(\"错误详情:\", {\n            name: err instanceof Error ? err.name : \"Unknown\",\n            message: err instanceof Error ? err.message : String(err),\n            stack: err instanceof Error ? err.stack : \"No stack\",\n          });\n        }\n      }\n    }\n  }\n}\n\nexport function getRectangleFromSerializedEntities(serializedEntities: Serialized.Entity[]): Rectangle {\n  const rectangles = [];\n  for (const node of serializedEntities) {\n    if (\n      Serialized.isTextNode(node) ||\n      Serialized.isSection(node) ||\n      Serialized.isImageNode(node) ||\n      Serialized.isUrlNode(node) ||\n      Serialized.isPortalNode(node) ||\n      Serialized.isSvgNode(node)\n    ) {\n      // 比较常规的矩形\n      rectangles.push(new Rectangle(new Vector(...node.location), new Vector(...node.size)));\n    }\n    if (node.type === \"core:connect_point\") {\n      rectangles.push(new Rectangle(new Vector(...node.location), new Vector(1, 1)));\n    } else if (node.type === \"core:pen_stroke\") {\n      // rectangles.push(new Rectangle(new Vector(...node.location), new Vector(1, 1)));\n      // TODO: 画笔粘贴板矩形暂时不考虑\n    }\n  }\n  return Rectangle.getBoundingRectangle(rectangles);\n}\n"
  },
  {
    "path": "app/src/core/service/dataManageService/copyEngine/copyEngineImage.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { isWindows } from \"@/utils/platform\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { ImageNode } from \"@/core/stage/stageObject/entity/ImageNode\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { readImage } from \"@tauri-apps/plugin-clipboard-manager\";\nimport { MouseLocation } from \"../../controlService/MouseLocation\";\nimport { toast } from \"sonner\";\nimport { Telemetry } from \"../../Telemetry\";\n\n/**\n * 图片复制粘贴引擎\n */\nexport class CopyEngineImage {\n  constructor(private project: Project) {}\n\n  public async processClipboardImage() {\n    try {\n      await this.processImageStandard();\n    } catch (error) {\n      console.error(\"标准图片处理失败，尝试Windows兼容模式:\", error);\n      if (isWindows) {\n        try {\n          await this.processImageWindowsCompat();\n        } catch (error) {\n          console.error(\"Windows兼容模式粘贴图片处理失败:\", error);\n          toast.error(\"Windows兼容模式粘贴图片处理失败，请关闭文件再打开重试、或重启软件\");\n          Telemetry.event(\"粘贴图片处理失败（Windows兼容模式）\", String(error));\n        }\n      } else {\n        toast.error(\"粘贴图片失败，请关闭文件再打开重试、或重启软件\");\n        Telemetry.event(\"粘贴图片处理失败（非Windows）\", String(error));\n      }\n    }\n  }\n\n  private async processImageStandard() {\n    // https://github.com/HuLaSpark/HuLa/blob/fe37c246777cde3325555ed2ba2fcf860888a4a8/src/utils/ImageUtils.ts#L121\n    const image = await readImage();\n    console.log(\"读取到剪贴板图片:\", image);\n\n    const imageData = await image.rgba();\n    const { width, height } = await image.size();\n\n    console.log(\"图片信息:\", { width, height, dataLength: imageData.length });\n\n    if (width === 0 || height === 0) {\n      console.warn(\"图片尺寸为0，无法处理\");\n      return;\n    }\n\n    // 调试：分析原始数据\n    this.debugImageData(imageData);\n\n    // 创建canvas并处理图片数据\n    const canvas = document.createElement(\"canvas\");\n    canvas.width = width;\n    canvas.height = height;\n    const ctx = canvas.getContext(\"2d\", { willReadFrequently: true })!;\n\n    // 处理图片数据格式\n    let processedData = this.ensureImageDataFormat(imageData, width, height);\n\n    // 处理图片数据格式，修复颜色通道顺序\n    processedData = this.fixImageData(processedData);\n\n    console.log(\"处理后的数据长度:\", processedData.length);\n\n    // 检查数据是否为空\n    const isEmpty = processedData.every((byte) => byte === 0);\n    if (isEmpty) {\n      console.warn(\"图片数据为空，可能是透明图片或数据格式问题\");\n    }\n\n    // 创建ImageData并绘制到canvas\n    const canvasImageData = new ImageData(Uint8ClampedArray.from(processedData), width, height);\n    ctx.putImageData(canvasImageData, 0, 0);\n\n    // 验证canvas内容\n    this.validateCanvasContent(ctx, width, height);\n\n    // 创建blob\n    const blob = await this.createBlobFromCanvas(canvas);\n\n    this.copyEnginePasteImage(blob);\n  }\n\n  async copyEnginePasteImage(item: Blob) {\n    const attachmentId = this.project.addAttachment(item);\n\n    const imageNode = new ImageNode(this.project, {\n      attachmentId,\n      collisionBox: new CollisionBox([\n        new Rectangle(this.project.renderer.transformView2World(MouseLocation.vector()), new Vector(300, 150)),\n      ]),\n    });\n    this.project.stageManager.add(imageNode);\n    // 图片的更新碰撞箱是 异步的，先不移动了。\n    // imageNode.move(\n    //   new Vector(-imageNode.collisionBox.getRectangle().width / 2, -imageNode.collisionBox.getRectangle().height / 2),\n    // );\n  }\n\n  private debugImageData(imageData: any): void {\n    if (!(imageData instanceof Uint8Array) && !(imageData instanceof Uint8ClampedArray)) {\n      console.warn(\"未知数据类型:\", imageData.constructor.name);\n      return;\n    }\n\n    const data = new Uint8Array(imageData);\n\n    // 检查数据分布\n    let minR = 255,\n      maxR = 0,\n      minG = 255,\n      maxG = 0,\n      minB = 255,\n      maxB = 0,\n      minA = 255,\n      maxA = 0;\n    let nonZeroPixels = 0;\n    let fullyTransparentPixels = 0;\n    let fullyOpaquePixels = 0;\n\n    for (let i = 0; i < data.length; i += 4) {\n      const r = data[i];\n      const g = data[i + 1];\n      const b = data[i + 2];\n      const a = data[i + 3];\n\n      minR = Math.min(minR, r);\n      maxR = Math.max(maxR, r);\n      minG = Math.min(minG, g);\n      maxG = Math.max(maxG, g);\n      minB = Math.min(minB, b);\n      maxB = Math.max(maxB, b);\n      minA = Math.min(minA, a);\n      maxA = Math.max(maxA, a);\n\n      if (r > 0 || g > 0 || b > 0 || a > 0) {\n        nonZeroPixels++;\n      }\n\n      if (a === 0) {\n        fullyTransparentPixels++;\n      } else if (a === 255) {\n        fullyOpaquePixels++;\n      }\n    }\n\n    console.log(\"图片数据详细分析:\", {\n      totalPixels: data.length / 4,\n      nonZeroPixels,\n      fullyTransparentPixels,\n      fullyOpaquePixels,\n      colorRange: {\n        red: { min: minR, max: maxR },\n        green: { min: minG, max: maxG },\n        blue: { min: minB, max: maxB },\n        alpha: { min: minA, max: maxA },\n      },\n      hasVisibleContent: nonZeroPixels > 0 && maxA > 0,\n    });\n\n    // 保存前几个像素作为样本\n    const samplePixels = [];\n    for (let i = 0; i < Math.min(10, data.length / 4); i++) {\n      const offset = i * 4;\n      samplePixels.push({\n        r: data[offset],\n        g: data[offset + 1],\n        b: data[offset + 2],\n        a: data[offset + 3],\n      });\n    }\n    console.log(\"前10个像素样本:\", samplePixels);\n  }\n\n  /**\n   * 修复图片数据格式，处理颜色通道顺序问题\n   */\n  private fixImageData(data: Uint8ClampedArray): Uint8ClampedArray {\n    // 检查是否是BGRA格式\n    let isBGRA = false;\n    let hasContent = false;\n    let allTransparent = true;\n    let rSum = 0;\n    let bSum = 0;\n    let pixelCount = 0;\n\n    for (let i = 0; i < data.length; i += 4) {\n      const r = data[i];\n      const g = data[i + 1];\n      const b = data[i + 2];\n      const a = data[i + 3];\n\n      // 检查是否有内容（RGB通道）\n      if (r > 0 || g > 0 || b > 0) {\n        hasContent = true;\n      }\n\n      // 检查是否所有alpha都为0\n      if (a > 0) {\n        allTransparent = false;\n      }\n\n      // 统计非透明像素的R和B值，用于判断是否为BGRA\n      if (a > 128) {\n        rSum += r;\n        bSum += b;\n        pixelCount++;\n      }\n    }\n\n    // 改进的BGRA检测：如果平均B值显著高于平均R值，则认为是BGRA\n    if (pixelCount > 0) {\n      const avgR = rSum / pixelCount;\n      const avgB = bSum / pixelCount;\n      // 如果B平均值是R的1.5倍以上，判断为BGRA\n      isBGRA = avgB > avgR * 1.5 && avgB > 50;\n    }\n\n    console.log(\"数据格式检测:\", { isBGRA, hasContent, allTransparent });\n\n    const fixedData = new Uint8ClampedArray(data);\n\n    // 1. 处理BGRA转RGBA\n    if (isBGRA) {\n      console.log(\"检测到BGRA格式，转换为RGBA\");\n      for (let i = 0; i < fixedData.length; i += 4) {\n        // 交换R和B通道\n        const r = fixedData[i];\n        const b = fixedData[i + 2];\n        fixedData[i] = b;\n        fixedData[i + 2] = r;\n      }\n    }\n\n    // 2. 修复：如果所有alpha为0但有RGB内容，设置alpha为255\n    if (allTransparent && hasContent) {\n      console.log(\"检测到所有alpha为0但有RGB内容，修复alpha通道\");\n      for (let i = 0; i < fixedData.length; i += 4) {\n        const r = fixedData[i];\n        const g = fixedData[i + 1];\n        const b = fixedData[i + 2];\n\n        // 如果有RGB内容，设置alpha为255（完全不透明）\n        if (r > 0 || g > 0 || b > 0) {\n          fixedData[i + 3] = 255;\n        }\n      }\n    }\n\n    return fixedData;\n  }\n\n  private async processImageWindowsCompat() {\n    // Windows特定的兼容处理\n    const image = await readImage();\n    console.log(\"Windows兼容模式 - 读取到剪贴板图片:\", image);\n\n    const imageData = await image.rgba();\n    const { width, height } = await image.size();\n\n    console.log(\"Windows兼容模式 - 图片信息:\", { width, height, dataLength: imageData.length });\n\n    if (width === 0 || height === 0) {\n      console.warn(\"Windows兼容模式 - 图片尺寸为0，无法处理\");\n      return;\n    }\n\n    // 使用更保守的数据处理方式\n    const canvas = document.createElement(\"canvas\");\n    canvas.width = width;\n    canvas.height = height;\n    const ctx = canvas.getContext(\"2d\")!;\n\n    // 强制转换数据格式\n    let processedData: Uint8ClampedArray;\n    try {\n      processedData = new Uint8ClampedArray(imageData);\n    } catch (e) {\n      console.warn(\"Windows兼容模式 - 数据转换失败，使用空数据填充\", e);\n      processedData = new Uint8ClampedArray(width * height * 4);\n    }\n\n    // 确保数据长度正确\n    const expectedLength = width * height * 4;\n    if (processedData.length !== expectedLength) {\n      console.warn(`Windows兼容模式 - 修复数据长度: 期望 ${expectedLength}, 实际 ${processedData.length}`);\n      const fixedData = new Uint8ClampedArray(expectedLength);\n      fixedData.set(processedData.slice(0, Math.min(processedData.length, expectedLength)));\n      processedData = fixedData;\n    }\n\n    // 处理图片数据格式，修复颜色通道顺序\n    processedData = this.fixImageData(processedData);\n\n    // 检查并修复透明数据\n    const hasNonTransparentPixels = processedData.some(\n      (byte, index) => index % 4 === 3 && byte > 0, // 检查alpha通道\n    );\n\n    if (!hasNonTransparentPixels) {\n      console.warn(\"Windows兼容模式 - 所有像素都是透明的，设置默认不透明\");\n      // 设置所有alpha通道为不透明\n      for (let i = 3; i < processedData.length; i += 4) {\n        processedData[i] = 255;\n      }\n    }\n\n    const canvasImageData = new ImageData(Uint8ClampedArray.from(processedData), width, height);\n    ctx.putImageData(canvasImageData, 0, 0);\n\n    const blob = await this.createBlobFromCanvas(canvas);\n\n    this.copyEnginePasteImage(blob);\n  }\n\n  private ensureImageDataFormat(data: any, width: number, height: number): Uint8ClampedArray {\n    const expectedLength = width * height * 4;\n\n    if (data instanceof Uint8ClampedArray) {\n      // 检查长度是否匹配\n      if (data.length === expectedLength) {\n        return data;\n      } else {\n        console.warn(`数据长度不匹配: ${data.length} vs ${expectedLength}`);\n        // 尝试修复长度\n        const fixedData = new Uint8ClampedArray(expectedLength);\n        fixedData.set(data.slice(0, Math.min(data.length, expectedLength)));\n        return fixedData;\n      }\n    } else if (data instanceof Uint8Array) {\n      // 转换为Uint8ClampedArray\n      return new Uint8ClampedArray(data);\n    } else if (data instanceof ArrayBuffer) {\n      // 处理ArrayBuffer\n      return new Uint8ClampedArray(data);\n    } else {\n      console.warn(\"未知数据类型，尝试转换:\", data.constructor.name);\n      return new Uint8ClampedArray(data);\n    }\n  }\n\n  private validateCanvasContent(ctx: CanvasRenderingContext2D, width: number, height: number): void {\n    const validationData = ctx.getImageData(0, 0, width, height);\n    const data = validationData.data;\n\n    let nonTransparentPixels = 0;\n    let nonBlackPixels = 0;\n    const totalPixels = data.length / 4;\n\n    for (let i = 0; i < data.length; i += 4) {\n      const r = data[i];\n      const g = data[i + 1];\n      const b = data[i + 2];\n      const a = data[i + 3];\n\n      if (a > 0) {\n        nonTransparentPixels++;\n      }\n\n      if (r > 0 || g > 0 || b > 0) {\n        nonBlackPixels++;\n      }\n    }\n\n    const transparencyRatio = nonTransparentPixels / totalPixels;\n    const colorRatio = nonBlackPixels / totalPixels;\n\n    console.log(\"图片内容验证:\", {\n      totalPixels,\n      nonTransparentPixels,\n      nonBlackPixels,\n      transparencyRatio: Math.round(transparencyRatio * 100) + \"%\",\n      colorRatio: Math.round(colorRatio * 100) + \"%\",\n      isEmpty: transparencyRatio < 0.01 && colorRatio < 0.01,\n    });\n\n    if (transparencyRatio < 0.01 && colorRatio < 0.01) {\n      console.warn(\"图片内容为空，可能是透明图片\");\n    } else if (transparencyRatio < 0.1) {\n      console.log(\"图片大部分区域透明，但有内容\");\n    } else {\n      console.log(\"图片内容正常\");\n    }\n  }\n\n  private async createBlobFromCanvas(canvas: HTMLCanvasElement): Promise<Blob> {\n    // 在创建blob之前，先验证canvas内容\n    const ctx = canvas.getContext(\"2d\")!;\n    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);\n\n    // 保存canvas内容用于调试\n    const dataURL = canvas.toDataURL(\"image/png\");\n    console.log(\"Canvas数据URL预览:\", dataURL.substring(0, 100) + \"...\");\n\n    // 检查是否有实际内容\n    let hasRealContent = false;\n    for (let i = 0; i < imageData.data.length; i += 4) {\n      const r = imageData.data[i];\n      const g = imageData.data[i + 1];\n      const b = imageData.data[i + 2];\n      const a = imageData.data[i + 3];\n\n      if (a > 0 && (r > 0 || g > 0 || b > 0)) {\n        hasRealContent = true;\n        break;\n      }\n    }\n\n    console.log(\"Canvas内容检查结果:\", { hasRealContent, width: canvas.width, height: canvas.height });\n\n    return new Promise<Blob>((resolve, reject) => {\n      canvas.toBlob((blob) => {\n        if (!blob) {\n          console.error(\"canvas.toBlob返回null\");\n          reject(new Error(\"canvas.toBlob返回null\"));\n        } else {\n          console.log(\"成功创建blob:\", {\n            type: blob.type,\n            size: blob.size,\n            hasContent: blob.size > 0,\n            hasRealContent,\n          });\n\n          // 如果blob有内容但看起来是空的，可能有问题\n          if (blob.size > 0 && !hasRealContent) {\n            console.warn(\"警告：创建了非空blob但canvas内容看起来为空\");\n          }\n\n          resolve(blob);\n        }\n      }, \"image/png\");\n    });\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataManageService/copyEngine/copyEngineText.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { SvgNode } from \"@/core/stage/stageObject/entity/SvgNode\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { UrlNode } from \"@/core/stage/stageObject/entity/UrlNode\";\nimport { PathString } from \"@/utils/pathString\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { MouseLocation } from \"../../controlService/MouseLocation\";\nimport { RectanglePushInEffect } from \"../../feedbackService/effectEngine/concrete/RectanglePushInEffect\";\nimport { isMermaidGraphString, isSvgString } from \"./stringValidTools\";\nimport { toast } from \"sonner\";\nimport { DetailsManager } from \"@/core/stage/stageObject/tools/entityDetailsManager\";\nimport { Settings } from \"@/core/service/Settings\";\n\n/**\n * 专门处理文本粘贴的服务\n */\nexport class CopyEngineText {\n  constructor(private project: Project) {}\n\n  async copyEnginePastePlainText(item: string) {\n    let entity: Entity | null = null;\n    const collisionBox = new CollisionBox([\n      new Rectangle(this.project.renderer.transformView2World(MouseLocation.vector()), Vector.getZero()),\n    ]);\n\n    if (isSvgString(item)) {\n      // 是SVG类型\n      const attachmentId = this.project.addAttachment(new Blob([item], { type: \"image/svg+xml\" }));\n      entity = new SvgNode(this.project, {\n        attachmentId,\n        collisionBox,\n      });\n    } else if (PathString.isValidURL(item)) {\n      // 是URL类型\n      entity = new UrlNode(this.project, {\n        title: \"链接\",\n        url: item,\n        collisionBox: new CollisionBox([\n          new Rectangle(this.project.renderer.transformView2World(MouseLocation.vector()), new Vector(300, 150)),\n        ]),\n      });\n      entity.move(\n        new Vector(-entity.collisionBox.getRectangle().width / 2, -entity.collisionBox.getRectangle().height / 2),\n      );\n    } else if (isMermaidGraphString(item)) {\n      // 是Mermaid图表类型\n      entity = new TextNode(this.project, {\n        text: \"mermaid图表，目前暂不支持\",\n        // details: \"```mermaid\\n\" + item + \"\\n```\",\n        collisionBox,\n      });\n    } else {\n      const { valid, text, url } = PathString.isMarkdownUrl(item);\n      if (valid) {\n        // 是Markdown链接类型\n        // [text](https://www.example.text.com)\n        entity = new UrlNode(this.project, {\n          title: text,\n          uuid: crypto.randomUUID(),\n          url: url,\n          collisionBox: new CollisionBox([\n            new Rectangle(this.project.renderer.transformView2World(MouseLocation.vector()), new Vector(300, 150)),\n          ]),\n        });\n        entity.move(\n          new Vector(-entity.collisionBox.getRectangle().width / 2, -entity.collisionBox.getRectangle().height / 2),\n        );\n      } else {\n        if (item === \"\") {\n          toast.warning(\"粘贴板中没有内容，若想快速复制多个文本节点，请交替按ctrl c、ctrl v\");\n          return;\n        }\n        // 只是普通的文本\n        if (item.length > 3000) {\n          entity = new TextNode(this.project, {\n            text: \"粘贴板文字过长（超过3000字符），已写入节点详细信息\",\n            collisionBox,\n            details: DetailsManager.markdownToDetails(item),\n          });\n        } else {\n          let collisionBox = new CollisionBox([\n            new Rectangle(this.project.renderer.transformView2World(MouseLocation.vector()), Vector.getZero()),\n          ]);\n          const threshold = Settings.textNodeBigContentThresholdWhenPaste;\n          const pasteMode = Settings.textNodePasteSizeAdjustMode;\n\n          let sizeAdjust: \"auto\" | \"manual\";\n          let isBigContent = false;\n\n          switch (pasteMode) {\n            case \"manual\":\n              sizeAdjust = \"manual\";\n              collisionBox = new CollisionBox([\n                new Rectangle(this.project.renderer.transformView2World(MouseLocation.vector()), new Vector(400, 100)),\n              ]);\n              break;\n            case \"auto\":\n              sizeAdjust = \"auto\";\n              break;\n            case \"autoByLength\":\n            default:\n              isBigContent = item.length > threshold;\n              sizeAdjust = isBigContent ? \"manual\" : \"auto\";\n              if (isBigContent) {\n                collisionBox = new CollisionBox([\n                  new Rectangle(\n                    this.project.renderer.transformView2World(MouseLocation.vector()),\n                    new Vector(400, 100),\n                  ),\n                ]);\n              }\n              break;\n          }\n\n          // Debug mode toast\n          if (Settings.showDebug) {\n            toast.info(\n              `粘贴内容长度: ${item.length}, 阈值: ${threshold}, 粘贴模式: ${pasteMode}, 最终换行模式: ${sizeAdjust === \"manual\" ? \"手动换行\" : \"自动换行\"}`,\n            );\n          }\n\n          entity = new TextNode(this.project, {\n            text: item,\n            collisionBox,\n            sizeAdjust,\n          });\n          entity.move(\n            new Vector(-entity.collisionBox.getRectangle().width / 2, -entity.collisionBox.getRectangle().height / 2),\n          );\n        }\n      }\n    }\n\n    if (entity !== null) {\n      this.project.stageManager.add(entity);\n      // 添加到section\n\n      const mouseSections = this.project.sectionMethods.getSectionsByInnerLocation(\n        this.project.renderer.transformView2World(MouseLocation.vector()),\n      );\n\n      if (mouseSections.length > 0) {\n        this.project.stageManager.goInSection([entity], mouseSections[0]);\n        this.project.effects.addEffect(\n          RectanglePushInEffect.sectionGoInGoOut(\n            entity.collisionBox.getRectangle(),\n            mouseSections[0].collisionBox.getRectangle(),\n          ),\n        );\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataManageService/copyEngine/copyEngineUtils.tsx",
    "content": "import { SetFunctions } from \"@/core/algorithm/setFunctions\";\nimport { Project } from \"@/core/Project\";\nimport { ConnectableAssociation } from \"@/core/stage/stageObject/abstract/Association\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { StageObject } from \"@/core/stage/stageObject/abstract/StageObject\";\nimport { Edge } from \"@/core/stage/stageObject/association/Edge\";\nimport { MultiTargetUndirectedEdge } from \"@/core/stage/stageObject/association/MutiTargetUndirectedEdge\";\nimport { ImageNode } from \"@/core/stage/stageObject/entity/ImageNode\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { SvgNode } from \"@/core/stage/stageObject/entity/SvgNode\";\n\n/**\n *\n */\nexport namespace CopyEngineUtils {\n  /**\n   * 根据一部分物体，获取所有相关的实体\n   * 可以用于选中复制的时候\n   * （导出mermaid也会用到这个工具函数）\n   */\n  export function getAllStageObjectFromEntities(project: Project, entities: Entity[]): StageObject[] {\n    //\n    if (entities.length === 0) {\n      return [];\n    }\n    const selectedUUIDs = new Set(entities.map((it) => it.uuid));\n    const result: StageObject[] = [...entities];\n    const isHaveSection = entities.some((it) => it instanceof Section);\n    if (isHaveSection) {\n      // 如果有框，则获取框内的实体\n      const innerEntities = project.sectionMethods.getAllEntitiesInSelectedSectionsOrEntities(entities);\n      // 根据 selectedUUIDs 过滤\n      const filteredInnerEntities = innerEntities.filter((it) => !selectedUUIDs.has(it.uuid));\n      result.push(...filteredInnerEntities);\n      // 补充 selectedUUIDs\n      for (const entity of filteredInnerEntities) {\n        selectedUUIDs.add(entity.uuid);\n      }\n    }\n\n    // O(N), N 为当前舞台对象数量\n    for (const association of project.stageManager.getAssociations()) {\n      if (association instanceof ConnectableAssociation) {\n        if (association instanceof Edge) {\n          if (selectedUUIDs.has(association.source.uuid) && selectedUUIDs.has(association.target.uuid)) {\n            result.push(association);\n          }\n        } else if (association instanceof MultiTargetUndirectedEdge) {\n          // 无向边\n          const associationUUIDs = new Set(association.associationList.map((it) => it.uuid));\n          if (SetFunctions.isSubset(associationUUIDs, selectedUUIDs)) {\n            result.push(association);\n          }\n        }\n      }\n    }\n    return result;\n  }\n\n  /**\n   * 收集所有需要复制的附件（ImageNode 和 SvgNode 的附件）\n   * @param project\n   * @param stageObjects\n   * @returns\n   */\n  export async function collectAttachmentFromStageObjects(\n    project: Project,\n    stageObjects: StageObject[],\n  ): Promise<Map<string, { data: ArrayBuffer; type: string }>> {\n    const attachmentMap = new Map<string, { data: ArrayBuffer; type: string }>();\n    const attachmentIds = new Set<string>();\n\n    // 从所有复制的舞台对象中收集 attachmentId\n    for (const stageObject of stageObjects) {\n      if (stageObject instanceof ImageNode || stageObject instanceof SvgNode) {\n        const attachmentId = stageObject.attachmentId;\n        if (attachmentId && !attachmentIds.has(attachmentId)) {\n          attachmentIds.add(attachmentId);\n          const blob = project.attachments.get(attachmentId);\n          if (blob) {\n            // 将 Blob 转换为 ArrayBuffer 以便序列化\n            const arrayBuffer = await blob.arrayBuffer();\n            attachmentMap.set(attachmentId, {\n              data: arrayBuffer,\n              type: blob.type,\n            });\n          }\n        }\n      }\n    }\n    return attachmentMap;\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataManageService/copyEngine/stringValidTools.tsx",
    "content": "import { toast } from \"sonner\";\n\nexport function isSvgString(str: string): boolean {\n  const trimmed = str.trim();\n\n  // 基础结构检查\n  if (trimmed.startsWith(\"<svg\") || trimmed.endsWith(\"</svg>\")) {\n    return true;\n  }\n\n  // 提取 <svg> 标签的属性部分\n  const openTagMatch = trimmed.match(/<svg/i);\n  if (!openTagMatch) return false; // 无有效属性则直接失败\n\n  // 检查是否存在 xmlns 命名空间声明\n  const xmlnsRegex = /xmlns\\s*=\\s*[\"']http:\\/\\/www\\.w3\\.org\\/2000\\/svg[\"']/i;\n  if (!xmlnsRegex.test(openTagMatch[1])) {\n    return false;\n  }\n\n  // 可选：通过 DOM 解析进一步验证（仅限浏览器环境）\n  // 若在 Node.js 等无 DOM 环境，可注释此部分\n  if (typeof DOMParser !== \"undefined\") {\n    try {\n      const parser = new DOMParser();\n      const doc = parser.parseFromString(trimmed, \"image/svg+xml\");\n      const svgElement = doc.documentElement;\n      return svgElement.tagName.toLowerCase() === \"svg\" && svgElement.namespaceURI === \"http://www.w3.org/2000/svg\";\n    } catch {\n      // 解析失败则直接失败\n      toast.error(\"SVG 解析失败\");\n      return false;\n    }\n  }\n\n  return true;\n}\n\nexport function isMermaidGraphString(str: string): boolean {\n  str = str.trim();\n  if (str.startsWith(\"graph TD;\") && str.endsWith(\";\")) {\n    return true;\n  }\n  return false;\n}\n"
  },
  {
    "path": "app/src/core/service/dataManageService/dragFileIntoStageEngine/dragFileIntoStageEngine.tsx",
    "content": "import { Random } from \"@/core/algorithm/random\";\nimport { Project } from \"@/core/Project\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { ImageNode } from \"@/core/stage/stageObject/entity/ImageNode\";\nimport { SvgNode } from \"@/core/stage/stageObject/entity/SvgNode\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { readFile } from \"@tauri-apps/plugin-fs\";\nimport { toast } from \"sonner\";\nimport { URI } from \"vscode-uri\";\nimport { onOpenFile } from \"../../GlobalMenu\";\nimport { PathString } from \"@/utils/pathString\";\n\n/**\n * 处理文件拖拽到舞台的引擎\n */\nexport namespace DragFileIntoStageEngine {\n  /**\n   * 处理文件拖拽到舞台，对各种类型的文件分类讨论\n   * @param project 当前活动的项目\n   * @param pathList 拖拽的文件路径列表\n   * @param mouseLocation 拖拽到的位置（舞台坐标系）\n   */\n  export async function handleDrop(project: Project, pathList: string[]) {\n    try {\n      for (const filePath of pathList) {\n        const extName = filePath.split(\".\").pop()?.toLowerCase();\n        if (extName === \"png\") {\n          handleDropImage(project, filePath, \"image/png\");\n        } else if (extName === \"jpg\" || extName === \"jpeg\") {\n          handleDropImage(project, filePath, \"image/jpeg\");\n        } else if (extName === \"webp\") {\n          handleDropImage(project, filePath, \"image/webp\");\n        } else if (extName === \"txt\") {\n          handleDropTxt(project, filePath);\n        } else if (extName === \"svg\") {\n          handleDropSvg(project, filePath);\n        } else if (extName === \"prg\") {\n          const uri = URI.file(filePath);\n          onOpenFile(uri, \"拖拽prg文件到舞台\");\n        } else {\n          toast.error(`不支持的文件类型: 【${extName}】`);\n        }\n      }\n    } catch (error) {\n      toast.error(`处理拖拽文件失败: ${error instanceof Error ? error.message : String(error)}`);\n    }\n  }\n\n  /**\n   * 把文件的绝对路径拖拽到舞台，生成一个文本节点\n   * @param project\n   * @param filePath 绝对路径\n   */\n  export async function handleDropFileAbsolutePath(project: Project, pathList: string[]) {\n    for (const filePath of pathList) {\n      const textNode = new TextNode(project, {\n        text: filePath,\n        collisionBox: new CollisionBox([new Rectangle(project.camera.location.clone(), new Vector(300, 150))]),\n      });\n\n      project.stageManager.add(textNode);\n    }\n  }\n\n  /**\n   * 把文件的相对路径拖拽到舞台，生成一个文本节点\n   * @param project\n   * @param filePath 相对路径\n   */\n  export async function handleDropFileRelativePath(project: Project, pathList: string[]) {\n    if (project.isDraft) {\n      toast.error(\"草稿是未保存文件，没有路径，不能用相对路径导入\");\n      return;\n    }\n    // windows 的fsPath大概率是  d:/ 小写的盘符\n    const currentProjectPath = PathString.uppercaseAbsolutePathDiskChar(project.uri.fsPath);\n\n    for (const filePath of pathList) {\n      const relativePath = PathString.getRelativePath(currentProjectPath, filePath);\n      const textNode = new TextNode(project, {\n        text: relativePath,\n        collisionBox: new CollisionBox([new Rectangle(project.camera.location.clone(), new Vector(300, 150))]),\n      });\n\n      project.stageManager.add(textNode);\n    }\n  }\n\n  /**\n   * 将任意图片格式（jpg/jpeg/webp/png）转换为 PNG Blob\n   * 利用浏览器 Canvas API 完成转换\n   */\n  async function convertToPngBlob(fileData: Uint8Array, sourceMime: string): Promise<Blob> {\n    const sourceBlob = new Blob([fileData], { type: sourceMime });\n    const url = URL.createObjectURL(sourceBlob);\n    return new Promise((resolve, reject) => {\n      const img = new Image();\n      img.onload = () => {\n        const canvas = document.createElement(\"canvas\");\n        canvas.width = img.naturalWidth;\n        canvas.height = img.naturalHeight;\n        const ctx = canvas.getContext(\"2d\");\n        if (!ctx) {\n          URL.revokeObjectURL(url);\n          reject(new Error(\"无法获取 Canvas 2D 上下文\"));\n          return;\n        }\n        ctx.drawImage(img, 0, 0);\n        URL.revokeObjectURL(url);\n        canvas.toBlob((pngBlob) => {\n          if (pngBlob) resolve(pngBlob);\n          else reject(new Error(\"Canvas toBlob 失败\"));\n        }, \"image/png\");\n      };\n      img.onerror = () => {\n        URL.revokeObjectURL(url);\n        reject(new Error(\"图片加载失败\"));\n      };\n      img.src = url;\n    });\n  }\n\n  /**\n   * 处理图片文件拖拽到舞台（支持 png/jpg/jpeg/webp，统一转换为 PNG 存储）\n   */\n  export async function handleDropImage(project: Project, filePath: string, sourceMime: string) {\n    const fileData = await readFile(filePath);\n\n    // 非 PNG 格式先转换为 PNG\n    const blob =\n      sourceMime === \"image/png\"\n        ? new Blob([new Uint8Array(fileData)], { type: \"image/png\" })\n        : await convertToPngBlob(new Uint8Array(fileData), sourceMime);\n\n    const attachmentId = project.addAttachment(blob);\n\n    const addLocation = project.camera.location.clone();\n    // 添加位置向左下角随机偏移\n    addLocation.x += Random.randomInt(0, -500);\n    addLocation.y += Random.randomInt(0, 500);\n\n    const imageNode = new ImageNode(project, {\n      attachmentId,\n      collisionBox: new CollisionBox([new Rectangle(addLocation, new Vector(300, 150))]),\n    });\n\n    project.stageManager.add(imageNode);\n  }\n\n  /** @deprecated 请使用 handleDropImage */\n  export async function handleDropPng(project: Project, filePath: string) {\n    return handleDropImage(project, filePath, \"image/png\");\n  }\n\n  export async function handleDropTxt(project: Project, filePath: string) {\n    const fileData = await readFile(filePath);\n    const content = new TextDecoder().decode(fileData);\n    const textNode = new TextNode(project, {\n      text: content,\n      collisionBox: new CollisionBox([new Rectangle(project.camera.location.clone(), new Vector(300, 150))]),\n      sizeAdjust: \"manual\",\n    });\n\n    project.stageManager.add(textNode);\n  }\n\n  export async function handleDropSvg(project: Project, filePath: string) {\n    const fileData = await readFile(filePath);\n    const content = new TextDecoder().decode(fileData);\n    const svg = new DOMParser().parseFromString(content, \"image/svg+xml\");\n    const item = new XMLSerializer().serializeToString(svg.documentElement);\n    const attachmentId = project.addAttachment(new Blob([item], { type: \"image/svg+xml\" }));\n    const entity = new SvgNode(project, {\n      attachmentId,\n    });\n    project.stageManager.add(entity);\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/dataManageService/textNodeSmartTools.tsx",
    "content": "import { Dialog } from \"@/components/ui/dialog\";\nimport { Project } from \"@/core/Project\";\nimport { Edge } from \"@/core/stage/stageObject/association/Edge\";\nimport { LineEdge } from \"@/core/stage/stageObject/association/LineEdge\";\nimport { MultiTargetUndirectedEdge } from \"@/core/stage/stageObject/association/MutiTargetUndirectedEdge\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { ReferenceBlockNode } from \"@/core/stage/stageObject/entity/ReferenceBlockNode\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { DetailsManager } from \"@/core/stage/stageObject/tools/entityDetailsManager\";\nimport { averageColors, Color, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { toast } from \"sonner\";\nimport { v4 } from \"uuid\";\n\nexport namespace TextNodeSmartTools {\n  /**\n   * 根据指向该节点的连线计算缩放锚点：无连线则中心；仅一条则用该连线在节点上的 target 比例。\n   * 用于 Ctrl+加减号 放大缩小节点时保持锚点不动。\n   */\n  export function getAnchorRateForTextNode(project: Project, node: TextNode): Vector {\n    const incomingEdges = project.graphMethods.edgeParentArray(node);\n    if (incomingEdges.length === 0) return new Vector(0.5, 0.5);\n    if (incomingEdges.length === 1) return incomingEdges[0].targetRectangleRate.clone();\n    return new Vector(0.5, 0.5);\n  }\n\n  export function ttt(project: Project) {\n    const selectedTextNodes = project.stageManager.getSelectedEntities().filter((node) => node instanceof TextNode);\n    for (const node of selectedTextNodes) {\n      if (node.sizeAdjust === \"auto\") {\n        node.sizeAdjust = \"manual\";\n        node.resizeHandle(Vector.getZero());\n      } else if (node.sizeAdjust === \"manual\") {\n        node.sizeAdjust = \"auto\";\n        node.forceAdjustSizeByText();\n      }\n    }\n  }\n  /**\n   * 揉成一个\n   * @param project\n   * @returns\n   */\n  export function rua(project: Project) {\n    let selectedTextNodes = project.stageManager.getSelectedEntities().filter((node) => node instanceof TextNode);\n    if (selectedTextNodes.length <= 1) {\n      toast.error(\"rua的节点数量不能小于2\");\n      return;\n    }\n    setTimeout(() => {\n      project.camera.clearMoveCommander();\n      Dialog.input(\"请输入连接符（n代表一个换行符，t代表一个制表符）\").then((userInput) => {\n        if (userInput === undefined) return;\n        userInput = userInput.replaceAll(\"n\", \"\\n\");\n        userInput = userInput.replaceAll(\"t\", \"\\t\");\n        selectedTextNodes = selectedTextNodes.sort(\n          (a, b) => a.collisionBox.getRectangle().location.y - b.collisionBox.getRectangle().location.y,\n        );\n\n        // 收集所有连线信息\n        const upstreamEdges = collectUpstreamEdges(project, selectedTextNodes);\n        const downstreamEdges = collectDownstreamEdges(project, selectedTextNodes);\n\n        // 创建合并后的节点\n        const newTextNode = createMergedNode(project, selectedTextNodes, userInput);\n        project.stageManager.add(newTextNode);\n\n        // 处理上游连线\n        processUpstreamEdges(project, upstreamEdges, newTextNode);\n\n        // 处理下游连线\n        processDownstreamEdges(project, downstreamEdges, newTextNode);\n\n        // 选中新的节点\n        newTextNode.isSelected = true;\n        project.stageManager.deleteEntities(selectedTextNodes);\n      });\n    });\n  }\n\n  /**\n   * 收集所有上游连线，按源节点分组\n   */\n  function collectUpstreamEdges(project: Project, nodes: TextNode[]): Map<string, Edge[]> {\n    const upstreamEdges = new Map<string, Edge[]>();\n\n    nodes.forEach((node) => {\n      const edges = project.graphMethods.edgeParentArray(node);\n      edges.forEach((edge) => {\n        if (!nodes.includes(edge.source as TextNode)) {\n          // 只收集来自外部节点的连线\n          const sourceId = edge.source.uuid;\n          if (!upstreamEdges.has(sourceId)) {\n            upstreamEdges.set(sourceId, []);\n          }\n          upstreamEdges.get(sourceId)!.push(edge);\n        }\n      });\n    });\n\n    return upstreamEdges;\n  }\n\n  /**\n   * 收集所有下游连线，按目标节点分组\n   */\n  function collectDownstreamEdges(project: Project, nodes: TextNode[]): Map<string, Edge[]> {\n    const downstreamEdges = new Map<string, Edge[]>();\n\n    nodes.forEach((node) => {\n      const edges = project.graphMethods.edgeChildrenArray(node);\n      edges.forEach((edge) => {\n        if (!nodes.includes(edge.target as TextNode)) {\n          // 只收集指向外部节点的连线\n          const targetId = edge.target.uuid;\n          if (!downstreamEdges.has(targetId)) {\n            downstreamEdges.set(targetId, []);\n          }\n          downstreamEdges.get(targetId)!.push(edge);\n        }\n      });\n    });\n\n    return downstreamEdges;\n  }\n\n  /**\n   * 创建合并后的节点\n   */\n  function createMergedNode(project: Project, nodes: TextNode[], userInput: string): TextNode {\n    let mergeText = \"\";\n    const detailsList = [];\n    for (const textNode of nodes) {\n      mergeText += textNode.text + userInput;\n      detailsList.push(textNode.details);\n    }\n    mergeText = mergeText.trim();\n    const leftTop = Rectangle.getBoundingRectangle(nodes.map((node) => node.collisionBox.getRectangle())).leftTop;\n    const avgColor = averageColors(nodes.map((node) => node.color));\n\n    return new TextNode(project, {\n      uuid: v4(),\n      text: mergeText,\n      collisionBox: new CollisionBox([new Rectangle(new Vector(leftTop.x, leftTop.y), new Vector(400, 1))]),\n      color: avgColor.clone(),\n      sizeAdjust: userInput.includes(\"\\n\") ? \"manual\" : \"auto\",\n      details: DetailsManager.mergeDetails(detailsList),\n    });\n  }\n\n  /**\n   * 处理上游连线\n   */\n  function processUpstreamEdges(project: Project, upstreamEdges: Map<string, Edge[]>, newNode: TextNode) {\n    upstreamEdges.forEach((edges) => {\n      const source = edges[0].source;\n\n      // 合并连线属性\n      const mergedEdgeProps = mergeEdgeProperties(edges);\n\n      // 创建新连线\n      project.stageManager.add(\n        new LineEdge(project, {\n          associationList: [source, newNode],\n          text: mergedEdgeProps.text,\n          targetRectangleRate: mergedEdgeProps.targetRectangleRate,\n          sourceRectangleRate: mergedEdgeProps.sourceRectangleRate,\n          color: mergedEdgeProps.color,\n        }),\n      );\n    });\n  }\n\n  /**\n   * 处理下游连线\n   */\n  function processDownstreamEdges(project: Project, downstreamEdges: Map<string, Edge[]>, newNode: TextNode) {\n    downstreamEdges.forEach((edges) => {\n      const target = edges[0].target;\n\n      // 合并连线属性\n      const mergedEdgeProps = mergeEdgeProperties(edges);\n\n      // 创建新连线\n      project.stageManager.add(\n        new LineEdge(project, {\n          associationList: [newNode, target],\n          text: mergedEdgeProps.text,\n          targetRectangleRate: mergedEdgeProps.targetRectangleRate,\n          sourceRectangleRate: mergedEdgeProps.sourceRectangleRate,\n          color: mergedEdgeProps.color,\n        }),\n      );\n    });\n  }\n\n  /**\n   * 合并连线属性\n   */\n  function mergeEdgeProperties(edges: Edge[]): {\n    text: string;\n    targetRectangleRate: Vector;\n    sourceRectangleRate: Vector;\n    color: Color;\n  } {\n    // 合并文本：按遍历顺序拼接不重复的文本\n    const texts = new Set<string>();\n    edges.forEach((edge) => {\n      if (edge.text && edge.text.trim()) {\n        texts.add(edge.text.trim());\n      }\n    });\n    const mergedText = Array.from(texts).join(\" \");\n\n    // 使用最后一个连线的位置属性\n    const lastEdge = edges[edges.length - 1];\n\n    // 合并颜色\n    const colors = edges.map((edge) => edge.color);\n    const mergedColor = averageColors(colors);\n\n    return {\n      text: mergedText,\n      targetRectangleRate: lastEdge.targetRectangleRate.clone(),\n      sourceRectangleRate: lastEdge.sourceRectangleRate.clone(),\n      color: mergedColor.clone(),\n    };\n  }\n\n  export function kei(project: Project) {\n    // 获取所有选中的文本节点\n    const selectedTextNodes = project.stageManager.getSelectedEntities().filter((node) => node instanceof TextNode);\n    selectedTextNodes.forEach((node) => {\n      node.isSelected = false;\n    });\n    setTimeout(() => {\n      Dialog.input(\"请输入分割符（n代表一个换行符，t代表一个制表符）\").then((userInput) => {\n        if (userInput === undefined || userInput === \"\") return;\n        userInput = userInput.replaceAll(\"n\", \"\\n\");\n        userInput = userInput.replaceAll(\"t\", \"\\t\");\n        for (const node of selectedTextNodes) {\n          keiOneTextNode(project, node, userInput);\n        }\n        // 删除所有选中的文本节点\n        project.stageManager.deleteEntities(selectedTextNodes);\n      });\n    });\n  }\n\n  function keiOneTextNode(project: Project, node: TextNode, userInput: string) {\n    const text = node.text;\n    const seps = [userInput];\n    const escapedSeps = seps.map((sep) => sep.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\"));\n    const regex = new RegExp(escapedSeps.join(\"|\"), \"g\");\n    const splitedTextList = text.split(regex).filter((item) => item !== \"\");\n    const putLocation = node.collisionBox.getRectangle().location.clone();\n\n    const newNodes: TextNode[] = [];\n\n    const fromLines: Edge[] = project.graphMethods.edgeParentArray(node);\n    const toLines: Edge[] = project.graphMethods.edgeChildrenArray(node);\n\n    splitedTextList.forEach((splitedText) => {\n      const newTextNode = new TextNode(project, {\n        uuid: v4(),\n        text: splitedText,\n        collisionBox: new CollisionBox([new Rectangle(new Vector(putLocation.x, putLocation.y), new Vector(1, 1))]),\n        color: node.color.clone(),\n      });\n      newNodes.push(newTextNode);\n      project.stageManager.add(newTextNode);\n      putLocation.y += 100;\n    });\n\n    fromLines.forEach((edge) => {\n      newNodes.forEach((newNode) => {\n        project.stageManager.add(\n          new LineEdge(project, {\n            associationList: [edge.source, newNode],\n            text: edge.text,\n            targetRectangleRate: edge.targetRectangleRate.clone(),\n            sourceRectangleRate: edge.sourceRectangleRate.clone(),\n            color: edge.color.clone(),\n          }),\n        );\n      });\n    });\n    toLines.forEach((edge) => {\n      newNodes.forEach((newNode) => {\n        project.stageManager.add(\n          new LineEdge(project, {\n            associationList: [newNode, edge.target],\n            text: edge.text,\n            targetRectangleRate: edge.targetRectangleRate.clone(),\n            sourceRectangleRate: edge.sourceRectangleRate.clone(),\n            color: edge.color.clone(),\n          }),\n        );\n      });\n    });\n\n    // 再整体向下排列一下\n    newNodes.forEach((newNode) => {\n      newNode.isSelected = true;\n    });\n    project.layoutManager.alignTopToBottomNoSpace();\n    newNodes.forEach((newNode) => {\n      newNode.isSelected = false;\n    });\n  }\n\n  export function exchangeTextAndDetails(project: Project) {\n    const selectedTextNodes = project.stageManager.getSelectedEntities().filter((node) => node instanceof TextNode);\n    for (const node of selectedTextNodes) {\n      const details = node.details;\n      const text = node.text;\n      node.details = DetailsManager.markdownToDetails(text);\n      node.text = DetailsManager.detailsToMarkdown(details);\n      node.forceAdjustSizeByText();\n    }\n    project.historyManager.recordStep();\n  }\n\n  export function removeFirstCharFromSelectedTextNodes(project: Project) {\n    const selectedTextNodes = project.stageManager.getSelectedEntities().filter((node) => node instanceof TextNode);\n    if (selectedTextNodes.length === 0) {\n      return;\n    }\n\n    // 记录操作历史\n    project.historyManager.recordStep();\n\n    for (const node of selectedTextNodes) {\n      if (node.text.length > 0) {\n        // 获取要移除的字符\n        const removedChar = node.text.charAt(0);\n\n        // 更新原节点文本\n        node.rename(node.text.substring(1));\n\n        // 创建新的单字符节点\n        const rect = node.collisionBox.getRectangle();\n\n        // 创建新节点（先创建但不立即添加到舞台，以便获取其实际宽度）\n        const newNode = new TextNode(project, {\n          text: removedChar,\n          collisionBox: new CollisionBox([new Rectangle(new Vector(0, 0), new Vector(0, 0))]),\n          color: node.color.clone(),\n        });\n\n        // 计算新节点的实际宽度\n        const newNodeWidth = newNode.collisionBox.getRectangle().width;\n\n        // 检测左侧是否有单字符节点，如果有则将它们往左推\n        const textNodes = project.stageManager.getTextNodes();\n        const leftNodes = textNodes.filter(\n          (n) =>\n            n !== node &&\n            n.text.length === 1 &&\n            n.rectangle.right <= rect.left &&\n            Math.abs(n.rectangle.center.y - rect.center.y) < rect.size.y / 2,\n        );\n\n        // 按x坐标从右到左排序，确保先推最靠近原节点的\n        leftNodes.sort((a, b) => b.rectangle.right - a.rectangle.right);\n\n        // 推动现有节点，使用新节点的实际宽度作为推动距离\n        leftNodes.forEach((n) => {\n          n.move(new Vector(-newNodeWidth, 0));\n        });\n\n        // 设置新节点的位置，使其右侧边缘贴住原节点的左侧边缘\n        newNode.moveTo(new Vector(rect.left - newNodeWidth, rect.location.y));\n        // 添加到舞台\n        project.stageManager.add(newNode);\n\n        // 保持原节点的选中状态\n        node.isSelected = true;\n      }\n    }\n  }\n\n  export function removeLastCharFromSelectedTextNodes(project: Project) {\n    const selectedTextNodes = project.stageManager.getSelectedEntities().filter((node) => node instanceof TextNode);\n    if (selectedTextNodes.length === 0) {\n      return;\n    }\n\n    // 记录操作历史\n    project.historyManager.recordStep();\n\n    for (const node of selectedTextNodes) {\n      if (node.text.length > 0) {\n        // 获取要移除的字符\n        const removedChar = node.text.charAt(node.text.length - 1);\n\n        // 更新原节点文本\n        node.rename(node.text.substring(0, node.text.length - 1));\n\n        // 创建新的单字符节点\n        const rect = node.collisionBox.getRectangle();\n\n        // 创建新节点（先创建但不立即添加到舞台，以便获取其实际宽度）\n        const newNode = new TextNode(project, {\n          text: removedChar,\n          collisionBox: new CollisionBox([new Rectangle(new Vector(0, 0), new Vector(0, 0))]),\n          color: node.color.clone(),\n        });\n\n        // 计算新节点的实际宽度\n        const newNodeWidth = newNode.collisionBox.getRectangle().width;\n\n        // 检测右侧是否有单字符节点，如果有则将它们往右推\n        const textNodes = project.stageManager.getTextNodes();\n        const rightNodes = textNodes.filter(\n          (n) =>\n            n !== node &&\n            n.text.length === 1 &&\n            n.rectangle.left >= rect.right &&\n            Math.abs(n.rectangle.center.y - rect.center.y) < rect.size.y / 2,\n        );\n\n        // 按x坐标从左到右排序，确保先推最靠近原节点的\n        rightNodes.sort((a, b) => a.rectangle.left - b.rectangle.left);\n\n        // 推动现有节点，使用新节点的实际宽度作为推动距离\n        rightNodes.forEach((n) => {\n          n.move(new Vector(newNodeWidth, 0));\n        });\n\n        // 设置新节点的位置，使其左侧边缘贴住原节点的右侧边缘\n        newNode.moveTo(new Vector(rect.right, rect.location.y));\n\n        // 添加到舞台\n        project.stageManager.add(newNode);\n\n        // 保持原节点的选中状态\n        node.isSelected = true;\n      }\n    }\n  }\n\n  const specialColorList = [new Color(59, 114, 60), new Color(61, 10, 11)];\n  const specialCharPrefix = [\"✅\", \"❌\"];\n\n  export function okk(project: Project) {\n    const selectedTextNodes = project.stageManager.getSelectedEntities().filter((node) => node instanceof TextNode);\n    for (const node of selectedTextNodes) {\n      if (specialColorList.some((value) => value.equals(node.color))) {\n        node.color = Color.Transparent;\n      } else {\n        node.color = new Color(59, 114, 60);\n      }\n      if (specialCharPrefix.some((value) => node.text.startsWith(value + \" \"))) {\n        node.rename(node.text.slice(2));\n      } else {\n        node.rename(\"✅ \" + node.text);\n      }\n      project.controllerUtils.finishChangeTextNode(node);\n    }\n    project.stageManager.updateReferences();\n  }\n\n  export function err(project: Project) {\n    const selectedTextNodes = project.stageManager.getSelectedEntities().filter((node) => node instanceof TextNode);\n    for (const node of selectedTextNodes) {\n      if (specialColorList.some((value) => value.equals(node.color))) {\n        node.color = Color.Transparent;\n      } else {\n        node.color = new Color(61, 10, 11);\n      }\n      if (specialCharPrefix.some((value) => node.text.startsWith(value + \" \"))) {\n        node.rename(node.text.slice(2));\n      } else {\n        node.rename(\"❌ \" + node.text);\n      }\n      project.controllerUtils.finishChangeTextNode(node);\n    }\n    project.stageManager.updateReferences();\n  }\n\n  /**\n   * 将选中的特殊格式的文本节点，转换成引用块\n   * @param project\n   * @returns\n   */\n  export async function changeTextNodeToReferenceBlock(project: Project) {\n    // 仅当项目不是草稿时才更新引用\n    if (project.isDraft) {\n      toast.error(\"草稿项目不能更新为引用块\");\n      return;\n    }\n\n    const selectedTextNodes = project.stageManager.getSelectedEntities().filter((node) => node instanceof TextNode);\n    if (selectedTextNodes.length !== 1) {\n      toast.error(\"只能选中一个节点作为引用块\");\n      return;\n    }\n    const selectedNode = selectedTextNodes[0];\n    const text = selectedNode.text;\n    let referenceName = \"\";\n    if (text.trim().startsWith(\"[[\") && text.trim().endsWith(\"]]\")) {\n      referenceName = text.trim().slice(2, -2);\n    } else {\n      toast.error(\"引用块必须以[[和]]包裹\");\n      return;\n    }\n    const fileName = referenceName.split(\"#\")[0];\n    const sectionName = referenceName.split(\"#\")[1] || \"\";\n\n    // 1. 获取所有与原节点相关的连线\n    const associations = project.stageManager.getAssociations();\n    const relatedEdges: (Edge | MultiTargetUndirectedEdge)[] = [];\n    for (const association of associations) {\n      if (association instanceof Edge) {\n        // 检查普通有向边\n        if (association.source === selectedNode || association.target === selectedNode) {\n          relatedEdges.push(association);\n        }\n      } else if (association instanceof MultiTargetUndirectedEdge) {\n        // 检查多目标无向边\n        if (association.associationList.includes(selectedNode)) {\n          relatedEdges.push(association);\n        }\n      }\n    }\n\n    const referenceBlock = new ReferenceBlockNode(project, {\n      collisionBox: new CollisionBox([\n        new Rectangle(selectedNode.collisionBox.getRectangle().leftTop, new Vector(100, 100)),\n      ]),\n      fileName,\n      sectionName,\n    });\n\n    project.stageManager.add(referenceBlock);\n\n    // 2. 更新所有相关连线，将原节点替换为新的引用块节点\n    for (const edge of relatedEdges) {\n      if (edge instanceof Edge) {\n        // 更新普通有向边\n        if (edge.source === selectedNode) {\n          edge.source = referenceBlock;\n        }\n        if (edge.target === selectedNode) {\n          edge.target = referenceBlock;\n        }\n      } else if (edge instanceof MultiTargetUndirectedEdge) {\n        // 更新多目标无向边\n        const index = edge.associationList.indexOf(selectedNode);\n        if (index !== -1) {\n          edge.associationList[index] = referenceBlock;\n        }\n      }\n    }\n\n    // 3. 删除原节点\n    project.stageManager.delete(selectedNode);\n    await project.referenceManager.insertRefDataToSourcePrgFile(fileName, sectionName);\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/ColorManager.tsx",
    "content": "import { Color } from \"@graphif/data-structures\";\nimport { Store } from \"@tauri-apps/plugin-store\";\nimport { createStore } from \"@/utils/store\";\n\nexport namespace ColorManager {\n  let store: Store;\n\n  export type ColorData = {\n    r: number;\n    g: number;\n    b: number;\n    a: number;\n  };\n\n  export async function init() {\n    store = await createStore(\"colors.json\");\n    store.save();\n  }\n\n  export async function getUserEntityFillColors(): Promise<Color[]> {\n    const data = ((await store.get(\"entityFillColors\")) as ColorData[]) || [];\n    const result: Color[] = [];\n    for (const colorData of data) {\n      const color = new Color(colorData.r, colorData.g, colorData.b, colorData.a);\n      result.push(color);\n    }\n    return result;\n  }\n\n  function colorToColorData(colors: Color[]): ColorData[] {\n    const result: ColorData[] = [];\n    for (const color of colors) {\n      const colorData: ColorData = {\n        r: color.r,\n        g: color.g,\n        b: color.b,\n        a: color.a,\n      };\n      result.push(colorData);\n    }\n    return result;\n  }\n\n  /**\n   * 添加一个颜色，如果已经有这个颜色了，则不做任何事情\n   * @param color\n   */\n  export async function addUserEntityFillColor(color: Color) {\n    const colorData = await getUserEntityFillColors();\n    // 先检查下有没有这个颜色\n    for (const c of colorData) {\n      if (c.equals(color)) {\n        return false;\n      }\n    }\n    colorData.push(color);\n    await store.set(\"entityFillColors\", colorToColorData(colorData));\n    store.save();\n    return true;\n  }\n\n  /**\n   * 删除一个颜色，如果没有则不做任何事情\n   * @param color\n   */\n  export async function removeUserEntityFillColor(color: Color) {\n    const colors = await getUserEntityFillColors();\n    const colorData = colorToColorData(colors);\n\n    let index = -1;\n    for (let i = 0; i < colorData.length; i++) {\n      const c = new Color(colorData[i].r, colorData[i].g, colorData[i].b, colorData[i].a);\n      if (c.equals(color)) {\n        index = i;\n        break;\n      }\n    }\n\n    if (index >= 0) {\n      colors.splice(index, 1);\n      store.set(\"entityFillColors\", colorToColorData(colors));\n      store.save();\n      return true;\n    }\n    return false;\n  }\n  /**\n   * 按照色相环的顺序整理用户实体填充颜色\n   */\n  export async function organizeUserEntityFillColors() {\n    const colors = await getUserEntityFillColors();\n    const sortedColors = sortColorsByHue(colors);\n    await store.set(\"entityFillColors\", colorToColorData(sortedColors));\n    store.save();\n  }\n\n  /**\n   * 按照色相环的顺序排序颜色（黑白最前，纯红其次，其他按色相）\n   * @param colors\n   */\n  function sortColorsByHue(colors: Color[]): Color[] {\n    return colors.sort((a, b) => {\n      // 判断颜色类型\n      const isGrayA = isGrayscale(a);\n      const isGrayB = isGrayscale(b);\n\n      // 优先级：灰度 > 彩色\n      if (isGrayA !== isGrayB) {\n        return isGrayA ? -1 : 1;\n      }\n\n      // 同类型比较\n      if (isGrayA) {\n        // 灰度颜色按亮度降序（从白到黑）\n        return getGrayscaleBrightness(b) - getGrayscaleBrightness(a);\n      } else {\n        // 彩色按色相升序\n        return getColorHue(a) - getColorHue(b);\n      }\n    });\n  }\n  /**\n   * 判断是否是灰度颜色\n   */\n  function isGrayscale(color: Color): boolean {\n    const rgb = color;\n    return rgb.r === rgb.g && rgb.g === rgb.b;\n  }\n\n  /**\n   * 获取灰度颜色的亮度（0-255）\n   */\n  function getGrayscaleBrightness(color: Color): number {\n    const rgb = color;\n    return rgb.r; // 因为r=g=b，直接返回红色通道值\n  }\n  /**\n   * 计算颜色的色相\n   * @param color\n   * @returns 色相值（0-360）\n   */\n  function getColorHue(color: Color): number {\n    return Color.getHue(color);\n  }\n}\n/**\njson数据格式\n{\n  \"entityFillColors\": [\n    [r, g, b, a],\n    [r, g, b, a],\n  ]\n}\n *\n */\n"
  },
  {
    "path": "app/src/core/service/feedbackService/SoundService.tsx",
    "content": "import { readFile } from \"@tauri-apps/plugin-fs\";\nimport { Settings } from \"../Settings\";\n\n/**\n * 播放音效的服务\n * 这个音效播放服务是用户自定义的\n */\nexport namespace SoundService {\n  /**\n   * 音调变化范围配置（音分）\n   * 100音分 = 1个半音\n   * 可根据需要调整此值以获得更明显或更微妙的音调变化\n   */\n  export function getPitchVariationRange() {\n    return Settings.soundPitchVariationRange ?? 150; // 默认±150音分（1.5个半音）\n  }\n  export namespace play {\n    // 开始切断\n    export function cuttingLineStart() {\n      loadAndPlaySound(Settings.cuttingLineStartSoundFile);\n    }\n\n    // 开始连接\n    export function connectLineStart() {\n      loadAndPlaySound(Settings.connectLineStartSoundFile);\n    }\n\n    // 连接吸附到目标点\n    export function connectFindTarget() {\n      loadAndPlaySound(Settings.connectFindTargetSoundFile);\n    }\n\n    // 自动保存执行特效\n    // 自动备份执行特效\n\n    // 框选增加物体音效\n\n    // 切断特效声音\n    export function cuttingLineRelease() {\n      loadAndPlaySound(Settings.cuttingLineReleaseSoundFile);\n    }\n    // 连接成功\n\n    // 对齐吸附音效\n    export function alignAndAttach() {\n      loadAndPlaySound(Settings.alignAndAttachSoundFile);\n    }\n    // 鼠标进入按钮区域的声音\n    export function mouseEnterButton() {\n      loadAndPlaySound(Settings.uiButtonEnterSoundFile);\n    }\n    export function mouseClickButton() {\n      loadAndPlaySound(Settings.uiButtonClickSoundFile);\n    }\n    export function mouseClickSwitchButtonOn() {\n      loadAndPlaySound(Settings.uiSwitchButtonOnSoundFile);\n    }\n    export function mouseClickSwitchButtonOff() {\n      loadAndPlaySound(Settings.uiSwitchButtonOffSoundFile);\n    }\n\n    /** ctrl + G 打包 */\n    export function packEntityToSectionSoundFile() {\n      loadAndPlaySound(Settings.packEntityToSectionSoundFile);\n    }\n\n    /**\n     * tab 生成树结构\n     */\n    export function treeGenerateDeepSoundFile() {\n      loadAndPlaySound(Settings.treeGenerateDeepSoundFile);\n    }\n\n    /**\n     * enter 广度生长\n     */\n    export function treeGenerateBroadSoundFile() {\n      loadAndPlaySound(Settings.treeGenerateBroadSoundFile);\n    }\n\n    /**\n     * 格式化树形结构\n     */\n    export function treeAdjustSoundFile() {\n      loadAndPlaySound(Settings.treeAdjustSoundFile);\n    }\n\n    /**\n     * 视图调整\n     */\n    export function viewAdjustSoundFile() {\n      loadAndPlaySound(Settings.viewAdjustSoundFile);\n    }\n\n    /**\n     * 物体跳跃\n     */\n    export function entityJumpSoundFile() {\n      loadAndPlaySound(Settings.entityJumpSoundFile);\n    }\n\n    /**\n     * 连线、无向边等东西的调整\n     */\n    export function associationAdjustSoundFile() {\n      loadAndPlaySound(Settings.associationAdjustSoundFile);\n    }\n  }\n\n  const audioContext = new window.AudioContext();\n\n  export function playSoundByFilePath(filePath: string) {\n    loadAndPlaySound(filePath);\n  }\n\n  async function loadAndPlaySound(filePath: string) {\n    if (!Settings.soundEnabled) {\n      return;\n    }\n    if (filePath.trim() === \"\") {\n      return;\n    }\n\n    // 解码音频数据\n    const audioBuffer = await getAudioBufferByFilePath(filePath); // 消耗0.1秒\n    const source = audioContext.createBufferSource();\n    source.buffer = audioBuffer;\n\n    // 添加音调随机化 - 根据配置的范围随机调整音调\n    // 使用getPitchVariationRange()函数获取配置的变化范围\n    const randomDetune = (Math.random() - 0.5) * getPitchVariationRange() * 2; // 范围内随机\n    source.detune.value = randomDetune;\n\n    source.connect(audioContext.destination); // 小概率消耗0.01秒\n    source.start(0);\n  }\n\n  const pathAudioBufferMap = new Map<string, AudioBuffer>();\n\n  async function getAudioBufferByFilePath(filePath: string) {\n    // 先从缓存中获取音频数据\n    const result = pathAudioBufferMap.get(filePath);\n    if (result) {\n      return result;\n    }\n\n    // 缓存中没有\n\n    // 读取文件为字符串\n    const uint8Array = await readFile(filePath);\n\n    // 创建 ArrayBuffer\n    const arrayBuffer = uint8Array.buffer as ArrayBuffer;\n\n    // 解码音频数据\n    const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);\n\n    // 加入缓存\n    pathAudioBufferMap.set(filePath, audioBuffer);\n\n    return audioBuffer;\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/README.md",
    "content": "2024年12月2日\n曾经想的是：渲染与数据相分离\n现在发现这个方法会导致两部分代码拆开，来回切换麻烦，还要写一个很丑很长的if else 链条或者switch链条，相当麻烦。还不如给每个特效类里写一个渲染方法。\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/concrete/CircleChangeRadiusEffect.tsx",
    "content": "import { Color, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Project } from \"@/core/Project\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\n\n/**\n * 圆形光圈缩放特效\n */\nexport class CircleChangeRadiusEffect extends Effect {\n  constructor(\n    /**\n     * 一开始为0，每tick + 1\n     */\n    public timeProgress: ProgressNumber,\n    public location: Vector,\n    public radiusStart: number,\n    public radiusEnd: number,\n    public color: Color,\n  ) {\n    super(timeProgress);\n  }\n\n  get radius() {\n    return this.radiusStart + (this.radiusEnd - this.radiusStart) * this.timeProgress.rate;\n  }\n\n  static fromConnectPointExpand(location: Vector, expandRadius: number) {\n    return new CircleChangeRadiusEffect(\n      new ProgressNumber(0, 10),\n      location,\n      0.01,\n      expandRadius,\n      new Color(255, 255, 255),\n    );\n  }\n  static fromConnectPointShrink(location: Vector, currentRadius: number) {\n    return new CircleChangeRadiusEffect(\n      new ProgressNumber(0, 10),\n      location,\n      currentRadius,\n      0.1,\n      new Color(255, 255, 255),\n    );\n  }\n\n  render(project: Project) {\n    if (this.timeProgress.isFull) {\n      return;\n    }\n    this.color.a = 1 - this.timeProgress.rate;\n    project.shapeRenderer.renderCircle(\n      project.renderer.transformWorld2View(this.location),\n      this.radius * project.camera.currentScale,\n      Color.Transparent,\n      this.color,\n      2 * project.camera.currentScale,\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/concrete/CircleFlameEffect.tsx",
    "content": "import { Color, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Project } from \"@/core/Project\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\n\n/**\n * 圆形火光特效\n * 中间有颜色，边缘透明，中心放射状过渡\n */\nexport class CircleFlameEffect extends Effect {\n  constructor(\n    /**\n     * 一开始为0，每tick + 1\n     */\n    public override timeProgress: ProgressNumber,\n    public location: Vector,\n    public radius: number,\n    public color: Color,\n  ) {\n    super(timeProgress);\n  }\n\n  render(project: Project) {\n    if (this.timeProgress.isFull) {\n      return;\n    }\n    this.color.a = 1 - this.timeProgress.rate;\n    const rendRadius = this.radius * this.timeProgress.rate;\n    project.shapeRenderer.renderCircleTransition(\n      project.renderer.transformWorld2View(this.location),\n      rendRadius * project.camera.currentScale,\n      this.color,\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/concrete/EdgeCutEffect.tsx",
    "content": "import { Color, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Project } from \"@/core/Project\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\nimport { easeOutQuint } from \"@/core/service/feedbackService/effectEngine/mathTools/easings\";\n\n/**\n * 直线连线被斩断的特效\n */\nexport class EdgeCutEffect extends Effect {\n  constructor(\n    timeProgress: ProgressNumber,\n    delay: number,\n    private start: Vector,\n    private end: Vector,\n    private color: Color,\n    private width: number,\n  ) {\n    super(timeProgress, delay);\n  }\n\n  static default(start: Vector, end: Vector, color: Color, width: number) {\n    return new EdgeCutEffect(new ProgressNumber(0, 300), 0, start, end, color, width);\n  }\n\n  render(project: Project) {\n    const midPoint = new Vector((this.start.x + this.end.x) / 2, (this.start.y + this.end.y) / 2);\n\n    // 计算动画进度 (0-1)\n    const progress = easeOutQuint(this.timeProgress.rate); // 30帧完成动画\n\n    // 计算两端缩短后的位置\n    const leftEnd = new Vector(\n      this.start.x + (midPoint.x - this.start.x) * (1 - progress),\n      this.start.y + (midPoint.y - this.start.y) * (1 - progress),\n    );\n\n    const rightEnd = new Vector(\n      this.end.x + (midPoint.x - this.end.x) * (1 - progress),\n      this.end.y + (midPoint.y - this.end.y) * (1 - progress),\n    );\n\n    // 绘制两端缩短的线条\n    project.curveRenderer.renderSolidLine(\n      project.renderer.transformWorld2View(this.start),\n      project.renderer.transformWorld2View(leftEnd),\n      this.color.toNewAlpha(1 - progress),\n      this.width * project.camera.currentScale,\n    );\n    project.curveRenderer.renderSolidLine(\n      project.renderer.transformWorld2View(rightEnd),\n      project.renderer.transformWorld2View(this.end),\n      this.color.toNewAlpha(1 - progress),\n      this.width * project.camera.currentScale,\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/concrete/EntityAlignEffect.tsx",
    "content": "import { mixColors, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Line, Rectangle } from \"@graphif/shapes\";\nimport { Project } from \"@/core/Project\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\n\n/**\n * 实体对齐特效\n */\nexport class EntityAlignEffect extends Effect {\n  private lines: Line[] = [];\n  static fromEntity(moveRectangle: Rectangle, targetRectangle: Rectangle): EntityAlignEffect {\n    return new EntityAlignEffect(new ProgressNumber(0, 20), moveRectangle, targetRectangle);\n  }\n  constructor(\n    public override timeProgress: ProgressNumber,\n    moveRectangle: Rectangle,\n    targetRectangle: Rectangle,\n  ) {\n    super(timeProgress);\n    const moveEntityRectangle = moveRectangle;\n    const targetEntityRectangle = targetRectangle;\n\n    // 两个矩形构成的最小外接矩形\n    const twoRectangle = Rectangle.getBoundingRectangle([moveEntityRectangle, targetEntityRectangle]);\n    // 计算两个矩形现在是哪里对齐了，是左边缘x轴对齐，还是右边缘x轴对齐，\n    // 还是上边缘y轴对齐，还是下边缘y轴对齐\n    // 左边缘x对齐检测\n    if (moveEntityRectangle.left === targetEntityRectangle.left) {\n      // 左边缘x轴对齐，添加一个左边缘线\n      this.lines.push(\n        new Line(\n          new Vector(moveEntityRectangle.left, twoRectangle.top),\n          new Vector(moveEntityRectangle.left, twoRectangle.bottom),\n        ),\n      );\n    }\n    // 右边缘x轴对齐检测\n    if (moveEntityRectangle.right === targetEntityRectangle.right) {\n      // 右边缘x轴对齐，添加一个右边缘线\n      this.lines.push(\n        new Line(\n          new Vector(moveEntityRectangle.right, twoRectangle.top),\n          new Vector(moveEntityRectangle.right, twoRectangle.bottom),\n        ),\n      );\n    }\n    // 中心x轴对齐检测\n    if (moveEntityRectangle.center.x === targetEntityRectangle.center.x) {\n      // 中心x轴对齐，添加一个竖着的中心线\n      this.lines.push(\n        new Line(\n          new Vector(twoRectangle.center.x, twoRectangle.top),\n          new Vector(twoRectangle.center.x, twoRectangle.bottom),\n        ),\n      );\n    }\n    // 上边缘y轴对齐检测\n    if (moveEntityRectangle.top === targetEntityRectangle.top) {\n      // 上边缘y轴对齐，添加一个上边缘线\n      this.lines.push(\n        new Line(\n          new Vector(twoRectangle.left, moveEntityRectangle.top),\n          new Vector(twoRectangle.right, moveEntityRectangle.top),\n        ),\n      );\n    }\n    // 下边缘y轴对齐检测\n    if (moveEntityRectangle.bottom === targetEntityRectangle.bottom) {\n      // 下边缘y轴对齐，添加一个下边缘线\n      this.lines.push(\n        new Line(\n          new Vector(twoRectangle.left, moveEntityRectangle.bottom),\n          new Vector(twoRectangle.right, moveEntityRectangle.bottom),\n        ),\n      );\n    }\n    // 中心y轴对齐检测\n    if (moveEntityRectangle.center.y === targetEntityRectangle.center.y) {\n      // 中心y轴对齐，添加一个横着的中心线\n      this.lines.push(\n        new Line(\n          new Vector(twoRectangle.left, twoRectangle.center.y),\n          new Vector(twoRectangle.right, twoRectangle.center.y),\n        ),\n      );\n    }\n  }\n\n  render(project: Project) {\n    for (const line of this.lines) {\n      project.curveRenderer.renderDashedLine(\n        project.renderer.transformWorld2View(line.start),\n        project.renderer.transformWorld2View(line.end),\n        mixColors(\n          project.stageStyleManager.currentStyle.CollideBoxSelected.toSolid(),\n          project.stageStyleManager.currentStyle.CollideBoxSelected.clone().toTransparent(),\n          1 - this.timeProgress.rate,\n        ),\n        0.5 * project.camera.currentScale,\n        8 * project.camera.currentScale,\n      );\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/concrete/EntityCreateFlashEffect.tsx",
    "content": "import { ProgressNumber } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { Project } from \"@/core/Project\";\nimport { Renderer } from \"@/core/render/canvas2d/renderer\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { EffectColors } from \"@/core/service/feedbackService/stageStyle/stageStyle\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\n\n/**\n * 实体创建时闪光特效\n */\nexport class EntityCreateFlashEffect extends Effect {\n  constructor(\n    /**\n     * 一开始为0，每tick + 1\n     */\n    public override timeProgress: ProgressNumber,\n    public rectangle: Rectangle,\n    public radius: number,\n    public color: keyof EffectColors = \"flash\",\n    public startBlurSize = 50,\n  ) {\n    super(timeProgress);\n  }\n\n  /**\n   * 常用的默认效果\n   * @param rectangle\n   * @returns\n   */\n  static fromRectangle(rectangle: Rectangle) {\n    return new EntityCreateFlashEffect(new ProgressNumber(0, 50), rectangle, Renderer.NODE_ROUNDED_RADIUS, \"flash\");\n  }\n\n  static fromCreateEntity(entity: Entity) {\n    const result = new EntityCreateFlashEffect(\n      new ProgressNumber(0, 15),\n      entity.collisionBox.getRectangle(),\n      Renderer.NODE_ROUNDED_RADIUS,\n      \"flash\",\n      100,\n    );\n    result.subEffects = [\n      new EntityCreateFlashEffect(\n        new ProgressNumber(0, 30),\n        entity.collisionBox.getRectangle(),\n        Renderer.NODE_ROUNDED_RADIUS,\n        \"successShadow\",\n        50,\n      ),\n      new EntityCreateFlashEffect(\n        new ProgressNumber(0, 45),\n        entity.collisionBox.getRectangle(),\n        Renderer.NODE_ROUNDED_RADIUS,\n        \"successShadow\",\n        25,\n      ),\n    ];\n    return result;\n  }\n\n  render(project: Project) {\n    if (this.timeProgress.isFull) {\n      return;\n    }\n    project.worldRenderUtils.renderRectangleFlash(\n      project.renderer.transformWorld2View(this.rectangle),\n      project.stageStyleManager.currentStyle.effects[this.color],\n      this.startBlurSize * project.camera.currentScale * (1 - this.timeProgress.rate),\n      this.radius * project.camera.currentScale,\n    );\n    for (const subEffect of this.subEffects) {\n      subEffect.render(project);\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/concrete/EntityDashTipEffect.tsx",
    "content": "import { Random } from \"@/core/algorithm/random\";\nimport { Project } from \"@/core/Project\";\nimport { EffectParticle } from \"@/core/service/feedbackService/effectEngine/effectElements/effectParticle\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\nimport { mixColors, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\n\nexport class EntityDashTipEffect extends Effect {\n  constructor(\n    public time: number,\n    public rect: Rectangle,\n  ) {\n    super(new ProgressNumber(0, time));\n    // 在矩形的每个边生成一些像素点，随机位置\n    const countPreLine = 100;\n    const initSpeedSize = 5;\n    const initAccelerationSize = 0.5;\n    // 顶边缘\n    for (let i = 0; i < countPreLine; i++) {\n      const pointLocation = new Vector(Random.randomFloat(rect.left, rect.right), rect.top);\n      this.dashPoints.push(\n        new EffectParticle(\n          pointLocation,\n          pointLocation.subtract(rect.center).normalize().multiply(Random.randomFloat(0, initSpeedSize)),\n          rect.center.subtract(pointLocation).normalize().multiply(initAccelerationSize),\n          \"dash\",\n          1,\n        ),\n      );\n    }\n    // 右边缘\n    for (let i = 0; i < countPreLine; i++) {\n      const pointLocation = new Vector(rect.right, Random.randomFloat(rect.top, rect.bottom));\n      this.dashPoints.push(\n        new EffectParticle(\n          pointLocation,\n          pointLocation.subtract(rect.center).normalize().multiply(Random.randomFloat(0, initSpeedSize)),\n          rect.center.subtract(pointLocation).normalize().multiply(initAccelerationSize),\n          \"dash\",\n          1,\n        ),\n      );\n    }\n    // 底边缘\n    for (let i = 0; i < countPreLine; i++) {\n      const pointLocation = new Vector(Random.randomFloat(rect.left, rect.right), rect.bottom);\n      this.dashPoints.push(\n        new EffectParticle(\n          pointLocation,\n          pointLocation.subtract(rect.center).normalize().multiply(Random.randomFloat(0, initSpeedSize)),\n          rect.center.subtract(pointLocation).normalize().multiply(initAccelerationSize),\n          \"dash\",\n          1,\n        ),\n      );\n    }\n    // 左边缘\n    for (let i = 0; i < countPreLine; i++) {\n      const pointLocation = new Vector(rect.left, Random.randomFloat(rect.top, rect.bottom));\n      this.dashPoints.push(\n        new EffectParticle(\n          pointLocation,\n          pointLocation.subtract(rect.center).normalize().multiply(Random.randomFloat(0, initSpeedSize)),\n          rect.center.subtract(pointLocation).normalize().multiply(initAccelerationSize),\n          \"dash\",\n          1,\n        ),\n      );\n    }\n  }\n\n  private dashPoints: EffectParticle[] = [];\n\n  override tick(project: Project) {\n    super.tick(project);\n    for (const point of this.dashPoints) {\n      point.tick();\n      // 粒子和矩形边缘碰撞\n      if (this.rect.isPointIn(point.location)) {\n        point.velocity = point.location.subtract(this.rect.center).normalize().multiply(Random.randomFloat(0, 5));\n      }\n    }\n  }\n\n  render(project: Project) {\n    for (const point of this.dashPoints) {\n      project.renderUtils.renderPixel(\n        project.renderer.transformWorld2View(point.location),\n        mixColors(\n          project.stageStyleManager.currentStyle.effects[point.color],\n          project.stageStyleManager.currentStyle.effects[point.color].toTransparent(),\n          this.timeProgress.rate,\n        ),\n      );\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/concrete/EntityJumpMoveEffect.tsx",
    "content": "import { Color, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { Project } from \"@/core/Project\";\nimport { Renderer } from \"@/core/render/canvas2d/renderer\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\nimport { RateFunctions } from \"@/core/service/feedbackService/effectEngine/mathTools/rateFunctions\";\n\nexport class EntityJumpMoveEffect extends Effect {\n  constructor(\n    public time: number,\n    public rectStart: Rectangle,\n    public delta: Vector,\n  ) {\n    super(new ProgressNumber(0, time));\n  }\n\n  render(project: Project) {\n    const currentRect = this.rectStart.clone();\n    currentRect.location = currentRect.location.add(this.delta.clone().multiply(this.timeProgress.rate));\n\n    const groundShadowRect = currentRect.clone();\n\n    const addHeight = RateFunctions.quadraticDownward(this.timeProgress.rate) * 100;\n    currentRect.location.y -= addHeight;\n\n    // 画地面阴影\n    project.shapeRenderer.renderRectWithShadow(\n      project.renderer.transformWorld2View(groundShadowRect),\n      project.stageStyleManager.currentStyle.effects.windowFlash.toNewAlpha(0.2),\n      Color.Transparent,\n      2 * project.camera.currentScale,\n      project.stageStyleManager.currentStyle.effects.windowFlash.toNewAlpha(0.2),\n      10,\n      0,\n      0,\n      Renderer.NODE_ROUNDED_RADIUS * project.camera.currentScale,\n    );\n\n    // 画跳高的框\n    project.shapeRenderer.renderRect(\n      project.renderer.transformWorld2View(currentRect),\n      Color.Transparent,\n      project.stageStyleManager.currentStyle.StageObjectBorder,\n      2 * project.camera.currentScale,\n      Renderer.NODE_ROUNDED_RADIUS * project.camera.currentScale,\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/concrete/EntityShakeEffect.tsx",
    "content": "import { Color, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { Random } from \"@/core/algorithm/random\";\nimport { Project } from \"@/core/Project\";\nimport { Renderer } from \"@/core/render/canvas2d/renderer\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\n\n/**\n * 实体抖动特效\n * 在实体的外接矩形增加一个像“TickTock”Logo 一样的抖动特效\n * 可以用来表示提醒效果\n */\nexport class EntityShakeEffect extends Effect {\n  constructor(\n    public time: number,\n    public rect: Rectangle,\n  ) {\n    super(new ProgressNumber(0, time));\n  }\n\n  private shakeOffsetA: Vector = Vector.getZero();\n  private shakeOffsetB: Vector = Vector.getZero();\n\n  override tick(project: Project) {\n    super.tick(project);\n    const alpha = 1 - this.timeProgress.rate;\n    const maxOffsetDistance = 10;\n    this.shakeOffsetA = Random.randomVectorOnNormalCircle().multiply(alpha * maxOffsetDistance);\n    this.shakeOffsetB = Random.randomVectorOnNormalCircle().multiply(alpha * maxOffsetDistance);\n  }\n\n  static fromEntity(entity: Entity): EntityShakeEffect {\n    return new EntityShakeEffect(30, entity.collisionBox.getRectangle());\n  }\n\n  render(project: Project) {\n    const rectangleA = this.rect.clone();\n    rectangleA.location = rectangleA.location.add(this.shakeOffsetA).add(Vector.same(-2));\n    const rectangleB = this.rect.clone();\n    rectangleB.location = rectangleB.location.add(this.shakeOffsetB).add(Vector.same(2));\n    const fillAlpha = (1 - this.timeProgress.rate) / 2;\n    project.shapeRenderer.renderRectWithShadow(\n      project.renderer.transformWorld2View(rectangleA),\n      new Color(255, 0, 0, fillAlpha),\n      new Color(255, 0, 0, 0.2),\n      2 * project.camera.currentScale,\n      Color.Red,\n      50 * project.camera.currentScale,\n      0,\n      0,\n      Renderer.NODE_ROUNDED_RADIUS,\n    );\n    project.shapeRenderer.renderRectWithShadow(\n      project.renderer.transformWorld2View(rectangleB),\n      new Color(0, 0, 255, fillAlpha),\n      new Color(0, 0, 255, 0.2),\n      2 * project.camera.currentScale,\n      Color.Blue,\n      50 * project.camera.currentScale,\n      0,\n      0,\n      Renderer.NODE_ROUNDED_RADIUS,\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/concrete/EntityShrinkEffect.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { Renderer } from \"@/core/render/canvas2d/renderer\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { Color, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\n\n/**\n * 实体收缩消失特效\n */\nexport class EntityShrinkEffect extends Effect {\n  constructor(\n    public time: number,\n    public rect: Rectangle,\n    public color?: Color,\n  ) {\n    super(new ProgressNumber(0, time));\n    this.originCenterLocation = rect.center;\n  }\n  private originCenterLocation = Vector.getZero();\n\n  override tick(project: Project) {\n    super.tick(project);\n    this.rect.size = this.rect.size.multiply(0.95);\n\n    const currentCenter = this.rect.center;\n    this.rect.location = this.rect.location.add(this.originCenterLocation.subtract(currentCenter));\n  }\n\n  static fromEntity(entity: Entity): EntityShrinkEffect {\n    return new EntityShrinkEffect(\n      10,\n      entity.collisionBox.getRectangle(),\n      \"color\" in entity && entity.color !== Color.Transparent ? (entity.color as Color).clone() : undefined,\n    );\n  }\n\n  render(project: Project) {\n    const rectangleA = this.rect.clone();\n\n    project.shapeRenderer.renderRect(\n      project.renderer.transformWorld2View(rectangleA),\n      (this.color ?? project.stageStyleManager.currentStyle.Background.clone()).toNewAlpha(1 - this.timeProgress.rate),\n      project.stageStyleManager.currentStyle.StageObjectBorder.toNewAlpha(1 - this.timeProgress.rate),\n      2 * project.camera.currentScale,\n      Renderer.NODE_ROUNDED_RADIUS * project.camera.currentScale,\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/concrete/ExplodeDashEffect.tsx",
    "content": "import { Color, mixColors, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { Random } from \"@/core/algorithm/random\";\nimport { Project } from \"@/core/Project\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\n\n/**\n * 方块的爆炸粉尘效果\n */\nexport class ExplodeDashEffect extends Effect {\n  ashLocationArray: Vector[] = [];\n  ashSpeedArray: Vector[] = [];\n\n  private getDashCountPreEntity(): number {\n    // // 说明是按Delete删除的\n    // if (project.controller.cutting.warningEntity.length === 0) {\n    //   return 0;\n    // }\n\n    // // 说明是按鼠标删除的，可以多一些\n    // return Math.floor(1000 / project.controller.cutting.warningEntity.length);\n    // TODO: 把逻辑移动到render()\n    return 30;\n  }\n\n  constructor(\n    /**\n     * 一开始为0，每tick + 1\n     */\n    public override timeProgress: ProgressNumber,\n    public rectangle: Rectangle,\n    public color: Color,\n  ) {\n    super(timeProgress);\n    for (let i = 0; i < this.getDashCountPreEntity(); i++) {\n      this.ashLocationArray.push(\n        new Vector(\n          Random.randomFloat(rectangle.left, rectangle.right),\n          Random.randomFloat(rectangle.top, rectangle.bottom),\n        ),\n      );\n      this.ashSpeedArray.push(\n        this.ashLocationArray[i].subtract(this.rectangle.center).normalize().multiply(Random.randomFloat(0.5, 10)),\n      );\n    }\n  }\n\n  override tick(project: Project) {\n    super.tick(project);\n    for (let i = 0; i < this.ashLocationArray.length; i++) {\n      this.ashLocationArray[i] = this.ashLocationArray[i].add(this.ashSpeedArray[i]);\n    }\n  }\n\n  render(project: Project) {\n    if (this.timeProgress.isFull) {\n      return;\n    }\n    for (const ashLocation of this.ashLocationArray) {\n      const viewLocation = project.renderer.transformWorld2View(ashLocation);\n      const color = mixColors(\n        project.stageStyleManager.currentStyle.StageObjectBorder,\n        project.stageStyleManager.currentStyle.StageObjectBorder.toTransparent(),\n        this.timeProgress.rate,\n      );\n\n      project.renderUtils.renderPixel(viewLocation, color);\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/concrete/LineCuttingEffect.tsx",
    "content": "import { Color, mixColors, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Project } from \"@/core/Project\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\n\n/**\n * 线段特效\n * 直接显示全部，随着时间推移逐渐透明，但会有一个从开始到结束点的划过的特效\n *\n * 0%\n * ------------------->\n * 50%\n *          ---------->\n * 100%\n *                   ->\n */\nexport class LineCuttingEffect extends Effect {\n  constructor(\n    public override timeProgress: ProgressNumber,\n    public fromLocation: Vector,\n    public toLocation: Vector,\n    public fromColor: Color,\n    public toColor: Color,\n    public lineWidth: number = 25,\n  ) {\n    super(timeProgress);\n  }\n\n  render(project: Project) {\n    if (this.timeProgress.isFull) {\n      return;\n    }\n    const fromLocation = this.fromLocation.add(\n      this.toLocation.subtract(this.fromLocation).multiply(this.timeProgress.rate),\n    );\n\n    const toLocation = this.toLocation;\n    project.worldRenderUtils.renderCuttingFlash(\n      fromLocation,\n      toLocation,\n      this.lineWidth * (1 - this.timeProgress.rate),\n      mixColors(this.fromColor, this.toColor, this.timeProgress.rate),\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/concrete/LineEffect.tsx",
    "content": "import { Color, mixColors, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Project } from \"@/core/Project\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\n\n/**\n * 线段特效\n * 直接显示全部，随着时间推移逐渐透明\n */\nexport class LineEffect extends Effect {\n  constructor(\n    public override timeProgress: ProgressNumber,\n    public fromLocation: Vector,\n    public toLocation: Vector,\n    public fromColor: Color,\n    public toColor: Color,\n    public lineWidth: number,\n  ) {\n    super(timeProgress);\n  }\n  static default(fromLocation: Vector, toLocation: Vector, width = 1) {\n    return new LineEffect(\n      new ProgressNumber(0, 30),\n      fromLocation,\n      toLocation,\n      Color.Green.clone(),\n      Color.Green.clone(),\n      width,\n    );\n  }\n\n  static rectangleEdgeTip(fromLocation: Vector, toLocation: Vector) {\n    return new LineEffect(\n      new ProgressNumber(0, 70),\n      fromLocation,\n      toLocation,\n      Color.Green.clone(),\n      Color.Green.clone(),\n      15,\n    );\n  }\n\n  render(project: Project) {\n    if (this.timeProgress.isFull) {\n      return;\n    }\n    const fromLocation = project.renderer.transformWorld2View(this.fromLocation);\n    const toLocation = project.renderer.transformWorld2View(this.toLocation);\n    const fromColor = mixColors(this.fromColor, this.fromColor.toTransparent(), this.timeProgress.rate);\n    const toColor = mixColors(this.toColor, this.toColor.toTransparent(), this.timeProgress.rate);\n    project.curveRenderer.renderGradientLine(\n      fromLocation,\n      toLocation,\n      fromColor,\n      toColor,\n      this.lineWidth * project.camera.currentScale,\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/concrete/MouseTipFeedbackEffect.tsx",
    "content": "import { Color, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Project } from \"@/core/Project\";\nimport { MouseLocation } from \"@/core/service/controlService/MouseLocation\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\n\ntype MouseTipType =\n  | \"shrink\"\n  | \"expand\"\n  | \"moveLeft\"\n  | \"moveRight\"\n  | \"moveUp\"\n  | \"moveDown\"\n  | \"move\"\n  | \"drag\"\n  | \"cameraMoveToMouse\"\n  | \"cameraBackToMouse\";\n\n/**\n * 在鼠标上释放一个小特效，用于提示\n */\nexport class MouseTipFeedbackEffect extends Effect {\n  constructor(\n    public override timeProgress: ProgressNumber,\n    public type: MouseTipType,\n    private direction: Vector,\n  ) {\n    super(timeProgress);\n  }\n\n  static default(type: MouseTipType) {\n    return new MouseTipFeedbackEffect(new ProgressNumber(0, 15), type, Vector.getZero());\n  }\n\n  /**\n   * 视野自由移动时、触控板触发 的鼠标提示\n   * @param direction\n   * @returns\n   */\n  static directionObject(direction: Vector) {\n    return new MouseTipFeedbackEffect(new ProgressNumber(0, 15), \"move\", direction);\n  }\n\n  render(project: Project) {\n    for (const effect of this.subEffects) {\n      effect.render(project);\n    }\n\n    if (this.type === \"shrink\") {\n      const hintCenter = MouseLocation.vector().add(new Vector(30, 25));\n      project.curveRenderer.renderSolidLine(\n        hintCenter.add(new Vector(-5, 0)),\n        hintCenter.add(new Vector(5, 0)),\n        project.stageStyleManager.currentStyle.StageObjectBorder.toNewAlpha(1 - this.timeProgress.rate),\n        2,\n      );\n    } else if (this.type === \"expand\") {\n      const hintCenter = MouseLocation.vector().add(new Vector(30, 25));\n      project.curveRenderer.renderSolidLine(\n        hintCenter.add(new Vector(-5, 0)),\n        hintCenter.add(new Vector(5, 0)),\n        project.stageStyleManager.currentStyle.StageObjectBorder.toNewAlpha(1 - this.timeProgress.rate),\n        2,\n      );\n      project.curveRenderer.renderSolidLine(\n        hintCenter.add(new Vector(0, -5)),\n        hintCenter.add(new Vector(0, 5)),\n        project.stageStyleManager.currentStyle.StageObjectBorder.toNewAlpha(1 - this.timeProgress.rate),\n        2,\n      );\n    } else if (this.type === \"moveLeft\") {\n      // 鼠标向左移动，右边应该出现幻影\n      project.curveRenderer.renderGradientLine(\n        MouseLocation.vector().clone(),\n        MouseLocation.vector().add(new Vector(100 * (1 - this.timeProgress.rate), 0)),\n        project.stageStyleManager.currentStyle.effects.successShadow.toNewAlpha(1 - this.timeProgress.rate),\n        project.stageStyleManager.currentStyle.effects.successShadow.toTransparent(),\n        10 * (1 - this.timeProgress.rate),\n      );\n    } else if (this.type === \"moveRight\") {\n      project.curveRenderer.renderGradientLine(\n        MouseLocation.vector().clone(),\n        MouseLocation.vector().add(new Vector(-100 * (1 - this.timeProgress.rate), 0)),\n        project.stageStyleManager.currentStyle.effects.successShadow.toNewAlpha(1 - this.timeProgress.rate),\n        project.stageStyleManager.currentStyle.effects.successShadow.toTransparent(),\n        10 * (1 - this.timeProgress.rate),\n      );\n    } else if (this.type === \"moveUp\") {\n      project.curveRenderer.renderGradientLine(\n        MouseLocation.vector().clone(),\n        MouseLocation.vector().add(new Vector(0, 100 * (1 - this.timeProgress.rate))),\n        project.stageStyleManager.currentStyle.effects.successShadow.toNewAlpha(1 - this.timeProgress.rate),\n        project.stageStyleManager.currentStyle.effects.successShadow.toTransparent(),\n        10 * (1 - this.timeProgress.rate),\n      );\n    } else if (this.type === \"moveDown\") {\n      project.curveRenderer.renderGradientLine(\n        MouseLocation.vector().clone(),\n        MouseLocation.vector().add(new Vector(0, -100 * (1 - this.timeProgress.rate))),\n        project.stageStyleManager.currentStyle.effects.successShadow.toNewAlpha(1 - this.timeProgress.rate),\n        project.stageStyleManager.currentStyle.effects.successShadow.toTransparent(),\n        10 * (1 - this.timeProgress.rate),\n      );\n    } else if (this.type === \"move\") {\n      project.curveRenderer.renderGradientLine(\n        MouseLocation.vector().clone(),\n        MouseLocation.vector().add(this.direction.multiply(-5 * (1 - this.timeProgress.rate))),\n        project.stageStyleManager.currentStyle.StageObjectBorder.toNewAlpha(1 - this.timeProgress.rate),\n        project.stageStyleManager.currentStyle.StageObjectBorder.toTransparent(),\n        2 * (1 - this.timeProgress.rate),\n      );\n    } else if (this.type === \"drag\") {\n      project.shapeRenderer.renderCircle(\n        MouseLocation.vector().clone(),\n        6 * (1 - this.timeProgress.rate),\n        Color.Transparent,\n        project.stageStyleManager.currentStyle.StageObjectBorder.toNewAlpha(1 - this.timeProgress.rate),\n        1,\n      );\n    } else if (this.type === \"cameraMoveToMouse\") {\n      project.curveRenderer.renderDashedLine(\n        project.renderer.transformWorld2View(project.camera.location),\n        MouseLocation.vector().clone(),\n        project.stageStyleManager.currentStyle.effects.successShadow.toNewAlpha(1 - this.timeProgress.rate),\n        1,\n        8,\n      );\n    } else if (this.type === \"cameraBackToMouse\") {\n      const mouseBackLocation = MouseLocation.vector().add(\n        project.renderer.transformWorld2View(project.camera.location).subtract(MouseLocation.vector()).multiply(2),\n      );\n      project.curveRenderer.renderDashedLine(\n        project.renderer.transformWorld2View(project.camera.location),\n        mouseBackLocation,\n        project.stageStyleManager.currentStyle.effects.successShadow.toNewAlpha(1 - this.timeProgress.rate),\n        1,\n        8,\n      );\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/concrete/NodeMoveShadowEffect.tsx",
    "content": "import { mixColors, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { Random } from \"@/core/algorithm/random\";\nimport { Project } from \"@/core/Project\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\n\n/**\n *\n */\nexport class NodeMoveShadowEffect extends Effect {\n  pointList: Vector[] = [];\n  pointInitSpeedList: Vector[] = [];\n\n  constructor(\n    public override timeProgress: ProgressNumber,\n    public rectangle: Rectangle,\n    public rectangleSpeed: Vector,\n  ) {\n    super(timeProgress);\n    if (rectangleSpeed.magnitude() < 1) {\n      return;\n    }\n    // 框的边缘或内部随机生成点\n    for (let i = 0; i < 2; i++) {\n      const direction = this.getSpeedMainDirection(this.rectangleSpeed);\n      let x, y;\n      if (direction === \"top\") {\n        x = Random.randomFloat(this.rectangle.left, this.rectangle.right);\n        y = this.rectangle.bottom;\n      } else if (direction === \"bottom\") {\n        x = Random.randomFloat(this.rectangle.left, this.rectangle.right);\n        y = this.rectangle.top;\n      } else if (direction === \"left\") {\n        y = Random.randomFloat(this.rectangle.top, this.rectangle.bottom);\n        x = this.rectangle.right;\n      } else if (direction === \"right\") {\n        y = Random.randomFloat(this.rectangle.top, this.rectangle.bottom);\n        x = this.rectangle.left;\n      } else {\n        x = Random.randomFloat(this.rectangle.left, this.rectangle.right);\n        y = Random.randomFloat(this.rectangle.top, this.rectangle.bottom);\n      }\n\n      this.pointList.push(new Vector(x, y));\n      this.pointInitSpeedList.push(\n        this.rectangleSpeed.multiply(Random.randomFloat(-0.1, -1)).rotateDegrees(Random.randomFloat(-30, 30)),\n      );\n    }\n  }\n\n  override tick(project: Project) {\n    super.tick(project);\n    // 移动点\n    for (let i = 0; i < this.pointList.length; i++) {\n      this.pointList[i] = this.pointList[i].add(this.pointInitSpeedList[i].multiply(1 - this.timeProgress.rate));\n    }\n  }\n\n  /**\n   * 将速度方向转换为垂直坐标轴的方向，按照最可能的方向返回\n   */\n  getSpeedMainDirection(speed: Vector): \"top\" | \"bottom\" | \"left\" | \"right\" {\n    if (Math.abs(speed.y) > Math.abs(speed.x)) {\n      // y轴更重要\n      if (speed.y > 0) {\n        return \"bottom\";\n      } else if (speed.y < 0) {\n        return \"top\";\n      }\n    } else {\n      // x轴更重要\n      if (speed.x > 0) {\n        return \"right\";\n      } else if (speed.x < 0) {\n        return \"left\";\n      }\n    }\n    // 不可能走到这里\n    return \"top\";\n  }\n\n  render(project: Project) {\n    if (this.timeProgress.isFull) {\n      return;\n    }\n    for (const point of this.pointList) {\n      const viewLocation = project.renderer.transformWorld2View(point);\n      const color = mixColors(\n        project.stageStyleManager.currentStyle.effects.flash,\n        project.stageStyleManager.currentStyle.effects.flash.toTransparent(),\n        this.timeProgress.rate,\n      );\n\n      project.renderUtils.renderPixel(viewLocation, color);\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/concrete/PenStrokeDeletedEffect.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\nimport { PenStroke } from \"@/core/stage/stageObject/entity/PenStroke\";\nimport { Color, ProgressNumber, Vector } from \"@graphif/data-structures\";\n\nexport class PenStrokeDeletedEffect extends Effect {\n  private pathList: Vector[] = [];\n  private color: Color = new Color(0, 0, 0);\n  private width: number = 1;\n\n  constructor(\n    public override timeProgress: ProgressNumber,\n    penStroke: PenStroke,\n  ) {\n    super(timeProgress);\n    const segmentList = penStroke.segments;\n    this.pathList = penStroke.getPath();\n    this.color = penStroke.color;\n    this.width = segmentList[0].pressure * 5;\n  }\n\n  static fromPenStroke(penStroke: PenStroke): PenStrokeDeletedEffect {\n    // 将固定时间设置为50帧（大约0.8秒，假设60FPS）\n    // 不再根据路径长度设置进度条最大值\n    return new PenStrokeDeletedEffect(new ProgressNumber(0, 50), penStroke);\n  }\n\n  override tick(project: Project) {\n    super.tick(project);\n    // 移除基于路径长度的分段逻辑\n    // 简单地使用进度条的进度来控制透明度\n  }\n\n  render(project: Project) {\n    if (this.timeProgress.isFull) {\n      return;\n    }\n\n    // 渲染整个路径，但使用进度来控制透明度\n    project.curveRenderer.renderSolidLineMultiple(\n      this.pathList.map((v) => project.renderer.transformWorld2View(v)),\n      this.color.toNewAlpha(1 - this.timeProgress.rate), // 随着进度增加，透明度逐渐降低\n      this.width * project.camera.currentScale,\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/concrete/README.md",
    "content": "文件名和类名需要保持绝对一致\n\n否则特效开关可能会出问题。\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/concrete/RectangleLittleNoteEffect.tsx",
    "content": "import { Color, mixColors, ProgressNumber } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { Random } from \"@/core/algorithm/random\";\nimport { Project } from \"@/core/Project\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\nimport { StageObject } from \"@/core/stage/stageObject/abstract/StageObject\";\n\n/**\n * 用于逻辑节点执行了一次效果\n * 附着在矩形上，从中心向外扩散\n */\nexport class RectangleLittleNoteEffect extends Effect {\n  private currentRect: Rectangle;\n\n  constructor(\n    public override timeProgress: ProgressNumber,\n    public targetRectangle: Rectangle,\n    public strokeColor: Color,\n    public strokeWidth: number = 2,\n  ) {\n    super(timeProgress);\n    this.currentRect = targetRectangle.clone();\n  }\n\n  static fromUtilsLittleNote(stageObject: StageObject): RectangleLittleNoteEffect {\n    return new RectangleLittleNoteEffect(\n      new ProgressNumber(0, 15),\n      stageObject.collisionBox.getRectangle(),\n      Color.Green,\n    );\n  }\n\n  static fromUtilsSlowNote(stageObject: StageObject): RectangleLittleNoteEffect {\n    return new RectangleLittleNoteEffect(\n      new ProgressNumber(0, 100),\n      stageObject.collisionBox.getRectangle(),\n      Color.Green,\n    );\n  }\n\n  static fromSearchNode(stageObject: StageObject): RectangleLittleNoteEffect {\n    return new RectangleLittleNoteEffect(\n      new ProgressNumber(0, 30),\n      stageObject.collisionBox.getRectangle(),\n      Color.Magenta,\n      30,\n    );\n  }\n\n  override tick(project: Project) {\n    super.tick(project);\n    this.currentRect = this.currentRect.expandFromCenter(Random.randomFloat(1, 2));\n  }\n\n  render(project: Project) {\n    if (this.timeProgress.isFull) {\n      return;\n    }\n    project.shapeRenderer.renderRect(\n      project.renderer.transformWorld2View(this.currentRect),\n      Color.Transparent,\n      mixColors(Color.Transparent, this.strokeColor, 1 - this.timeProgress.rate),\n      this.strokeWidth * project.camera.currentScale,\n      8 * project.camera.currentScale,\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/concrete/RectangleNoteEffect.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\nimport { reverseAnimate } from \"@/core/service/feedbackService/effectEngine/mathTools/animateFunctions\";\nimport { easeOutQuint } from \"@/core/service/feedbackService/effectEngine/mathTools/easings\";\nimport { Color, mixColors, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\n\n/**\n * 用于提示某个矩形区域的效果\n *\n * 一个无比巨大的矩形（恰好是视野大小）突然缩小到目标矩形大小上。\n *\n * 这个效果可以用来提示某个矩形区域的存在，或者用来强调某个矩形区域的重要性。\n *\n * 目标矩形大小是世界坐标系\n */\nexport class RectangleNoteEffect extends Effect {\n  constructor(\n    public override timeProgress: ProgressNumber,\n    public targetRectangle: Rectangle,\n    public strokeColor: Color,\n  ) {\n    super(timeProgress);\n  }\n\n  static fromShiftClickSelect(project: Project, rectangle: Rectangle) {\n    return new RectangleNoteEffect(\n      new ProgressNumber(0, 50),\n      rectangle,\n      project.stageStyleManager.currentStyle.CollideBoxPreSelected.toSolid(),\n    );\n  }\n\n  render(project: Project) {\n    if (this.timeProgress.isFull) {\n      return;\n    }\n    const startRect = project.renderer.getCoverWorldRectangle();\n    const currentRect = new Rectangle(\n      startRect.location.add(\n        this.targetRectangle.location.subtract(startRect.location).multiply(easeOutQuint(this.timeProgress.rate)),\n      ),\n      new Vector(\n        startRect.size.x + (this.targetRectangle.size.x - startRect.size.x) * easeOutQuint(this.timeProgress.rate),\n        startRect.size.y + (this.targetRectangle.size.y - startRect.size.y) * easeOutQuint(this.timeProgress.rate),\n      ),\n    );\n    project.shapeRenderer.renderRect(\n      project.renderer.transformWorld2View(currentRect),\n      Color.Transparent,\n      mixColors(Color.Transparent, this.strokeColor, reverseAnimate(this.timeProgress.rate)),\n      2,\n      5,\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/concrete/RectangleNoteReversedEffect.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\nimport { reverseAnimate } from \"@/core/service/feedbackService/effectEngine/mathTools/animateFunctions\";\nimport { easeInQuint } from \"@/core/service/feedbackService/effectEngine/mathTools/easings\";\nimport { Color, mixColors, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\n\n/**\n * 用于提示某个矩形区域的效果\n *\n * 一个无比巨大的矩形（恰好是视野大小）突然缩小到目标矩形大小上。\n *\n * 这个效果可以用来提示某个矩形区域的存在，或者用来强调某个矩形区域的重要性。\n *\n * 目标矩形大小是世界坐标系\n */\nexport class RectangleNoteReversedEffect extends Effect {\n  constructor(\n    public override timeProgress: ProgressNumber,\n    public targetRectangle: Rectangle,\n    public strokeColor: Color,\n  ) {\n    super(timeProgress);\n  }\n\n  render(project: Project) {\n    if (this.timeProgress.isFull) {\n      return;\n    }\n    const startRect = project.renderer.getCoverWorldRectangle();\n    const currentRect = new Rectangle(\n      startRect.location.add(\n        this.targetRectangle.location.subtract(startRect.location).multiply(easeInQuint(1 - this.timeProgress.rate)),\n      ),\n      new Vector(\n        startRect.size.x + (this.targetRectangle.size.x - startRect.size.x) * easeInQuint(1 - this.timeProgress.rate),\n        startRect.size.y + (this.targetRectangle.size.y - startRect.size.y) * easeInQuint(1 - this.timeProgress.rate),\n      ),\n    );\n    project.shapeRenderer.renderRect(\n      project.renderer.transformWorld2View(currentRect),\n      Color.Transparent,\n      mixColors(this.strokeColor, Color.Transparent, reverseAnimate(this.timeProgress.rate)),\n      2,\n      5,\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/concrete/RectanglePushInEffect.tsx",
    "content": "import { Color, ProgressNumber } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { Project } from \"@/core/Project\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\nimport { LineCuttingEffect } from \"@/core/service/feedbackService/effectEngine/concrete/LineCuttingEffect\";\n\n/**\n * 用于某个节点进入了某个Section内部，四个角连向了父Section矩形的四个角\n */\nexport class RectanglePushInEffect extends Effect {\n  constructor(\n    public smallRectangle: Rectangle,\n    public bigRectangle: Rectangle,\n    public override timeProgress: ProgressNumber = new ProgressNumber(0, 50),\n    private reversed = false,\n  ) {\n    super(timeProgress);\n    if (this.reversed) {\n      this.subEffects = [\n        new LineCuttingEffect(timeProgress, bigRectangle.leftTop, smallRectangle.leftTop, Color.Red, Color.Red),\n        new LineCuttingEffect(timeProgress, bigRectangle.rightTop, smallRectangle.rightTop, Color.Red, Color.Red),\n        new LineCuttingEffect(timeProgress, bigRectangle.leftBottom, smallRectangle.leftBottom, Color.Red, Color.Red),\n        new LineCuttingEffect(timeProgress, bigRectangle.rightBottom, smallRectangle.rightBottom, Color.Red, Color.Red),\n      ];\n    } else {\n      this.subEffects = [\n        new LineCuttingEffect(timeProgress, smallRectangle.leftTop, bigRectangle.leftTop, Color.Green, Color.Green),\n        new LineCuttingEffect(timeProgress, smallRectangle.rightTop, bigRectangle.rightTop, Color.Green, Color.Green),\n        new LineCuttingEffect(\n          timeProgress,\n          smallRectangle.leftBottom,\n          bigRectangle.leftBottom,\n          Color.Green,\n          Color.Green,\n        ),\n        new LineCuttingEffect(\n          timeProgress,\n          smallRectangle.rightBottom,\n          bigRectangle.rightBottom,\n          Color.Green,\n          Color.Green,\n        ),\n      ];\n    }\n  }\n\n  static sectionGoInGoOut(entityRectangle: Rectangle, sectionRectangle: Rectangle, isGoOut = false) {\n    const timeProgress = new ProgressNumber(0, 50);\n    return new RectanglePushInEffect(entityRectangle, sectionRectangle, timeProgress, isGoOut);\n  }\n\n  protected subEffects: Effect[];\n\n  render(project: Project) {\n    for (const effect of this.subEffects) {\n      effect.render(project);\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/concrete/RectangleRenderEffect.tsx",
    "content": "import { Color, mixColors, ProgressNumber } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { Project } from \"@/core/Project\";\nimport { Renderer } from \"@/core/render/canvas2d/renderer\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\n\n/**\n * 自动吸附对齐的时候闪烁一下实体矩形边框\n */\nexport class RectangleRenderEffect extends Effect {\n  constructor(\n    public override timeProgress: ProgressNumber,\n    private rectangle: Rectangle,\n    private fillColor: Color,\n    private strokeColor: Color,\n    private strokeWidth: number,\n  ) {\n    super(timeProgress);\n  }\n\n  render(project: Project) {\n    project.shapeRenderer.renderRect(\n      project.renderer.transformWorld2View(this.rectangle),\n      this.fillColor,\n      mixColors(this.strokeColor, this.strokeColor.toTransparent(), this.timeProgress.rate),\n      this.strokeWidth * project.camera.currentScale,\n      Renderer.NODE_ROUNDED_RADIUS * project.camera.currentScale,\n    );\n  }\n\n  static fromPreAlign(rectangle: Rectangle): RectangleRenderEffect {\n    return new RectangleRenderEffect(\n      new ProgressNumber(0, 10),\n      rectangle,\n      Color.Transparent,\n      // TODO: 先暂时不解决 this.project 报错问题\n      // this.project.stageStyleManager.currentStyle.CollideBoxPreSelected,\n      Color.White,\n      4,\n    );\n  }\n\n  static fromShiftClickSelect(rectangle: Rectangle): RectangleRenderEffect {\n    return new RectangleRenderEffect(\n      new ProgressNumber(0, 100),\n      rectangle,\n      Color.Transparent,\n      // TODO\n      Color.White,\n      // this.project.stageStyleManager.currentStyle.CollideBoxPreSelected.toSolid(),\n      4,\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/concrete/RectangleSlideEffect.tsx",
    "content": "import { Color, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { Random } from \"@/core/algorithm/random\";\nimport { Project } from \"@/core/Project\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\nimport { LineEffect } from \"@/core/service/feedbackService/effectEngine/concrete/LineEffect\";\n\n/**\n * 专门处理矩形水平和垂直移动效果\n * 显示渐变直线作为视觉效果\n */\nexport class RectangleSlideEffect extends Effect {\n  constructor(\n    public startRect: Rectangle,\n    public endRect: Rectangle,\n    public override timeProgress: ProgressNumber = new ProgressNumber(0, 50),\n    public color: Color = Color.Blue,\n    public isHorizontal: boolean = true,\n  ) {\n    super(timeProgress);\n    this.subEffects = [];\n    const spacing = 20; // 尾翼线之间保持固定间距\n    const minLength = 100;\n    const maxLength = 200;\n\n    if (isHorizontal) {\n      // 水平移动：只显示移动方向相反侧的尾翼线\n      const isMovingRight = endRect.left > startRect.left;\n      const edgeX = isMovingRight ? endRect.left : endRect.right;\n\n      // 在垂直边缘按固定间距分布尾翼线\n      for (let offset = 0; offset <= endRect.height; offset += spacing) {\n        const y = endRect.top + offset;\n        const startPoint = new Vector(edgeX, y);\n        const endPoint = isMovingRight\n          ? startPoint.subtract(new Vector(Random.randomFloat(minLength, maxLength), 0))\n          : startPoint.add(new Vector(Random.randomFloat(minLength, maxLength), 0));\n\n        this.subEffects.push(\n          new LineEffect(timeProgress.clone(), startPoint, endPoint, color.toNewAlpha(0.8), color.toNewAlpha(0.2), 2),\n        );\n      }\n    } else {\n      // 垂直移动：只显示移动方向相反侧的尾翼线\n      const isMovingDown = endRect.top > startRect.top;\n      const edgeY = isMovingDown ? endRect.top : endRect.bottom;\n\n      // 在水平边缘按固定间距分布尾翼线\n      for (let offset = 0; offset <= endRect.width; offset += spacing) {\n        const x = endRect.left + offset;\n        const startPoint = new Vector(x, edgeY);\n        const endPoint = isMovingDown\n          ? startPoint.subtract(new Vector(0, Random.randomFloat(minLength, maxLength)))\n          : startPoint.add(new Vector(0, Random.randomFloat(minLength, maxLength)));\n\n        this.subEffects.push(\n          new LineEffect(timeProgress.clone(), startPoint, endPoint, color.toNewAlpha(0.8), color.toNewAlpha(0.2), 2),\n        );\n      }\n    }\n  }\n\n  protected subEffects: Effect[];\n\n  render(project: Project) {\n    for (const effect of this.subEffects) {\n      effect.render(project);\n    }\n  }\n\n  /**\n   * 创建水平滑动效果\n   */\n  static horizontalSlide(startRect: Rectangle, endRect: Rectangle, color?: Color) {\n    const timeProgress = new ProgressNumber(0, 30);\n    return new RectangleSlideEffect(startRect, endRect, timeProgress, color, true);\n  }\n\n  /**\n   * 创建垂直滑动效果\n   */\n  static verticalSlide(startRect: Rectangle, endRect: Rectangle, color?: Color) {\n    const timeProgress = new ProgressNumber(0, 30);\n    return new RectangleSlideEffect(startRect, endRect, timeProgress, color, false);\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/concrete/RectangleSplitTwoPartEffect.tsx",
    "content": "import { Color, mixColors, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { Random } from \"@/core/algorithm/random\";\nimport { Project } from \"@/core/Project\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\n\n/**\n * 一个矩形被一刀切成两半，两个多边形的的特效\n */\nexport class RectangleSplitTwoPartEffect extends Effect {\n  /**\n   * 长度只有2\n   */\n  private splitedRectangles: SplitedRectangle[] = [];\n  private initFillColor: Color;\n  private endFillColor: Color;\n\n  constructor(\n    rectangle: Rectangle,\n    twoPoint: Vector[],\n    time: number,\n    fillColor: Color,\n    strokeColor: Color,\n    strokeWidth: number,\n  ) {\n    super(new ProgressNumber(0, time));\n    this.initFillColor = fillColor.clone();\n    this.endFillColor = fillColor.toTransparent();\n\n    const leftTop = rectangle.location;\n    const rightTop = new Vector(leftTop.x + rectangle.size.x, leftTop.y);\n    const rightBottom = new Vector(rightTop.x, leftTop.y + rectangle.size.y);\n    const leftBottom = new Vector(leftTop.x, rightBottom.y);\n    const p1 = twoPoint[0];\n    const p2 = twoPoint[1];\n\n    const getEdge = (rect: Rectangle, point: Vector): string => {\n      const x = rect.location.x;\n      const y = rect.location.y;\n      const w = rect.size.x;\n      const h = rect.size.y;\n      if (point.x === x) return \"left\";\n      if (point.x === x + w) return \"right\";\n      if (point.y === y) return \"top\";\n      if (point.y === y + h) return \"bottom\";\n      // throw new Error(\"Point is not on the rectangle's edge\");\n      // 其他有一种意外情况就是在内部\n      return \"inner\";\n    };\n\n    const edge1 = getEdge(rectangle, p1);\n    const edge2 = getEdge(rectangle, p2);\n\n    let poly1: Vector[], poly2: Vector[];\n\n    // 处理对边情况\n    if ((edge1 === \"left\" && edge2 === \"right\") || (edge1 === \"right\" && edge2 === \"left\")) {\n      // 横着切\n      const [leftPt, rightPt] = edge1 === \"left\" ? [p1, p2] : [p2, p1];\n      poly1 = [leftTop, leftPt, rightPt, rightTop];\n      poly2 = [leftPt, leftBottom, rightBottom, rightPt];\n    } else if ((edge1 === \"top\" && edge2 === \"bottom\") || (edge1 === \"bottom\" && edge2 === \"top\")) {\n      // 竖着切\n      const [topPt, bottomPt] = edge1 === \"top\" ? [p1, p2] : [p2, p1];\n      poly1 = [leftTop, topPt, bottomPt, leftBottom];\n      poly2 = [topPt, rightTop, rightBottom, bottomPt];\n    } else if ((edge1 === \"left\" && edge2 === \"bottom\") || (edge1 === \"bottom\" && edge2 === \"left\")) {\n      const [leftPt, bottomPt] = edge1 === \"left\" ? [p1, p2] : [p2, p1];\n      // 切左下角\n      poly1 = [leftPt, leftTop, rightTop, rightBottom, bottomPt];\n      poly2 = [leftPt, leftBottom, bottomPt];\n    } else if ((edge1 === \"right\" && edge2 === \"top\") || (edge1 === \"top\" && edge2 === \"right\")) {\n      // 切右上角\n      const [rightPt, topPt] = edge1 === \"right\" ? [p1, p2] : [p2, p1];\n      poly1 = [rightPt, rightTop, topPt];\n      poly2 = [rightPt, rightBottom, leftBottom, leftTop, topPt];\n    } else if ((edge1 === \"left\" && edge2 === \"top\") || (edge1 === \"top\" && edge2 === \"left\")) {\n      // 左上切割（连接左边和顶边）\n      const [leftPt, topPt] = edge1 === \"left\" ? [p1, p2] : [p2, p1];\n\n      // 多边形1（左上三角）：leftPt -> leftTop -> topPt\n      poly1 = [leftPt, leftTop, topPt];\n\n      // 多边形2（剩余部分）：leftPt -> leftBottom -> rightBottom -> rightTop -> topPt\n      poly2 = [leftPt, leftBottom, rightBottom, rightTop, topPt];\n    } else if ((edge1 === \"right\" && edge2 === \"bottom\") || (edge1 === \"bottom\" && edge2 === \"right\")) {\n      // 右下切割（连接右边和底边）\n      const [rightPt, bottomPt] = edge1 === \"right\" ? [p1, p2] : [p2, p1];\n\n      // 多边形1（右下三角）：rightPt -> rightBottom -> bottomPt\n      poly1 = [rightPt, rightBottom, bottomPt];\n\n      // 多边形2（剩余部分）：rightPt -> rightTop -> leftTop -> leftBottom -> bottomPt\n      poly2 = [rightPt, rightTop, leftTop, leftBottom, bottomPt];\n    } else if (edge1 === \"inner\" || edge2 === \"inner\") {\n      const innerPt = edge1 === \"inner\" ? p1 : p2;\n      // 直接裂成四块\n      poly1 = [leftTop, rightTop, innerPt];\n      poly2 = [leftTop, leftBottom, innerPt];\n      const poly3 = [rightTop, rightBottom, innerPt];\n      const poly4 = [leftBottom, rightBottom, innerPt];\n      this.splitedRectangles.push(\n        new SplitedRectangle(poly1, fillColor, strokeColor, strokeWidth),\n        new SplitedRectangle(poly2, fillColor, strokeColor, strokeWidth),\n        new SplitedRectangle(poly3, fillColor, strokeColor, strokeWidth),\n        new SplitedRectangle(poly4, fillColor, strokeColor, strokeWidth),\n      );\n      for (const rect of this.splitedRectangles) {\n        rect.speed = new Vector(0, -Random.randomInt(1, 10)).rotateDegrees(Random.randomInt(-45, 45));\n        rect.accleration = new Vector(0, 0.5);\n      }\n      return;\n    } else {\n      // 处理其他情况或抛出错误\n      // throw new Error(`Unsupported edge combination: ${edge1} and ${edge2}`);\n      poly1 = [leftTop, rightTop, rightBottom, leftBottom];\n      poly2 = [leftTop, rightTop, rightBottom, leftBottom];\n    }\n\n    this.splitedRectangles.push(\n      new SplitedRectangle(poly1, fillColor, strokeColor, strokeWidth),\n      new SplitedRectangle(poly2, fillColor, strokeColor, strokeWidth),\n    );\n    this.splitedRectangles.sort((a, b) => a.center.x - b.center.x);\n    this.splitedRectangles[0].speed = new Vector(-Random.randomInt(1, 10), -Random.randomInt(0, 3));\n    this.splitedRectangles[1].speed = new Vector(Random.randomInt(1, 10), -Random.randomInt(0, 3));\n    // 重力加速度\n    this.splitedRectangles[0].accleration = new Vector(0, 0.5);\n    this.splitedRectangles[1].accleration = new Vector(0, 0.5);\n  }\n  render(project: Project) {\n    for (const rect of this.splitedRectangles) {\n      rect.render(project);\n    }\n  }\n  override tick(project: Project) {\n    super.tick(project);\n    for (const rect of this.splitedRectangles) {\n      rect.tick();\n      rect.fillColor = mixColors(this.initFillColor, this.endFillColor, this.timeProgress.rate);\n      rect.strokeColor = rect.strokeColor.toNewAlpha(1 - this.timeProgress.rate);\n    }\n  }\n}\n\n/**\n * 被切割了的矩形\n * @param polygon 切割后的多边形\n */\nclass SplitedRectangle {\n  // 当前速度\n  public speed = new Vector(0, 0);\n  public accleration = new Vector(0, 0);\n\n  /**\n   * 多边形的顶点，按照一个时针方向排列，起点和终点不重复出现在数组中。\n   * @param polygon 切割后的多边形\n   */\n  constructor(\n    public polygon: Vector[],\n    public fillColor: Color,\n    public strokeColor: Color,\n    private strokeWidth: number,\n  ) {\n    if (polygon.length < 3 || polygon.length > 5) {\n      throw new Error(\"Polygon must have 3 or 5 points\");\n    }\n  }\n\n  get center(): Vector {\n    // 获取这个碎片形状的外接矩形的中心点\n    const minX = Math.min(...this.polygon.map((v) => v.x));\n    const maxX = Math.max(...this.polygon.map((v) => v.x));\n    const minY = Math.min(...this.polygon.map((v) => v.y));\n    const maxY = Math.max(...this.polygon.map((v) => v.y));\n    return new Vector((minX + maxX) / 2, (minY + maxY) / 2);\n  }\n\n  public tick() {\n    // 移动这个碎片形状\n    this.speed = this.speed.add(this.accleration);\n    this.move(this.speed);\n  }\n\n  public move(offset: Vector) {\n    // 移动这个碎片形状\n    const newPoints = this.polygon.map((v) => v.add(offset));\n    this.polygon = newPoints;\n  }\n\n  public moveTo(position: Vector) {\n    // 移动这个碎片形状到指定的位置\n    const offset = position.subtract(this.center);\n    this.move(offset);\n  }\n\n  render(project: Project) {\n    project.shapeRenderer.renderPolygonAndFill(\n      this.polygon.map((v) => project.renderer.transformWorld2View(v)),\n      this.fillColor,\n      this.strokeColor,\n      this.strokeWidth * project.camera.currentScale,\n      \"round\",\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/concrete/TextRaiseEffectLocated.tsx",
    "content": "import { ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Project } from \"@/core/Project\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\n\n/**\n * 在特定的世界坐标系下渲染一个文字\n * 用途之一：给逻辑引擎渲染编号\n */\nexport class TextRaiseEffectLocated extends Effect {\n  constructor(\n    public text: string,\n    public location: Vector,\n    public raiseDistance: number,\n    public textSize: number = 16,\n    public override timeProgress: ProgressNumber = new ProgressNumber(0, 100),\n  ) {\n    super(timeProgress);\n  }\n\n  render(project: Project) {\n    if (this.timeProgress.isFull) {\n      return;\n    }\n    project.textRenderer.renderTextFromCenter(\n      this.text,\n      project.renderer.transformWorld2View(\n        this.location.add(new Vector(0, -this.timeProgress.rate * this.raiseDistance)),\n      ),\n      this.textSize * project.camera.currentScale,\n      project.stageStyleManager.currentStyle.CollideBoxPreSelected,\n    );\n  }\n\n  static fromDebugLogicNode(n: number, location: Vector): TextRaiseEffectLocated {\n    return new TextRaiseEffectLocated(`${n}`, location, 0, 150, new ProgressNumber(0, 3));\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/concrete/ViewFlashEffect.tsx",
    "content": "import { Color, mixColors, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { Project } from \"@/core/Project\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\n\n/**\n * 屏幕闪颜色效果\n */\nexport class ViewFlashEffect extends Effect {\n  constructor(\n    public color: Color,\n    public override timeProgress: ProgressNumber = new ProgressNumber(0, 100),\n  ) {\n    super(timeProgress);\n  }\n\n  static SaveFile() {\n    return new ViewFlashEffect(\n      this.project.stageStyleManager.currentStyle.effects.windowFlash,\n      new ProgressNumber(0, 10),\n    );\n  }\n  static Portal() {\n    return new ViewFlashEffect(new Color(127, 75, 124), new ProgressNumber(0, 10));\n  }\n\n  render(project: Project) {\n    if (this.timeProgress.isFull) {\n      return;\n    }\n    project.shapeRenderer.renderRect(\n      new Rectangle(new Vector(-10000, -10000), new Vector(20000, 20000)),\n      mixColors(this.color, new Color(0, 0, 0, 0), this.timeProgress.rate),\n      Color.Transparent,\n      0,\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/concrete/ViewOutlineFlashEffect.tsx",
    "content": "import { Color, mixColors, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { Project } from \"@/core/Project\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\n\n/**\n * 屏幕边缘闪颜色效果\n */\nexport class ViewOutlineFlashEffect extends Effect {\n  constructor(\n    public color: Color,\n    public override timeProgress: ProgressNumber = new ProgressNumber(0, 100),\n  ) {\n    super(timeProgress);\n  }\n\n  static normal(color: Color): ViewOutlineFlashEffect {\n    return new ViewOutlineFlashEffect(color);\n  }\n\n  static short(color: Color): ViewOutlineFlashEffect {\n    return new ViewOutlineFlashEffect(color, new ProgressNumber(0, 5));\n  }\n\n  render(project: Project) {\n    if (this.timeProgress.isFull) {\n      return;\n    }\n    const viewRect = project.renderer.getCoverWorldRectangle();\n\n    const currentColor = mixColors(this.color, new Color(0, 0, 0, 0), this.timeProgress.rate);\n    // 左侧边缘\n\n    project.shapeRenderer.renderRectWithShadow(\n      project.renderer.transformWorld2View(new Rectangle(viewRect.leftTop, new Vector(20, viewRect.size.y))),\n      currentColor,\n      Color.Transparent,\n      0,\n      currentColor,\n      200,\n    );\n    // 右侧边缘\n    project.shapeRenderer.renderRectWithShadow(\n      project.renderer.transformWorld2View(\n        new Rectangle(new Vector(viewRect.left + viewRect.size.x - 20, viewRect.top), new Vector(20, viewRect.size.y)),\n      ),\n      currentColor,\n      Color.Transparent,\n      0,\n      currentColor,\n      50,\n    );\n    // 上侧边缘\n    project.shapeRenderer.renderRectWithShadow(\n      project.renderer.transformWorld2View(new Rectangle(viewRect.leftTop, new Vector(viewRect.size.x, 20))),\n      currentColor,\n      Color.Transparent,\n      0,\n      currentColor,\n      50,\n    );\n    // 下侧边缘\n    project.shapeRenderer.renderRectWithShadow(\n      project.renderer.transformWorld2View(\n        new Rectangle(new Vector(viewRect.left, viewRect.top + viewRect.size.y - 20), new Vector(viewRect.size.x, 20)),\n      ),\n      currentColor,\n      Color.Transparent,\n      0,\n      currentColor,\n      50,\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/effectElements/effectParticle.tsx",
    "content": "import { Vector } from \"@graphif/data-structures\";\nimport { EffectColors } from \"../../stageStyle/stageStyle\";\n\n/**\n * 粒子类\n */\nexport class EffectParticle {\n  constructor(\n    public location: Vector,\n    public velocity: Vector,\n    public acceleration: Vector,\n    public color: keyof EffectColors,\n    public size: number,\n  ) {}\n\n  tick() {\n    this.location = this.location.add(this.velocity);\n    this.velocity = this.velocity.add(this.acceleration);\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/effectMachine.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { Effect } from \"@/core/service/feedbackService/effectEngine/effectObject\";\nimport { getOriginalNameOf } from \"virtual:original-class-name\";\n\n/**\n * 特效机器\n *\n * 它将产生一个机器对象，并唯一附着在舞台上。\n * 如果有多页签多页面，则每个页面都有自己的唯一特效机器。\n */\n@service(\"effects\")\nexport class Effects {\n  constructor(private readonly project: Project) {}\n\n  private effects: Effect[] = [];\n\n  public addEffect(effect: Effect) {\n    if (!(Settings.effectsPerferences[getOriginalNameOf(effect.constructor)] ?? true)) {\n      return;\n    }\n    this.effects.push(effect);\n  }\n\n  public get effectsCount() {\n    return this.effects.length;\n  }\n\n  public addEffects(effects: Effect[]) {\n    this.effects.push(\n      ...effects.filter((effect) => Settings.effectsPerferences[getOriginalNameOf(effect.constructor)] ?? true),\n    );\n  }\n\n  tick() {\n    // 清理过时特效\n    this.effects = this.effects.filter((effect) => !effect.timeProgress.isFull);\n    for (const effect of this.effects) {\n      effect.tick(this.project);\n      effect.render(this.project);\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/effectObject.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { ProgressNumber } from \"@graphif/data-structures\";\n\n/**\n * 一次性特效类\n * timeProgress 0~max 表示时间进度，0表示开始，单位：帧\n */\nexport abstract class Effect {\n  constructor(\n    /**\n     * 注意这个进度条初始值应该是0\n     */\n    public timeProgress: ProgressNumber,\n    public delay: number = 0,\n  ) {}\n\n  /** 子特效（构成树形组合模式） */\n  protected subEffects: Effect[] = [];\n\n  tick(project: Project): void {\n    if (this.timeProgress.maxValue > this.timeProgress.curValue) {\n      // 自动+1帧\n      this.timeProgress.add(1);\n    } else {\n      // 倒放\n      this.timeProgress.subtract(1);\n    }\n    // 子特效tick\n    for (const subEffect of this.subEffects) {\n      subEffect.tick(project);\n    }\n  }\n\n  /**\n   * 渲染方法\n   */\n  abstract render(project: Project): void;\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/mathTools/README.md",
    "content": "注意：这个文件夹里的数学工具类仅应该用于为特效服务，若发现有其他用途，应该移动到 核心中的算法文件夹中。\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/mathTools/animateFunctions.tsx",
    "content": "/**\n * 更多的关于动画的函数\n *\n */\n\n//\n/**\n * 0 -> 1 -> 0\n * @param t\n * @returns\n */\nexport const reverseAnimate = (t: number) => {\n  return Math.sin(t * Math.PI);\n};\n\n/**\n * 正弦函数\n */\nexport const sine = (t: number, maxValue: number, minValue: number, xRate: number) => {\n  const y = Math.sin(t * xRate);\n  return (maxValue - minValue) * y + minValue;\n};\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/mathTools/easings.tsx",
    "content": "/**\n * 此模块定义了一些常见的缓动函数\n *\n * https://easings.net/zh-cn\n */\n\n/**\n *\n * @param t\n * @returns\n */\nexport const easeInSine = (t: number) => 1 - Math.cos((t * Math.PI) / 2);\nexport const easeOutSine = (t: number) => Math.sin((t * Math.PI) / 2);\nexport const easeInOutSine = (t: number) => -(Math.cos(Math.PI * t) - 1) / 2;\n\nexport const easeInQuad = (t: number) => t * t;\nexport const easeOutQuad = (t: number) => t * (2 - t);\nexport const easeInOutQuad = (t: number) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t);\n\nexport const easeInCubic = (t: number) => t * t * t;\nexport const easeOutCubic = (t: number) => --t * t * t + 1;\nexport const easeInOutCubic = (t: number) => (t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1);\n\nexport const easeInQuart = (t: number) => t * t * t * t;\nexport const easeOutQuart = (t: number) => 1 - --t * t * t * t;\nexport const easeInOutQuart = (t: number) => (t < 0.5 ? 8 * t * t * t * t : 1 - 8 * --t * t * t * t);\n\nexport const easeInQuint = (t: number) => t * t * t * t * t;\nexport const easeOutQuint = (t: number) => 1 + --t * t * t * t * t;\nexport const easeInOutQuint = (t: number) => (t < 0.5 ? 16 * t * t * t * t * t : 1 + 16 * --t * t * t * t * t);\n\nexport const easeInExpo = (t: number) => (t === 0 ? 0 : Math.pow(2, 10 * t - 10));\nexport const easeOutExpo = (t: number) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t));\nexport const easeInOutExpo = (t: number) =>\n  t === 0 ? 0 : t === 1 ? 1 : t < 0.5 ? Math.pow(2, 20 * t - 10) / 2 : (2 - Math.pow(2, -20 * t + 10)) / 2;\n\nexport const easeInCirc = (t: number) => 1 - Math.sqrt(1 - t * t);\nexport const easeOutCirc = (t: number) => Math.sqrt(1 - --t * t);\nexport const easeInOutCirc = (t: number) =>\n  t < 0.5 ? (1 - Math.sqrt(1 - 2 * t * t)) / 2 : (Math.sqrt(1 - (2 * t - 1) * (2 * t - 1)) + 1) / 2;\n\nexport const easeInElastic = (t: number) =>\n  t === 0 ? 0 : t === 1 ? 1 : -Math.pow(2, 10 * t - 10) * Math.sin((t * 10 - 10.75) * ((2 * Math.PI) / 3));\nexport const easeOutElastic = (t: number) =>\n  t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * ((2 * Math.PI) / 3)) + 1;\nexport const easeInOutElastic = (t: number) =>\n  t === 0\n    ? 0\n    : t === 1\n      ? 1\n      : t < 0.5\n        ? -(Math.pow(2, 20 * t - 10) * Math.sin((20 * t - 11.125) * ((2 * Math.PI) / 4.5))) / 2\n        : (Math.pow(2, -20 * t + 10) * Math.sin((20 * t - 11.125) * ((2 * Math.PI) / 4.5))) / 2 + 1;\n\nexport const easeInBack = (t: number) => c3 * t * t * t - c1 * t * t;\nexport const easeOutBack = (t: number) => 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);\nexport const easeInOutBack = (t: number) =>\n  t < 0.5\n    ? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2\n    : (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2;\n\nconst c1 = 1.70158;\nconst c2 = c1 * 1.525;\nconst c3 = c1 + 1;\n"
  },
  {
    "path": "app/src/core/service/feedbackService/effectEngine/mathTools/rateFunctions.tsx",
    "content": "export namespace RateFunctions {\n  /**\n   * 从0,0点到 1,0，y值从0到1再回到0\n   * @param xRate\n   * @returns\n   */\n  export function doorFunction(xRate: number): number {\n    return Math.sin(xRate * Math.PI) ** 1;\n  }\n\n  /**\n   * 开口向下的二次函数，恰好过0,0点和1,0点，最大高度为1\n   * @param xRate 输入的x值\n   * @returns 对应的y值\n   */\n  export function quadraticDownward(xRate: number): number {\n    // 计算开口向下的二次函数值\n    return -4 * (xRate - 0.5) ** 2 + 1;\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/stageStyle/README.md",
    "content": "舞台颜色样式模块\n\n注意，是Canvas的舞台样式模块，不是UI层面的样式。\n"
  },
  {
    "path": "app/src/core/service/feedbackService/stageStyle/StageStyleManager.tsx",
    "content": "import { service } from \"@/core/Project\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { StageStyle } from \"@/core/service/feedbackService/stageStyle/stageStyle\";\n\n/**\n * 舞台上的颜色风格管理器\n */\n@service(\"stageStyleManager\")\nexport class StageStyleManager {\n  currentStyle = new StageStyle();\n\n  // 软件启动运行一次\n  constructor() {\n    Settings.watch(\"theme\", async (value) => {\n      this.currentStyle = await StageStyle.styleFromTheme(value);\n    });\n  }\n}\n"
  },
  {
    "path": "app/src/core/service/feedbackService/stageStyle/stageStyle.tsx",
    "content": "import { Settings } from \"@/core/service/Settings\";\nimport { Themes } from \"@/core/service/Themes\";\nimport { Color } from \"@graphif/data-structures\";\n\nexport interface EffectColors {\n  /** 闪光线，切割线等白光刃的白色 */\n  flash: Color;\n  /** 粒子效果的颜色 */\n  dash: Color;\n  windowFlash: Color;\n  /** 警告阴影的颜色 */\n  warningShadow: Color;\n  /** 成功阴影的颜色 */\n  successShadow: Color;\n}\n\nexport class StageStyle {\n  /** 背景颜色 */\n  Background: Color = Color.Black;\n  /** 网格其他坐标轴上的颜色 */\n  GridNormal: Color = Color.Black;\n  /** 网格上y=0或x=0坐标轴上的颜色 */\n  GridHeavy: Color = Color.Black;\n  /** 左上角调试信息颜色 */\n  DetailsDebugText: Color = Color.Black;\n\n  /** 选择矩形边框颜色 */\n  SelectRectangleBorder: Color = Color.Black;\n  /** 选择矩形填充颜色 */\n  SelectRectangleFill: Color = Color.Black;\n\n  /** 节点边框颜色，包括线条颜色，节点边框，箭头等等 */\n  StageObjectBorder: Color = Color.Black;\n  /** 节点详细信息文本颜色 */\n  NodeDetailsText: Color = Color.Black;\n\n  /** 已经选中的颜色 */\n  CollideBoxSelected: Color = Color.Black;\n  /** 准备选中的绿色 */\n  CollideBoxPreSelected: Color = Color.Black;\n  /** 准备删除的红色 */\n  CollideBoxPreDelete: Color = Color.Black;\n\n  effects: EffectColors = {\n    flash: Color.White,\n    dash: Color.White,\n    windowFlash: Color.White,\n    warningShadow: Color.Red,\n    successShadow: Color.Green,\n  };\n\n  // 其他风格的静态工厂方法可以按照类似的方式添加\n  static async styleFromTheme(theme: Settings[\"theme\"]) {\n    const themeObj = await Themes.getThemeById(theme);\n    if (!themeObj) {\n      // 未知的主题\n      return new StageStyle();\n    }\n    const style = new StageStyle();\n    Object.assign(\n      style,\n      Object.fromEntries(Object.entries(themeObj?.content.stage).map(([k, v]) => [k, Color.fromCss(v as string)])),\n    );\n    style.effects = Object.fromEntries(\n      Object.entries(themeObj?.content.effects).map(([k, v]) => [k, Color.fromCss(v as string)]),\n    ) as any as EffectColors;\n    return style;\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/Camera.tsx",
    "content": "import { Dialog } from \"@/components/ui/dialog\";\nimport { NumberFunctions } from \"@/core/algorithm/numberFunctions\";\nimport { Project, service } from \"@/core/Project\";\nimport { easeOutExpo } from \"@/core/service/feedbackService/effectEngine/mathTools/easings\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { SoundService } from \"../service/feedbackService/SoundService\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { Direction } from \"@/types/directions\";\nimport { isMac } from \"@/utils/platform\";\nimport { Queue, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { toast } from \"sonner\";\nimport { Telemetry } from \"../service/Telemetry\";\n\n/**\n * 摄像机\n *\n * 该摄像机可以看成是悬浮在空中的，能上下左右四个方向喷气的小型飞机。\n * 喷气的含义是：按下WASD键可以控制四个喷气孔喷气，产生动力，松开立刻失去动力。\n * 同时空气有空气阻力，会对速度的反方向产生阻力。\n * 但滚轮会控制摄像机的缩放镜头。同时缩放大小也会影响喷气动力的大小，越是观看细节，喷的动力越小，移动越慢。\n */\n@service(\"camera\")\nexport class Camera {\n  /**\n   * 空气摩擦力速度指数\n   * 指数=2，表示 f = -k * v^2\n   * 指数=1，表示 f = -k * v\n   * 指数越大，速度衰减越快\n   */\n  readonly frictionExponent = 1.5;\n\n  /**\n   * 摄像机的位置（世界坐标）\n   * 实际上代表的是 currentLocation\n   */\n  location: Vector = Vector.getZero();\n  /**\n   * 上次鼠标缩放滚轮交互位置\n   * 世界坐标\n   */\n\n  targetLocationByScale: Vector = Vector.getZero();\n\n  /** 当前的 画布/摄像机移动的速度矢量 */\n  speed: Vector = Vector.getZero();\n\n  /**\n   * 可以看成一个九宫格，主要用于处理 w s a d 按键移动，\n   * 当同时按下w和s，这个值会是(-1,-1)，表示朝着左上移动\n   */\n\n  accelerateCommander: Vector = Vector.getZero();\n\n  /**\n   * 当前镜头缩放比例 >1放大 <1缩小\n   * 会逐渐趋近于目标缩放比例\n   */\n  currentScale: number = 1;\n  /** 目标镜头缩放比例 */\n  targetScale: number = 1;\n\n  /**\n   * 震动特效导致的位置偏移\n   * 也就是当有震动特效的时候，不是舞台在震动，而是摄像机在震动\n   */\n  readonly shakeLocation: Vector = Vector.getZero();\n\n  /**\n   * 记录的摄像机位置和缩放大小\n   */\n  private savedCameraState: { location: Vector; scale: number } | null = null;\n\n  // pageup / pagedown 爆炸式移动\n\n  private readonly shockMoveDiffLocationsQueue = new Queue<Vector>();\n  /**\n   * 触发一次翻页式移动\n   *\n   * 触发一次后，接下来的60帧里，摄像机都会移动一小段距离，朝向目的位置移动\n   */\n  pageMove(direction: Direction) {\n    // 计算爆炸式移动的目标位置\n    const targetLocation = this.location.clone();\n    const rect = this.project.renderer.getCoverWorldRectangle();\n    if (direction === Direction.Up) {\n      targetLocation.y -= rect.height * 0.5;\n    } else if (direction === Direction.Down) {\n      targetLocation.y += rect.height * 0.5;\n    } else if (direction === Direction.Left) {\n      targetLocation.x -= rect.width * 0.5;\n    } else if (direction === Direction.Right) {\n      targetLocation.x += rect.width * 0.5;\n    }\n    // 生成接下来一些帧里的移动轨迹位置点。\n    this.bombMove(targetLocation);\n  }\n  /**\n   * 爆炸式移动\n   * @param targetLocation 摄像机即将要移动到的世界坐标\n   */\n  bombMove(targetLocation: Vector, frameCount = 40) {\n    // 先清空之前的队列\n    this.shockMoveDiffLocationsQueue.clear();\n    // 生成接下来一些帧里的移动轨迹位置点。\n    const movePoints = [];\n    for (let i = 0; i < frameCount; i++) {\n      // 进度：0~1\n      const rate = easeOutExpo(i / frameCount);\n      const newPoint = this.location.add(targetLocation.subtract(this.location).multiply(rate));\n      movePoints.push(newPoint);\n    }\n    // 根据位置轨迹点生成距离变化小向量段\n    const diffLocations = [];\n    for (let i = 1; i < movePoints.length; i++) {\n      const diff = movePoints[i].subtract(movePoints[i - 1]);\n      diffLocations.push(diff);\n      // 将距离变化加入队列\n      this.shockMoveDiffLocationsQueue.enqueue(diff);\n    }\n  }\n\n  tick() {\n    // 计算摩擦力 与速度方向相反,固定值,但速度为0摩擦力就不存在\n    // 获得速度的大小和方向\n\n    if (Number.isNaN(this.location.x) || Number.isNaN(this.location.y)) {\n      // 实测只有把摩擦力和动力都拉满时才会瞬间触发NaN，当玩家正常数据状态下有意识地向远处飞时反而不会触发\n      // 因此这个彩蛋可能是个bug。先暂时改成正常的提示语\n      // this.project.effects.addEffect(new TextRiseEffect(\"派蒙：前面的区域以后再来探索吧？\"));\n      toast.error(\"数值溢出了，已自动重置视野\" + `${this.location.x}, ${this.location.y}`);\n      this.speed = Vector.getZero();\n      this.reset();\n      return;\n    }\n\n    // 回弹效果\n    // if (this.currentScale < 0.001) {\n    //   this.targetScale = 0.005;\n    // }\n    if (this.currentScale < 0.0000000001) {\n      this.targetScale = 0.0000000002;\n    }\n    // 彩蛋\n    if (this.currentScale > 100) {\n      this.currentScale = 0.001;\n      this.targetScale = 0.01;\n      if (isMac) {\n        toast(\n          \"视野已经放大到极限了！默认快捷键F可根据内容重置视野，mac在刚启动软件的若干秒内鼠标滚轮可能过于灵敏，导致缩放过快\",\n        );\n      } else {\n        toast(\"您已抵达微观的尽头，世界就此反转，现在回归到了宏观。默认快捷键F可根据内容重置视野\", {\n          action: {\n            label: \"我有更好的idea\",\n            onClick: async () => {\n              const idea = await Dialog.input(\n                \"发送反馈：微观尽头彩蛋\",\n                \"您输入的内容将发送到服务器，请勿包含敏感信息\",\n                {\n                  multiline: true,\n                },\n              );\n              if (!idea) return;\n              Telemetry.event(\"微观尽头更好的idea\", { idea });\n            },\n          },\n        });\n      }\n    }\n    // 冲击式移动\n    if (!this.shockMoveDiffLocationsQueue.isEmpty()) {\n      const diffLocation = this.shockMoveDiffLocationsQueue.dequeue();\n      if (diffLocation !== undefined) {\n        this.location = this.location.add(diffLocation);\n      }\n    }\n\n    // 计算摩擦力\n    let friction = Vector.getZero();\n\n    if (!this.speed.isZero()) {\n      const speedSize = this.speed.magnitude();\n\n      friction = this.speed\n        .normalize()\n        .multiply(-1)\n        .multiply(Settings.moveFriction * speedSize ** this.frictionExponent)\n        .limitX(-300, 300)\n        .limitY(-300, 300);\n    }\n\n    // 计算动力\n    /** 摄像机 >1放大 <1缩小，为了让放大的时候移动速度慢，所以取倒数 */\n    // 为了加强宏观快速移动，微观慢速移动的特性，动力计算公式再取了一个平方\n    let power = this.accelerateCommander.multiply(Settings.moveAmplitude * (1 / this.currentScale) ** 2);\n    power = power.limitX(-300, 300).limitY(-300, 300);\n\n    // 速度 = 速度 + 加速度（动力+摩擦力）\n    this.speed = this.speed.add(power).add(friction);\n    this.location = this.location.add(this.speed);\n\n    // 处理缩放\n    // 缩放的过程中应该维持摄像机中心点和鼠标滚轮交互位置的相对视野坐标的 不变性\n\n    /** 鼠标交互位置的view坐标系相对于画面左上角的坐标 */\n    const diffViewVector = this.project.renderer.transformWorld2View(this.targetLocationByScale);\n    this.dealCameraScaleInTick();\n    if (Settings.scaleCameraByMouseLocation) {\n      if (this.tickNumber > this.allowScaleFollowMouseLocationTicks) {\n        this.setLocationByOtherLocation(this.targetLocationByScale, diffViewVector);\n      }\n    }\n    // 循环空间\n    if (Settings.limitCameraInCycleSpace) {\n      this.dealCycleSpace();\n    }\n    if (this.isStartZoomIn) {\n      this.targetScale *= 1.05;\n    }\n    if (this.isStartZoomOut) {\n      this.targetScale *= 0.95;\n    }\n    this.tickNumber++;\n  }\n  /**\n   * 当前的帧编号\n   */\n  private tickNumber = 0;\n  /**\n   * 多少帧以后，才能继续跟随鼠标缩放\n   */\n  private allowScaleFollowMouseLocationTicks = 0;\n  setAllowScaleFollowMouseLocationTicks(ticks: number) {\n    this.allowScaleFollowMouseLocationTicks = ticks;\n  }\n\n  zoomInByKeyboardPress() {\n    this.targetScale *= 1 + Settings.cameraKeyboardScaleRate;\n    this.addScaleFollowMouseLocationTime(5);\n  }\n\n  zoomOutByKeyboardPress() {\n    this.targetScale *= 1 - Settings.cameraKeyboardScaleRate;\n    this.addScaleFollowMouseLocationTime(5);\n  }\n\n  public addScaleFollowMouseLocationTime(sec: number) {\n    this.allowScaleFollowMouseLocationTicks = this.tickNumber + sec * 60;\n  }\n\n  public isStartZoomIn: boolean = false;\n  public isStartZoomOut: boolean = false;\n\n  /**\n   * 处理循环空间\n   */\n  private dealCycleSpace() {\n    this.location.x = NumberFunctions.mod(this.location.x, Settings.cameraCycleSpaceSizeX);\n    this.location.y = NumberFunctions.mod(this.location.y, Settings.cameraCycleSpaceSizeY);\n    // 限制缩放不能超过循环空间大小\n  }\n\n  /**\n   * 修改摄像机位置，但是通过一种奇特的方式来修改\n   * 将某个世界坐标位置对准当前的某个视野坐标位置，来修改摄像机位置\n   * @param otherWorldLocation\n   * @param viewLocation\n   */\n  private setLocationByOtherLocation(otherWorldLocation: Vector, viewLocation: Vector) {\n    const otherLocationView = this.project.renderer.transformWorld2View(otherWorldLocation);\n    const leftTopLocationWorld = this.project.renderer.transformView2World(otherLocationView.subtract(viewLocation));\n    const rect = this.project.renderer.getCoverWorldRectangle();\n    this.location = leftTopLocationWorld.add(rect.size.divide(2));\n  }\n\n  /**\n   * 强制清除移动动力命令\n   * 防止无限滚屏\n   */\n  clearMoveCommander() {\n    this.accelerateCommander = Vector.getZero();\n  }\n\n  /**\n   * 突然停止摄像机所有运动\n   * 清除移动动力、速度、缩放操作\n   */\n  stopImmediately() {\n    this.accelerateCommander = Vector.getZero();\n    this.speed = Vector.getZero();\n    this.isStartZoomIn = false;\n    this.isStartZoomOut = false;\n    this.shockMoveDiffLocationsQueue.clear();\n  }\n\n  /**\n   * 单纯缩放镜头\n   * 让currentScale不断逼近targetScale\n   * @returns 缩放前后变化的比值\n   */\n  private dealCameraScaleInTick() {\n    let newCurrentScale = this.currentScale;\n\n    if (this.currentScale < this.targetScale) {\n      newCurrentScale = Math.min(\n        this.currentScale + (this.targetScale - this.currentScale) * Settings.scaleExponent,\n        this.targetScale,\n      );\n    } else if (this.currentScale > this.targetScale) {\n      newCurrentScale = Math.max(\n        this.currentScale - (this.currentScale - this.targetScale) * Settings.scaleExponent,\n        this.targetScale,\n      );\n    }\n    // 性能优化之，将缩放小数点保留四位\n    if (this.currentScale > 0.01) {\n      newCurrentScale = parseFloat(newCurrentScale.toFixed(4));\n    }\n    const diff = newCurrentScale / this.currentScale;\n    this.currentScale = newCurrentScale;\n\n    return diff;\n  }\n\n  // 确保这个函数在软件打开的那一次调用\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 重置摄像机的缩放，让其画面刚好能容下舞台上所有内容的外接矩形\n   * 还是不要有动画过度了，因为过度效果会带来一点卡顿（2024年10月25日）\n   */\n  reset() {\n    this.location = this.project.stageManager.getCenter();\n    this.targetLocationByScale = this.location.clone();\n    // this.currentScale = 0.01;\n    const allEntitiesSize = this.project.stageManager.getSize();\n    allEntitiesSize.multiply(Settings.cameraResetViewPaddingRate);\n    // 添加缩放上限，与resetByRectangle方法保持一致\n    this.currentScale = Math.min(\n      Settings.cameraResetViewPaddingRate,\n      Math.min(this.project.renderer.h / allEntitiesSize.y, this.project.renderer.w / allEntitiesSize.x),\n    );\n    this.targetScale = this.currentScale;\n    SoundService.play.viewAdjustSoundFile();\n  }\n\n  resetBySelected() {\n    const selectedEntity: Entity[] = this.project.stageManager.getSelectedEntities();\n    if (selectedEntity.length === 0) {\n      this.reset();\n      return;\n    }\n    const viewRectangle = Rectangle.getBoundingRectangle(selectedEntity.map((e) => e.collisionBox.getRectangle()));\n    this.resetByRectangle(viewRectangle);\n    SoundService.play.viewAdjustSoundFile();\n  }\n\n  resetByRectangle(viewRectangle: Rectangle) {\n    const center = viewRectangle.center;\n    this.location = center;\n    this.targetLocationByScale = center.clone();\n\n    const selectedRectangleSize = viewRectangle.size.multiply(Settings.cameraResetViewPaddingRate);\n\n    // 再取max是为了防止缩放过大\n    this.currentScale = Math.min(\n      Settings.cameraResetMaxScale,\n      Math.min(this.project.renderer.h / selectedRectangleSize.y, this.project.renderer.w / selectedRectangleSize.x),\n    );\n    this.targetScale = this.currentScale;\n  }\n\n  resetScale() {\n    this.currentScale = 1;\n    this.targetScale = 1;\n  }\n\n  resetLocationToZero() {\n    this.bombMove(Vector.getZero());\n  }\n\n  /**\n   * 保存当前摄像机状态\n   * 只有在当前没有保存状态的情况下才保存\n   */\n  saveCameraState() {\n    // 只有在当前没有保存状态的情况下才保存\n    if (!this.savedCameraState) {\n      this.savedCameraState = {\n        location: this.location.clone(),\n        scale: this.currentScale,\n      };\n    }\n  }\n\n  /**\n   * 恢复之前保存的摄像机状态\n   * 恢复后清除保存的状态，以便下次使用\n   */\n  restoreCameraState() {\n    if (this.savedCameraState) {\n      this.location = this.savedCameraState.location.clone();\n      this.targetLocationByScale = this.savedCameraState.location.clone();\n      this.currentScale = this.savedCameraState.scale;\n      this.targetScale = this.savedCameraState.scale;\n      // 恢复后清除保存的状态，以便下次使用\n      this.savedCameraState = null;\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/Canvas.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { Settings } from \"@/core/service/Settings\";\n\n/**\n * 将Canvas标签和里面的ctx捏在一起封装成一个类\n */\n@service(\"canvas\")\nexport class Canvas {\n  ctx: CanvasRenderingContext2D;\n\n  constructor(\n    private readonly project: Project,\n    public element: HTMLCanvasElement = document.createElement(\"canvas\"),\n  ) {\n    element.tabIndex = -1;\n    // 鼠标移动到画布上开始tick\n    element.addEventListener(\"mousemove\", () => {\n      if (document.querySelector(\"[data-radix-popper-content-wrapper]\")) {\n        // workaround: 解决菜单栏弹出后鼠标移动到canvas区域，导致菜单自动关闭的问题\n        return;\n      }\n      this.project.loop();\n    });\n    // 重定向键盘事件\n    element.addEventListener(\"focus\", () => element.blur());\n    const shouldRedirectKeyboardEvent = () =>\n      !(\n        document.activeElement?.tagName === \"INPUT\" ||\n        document.activeElement?.tagName === \"TEXTAREA\" ||\n        document.activeElement?.getAttribute(\"contenteditable\") === \"true\"\n      );\n    window.addEventListener(\"keydown\", (event) => {\n      if (!shouldRedirectKeyboardEvent()) return;\n      // 在窗口层面拦截浏览器默认快捷键，避免触发系统/浏览器查找/搜索等行为\n      const key = event.key;\n      if (\n        (event.ctrlKey && (key === \"f\" || key === \"F\" || key === \"g\" || key === \"G\" || key === \"r\" || key === \"R\")) ||\n        key === \"F3\" ||\n        key === \"F5\" ||\n        key === \"F7\"\n      ) {\n        event.preventDefault();\n      }\n      if (project.isRunning) {\n        element.dispatchEvent(\n          new KeyboardEvent(\"keydown\", {\n            key: event.key,\n            altKey: event.altKey,\n            ctrlKey: event.ctrlKey,\n            shiftKey: event.shiftKey,\n            metaKey: event.metaKey,\n          }),\n        );\n      }\n    });\n    window.addEventListener(\"keyup\", (event) => {\n      if (!shouldRedirectKeyboardEvent()) {\n        this.project.controller.pressingKeySet.clear();\n        return;\n      }\n      if (project.isRunning) {\n        element.dispatchEvent(\n          new KeyboardEvent(\"keyup\", {\n            key: event.key,\n            altKey: event.altKey,\n            ctrlKey: event.ctrlKey,\n            shiftKey: event.shiftKey,\n            metaKey: event.metaKey,\n          }),\n        );\n      }\n    });\n    // 失焦时清空按下的按键\n    window.addEventListener(\"blur\", () => {\n      this.project.controller.pressingKeySet.clear();\n    });\n    this.ctx = element.getContext(\"2d\")!;\n    if (Settings.antialiasing === \"disabled\") {\n      this.ctx.imageSmoothingEnabled = false;\n    } else {\n      this.ctx.imageSmoothingQuality = Settings.antialiasing;\n    }\n  }\n\n  mount(wrapper: HTMLDivElement) {\n    wrapper.innerHTML = \"\";\n    wrapper.appendChild(this.element);\n    // 监听画布大小变化\n    const resizeObserver = new ResizeObserver(() => {\n      this.project.renderer.resizeWindow(wrapper.clientWidth, wrapper.clientHeight);\n    });\n    resizeObserver.observe(wrapper);\n  }\n\n  dispose() {\n    this.element.remove();\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/ProjectUpgrader.tsx",
    "content": "import { Serialized } from \"@/types/node\";\nimport { ProjectMetadata } from \"@/types/metadata\";\nimport { Path } from \"@/utils/path\";\nimport { readFile } from \"@tauri-apps/plugin-fs\";\nimport { v4 as uuidv4 } from \"uuid\";\nimport { URI } from \"vscode-uri\";\nimport { PenStrokeSegment } from \"./stageObject/entity/PenStroke\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { toast } from \"sonner\";\nimport { DetailsManager } from \"./stageObject/tools/entityDetailsManager\";\n\nexport namespace ProjectUpgrader {\n  /** N系列的最新版本 */\n  export const NLatestVersion = \"2.2.0\";\n\n  /**\n   * 比较两个版本号字符串（格式：x.y.z）\n   * @param version1 版本1\n   * @param version2 版本2\n   * @returns 如果 version1 < version2 返回 -1，如果 version1 > version2 返回 1，如果相等返回 0\n   */\n  function compareVersion(version1: string, version2: string): number {\n    const v1Parts = version1.split(\".\").map(Number);\n    const v2Parts = version2.split(\".\").map(Number);\n    const maxLength = Math.max(v1Parts.length, v2Parts.length);\n\n    for (let i = 0; i < maxLength; i++) {\n      const v1Part = v1Parts[i] || 0;\n      const v2Part = v2Parts[i] || 0;\n      if (v1Part < v2Part) return -1;\n      if (v1Part > v2Part) return 1;\n    }\n    return 0;\n  }\n\n  /**\n   * 1.0~1.8 系列版本的json文件升级，\n   * 注意：在2.0后改为了 内部含有msgpack的 .prg文件格式因此，\n   * 此函数仅作为旧版本文件的迁移函数\n   * @param data 原始json数据\n   * @returns 升级后的json数据\n   */\n  export function upgradeVAnyToVLatest(data: Record<string, any>): Record<string, any> {\n    data = convertV1toV2(data);\n    data = convertV2toV3(data);\n    data = convertV3toV4(data);\n    data = convertV4toV5(data);\n    data = convertV5toV6(data);\n    data = convertV6toV7(data);\n    data = convertV7toV8(data);\n    data = convertV8toV9(data);\n    data = convertV9toV10(data);\n    data = convertV10toV11(data);\n    data = convertV11toV12(data);\n    data = convertV12toV13(data);\n    data = convertV13toV14(data);\n    data = convertV14toV15(data);\n    data = convertV15toV16(data);\n    data = convertV16toV17(data);\n    return data;\n  }\n\n  function convertV1toV2(data: Record<string, any>): Record<string, any> {\n    // 如果有version字段，说明数据是v2以上版本，不需要转换\n    if (\"version\" in data) {\n      return data;\n    }\n    data.version = 2;\n    // 检查缺失的字段\n    if (!(\"nodes\" in data)) {\n      data.nodes = [];\n    }\n    if (!(\"links\" in data)) {\n      data.links = [];\n    }\n    // 检查节点中缺失的字段\n    for (const node of data.nodes) {\n      if (!(\"details\" in node)) {\n        node.details = {};\n      }\n      if (!(\"inner_text\" in node)) {\n        node.inner_text = \"\";\n      }\n      if (!(\"children\" in node)) {\n        node.children = [];\n      }\n      if (!(\"uuid\" in node)) {\n        throw new Error(\"节点缺少uuid字段\");\n      }\n    }\n    for (const link of data.links) {\n      if (!(\"inner_text\" in link)) {\n        link.inner_text = \"\";\n      }\n    }\n    return data;\n  }\n  function convertV2toV3(data: Record<string, any>): Record<string, any> {\n    if (data.version >= 3) {\n      return data;\n    }\n    data.version = 3;\n    // 重命名字段\n    for (const node of data.nodes) {\n      node.shape = node.body_shape;\n      delete node.body_shape;\n      node.shape.location = node.shape.location_left_top;\n      delete node.shape.location_left_top;\n      node.shape.size = [node.shape.width, node.shape.height];\n      delete node.shape.width;\n      delete node.shape.height;\n      node.text = node.inner_text;\n      delete node.inner_text;\n    }\n    data.edges = data.links;\n    delete data.links;\n    for (const edge of data.edges) {\n      edge.source = edge.source_node;\n      delete edge.source_node;\n      edge.target = edge.target_node;\n      delete edge.target_node;\n      edge.text = edge.inner_text;\n      delete edge.inner_text;\n    }\n    return data;\n  }\n  function convertV3toV4(data: Record<string, any>): Record<string, any> {\n    if (data.version >= 4) {\n      return data;\n    }\n    data.version = 4;\n    for (const node of data.nodes) {\n      node.color = [0, 0, 0, 0];\n      node.location = node.shape.location;\n      delete node.shape.location;\n      node.size = node.shape.size;\n      delete node.shape.size;\n    }\n    return data;\n  }\n  function convertV4toV5(data: Record<string, any>): Record<string, any> {\n    if (data.version >= 5) {\n      return data;\n    }\n    data.version = 5;\n    for (const node of data.nodes) {\n      if (!node.color) {\n        node.color = [0, 0, 0, 0];\n      }\n    }\n    return data;\n  }\n\n  // 继承体系重构，移除节点的 children字段\n  function convertV5toV6(data: Record<string, any>): Record<string, any> {\n    if (data.version >= 6) {\n      return data;\n    }\n    data.version = 6;\n    for (const node of data.nodes) {\n      if (typeof node.children !== \"undefined\") {\n        delete node.children;\n      }\n    }\n    return data;\n  }\n\n  // 继承体系重构，Edge增加uuid字段\n  function convertV6toV7(data: Record<string, any>): Record<string, any> {\n    if (data.version >= 7) {\n      return data;\n    }\n    data.version = 7;\n    for (const edge of data.edges) {\n      if (typeof edge.uuid === \"undefined\") {\n        edge.uuid = uuidv4();\n      }\n    }\n    return data;\n  }\n\n  // 继承体系重构，增加type\n  function convertV7toV8(data: Record<string, any>): Record<string, any> {\n    if (data.version >= 8) {\n      return data;\n    }\n    data.version = 8;\n    for (const node of data.nodes) {\n      node.type = \"core:text_node\";\n    }\n    for (const edge of data.edges) {\n      edge.type = \"core:edge\";\n    }\n    return data;\n  }\n\n  // 增加连接点 ConnectionPoint\n  function convertV8toV9(data: Record<string, any>): Record<string, any> {\n    if (data.version >= 9) {\n      return data;\n    }\n    data.version = 9;\n    return data;\n  }\n\n  // 增加tags\n  function convertV9toV10(data: Record<string, any>): Record<string, any> {\n    if (data.version >= 10) {\n      return data;\n    }\n    data.version = 10;\n    data.tags = [];\n    return data;\n  }\n\n  // 所有实体都支持 details，不再仅仅是TextNode支持\n  function convertV10toV11(data: Record<string, any>): Record<string, any> {\n    if (data.version >= 11) {\n      return data;\n    }\n    data.version = 11;\n    for (const node of data.nodes) {\n      if (node.type === \"core:section\") {\n        // bug\n        if (typeof node.details === \"undefined\") {\n          node.details = \"\";\n        }\n      } else if (node.type === \"core:connect_point\") {\n        // bug\n        if (typeof node.details === \"undefined\") {\n          node.details = \"\";\n        }\n      } else if (node.type === \"core:image_node\") {\n        if (typeof node.details === \"undefined\") {\n          node.details = \"\";\n        }\n      }\n    }\n    return data;\n  }\n\n  // 图片支持自定义缩放大小\n  function convertV11toV12(data: Record<string, any>): Record<string, any> {\n    if (data.version >= 12) {\n      return data;\n    }\n    data.version = 12;\n    for (const node of data.nodes) {\n      if (node.type === \"core:image_node\") {\n        if (typeof node.scale === \"undefined\") {\n          node.scale = 1 / (window.devicePixelRatio || 1);\n        }\n      }\n    }\n    return data;\n  }\n\n  /**\n   * node -> entities\n   * edge -> associations\n   * @param data\n   * @returns\n   */\n  function convertV12toV13(data: Record<string, any>): Record<string, any> {\n    if (data.version >= 13) {\n      return data;\n    }\n    data.version = 13;\n    if (\"nodes\" in data) {\n      data.entities = data.nodes;\n      delete data.nodes;\n    }\n    if (\"edges\" in data) {\n      for (const edge of data.edges) {\n        edge.type = \"core:line_edge\";\n      }\n      data.associations = data.edges;\n      delete data.edges;\n    }\n\n    return data;\n  }\n\n  /**\n   * Edge增加了颜色字段\n   * @param data\n   */\n  function convertV13toV14(data: Record<string, any>): Record<string, any> {\n    if (data.version >= 14) {\n      return data;\n    }\n    data.version = 14;\n    for (const edge of data.associations) {\n      // edge.color = [0, 0, 0, 0];\n      if (typeof edge.color === \"undefined\") {\n        edge.color = [0, 0, 0, 0];\n      }\n    }\n    return data;\n  }\n\n  /**\n   * 涂鸦增加颜色字段\n   * @param data\n   */\n  function convertV14toV15(data: Record<string, any>): Record<string, any> {\n    if (data.version >= 15) {\n      return data;\n    }\n    data.version = 15;\n    for (const node of data.entities) {\n      if (node.type === \"core:pen_stroke\") {\n        if (typeof node.color === \"undefined\") {\n          node.color = [0, 0, 0, 0];\n        }\n      }\n    }\n    return data;\n  }\n\n  /**\n   * 文本节点增加自动转换大小/手动转换大小功能\n   * @param data\n   */\n  function convertV15toV16(data: Record<string, any>): Record<string, any> {\n    if (data.version >= 16) {\n      return data;\n    }\n    data.version = 16;\n    for (const node of data.entities) {\n      if (node.type === \"core:text_node\") {\n        if (typeof node.sizeAdjust === \"undefined\") {\n          node.sizeAdjust = \"auto\";\n        }\n      }\n    }\n    return data;\n  }\n\n  /**\n   * Edge连线接头增加比率字段\n   * @param data\n   */\n  function convertV16toV17(data: Record<string, any>): Record<string, any> {\n    if (data.version >= 17) {\n      return data;\n    }\n    data.version = 17;\n    for (const edge of data.associations) {\n      if (Serialized.isEdge(edge) && edge.sourceRectRate === undefined && edge.targetRectRate === undefined) {\n        edge.sourceRectRate = [0.5, 0.5];\n        edge.targetRectRate = [0.5, 0.5];\n      }\n    }\n    return data;\n  }\n\n  /**\n   * N版本的prg文件升级，从任意N版本升级到最新N版本\n   * @param data 原始N版本数据\n   * @param metadata 原始metadata\n   * @returns 升级后的N版本数据和metadata\n   */\n  export function upgradeNAnyToNLatest(data: any[], metadata: any): [any[], ProjectMetadata] {\n    const currentVersion = metadata?.version || \"2.0.0\";\n\n    // 如果版本小于 2.1.0，需要升级\n    if (compareVersion(currentVersion, \"2.1.0\") < 0) {\n      [data, metadata] = convertN1toN2(data, metadata);\n    }\n\n    // 如果版本小于 2.2.0，需要升级\n    if (compareVersion(currentVersion, \"2.2.0\") < 0) {\n      [data, metadata] = convertN2toN3(data, metadata);\n    }\n\n    return [data, metadata];\n  }\n\n  /**\n   * 将 2.0.0 版本升级到 2.1.0 版本\n   * @param data 2.0.0版本数据\n   * @param metadata 2.0.0版本metadata\n   * @returns 2.1.0版本数据和metadata\n   */\n  function convertN1toN2(data: any[], metadata: any): [any[], ProjectMetadata] {\n    // 为LineEdge添加lineType属性，默认值为'solid'\n    for (const item of data) {\n      if (item._ === \"LineEdge\") {\n        // 如果lineType属性不存在，添加默认值\n        if (!item.lineType) {\n          item.lineType = \"solid\";\n        }\n      }\n    }\n    return [data, { ...metadata, version: \"2.1.0\" }];\n  }\n\n  /**\n   * 将 2.1.0 版本升级到 2.2.0 版本\n   * @param data 2.1.0版本数据\n   * @param metadata 2.1.0版本metadata\n   * @returns 2.2.0版本数据和metadata\n   */\n  function convertN2toN3(data: any[], metadata: any): [any[], ProjectMetadata] {\n    // 为TextNode添加fontScaleLevel属性，默认值为0\n    for (const item of data) {\n      if (item._ === \"TextNode\") {\n        // 如果fontScaleLevel属性不存在，添加默认值\n        if (item.fontScaleLevel === undefined) {\n          item.fontScaleLevel = 0;\n        }\n      }\n    }\n    return [data, { ...metadata, version: \"2.2.0\" }];\n  }\n\n  export async function convertVAnyToN1(json: Record<string, any>, uri: URI) {\n    // 升级json数据到最新版本\n    json = ProjectUpgrader.upgradeVAnyToVLatest(json);\n    let isHaveImageNode = false;\n    const uuidMap = new Map<string, Record<string, any>>();\n    const resultStage: Record<string, any>[] = [];\n    const attachments = new Map<string, Blob>();\n\n    const basePath = new Path(uri.fsPath).parent;\n\n    // Helper functions for repeated structures\n    const toColor = (colorArr: number[]) => ({\n      _: \"Color\",\n      r: colorArr[0],\n      g: colorArr[1],\n      b: colorArr[2],\n      a: colorArr[3],\n    });\n    const toVector = (vectorArr: number[]) => ({\n      _: \"Vector\",\n      x: vectorArr[0],\n      y: vectorArr[1],\n    });\n\n    // Recursively convert all entities\n    async function convertEntityVAnyToN1(\n      entity: Record<string, any>,\n      uuidMap: Map<string, Record<string, any>>,\n      entities: Array<Record<string, any>>,\n    ): Promise<Record<string, any> | undefined> {\n      if (uuidMap.has(entity.uuid)) {\n        return uuidMap.get(entity.uuid);\n      }\n\n      let data: Record<string, any> | undefined;\n\n      switch (entity.type) {\n        case \"core:text_node\": {\n          data = {\n            _: \"TextNode\",\n            uuid: entity.uuid,\n            text: entity.text,\n            details: DetailsManager.markdownToDetails(entity.details),\n            collisionBox: {\n              _: \"CollisionBox\",\n              shapes: [\n                {\n                  _: \"Rectangle\",\n                  location: toVector(entity.location),\n                  size: toVector(entity.size),\n                },\n              ],\n            },\n            color: toColor(entity.color),\n            sizeAdjust: entity.sizeAdjust,\n          };\n          break;\n        }\n        case \"core:section\": {\n          const children: Array<Record<string, any>> = [];\n          if (entity.children) {\n            for (const childUUID of entity.children) {\n              let childData = uuidMap.get(childUUID);\n              if (!childData) {\n                const childEntity = entities.find((e) => e.uuid === childUUID);\n                if (childEntity) {\n                  childData = await convertEntityVAnyToN1(childEntity, uuidMap, entities);\n                }\n              }\n              if (childData) {\n                children.push(childData);\n              }\n            }\n          }\n          data = {\n            _: \"Section\",\n            uuid: entity.uuid,\n            text: entity.text,\n            details: DetailsManager.markdownToDetails(entity.details),\n            isCollapsed: entity.isCollapsed,\n            isHidden: entity.isHidden,\n            children,\n            collisionBox: {\n              _: \"CollisionBox\",\n              shapes: [\n                {\n                  _: \"Rectangle\",\n                  location: toVector(entity.location),\n                  size: toVector(entity.size),\n                },\n              ],\n            },\n            color: toColor(entity.color),\n          };\n          break;\n        }\n        case \"core:pen_stroke\": {\n          // 涂鸦\n          const segments: PenStrokeSegment[] = [];\n          const stringParts = entity.content.split(\"~\");\n          for (const part of stringParts) {\n            const [x, y, pressure] = part.split(\",\");\n            const segment = new PenStrokeSegment(new Vector(Number(x), Number(y)), Number(pressure) / 5);\n            segments.push(segment);\n          }\n          data = {\n            _: \"PenStroke\",\n            uuid: entity.uuid,\n            color: toColor(entity.color),\n            segments,\n          };\n\n          break;\n        }\n        case \"core:image_node\": {\n          isHaveImageNode = true;\n          // 图片\n          const path = entity.path;\n          const imageContent = await readFile(basePath.join(path).toString());\n          const blob = new Blob([imageContent], { type: \"image/png\" });\n          const attachmentId = crypto.randomUUID();\n          attachments.set(attachmentId, blob);\n          data = {\n            _: \"ImageNode\",\n            uuid: entity.uuid,\n            attachmentId,\n            details: DetailsManager.markdownToDetails(entity.details),\n            collisionBox: {\n              _: \"CollisionBox\",\n              shapes: [\n                {\n                  _: \"Rectangle\",\n                  location: toVector(entity.location),\n                  size: toVector(entity.size),\n                },\n              ],\n            },\n            scale: entity.scale || 1,\n          };\n          break;\n        }\n        case \"core:connect_point\": {\n          // 连接点\n          data = {\n            _: \"ConnectPoint\",\n            uuid: entity.uuid,\n            details: DetailsManager.markdownToDetails(entity.details),\n            collisionBox: {\n              _: \"CollisionBox\",\n              shapes: [\n                {\n                  _: \"Rectangle\",\n                  location: toVector(entity.location),\n                  size: toVector([1, 1]),\n                },\n              ],\n            },\n          };\n          break;\n        }\n        case \"core:url_node\": {\n          // 链接\n          data = {\n            _: \"UrlNode\",\n            uuid: entity.uuid,\n            title: entity.title,\n            url: entity.url,\n            details: DetailsManager.markdownToDetails(entity.details),\n            collisionBox: {\n              _: \"CollisionBox\",\n              shapes: [\n                {\n                  _: \"Rectangle\",\n                  location: toVector(entity.location),\n                  size: toVector(entity.size),\n                },\n              ],\n            },\n            color: toColor(entity.color),\n          };\n          break;\n        }\n        case \"core:portal_node\": {\n          // 传送门，不能不管，万一有了链接它的Edge可能就报错了\n          // 为了防止报错，先暂时转换为TextNode\n          data = {\n            _: \"TextNode\",\n            uuid: entity.uuid,\n            text: entity.title,\n            collisionBox: {\n              _: \"CollisionBox\",\n              shapes: [\n                {\n                  _: \"Rectangle\",\n                  location: toVector(entity.location),\n                  size: toVector(entity.size),\n                },\n              ],\n            },\n          };\n          break;\n        }\n        case \"core:svg_node\": {\n          // svg节点，和图片一样，要处理附件\n          const code = entity.content;\n          const attachmentId = crypto.randomUUID();\n          const blob = new Blob([code], { type: \"image/svg+xml\" });\n          attachments.set(attachmentId, blob);\n          data = {\n            _: \"SvgNode\",\n            uuid: entity.uuid,\n            attachmentId,\n            details: DetailsManager.markdownToDetails(entity.details),\n            collisionBox: {\n              _: \"CollisionBox\",\n              shapes: [\n                {\n                  _: \"Rectangle\",\n                  location: toVector(entity.location),\n                  size: toVector(entity.size),\n                },\n              ],\n            },\n            scale: entity.scale || 1,\n            color: toColor(entity.color),\n          };\n          break;\n        }\n        default: {\n          console.warn(`未知的实体类型${entity.type}`);\n          break;\n        }\n      }\n\n      if (data) {\n        uuidMap.set(entity.uuid, data);\n      }\n      return data;\n    }\n\n    // Convert all top-level entities\n    for (const entity of json.entities) {\n      const data = await convertEntityVAnyToN1(entity, uuidMap, json.entities);\n      if (data) {\n        resultStage.push(data);\n      }\n    }\n\n    // Convert associations\n    for (const association of json.associations) {\n      switch (association.type) {\n        case \"core:line_edge\": {\n          const fromNode = uuidMap.get(association.source);\n          const toNode = uuidMap.get(association.target);\n\n          if (!fromNode || !toNode) {\n            // toast.warning(`边 ${association.uuid} 关联的节点不存在: ${association.source} -> ${association.target}`);\n            continue;\n          }\n\n          resultStage.push({\n            _: \"LineEdge\",\n            uuid: association.uuid,\n            associationList: [fromNode, toNode],\n            text: association.text,\n            color: toColor(association.color),\n            sourceRectangleRate: toVector(association.sourceRectRate),\n            targetRectangleRate: toVector(association.targetRectRate),\n            // lineType: \"solid\",\n          });\n          break;\n        }\n        case \"core:cublic_catmull_rom_spline_edge\": {\n          // CR曲线\n          // 先转换成LineEdge，毕竟以前也没做好\n          const fromNode = uuidMap.get(association.source);\n          const toNode = uuidMap.get(association.target);\n\n          if (!fromNode || !toNode) {\n            // toast.warning(`边 ${association.uuid} 关联的节点不存在: ${association.source} -> ${association.target}`);\n            continue;\n          }\n\n          resultStage.push({\n            _: \"LineEdge\",\n            uuid: association.uuid,\n            associationList: [fromNode, toNode],\n            text: association.text,\n            color: toColor(association.color),\n            sourceRectangleRate: toVector(association.sourceRectRate),\n            targetRectangleRate: toVector(association.targetRectRate),\n          });\n\n          break;\n        }\n        case \"core:multi_target_undirected_edge\": {\n          // 多源无向边\n          break;\n        }\n        default: {\n          // 其他类型\n          break;\n        }\n      }\n    }\n\n    // 遍历所有标签\n    // TODO\n\n    if (isHaveImageNode) {\n      toast.warning(\"有图片节点，请保存当前prg文件后，再用软件重新打开此prg文件，才能正常显示图片。\", {\n        duration: 30000,\n      });\n    }\n\n    return { data: resultStage, attachments };\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageManager/StageHistoryManager.tsx",
    "content": "import { Project, ProjectState, service } from \"@/core/Project\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { deserialize, serialize } from \"@graphif/serializer\";\nimport { cn } from \"@udecode/cn\";\nimport { Delta, diff, patch } from \"jsondiffpatch\";\nimport _ from \"lodash\";\nimport { toast } from \"sonner\";\n\nabstract class HistoryManagerAbs {\n  public abstract recordStep(): void;\n  public abstract undo(): void;\n  public abstract redo(): void;\n  public abstract get(index: number): Record<string, any>[];\n  public abstract clearHistory(): void;\n}\n\nclass HistoryManagerTimeEfficient extends HistoryManagerAbs {\n  /**\n   * 初始化的舞台数据\n   */\n  initialStage: Record<string, any>[] = [];\n\n  /**\n   * 存储完整序列化历史记录的数组\n   * 每一项都是完整的舞台数据，不进行diff操作\n   */\n  history: Record<string, any>[] = [];\n\n  /**\n   * 历史记录指针\n   */\n  currentIndex = -1;\n\n  // 在project加载完毕后调用\n  constructor(private readonly project: Project) {\n    super();\n    this.initialStage = serialize(project.stage);\n  }\n\n  /**\n   * 记录一步骤\n   * 直接保存完整的舞台序列化数据，优先考虑时间效率\n   */\n  recordStep() {\n    // 删除当前指针之后的所有历史记录\n    this.history.splice(this.currentIndex + 1);\n\n    // 保存当前舞台的完整序列化数据\n    const currentStage = serialize(this.project.stage);\n    this.history.push(currentStage);\n    this.currentIndex = this.history.length - 1;\n\n    // 当历史记录超过限制时，删除最旧的记录\n    while (this.history.length > Settings.historySize) {\n      this.history.shift();\n      this.currentIndex--;\n    }\n\n    this.project.state = ProjectState.Unsaved;\n  }\n\n  /**\n   * 估算历史记录的内存占用大小\n   * @returns 格式化后的内存大小字符串\n   */\n  private estimateMemoryUsage(): string {\n    try {\n      // 将history数组序列化为JSON字符串，估算内存占用\n      const jsonString = JSON.stringify(this.history);\n      // 每个字符大约占用2字节（UTF-16编码）\n      const bytes = jsonString.length * 2;\n\n      // 格式化显示\n      if (bytes < 1024) {\n        return `${bytes} B`;\n      } else if (bytes < 1024 * 1024) {\n        return `${(bytes / 1024).toFixed(2)} KB`;\n      } else {\n        return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;\n      }\n    } catch (error) {\n      return \"无法估算\" + JSON.stringify(error);\n    }\n  }\n\n  /**\n   * 撤销\n   */\n  undo() {\n    if (this.currentIndex >= 0) {\n      this.currentIndex--;\n      this.project.stage = this.get(this.currentIndex);\n    }\n\n    // 显示toast信息，与memoryEfficient版本保持一致\n    if (Settings.showDebug) {\n      toast(\n        <div className=\"flex text-sm\">\n          <span className=\"m-2 flex flex-col justify-center\">\n            <span>当前历史位置</span>\n            <span className={cn(this.currentIndex === -1 && \"text-red-500\")}>{this.currentIndex + 1}</span>\n          </span>\n          <span className=\"m-2 flex flex-col justify-center\">\n            <span>当前历史长度</span>\n            <span className={cn(this.history.length === Settings.historySize && \"text-yellow-500\")}>\n              {this.history.length}\n            </span>\n          </span>\n          <span className=\"m-2 flex flex-col justify-center\">\n            <span>限定历史长度</span>\n            <span className=\"opacity-50\">{Settings.historySize}</span>\n          </span>\n          <span className=\"m-2 flex flex-col justify-center\">\n            <span>内存占用估算</span>\n            <span className=\"text-blue-500\">{this.estimateMemoryUsage()}</span>\n          </span>\n        </div>,\n      );\n    }\n  }\n\n  /**\n   * 反撤销\n   */\n  redo() {\n    if (this.currentIndex < this.history.length - 1) {\n      this.currentIndex++;\n      this.project.stage = this.get(this.currentIndex);\n    }\n\n    // 显示toast信息，与memoryEfficient版本保持一致\n    if (Settings.showDebug) {\n      toast(\n        <div className=\"flex text-sm\">\n          <span className=\"m-2 flex flex-col justify-center\">\n            <span>当前历史位置</span>\n            <span className={cn(this.currentIndex === this.history.length - 1 && \"text-green-500\")}>\n              {this.currentIndex + 1}\n            </span>\n          </span>\n          <span className=\"m-2 flex flex-col justify-center\">\n            <span>当前历史长度</span>\n            <span className={cn(this.history.length === Settings.historySize && \"text-yellow-500\")}>\n              {this.history.length}\n            </span>\n          </span>\n          <span className=\"m-2 flex flex-col justify-center\">\n            <span>限定历史长度</span>\n            <span className=\"opacity-50\">{Settings.historySize}</span>\n          </span>\n          <span className=\"m-2 flex flex-col justify-center\">\n            <span>内存占用估算</span>\n            <span className=\"text-blue-500\">{this.estimateMemoryUsage()}</span>\n          </span>\n        </div>,\n      );\n    }\n  }\n\n  /**\n   * 获取指定索引的历史记录\n   * @param index 历史记录索引\n   * @returns 舞台对象\n   */\n  get(index: number) {\n    // 处理边界情况：如果索引为负数，直接返回初始状态\n    if (index < 0) {\n      return deserialize(_.cloneDeep(this.initialStage), this.project);\n    }\n\n    // 直接返回对应索引的历史记录，无需应用diff\n    const historyData = _.cloneDeep(this.history[index]);\n    return deserialize(historyData, this.project);\n  }\n\n  /**\n   * 清空历史记录\n   * 保存文件时调用，将当前状态设为新的初始状态\n   */\n  clearHistory() {\n    this.history = [];\n    this.currentIndex = -1;\n    this.initialStage = serialize(this.project.stage);\n    this.project.state = ProjectState.Saved;\n    if (Settings.showDebug) {\n      toast(\"历史记录已清空\");\n    }\n  }\n}\n\nclass HistorymanagerMemoryEfficient extends HistoryManagerAbs {\n  /**\n   * 历史记录列表数组\n   * 每一项都是变化的delta，不是完整的舞台数据！\n   */\n  deltas: Delta[] = [];\n  /**\n   * 历史记录列表数组上的一个指针\n   *\n   * []\n   * -1      一开始数组为空时，指针指向 -1\n   *\n   * [a]\n   *  0\n   *\n   * [a, b]\n   *     1\n   */\n  currentIndex = -1;\n\n  /**\n   * 初始化的舞台数据\n   */\n  initialStage: Record<string, any>[] = [];\n\n  // 在project加载完毕后调用\n  constructor(private readonly project: Project) {\n    super();\n    this.initialStage = serialize(project.stage);\n  }\n\n  /**\n   * 记录一步骤\n   * @param file\n   */\n  recordStep() {\n    // console.trace(\"recordStep\");\n    // this.deltas = this.deltas.splice(this.currentIndex + 1);\n    this.deltas.splice(this.currentIndex + 1);\n    // 上面一行的含义：删除从 currentIndex + 1 开始的所有元素。\n    // [a, b, c, d, e]\n    //  0  1  2  3  4\n    //        ^\n    //  currentIndex = 2，去掉 3 4\n    // 变成\n    // [a, b, c]\n    //  0  1  2\n    //        ^\n\n    // 也就是撤回了好几步(两步)之后再做修改，后面的曾经历史就都删掉了，相当于重开了一个分支。\n    this.currentIndex++;\n    // [a, b, c]\n    //  0  1  2  3\n    //           ^\n    const prev = serialize(this.get(this.currentIndex - 1)); // [C stage]\n    const current = serialize(this.project.stage); // [D stage]\n    const patch_ = diff(prev, current); // [D stage] - [C stage] = [d]\n    if (!patch_) {\n      this.currentIndex--; // 没有变化，当指针回退到当前位置\n      return;\n    }\n\n    this.deltas.push(patch_);\n    // [a, b, c, d]\n    //  0  1  2  3\n    //           ^\n    while (this.deltas.length > Settings.historySize) {\n      // 当历史记录超过限制时，需要删除最旧的记录\n      // 但是不能简单删除，因为get方法依赖于从initialStage开始应用所有delta\n      // 所以我们需要将第一个delta合并到initialStage中，然后删除这个delta\n      const firstDelta = _.cloneDeep(this.deltas[0]);\n      this.initialStage = patch(_.cloneDeep(this.initialStage), firstDelta) as any;\n      this.deltas.shift(); // 删除第一个delta [a]\n      // [b, c, d]\n      //  0  1  2  3\n      //           ^\n\n      this.currentIndex--;\n      // [b, c, d]\n      //  0  1  2\n      //        ^\n    }\n    // 检测index是否越界\n    if (this.currentIndex >= this.deltas.length) {\n      this.currentIndex = this.deltas.length - 1;\n    }\n    this.project.state = ProjectState.Unsaved;\n  }\n\n  /**\n   * 撤销\n   */\n  undo() {\n    // currentIndex 最小为 -1\n    if (this.currentIndex >= 0) {\n      this.currentIndex--;\n      this.project.stage = this.get(this.currentIndex);\n    }\n    if (Settings.showDebug) {\n      toast(\n        <div className=\"flex text-sm\">\n          <span className=\"m-2 flex flex-col justify-center\">\n            <span>当前历史位置</span>\n            <span className={cn(this.currentIndex === -1 && \"text-red-500\")}>{this.currentIndex + 1}</span>\n          </span>\n          <span className=\"m-2 flex flex-col justify-center\">\n            <span>当前历史长度</span>\n            <span className={cn(this.deltas.length === Settings.historySize && \"text-yellow-500\")}>\n              {this.deltas.length}\n            </span>\n          </span>\n          <span className=\"m-2 flex flex-col justify-center\">\n            <span>限定历史长度</span>\n            <span className=\"opacity-50\">{Settings.historySize}</span>\n          </span>\n        </div>,\n      );\n    }\n  }\n\n  /**\n   * 反撤销\n   */\n  redo() {\n    if (this.currentIndex < this.deltas.length - 1) {\n      this.currentIndex++;\n      this.project.stage = this.get(this.currentIndex);\n    }\n    if (Settings.showDebug) {\n      toast(\n        <div className=\"flex text-sm\">\n          <span className=\"m-2 flex flex-col justify-center\">\n            <span>当前历史位置</span>\n            <span className={cn(this.currentIndex === this.deltas.length - 1 && \"text-green-500\")}>\n              {this.currentIndex + 1}\n            </span>\n          </span>\n          <span className=\"m-2 flex flex-col justify-center\">\n            <span>当前历史长度</span>\n            <span className={cn(this.deltas.length === Settings.historySize && \"text-yellow-500\")}>\n              {this.deltas.length}\n            </span>\n          </span>\n          <span className=\"m-2 flex flex-col justify-center\">\n            <span>限定历史长度</span>\n            <span className=\"opacity-50\">{Settings.historySize}</span>\n          </span>\n        </div>,\n      );\n    }\n  }\n\n  get(index: number) {\n    // 处理边界情况：如果索引为负数，直接返回初始状态\n    if (index < 0) {\n      return deserialize(_.cloneDeep(this.initialStage), this.project);\n    }\n\n    // 先获取从0到index（包含index）的所有patch\n    const deltas = _.cloneDeep(this.deltas.slice(0, index + 1));\n    // 从initialStage开始应用patch，得到在index时刻的舞台序列化数据\n    // const data = deltas.reduce((acc, delta) => {\n    //   return patch(_.cloneDeep(acc), _.cloneDeep(delta)) as any;\n    // }, _.cloneDeep(this.initialStage));\n    let data = _.cloneDeep(this.initialStage); // 迭代这个data\n    for (const delta of deltas) {\n      data = patch(data, _.cloneDeep(delta)) as any;\n    }\n    // 反序列化得到舞台对象\n    const stage = deserialize(data, this.project);\n    return stage;\n  }\n\n  /**\n   * 清空历史记录\n   * 保存文件时调用，将当前状态设为新的初始状态\n   */\n  clearHistory() {\n    this.deltas = [];\n    this.currentIndex = -1;\n    this.initialStage = serialize(this.project.stage);\n    this.project.state = ProjectState.Saved;\n    if (Settings.showDebug) {\n      toast(\"历史记录已清空\");\n    }\n  }\n}\n\n/**\n * 专门管理历史记录\n * 负责撤销、反撤销、重做等操作\n * 具有直接更改舞台状态的能力\n */\n@service(\"historyManager\")\nexport class HistoryManager extends HistoryManagerAbs {\n  private memoryEfficient: HistoryManagerAbs;\n  private timeEfficient: HistoryManagerAbs;\n\n  // 当前使用的历史管理器实例\n  private currentManager: HistoryManagerAbs;\n\n  constructor(project: Project) {\n    super();\n    this.memoryEfficient = new HistorymanagerMemoryEfficient(project);\n    this.timeEfficient = new HistoryManagerTimeEfficient(project);\n\n    // 根据设置初始化历史记录管理器模式\n    const initialMode = Settings.historyManagerMode;\n    this.currentManager = initialMode === \"memoryEfficient\" ? this.memoryEfficient : this.timeEfficient;\n\n    // 监听设置变化\n    Settings.watch(\"historyManagerMode\", (newMode) => {\n      this.switchMode(newMode === \"timeEfficient\");\n    });\n  }\n\n  /**\n   * 记录一步骤\n   */\n  public recordStep(): void {\n    this.currentManager.recordStep();\n  }\n\n  /**\n   * 撤销\n   */\n  public undo(): void {\n    this.currentManager.undo();\n  }\n\n  /**\n   * 反撤销\n   */\n  public redo(): void {\n    this.currentManager.redo();\n  }\n\n  /**\n   * 获取指定索引的历史记录\n   * @param index 历史记录索引\n   * @returns 舞台对象\n   */\n  public get(index: number): Record<string, any>[] {\n    return this.currentManager.get(index);\n  }\n\n  /**\n   * 清空历史记录\n   */\n  public clearHistory(): void {\n    this.currentManager.clearHistory();\n  }\n\n  /**\n   * 切换历史记录管理器模式\n   * @param useTimeEfficient 是否使用时间效率优先的管理器\n   */\n  public switchMode(useTimeEfficient: boolean): void {\n    // 保存当前舞台状态\n    // const currentStage = this.get();\n\n    // 清空两个管理器的历史记录\n    this.timeEfficient.clearHistory();\n    this.memoryEfficient.clearHistory();\n\n    // 切换到指定的管理器\n    this.currentManager = useTimeEfficient ? this.timeEfficient : this.memoryEfficient;\n\n    // 将当前状态记录为新管理器的初始状态\n    // if (currentStage.length > 0) {\n    //   this.recordStep(currentStage);\n    // }\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageManager/StageManager.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { EntityShrinkEffect } from \"@/core/service/feedbackService/effectEngine/concrete/EntityShrinkEffect\";\nimport { PenStrokeDeletedEffect } from \"@/core/service/feedbackService/effectEngine/concrete/PenStrokeDeletedEffect\";\nimport { SoundService } from \"@/core/service/feedbackService/SoundService\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { Association } from \"@/core/stage/stageObject/abstract/Association\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { StageObject } from \"@/core/stage/stageObject/abstract/StageObject\";\nimport { CubicCatmullRomSplineEdge } from \"@/core/stage/stageObject/association/CubicCatmullRomSplineEdge\";\nimport { Edge } from \"@/core/stage/stageObject/association/Edge\";\nimport { LineEdge } from \"@/core/stage/stageObject/association/LineEdge\";\nimport { MultiTargetUndirectedEdge } from \"@/core/stage/stageObject/association/MutiTargetUndirectedEdge\";\nimport { ConnectPoint } from \"@/core/stage/stageObject/entity/ConnectPoint\";\nimport { ImageNode } from \"@/core/stage/stageObject/entity/ImageNode\";\nimport { PenStroke } from \"@/core/stage/stageObject/entity/PenStroke\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { SvgNode } from \"@/core/stage/stageObject/entity/SvgNode\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { UrlNode } from \"@/core/stage/stageObject/entity/UrlNode\";\nimport { Direction } from \"@/types/directions\";\n// import { Serialized } from \"@/types/node\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { toast } from \"sonner\";\n\n// littlefean:应该改成类，实例化的对象绑定到舞台上。这成单例模式了\n// 开发过程中会造成多开\n// zty012:这个是存储数据的，和舞台无关，应该单独抽离出来\n// 并且会在舞台之外的地方操作，所以应该是namespace单例\n\n/**\n * 子场景的相机数据\n */\nexport type ChildCameraData = {\n  /**\n   * 传送门的左上角位置\n   */\n  location: Vector;\n  zoom: number;\n  /**\n   * 传送门大小\n   */\n  size: Vector;\n  /**\n   * 相机的目标位置\n   */\n  targetLocation: Vector;\n};\n\n/**\n * 舞台管理器，也可以看成包含了很多操作方法的《舞台实体容器》\n * 管理节点、边的关系等，内部包含了舞台上的所有实体\n */\n@service(\"stageManager\")\nexport class StageManager {\n  constructor(private readonly project: Project) {}\n\n  /**\n   * TODO: 这个get方法在2.0从O(1)变成O(N)了，可能是引起卡顿的原因，后面待排查\n   * @param uuid\n   * @returns\n   */\n  get(uuid: string) {\n    return this.project.stage.find((node) => node.uuid === uuid);\n  }\n\n  isEmpty(): boolean {\n    return this.project.stage.length === 0;\n  }\n  getTextNodes(): TextNode[] {\n    return this.project.stage.filter((node) => node instanceof TextNode);\n  }\n  getConnectableEntity(): ConnectableEntity[] {\n    return this.project.stage.filter((node) => {\n      if (node instanceof ConnectableEntity) {\n        // 排除背景图片\n        if (node instanceof ImageNode && (node as ImageNode).isBackground) {\n          return false;\n        }\n        return true;\n      }\n      return false;\n    }) as ConnectableEntity[];\n  }\n  isEntityExists(uuid: string): boolean {\n    return this.project.stage.filter((node) => node.uuid === uuid).length > 0;\n  }\n  getSections(): Section[] {\n    return this.project.stage.filter((node) => node instanceof Section);\n  }\n  getImageNodes(): ImageNode[] {\n    return this.project.stage.filter((node) => node instanceof ImageNode);\n  }\n  getConnectPoints(): ConnectPoint[] {\n    return this.project.stage.filter((node) => node instanceof ConnectPoint);\n  }\n  getUrlNodes(): UrlNode[] {\n    return this.project.stage.filter((node) => node instanceof UrlNode);\n  }\n  // getPortalNodes(): PortalNode[] {\n  //   return this.project.stage.filter((node) => node instanceof PortalNode);\n  // }\n  getPenStrokes(): PenStroke[] {\n    return this.project.stage.filter((node) => node instanceof PenStroke);\n  }\n  getSvgNodes(): SvgNode[] {\n    return this.project.stage.filter((node) => node instanceof SvgNode);\n  }\n\n  getStageObjects(): StageObject[] {\n    return this.project.stage;\n  }\n\n  /**\n   * 获取场上所有的实体\n   * @returns\n   */\n  getEntities(): Entity[] {\n    return this.project.stage.filter((node) => node instanceof Entity);\n  }\n  getEntitiesByUUIDs(uuids: string[]): Entity[] {\n    return this.project.stage.filter((node) => uuids.includes(node.uuid) && node instanceof Entity) as Entity[];\n  }\n  isNoEntity(): boolean {\n    return this.project.stage.filter((node) => node instanceof Entity).length === 0;\n  }\n  delete(stageObject: StageObject) {\n    this.project.stage.splice(this.project.stage.indexOf(stageObject), 1);\n  }\n\n  getAssociations(): Association[] {\n    return this.project.stage.filter((node) => node instanceof Association);\n  }\n  getEdges(): Edge[] {\n    return this.project.stage.filter((node) => node instanceof Edge);\n  }\n  getLineEdges(): LineEdge[] {\n    return this.project.stage.filter((node) => node instanceof LineEdge);\n  }\n  getCrEdges(): CubicCatmullRomSplineEdge[] {\n    return this.project.stage.filter((node) => node instanceof CubicCatmullRomSplineEdge);\n  }\n\n  add(stageObject: StageObject) {\n    this.project.stage.push(stageObject);\n  }\n\n  /**\n   * 更新节点的引用，将unknown的节点替换为真实的节点，保证对象在内存中的唯一性\n   * 节点什么情况下会是unknown的？\n   *\n   * 包含了对Section框的更新\n   * 包含了对Edge双向线偏移状态的更新\n   */\n  updateReferences() {\n    for (const entity of this.getEntities()) {\n      // 实体是可连接类型\n      if (entity instanceof ConnectableEntity) {\n        for (const edge of this.getAssociations()) {\n          if (edge instanceof Edge) {\n            if (edge.source.unknown && edge.source.uuid === entity.uuid) {\n              edge.source = entity;\n            }\n            if (edge.target.unknown && edge.target.uuid === entity.uuid) {\n              edge.target = entity;\n            }\n          }\n        }\n      }\n    }\n    // 以下是Section框的更新，y值降序排序，从下往上排序，因为下面的往往是内层的Section\n    for (const section of this.getSections().sort(\n      (a, b) => b.collisionBox.getRectangle().location.y - a.collisionBox.getRectangle().location.y,\n    )) {\n      // 更新孩子数组，并调整位置和大小\n      const newChildList = [];\n\n      for (const child of section.children) {\n        if (this.project.stage.find((node) => node.uuid === child.uuid)) {\n          const childObject = this.project.stage.find(\n            (node) => node.uuid === child.uuid && node instanceof Entity,\n          ) as Entity;\n          if (childObject) {\n            newChildList.push(childObject);\n          }\n        }\n      }\n      section.children = newChildList;\n      section.adjustLocationAndSize();\n      section.adjustChildrenStateByCollapse();\n    }\n\n    // 以下是LineEdge双向线偏移状态的更新\n    for (const edge of this.getLineEdges()) {\n      let isShifting = false;\n      for (const otherEdge of this.getLineEdges()) {\n        if (edge.source === otherEdge.target && edge.target === otherEdge.source) {\n          isShifting = true;\n          break;\n        }\n      }\n      edge.isShifting = isShifting;\n    }\n  }\n\n  getTextNodeByUUID(uuid: string): TextNode | null {\n    for (const node of this.getTextNodes()) {\n      if (node.uuid === uuid) {\n        return node;\n      }\n    }\n    return null;\n  }\n  getConnectableEntityByUUID(uuid: string): ConnectableEntity | null {\n    for (const node of this.getConnectableEntity()) {\n      if (node.uuid === uuid) {\n        return node;\n      }\n    }\n    return null;\n  }\n  isSectionByUUID(uuid: string): boolean {\n    return this.project.stage.find((node) => node.uuid === uuid) instanceof Section;\n  }\n  getSectionByUUID(uuid: string): Section | null {\n    const entity = this.get(uuid);\n    if (entity instanceof Section) {\n      return entity;\n    }\n    return null;\n  }\n\n  /**\n   * 计算所有节点的中心点\n   */\n  getCenter(): Vector {\n    if (this.project.stage.length === 0) {\n      return Vector.getZero();\n    }\n    const allNodesRectangle = Rectangle.getBoundingRectangle(\n      this.project.stage.map((node) => node.collisionBox.getRectangle()),\n    );\n    return allNodesRectangle.center;\n  }\n\n  /**\n   * 计算所有节点的大小\n   */\n  getSize(): Vector {\n    if (this.project.stage.length === 0) {\n      return new Vector(this.project.renderer.w, this.project.renderer.h);\n    }\n    const size = this.getBoundingRectangle().size;\n\n    return size;\n  }\n\n  /**\n   * 获取舞台的矩形对象\n   */\n  getBoundingRectangle(): Rectangle {\n    const rect = Rectangle.getBoundingRectangle(\n      Array.from(this.project.stage).map((node) => node.collisionBox.getRectangle()),\n    );\n\n    return rect;\n  }\n\n  /**\n   * 根据位置查找节点，常用于点击事件\n   * @param location\n   * @returns\n   */\n  findTextNodeByLocation(location: Vector): TextNode | null {\n    for (const node of this.getTextNodes()) {\n      if (node.collisionBox.isContainsPoint(location)) {\n        return node;\n      }\n    }\n    return null;\n  }\n\n  /**\n   * 用于鼠标悬停时查找边\n   * @param location\n   * @returns\n   */\n  findLineEdgeByLocation(location: Vector): LineEdge | null {\n    for (const edge of this.getLineEdges()) {\n      if (edge.collisionBox.isContainsPoint(location)) {\n        return edge;\n      }\n    }\n    return null;\n  }\n\n  findAssociationByLocation(location: Vector): Association | null {\n    for (const association of this.getAssociations()) {\n      if (association.collisionBox.isContainsPoint(location)) {\n        return association;\n      }\n    }\n    return null;\n  }\n\n  findSectionByLocation(location: Vector): Section | null {\n    for (const section of this.getSections()) {\n      if (section.collisionBox.isContainsPoint(location)) {\n        return section;\n      }\n    }\n    return null;\n  }\n\n  findImageNodeByLocation(location: Vector): ImageNode | null {\n    for (const node of this.getImageNodes()) {\n      if (!node.isBackground && node.collisionBox.isContainsPoint(location)) {\n        return node;\n      }\n    }\n    return null;\n  }\n\n  findConnectableEntityByLocation(location: Vector): ConnectableEntity | null {\n    for (const entity of this.getConnectableEntity()) {\n      if (entity.isHiddenBySectionCollapse) {\n        continue;\n      }\n      if (entity.collisionBox.isContainsPoint(location)) {\n        return entity;\n      }\n    }\n    return null;\n  }\n\n  /**\n   * 优先级：\n   * 涂鸦 > 其他\n   * @param location\n   * @returns\n   */\n  findEntityByLocation(location: Vector): Entity | null {\n    for (const penStroke of this.getPenStrokes()) {\n      if (penStroke.isHiddenBySectionCollapse) continue;\n      if (penStroke.collisionBox.isContainsPoint(location)) {\n        return penStroke;\n      }\n    }\n    for (const entity of this.getEntities()) {\n      if (entity.isHiddenBySectionCollapse) {\n        continue;\n      }\n      if (entity instanceof ImageNode && entity.isBackground) {\n        continue;\n      }\n      if (entity.collisionBox.isContainsPoint(location)) {\n        return entity;\n      }\n    }\n    return null;\n  }\n\n  findConnectPointByLocation(location: Vector): ConnectPoint | null {\n    for (const point of this.getConnectPoints()) {\n      if (point.isHiddenBySectionCollapse) {\n        continue;\n      }\n      if (point.collisionBox.isContainsPoint(location)) {\n        return point;\n      }\n    }\n    return null;\n  }\n  isHaveEntitySelected(): boolean {\n    for (const entity of this.getEntities()) {\n      if (entity.isSelected) {\n        return true;\n      }\n    }\n    return false;\n  }\n\n  /**\n   * O(n)\n   * @returns\n   */\n  getSelectedEntities(): Entity[] {\n    return this.project.stage.filter(\n      (so) => so.isSelected && so instanceof Entity && !(so instanceof ImageNode && (so as ImageNode).isBackground),\n    ) as Entity[];\n  }\n  getSelectedAssociations(): Association[] {\n    return this.project.stage.filter((so) => so.isSelected && so instanceof Association) as Association[];\n  }\n  getSelectedStageObjects(): StageObject[] {\n    const result: StageObject[] = [];\n    result.push(...this.getSelectedEntities());\n    result.push(...this.getSelectedAssociations());\n    return result;\n  }\n\n  /**\n   * 获取选中内容的边界矩形\n   * @returns\n   */\n  getBoundingBoxOfSelected(): Rectangle {\n    const selectedObjects = this.getSelectedStageObjects();\n    if (selectedObjects.length === 0) {\n      // 如果没有选中任何对象，返回一个默认的矩形\n      return new Rectangle(Vector.getZero(), new Vector(100, 100));\n    }\n\n    const rectangles = selectedObjects.map((obj) => obj.collisionBox.getRectangle());\n    return Rectangle.getBoundingRectangle(rectangles);\n  }\n\n  /**\n   * 判断某一点是否有实体存在（排除实体的被Section折叠）\n   * @param location\n   * @returns\n   */\n  isEntityOnLocation(location: Vector): boolean {\n    for (const entity of this.getEntities()) {\n      if (entity.isHiddenBySectionCollapse) {\n        continue;\n      }\n      if (entity instanceof ImageNode && entity.isBackground) {\n        continue;\n      }\n      if (entity.collisionBox.isContainsPoint(location)) {\n        return true;\n      }\n    }\n    return false;\n  }\n  isAssociationOnLocation(location: Vector): boolean {\n    for (const association of this.getAssociations()) {\n      if (association instanceof Edge) {\n        if (association.target.isHiddenBySectionCollapse && association.source.isHiddenBySectionCollapse) {\n          continue;\n        }\n      }\n      if (association.collisionBox.isContainsPoint(location)) {\n        return true;\n      }\n    }\n    return false;\n  }\n\n  // region 以下为舞台操作相关的函数\n  // 建议不同的功能分类到具体的文件中，然后最后集中到这里调用，使得下面的显示简短一些\n  // 每个操作函数尾部都要加一个记录历史的操作\n\n  deleteEntities(deleteNodes: Entity[]) {\n    if (deleteNodes.length === 0) {\n      // 此处return 性能优化60ms\n      return;\n    }\n    this.project.deleteManager.deleteEntities(deleteNodes);\n    this.project.historyManager.recordStep();\n  }\n\n  /**\n   * 外部的交互层的delete键可以直接调用这个函数\n   */\n  deleteSelectedStageObjects() {\n    const selectedEntities = this.getEntities().filter((node) => node.isSelected);\n\n    // 检查选中的实体是否在锁定的 section 内，或者实体本身是否是锁定的 section，或者是背景图片\n    const filteredEntities = selectedEntities.filter((entity) => {\n      return (\n        !this.project.sectionMethods.isObjectBeLockedBySection(entity) &&\n        !(entity instanceof ImageNode && entity.isBackground)\n      );\n    });\n\n    for (const entity of filteredEntities) {\n      if (entity instanceof PenStroke) {\n        this.project.effects.addEffect(PenStrokeDeletedEffect.fromPenStroke(entity));\n      } else {\n        this.project.effects.addEffect(EntityShrinkEffect.fromEntity(entity));\n      }\n    }\n    this.deleteEntities(filteredEntities);\n\n    // 处理所有类型的边，包括普通边和多目标无向边\n    for (const association of this.getAssociations()) {\n      if (association.isSelected) {\n        // 检查连线是否连接了锁定的 section 内的物体\n        if (this.project.sectionMethods.isObjectBeLockedBySection(association)) {\n          continue; // 连接了锁定 section 内物体的连线不参与删除\n        }\n\n        this.deleteAssociation(association);\n        if (association instanceof Edge) {\n          this.project.effects.addEffects(this.project.edgeRenderer.getCuttingEffects(association));\n        }\n      }\n    }\n  }\n  deleteAssociation(deleteAssociation: Association): boolean {\n    if (deleteAssociation instanceof Edge) {\n      return this.deleteEdge(deleteAssociation);\n    } else if (deleteAssociation instanceof MultiTargetUndirectedEdge) {\n      const res = this.project.deleteManager.deleteMultiTargetUndirectedEdge(deleteAssociation);\n      this.project.historyManager.recordStep();\n      return res;\n    }\n    toast.error(\"无法删除未知类型的关系\");\n    return false;\n  }\n\n  deleteEdge(deleteEdge: Edge): boolean {\n    const res = this.project.deleteManager.deleteEdge(deleteEdge);\n    this.project.historyManager.recordStep();\n    return res;\n  }\n\n  // 一个简单的文案\n  private static w = `自环已被禁止，可在设置>控制>连线 中打开允许添加自环选项，\n    但您可能并不是想添加自环，您可能是想打开右键菜单，所以请在空白位置右键。\n    如果您不需要添加自环的操作但想保持能够通过在节点上右键打开菜单的操作，\n    可在设置>控制>连线 中关闭“启用右键点击连线功能”。\n    但如果您既要右键点击节点创建连线功能，又想要在节点上右键展开右键菜单操作，很抱歉，这两个功能在逻辑上冲突了。\n    `;\n\n  connectEntity(fromNode: ConnectableEntity, toNode: ConnectableEntity, isCrEdge: boolean = false) {\n    if (fromNode === toNode && !Settings.allowAddCycleEdge) {\n      toast.warning(\n        <div>\n          <span>{StageManager.w}</span>\n        </div>,\n      );\n      return false;\n    }\n    if (isCrEdge) {\n      this.project.nodeConnector.addCrEdge(fromNode, toNode);\n    } else {\n      this.project.nodeConnector.connectConnectableEntity(fromNode, toNode);\n    }\n\n    this.project.historyManager.recordStep();\n    return this.project.graphMethods.isConnected(fromNode, toNode);\n  }\n\n  /**\n   * 多重连接，只记录一次历史\n   * @param fromNodes\n   * @param toNode\n   * @param isCrEdge\n   * @returns\n   */\n  connectMultipleEntities(\n    fromNodes: ConnectableEntity[],\n    toNode: ConnectableEntity,\n    isCrEdge: boolean = false,\n    sourceRectRate?: [number, number],\n    targetRectRate?: [number, number],\n  ) {\n    if (fromNodes.length === 0) {\n      return false;\n    }\n    for (const fromNode of fromNodes) {\n      if (fromNode === toNode && !Settings.allowAddCycleEdge) {\n        toast.warning(\n          <div>\n            <h2 className=\"text-xl\">请在空白处右键</h2>\n            <span>{StageManager.w}</span>\n          </div>,\n        );\n        continue;\n      }\n      if (isCrEdge) {\n        this.project.nodeConnector.addCrEdge(fromNode, toNode);\n      } else {\n        this.project.nodeConnector.connectConnectableEntity(fromNode, toNode, \"\", targetRectRate, sourceRectRate);\n      }\n    }\n    this.project.historyManager.recordStep();\n    return true;\n  }\n\n  /**\n   * 反转一个节点与他相连的所有连线方向\n   * @param connectEntity\n   */\n  private reverseNodeEdges(connectEntity: ConnectableEntity) {\n    const prepareReverseEdges = [];\n    for (const edge of this.getLineEdges()) {\n      if (edge.target === connectEntity || edge.source === connectEntity) {\n        prepareReverseEdges.push(edge);\n      }\n    }\n    this.project.nodeConnector.reverseEdges(prepareReverseEdges);\n  }\n\n  /**\n   * 反转所有选中的节点的每个节点的连线\n   */\n  reverseSelectedNodeEdge() {\n    const entities = this.getSelectedEntities().filter((entity) => entity instanceof ConnectableEntity);\n    for (const entity of entities) {\n      this.reverseNodeEdges(entity);\n    }\n  }\n\n  reverseSelectedEdges() {\n    const selectedEdges = this.getLineEdges().filter((edge) => edge.isSelected);\n    if (selectedEdges.length === 0) {\n      return;\n    }\n    this.project.nodeConnector.reverseEdges(selectedEdges);\n  }\n\n  // addSerializedData(serializedData: Serialized.File, diffLocation = new Vector(0, 0)) {\n  //   this.project.serializedDataAdder.addSerializedData(serializedData, diffLocation);\n  //   this.project.historyManager.recordStep();\n  // }\n\n  generateNodeTreeByText(text: string, indention: number = 4, location = this.project.camera.location) {\n    this.project.nodeAdder.addNodeTreeByText(text, indention, location);\n    this.project.historyManager.recordStep();\n  }\n\n  generateNodeGraphByText(text: string, location = this.project.camera.location) {\n    try {\n      this.project.nodeAdder.addNodeGraphByText(text, location);\n      this.project.historyManager.recordStep();\n    } catch (e: any) {\n      toast.error(e.message);\n    }\n  }\n\n  generateNodeMermaidByText(text: string, location = this.project.camera.location) {\n    try {\n      this.project.nodeAdder.addNodeMermaidByText(text, location);\n      this.project.historyManager.recordStep();\n    } catch (e: any) {\n      toast.error(e.message);\n    }\n  }\n\n  generateNodeByMarkdown(text: string, location = this.project.camera.location, autoLayout = true) {\n    this.project.nodeAdder.addNodeByMarkdown(text, location, autoLayout);\n    this.project.historyManager.recordStep();\n  }\n\n  /** 将多个实体打包成一个section，并添加到舞台中 */\n  async packEntityToSection(addEntities: Entity[]) {\n    await this.project.sectionPackManager.packEntityToSection(addEntities);\n    this.project.historyManager.recordStep();\n  }\n\n  /** 将选中的实体打包成一个section，并添加到舞台中 */\n  async packEntityToSectionBySelected() {\n    const selectedNodes = this.getSelectedEntities();\n    if (selectedNodes.length === 0) {\n      return;\n    }\n    this.packEntityToSection(selectedNodes);\n  }\n\n  goInSection(entities: Entity[], section: Section) {\n    this.project.sectionInOutManager.goInSection(entities, section);\n    this.project.historyManager.recordStep();\n  }\n\n  goOutSection(entities: Entity[], section: Section) {\n    this.project.sectionInOutManager.goOutSection(entities, section);\n    this.project.historyManager.recordStep();\n  }\n  /** 将所有选中的Section折叠起来 */\n  packSelectedSection() {\n    this.project.sectionPackManager.packSection();\n    this.project.historyManager.recordStep();\n  }\n\n  /** 将所有选中的Section展开 */\n  unpackSelectedSection() {\n    this.project.sectionPackManager.unpackSection();\n    this.project.historyManager.recordStep();\n  }\n\n  /**\n   * 切换选中的Section的折叠状态\n   */\n  sectionSwitchCollapse() {\n    this.project.sectionPackManager.switchCollapse();\n    this.project.historyManager.recordStep();\n  }\n\n  connectEntityByCrEdge(fromNode: ConnectableEntity, toNode: ConnectableEntity) {\n    return this.project.nodeConnector.addCrEdge(fromNode, toNode);\n  }\n\n  /**\n   * 刷新所有舞台内容\n   */\n  refreshAllStageObjects() {\n    const entities = this.getEntities();\n    for (const entity of entities) {\n      if (entity instanceof TextNode) {\n        if (entity.sizeAdjust === \"auto\") {\n          entity.forceAdjustSizeByText();\n        }\n      } else if (entity instanceof Section) {\n        entity.adjustLocationAndSize();\n      }\n    }\n  }\n\n  /**\n   * 刷新选中内容\n   */\n  refreshSelected() {\n    const entities = this.getSelectedEntities();\n    for (const entity of entities) {\n      if (entity instanceof ImageNode) {\n        // entity.refresh();\n      }\n    }\n  }\n\n  /**\n   * 改变连线的目标接头点位置\n   * @param direction\n   */\n  changeSelectedEdgeConnectLocation(direction: Direction | null, isSource: boolean = false) {\n    const edges = this.getSelectedAssociations().filter((edge) => edge instanceof Edge);\n    this.changeEdgesConnectLocation(edges, direction, isSource);\n  }\n\n  /**\n   * 更改多个连线的目标接头点位置\n   * @param edges\n   * @param direction\n   * @param isSource\n   */\n  changeEdgesConnectLocation(edges: Edge[], direction: Direction | null, isSource: boolean = false) {\n    const newLocationRate = new Vector(0.5, 0.5);\n    if (direction === Direction.Left) {\n      newLocationRate.x = 0.01;\n    } else if (direction === Direction.Right) {\n      newLocationRate.x = 0.99;\n    } else if (direction === Direction.Up) {\n      newLocationRate.y = 0.01;\n    } else if (direction === Direction.Down) {\n      newLocationRate.y = 0.99;\n    }\n\n    for (const edge of edges) {\n      if (isSource) {\n        edge.sourceRectangleRate = newLocationRate;\n      } else {\n        edge.targetRectangleRate = newLocationRate;\n      }\n    }\n    // 播放连线调整音效\n    SoundService.play.associationAdjustSoundFile();\n  }\n\n  switchLineEdgeToCrEdge() {\n    const prepareDeleteLineEdge: LineEdge[] = [];\n    for (const edge of this.getLineEdges()) {\n      if (edge instanceof LineEdge && edge.isSelected) {\n        // 删除这个连线，并准备创建cr曲线\n        prepareDeleteLineEdge.push(edge);\n      }\n    }\n    for (const lineEdge of prepareDeleteLineEdge) {\n      this.deleteEdge(lineEdge);\n      this.connectEntityByCrEdge(lineEdge.source, lineEdge.target);\n    }\n  }\n\n  /**\n   * 有向边转无向边\n   */\n  switchEdgeToUndirectedEdge() {\n    const prepareDeleteLineEdge: LineEdge[] = [];\n    for (const edge of this.getLineEdges()) {\n      if (edge instanceof LineEdge && edge.isSelected) {\n        // 删除这个连线，并准备创建\n        prepareDeleteLineEdge.push(edge);\n      }\n    }\n    for (const edge of prepareDeleteLineEdge) {\n      if (edge.target === edge.source) {\n        continue;\n      }\n      this.deleteEdge(edge);\n      const undirectedEdge = MultiTargetUndirectedEdge.createFromSomeEntity(this.project, [edge.target, edge.source]);\n      undirectedEdge.text = edge.text;\n      undirectedEdge.color = edge.color.clone();\n      undirectedEdge.arrow = \"outer\";\n      // undirectedEdge.isSelected = true;\n      this.add(undirectedEdge);\n    }\n  }\n  /**\n   * 无向边转有向边\n   */\n  switchUndirectedEdgeToEdge() {\n    const prepareDeleteUndirectedEdge: MultiTargetUndirectedEdge[] = [];\n    for (const edge of this.getAssociations()) {\n      if (edge instanceof MultiTargetUndirectedEdge && edge.isSelected) {\n        // 删除这个连线，并准备创建\n        prepareDeleteUndirectedEdge.push(edge);\n      }\n    }\n    for (const edge of prepareDeleteUndirectedEdge) {\n      if (edge.associationList.length !== 2) {\n        continue;\n      }\n\n      const [fromNode, toNode] = edge.associationList;\n      if (fromNode && toNode && fromNode instanceof ConnectableEntity && toNode instanceof ConnectableEntity) {\n        const lineEdge = LineEdge.fromTwoEntity(this.project, fromNode, toNode);\n        lineEdge.text = edge.text;\n        lineEdge.color = edge.color.clone();\n        this.deleteAssociation(edge);\n        this.add(lineEdge);\n        this.updateReferences();\n      }\n    }\n  }\n\n  addSelectedCREdgeControlPoint() {\n    const selectedCREdge = this.getSelectedAssociations().filter((edge) => edge instanceof CubicCatmullRomSplineEdge);\n    for (const edge of selectedCREdge) {\n      edge.addControlPoint();\n    }\n  }\n\n  addSelectedCREdgeTension() {\n    const selectedCREdge = this.getSelectedAssociations().filter((edge) => edge instanceof CubicCatmullRomSplineEdge);\n    for (const edge of selectedCREdge) {\n      edge.tension += 0.25;\n      edge.tension = Math.min(1, edge.tension);\n    }\n  }\n\n  reduceSelectedCREdgeTension() {\n    const selectedCREdge = this.getSelectedAssociations().filter((edge) => edge instanceof CubicCatmullRomSplineEdge);\n    for (const edge of selectedCREdge) {\n      edge.tension -= 0.25;\n      edge.tension = Math.max(0, edge.tension);\n    }\n  }\n\n  /**\n   * 设置选中Edge的线条类型\n   */\n  setSelectedEdgeLineType(lineType: string) {\n    const selectedEdges = this.getSelectedAssociations().filter((edge) => edge instanceof LineEdge);\n    for (const edge of selectedEdges) {\n      edge.lineType = lineType;\n    }\n  }\n\n  /**\n   * ctrl + A 全选\n   */\n  selectAll() {\n    const allEntity = this.project.stage;\n    for (const entity of allEntity) {\n      entity.isSelected = true;\n    }\n  }\n  clearSelectAll() {\n    for (const entity of this.project.stage) {\n      entity.isSelected = false;\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageManager/basicMethods/GraphMethods.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { Edge } from \"@/core/stage/stageObject/association/Edge\";\nimport { MultiTargetUndirectedEdge } from \"../../stageObject/association/MutiTargetUndirectedEdge\";\n\n@service(\"graphMethods\")\nexport class GraphMethods {\n  constructor(protected readonly project: Project) {}\n\n  isTree(node: ConnectableEntity): boolean {\n    const dfs = (node: ConnectableEntity, visited: ConnectableEntity[]): boolean => {\n      if (visited.includes(node)) {\n        return false;\n      }\n      visited.push(node);\n      for (const child of this.nodeChildrenArray(node)) {\n        if (!dfs(child, visited)) {\n          return false;\n        }\n      }\n      return true;\n    };\n\n    return dfs(node, []);\n  }\n\n  /** 获取节点连接的子节点数组，未排除自环 */\n  nodeChildrenArray(node: ConnectableEntity): ConnectableEntity[] {\n    const res: ConnectableEntity[] = [];\n    for (const edge of this.project.stageManager.getLineEdges()) {\n      if (edge.source.uuid === node.uuid) {\n        res.push(edge.target);\n      }\n    }\n    return res;\n  }\n\n  /**\n   * 获取一个节点的所有父亲节点，排除自环\n   * 性能有待优化！！\n   */\n  nodeParentArray(node: ConnectableEntity): ConnectableEntity[] {\n    const res: ConnectableEntity[] = [];\n    for (const edge of this.project.stageManager.getLineEdges()) {\n      if (edge.target.uuid === node.uuid && edge.target.uuid !== edge.source.uuid) {\n        res.push(edge.source);\n      }\n    }\n    return res;\n  }\n\n  edgeChildrenArray(node: ConnectableEntity): Edge[] {\n    return this.project.stageManager.getLineEdges().filter((edge) => edge.source.uuid === node.uuid);\n  }\n\n  edgeParentArray(node: ConnectableEntity): Edge[] {\n    return this.project.stageManager.getLineEdges().filter((edge) => edge.target.uuid === node.uuid);\n  }\n\n  /**\n   * 获取反向边集\n   * @param edges\n   */\n  private getReversedEdgeDict(): Record<string, string> {\n    const res: Record<string, string> = {};\n    for (const edge of this.project.stageManager.getLineEdges()) {\n      res[edge.target.uuid] = edge.source.uuid;\n    }\n    return res;\n  }\n\n  /**\n   * 当前节点是否是存在于树形结构中，且非树形结构的跟节点\n   * @param node\n   * @returns\n   */\n  isCurrentNodeInTreeStructAndNotRoot(node: ConnectableEntity): boolean {\n    const roots = this.getRoots(node);\n    if (roots.length !== 1) {\n      return false;\n    }\n    const rootNode = roots[0];\n    if (rootNode.uuid === node.uuid) {\n      return false;\n    }\n    return this.isTree(rootNode);\n  }\n\n  /**\n   * 获取自己的祖宗节点\n   * @param node 节点\n   */\n  getRoots(node: ConnectableEntity): ConnectableEntity[] {\n    const reverseEdges = this.getReversedEdgeDict();\n    let rootUUID = node.uuid;\n    const visited: Set<string> = new Set(); // 用于记录已经访问过的节点，避免重复访问\n    while (reverseEdges[rootUUID] && !visited.has(rootUUID)) {\n      visited.add(rootUUID);\n      const parentUUID = reverseEdges[rootUUID];\n      const parent = this.project.stageManager.getConnectableEntityByUUID(parentUUID);\n      if (parent) {\n        rootUUID = parentUUID;\n      } else {\n        break;\n      }\n    }\n    const root = this.project.stageManager.getConnectableEntityByUUID(rootUUID);\n    if (root) {\n      return [root];\n    } else {\n      return [];\n    }\n  }\n\n  isConnected(node: ConnectableEntity, target: ConnectableEntity): boolean {\n    for (const edge of this.project.stageManager.getLineEdges()) {\n      if (edge.source === node && edge.target === target) {\n        return true;\n      }\n    }\n    return false;\n  }\n\n  /**\n   * 通过一个节点获取一个 可达节点集合/后继节点集合 Successor Set\n   * 包括它自己\n   * @param node\n   */\n  getSuccessorSet(node: ConnectableEntity, isHaveSelf: boolean = true): ConnectableEntity[] {\n    let result: ConnectableEntity[] = []; // 存储可达节点的结果集\n    const visited: Set<string> = new Set(); // 用于记录已经访问过的节点，避免重复访问\n\n    // 深度优先搜索 (DFS) 实现\n    const dfs = (currentNode: ConnectableEntity): void => {\n      if (visited.has(currentNode.uuid)) {\n        return; // 如果节点已经被访问过，直接返回\n      }\n      visited.add(currentNode.uuid); // 标记当前节点为已访问\n      result.push(currentNode); // 将当前节点加入结果集\n\n      // 遍历当前节点的所有子节点\n      const children = this.nodeChildrenArray(currentNode);\n      for (const child of children) {\n        dfs(child); // 对每个子节点递归调用 DFS\n      }\n    };\n\n    // 从给定节点开始进行深度优先搜索\n    dfs(node);\n    if (!isHaveSelf) {\n      result = result.filter((n) => n === node);\n    }\n\n    return result; // 返回所有可达节点的集合\n  }\n\n  /**\n   * 获取一个节点的一步可达节点集合/后继节点集合 One-Step Successor Set\n   * 排除自环\n   * @param node\n   */\n  getOneStepSuccessorSet(node: ConnectableEntity): ConnectableEntity[] {\n    const result: ConnectableEntity[] = []; // 存储可达节点的结果集\n    for (const edge of this.project.stageManager.getLineEdges()) {\n      if (edge.source === node && edge.target.uuid !== edge.source.uuid) {\n        result.push(edge.target);\n      }\n    }\n    return result;\n  }\n\n  getEdgesBetween(node1: ConnectableEntity, node2: ConnectableEntity): Edge[] {\n    const result: Edge[] = []; // 存储连接两个节点的边的结果集\n    for (const edge of this.project.stageManager.getEdges()) {\n      if (edge.source === node1 && edge.target === node2) {\n        result.push(edge);\n      }\n    }\n    return result;\n  }\n\n  getEdgeFromTwoEntity(fromNode: ConnectableEntity, toNode: ConnectableEntity): Edge | null {\n    for (const edge of this.project.stageManager.getEdges()) {\n      if (edge.source === fromNode && edge.target === toNode) {\n        return edge;\n      }\n    }\n    return null;\n  }\n\n  /**\n   * 找到和一个节点直接相连的所有超边\n   * @param node\n   * @returns\n   */\n  getHyperEdgesByNode(node: ConnectableEntity): MultiTargetUndirectedEdge[] {\n    const edges: MultiTargetUndirectedEdge[] = [];\n    const hyperEdges = this.project.stageManager\n      .getAssociations()\n      .filter((association) => association instanceof MultiTargetUndirectedEdge);\n    for (const hyperEdge of hyperEdges) {\n      if (hyperEdge.associationList.includes(node)) {\n        edges.push(hyperEdge);\n      }\n    }\n    return edges;\n  }\n\n  /**\n   * 获取一个节点的所有出度（出边）\n   * @param node 源节点\n   * @returns 节点的所有出边数组\n   */\n  public getOutgoingEdges(node: ConnectableEntity): Edge[] {\n    const result: Edge[] = [];\n    for (const edge of this.project.stageManager.getEdges()) {\n      if (edge.source === node) {\n        result.push(edge);\n      }\n    }\n    return result;\n  }\n\n  /**\n   * 获取一个节点的所有入度（入边）\n   * @param node 目标节点\n   * @returns 节点的所有入边数组\n   */\n  public getIncomingEdges(node: ConnectableEntity): Edge[] {\n    const result: Edge[] = [];\n    for (const edge of this.project.stageManager.getEdges()) {\n      if (edge.target === node) {\n        result.push(edge);\n      }\n    }\n    return result;\n  }\n\n  /**\n   * 获取一个节点通过连接它的所有超边的其他节点\n   * 例如 {A B C}, {C, D, E}，f(A) => {B, C, D, E}\n   * @param node 指定节点\n   * @returns 通过超边连接的所有其他节点集合（排除节点自身）\n   */\n  public getNodesConnectedByHyperEdges(node: ConnectableEntity): ConnectableEntity[] {\n    // 获取与节点相连的所有超边\n    const hyperEdges = this.getHyperEdgesByNode(node);\n\n    // 创建一个Set来存储结果，确保没有重复节点\n    const connectedNodes = new Set<ConnectableEntity>();\n\n    // 遍历所有超边，收集连接的节点\n    for (const hyperEdge of hyperEdges) {\n      for (const connectedNode of hyperEdge.associationList) {\n        // 排除节点自身\n        if (connectedNode.uuid !== node.uuid) {\n          connectedNodes.add(connectedNode);\n        }\n      }\n    }\n\n    // 将Set转换为数组并返回\n    return Array.from(connectedNodes);\n  }\n\n  private nodeChildrenArrayWithinSet(node: ConnectableEntity, nodeSet: Set<string>): ConnectableEntity[] {\n    return this.nodeChildrenArray(node).filter((child) => nodeSet.has(child.uuid));\n  }\n\n  private nodeParentArrayWithinSet(node: ConnectableEntity, nodeSet: Set<string>): ConnectableEntity[] {\n    return this.nodeParentArray(node).filter((parent) => nodeSet.has(parent.uuid));\n  }\n\n  /**\n   * 根据一组节点判断其在子图中的连接关系是否构成一棵树，并返回唯一根节点。\n   * 规则：\n   * - 子图中每个节点的入度至多为1\n   * - 恰好存在一个入度为0的根节点\n   * - 从根出发可达所有节点（连通），且无环\n   */\n  public getTreeRootByNodes(nodes: ConnectableEntity[]): ConnectableEntity | null {\n    if (nodes.length === 0) return null;\n    const nodeSet = new Set<string>(nodes.map((n) => n.uuid));\n    const roots = nodes.filter((n) => this.nodeParentArrayWithinSet(n, nodeSet).length === 0);\n    if (roots.length !== 1) return null;\n    return roots[0];\n  }\n\n  /** 判断一组节点在其诱导子图中是否构成一棵树 */\n  public isTreeByNodes(nodes: ConnectableEntity[]): boolean {\n    if (nodes.length === 0) return false;\n    const nodeSet = new Set<string>(nodes.map((n) => n.uuid));\n\n    // 每个节点入度最多为1\n    for (const n of nodes) {\n      if (this.nodeParentArrayWithinSet(n, nodeSet).length > 1) {\n        return false;\n      }\n    }\n\n    // 唯一根节点\n    const root = this.getTreeRootByNodes(nodes);\n    if (!root) return false;\n\n    // DFS 检测无环且连通（覆盖所有节点）\n    const visited = new Set<string>();\n    const dfs = (current: ConnectableEntity): boolean => {\n      if (visited.has(current.uuid)) {\n        return false; // 发现环\n      }\n      visited.add(current.uuid);\n      for (const child of this.nodeChildrenArrayWithinSet(current, nodeSet)) {\n        if (!dfs(child)) return false;\n      }\n      return true;\n    };\n\n    if (!dfs(root)) return false;\n    return visited.size === nodeSet.size;\n  }\n\n  /** 判断一组节点在其诱导子图中是否构成有向无环图（DAG） */\n  public isDAGByNodes(nodes: ConnectableEntity[]): boolean {\n    if (nodes.length === 0) return false;\n    const nodeSet = new Set<string>(nodes.map((n) => n.uuid));\n\n    // 使用 Kahn算法检测DAG\n    // 1. 计算每个节点的入度\n    const inDegree: Map<string, number> = new Map();\n    const adjacency: Map<string, ConnectableEntity[]> = new Map();\n\n    // 初始化入度和邻接表\n    for (const node of nodes) {\n      inDegree.set(node.uuid, this.nodeParentArrayWithinSet(node, nodeSet).length);\n      adjacency.set(node.uuid, this.nodeChildrenArrayWithinSet(node, nodeSet));\n    }\n\n    // 2. 将所有入度为0的节点入队\n    const queue: ConnectableEntity[] = [];\n    for (const node of nodes) {\n      if (inDegree.get(node.uuid) === 0) {\n        queue.push(node);\n      }\n    }\n\n    // 3. 拓扑排序\n    let count = 0;\n    while (queue.length > 0) {\n      const current = queue.shift()!;\n      count++;\n\n      // 遍历所有邻接节点\n      for (const neighbor of adjacency.get(current.uuid)!) {\n        const neighborId = neighbor.uuid;\n        const newInDegree = inDegree.get(neighborId)! - 1;\n        inDegree.set(neighborId, newInDegree);\n\n        if (newInDegree === 0) {\n          queue.push(neighbor);\n        }\n      }\n    }\n\n    // 如果所有节点都被访问过，说明没有环，是DAG\n    return count === nodes.length;\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageManager/basicMethods/PenStrokeMethods.tsx",
    "content": "import { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\n// import { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { PenStroke } from \"@/core/stage/stageObject/entity/PenStroke\";\nimport type { Project } from \"@/core/Project\";\n\n/**\n * 一切和涂鸦算法相关的内容\n */\nexport namespace PenStrokeMethods {\n  /**\n   * 传入一些实体，返回这些实体经过涂鸦粘连扩散后的整个实体集合\n   * @param project\n   * @param entities 可以代表选中的实体，但不包括涂鸦\n   * 返回的结果集合中，不包含传入的实体，只包括额外扩散后的波及实体，也包括涂鸦\n   */\n  // function getAllRoundedStickEntities(project: Project, entities: Entity[]): Entity[] {\n  //   const result: Set<string> = new Set();\n\n  //   const dfs = (entity: ConnectableEntity) => {\n  //     if (result.has(entity.uuid)) {\n  //       return;\n  //     }\n  //     result.add(entity.uuid);\n  //     // 遍历这个实体触碰到的所有涂鸦\n  //     const touchingPenStrokes: PenStroke[] = getTouchingPenStrokes(project, entity);\n  //     // 将所有涂鸦添加到结果集合中\n  //     for (const penStroke of touchingPenStrokes) {\n  //       result.add(penStroke.uuid);\n  //       // 遍历这个涂鸦触碰到的所有实体\n  //       const touchingEntities: ConnectableEntity[] = getConnectableEntitiesByPenStroke(project, penStroke);\n  //       // 将所有实体添加到结果集合中\n  //       for (const touchingEntity of touchingEntities) {\n  //         dfs(touchingEntity);\n  //       }\n  //     }\n  //   };\n\n  //   for (const entity of entities) {\n  //     if (entity instanceof ConnectableEntity) {\n  //       dfs(entity);\n  //     }\n  //   }\n\n  //   // 最后再排除最开始传入的实体\n  //   for (const entity of entities) {\n  //     result.delete(entity.uuid);\n  //   }\n  //   return project.stageManager.getEntitiesByUUIDs(Array.from(result));\n  // }\n\n  /**\n   * 此函数最终由快捷键调用\n   */\n  export function selectEntityByPenStroke(project: Project) {\n    const selectedEntities = project.stageManager.getSelectedEntities();\n    for (const entity of selectedEntities) {\n      if (entity instanceof ConnectableEntity) {\n        const penStrokes = getTouchingPenStrokes(project, entity);\n        for (const penStroke of penStrokes) {\n          penStroke.isSelected = true;\n        }\n      } else if (entity instanceof PenStroke) {\n        const connectableEntities = getConnectableEntitiesByPenStroke(project, entity);\n        for (const connectableEntity of connectableEntities) {\n          connectableEntity.isSelected = true;\n        }\n      }\n    }\n  }\n\n  /**\n   * 获取一个可连接实体触碰到的所有涂鸦\n   * @param project\n   * @param entity\n   * @returns\n   */\n  function getTouchingPenStrokes(project: Project, entity: ConnectableEntity): PenStroke[] {\n    const touchingPenStrokes: PenStroke[] = [];\n    for (const penStroke of project.stageManager.getPenStrokes()) {\n      if (isConnectableEntityCollideWithPenStroke(entity, penStroke)) {\n        touchingPenStrokes.push(penStroke);\n      }\n    }\n    return touchingPenStrokes;\n  }\n\n  /**\n   * 获取一个涂鸦触碰到的所有实体\n   * @param project\n   * @param penStroke\n   */\n  function getConnectableEntitiesByPenStroke(project: Project, penStroke: PenStroke): ConnectableEntity[] {\n    const touchingEntities: ConnectableEntity[] = [];\n    for (const entity of project.stageManager.getConnectableEntity()) {\n      if (isConnectableEntityCollideWithPenStroke(entity, penStroke)) {\n        touchingEntities.push(entity);\n      }\n    }\n    return touchingEntities;\n  }\n\n  function isConnectableEntityCollideWithPenStroke(entity: ConnectableEntity, penStroke: PenStroke): boolean {\n    return penStroke.collisionBox.isIntersectsWithRectangle(entity.collisionBox.getRectangle());\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageManager/basicMethods/SectionMethods.tsx",
    "content": "import { Vector } from \"@graphif/data-structures\";\nimport { Project, service } from \"@/core/Project\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { Edge } from \"@/core/stage/stageObject/association/Edge\";\nimport { MultiTargetUndirectedEdge } from \"@/core/stage/stageObject/association/MutiTargetUndirectedEdge\";\nimport { StageObject } from \"@/core/stage/stageObject/abstract/StageObject\";\n\n@service(\"sectionMethods\")\nexport class SectionMethods {\n  constructor(protected readonly project: Project) {}\n\n  /**\n   * 获取一个实体的它自己的父亲Sections、是第一层所有父亲Sections\n   * 注：需要遍历所有Section\n   * @param entity\n   */\n  getFatherSections(entity: Entity): Section[] {\n    const result = [];\n    for (const section of this.project.stageManager.getSections()) {\n      if (section.children.includes(entity)) {\n        result.push(section);\n      }\n    }\n    return result;\n  }\n\n  /**\n   * 检查舞台对象是否在锁定的Section内\n   * 对于实体：检查它的所有父Section是否有锁定的\n   * 对于连线：检查它连接的所有实体是否在锁定的Section内\n   * @param object 舞台对象（实体或连线）\n   * @returns 如果对象连接了锁定的Section内物体，返回true\n   */\n  isObjectBeLockedBySection(object: StageObject): boolean {\n    if (object instanceof Entity) {\n      // 检查实体本身是否是锁定的Section\n      if (object instanceof Section && object.locked) {\n        return true;\n      }\n      // 检查实体是否在任何锁定的祖先Section内\n      const ancestorSections = this.getFatherSectionsList(object);\n      return ancestorSections.some((section) => section.locked);\n    } else if (object instanceof Edge) {\n      // 对于有向边，检查source和target是否在锁定的Section内\n      const sourceAncestorSections = this.getFatherSectionsList(object.source);\n      const targetAncestorSections = this.getFatherSectionsList(object.target);\n      return (\n        sourceAncestorSections.some((section) => section.locked) ||\n        targetAncestorSections.some((section) => section.locked)\n      );\n    } else if (object instanceof MultiTargetUndirectedEdge) {\n      // 对于无向边，检查所有关联实体是否在锁定的Section内\n      for (const entity of object.associationList) {\n        const ancestorSections = this.getFatherSectionsList(entity);\n        if (ancestorSections.some((section) => section.locked)) {\n          return true;\n        }\n      }\n      return false;\n    }\n    // 其他类型的舞台对象（如未知类型）默认返回false\n    return false;\n  }\n\n  /**\n   * 获取一个实体被他包围的全部实体，一层一层的包含并以数组返回\n   * A{B{C{entity}}}\n   * 会返回 [C, B, A]\n   * @param entity\n   */\n  getFatherSectionsList(entity: Entity): Section[] {\n    const result = [];\n    for (const section of this.project.stageManager.getSections()) {\n      if (this.isEntityInSection(entity, section)) {\n        result.push(section);\n      }\n    }\n    return this.getSortedSectionsByZ(result).reverse();\n  }\n\n  /**\n   * 根据一个位置，获取包含这个位置的所有Section（深Section优先）\n   * 例如在十字位置上，获取到的结果是 [B]\n   *               │\n   *     ┌─────────┼────────────────────────┐\n   *     │A        │                        │\n   *     │  ┌──────┼──────┐   ┌───────┐     │\n   *     │  │B     │      │   │C      │     │\n   *─────┼──┼──────┼──────┼───┼───────┼─────┼─────\n   *     │  │      │      │   │       │     │\n   *     │  └──────┼──────┘   └───────┘     │\n   *     │         │                        │\n   *     └─────────┼────────────────────────┘\n   *               │\n   * @returns\n   */\n  getSectionsByInnerLocation(location: Vector): Section[] {\n    const sections: Section[] = [];\n    for (const section of this.project.stageManager.getSections()) {\n      if (section.isCollapsed || section.isHiddenBySectionCollapse) {\n        continue;\n      }\n      if (section.collisionBox.getRectangle().isPointIn(location)) {\n        sections.push(section);\n      }\n    }\n    return this.deeperSections(sections);\n  }\n\n  /**\n   * 用于去除重叠集合，当有完全包含的集合时，返回最小的集合\n   * @param sections\n   */\n  private deeperSections(sections: Section[]): Section[] {\n    const outerSections: Section[] = []; // 要被排除的Section\n\n    for (const sectionI of sections) {\n      for (const sectionJ of sections) {\n        if (sectionI === sectionJ) {\n          continue;\n        }\n        if (this.isEntityInSection(sectionI, sectionJ) && !this.isEntityInSection(sectionJ, sectionI)) {\n          // I 在 J 中，J不在I中，J大，排除J\n          outerSections.push(sectionJ);\n        }\n      }\n    }\n    const result: Section[] = [];\n    for (const section of sections) {\n      if (!outerSections.includes(section)) {\n        result.push(section);\n      }\n    }\n    return result;\n  }\n\n  /**\n   * 通过多个Section，获取最外层的Section（即没有父亲的Section）\n   * @param sections\n   * @returns\n   */\n  shallowerSection(sections: Section[]): Section[] {\n    const rootSections: Section[] = [];\n    const sectionMap = new Map<string, Section>();\n    // 首先将所有section放入map，方便快速查找\n    for (const section of sections) {\n      sectionMap.set(section.uuid, section);\n    }\n    // 遍历所有section，检查是否有父亲节点\n    for (const section of sections) {\n      for (const child of section.children) {\n        sectionMap.delete(child.uuid);\n      }\n    }\n    for (const section of sectionMap.keys()) {\n      const result = sectionMap.get(section);\n      if (result) {\n        rootSections.push(result);\n      }\n    }\n\n    return rootSections;\n  }\n\n  shallowerNotSectionEntities(entities: Entity[]): Entity[] {\n    // shallowerSection + 所有非Section的实体\n    const sections = entities.filter((entity) => entity instanceof Section);\n    const nonSections = entities.filter((entity) => !(entity instanceof Section));\n    // 遍历所有非section实体，如果是任何一个section的子节点，则删除\n    const result: Entity[] = [];\n    for (const entity of nonSections) {\n      let isAnyChild = false;\n      for (const section of sections) {\n        if (this.isEntityInSection(entity, section)) {\n          isAnyChild = true;\n        }\n      }\n      if (!isAnyChild) {\n        result.push(entity);\n      }\n    }\n    result.push(...sections);\n    return result;\n  }\n\n  /**\n   * 检测某个实体是否在某个集合内，跨级也算\n   * @param entity\n   * @param section\n   */\n  isEntityInSection(entity: Entity, section: Section): boolean {\n    return this._isEntityInSection(entity, section, 0);\n  }\n\n  /**\n   * 检测某个实体的几何区域是否在某个集合内，仅计算碰撞，不看引用，所以是个假的\n   * 性能比较高\n   * @param entity\n   * @param section\n   */\n  private isEntityInSection_fake(entity: Entity, section: Section): boolean {\n    const entityBox = entity.collisionBox.getRectangle();\n    const sectionBox = section.collisionBox.getRectangle();\n    return entityBox.isCollideWithRectangle(sectionBox);\n  }\n\n  private _isEntityInSection(entity: Entity, section: Section, deep = 0): boolean {\n    if (deep > 996) {\n      return false;\n    }\n    // 直接先检测一级\n    if (section.children.includes(entity)) {\n      return true;\n    } else {\n      // 涉及跨级检测\n      for (const child of section.children) {\n        if (child instanceof Section) {\n          if (this._isEntityInSection(entity, child, deep + 1)) {\n            return true;\n          }\n        }\n      }\n      return false;\n    }\n  }\n\n  /**\n   * 检测一个Section内部是否符合树形嵌套结构\n   * @param rootNode\n   */\n  isTreePack(rootNode: Section) {\n    const dfs = (node: Entity, visited: Entity[]): boolean => {\n      if (visited.includes(node)) {\n        return false;\n      }\n      visited.push(node);\n      if (node instanceof Section) {\n        for (const child of node.children) {\n          if (!dfs(child, visited)) {\n            return false;\n          }\n        }\n      }\n      return true;\n    };\n    return dfs(rootNode, []);\n  }\n\n  /**\n   * 返回一个Section框的最大嵌套深度\n   * @param section\n   */\n  getSectionMaxDeep(section: Section): number {\n    const visited: Section[] = [];\n    const dfs = (node: Section, deep = 1): number => {\n      if (visited.includes(node)) {\n        return deep;\n      }\n      visited.push(node);\n      for (const child of node.children) {\n        if (child instanceof Section) {\n          deep = Math.max(deep, dfs(child, deep + 1));\n        }\n      }\n      return deep;\n    };\n    return dfs(section);\n  }\n\n  /**\n   * 用途：\n   * 根据选中的多个Section，获取所有选中的实体（包括子实体）\n   * 可以解决复制多个Section时，内部实体的连线问题\n   * @param selectedEntities\n   */\n  getAllEntitiesInSelectedSectionsOrEntities(selectedEntities: Entity[]): Entity[] {\n    const entityUUIDSet = new Set<string>();\n    const dfs = (currentEntity: Entity) => {\n      if (currentEntity.uuid in entityUUIDSet) {\n        return;\n      }\n      if (currentEntity instanceof Section) {\n        for (const child of currentEntity.children) {\n          dfs(child);\n        }\n      }\n      entityUUIDSet.add(currentEntity.uuid);\n    };\n    for (const entity of selectedEntities) {\n      dfs(entity);\n    }\n    return this.project.stageManager.getEntitiesByUUIDs(Array.from(entityUUIDSet));\n  }\n\n  getSortedSectionsByZ(sections: Section[]): Section[] {\n    // 先按y排序，从上到下，先不管z\n    return sections.sort((a, b) => a.collisionBox.getRectangle().top - b.collisionBox.getRectangle().top);\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageManager/concreteMethods/LayoutManager.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { Section } from \"../../stageObject/entity/Section\";\n\n@service(\"layoutManager\")\nexport class LayoutManager {\n  constructor(private readonly project: Project) {}\n\n  // 左侧对齐\n  alignLeft() {\n    const nodes = Array.from(this.project.stageManager.getEntities()).filter((node) => node.isSelected);\n    const minX = Math.min(...nodes.map((node) => node.collisionBox.getRectangle().left));\n    for (const node of nodes) {\n      this.project.entityMoveManager.moveEntityUtils(node, new Vector(minX - node.collisionBox.getRectangle().left, 0));\n    }\n    this.project.historyManager.recordStep();\n  }\n\n  // 右侧对齐\n  alignRight() {\n    const nodes = Array.from(this.project.stageManager.getEntities()).filter((node) => node.isSelected);\n    const maxX = Math.max(...nodes.map((node) => node.collisionBox.getRectangle().right));\n    for (const node of nodes) {\n      this.project.entityMoveManager.moveEntityUtils(\n        node,\n        new Vector(maxX - node.collisionBox.getRectangle().right, 0),\n      );\n    }\n    this.project.historyManager.recordStep();\n  }\n\n  // 上侧对齐\n  alignTop() {\n    const nodes = Array.from(this.project.stageManager.getEntities()).filter((node) => node.isSelected);\n    const minY = Math.min(...nodes.map((node) => node.collisionBox.getRectangle().top));\n    for (const node of nodes) {\n      this.project.entityMoveManager.moveEntityUtils(node, new Vector(0, minY - node.collisionBox.getRectangle().top));\n    }\n    this.project.historyManager.recordStep();\n  }\n\n  // 下侧对齐\n  alignBottom() {\n    const nodes = Array.from(this.project.stageManager.getEntities()).filter((node) => node.isSelected);\n    const maxY = Math.max(...nodes.map((node) => node.collisionBox.getRectangle().bottom));\n    for (const node of nodes) {\n      this.project.entityMoveManager.moveEntityUtils(\n        node,\n        new Vector(0, maxY - node.collisionBox.getRectangle().bottom),\n      );\n    }\n    this.project.historyManager.recordStep();\n  }\n\n  alignCenterHorizontal() {\n    const nodes = Array.from(this.project.stageManager.getEntities()).filter((node) => node.isSelected);\n    if (nodes.length <= 1) return; // 如果只有一个或没有选中的节点，则不需要重新排列\n\n    // 计算所有选中节点的总高度和最小 y 坐标\n    const minY = Math.min(...nodes.map((node) => node.collisionBox.getRectangle().top));\n    const maxY = Math.max(...nodes.map((node) => node.collisionBox.getRectangle().bottom));\n    const totalHeight = maxY - minY;\n    const centerY = minY + totalHeight / 2;\n\n    for (const node of nodes) {\n      const nodeCenterY = node.collisionBox.getRectangle().top + node.collisionBox.getRectangle().size.y / 2;\n      const newY = centerY - (nodeCenterY - node.collisionBox.getRectangle().top);\n      this.project.entityMoveManager.moveEntityToUtils(node, new Vector(node.collisionBox.getRectangle().left, newY));\n    }\n    this.project.historyManager.recordStep();\n  }\n\n  alignCenterVertical() {\n    const nodes = Array.from(this.project.stageManager.getEntities()).filter((node) => node.isSelected);\n    if (nodes.length <= 1) return; // 如果只有一个或没有选中的节点，则不需要重新排列\n\n    // 计算所有选中节点的总宽度和最小 x 坐标\n    const minX = Math.min(...nodes.map((node) => node.collisionBox.getRectangle().left));\n    const maxX = Math.max(...nodes.map((node) => node.collisionBox.getRectangle().right));\n    const totalWidth = maxX - minX;\n    const centerX = minX + totalWidth / 2;\n\n    for (const node of nodes) {\n      const nodeCenterX = node.collisionBox.getRectangle().left + node.collisionBox.getRectangle().size.x / 2;\n      const newX = centerX - (nodeCenterX - node.collisionBox.getRectangle().left);\n      this.project.entityMoveManager.moveEntityToUtils(node, new Vector(newX, node.collisionBox.getRectangle().top));\n    }\n    this.project.historyManager.recordStep();\n  }\n\n  // 相等间距水平分布对齐\n  alignHorizontalSpaceBetween() {\n    const nodes = Array.from(this.project.stageManager.getEntities()).filter((node) => node.isSelected);\n    if (nodes.length <= 1) return; // 如果只有一个或没有选中的节点，则不需要重新排列\n\n    const minX = Math.min(...nodes.map((node) => node.collisionBox.getRectangle().left));\n    const maxX = Math.max(...nodes.map((node) => node.collisionBox.getRectangle().right));\n    const totalWidth = maxX - minX;\n    const totalNodesWidth = nodes.reduce((sum, node) => sum + node.collisionBox.getRectangle().size.x, 0);\n    const availableSpace = totalWidth - totalNodesWidth;\n    const spaceBetween = nodes.length > 1 ? availableSpace / (nodes.length - 1) : 0;\n\n    let startX = minX;\n    for (const node of nodes.sort((a, b) => a.collisionBox.getRectangle().left - b.collisionBox.getRectangle().left)) {\n      this.project.entityMoveManager.moveEntityToUtils(node, new Vector(startX, node.collisionBox.getRectangle().top));\n      startX += node.collisionBox.getRectangle().size.x + spaceBetween;\n    }\n    this.project.historyManager.recordStep();\n  }\n\n  // 相等间距垂直分布对齐\n  alignVerticalSpaceBetween() {\n    const nodes = Array.from(this.project.stageManager.getEntities()).filter((node) => node.isSelected);\n    if (nodes.length <= 1) return; // 如果只有一个或没有选中的节点，则不需要重新排列\n\n    const minY = Math.min(...nodes.map((node) => node.collisionBox.getRectangle().top));\n    const maxY = Math.max(...nodes.map((node) => node.collisionBox.getRectangle().bottom));\n    const totalHeight = maxY - minY;\n    const totalNodesHeight = nodes.reduce((sum, node) => sum + node.collisionBox.getRectangle().size.y, 0);\n    const availableSpace = totalHeight - totalNodesHeight;\n    const spaceBetween = nodes.length > 1 ? availableSpace / (nodes.length - 1) : 0;\n\n    let startY = minY;\n    for (const node of nodes.sort((a, b) => a.collisionBox.getRectangle().top - b.collisionBox.getRectangle().top)) {\n      this.project.entityMoveManager.moveEntityToUtils(node, new Vector(node.collisionBox.getRectangle().left, startY));\n      startY += node.collisionBox.getRectangle().size.y + spaceBetween;\n    }\n    this.project.historyManager.recordStep();\n  }\n\n  /**\n   * 从左到右紧密排列\n   */\n  alignLeftToRightNoSpace() {\n    let nodes = Array.from(this.project.stageManager.getEntities()).filter((node) => node.isSelected);\n    if (nodes.length <= 1) return; // 如果只有一个或没有选中的节点，则不需要重新排列\n    nodes = nodes.sort((a, b) => a.collisionBox.getRectangle().left - b.collisionBox.getRectangle().left);\n\n    let leftBoundX = nodes[0].collisionBox.getRectangle().right;\n    for (let i = 1; i < nodes.length; i++) {\n      const currentNode = nodes[i];\n      this.project.entityMoveManager.moveEntityToUtils(\n        currentNode,\n        new Vector(leftBoundX, currentNode.collisionBox.getRectangle().top),\n      );\n      leftBoundX = currentNode.collisionBox.getRectangle().right;\n    }\n  }\n  /**\n   * 从上到下密排列\n   */\n  alignTopToBottomNoSpace() {\n    let nodes = Array.from(this.project.stageManager.getEntities()).filter((node) => node.isSelected);\n    if (nodes.length <= 1) return; // 如果只有一个或没有选中的节点，则不需要重新排列\n    nodes = nodes.sort((a, b) => a.collisionBox.getRectangle().top - b.collisionBox.getRectangle().top);\n\n    let topBoundY = nodes[0].collisionBox.getRectangle().bottom;\n    for (let i = 1; i < nodes.length; i++) {\n      const currentNode = nodes[i];\n      this.project.entityMoveManager.moveEntityToUtils(\n        currentNode,\n        new Vector(currentNode.collisionBox.getRectangle().left, topBoundY),\n      );\n      topBoundY = currentNode.collisionBox.getRectangle().bottom;\n    }\n  }\n  layoutBySelected(layoutFunction: (entities: Entity[]) => void, isDeep: boolean) {\n    const entities = Array.from(this.project.stageManager.getEntities()).filter((node) => node.isSelected);\n    if (isDeep) {\n      // 递归紧密堆积时，临时禁用 Section 碰撞，防止堆积过程中产生碰撞推挤影响最终效果\n      const prevSectionCollision = Settings.isEnableSectionCollision;\n      Settings.isEnableSectionCollision = false;\n      try {\n        // 递归\n        const dfs = (entityList: Entity[]) => {\n          // 检查每一个实体\n          for (const entity of entityList) {\n            // 如果当前这个实体是 Section，就进入到Section内部\n            if (entity instanceof Section) {\n              const childEntity = entity.children;\n              dfs(childEntity);\n            }\n          }\n          layoutFunction(entityList);\n        };\n        dfs(entities);\n      } finally {\n        // 恢复用户原有的 Section 碰撞设置\n        Settings.isEnableSectionCollision = prevSectionCollision;\n      }\n    } else {\n      layoutFunction(entities);\n    }\n    this.project.historyManager.recordStep();\n  }\n  adjustSelectedTextNodeWidth(mode: \"maxWidth\" | \"minWidth\" | \"average\") {\n    const selectedTextNode = this.project.stageManager\n      .getSelectedEntities()\n      .filter((entity) => entity instanceof TextNode);\n    const maxWidth = selectedTextNode.reduce((acc, cur) => Math.max(acc, cur.collisionBox.getRectangle().width), 0);\n    const minWidth = selectedTextNode.reduce(\n      (acc, cur) => Math.min(acc, cur.collisionBox.getRectangle().width),\n      Infinity,\n    );\n    const average =\n      selectedTextNode.reduce((acc, cur) => acc + cur.collisionBox.getRectangle().width, 0) / selectedTextNode.length;\n\n    for (const textNode of selectedTextNode) {\n      textNode.sizeAdjust = \"manual\";\n      switch (mode) {\n        case \"maxWidth\":\n          textNode.resizeWidthTo(maxWidth);\n          break;\n        case \"minWidth\":\n          textNode.resizeWidthTo(minWidth);\n          break;\n        case \"average\":\n          textNode.resizeWidthTo(average);\n          break;\n      }\n    }\n  }\n  layoutToSquare(entities: Entity[]) {\n    const n = entities.length;\n    if (n <= 1) return;\n\n    // 计算所有节点的最大宽度和高度\n    let maxWidth = 0,\n      maxHeight = 0;\n    entities.forEach((node) => {\n      const rect = node.collisionBox.getRectangle();\n      maxWidth = Math.max(maxWidth, rect.size.x);\n      maxHeight = Math.max(maxHeight, rect.size.y);\n    });\n\n    const spacing = 20; // 单元格之间的间距\n    const cellSize = Math.max(maxWidth, maxHeight) + spacing;\n\n    // 计算最优的行列数，使网格尽可能接近正方形\n    const { rows, cols } = getOptimalRowsCols(n);\n\n    // 计算网格的总尺寸\n    const gridWidth = cols * cellSize;\n    const gridHeight = rows * cellSize;\n\n    // 计算原始包围盒的中心点\n    let minX = Infinity,\n      minY = Infinity,\n      maxX = -Infinity,\n      maxY = -Infinity;\n    entities.forEach((node) => {\n      const rect = node.collisionBox.getRectangle();\n      minX = Math.min(minX, rect.left);\n      minY = Math.min(minY, rect.top);\n      maxX = Math.max(maxX, rect.right);\n      maxY = Math.max(maxY, rect.bottom);\n    });\n    const centerX = (minX + maxX) / 2;\n    const centerY = (minY + maxY) / 2;\n\n    // 计算网格的起始位置（左上角）\n    const startX = centerX - gridWidth / 2;\n    const startY = centerY - gridHeight / 2;\n\n    // 将节点排列到网格中\n    entities.forEach((node, index) => {\n      const row = Math.floor(index / cols);\n      const col = index % cols;\n      const cellCenterX = startX + col * cellSize + cellSize / 2;\n      const cellCenterY = startY + row * cellSize + cellSize / 2;\n      const rect = node.collisionBox.getRectangle();\n      const newX = cellCenterX - rect.size.x / 2;\n      const newY = cellCenterY - rect.size.y / 2;\n      this.project.entityMoveManager.moveEntityToUtils(node, new Vector(newX, newY));\n    });\n  }\n  layoutToTightSquare(entities: Entity[]) {\n    if (entities.length === 0) return;\n    const layoutItems = entities.map((entity) => ({\n      entity,\n      rect: entity.collisionBox.getRectangle().clone(),\n    }));\n    // 记录调整前的全部矩形的外接矩形\n    const boundingRectangleBefore = Rectangle.getBoundingRectangle(layoutItems.map((item) => item.rect));\n\n    const sortedRects = sortRectangleGreedy(\n      layoutItems.map((item) => item.rect),\n      20,\n    );\n\n    for (let i = 0; i < sortedRects.length; i++) {\n      layoutItems[i].entity.moveTo(sortedRects[i].leftTop.clone());\n    }\n\n    // 调整后的全部矩形的外接矩形\n    const boundingRectangleAfter = Rectangle.getBoundingRectangle(sortedRects);\n    // 整体移动，使得全部内容的外接矩形中心坐标保持不变\n    const diff = boundingRectangleBefore.center.subtract(boundingRectangleAfter.center);\n    for (const item of layoutItems) {\n      item.entity.move(diff);\n    }\n  }\n}\n// 辅助函数：计算最优的行列数，使网格尽可能接近正方形\nfunction getOptimalRowsCols(n: number): { rows: number; cols: number } {\n  let bestRows = Math.floor(Math.sqrt(n));\n  let bestCols = Math.ceil(n / bestRows);\n  let bestDiff = Math.abs(bestRows - bestCols);\n\n  // 遍历可能的行数，寻找行列差最小的情况\n  for (let rows = bestRows; rows >= 1; rows--) {\n    const cols = Math.ceil(n / rows);\n    const diff = Math.abs(rows - cols);\n    if (diff < bestDiff) {\n      bestDiff = diff;\n      bestRows = rows;\n      bestCols = cols;\n    }\n  }\n\n  return { rows: bestRows, cols: bestCols };\n} /**\n *\n * 装箱问题，排序矩形\n    :param rectangles: N个矩形的大小和位置\n    :param margin: 矩形之间的间隔（为了美观考虑）\n    :return: 调整好后的N个矩形的大小和位置，数组内每个矩形一一对应。\n    例如：\n    rectangles = [Rectangle(NumberVector(0, 0), 10, 10), Rectangle(NumberVector(10, 10), 1, 1)]\n    这两个矩形对角放，外套矩形空隙面积过大，空间浪费，需要调整位置。\n\n    调整后返回：\n\n    [Rectangle(NumberVector(0, 0), 10, 10), Rectangle(NumberVector(12, 0), 1, 1)]\n    参数 margin = 2\n    横向放置，减少了空间浪费。\n *\n *\n *\n *\n */\n\n// 从visual-file项目里抄过来的\nfunction sortRectangleGreedy(rectangles: Rectangle[], margin = 20): Rectangle[] {\n  if (rectangles.length <= 6) return arrangeRectangleInCompactByBranch(rectangles, margin);\n  function appendRight(origin: Rectangle, originalRect: Rectangle, existingRects: Rectangle[], margin = 20): Rectangle {\n    const candidate = new Rectangle(\n      new Vector(origin.right + margin, origin.location.y),\n      new Vector(originalRect.size.x, originalRect.size.y),\n    );\n\n    let hasCollision: boolean;\n    do {\n      hasCollision = false;\n      for (const existing of existingRects) {\n        if (candidate.isCollideWithRectangle(existing)) {\n          hasCollision = true;\n          // 调整位置：下移到底部并保持右侧对齐\n          candidate.location.y = existing.bottom;\n          candidate.location.x = Math.max(candidate.location.x, existing.right);\n          break;\n        }\n      }\n    } while (hasCollision);\n\n    return candidate;\n  }\n\n  function appendBottom(\n    origin: Rectangle,\n    originalRect: Rectangle,\n    existingRects: Rectangle[],\n    margin = 20,\n  ): Rectangle {\n    const candidate = new Rectangle(\n      new Vector(origin.location.x, origin.bottom + margin),\n      new Vector(originalRect.size.x, originalRect.size.y),\n    );\n\n    let hasCollision: boolean;\n    do {\n      hasCollision = false;\n      for (const existing of existingRects) {\n        if (candidate.isCollideWithRectangle(existing)) {\n          hasCollision = true;\n          // 调整位置：右移并保持底部对齐\n          candidate.location.x = existing.right;\n          candidate.location.y = Math.max(candidate.location.y, existing.bottom);\n          break;\n        }\n      }\n    } while (hasCollision);\n\n    return candidate;\n  }\n\n  if (rectangles.length === 0) return [];\n\n  // 处理第一个矩形\n  const firstOriginal = rectangles[0];\n  const first = new Rectangle(new Vector(0, 0), new Vector(firstOriginal.size.x, firstOriginal.size.y));\n  const ret: Rectangle[] = [first];\n  let currentWidth = first.right;\n  let currentHeight = first.bottom;\n\n  for (let i = 1; i < rectangles.length; i++) {\n    const originalRect = rectangles[i];\n    let bestCandidate: Rectangle | null = null;\n    let minSpaceScore = Infinity;\n    let minShapeScore = Infinity;\n\n    for (const placedRect of ret) {\n      // 尝试放在右侧\n      const candidateRight = appendRight(placedRect, originalRect, ret, margin);\n      const rightSpaceScore =\n        Math.max(currentWidth, candidateRight.right) -\n        currentWidth +\n        (Math.max(currentHeight, candidateRight.bottom) - currentHeight);\n      const rightShapeScore = Math.abs(\n        Math.max(candidateRight.right, currentWidth) - Math.max(candidateRight.bottom, currentHeight),\n      );\n\n      if (rightSpaceScore < minSpaceScore || (rightSpaceScore === minSpaceScore && rightShapeScore < minShapeScore)) {\n        minSpaceScore = rightSpaceScore;\n        minShapeScore = rightShapeScore;\n        bestCandidate = candidateRight;\n      }\n\n      // 尝试放在下方\n      const candidateBottom = appendBottom(placedRect, originalRect, ret, margin);\n      const bottomSpaceScore =\n        Math.max(currentWidth, candidateBottom.right) -\n        currentWidth +\n        (Math.max(currentHeight, candidateBottom.bottom) - currentHeight);\n      const bottomShapeScore = Math.abs(\n        Math.max(candidateBottom.right, currentWidth) - Math.max(candidateBottom.bottom, currentHeight),\n      );\n\n      if (\n        bottomSpaceScore < minSpaceScore ||\n        (bottomSpaceScore === minSpaceScore && bottomShapeScore < minShapeScore)\n      ) {\n        minSpaceScore = bottomSpaceScore;\n        minShapeScore = bottomShapeScore;\n        bestCandidate = candidateBottom;\n      }\n    }\n\n    if (bestCandidate) {\n      ret.push(bestCandidate);\n      currentWidth = Math.max(currentWidth, bestCandidate.right);\n      currentHeight = Math.max(currentHeight, bestCandidate.bottom);\n    } else {\n      throw new Error(\"No candidate found\");\n    }\n  }\n\n  return ret;\n}\n\n// function arrangeRectangleInCompactByDivide(rectangles: Rectangle[], margin = 20): Rectangle[] {\n//   if (rectangles.length <= 6) return arrangeRectangleInCompactByBranch(rectangles, margin);\n//   // 保存原始矩形的索引，以便后续恢复顺序\n//   const indexedRectangles = rectangles.map((rect, index) => ({ rect, originalIndex: index }));\n//   // 按面积排序\n//   indexedRectangles.sort((a, b) => a.rect.size.x * a.rect.size.y - b.rect.size.x * b.rect.size.y);\n//   // 提取排序后的矩形进行布局\n//   const sortedRects = indexedRectangles.map((item) => item.rect);\n//   const arrangedRects = arrangeRectangleInCompactByDivideHelper(sortedRects, margin);\n//   // 创建一个映射，将原始索引映射到排列后的矩形\n//   const indexToRectMap = new Map<number, Rectangle>();\n//   indexedRectangles.forEach((item, index) => {\n//     indexToRectMap.set(item.originalIndex, arrangedRects[index]);\n//   });\n//   // 按照原始顺序重新排列矩形\n//   const result: Rectangle[] = [];\n//   for (let i = 0; i < rectangles.length; i++) {\n//     result.push(indexToRectMap.get(i)!);\n//   }\n//   return result;\n\n//   function arrangeRectangleInCompactByDivideHelper(rectangles: Rectangle[], margin = 20): Rectangle[] {\n//     const n = rectangles.length;\n//     if (n < 4) {\n//       const ret: Rectangle[] = [\n//         new Rectangle(new Vector(0, 0), new Vector(rectangles[0].size.x, rectangles[0].size.y)),\n//       ];\n//       if (n >= 2) {\n//         ret.push(\n//           new Rectangle(\n//             new Vector(ret[0].width + margin, ret[0].height - rectangles[1].height),\n//             new Vector(rectangles[1].size.x, rectangles[1].size.y),\n//           ),\n//         );\n//       }\n//       if (n === 3) {\n//         ret.push(\n//           new Rectangle(\n//             new Vector((ret[0].width + margin + ret[1].width) / 2 - rectangles[2].width / 2, ret[0].height + margin),\n//             new Vector(rectangles[2].size.x, rectangles[2].size.y),\n//           ),\n//         );\n//       }\n//       return ret;\n//     }\n//     const subs: Rectangle[][] = [\n//       arrangeRectangleInCompactByDivide(rectangles.slice(0, n / 4), margin),\n//       arrangeRectangleInCompactByDivide(rectangles.slice(n / 4, n / 2), margin),\n//       arrangeRectangleInCompactByDivide(rectangles.slice(n / 2, (n / 4) * 3), margin),\n//       arrangeRectangleInCompactByDivide(rectangles.slice((n / 4) * 3, n), margin),\n//     ];\n//     const bods = subs.map((sub) => Rectangle.getBoundingRectangle(sub));\n//     for (const r of subs[1]) {\n//       r.location = r.location.add(new Vector(bods[0].width + margin, bods[0].height - bods[1].height));\n//     }\n//     for (const r of subs[2]) {\n//       r.location = r.location.add(new Vector(bods[0].width - bods[2].width, bods[0].height + margin));\n//     }\n//     for (const r of subs[3]) {\n//       r.location = r.location.add(new Vector(bods[0].width + margin, bods[0].height + margin));\n//     }\n//     return subs[0].concat(subs[1], subs[2], subs[3]);\n//   }\n// }\n\n/**\n * 使用分支限界法计算矩形的最优紧凑布局，寻找最小外接正方形\n *\n * @param rectangles 要排列的矩形数组\n * @param margin 矩形之间的间距，默认为20\n * @returns 排列后的矩形数组，保持原始矩形的顺序\n *\n * @remarks\n * 时间复杂度：理论上为 O(n!)，其中n是矩形数量。由于采用了多种剪枝策略，\n * 实际运行时间会显著低于理论上限，但仍随矩形数量呈指数增长。\n *\n * 性能建议：\n * - 对于n ≤ 6个矩形：可以快速计算出最优解\n * - 对于n > 6个矩形：计算时间会明显增加，可能需要较长等待时间\n * - 对于大规模矩形布局问题，建议使用启发式算法替代\n *\n * 算法特点：\n * - 使用优先队列管理搜索状态\n * - 大矩形优先放置策略\n * - 多层剪枝优化搜索空间\n * - 仅返回完整的最优解\n */\nfunction arrangeRectangleInCompactByBranch(rectangles: Rectangle[], margin = 20): Rectangle[] {\n  if (rectangles.length === 0) return [];\n\n  // 定义状态接口，表示放置进度和当前布局情况\n  interface State {\n    placedRectangles: Rectangle[];\n    remainingIndices: number[];\n    // 当前布局的外接矩形信息\n    currentWidth: number;\n    currentHeight: number;\n    // 启发式值，用于分支限界\n    heuristicValue: number;\n  }\n\n  // 按面积从大到小排序矩形，优先放置大矩形\n  const sortedIndices = Array.from({ length: rectangles.length }, (_, i) => i).sort(\n    (a, b) => rectangles[b].size.x * rectangles[b].size.y - rectangles[a].size.x * rectangles[a].size.y,\n  );\n\n  // 计算矩形的总面积，用于启发式估计\n  const totalArea = rectangles.reduce((sum, rect) => sum + rect.size.x * rect.size.y, 0);\n\n  // 计算最小可能的边长（基于总面积的理论下限）\n  const minPossibleSide = Math.ceil(Math.sqrt(totalArea));\n\n  // 用于存储最优解\n  let bestSolution: Rectangle[] = [];\n  let bestSideLength = Infinity; // 初始化为无穷大，确保第一个解会被接受\n\n  // 计算启发式值：更精确地估计剩余矩形放置后的最小可能边长\n  function calculateHeuristic(currentSide: number, placedArea: number): number {\n    // 计算剩余面积\n    const remainingArea = totalArea - placedArea;\n    // 计算剩余矩形的最小可能边长增量并用于启发式计算\n    const minAdditionalSide = Math.ceil(Math.sqrt(remainingArea));\n    // 启发式值：当前边长与基于总面积的理论最小边长的最大值\n    // 考虑剩余面积的影响，提供更准确的估计\n    return Math.max(currentSide, minPossibleSide, currentSide + minAdditionalSide * 0.3); // 使用0.3系数平衡准确性和性能\n  }\n\n  // 初始状态：放置第一个矩形（最大的）\n  const firstRect = new Rectangle(\n    new Vector(0, 0),\n    new Vector(rectangles[sortedIndices[0]].size.x, rectangles[sortedIndices[0]].size.y),\n  );\n\n  const firstRectArea = firstRect.size.x * firstRect.size.y;\n  const firstHeuristic = calculateHeuristic(Math.max(firstRect.size.x, firstRect.size.y), firstRectArea);\n\n  // 使用优先队列来管理搜索状态，按照启发式值排序\n  const priorityQueue: State[] = [\n    {\n      placedRectangles: [firstRect],\n      remainingIndices: sortedIndices.slice(1),\n      currentWidth: firstRect.size.x,\n      currentHeight: firstRect.size.y,\n      heuristicValue: firstHeuristic,\n    },\n  ];\n\n  // 检查两个矩形是否重叠（确保间距至少为margin）\n  function checkCollision(newRect: Rectangle, placedRects: Rectangle[], margin: number): boolean {\n    return placedRects.some(\n      (rect) =>\n        !(\n          newRect.right + margin <= rect.left ||\n          newRect.left >= rect.right + margin ||\n          newRect.bottom + margin <= rect.top ||\n          newRect.top >= rect.bottom + margin\n        ),\n    );\n  }\n\n  // 生成可能的放置位置\n  function generatePossiblePositions(rect: Rectangle, placedRects: Rectangle[], margin: number): Vector[] {\n    const positions: Set<string> = new Set(); // 使用Set避免重复位置\n\n    // 如果还没有放置任何矩形，只返回原点位置\n    if (placedRects.length === 0) {\n      return [new Vector(0, 0)];\n    }\n\n    // 基于已放置矩形的边缘生成候选位置，确保间距正好为margin\n    placedRects.forEach((placed) => {\n      // 右侧位置（与左侧矩形间距正好为margin）\n      positions.add(`${placed.right + margin},${placed.top}`);\n      positions.add(`${placed.right + margin},${placed.bottom - rect.size.y}`);\n      positions.add(`${placed.right + margin},${placed.top + (placed.size.y - rect.size.y) / 2}`);\n\n      // 底部位置（与上方矩形间距正好为margin）\n      positions.add(`${placed.left},${placed.bottom + margin}`);\n      positions.add(`${placed.right - rect.size.x},${placed.bottom + margin}`);\n      positions.add(`${placed.left + (placed.size.x - rect.size.x) / 2},${placed.bottom + margin}`);\n\n      // 左侧位置（与右侧矩形间距正好为margin）\n      positions.add(`${placed.left - rect.size.x - margin},${placed.top}`);\n      positions.add(`${placed.left - rect.size.x - margin},${placed.bottom - rect.size.y}`);\n      positions.add(`${placed.left - rect.size.x - margin},${placed.top + (placed.size.y - rect.size.y) / 2}`);\n\n      // 顶部位置（与下方矩形间距正好为margin）\n      positions.add(`${placed.left},${placed.top - rect.size.y - margin}`);\n      positions.add(`${placed.right - rect.size.x},${placed.top - rect.size.y - margin}`);\n      positions.add(`${placed.left + (placed.size.x - rect.size.x) / 2},${placed.top - rect.size.y - margin}`);\n    });\n\n    // 将Set中的字符串位置转换回Vector对象并过滤负坐标\n    return (\n      Array.from(positions)\n        .map((posStr) => {\n          const [x, y] = posStr.split(\",\").map(Number);\n          return new Vector(x, y);\n        })\n        .filter((pos) => pos.x >= 0 && pos.y >= 0)\n        // 按位置的紧凑程度排序：优先选择靠近原点的位置\n        .sort((a, b) => {\n          const distanceA = a.x + a.y;\n          const distanceB = b.x + b.y;\n          return distanceA - distanceB;\n        })\n    );\n  }\n\n  // 分支限界搜索：专注于寻找最优解\n  while (priorityQueue.length > 0) {\n    // 取出启发式值最小的状态（优先队列排序后取最后一个）\n    priorityQueue.sort((a, b) => a.heuristicValue - b.heuristicValue); // 升序排序\n    const state = priorityQueue.pop()!;\n\n    // 剪枝：如果当前状态的启发式值已经大于等于最佳解的边长，则跳过\n    if (state.heuristicValue >= bestSideLength) {\n      continue;\n    }\n\n    // 如果没有剩余矩形，这是一个完整解，检查是否是最优解\n    if (state.remainingIndices.length === 0) {\n      const currentSideLength = Math.max(state.currentWidth, state.currentHeight);\n      // 只接受完整解，并且只有当它比当前最优解更好时才更新\n      if (currentSideLength < bestSideLength) {\n        bestSideLength = currentSideLength;\n        bestSolution = state.placedRectangles;\n      }\n      continue;\n    }\n\n    // 取出下一个要放置的矩形索引\n    const nextIndex = state.remainingIndices[0];\n    const remainingIndices = state.remainingIndices.slice(1);\n    const nextRect = rectangles[nextIndex];\n\n    // 计算已放置矩形的总面积\n    const placedArea = state.placedRectangles.reduce((sum, rect) => sum + rect.size.x * rect.size.y, 0);\n\n    // 生成可能的放置位置\n    const possiblePositions = generatePossiblePositions(nextRect, state.placedRectangles, margin);\n\n    // 尝试每个可能的位置\n    for (const position of possiblePositions) {\n      const newRect = new Rectangle(position, new Vector(nextRect.size.x, nextRect.size.y));\n\n      // 检查是否与已放置的矩形冲突\n      if (!checkCollision(newRect, state.placedRectangles, margin)) {\n        const newPlacedRectangles = [...state.placedRectangles, newRect];\n        const newWidth = Math.max(state.currentWidth, newRect.right);\n        const newHeight = Math.max(state.currentHeight, newRect.bottom);\n        const newSideLength = Math.max(newWidth, newHeight);\n\n        // 剪枝：如果新的边长已经大于等于当前最优解的边长，则跳过\n        if (newSideLength >= bestSideLength) {\n          continue;\n        }\n\n        // 计算新状态的启发式值\n        const newPlacedArea = placedArea + newRect.size.x * newRect.size.y;\n        const heuristicValue = calculateHeuristic(newSideLength, newPlacedArea);\n\n        // 剪枝：如果启发式值已经大于等于当前最优解的边长，则跳过\n        if (heuristicValue >= bestSideLength) {\n          continue;\n        }\n\n        // 将新状态加入优先队列\n        priorityQueue.push({\n          placedRectangles: newPlacedRectangles,\n          remainingIndices,\n          currentWidth: newWidth,\n          currentHeight: newHeight,\n          heuristicValue,\n        });\n      }\n    }\n  }\n\n  // 确保只返回完整的最优解\n  if (bestSolution.length !== rectangles.length) {\n    // 如果没有找到完整解（理论上不应该发生，除非搜索空间被完全剪枝），返回按原始顺序的矩形\n    return rectangles.map((rect) => rect.clone());\n  }\n\n  // 按照原始顺序返回矩形\n  const result: Rectangle[] = [];\n  for (let i = 0; i < rectangles.length; i++) {\n    // 找到原始索引对应的矩形\n    const originalIndex = sortedIndices.findIndex((sortedIdx) => sortedIdx === i);\n    if (originalIndex >= 0 && originalIndex < bestSolution.length) {\n      result[i] = bestSolution[originalIndex];\n    } else {\n      // 如果找不到对应关系，使用原始矩形\n      result[i] = rectangles[i].clone();\n    }\n  }\n\n  return result;\n}\n"
  },
  {
    "path": "app/src/core/stage/stageManager/concreteMethods/README.md",
    "content": "这里存放所有操作具体的功能\n\n这里所有的单例都对舞台上的内容做出了具体的修改。\n"
  },
  {
    "path": "app/src/core/stage/stageManager/concreteMethods/SectionCollisionSolver.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Line, Rectangle } from \"@graphif/shapes\";\n\n/**\n * Section 碰撞管理器：负责检测同级 Section 之间的碰撞，并将重叠的同级分支递归地推离。\n *\n * 集成点：在 updateFatherSectionByMove 的每次 adjustLocationAndSize() 调用后，\n * 调用 solveOverlaps(section) 来消除新产生的重叠。\n * 可通过设置 isEnableSectionCollision 全局开关控制是否启用。\n */\n@service(\"sectionCollisionSolver\")\nexport class SectionCollisionSolver {\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 当 grownSection 刚刚通过 adjustLocationAndSize() 增大后，\n   * 检测其与同级 Section 的重叠，并将重叠的同级分支推离。\n   * 递归地向上传播，确保每一层级的同级冲突都被解决。\n   *\n   * @param grownSection 刚刚增大或移动的 Section\n   * @param visited      本次求解链中已处理过的 Section uuid（防止循环）\n   */\n  solveOverlaps(grownSection: Section, visited: Set<string> = new Set()): void {\n    if (!Settings.isEnableSectionCollision) return;\n    if (visited.has(grownSection.uuid)) return;\n    visited.add(grownSection.uuid);\n\n    const siblings = this.getSiblingsSections(grownSection);\n    const grownRect = grownSection.collisionBox.getRectangle();\n\n    for (const sibling of siblings) {\n      // 跳过被锁定的同级 Section（无法安全地原地修改其位置数据）\n      if (sibling.locked) continue;\n      if (visited.has(sibling.uuid)) continue;\n\n      const siblingRect = sibling.collisionBox.getRectangle();\n      if (!grownRect.isCollideWith(siblingRect)) continue;\n\n      const delta = this.computePushDelta(grownRect, siblingRect);\n      if (delta.x === 0 && delta.y === 0) continue;\n\n      // 直接平移 sibling 整棵子树，不触发父级更新或碰撞事件链\n      this.rawShiftEntityTree(sibling, delta);\n\n      // 向上更新包围盒，并在每一层继续检测新的同级碰撞\n      this.updateAncestorsAfterShift(sibling, visited);\n    }\n  }\n\n  // ─────────────────────────────────────────────\n  // 私有辅助方法\n  // ─────────────────────────────────────────────\n\n  /**\n   * 在 sibling 被推移后，沿父级链向上依次 adjustLocationAndSize，\n   * 并对每个扩大的父框再次检测同级碰撞（递归向上传播）。\n   */\n  private updateAncestorsAfterShift(entity: Entity, visited: Set<string>): void {\n    const fathers = this.project.sectionMethods.getFatherSections(entity);\n    for (const father of fathers) {\n      father.adjustLocationAndSize();\n      // 父框扩大后可能与其自身的同级 Section 碰撞，继续向上求解\n      this.solveOverlaps(father, visited);\n      this.updateAncestorsAfterShift(father, visited);\n    }\n  }\n\n  /**\n   * 获取与给定 Section 共享直接父框的所有同级 Section。\n   * 若 section 处于根层级（无父框），则返回所有其他根层级 Section。\n   */\n  private getSiblingsSections(section: Section): Section[] {\n    const parents = this.project.sectionMethods.getFatherSections(section);\n\n    if (parents.length === 0) {\n      // 根层级：所有无父框的其他 Section 都是同级\n      return this.project.stageManager.getSections().filter((s) => {\n        if (s === section) return false;\n        return this.project.sectionMethods.getFatherSections(s).length === 0;\n      });\n    }\n\n    // 从各个父框的直接子节点中收集同级 Section（去重）\n    const seen = new Set<string>();\n    const siblings: Section[] = [];\n    for (const parent of parents) {\n      for (const child of parent.children) {\n        if (child instanceof Section && child !== section && !seen.has(child.uuid)) {\n          seen.add(child.uuid);\n          siblings.push(child);\n        }\n      }\n    }\n    return siblings;\n  }\n\n  /**\n   * 计算将 siblingRect 从 grownRect 推离所需的最小分离向量。\n   * 选择重叠量较小的轴方向推移（与 collideWithOtherEntity 逻辑一致）。\n   */\n  private computePushDelta(grownRect: Rectangle, siblingRect: Rectangle): Vector {\n    const overlapSize = grownRect.getOverlapSize(siblingRect);\n    if (overlapSize.x === 0 && overlapSize.y === 0) {\n      return Vector.getZero();\n    }\n\n    if (overlapSize.x <= overlapSize.y) {\n      // 水平方向推移（重叠宽度更小）\n      const cx = siblingRect.center.x - grownRect.center.x;\n      const dir = cx === 0 ? 1 : Math.sign(cx);\n      return new Vector(overlapSize.x * dir, 0);\n    } else {\n      // 垂直方向推移（重叠高度更小）\n      const cy = siblingRect.center.y - grownRect.center.y;\n      const dir = cy === 0 ? 1 : Math.sign(cy);\n      return new Vector(0, overlapSize.y * dir);\n    }\n  }\n\n  /**\n   * 对 entity 及其所有后代（子树）直接施加位移，\n   * 不触发 updateFatherSectionByMove / updateOtherEntityLocationByMove 等事件链，\n   * 从而避免在批量推移过程中引发循环或振荡。\n   *\n   * 注意：对锁定 Section，collisionBox 返回临时对象，无法有效修改底层数据，\n   * 因此跳过。调用方应在调用前过滤掉锁定的同级 Section。\n   */\n  private rawShiftEntityTree(entity: Entity, delta: Vector): void {\n    // 锁定 Section 的 collisionBox 是每次重新计算的临时值，无法原地修改\n    if (entity instanceof Section && entity.locked) return;\n\n    for (const shape of entity.collisionBox.shapes) {\n      if (shape instanceof Line) {\n        shape.start = shape.start.add(delta);\n        shape.end = shape.end.add(delta);\n      } else if (shape instanceof Rectangle) {\n        shape.location = shape.location.add(delta);\n      }\n    }\n\n    // Section 需要递归平移所有子节点\n    if (entity instanceof Section) {\n      for (const child of entity.children) {\n        this.rawShiftEntityTree(child, delta);\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageManager/concreteMethods/StageAutoAlignManager.tsx",
    "content": "import { ArrayFunctions } from \"@/core/algorithm/arrayFunctions\";\nimport { Project, service } from \"@/core/Project\";\nimport { EntityAlignEffect } from \"@/core/service/feedbackService/effectEngine/concrete/EntityAlignEffect\";\nimport { RectangleRenderEffect } from \"@/core/service/feedbackService/effectEngine/concrete/RectangleRenderEffect\";\nimport { SoundService } from \"@/core/service/feedbackService/SoundService\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { toast } from \"sonner\";\n\n/**\n * 自动对齐和布局管理器\n */\n@service(\"autoAlign\")\nexport class AutoAlign {\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 对齐到网格\n   */\n  alignAllSelectedToGrid() {\n    const selectedEntities = this.project.stageManager.getSelectedEntities();\n    for (const selectedEntity of selectedEntities) {\n      if (selectedEntity.isAlignExcluded) {\n        // 涂鸦对象不参与对齐\n        continue;\n      }\n      this.onEntityMoveAlignToGrid(selectedEntity);\n    }\n  }\n\n  /**\n   * 吸附函数\n   * 用于鼠标松开的时候自动移动位置一小段距离\n   */\n  alignAllSelected() {\n    const selectedEntities = this.project.stageManager.getSelectedEntities();\n    const viewRectangle = this.project.renderer.getCoverWorldRectangle();\n    const otherEntities = this.project.stageManager\n      .getEntities()\n      .filter((entity) => !entity.isSelected)\n      .filter((entity) => entity.collisionBox.getRectangle().isAbsoluteIn(viewRectangle));\n    for (const selectedEntity of selectedEntities) {\n      if (selectedEntity.isAlignExcluded) {\n        // 涂鸦对象不参与对齐\n        continue;\n      }\n      this.onEntityMoveAlignToOtherEntity(selectedEntity, otherEntities);\n    }\n  }\n\n  /**\n   * 预先对齐显示反馈\n   * 用于鼠标移动的时候显示对齐的效果\n   */\n  preAlignAllSelected() {\n    const selectedEntities = this.project.stageManager.getSelectedEntities();\n    const viewRectangle = this.project.renderer.getCoverWorldRectangle();\n    const otherEntities = this.project.stageManager\n      .getEntities()\n      .filter((entity) => !entity.isSelected)\n      .filter((entity) => entity.collisionBox.getRectangle().isAbsoluteIn(viewRectangle));\n    for (const selectedEntity of selectedEntities) {\n      if (selectedEntity.isAlignExcluded) {\n        // 涂鸦对象不参与对齐\n        continue;\n      }\n      this.onEntityMoveAlignToOtherEntity(selectedEntity, otherEntities, true);\n    }\n  }\n  /**\n   * 将一个节点对齐到网格\n   * @param selectedEntity\n   */\n  private onEntityMoveAlignToGrid(selectedEntity: Entity) {\n    this.onEntityMoveAlignToGridX(selectedEntity);\n    this.onEntityMoveAlignToGridY(selectedEntity);\n  }\n\n  private onEntityMoveAlignToGridX(selectedEntity: Entity) {\n    const rect = selectedEntity.collisionBox.getRectangle();\n    const leftMod = rect.left % 50;\n    const rightMode = rect.right % 50;\n    const leftMoveDistance = Math.min(leftMod, 50 - leftMod);\n    const rightMoveDistance = Math.min(rightMode, 50 - rightMode);\n    if (leftMoveDistance < rightMoveDistance) {\n      // 根据实体左边缘对齐\n      if (leftMod < 50 - leftMod) {\n        // 向左\n        selectedEntity.move(new Vector(-leftMod, 0));\n      } else {\n        // 向右\n        selectedEntity.move(new Vector(50 - leftMod, 0));\n      }\n    } else {\n      // 根据右边缘对齐\n      if (rightMode < 50 - rightMode) {\n        // 向左\n        selectedEntity.move(new Vector(-rightMode, 0));\n      } else {\n        // 向右\n        selectedEntity.move(new Vector(50 - rightMode, 0));\n      }\n    }\n  }\n  private onEntityMoveAlignToGridY(selectedEntity: Entity) {\n    const rect = selectedEntity.collisionBox.getRectangle();\n    const topMod = rect.top % 50;\n    const bottomMode = rect.bottom % 50;\n    const topMoveDistance = Math.min(topMod, 50 - topMod);\n    const bottomMoveDistance = Math.min(bottomMode, 50 - bottomMode);\n    if (topMoveDistance < bottomMoveDistance) {\n      // 根据实体左边缘对齐\n      if (topMod < 50 - topMod) {\n        // 向左\n        selectedEntity.move(new Vector(0, -topMod));\n      } else {\n        // 向右\n        selectedEntity.move(new Vector(0, 50 - topMod));\n      }\n    } else {\n      // 根据右边缘对齐\n      if (bottomMode < 50 - bottomMode) {\n        // 向左\n        selectedEntity.move(new Vector(0, -bottomMode));\n      } else {\n        // 向右\n        selectedEntity.move(new Vector(0, 50 - bottomMode));\n      }\n    }\n  }\n  /**\n   * 将一个节点对齐到其他节点\n   * @param selectedEntity\n   * @param otherEntities 其他未选中的节点，在上游做好筛选\n   */\n  private onEntityMoveAlignToOtherEntity(selectedEntity: Entity, otherEntities: Entity[], isPreAlign = false) {\n    // // 只能和一个节点对齐\n    // let isHaveAlignTarget = false;\n    // 按照与 selectedEntity 的距离排序\n    const sortedOtherEntities = otherEntities\n      .sort((a, b) => {\n        const distanceA = this.calculateDistance(selectedEntity, a);\n        const distanceB = this.calculateDistance(selectedEntity, b);\n        return distanceA - distanceB; // 升序排序\n      })\n      .filter((entity) => {\n        // 排除entity是selectedEntity的父亲Section框\n        // 可以偷个懒，如果检测两个entity具有位置重叠了，那么直接排除过滤掉\n        return !entity.collisionBox.getRectangle().isCollideWithRectangle(selectedEntity.collisionBox.getRectangle());\n      });\n    let isAlign = false;\n    // 目前先只做节点吸附\n    let xMoveDiff = 0;\n    let yMoveDiff = 0;\n    const xTargetRectangles: Rectangle[] = [];\n    const yTargetRectangles: Rectangle[] = [];\n    // X轴对齐 ||||\n    for (const otherEntity of sortedOtherEntities) {\n      xMoveDiff = this.onEntityMoveAlignToTargetEntityX(selectedEntity, otherEntity, isPreAlign);\n      if (xMoveDiff !== 0) {\n        isAlign = true;\n        xTargetRectangles.push(otherEntity.collisionBox.getRectangle());\n        break;\n      }\n    }\n    // Y轴对齐 =\n    for (const otherEntity of sortedOtherEntities) {\n      yMoveDiff = this.onEntityMoveAlignToTargetEntityY(selectedEntity, otherEntity, isPreAlign);\n      if (yMoveDiff !== 0) {\n        isAlign = true;\n        yTargetRectangles.push(otherEntity.collisionBox.getRectangle());\n        break;\n      }\n    }\n    if (isAlign && isPreAlign) {\n      // 预先对齐显示反馈\n      const rectangle = selectedEntity.collisionBox.getRectangle();\n      const moveTargetRectangle = rectangle.clone();\n      moveTargetRectangle.location.x += xMoveDiff;\n      moveTargetRectangle.location.y += yMoveDiff;\n\n      this.project.effects.addEffect(RectangleRenderEffect.fromPreAlign(moveTargetRectangle));\n      for (const targetRectangle of xTargetRectangles.concat(yTargetRectangles)) {\n        this.project.effects.addEffect(EntityAlignEffect.fromEntity(moveTargetRectangle, targetRectangle));\n      }\n    }\n    if (isAlign && !isPreAlign) {\n      SoundService.play.alignAndAttach();\n    }\n  }\n\n  /**\n   * 添加对齐特效\n   * @param selectedEntity\n   * @param otherEntity\n   */\n  private _addAlignEffect(selectedEntity: Entity, otherEntity: Entity) {\n    this.project.effects.addEffect(\n      EntityAlignEffect.fromEntity(selectedEntity.collisionBox.getRectangle(), otherEntity.collisionBox.getRectangle()),\n    );\n  }\n\n  /**\n   * 将一个节点对齐到另一个节点\n   * @param selectedEntity\n   * @param otherEntity\n   * @returns 返回吸附距离\n   */\n  private onEntityMoveAlignToTargetEntityX(selectedEntity: Entity, otherEntity: Entity, isPreAlign = false): number {\n    const selectedRect = selectedEntity.collisionBox.getRectangle();\n    const otherRect = otherEntity.collisionBox.getRectangle();\n    const distanceList = [\n      otherRect.left - selectedRect.left,\n      otherRect.center.x - selectedRect.center.x,\n      otherRect.right - selectedRect.right,\n    ];\n    const minDistance = ArrayFunctions.getMinAbsValue(distanceList);\n    if (Math.abs(minDistance) < 25) {\n      if (!isPreAlign) {\n        selectedEntity.move(new Vector(minDistance, 0));\n      }\n      // 添加特效\n      this._addAlignEffect(selectedEntity, otherEntity);\n      return minDistance;\n    } else {\n      return 0;\n    }\n  }\n\n  private onEntityMoveAlignToTargetEntityY(selectedEntity: Entity, otherEntity: Entity, isPreAlign = false): number {\n    const selectedRect = selectedEntity.collisionBox.getRectangle();\n    const otherRect = otherEntity.collisionBox.getRectangle();\n    const distanceList = [\n      otherRect.top - selectedRect.top,\n      otherRect.center.y - selectedRect.center.y,\n      otherRect.bottom - selectedRect.bottom,\n    ];\n    const minDistance = ArrayFunctions.getMinAbsValue(distanceList);\n    if (Math.abs(minDistance) < 25) {\n      if (!isPreAlign) {\n        selectedEntity.move(new Vector(0, minDistance));\n      }\n      // 添加特效\n      this._addAlignEffect(selectedEntity, otherEntity);\n      return minDistance;\n    } else {\n      return 0;\n    }\n  }\n\n  // 假设你有一个方法可以计算两个节点之间的距离\n  private calculateDistance(entityA: Entity, entityB: Entity) {\n    const rectA = entityA.collisionBox.getRectangle();\n    const rectB = entityB.collisionBox.getRectangle();\n\n    // 计算距离，可以根据需要选择合适的距离计算方式\n    const dx = rectA.center.x - rectB.center.x;\n    const dy = rectA.center.y - rectB.center.y;\n\n    return Math.sqrt(dx * dx + dy * dy); // 返回欧几里得距离\n  }\n\n  /**\n   * 自动布局树形结构\n   * @param selectedRootEntity\n   */\n  autoLayoutSelectedFastTreeMode(selectedRootEntity: ConnectableEntity) {\n    // 检测树形结构\n    if (!this.project.graphMethods.isTree(selectedRootEntity)) {\n      // 不是树形结构，不做任何处理\n      toast.error(\"选择的节点必须是树形结构，不能有菱形、环、等复杂结构\");\n      return;\n    }\n    this.project.autoLayoutFastTree.autoLayoutFastTreeMode(selectedRootEntity);\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageManager/concreteMethods/StageDeleteManager.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { ExplodeDashEffect } from \"@/core/service/feedbackService/effectEngine/concrete/ExplodeDashEffect\";\nimport { Association } from \"@/core/stage/stageObject/abstract/Association\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { StageObject } from \"@/core/stage/stageObject/abstract/StageObject\";\nimport { Edge } from \"@/core/stage/stageObject/association/Edge\";\nimport { MultiTargetUndirectedEdge } from \"@/core/stage/stageObject/association/MutiTargetUndirectedEdge\";\nimport { ConnectPoint } from \"@/core/stage/stageObject/entity/ConnectPoint\";\nimport { ImageNode } from \"@/core/stage/stageObject/entity/ImageNode\";\nimport { PenStroke } from \"@/core/stage/stageObject/entity/PenStroke\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { SvgNode } from \"@/core/stage/stageObject/entity/SvgNode\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { UrlNode } from \"@/core/stage/stageObject/entity/UrlNode\";\nimport { Color, ProgressNumber } from \"@graphif/data-structures\";\nimport { ReferenceBlockNode } from \"../../stageObject/entity/ReferenceBlockNode\";\nimport { ConnectableEntity } from \"../../stageObject/abstract/ConnectableEntity\";\n\ntype DeleteHandler<T extends StageObject> = (object: T) => void;\ntype Constructor<T> = { new (...args: any[]): T };\n\n/**\n * 包含一切删除舞台上的元素的方法\n */\n@service(\"deleteManager\")\nexport class DeleteManager {\n  private deleteHandlers = new Map<Constructor<StageObject>, DeleteHandler<StageObject>>();\n  // 类型注册器，保证一个类型对应一个函数，绝对类型安全，同时可扩展\n  private registerHandler<T extends StageObject>(constructor: Constructor<T>, handler: DeleteHandler<T>) {\n    this.deleteHandlers.set(constructor, handler as DeleteHandler<StageObject>);\n  }\n\n  constructor(private readonly project: Project) {\n    this.registerHandler(TextNode, this.deleteTextNode.bind(this));\n    this.registerHandler(Section, this.deleteSection.bind(this));\n    this.registerHandler(ConnectPoint, this.deleteConnectPoint.bind(this));\n    this.registerHandler(ImageNode, this.deleteImageNode.bind(this));\n    this.registerHandler(UrlNode, this.deleteUrlNode.bind(this));\n    this.registerHandler(PenStroke, this.deletePenStroke.bind(this));\n    this.registerHandler(SvgNode, this.deleteSvgNode.bind(this));\n    this.registerHandler(ReferenceBlockNode, this.deleteReferenceBlockNode.bind(this));\n    this.registerHandler(MultiTargetUndirectedEdge, this.deleteMultiTargetUndirectedEdge.bind(this));\n  }\n\n  deleteEntities(deleteNodes: Entity[]) {\n    for (const entity of deleteNodes) {\n      const handler = this.findDeleteHandler(entity);\n      handler?.(entity);\n    }\n    this.project.stageManager.updateReferences();\n  }\n\n  private findDeleteHandler(object: StageObject) {\n    for (const [ctor, handler] of this.deleteHandlers) {\n      if (object instanceof ctor) return handler;\n    }\n    console.warn(`No delete handler for ${object.constructor.name}`);\n  }\n\n  private deleteSvgNode(entity: SvgNode) {\n    if (this.project.stageManager.getEntities().includes(entity)) {\n      this.project.stageManager.delete(entity);\n      // 删除所有相关的边\n      this.deleteEntityAfterClearAssociation(entity);\n    }\n  }\n\n  private deleteReferenceBlockNode(entity: ReferenceBlockNode) {\n    if (this.project.stageManager.getEntities().includes(entity)) {\n      this.project.stageManager.delete(entity);\n      // 删除所有相关的边\n      this.deleteEntityAfterClearAssociation(entity);\n    }\n  }\n\n  private deletePenStroke(penStroke: PenStroke) {\n    if (this.project.stageManager.getPenStrokes().includes(penStroke)) {\n      this.project.stageManager.delete(penStroke);\n    }\n  }\n\n  private deleteSection(entity: Section) {\n    if (!this.project.stageManager.getSections().includes(entity)) {\n      console.warn(\"section not in sections!!!\", entity.uuid);\n      return;\n    }\n\n    // 先删除所有内部的东西\n    if (entity.isCollapsed) {\n      this.deleteEntities(entity.children);\n    }\n\n    // 再删除自己\n    this.project.stageManager.delete(entity);\n    this.deleteEntityAfterClearAssociation(entity);\n    // 将自己所有的父级Section的children添加自己的children\n    const fatherSections = this.project.sectionMethods.getFatherSections(entity);\n    this.project.sectionInOutManager.goInSections(entity.children, fatherSections);\n  }\n  private deleteImageNode(entity: ImageNode) {\n    if (this.project.stageManager.getImageNodes().includes(entity)) {\n      this.project.stageManager.delete(entity);\n      this.project.effects.addEffect(\n        new ExplodeDashEffect(new ProgressNumber(0, 30), entity.collisionBox.getRectangle(), Color.White),\n      );\n      // 删除所有相关的边\n      this.deleteEntityAfterClearAssociation(entity);\n    }\n  }\n  private deleteUrlNode(entity: UrlNode) {\n    if (this.project.stageManager.getUrlNodes().includes(entity)) {\n      this.project.stageManager.delete(entity);\n      // 删除所有相关的边\n      this.deleteEntityAfterClearAssociation(entity);\n    }\n  }\n\n  private deleteConnectPoint(entity: ConnectPoint) {\n    // 先判断这个node是否在nodes里\n    if (this.project.stageManager.getConnectPoints().includes(entity)) {\n      // 从数组中去除\n      this.project.stageManager.delete(entity);\n      this.project.effects.addEffect(\n        new ExplodeDashEffect(new ProgressNumber(0, 30), entity.collisionBox.getRectangle(), Color.White),\n      );\n      // 删除所有相关的边\n      this.deleteEntityAfterClearAssociation(entity);\n    } else {\n      console.warn(\"connect point not in connect points\", entity.uuid);\n    }\n  }\n\n  private deleteTextNode(entity: TextNode) {\n    // 先判断这个node是否在nodes里\n    if (this.project.stageManager.isEntityExists(entity.uuid)) {\n      // TODO: 删除逻辑节点存储的状态\n      // if (NodeLogic.delayStates.has(entity.uuid)) NodeLogic.delayStates.delete(entity.uuid);\n      // 从数组中去除\n      this.project.stageManager.delete(entity);\n      // 增加特效\n      this.project.effects.addEffect(\n        new ExplodeDashEffect(\n          new ProgressNumber(0, 30),\n          entity.collisionBox.getRectangle(),\n          entity.color.a === 0 ? Color.White : entity.color.clone(),\n        ),\n      );\n    } else {\n      console.warn(\"node not in nodes\", entity.uuid);\n    }\n    // 删除所有相关的边\n    this.deleteEntityAfterClearAssociation(entity);\n  }\n\n  /**\n   * 删除所有相关的边\n   * @param entity\n   */\n  private deleteEntityAfterClearAssociation(entity: ConnectableEntity) {\n    const prepareDeleteAssociation: Association[] = [];\n    const visitedAssociations: Set<string> = new Set();\n\n    for (const edge of this.project.stageManager.getAssociations()) {\n      if (edge instanceof Edge) {\n        if ((edge.source === entity || edge.target === entity) && visitedAssociations.has(edge.uuid) === false) {\n          prepareDeleteAssociation.push(edge);\n          visitedAssociations.add(edge.uuid);\n        }\n      } else if (edge instanceof MultiTargetUndirectedEdge) {\n        if (edge.associationList.includes(entity) && visitedAssociations.has(edge.uuid) === false) {\n          prepareDeleteAssociation.push(edge);\n          visitedAssociations.add(edge.uuid);\n        }\n      }\n    }\n    for (const edge of prepareDeleteAssociation) {\n      this.project.stageManager.delete(edge);\n    }\n  }\n\n  /**\n   * 注意不要在遍历edges数组中调用这个方法，否则会导致数组长度变化，导致索引错误\n   * @param deleteEdge 要删除的边\n   * @returns\n   */\n  deleteEdge(deleteEdge: Edge): boolean {\n    const fromNode = deleteEdge.source;\n    const toNode = deleteEdge.target;\n    // 检查边的源和目标是否在锁定的 section 内\n    if (this.project.sectionMethods.isObjectBeLockedBySection(deleteEdge)) {\n      return false; // 连接了锁定 section 内物体的连线不允许删除\n    }\n    // 先判断这两个节点是否在nodes里\n    if (\n      this.project.stageManager.isEntityExists(fromNode.uuid) &&\n      this.project.stageManager.isEntityExists(toNode.uuid)\n    ) {\n      // 删除边\n      this.project.stageManager.delete(deleteEdge);\n      this.project.stageManager.updateReferences();\n      return true;\n    } else {\n      return false;\n    }\n  }\n\n  deleteMultiTargetUndirectedEdge(edge: MultiTargetUndirectedEdge) {\n    // 检查无向边是否连接了锁定的 section 内的物体\n    if (this.project.sectionMethods.isObjectBeLockedBySection(edge)) {\n      return false; // 连接了锁定 section 内物体的无向边不允许删除\n    }\n    this.project.stageManager.delete(edge);\n    this.project.stageManager.updateReferences();\n    return true;\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageManager/concreteMethods/StageEntityMoveManager.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { EntityJumpMoveEffect } from \"@/core/service/feedbackService/effectEngine/concrete/EntityJumpMoveEffect\";\nimport { RectanglePushInEffect } from \"@/core/service/feedbackService/effectEngine/concrete/RectanglePushInEffect\";\nimport { SoundService } from \"@/core/service/feedbackService/SoundService\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { Vector } from \"@graphif/data-structures\";\n\n/**\n * 管理节点的位置移动\n * 不仅仅有鼠标拖动的移动，还有对齐造成的移动\n * 还要处理节点移动后，对Section大小造成的影响\n * 以后还可能有自动布局的功能\n */\n@service(\"entityMoveManager\")\nexport class EntityMoveManager {\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 检查实体是否可以移动（考虑锁定状态）\n   * @param entity 要检查的实体\n   * @returns 如果实体可以移动返回 true，否则返回 false\n   */\n  private canMoveEntity(entity: Entity): boolean {\n    // 检查实体是否有锁定的祖先section（递归检查）\n    const ancestorSections = this.project.sectionMethods.getFatherSectionsList(entity);\n    if (ancestorSections.some((section) => section.locked)) {\n      return false;\n    }\n    return true;\n  }\n\n  /**\n   * 让某一个实体移动一小段距离\n   * @param entity\n   * @param delta\n   * @param isAutoAdjustSection 移动的时候是否触发section框的弹性调整\n   */\n  moveEntityUtils(entity: Entity, delta: Vector, isAutoAdjustSection: boolean = true) {\n    // 检查实体是否可以被移动（锁定状态检查）\n    if (!this.canMoveEntity(entity)) {\n      return;\n    }\n    // 让自己移动\n    entity.move(delta);\n\n    const nodeUUID = entity.uuid;\n\n    // if (this.project.stageManager.isSectionByUUID(nodeUUID)) {\n    //   // 如果是Section，则需要带动孩子一起移动\n    //   const section = this.project.stageManager.getSectionByUUID(nodeUUID);\n    //   if (section) {\n    //     for (const child of section.children) {\n    //       moveEntityUtils(child, delta);\n    //     }\n    //   }\n    // }\n    if (isAutoAdjustSection) {\n      for (const section of this.project.stageManager.getSections()) {\n        if (section.children.find((it) => it.uuid === nodeUUID)) {\n          section.adjustLocationAndSize();\n        }\n      }\n    }\n  }\n\n  /**\n   * 跳跃式移动传入的实体\n   * 会破坏嵌套关系\n   * @param entity\n   * @param delta\n   */\n  jumpMoveEntityUtils(entity: Entity, delta: Vector) {\n    // 检查实体是否可以被移动（锁定状态检查）\n    if (!this.canMoveEntity(entity)) {\n      return;\n    }\n\n    const beforeMoveRect = entity.collisionBox.getRectangle().clone();\n    console.log(\"JUMP MOVE\");\n    // 将自己移动前加特效\n    this.project.effects.addEffect(new EntityJumpMoveEffect(15, beforeMoveRect, delta));\n\n    // 即将跳入的sections区域\n    const targetSections = this.project.sectionMethods.getSectionsByInnerLocation(beforeMoveRect.center.add(delta));\n\n    // 检查目标位置是否在锁定的 section 内（包括祖先section的锁定状态）\n    if (targetSections.some((section) => this.project.sectionMethods.isObjectBeLockedBySection(section))) {\n      return;\n    }\n    // 改变层级\n    if (targetSections.length === 0) {\n      // 代表想要走出当前section\n      const currentFatherSections = this.project.sectionMethods.getFatherSections(entity);\n      if (currentFatherSections.length !== 0) {\n        this.project.stageManager.goOutSection([entity], currentFatherSections[0]);\n      }\n    } else {\n      this.project.sectionInOutManager.goInSections([entity], targetSections);\n      for (const section of targetSections) {\n        // 特效\n        this.project.effects.addEffect(\n          new RectanglePushInEffect(entity.collisionBox.getRectangle(), section.collisionBox.getRectangle()),\n        );\n        SoundService.play.entityJumpSoundFile();\n      }\n    }\n\n    // 让自己移动\n    // entity.move(delta);\n    this.moveEntityUtils(entity, delta, false);\n  }\n\n  /**\n   * 将某个实体移动到目标位置\n   * @param entity\n   * @param location\n   */\n  moveEntityToUtils(entity: Entity, location: Vector) {\n    // 检查实体是否可以被移动（锁定状态检查）\n    if (!this.canMoveEntity(entity)) {\n      return;\n    }\n    entity.moveTo(location);\n    const nodeUUID = entity.uuid;\n    for (const section of this.project.stageManager.getSections()) {\n      if (section.children.find((it) => it.uuid === nodeUUID)) {\n        section.adjustLocationAndSize();\n      }\n    }\n  }\n\n  /**\n   * 移动所有选中的实体一小段距离\n   * @param delta\n   * @param isAutoAdjustSection\n   */\n  moveSelectedEntities(delta: Vector, isAutoAdjustSection: boolean = true) {\n    for (const node of this.project.stageManager.getEntities()) {\n      if (node.isSelected) {\n        this.moveEntityUtils(node, delta, isAutoAdjustSection);\n      }\n    }\n  }\n\n  /**\n   * 跳跃式移动所有选中的可连接实体\n   * 会破坏框的嵌套关系\n   * @param delta\n   */\n  jumpMoveSelectedConnectableEntities(delta: Vector) {\n    for (const node of this.project.stageManager.getConnectableEntity()) {\n      if (node.isSelected) {\n        this.jumpMoveEntityUtils(node, delta);\n      }\n    }\n  }\n\n  /**\n   * 树型移动 所有选中的实体\n   * @param delta\n   */\n  moveEntitiesWithChildren(delta: Vector) {\n    for (const node of this.project.stageManager.getEntities()) {\n      if (node.isSelected) {\n        if (node instanceof ConnectableEntity) {\n          this.moveWithChildren(node, delta);\n        } else {\n          this.moveEntityUtils(node, delta);\n        }\n      }\n    }\n  }\n  /**\n   * 树形移动传入的可连接实体\n   * @param node\n   * @param delta\n   */\n  moveWithChildren(node: ConnectableEntity, delta: Vector) {\n    const successorSet = this.project.graphMethods.getSuccessorSet(node);\n    for (const successor of successorSet) {\n      this.moveEntityUtils(successor, delta);\n    }\n  }\n\n  // 按住shift键移动\n}\n"
  },
  {
    "path": "app/src/core/stage/stageManager/concreteMethods/StageManagerUtils.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { StageObject } from \"@/core/stage/stageObject/abstract/StageObject\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\n\n/**\n * 舞台管理器相关的工具函数\n *\n */\n@service(\"stageUtils\")\nexport class StageUtils {\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 替换不需要在舞台上做检测的自动生成的名称\n   * @param template\n   * @returns\n   */\n  replaceAutoNameWithoutStage(template: string): string {\n    if (template.includes(\"{{date}}\")) {\n      const now = new Date();\n      const year = now.getFullYear();\n      const month = now.getMonth() + 1;\n      const date = now.getDate();\n      template = template.replaceAll(\"{{date}}\", `${year}-${month}-${date}`);\n    }\n    if (template.includes(\"{{time}}\")) {\n      const now = new Date();\n      const hour = now.getHours();\n      const minute = now.getMinutes();\n      const second = now.getSeconds();\n      template = template.replaceAll(\"{{time}}\", `${hour}:${minute}:${second}`);\n    }\n    return template;\n  }\n\n  /**\n   * 替换带有{{i}} 命名的自动生成的名称\n   * @param template\n   * @param targetStageObject\n   */\n  replaceAutoNameTemplate(currentName: string, targetStageObject: StageObject): string {\n    // 先替换掉不需要检测舞台上内容的部分\n    currentName = this.replaceAutoNameWithoutStage(currentName);\n\n    if (currentName.includes(\"{{i}}\")) {\n      let i = 0;\n      while (true) {\n        const currentCmpName = currentName.replace(\"{{i}}\", i.toString());\n        let isConflict = false;\n        if (targetStageObject instanceof TextNode) {\n          isConflict = this.isNameConflictWithTextNodes(currentCmpName);\n        } else if (targetStageObject instanceof Section) {\n          isConflict = this.isNameConflictWithSections(currentCmpName);\n        }\n        if (isConflict) {\n          i++;\n          continue;\n        } else {\n          // 没有冲突，就这样了\n          break;\n        }\n      }\n      currentName = currentName.replaceAll(\"{{i}}\", i.toString());\n    }\n    return currentName;\n  }\n\n  isNameConflictWithTextNodes(name: string): boolean {\n    // 获取当前视野范围\n    const viewportRect = this.project.renderer.getCoverWorldRectangle();\n    for (const node of this.project.stageManager.getTextNodes()) {\n      // 仅检查视野内的节点\n      if (viewportRect.isCollideWith(node.rectangle)) {\n        if (node.text === name) {\n          return true;\n        }\n      }\n    }\n    return false;\n  }\n\n  isNameConflictWithSections(name: string): boolean {\n    // 获取当前视野范围\n    const viewportRect = this.project.renderer.getCoverWorldRectangle();\n    for (const section of this.project.stageManager.getSections()) {\n      // 仅检查视野内的 section\n      if (viewportRect.isCollideWith(section.rectangle)) {\n        if (section.text === name) {\n          return true;\n        }\n      }\n    }\n    return false;\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageManager/concreteMethods/StageMultiTargetEdgeMove.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { MultiTargetUndirectedEdge } from \"@/core/stage/stageObject/association/MutiTargetUndirectedEdge\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\n\n/**\n * 多源无向边移动中心点\n */\n@service(\"multiTargetEdgeMove\")\nexport class MultiTargetEdgeMove {\n  constructor(private readonly project: Project) {}\n\n  /**\n   *\n   * @param lastMoveLocation 鼠标按下的位置\n   * @param diffLocation 鼠标移动向量\n   */\n  moveMultiTargetEdge(diffLocation: Vector) {\n    for (const association of this.project.stageManager.getSelectedAssociations()) {\n      if (!(association instanceof MultiTargetUndirectedEdge)) {\n        continue;\n      }\n      if (!association.isSelected) {\n        continue;\n      }\n      // const startMouseDragLocation = lastMoveLocation.clone();\n      // const endMouseDragLocation = startMouseDragLocation.add(diffLocation);\n\n      const boundingRectangle = Rectangle.getBoundingRectangle(\n        association.associationList.map((n) => n.collisionBox.getRectangle()),\n      );\n      // 当前的中心点\n      const currentCenter = association.centerLocation;\n      // 新的中心点\n      const newCenter = currentCenter.add(diffLocation);\n      // 新的比例\n      const newRate = new Vector(\n        (newCenter.x - boundingRectangle.location.x) / boundingRectangle.width,\n        (newCenter.y - boundingRectangle.location.y) / boundingRectangle.height,\n      );\n      association.centerRate = newRate;\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageManager/concreteMethods/StageNodeAdder.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { RectanglePushInEffect } from \"@/core/service/feedbackService/effectEngine/concrete/RectanglePushInEffect\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { ConnectPoint } from \"@/core/stage/stageObject/entity/ConnectPoint\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { Direction } from \"@/types/directions\";\nimport { Color, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\n\n/**\n * 包含增加节点的方法\n * 有可能是用鼠标增加，涉及自动命名器\n * 也有可能是用键盘增加，涉及快捷键和自动寻找空地\n */\n@service(\"nodeAdder\")\nexport class NodeAdder {\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 通过点击位置增加节点\n   * @param clickWorldLocation\n   * 如果是直接创建，则需要记录位置，如果是通过已有位置创建，则还需要调整一次位置，此时不需要记录\n   * @param shouldRecordHistory\n   * @returns 创建节点的uuid\n   */\n  async addTextNodeByClick(\n    clickWorldLocation: Vector,\n    addToSections: Section[],\n    selectCurrent = false,\n    shouldRecordHistory = true,\n  ): Promise<string> {\n    const autoFillColor = this.getAutoColor();\n    const node = new TextNode(this.project, {\n      text: await this.getAutoName(),\n      collisionBox: new CollisionBox([new Rectangle(clickWorldLocation, Vector.getZero())]),\n      color: autoFillColor,\n    });\n    // 将node本身向左上角移动，使其居中\n    node.moveTo(node.rectangle.location.subtract(node.rectangle.size.divide(2)));\n    this.project.stageManager.add(node);\n\n    for (const section of addToSections) {\n      section.children.push(node);\n      section.adjustLocationAndSize();\n      this.project.effects.addEffect(\n        new RectanglePushInEffect(node.rectangle.clone(), section.rectangle.clone(), new ProgressNumber(0, 100)),\n      );\n    }\n    // 处理选中问题\n    if (selectCurrent) {\n      for (const otherNode of this.project.stageManager.getTextNodes()) {\n        if (otherNode.isSelected) {\n          otherNode.isSelected = false;\n        }\n      }\n      node.isSelected = true;\n    }\n    if (shouldRecordHistory) {\n      this.project.historyManager.recordStep();\n    }\n    return node.uuid;\n  }\n\n  /**\n   * 在当前已经选中的某个节点的情况下，增加节点\n   * 增加在某个选中的节点的上方，下方，左方，右方等位置\n   * ——快深频\n   * @param selectCurrent\n   * @returns 返回的是创建节点的uuid，如果当前没有选中节点，则返回空字符串\n   */\n  async addTextNodeFromCurrentSelectedNode(\n    direction: Direction,\n    addToSections: Section[],\n    selectCurrent = false,\n  ): Promise<string> {\n    // 先检查当前是否有选中的唯一实体\n    const selectedEntities = this.project.stageManager\n      .getSelectedEntities()\n      .filter((entity) => entity instanceof ConnectableEntity);\n    if (selectedEntities.length !== 1) {\n      // 未选中或选中多个\n      return \"\";\n    }\n    /**\n     * 当前选择的实体\n     */\n    const selectedEntity = selectedEntities[0];\n    const entityRectangle = selectedEntity.collisionBox.getRectangle();\n    let createLocation = new Vector(0, 0);\n    const distanceLength = 100;\n    if (direction === Direction.Up) {\n      createLocation = entityRectangle.topCenter.add(new Vector(0, -distanceLength));\n    } else if (direction === Direction.Down) {\n      createLocation = entityRectangle.bottomCenter.add(new Vector(0, distanceLength));\n    } else if (direction === Direction.Left) {\n      createLocation = entityRectangle.leftCenter.add(new Vector(-distanceLength, 0));\n    } else if (direction === Direction.Right) {\n      createLocation = entityRectangle.rightCenter.add(new Vector(distanceLength, 0));\n    }\n    addToSections = this.project.sectionMethods.getFatherSections(selectedEntity);\n    const uuid = await this.addTextNodeByClick(createLocation, addToSections, selectCurrent, false);\n    const newNode = this.project.stageManager.getTextNodeByUUID(uuid);\n    if (!newNode) {\n      throw new Error(\"Failed to add node\");\n    }\n    // 如果是通过上下创建的节点，则需要左对齐\n    if (direction === Direction.Up || direction === Direction.Down) {\n      const distance = newNode.rectangle.left - entityRectangle.left;\n      newNode.moveTo(newNode.rectangle.location.add(new Vector(-distance, 0)));\n    }\n    if (direction === Direction.Left) {\n      // 顶对齐\n      const distance = newNode.rectangle.top - entityRectangle.top;\n      newNode.moveTo(newNode.rectangle.location.add(new Vector(0, -distance)));\n    }\n    if (direction === Direction.Right) {\n      // 顶对齐，+ 自己对齐到目标的右侧\n      const targetLocation = entityRectangle.rightTop;\n      newNode.moveTo(targetLocation);\n    }\n    if (direction === Direction.Up) {\n      const targetLocation = entityRectangle.leftTop.subtract(\n        new Vector(0, newNode.collisionBox.getRectangle().height),\n      );\n      newNode.moveTo(targetLocation);\n    }\n    if (direction === Direction.Down) {\n      const targetLocation = entityRectangle.leftBottom;\n      newNode.moveTo(targetLocation);\n    }\n    this.project.historyManager.recordStep();\n    // 创建时没有记录，这里调整完位置再记录\n    return uuid;\n  }\n\n  private async getAutoName(): Promise<string> {\n    let template = Settings.autoNamerTemplate;\n    template = this.project.stageUtils.replaceAutoNameTemplate(template, this.project.stageManager.getTextNodes()[0]);\n    return template;\n  }\n\n  private getAutoColor(): Color {\n    const isEnable = Settings.autoFillNodeColorEnable;\n    if (isEnable) {\n      const colorData = Settings.autoFillNodeColor;\n      return new Color(...colorData);\n    } else {\n      return Color.Transparent;\n    }\n  }\n\n  public addConnectPoint(clickWorldLocation: Vector, addToSections: Section[]): string {\n    const connectPoint = new ConnectPoint(this.project, {\n      collisionBox: new CollisionBox([\n        new Rectangle(\n          clickWorldLocation.subtract(Vector.same(ConnectPoint.CONNECT_POINT_SHRINK_RADIUS)),\n          Vector.same(ConnectPoint.CONNECT_POINT_SHRINK_RADIUS * 2),\n        ),\n      ]),\n    });\n    this.project.stageManager.add(connectPoint);\n\n    // 把质点加入到每一个section中，并调整section大小\n    for (const section of addToSections) {\n      section.children.push(connectPoint);\n      section.adjustLocationAndSize();\n      // 特效\n      this.project.effects.addEffect(\n        new RectanglePushInEffect(\n          connectPoint.collisionBox.getRectangle(),\n          section.rectangle.clone(),\n          new ProgressNumber(0, 100),\n        ),\n      );\n    }\n\n    this.project.historyManager.recordStep();\n    return connectPoint.uuid;\n  }\n\n  /**\n   * 通过纯文本生成网状结构\n   * 这个函数不稳定，可能会随时throw错误\n   * @param text 网状结构的格式文本\n   * @param diffLocation\n   */\n  public addNodeGraphByText(text: string, diffLocation: Vector = Vector.getZero()): void {\n    this.project.stageImport.addNodeGraphByText(text, diffLocation);\n  }\n\n  /**\n   * 通过带有缩进格式的文本来增加节点\n   */\n  public addNodeTreeByText(text: string, indention: number, diffLocation: Vector = Vector.getZero()): void {\n    this.project.stageImport.addNodeTreeByText(text, indention, diffLocation);\n  }\n\n  /**\n   * 根据 mermaid 文本生成框嵌套网状结构\n   * 支持 graph TD 格式的 mermaid 文本\n   * @param text Mermaid 格式文本\n   * @param diffLocation 偏移位置\n   */\n  public addNodeMermaidByText(text: string, diffLocation: Vector = Vector.getZero()): void {\n    this.project.stageImport.addNodeMermaidByText(text, diffLocation);\n  }\n\n  /**\n   * 根据 Markdown 文本生成节点树结构\n   * @param markdownText Markdown 格式文本\n   * @param diffLocation 偏移位置\n   * @param autoLayout 是否自动应用树形布局（默认为 true）\n   */\n  public addNodeByMarkdown(markdownText: string, diffLocation: Vector = Vector.getZero(), autoLayout = true) {\n    this.project.stageImport.addNodeByMarkdown(markdownText, diffLocation, autoLayout);\n  }\n\n  /***\n   * 'a' -> 0\n   * '    a' -> 1\n   * '\\t\\ta' -> 2\n   */\n  private getIndentLevel(line: string, indention: number): number {\n    let indent = 0;\n    for (let i = 0; i < line.length; i++) {\n      if (line[i] === \" \") {\n        indent++;\n      } else if (line[i] === \"\\t\") {\n        indent += indention;\n      } else {\n        break;\n      }\n    }\n    return Math.floor(indent / indention);\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageManager/concreteMethods/StageNodeConnector.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { CubicCatmullRomSplineEdge } from \"@/core/stage/stageObject/association/CubicCatmullRomSplineEdge\";\nimport { LineEdge } from \"@/core/stage/stageObject/association/LineEdge\";\nimport { ConnectPoint } from \"@/core/stage/stageObject/entity/ConnectPoint\";\nimport { Vector } from \"@graphif/data-structures\";\n\n/**\n * 集成所有连线相关的功能\n */\n@service(\"nodeConnector\")\nexport class NodeConnector {\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 检测是否可以连接两个节点\n   * @param fromNode\n   * @param toNode\n   */\n  private isConnectable(fromNode: ConnectableEntity, toNode: ConnectableEntity): boolean {\n    if (\n      this.project.stageManager.isEntityExists(fromNode.uuid) &&\n      this.project.stageManager.isEntityExists(toNode.uuid)\n    ) {\n      if (fromNode.uuid === toNode.uuid && fromNode instanceof ConnectPoint) {\n        return false;\n      }\n      // 2.7 开始，允许多重边\n      // if (this.project.graphMethods.isConnected(fromNode, toNode)) {\n      //   // 已经连接过了，不需要再次连接\n      //   return false;\n      // }\n      return true;\n    } else {\n      return false;\n    }\n  }\n\n  /**\n   * 如果两个节点都是同一个 ConnectPoint 对象类型，则不能连接，因为没有必要\n   * @param fromNode\n   * @param toNode\n   * @param text\n   * @returns\n   */\n  connectConnectableEntity(\n    fromNode: ConnectableEntity,\n    toNode: ConnectableEntity,\n    text: string = \"\",\n    targetRectRate?: [number, number],\n    sourceRectRate?: [number, number],\n  ): void {\n    if (!this.isConnectable(fromNode, toNode)) {\n      return;\n    }\n    const newEdge = new LineEdge(this.project, {\n      associationList: [fromNode, toNode],\n      text,\n      targetRectangleRate: new Vector(...(targetRectRate || [0.5, 0.5])),\n      sourceRectangleRate: new Vector(...(sourceRectRate || [0.5, 0.5])),\n    });\n\n    this.project.stageManager.add(newEdge);\n\n    this.project.stageManager.updateReferences();\n  }\n\n  connectEntityFast(fromNode: ConnectableEntity, toNode: ConnectableEntity, text: string = \"\"): void {\n    const newEdge = new LineEdge(this.project, {\n      associationList: [fromNode, toNode],\n      text,\n      targetRectangleRate: new Vector(0.5, 0.5),\n      sourceRectangleRate: new Vector(0.5, 0.5),\n    });\n\n    this.project.stageManager.add(newEdge);\n  }\n\n  addCrEdge(fromNode: ConnectableEntity, toNode: ConnectableEntity): void {\n    if (!this.isConnectable(fromNode, toNode)) {\n      return;\n    }\n    const newEdge = CubicCatmullRomSplineEdge.fromTwoEntity(this.project, fromNode, toNode);\n    this.project.stageManager.add(newEdge);\n    this.project.stageManager.updateReferences();\n  }\n\n  // 将多个节点之间全连接\n\n  // 反向连线\n  reverseEdges(edges: LineEdge[]) {\n    edges.forEach((edge) => {\n      const oldSource = edge.source;\n      edge.source = edge.target;\n      edge.target = oldSource;\n      const oldSourceRectRage = edge.sourceRectangleRate;\n      edge.sourceRectangleRate = edge.targetRectangleRate;\n      edge.targetRectangleRate = oldSourceRectRage;\n    });\n    this.project.stageManager.updateReferences();\n  }\n\n  /**\n   * 单独改变一个节点的连接点\n   * @param edge\n   * @param newTarget\n   * @returns\n   */\n  private changeEdgeTarget(edge: LineEdge, newTarget: ConnectableEntity) {\n    if (edge.target.uuid === newTarget.uuid) {\n      return;\n    }\n    edge.target = newTarget;\n    this.project.stageManager.updateReferences();\n  }\n\n  /**\n   * 单独改变一个节点的源连接点\n   * @param edge\n   * @param newSource\n   * @returns\n   */\n  private changeEdgeSource(edge: LineEdge, newSource: ConnectableEntity) {\n    if (edge.source.uuid === newSource.uuid) {\n      return;\n    }\n    edge.source = newSource;\n    this.project.stageManager.updateReferences();\n  }\n\n  /**\n   * 改变所有选中的连线的目标节点\n   * @param newTarget\n   */\n  changeSelectedEdgeTarget(newTarget: ConnectableEntity) {\n    const selectedEdges = this.project.stageManager.getSelectedStageObjects().filter((obj) => obj instanceof LineEdge);\n    for (const edge of selectedEdges) {\n      if (edge instanceof LineEdge) {\n        this.changeEdgeTarget(edge, newTarget);\n      }\n    }\n    // https://github.com/graphif/project-graph/issues/522\n    // this.project.historyManager.recordStep();\n  }\n\n  /**\n   * 改变所有选中的连线的源节点\n   * @param newSource\n   */\n  changeSelectedEdgeSource(newSource: ConnectableEntity) {\n    const selectedEdges = this.project.stageManager.getSelectedStageObjects().filter((obj) => obj instanceof LineEdge);\n    for (const edge of selectedEdges) {\n      if (edge instanceof LineEdge) {\n        this.changeEdgeSource(edge, newSource);\n      }\n    }\n    // https://github.com/graphif/project-graph/issues/522\n    // this.project.historyManager.recordStep();\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageManager/concreteMethods/StageObjectColorManager.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { Color } from \"@graphif/data-structures\";\n\n/**\n * 管理所有 节点/连线 的颜色\n * 不仅包括添加颜色和去除颜色，还包括让颜色变暗和变亮等\n */\n@service(\"stageObjectColorManager\")\nexport class StageObjectColorManager {\n  constructor(private readonly project: Project) {}\n\n  setSelectedStageObjectColor(color: Color) {\n    for (const node of this.project.stageManager.getTextNodes()) {\n      if (node.isSelected) {\n        node.color = color;\n        this.project.controllerUtils.finishChangeTextNode(node);\n      }\n    }\n    for (const node of this.project.stageManager.getSections()) {\n      if (node.isSelected) {\n        node.color = color;\n      }\n    }\n    for (const entity of this.project.stageManager.getPenStrokes()) {\n      if (entity.isSelected) {\n        entity.color = color;\n      }\n    }\n    for (const entity of this.project.stageManager.getSvgNodes()) {\n      if (entity.isSelected) {\n        entity.changeColor(color);\n      }\n    }\n    for (const entity of this.project.stageManager.getUrlNodes()) {\n      if (entity.isSelected) {\n        entity.color = color;\n      }\n    }\n    for (const edge of this.project.stageManager.getAssociations()) {\n      if (edge.isSelected) {\n        edge.color = color;\n      }\n    }\n    // 特性：统一取消框选\n    // this.project.stageManager.clearSelectAll();  // 不能统一取消全选，因为填充后可能会发现颜色不合适\n    this.project.historyManager.recordStep();\n  }\n\n  darkenNodeColor() {\n    for (const node of this.project.stageManager.getTextNodes()) {\n      if (node.isSelected && node.color) {\n        const darkenedColor = node.color.clone();\n        darkenedColor.r = Math.max(darkenedColor.r - 20, 0);\n        darkenedColor.g = Math.max(darkenedColor.g - 20, 0);\n        darkenedColor.b = Math.max(darkenedColor.b - 20, 0);\n        node.color = darkenedColor;\n      }\n    }\n    this.project.historyManager.recordStep();\n  }\n\n  lightenNodeColor() {\n    for (const node of this.project.stageManager.getTextNodes()) {\n      if (node.isSelected && node.color) {\n        const lightenedColor = node.color.clone();\n        lightenedColor.r = Math.min(lightenedColor.r + 20, 255);\n        lightenedColor.g = Math.min(lightenedColor.g + 20, 255);\n        lightenedColor.b = Math.min(lightenedColor.b + 20, 255);\n        node.color = lightenedColor;\n      }\n    }\n    this.project.historyManager.recordStep();\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageManager/concreteMethods/StageObjectSelectCounter.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { Association } from \"@/core/stage/stageObject/abstract/Association\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { CubicCatmullRomSplineEdge } from \"@/core/stage/stageObject/association/CubicCatmullRomSplineEdge\";\nimport { Edge } from \"@/core/stage/stageObject/association/Edge\";\nimport { MultiTargetUndirectedEdge } from \"@/core/stage/stageObject/association/MutiTargetUndirectedEdge\";\nimport { ImageNode } from \"@/core/stage/stageObject/entity/ImageNode\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\n\n/**\n * 实时记录选中的各种类型的对象的数量\n * 用于工具栏实时切换按钮的显示\n *\n * 现在2.0已经废弃了，因为有右键菜单了\n */\n@service(\"stageObjectSelectCounter\")\nexport class StageObjectSelectCounter {\n  constructor(private readonly project: Project) {}\n\n  // 用于UI层监测\n  selectedStageObjectCount = 0;\n  selectedEntityCount = 0;\n  selectedAssociationCount = 0;\n  selectedEdgeCount = 0;\n  selectedCREdgeCount = 0;\n  selectedImageNodeCount = 0;\n  selectedTextNodeCount = 0;\n  selectedSectionCount = 0;\n  selectedMultiTargetUndirectedEdgeCount = 0;\n\n  /**\n   * 上次更新时间\n   * 防止频繁更新，影响性能\n   */\n  private lastUpdateTimestamp = 0;\n\n  update() {\n    console.time(\"updateCount\");\n    if (Date.now() - this.lastUpdateTimestamp < 10) {\n      return;\n    }\n    this.lastUpdateTimestamp = Date.now();\n\n    // 刷新UI层的选中数量\n    this.selectedStageObjectCount = 0;\n    this.selectedEntityCount = 0;\n    this.selectedEdgeCount = 0;\n    this.selectedCREdgeCount = 0;\n    this.selectedImageNodeCount = 0;\n    this.selectedTextNodeCount = 0;\n    this.selectedSectionCount = 0;\n    this.selectedAssociationCount = 0;\n    this.selectedMultiTargetUndirectedEdgeCount = 0;\n\n    for (const stageObject of this.project.stageManager.getStageObjects()) {\n      if (!stageObject.isSelected) {\n        continue;\n      }\n      this.selectedStageObjectCount++;\n      if (stageObject instanceof Entity) {\n        this.selectedEntityCount++;\n        if (stageObject instanceof ImageNode) {\n          this.selectedImageNodeCount++;\n        } else if (stageObject instanceof TextNode) {\n          this.selectedTextNodeCount++;\n        } else if (stageObject instanceof Section) {\n          this.selectedSectionCount++;\n        }\n      } else if (stageObject instanceof Association) {\n        this.selectedAssociationCount++;\n        if (stageObject instanceof MultiTargetUndirectedEdge) {\n          this.selectedMultiTargetUndirectedEdgeCount++;\n        }\n        if (stageObject instanceof Edge) {\n          this.selectedEdgeCount++;\n          if (stageObject instanceof CubicCatmullRomSplineEdge) {\n            this.selectedCREdgeCount++;\n          }\n        }\n      }\n    }\n    console.timeEnd(\"updateCount\");\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageManager/concreteMethods/StageReferenceManager.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Section } from \"../../stageObject/entity/Section\";\nimport { toast } from \"sonner\";\nimport { RecentFileManager } from \"@/core/service/dataFileService/RecentFileManager\";\nimport { PathString } from \"@/utils/pathString\";\nimport { onOpenFile } from \"@/core/service/GlobalMenu\";\nimport { ReferenceBlockNode } from \"../../stageObject/entity/ReferenceBlockNode\";\nimport { RectangleLittleNoteEffect } from \"@/core/service/feedbackService/effectEngine/concrete/RectangleLittleNoteEffect\";\nimport { SectionReferencePanel } from \"@/sub/ReferencesWindow\";\nimport { loadAllServicesBeforeInit } from \"@/core/loadAllServices\";\nimport { projectsAtom, store } from \"@/state\";\n\ninterface parserResult {\n  /**\n   * 是否是一个合法的引用块内容\n   */\n  isValid: boolean;\n  /**\n   * 不合法的原因\n   */\n  invalidReason: string;\n  /**\n   * 引用的文件名\n   */\n  fileName: string;\n  /**\n   * 引用的章节名，为空表示引用整个文件\n   */\n  sectionName: string;\n}\n\n@service(\"referenceManager\")\nexport class ReferenceManager {\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 保险检查函数\n   * 解析用户在文本节点中输入的引用格式文本，防止直接退出编辑模式后触发转换，导致引用块内容被错误解析\n   * @param text 引用块文本\n   * @returns\n   */\n  public static referenceBlockTextParser(text: string): parserResult {\n    if (!text.startsWith(\"[[\") || !text.endsWith(\"]]\")) {\n      return {\n        isValid: false,\n        invalidReason: \"引用块内容格式错误, 必须用双中括号包裹起来，且双中括号外侧不能有空格\",\n        fileName: \"\",\n        sectionName: \"\",\n      };\n    }\n    const content = text.slice(2, -2);\n    if (content.includes(\"#\")) {\n      const [fileName, sectionName] = content.split(\"#\");\n      if (!fileName) {\n        return {\n          isValid: false,\n          invalidReason: \"引用块格式错误，文件名不能为空\",\n          fileName: \"\",\n          sectionName: \"\",\n        };\n      }\n      if (!sectionName) {\n        return {\n          isValid: false,\n          invalidReason: \"引用块格式错误，章节名不能为空\",\n          fileName: fileName,\n          sectionName: \"\",\n        };\n      }\n      return {\n        isValid: true,\n        invalidReason: \"\",\n        fileName: fileName,\n        sectionName: sectionName,\n      };\n    } else {\n      if (!content) {\n        return {\n          isValid: false,\n          invalidReason: \"引用块内容格式错误, 文件名不能为空\",\n          fileName: \"\",\n          sectionName: \"\",\n        };\n      }\n      return {\n        isValid: true,\n        invalidReason: \"\",\n        fileName: content,\n        sectionName: \"\",\n      };\n    }\n  }\n\n  /**\n   * 处理引用按钮点击事件\n   * O(N) 需要查找每一个引用的Section\n   * @param clickLocation 点击位置\n   */\n  public onClickReferenceNumber(clickLocation: Vector) {\n    if (Object.keys(this.project.references.sections).length === 0) return;\n    const sectionNameMap = this.buildSectionName2SectionMap(Object.keys(this.project.references.sections));\n    for (const sectionName in this.project.references.sections) {\n      const section = sectionNameMap[sectionName];\n      if (section) {\n        if (section.isMouseInReferenceButton(clickLocation)) {\n          // 打开这个详细信息的引用弹窗\n          this.openSectionReferencePanel(section);\n          return;\n        }\n      }\n    }\n  }\n\n  private buildSectionName2SectionMap(sectionNames: string[]): Record<string, Section> {\n    const res: Record<string, Section> = {};\n    const sectionNameSet = new Set(sectionNames);\n    for (const section of this.project.stage.filter((obj) => obj instanceof Section)) {\n      if (sectionNameSet.has(section.text)) {\n        res[section.text] = section;\n      }\n    }\n    return res;\n  }\n\n  /**\n   * 更新当前项目中的一个Section的引用信息\n   * @param recentFiles\n   * @param sectionName\n   */\n  public async updateOneSectionReferenceInfo(recentFiles: RecentFileManager.RecentFile[], sectionName: string) {\n    const fileNameList = this.project.references.sections[sectionName];\n    const fileNameListNew = [];\n    for (const fileName of fileNameList) {\n      const file = recentFiles.find(\n        (file) =>\n          PathString.getFileNameFromPath(file.uri.path) === fileName ||\n          PathString.getFileNameFromPath(file.uri.fsPath) === fileName,\n      );\n      if (file) {\n        // 即使文件存在，也要打开看一看引用块是否在那个文件中。\n        const thatProject = new Project(file.uri);\n        loadAllServicesBeforeInit(thatProject);\n        await thatProject.init();\n        if (\n          this.checkReferenceBlockInProject(\n            thatProject,\n            PathString.getFileNameFromPath(this.project.uri.path),\n            sectionName,\n          )\n        ) {\n          fileNameListNew.push(fileName);\n        } else {\n          toast.warning(`文件 ${fileName} 中不再引用 ${sectionName}，已从引用列表中移除`);\n        }\n        thatProject.dispose();\n      }\n    }\n    if (fileNameListNew.length === 0) {\n      // 直接把这个章节从引用列表中删除\n      delete this.project.references.sections[sectionName];\n    } else {\n      this.project.references.sections[sectionName] = fileNameListNew;\n    }\n  }\n\n  /**\n   * 更新当前项目的引用信息\n   * （清理无效的引用）\n   */\n  public async updateCurrentProjectReference() {\n    const recentFiles = await RecentFileManager.getRecentFiles();\n\n    // 遍历当前项目的每一个被引用的Section框\n    for (const sectionName in this.project.references.sections) {\n      await this.updateOneSectionReferenceInfo(recentFiles, sectionName);\n    }\n\n    // 遍历每一个直接引用自己整个文件的文件\n    const fileNameListNew = [];\n    for (const fileName of this.project.references.files) {\n      const file = recentFiles.find(\n        (file) =>\n          PathString.getFileNameFromPath(file.uri.path) === fileName ||\n          PathString.getFileNameFromPath(file.uri.fsPath) === fileName,\n      );\n      if (file) {\n        // 即使文件存在，也要打开看一看引用块是否在那个文件中。\n        const thatProject = new Project(file.uri);\n        loadAllServicesBeforeInit(thatProject);\n        await thatProject.init();\n        if (this.checkReferenceBlockInProject(thatProject, fileName, \"\")) {\n          fileNameListNew.push(fileName);\n        }\n        thatProject.dispose();\n      }\n    }\n    this.project.references.files = fileNameListNew;\n  }\n\n  public checkReferenceBlockInProject(project: Project, fileName: string, sectionName: string) {\n    const referenceBlocks = project.stage\n      .filter((object) => object instanceof ReferenceBlockNode)\n      .filter(\n        (referenceBlockNode) =>\n          referenceBlockNode.fileName === fileName && referenceBlockNode.sectionName === sectionName,\n      );\n    if (referenceBlocks.length > 0) {\n      return true;\n    }\n    return false;\n  }\n\n  public async insertRefDataToSourcePrgFile(fileName: string, sectionName: string) {\n    // 更新被引用文件的reference.msgpack\n    const currentFileName = PathString.getFileNameFromPath(this.project.uri.path);\n    if (!currentFileName) return;\n\n    try {\n      // 根据文件名查找被引用文件\n      const recentFiles = await RecentFileManager.getRecentFiles();\n      const referencedFile = recentFiles.find(\n        (file) =>\n          PathString.getFileNameFromPath(file.uri.path) === fileName ||\n          PathString.getFileNameFromPath(file.uri.fsPath) === fileName,\n      );\n      if (!referencedFile) return;\n\n      // 先检查当前是否已经打开了该文件的Project实例\n      const allProjects = store.get(projectsAtom);\n      let referencedProject = allProjects.find((project) => {\n        const projectFileName = PathString.getFileNameFromPath(project.uri.path);\n        const projectFileNameFs = PathString.getFileNameFromPath(project.uri.fsPath);\n        return projectFileName === fileName || projectFileNameFs === fileName;\n      });\n\n      // 如果没有打开，则创建新的Project实例\n      let shouldDisposeProject = false;\n      if (!referencedProject) {\n        referencedProject = new Project(referencedFile.uri);\n        loadAllServicesBeforeInit(referencedProject);\n        await referencedProject.init();\n        shouldDisposeProject = true;\n      }\n\n      // 更新引用\n      if (sectionName) {\n        // 引用特定Section的情况\n        if (!referencedProject.references.sections[sectionName]) {\n          referencedProject.references.sections[sectionName] = [];\n        }\n\n        // 确保数组中没有重复的文件名\n        const index = referencedProject.references.sections[sectionName].indexOf(currentFileName);\n        if (index === -1) {\n          referencedProject.references.sections[sectionName].push(currentFileName);\n          // 保存更新\n          await referencedProject.save();\n        }\n      } else {\n        // 引用整个文件的情况\n        if (!referencedProject.references.files) {\n          referencedProject.references.files = [];\n        }\n\n        // 确保数组中没有重复的文件名\n        const index = referencedProject.references.files.indexOf(currentFileName);\n        if (index === -1) {\n          referencedProject.references.files.push(currentFileName);\n          // 保存更新\n          await referencedProject.save();\n        }\n      }\n\n      // 只有在我们创建的情况下才需要dispose\n      if (shouldDisposeProject) {\n        await referencedProject.dispose();\n      }\n    } catch (error) {\n      toast.error(\"更新reference.msgpack失败：\" + String(error));\n    }\n  }\n\n  /**\n   * 从源头 跳转到引用位置\n   * @param section\n   */\n  public async jumpToReferenceLocation(fileName: string, referenceBlockNodeSectionName: string) {\n    const recentFiles = await RecentFileManager.getRecentFiles();\n    const file = recentFiles.find(\n      (file) =>\n        PathString.getFileNameFromPath(file.uri.path) === fileName ||\n        PathString.getFileNameFromPath(file.uri.fsPath) === fileName,\n    );\n    if (!file) {\n      toast.error(`文件 ${fileName} 未找到`);\n      return;\n    }\n    const project = await onOpenFile(file.uri, \"ReferencesWindow跳转打开-prg文件\");\n    // 从被引用的源头，跳转到引用的地方\n    if (project && referenceBlockNodeSectionName) {\n      setTimeout(() => {\n        const referenceBlockNode = project.stage\n          .filter((o) => o instanceof ReferenceBlockNode)\n          .find((o) => o.sectionName === referenceBlockNodeSectionName);\n        if (referenceBlockNode) {\n          const center = referenceBlockNode.collisionBox.getRectangle().center;\n          project.camera.location = center;\n          // 加一个特效\n          project.effects.addEffect(RectangleLittleNoteEffect.fromUtilsSlowNote(referenceBlockNode));\n        } else {\n          toast.error(`没有找到引用标题为 “${referenceBlockNodeSectionName}” 的引用块节点`);\n        }\n      }, 100);\n    }\n  }\n\n  private openSectionReferencePanel(section: Section) {\n    // 打开这个详细信息的引用弹窗\n    SectionReferencePanel.open(\n      this.project.uri,\n      section.text,\n      this.project.renderer.transformWorld2View(section.rectangle.leftTop),\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageManager/concreteMethods/StageSectionInOutManager.tsx",
    "content": "import { Project, service } from \"@/core/Project\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { Edge } from \"@/core/stage/stageObject/association/Edge\";\nimport { MultiTargetUndirectedEdge } from \"@/core/stage/stageObject/association/MutiTargetUndirectedEdge\";\nimport { CollisionBox } from \"../../stageObject/collisionBox/collisionBox\";\n\n/**\n * 管理所有东西进出StageSection的逻辑\n */\n@service(\"sectionInOutManager\")\nexport class SectionInOutManager {\n  constructor(private readonly project: Project) {}\n\n  goInSection(entities: Entity[], section: Section) {\n    for (const entity of entities) {\n      if (section.children.includes(entity)) {\n        // 已经在section里面了，不用再次进入\n        continue;\n      }\n      if (entity === section) {\n        // 自己不能包自己\n        continue;\n      }\n      section.children.push(entity);\n    }\n    this.project.stageManager.updateReferences();\n  }\n\n  /**\n   * 一些实体跳入多个Section（交叉嵌套）\n   * 会先解除所有实体与Section的关联，再重新关联\n   * @param entities\n   * @param sections\n   */\n  goInSections(entities: Entity[], sections: Section[]) {\n    // 先解除所有实体与Section的关联\n    for (const entity of entities) {\n      this.entityDropParent(entity);\n    }\n    // 再重新关联\n    for (const section of sections) {\n      this.goInSection(entities, section);\n    }\n  }\n\n  goOutSection(entities: Entity[], section: Section) {\n    for (const entity of entities) {\n      this.sectionDropChild(section, entity);\n    }\n    this.project.stageManager.updateReferences();\n  }\n\n  private entityDropParent(entity: Entity) {\n    for (const section of this.project.stageManager.getSections()) {\n      if (section.children.includes(entity)) {\n        this.sectionDropChild(section, entity);\n      }\n    }\n  }\n\n  /**\n   * Section 丢弃某个孩子\n   * @param section\n   * @param entity\n   */\n  private sectionDropChild(section: Section, entity: Entity) {\n    const newChildrenUUID: string[] = [];\n    const newChildren: Entity[] = [];\n    for (const child of section.children) {\n      if (entity.uuid !== child.uuid) {\n        newChildrenUUID.push(child.uuid);\n        newChildren.push(child);\n      }\n    }\n    section.children = newChildren;\n\n    // 当section的最后一个子元素被移除时，将section转换为TextNode\n    if (section.children.length === 0) {\n      this.convertSectionToTextNode(section);\n    }\n  }\n\n  /**\n   * 将section转换为TextNode，保持UUID、详细信息和连线关系不变\n   * @param section 要转换的section\n   */\n  private convertSectionToTextNode(section: Section) {\n    // 获取section的父级section\n    const fatherSections = this.project.sectionMethods.getFatherSections(section);\n\n    // 先从父 section 的 children 中移除旧的 section 引用（直接操作数组，避免触发 sectionDropChild 的连锁反应）\n    for (const fatherSection of fatherSections) {\n      fatherSection.children = fatherSection.children.filter((child) => child.uuid !== section.uuid);\n    }\n\n    // 创建新的TextNode，保持UUID不变\n    const textNode = new TextNode(this.project, {\n      uuid: section.uuid, // 保持UUID不变\n      text: section.text,\n      details: section.details,\n      collisionBox: new CollisionBox([section.collisionBox.getRectangle()]),\n      color: section.color.clone(),\n    });\n\n    // 将新的TextNode添加到舞台\n    this.project.stageManager.add(textNode);\n\n    // 将新的TextNode添加到父section中\n    for (const fatherSection of fatherSections) {\n      this.project.sectionInOutManager.goInSection([textNode], fatherSection);\n    }\n\n    // 处理所有连向section的边\n    for (const edge of this.project.stageManager.getAssociations()) {\n      if (edge instanceof Edge) {\n        // 处理有向边\n        if (edge.target.uuid === section.uuid) {\n          edge.target = textNode;\n        }\n        if (edge.source.uuid === section.uuid) {\n          edge.source = textNode;\n        }\n      } else if (edge instanceof MultiTargetUndirectedEdge) {\n        // 处理无向边\n        for (let i = 0; i < edge.associationList.length; i++) {\n          if (edge.associationList[i].uuid === section.uuid) {\n            edge.associationList[i] = textNode;\n          }\n        }\n      }\n    }\n\n    // 从舞台中删除原section\n    this.project.stageManager.deleteEntities([section]);\n\n    // 更新引用\n    this.project.stageManager.updateReferences();\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageManager/concreteMethods/StageSectionPackManager.tsx",
    "content": "// import { Section } from \"@/core/stageObject/entity/Section\";\n// import { Entity } from \"@/core/stageObject/StageEntity\";\nimport { Project, service } from \"@/core/Project\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { Edge } from \"@/core/stage/stageObject/association/Edge\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { ConnectPoint } from \"@/core/stage/stageObject/entity/ConnectPoint\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { toast } from \"sonner\";\nimport { v4 } from \"uuid\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { ConnectableEntity } from \"../../stageObject/abstract/ConnectableEntity\";\nimport { SoundService } from \"@/core/service/feedbackService/SoundService\";\n\n/**\n * 管理所有东西进出StageSection的逻辑\n */\n@service(\"sectionPackManager\")\nexport class SectionPackManager {\n  constructor(private readonly project: Project) {}\n\n  /** 折叠起来 */\n  packSection(): void {\n    for (const section of this.project.stageManager.getSections()) {\n      if (!section.isSelected) {\n        continue;\n      }\n      this.modifyHiddenDfs(section, true);\n      section.isCollapsed = true;\n    }\n    this.project.stageManager.updateReferences();\n  }\n\n  /**\n   * 由于复层折叠，引起所有子节点的被隐藏状态发生改变\n   * @param section\n   * @param isCollapsed\n   */\n  private modifyHiddenDfs(section: Section, isCollapsed: boolean) {\n    // section.isCollapsed = isCollapsed;\n    for (const childEntity of section.children) {\n      if (childEntity instanceof Section) {\n        this.modifyHiddenDfs(childEntity, isCollapsed);\n      }\n      childEntity.isHiddenBySectionCollapse = isCollapsed;\n    }\n  }\n\n  /** 展开 */\n  unpackSection(): void {\n    for (const section of this.project.stageManager.getSections()) {\n      if (!section.isSelected) {\n        continue;\n      }\n      this.modifyHiddenDfs(section, false);\n      section.isCollapsed = false;\n    }\n    this.project.stageManager.updateReferences();\n  }\n\n  switchCollapse(): void {\n    for (const section of this.project.stageManager.getSections()) {\n      if (!section.isSelected) {\n        continue;\n      }\n      if (section.isCollapsed) {\n        this.unpackSection();\n      } else {\n        this.packSection();\n      }\n    }\n  }\n\n  /**\n   * 将所有选中的节点当场转换成Section\n   */\n  textNodeToSection(): void {\n    for (const textNode of this.project.stageManager.getTextNodes()) {\n      if (!textNode.isSelected) {\n        continue;\n      }\n      this.targetTextNodeToSection(textNode, false, true);\n    }\n    this.project.historyManager.recordStep();\n  }\n\n  /**\n   * 将节点树转换成嵌套集合 （递归的）\n   */\n  textNodeTreeToSection(rootNode: TextNode): void {\n    if (!this.project.graphMethods.isTree(rootNode)) {\n      toast.error(\"请选择一个树状结构的节点作为根节点\");\n      return;\n    }\n    const dfs = (node: TextNode): Section | TextNode => {\n      const childNodes = this.project.graphMethods.nodeChildrenArray(node).filter((node) => node instanceof TextNode);\n      if (childNodes.length === 0) {\n        return node;\n      }\n      const childEntityList = [];\n      for (const childNode of childNodes) {\n        const transEntity = dfs(childNode);\n        childEntityList.push(transEntity);\n\n        const edges = this.project.graphMethods.getEdgesBetween(node, childNode);\n        for (const edge of edges) {\n          this.project.stageManager.deleteEdge(edge);\n        }\n      }\n      const section = this.targetTextNodeToSection(node, true);\n\n      this.project.sectionInOutManager.goInSection(childEntityList, section);\n      return section;\n    };\n    dfs(rootNode);\n    this.project.historyManager.recordStep();\n  }\n\n  /**\n   * 非递归的 将节点树转换成嵌套集合\n   * @param rootNode\n   */\n  textNodeTreeToSectionNoDeep(rootNode: TextNode): void {\n    if (!this.project.graphMethods.isTree(rootNode)) {\n      toast.error(\"请选择一个树状结构的节点作为根节点\");\n      return;\n    }\n    const childNodes = this.project.graphMethods.nodeChildrenArray(rootNode).filter((node) => node instanceof TextNode);\n    const childSets = this.project.graphMethods.getSuccessorSet(rootNode, true);\n    if (childNodes.length === 0) {\n      return;\n    }\n\n    for (const childNode of childNodes) {\n      const edges = this.project.graphMethods.getEdgesBetween(rootNode, childNode);\n      for (const edge of edges) {\n        this.project.stageManager.deleteEdge(edge);\n      }\n    }\n    const section = this.targetTextNodeToSection(rootNode, true);\n    const rootNodeFatherSection = this.project.sectionMethods.getFatherSections(rootNode);\n    for (const fatherSection of rootNodeFatherSection) {\n      this.project.sectionInOutManager.goOutSection(childSets, fatherSection);\n    }\n    this.project.sectionInOutManager.goInSection(childSets, section);\n    this.project.historyManager.recordStep();\n  }\n\n  /**\n   * 将指定的文本节点转换成Section，自动删除原来的TextNode\n   * @param textNode 要转换的节点\n   * @param ignoreEdges 是否忽略边的影响\n   * @param addConnectPoints 是否添加质点\n   */\n  targetTextNodeToSection(\n    textNode: TextNode,\n    ignoreEdges: boolean = false,\n    addConnectPoints: boolean = false,\n  ): Section {\n    // 获取这个节点的父级Section\n    const fatherSections = this.project.sectionMethods.getFatherSections(textNode);\n    const newSection = new Section(this.project, {\n      text: textNode.text,\n      collisionBox: textNode.collisionBox,\n      color: textNode.color,\n      details: textNode.details,\n    });\n    newSection.adjustLocationAndSize();\n\n    // 创建左上角和右下角的质点\n    if (addConnectPoints) {\n      const radius = ConnectPoint.CONNECT_POINT_SHRINK_RADIUS;\n      const sectionRectangle = newSection.collisionBox.getRectangle();\n\n      // 左上角质点\n      const topLeftLocation = sectionRectangle.location.clone();\n      const topLeftCollisionBox = new CollisionBox([new Rectangle(topLeftLocation, Vector.same(radius * 2))]);\n      const topLeftPoint = new ConnectPoint(this.project, {\n        collisionBox: topLeftCollisionBox,\n      });\n\n      // 右下角质点\n      const bottomRightLocation = sectionRectangle.location\n        .clone()\n        .add(new Vector(sectionRectangle.size.x - radius * 2, sectionRectangle.size.y - radius * 2));\n      const bottomRightCollisionBox = new CollisionBox([new Rectangle(bottomRightLocation, Vector.same(radius * 2))]);\n      const bottomRightPoint = new ConnectPoint(this.project, {\n        collisionBox: bottomRightCollisionBox,\n      });\n\n      // 将质点添加到舞台\n      this.project.stageManager.add(topLeftPoint);\n      this.project.stageManager.add(bottomRightPoint);\n\n      // 将质点放入Section\n      this.project.sectionInOutManager.goInSection([topLeftPoint, bottomRightPoint], newSection);\n    }\n\n    // 将新的Section加入舞台\n    this.project.stageManager.add(newSection);\n    for (const fatherSection of fatherSections) {\n      this.project.sectionInOutManager.goInSection([newSection], fatherSection);\n    }\n\n    if (!ignoreEdges) {\n      for (const edge of this.project.stageManager.getAssociations()) {\n        if (edge instanceof Edge) {\n          if (edge.target.uuid === textNode.uuid) {\n            edge.target = newSection;\n          }\n          if (edge.source.uuid === textNode.uuid) {\n            edge.source = newSection;\n          }\n        }\n      }\n    }\n    // 删除原来的textNode\n    this.project.stageManager.deleteEntities([textNode]);\n    // 更新section的碰撞箱\n    newSection.adjustLocationAndSize();\n    return newSection;\n  }\n\n  /**\n   * 拆包操作\n   */\n  unpackSelectedSections() {\n    const selectedSections = this.project.stageManager.getSelectedEntities();\n    this.unpackSections(selectedSections);\n    this.project.historyManager.recordStep();\n  }\n\n  /**\n   * 打包的反操作：拆包\n   * @param entities 要拆包的实体\n   * 如果选择了section内部一层的实体，则父section脱离剥皮，变成一个textNode\n   * 如果选择的是一个section，则其本身脱离剥皮，变成一个textNode，内部内容掉落出来。\n   */\n  private unpackSections(entities: Entity[]) {\n    if (entities.length === 0) return;\n    // 目前先仅支持选中section后再进行拆包操作\n    const sections = entities.filter((entity) => entity instanceof Section);\n    if (sections.length === 0) {\n      toast.error(\"请选择一个section\");\n      return;\n    }\n    for (const section of sections) {\n      const currentSectionFathers = this.project.sectionMethods.getFatherSections(section);\n      // 生成一个textnode\n      const sectionLocation = section.collisionBox.getRectangle().location;\n      const textNode = new TextNode(this.project, {\n        uuid: v4(),\n        text: section.text,\n        details: section.details,\n        collisionBox: new CollisionBox([new Rectangle(sectionLocation.clone(), Vector.getZero())]),\n        color: section.color.clone(),\n      });\n      // 将textNode添加到舞台\n      this.project.stageManager.add(textNode);\n      // 将新的textnode添加到父section中\n      this.project.sectionInOutManager.goInSections([textNode], currentSectionFathers);\n      // 将section的子节点添加到父section中\n      this.project.sectionInOutManager.goInSections(section.children, currentSectionFathers);\n      // 将section从舞台中删除\n      this.project.stageManager.deleteEntities([section]);\n    }\n  }\n\n  /** 将多个实体打包成一个section，并添加到舞台中 */\n  async packEntityToSection(addEntities: Entity[]) {\n    if (addEntities.length === 0) {\n      return;\n    }\n    addEntities = this.project.sectionMethods.shallowerNotSectionEntities(addEntities);\n    // 检测父亲section是否是等同\n    const firstParents = this.project.sectionMethods.getFatherSections(addEntities[0]);\n    if (addEntities.length > 1) {\n      let isAllSameFather = true;\n\n      for (let i = 1; i < addEntities.length; i++) {\n        const secondParents = this.project.sectionMethods.getFatherSections(addEntities[i]);\n        if (firstParents.length !== secondParents.length) {\n          isAllSameFather = false;\n          break;\n        }\n        // 检查父亲数组是否相同\n        const firstParentsString = firstParents\n          .map((section: any) => section.uuid)\n          .sort()\n          .join();\n        const secondParentsString = secondParents\n          .map((section: any) => section.uuid)\n          .sort()\n          .join();\n        if (firstParentsString !== secondParentsString) {\n          isAllSameFather = false;\n          break;\n        }\n      }\n\n      if (!isAllSameFather) {\n        // 暂时不支持交叉section的创建\n        toast.error(\"选中的实体不在同一层级下，暂时不鼓励交叉section的直接打包型创建\");\n        return;\n      }\n    }\n    for (const fatherSection of firstParents) {\n      this.project.stageManager.goOutSection(addEntities, fatherSection);\n    }\n    const section = Section.fromEntities(this.project, addEntities);\n    let smartTitle = this.getSmartSectionTitle(addEntities);\n    if (smartTitle.length > 10) {\n      smartTitle = smartTitle.slice(0, 10) + \"...\";\n    }\n    section.text =\n      smartTitle.length > 0\n        ? smartTitle\n        : this.project.stageUtils.replaceAutoNameTemplate(Settings.autoNamerSectionTemplate, section);\n    this.project.stageManager.add(section);\n    for (const fatherSection of firstParents) {\n      this.project.stageManager.goInSection([section], fatherSection);\n    }\n  }\n\n  /**\n   * 从框选区域创建Section，并在左上角和右下角添加质点\n   */\n  createSectionFromSelectionRectangle(): void {\n    const rectangleSelect = this.project.rectangleSelect;\n    const selectionRectangle = rectangleSelect.getRectangle();\n\n    if (!selectionRectangle) {\n      return;\n    }\n\n    // 创建空的Section\n    const collisionBox = new CollisionBox([selectionRectangle.clone()]);\n    const section = new Section(this.project, {\n      text: \"section\",\n      collisionBox: collisionBox,\n      children: [],\n      isCollapsed: false,\n      locked: false,\n    });\n\n    // 创建左上角和右下角的质点\n    const radius = ConnectPoint.CONNECT_POINT_SHRINK_RADIUS;\n\n    // 左上角质点\n    const topLeftLocation = selectionRectangle.location.clone();\n    const topLeftCollisionBox = new CollisionBox([new Rectangle(topLeftLocation, Vector.same(radius * 2))]);\n    const topLeftPoint = new ConnectPoint(this.project, {\n      collisionBox: topLeftCollisionBox,\n    });\n\n    // 右下角质点\n    const bottomRightLocation = selectionRectangle.location\n      .clone()\n      .add(new Vector(selectionRectangle.size.x - radius * 2, selectionRectangle.size.y - radius * 2));\n    const bottomRightCollisionBox = new CollisionBox([new Rectangle(bottomRightLocation, Vector.same(radius * 2))]);\n    const bottomRightPoint = new ConnectPoint(this.project, {\n      collisionBox: bottomRightCollisionBox,\n    });\n\n    // 将质点添加到舞台\n    this.project.stageManager.add(topLeftPoint);\n    this.project.stageManager.add(bottomRightPoint);\n\n    // 将Section添加到舞台\n    this.project.stageManager.add(section);\n    // 重命名 Section\n    // 此处未生效，以后再排查\n    // setTimeout(() => {\n    //   this.project.stageUtils.replaceAutoNameTemplate(Settings.autoNamerSectionTemplate, section);\n    // });\n\n    // 将质点放入Section\n    this.project.stageManager.goInSection([topLeftPoint, bottomRightPoint], section);\n\n    // 清空矩形框\n    rectangleSelect.shutDown();\n\n    // 记录历史步骤\n    this.project.historyManager.recordStep();\n  }\n\n  /**\n   * 将选中的实体打包成Section\n   */\n  packSelectedEntitiesToSection(): void {\n    const selectedEntities = this.project.stageManager.getEntities().filter((entity) => entity.isSelected);\n    if (selectedEntities.length > 0) {\n      this.packEntityToSection(selectedEntities);\n      SoundService.play.packEntityToSectionSoundFile();\n    }\n  }\n\n  /**\n   * 获取一个智能的Section标题，如果Section内是树形结构\n   * @param addEntities\n   * @returns\n   */\n  private getSmartSectionTitle(addEntities: Entity[]): string {\n    // 只看所有的可连接节点，涂鸦之类的直接忽略\n    const connectableEntities = addEntities.filter((e) => e instanceof ConnectableEntity);\n    if (connectableEntities.length === 0) return \"\";\n\n    // 必须构成树形结构\n    if (!this.project.graphMethods.isTreeByNodes(connectableEntities)) return \"\";\n\n    const root = this.project.graphMethods.getTreeRootByNodes(connectableEntities);\n    if (!root || !(root instanceof TextNode)) return \"\";\n    return root.text;\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageManager/concreteMethods/StageTagManager.tsx",
    "content": "import { Color, ProgressNumber } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { Project, service } from \"@/core/Project\";\nimport { LineCuttingEffect } from \"@/core/service/feedbackService/effectEngine/concrete/LineCuttingEffect\";\nimport { RectangleNoteEffect } from \"@/core/service/feedbackService/effectEngine/concrete/RectangleNoteEffect\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { StageObject } from \"@/core/stage/stageObject/abstract/StageObject\";\nimport { Edge } from \"@/core/stage/stageObject/association/Edge\";\nimport { LineEdge } from \"@/core/stage/stageObject/association/LineEdge\";\nimport { ConnectPoint } from \"@/core/stage/stageObject/entity/ConnectPoint\";\nimport { ImageNode } from \"@/core/stage/stageObject/entity/ImageNode\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { UrlNode } from \"@/core/stage/stageObject/entity/UrlNode\";\nimport { MouseLocation } from \"@/core/service/controlService/MouseLocation\";\n\n/**\n * 标签管理器\n */\n@service(\"tagManager\")\nexport class TagManager {\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 和project.tags同步\n   * 用于提高性能\n   * 不要在外界修改\n   */\n  tagSet: Set<string> = new Set();\n\n  reset(uuids: string[]) {\n    this.project.tags = [];\n    for (const uuid of uuids) {\n      this.project.tags.push(uuid);\n      this.tagSet.add(uuid);\n    }\n  }\n\n  addTag(uuid: string) {\n    this.project.tags.push(uuid);\n    this.tagSet.add(uuid);\n  }\n\n  removeTag(uuid: string) {\n    const index = this.project.tags.indexOf(uuid);\n    if (index !== -1) {\n      this.project.tags.splice(index, 1);\n      this.tagSet.delete(uuid);\n    }\n  }\n\n  /**\n   * O(1)查询某uuid是否是标签\n   * @param uuid\n   * @returns\n   */\n  hasTag(uuid: string): boolean {\n    return this.tagSet.has(uuid);\n  }\n\n  /**\n   * 清理未引用的标签\n   */\n  updateTags() {\n    const uuids = this.project.tags.slice();\n    for (const uuid of uuids) {\n      if (!this.project.stage.some((stageObject) => stageObject.uuid === uuid)) {\n        this.project.tags.splice(this.project.tags.indexOf(uuid), 1);\n        this.tagSet.delete(uuid);\n      }\n    }\n  }\n\n  moveUpTag(uuid: string) {\n    const index = this.project.tags.indexOf(uuid);\n    if (index !== -1 && index > 0) {\n      const temp = this.project.tags[index - 1];\n      this.project.tags[index - 1] = uuid;\n      this.project.tags[index] = temp;\n    }\n  }\n\n  moveDownTag(uuid: string) {\n    const index = this.project.tags.indexOf(uuid);\n    if (index !== -1 && index < this.project.tags.length - 1) {\n      const temp = this.project.tags[index + 1];\n      this.project.tags[index + 1] = uuid;\n      this.project.tags[index] = temp;\n    }\n  }\n\n  /**\n   * 将所有选择的实体添加或移除标签\n   *\n   * 目前先仅支持TextNode\n   */\n  changeTagBySelected() {\n    for (const selectedEntities of this.project.stageManager.getSelectedStageObjects()) {\n      // 若有则删，若无则加\n      if (this.hasTag(selectedEntities.uuid)) {\n        this.removeTag(selectedEntities.uuid);\n      } else {\n        this.addTag(selectedEntities.uuid);\n      }\n    }\n  }\n\n  /**\n   * 用于ui渲染\n   * @returns 所有标签对应的名字\n   */\n  refreshTagNamesUI() {\n    const res: { tagName: string; uuid: string; color: [number, number, number, number] }[] = [];\n    const tagObjectList: StageObject[] = this.project.tags\n      .map((tagUUID) => this.project.stageManager.get(tagUUID))\n      .filter((stageObject): stageObject is StageObject => stageObject !== undefined);\n\n    for (const tagObject of tagObjectList) {\n      let title = \"\";\n      let colorItem: [number, number, number, number] = [0, 0, 0, 0];\n      if (tagObject instanceof TextNode) {\n        title = tagObject.text;\n        colorItem = tagObject.color.toArray();\n      } else if (tagObject instanceof Section) {\n        title = tagObject.text;\n        colorItem = tagObject.color.toArray();\n      } else if (tagObject instanceof UrlNode) {\n        title = tagObject.title;\n      } else if (tagObject instanceof ImageNode) {\n        title = \"Image: \" + tagObject.uuid.slice(0, 4);\n      } else if (tagObject instanceof Edge) {\n        title = tagObject.text.slice(0, 20).trim();\n        if (title.length === 0) {\n          title = \"未命名连线\";\n        }\n        if (tagObject instanceof LineEdge) {\n          colorItem = tagObject.color.toArray();\n        }\n      } else if (tagObject instanceof ConnectPoint) {\n        title = \"Connect Point: \" + tagObject.uuid.slice(0, 4);\n      } else {\n        title = \"Unknown: \" + tagObject.uuid.slice(0, 4);\n      }\n      res.push({ tagName: title, uuid: tagObject.uuid, color: colorItem });\n    }\n    return res;\n  }\n\n  /**\n   * 跳转到标签位置\n   * @param tagUUID\n   * @returns\n   */\n  moveCameraToTag(tagUUID: string) {\n    const tagObject = this.project.stageManager.get(tagUUID);\n    if (!tagObject) {\n      return;\n    }\n    if (tagObject instanceof ConnectableEntity) {\n      const childNodes = this.project.graphMethods.getSuccessorSet(tagObject);\n      const boundingRect = Rectangle.getBoundingRectangle(\n        childNodes.map((childNode) => childNode.collisionBox.getRectangle()),\n      );\n      this.project.camera.resetByRectangle(boundingRect);\n      this.project.effects.addEffect(\n        new LineCuttingEffect(\n          new ProgressNumber(0, 10),\n          this.project.renderer.transformView2World(MouseLocation.vector()),\n          tagObject.collisionBox.getRectangle().center,\n          Color.Green,\n          Color.Green,\n        ),\n      );\n      this.project.effects.addEffect(\n        new RectangleNoteEffect(\n          new ProgressNumber(0, 30),\n          boundingRect,\n          this.project.stageStyleManager.currentStyle.CollideBoxPreSelected,\n        ),\n      );\n    } else {\n      const location = tagObject.collisionBox.getRectangle().center;\n      this.project.camera.location = location;\n      this.project.effects.addEffect(\n        new LineCuttingEffect(\n          new ProgressNumber(0, 10),\n          this.project.renderer.transformView2World(MouseLocation.vector()),\n          location,\n          Color.Green,\n          Color.Green,\n        ),\n      );\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageManager/concreteMethods/stageNodeRotate.tsx",
    "content": "import { Color, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { Project, service } from \"@/core/Project\";\nimport { LineEffect } from \"@/core/service/feedbackService/effectEngine/concrete/LineEffect\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { Settings } from \"@/core/service/Settings\";\n\n/**\n * 所有和旋转相关的操作\n */\n@service(\"stageNodeRotate\")\nexport class StageNodeRotate {\n  constructor(private readonly project: Project) {}\n\n  /**\n   * 通过拖拽边的方式来旋转节点\n   * 会查找所有选中的边，但只能旋转一个边\n   * @param lastMoveLocation\n   * @param diffLocation\n   */\n  moveEdges(lastMoveLocation: Vector, diffLocation: Vector) {\n    if (!Settings.enableDragEdgeRotateStructure) {\n      return;\n    }\n    for (const edge of this.project.stageManager.getLineEdges()) {\n      if (edge.isSelected) {\n        const startMouseDragLocation = lastMoveLocation.clone();\n        const endMouseDragLocation = startMouseDragLocation.add(diffLocation);\n        const vectorStart = startMouseDragLocation.subtract(edge.source.geometryCenter);\n        const vectorEnd = endMouseDragLocation.subtract(edge.source.geometryCenter);\n        let degrees = vectorStart.angleToSigned(vectorEnd);\n        // degrees一直是正数\n        if (Number.isNaN(degrees)) {\n          degrees = 0;\n        }\n        const sourceEntity = this.project.stageManager.getConnectableEntityByUUID(edge.source.uuid);\n        const targetEntity = this.project.stageManager.getConnectableEntityByUUID(edge.target.uuid);\n\n        if (sourceEntity && targetEntity) {\n          this.rotateNodeDfs(\n            this.project.stageManager.getConnectableEntityByUUID(edge.source.uuid)!,\n            this.project.stageManager.getConnectableEntityByUUID(edge.target.uuid)!,\n            degrees,\n            [edge.source.uuid],\n          );\n        } else {\n          console.error(\"source or target entity not found\");\n        }\n        break; // 只旋转一个边\n      }\n    }\n  }\n\n  /**\n   *\n   * @param rotateCenterNode 递归开始的节点\n   * @param currentNode 当前递归遍历到的节点\n   * @param degrees 旋转角度\n   * @param visitedUUIDs 已经访问过的节点的uuid列表，用于避免死循环\n   */\n  rotateNodeDfs(\n    rotateCenterNode: ConnectableEntity,\n    currentNode: ConnectableEntity,\n    degrees: number,\n    visitedUUIDs: string[],\n  ): void {\n    const rotateCenterLocation = rotateCenterNode.geometryCenter;\n    // 先旋转自己\n\n    const centerToChildVector = currentNode.geometryCenter.subtract(rotateCenterLocation);\n\n    const centerToChildVectorRotated = centerToChildVector.rotateDegrees(degrees);\n\n    this.project.entityMoveManager.moveEntityUtils(\n      currentNode,\n      centerToChildVectorRotated.subtract(centerToChildVector),\n    );\n    // 再旋转子节点\n    for (const child of this.project.graphMethods.nodeChildrenArray(currentNode)) {\n      if (visitedUUIDs.includes(child.uuid)) {\n        continue;\n      }\n      visitedUUIDs.push(child.uuid);\n      const childNode = this.project.stageManager.getConnectableEntityByUUID(child.uuid);\n      if (!childNode) {\n        console.error(\"child node not found\");\n        continue;\n      }\n      const midPoint = Vector.fromTwoPointsCenter(currentNode.geometryCenter, childNode.geometryCenter);\n\n      this.project.effects.addEffect(\n        new LineEffect(\n          new ProgressNumber(0, 20),\n          currentNode.geometryCenter,\n          midPoint,\n          new Color(255, 255, 255, 0),\n          new Color(255, 255, 255, 0.5),\n          Math.abs(degrees),\n        ),\n      );\n      this.project.effects.addEffect(\n        new LineEffect(\n          new ProgressNumber(0, 20),\n          midPoint,\n          childNode.geometryCenter,\n          new Color(255, 255, 255, 0.5),\n          new Color(255, 255, 255, 0),\n          Math.abs(degrees),\n        ),\n      );\n\n      this.rotateNodeDfs(\n        rotateCenterNode,\n        // 2024年10月6日：发现打开文件后，旋转节点无法带动子树，只能传递一层。\n        // child,\n        childNode,\n        degrees,\n        // visitedUUIDs.concat(currentNode.uuid),\n        visitedUUIDs,\n      );\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageObject/README.md",
    "content": "# StageObject\n\n说明：此文件内都是一些定义基础的数据类型和概念、表示了舞台中的各个基础的元素。\n\n这些基础的元素之间又有各种的相互继承关系，形成一个继承体系。\n\n可以详见docs-pg里有一个继承体系图，是经过思考和讨论后的产物。\n\n此文件夹中不包含各种具体的、实用的、高级的功能和业务。\n"
  },
  {
    "path": "app/src/core/stage/stageObject/abstract/Association.tsx",
    "content": "import { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { StageObject } from \"@/core/stage/stageObject/abstract/StageObject\";\nimport { Color } from \"@graphif/data-structures\";\nimport { serializable } from \"@graphif/serializer\";\n\n/**\n * 一切连接关系的抽象\n */\nexport abstract class Association extends StageObject {\n  @serializable\n  public associationList: StageObject[] = [];\n\n  /**\n   * 任何关系都应该有一个颜色用来标注\n   */\n  public color: Color = Color.Transparent;\n}\n\n/**\n * 一切可被连接的关联\n */\nexport abstract class ConnectableAssociation extends Association {\n  @serializable\n  public override associationList: ConnectableEntity[] = [];\n\n  public reverse() {\n    this.associationList.reverse();\n  }\n\n  get target(): ConnectableEntity {\n    return this.associationList[1];\n  }\n\n  set target(value: ConnectableEntity) {\n    this.associationList[1] = value;\n  }\n\n  get source(): ConnectableEntity {\n    return this.associationList[0];\n  }\n  set source(value: ConnectableEntity) {\n    this.associationList[0] = value;\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageObject/abstract/ConnectableEntity.tsx",
    "content": "import { Vector } from \"@graphif/data-structures\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\n\n/**\n * 一切可被Edge连接的东西，且会算入图分析算法的东西\n */\nexport abstract class ConnectableEntity extends Entity {\n  /**\n   * 几何中心点\n   * 用于联动旋转等算法\n   */\n  abstract geometryCenter: Vector;\n\n  public unknown = false;\n}\n"
  },
  {
    "path": "app/src/core/stage/stageObject/abstract/StageEntity.tsx",
    "content": "import { Settings } from \"@/core/service/Settings\";\nimport { StageObject } from \"@/core/stage/stageObject/abstract/StageObject\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { serializable } from \"@graphif/serializer\";\nimport { Circle, Rectangle } from \"@graphif/shapes\";\nimport type { Value } from \"platejs\";\nimport { DetailsManager } from \"../tools/entityDetailsManager\";\n/**\n * 一切独立存在、能被移动的东西，且放在框里能被连带移动的东西\n * 实体\n */\nexport abstract class Entity extends StageObject {\n  /**\n   * 将某个物体移动某个距离\n   * @param delta\n   */\n  abstract move(delta: Vector): void;\n\n  /**\n   * 是否忽略自动对齐功能\n   * 例如涂鸦就不吸附对齐\n   */\n  public isAlignExcluded = false;\n  /**\n   * 将某个物体移动到某个位置\n   * 注意：看的是最小外接矩形的左上角位置，不是中心位置\n   * @param location\n   */\n  abstract moveTo(location: Vector): void;\n\n  /**\n   * [\n   *  { type: 'p', children: [{ text: 'Serialize just this paragraph.' }] },\n   *  { type: 'h1', children: [{ text: 'And this heading.' }] }\n   * ]\n   */\n  @serializable\n  public details: Value = [];\n\n  /** 用于交互使用，比如鼠标悬浮显示details */\n  public isMouseHover: boolean = false;\n\n  public detailsButtonRectangle(): Rectangle {\n    const thisRectangle = this.collisionBox.getRectangle();\n    return new Rectangle(thisRectangle.rightTop.subtract(new Vector(10, 10)), new Vector(25, 25));\n  }\n  public isMouseInDetailsButton(mouseWorldLocation: Vector): boolean {\n    return this.detailsButtonRectangle().isPointIn(mouseWorldLocation);\n  }\n\n  public referenceButtonCircle(): Circle {\n    const thisRectangle = this.collisionBox.getRectangle();\n    return new Circle(thisRectangle.leftTop.subtract(new Vector(25, 25)), 25);\n  }\n  public isMouseInReferenceButton(mouseWorldLocation: Vector): boolean {\n    return this.referenceButtonCircle().isPointIn(mouseWorldLocation);\n  }\n\n  /**\n   * 由于自身位置的移动，递归的更新所有父级Section的位置和大小。\n   * 每次父框 adjustLocationAndSize 后，调用碰撞求解器推开与其重叠的同级分支。\n   */\n  protected updateFatherSectionByMove() {\n    const fatherSections = this.project.sectionMethods.getFatherSections(this);\n    for (const section of fatherSections) {\n      section.adjustLocationAndSize();\n      // 父框增大后，检测并推移与其重叠的同级 Section 分支\n      this.project.sectionCollisionSolver.solveOverlaps(section);\n      section.updateFatherSectionByMove();\n    }\n  }\n  /**\n   * 由于自身位置的更新，排开所有同级节点的位置\n   * 此函数在move函数中被调用，更新\n   */\n  protected updateOtherEntityLocationByMove() {\n    if (!Settings.isEnableEntityCollision) {\n      return;\n    }\n    for (const entity of this.project.stageManager.getEntities()) {\n      if (entity === this) {\n        continue;\n      }\n      this.collideWithOtherEntity(entity);\n    }\n  }\n\n  /**\n   * 与其他实体碰撞，调整位置；能够递归传递\n   * @param other 其他实体\n   */\n  protected collideWithOtherEntity(other: Entity) {\n    if (!Settings.isEnableEntityCollision) {\n      return;\n    }\n    const selfRectangle = this.collisionBox.getRectangle();\n    const otherRectangle = other.collisionBox.getRectangle();\n    if (!selfRectangle.isCollideWith(otherRectangle)) {\n      return;\n    }\n\n    // 两者相交，需要调整位置\n    const overlapSize = selfRectangle.getOverlapSize(otherRectangle);\n    let moveDelta;\n    if (Math.abs(overlapSize.x) < Math.abs(overlapSize.y)) {\n      moveDelta = new Vector(overlapSize.x * Math.sign(otherRectangle.center.x - selfRectangle.center.x), 0);\n    } else {\n      moveDelta = new Vector(0, overlapSize.y * Math.sign(otherRectangle.center.y - selfRectangle.center.y));\n    }\n    other.move(moveDelta);\n  }\n  /**\n   * 是不是因为所在的Section被折叠而隐藏了\n   * 因为任何Entity都可以放入Section\n   */\n  abstract isHiddenBySectionCollapse: boolean;\n\n  // 桥接模式，让详细信息的各种操作封装在外部类中\n  public detailsManager = new DetailsManager(this);\n}\n"
  },
  {
    "path": "app/src/core/stage/stageObject/abstract/StageObject.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\n\n/**\n * 注：关于舞台上的东西的这一部分的\n * 继承体系是 Rutubet 和 Littlefean 的讨论结果\n *\n */\n\n/**\n * 一切舞台上的东西\n * 都具有碰撞箱，uuid\n */\nexport abstract class StageObject {\n  protected abstract readonly project: Project;\n\n  // 舞台对象，必定有 uuid\n  public abstract uuid: string;\n\n  // 舞台对象，必定有碰撞箱\n  public abstract collisionBox: CollisionBox;\n\n  // 舞台对象，必定有选中状态\n  _isSelected: boolean = false;\n\n  public get isSelected(): boolean {\n    return this._isSelected;\n  }\n\n  public set isSelected(value: boolean) {\n    this._isSelected = value;\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageObject/abstract/StageObjectInterface.tsx",
    "content": "/**\n * 此文件记录各种关于舞台场景的特性\n */\n\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\n\nexport interface ResizeAble {\n  /**\n   * 拽住右下角的拖拽点拖拽，来改变大小\n   * @param delta\n   */\n  resizeHandle(delta: Vector): void;\n\n  /**\n   * 获取改变大小的拖拽区域\n   */\n  getResizeHandleRect(): Rectangle;\n}\n"
  },
  {
    "path": "app/src/core/stage/stageObject/association/CubicCatmullRomSplineEdge.tsx",
    "content": "import { Color, Vector } from \"@graphif/data-structures\";\nimport { CubicCatmullRomSpline, Rectangle } from \"@graphif/shapes\";\nimport { v4 as uuidv4 } from \"uuid\";\nimport { Serialized } from \"@/types/node\";\nimport { getMultiLineTextSize } from \"@/utils/font\";\nimport { Project } from \"@/core/Project\";\nimport { Renderer } from \"@/core/render/canvas2d/renderer\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { Edge } from \"@/core/stage/stageObject/association/Edge\";\n\n/**\n * CR曲线连线\n * 和早期的Edge一样，用于有向的连接两个实体，形成连接关系\n * alpha 不用自己修改了，这个是0.5固定值了，只会微微影响形状\n * tension 控制曲线的弯曲程度，0是折线。\n */\nexport class CubicCatmullRomSplineEdge extends Edge {\n  public uuid: string;\n  public text: string;\n  protected _source: ConnectableEntity;\n  protected _target: ConnectableEntity;\n  public color: Color = Color.Transparent;\n  public alpha = 0.5;\n  public tension = 0;\n  private controlPoints: Vector[] = [];\n  public getControlPoints(): Vector[] {\n    return this.controlPoints;\n  }\n\n  // 实验性的增加控制点\n  public addControlPoint() {\n    if (this.controlPoints.length >= 4) {\n      // 获取倒数第二个和倒数第三个控制点\n      const secondLastPoint = this.controlPoints[this.controlPoints.length - 2];\n      const thirdLastPoint = this.controlPoints[this.controlPoints.length - 3];\n      // 计算中间控制点\n      const middlePoint = Vector.fromTwoPointsCenter(secondLastPoint, thirdLastPoint);\n      // 将新的控制点插入到数组对应的位置上\n      this.controlPoints.splice(this.controlPoints.length - 2, 0, middlePoint);\n    }\n  }\n\n  private _collisionBox: CollisionBox;\n\n  get collisionBox(): CollisionBox {\n    return this._collisionBox;\n  }\n\n  static fromTwoEntity(\n    project: Project,\n    source: ConnectableEntity,\n    target: ConnectableEntity,\n  ): CubicCatmullRomSplineEdge {\n    // 处理控制点，控制点必须有四个，1 2 3 4，12可重叠，34可重叠\n    const startLocation = source.geometryCenter.clone();\n    const endLocation = target.geometryCenter.clone();\n    const line = Edge.getCenterLine(source, target);\n\n    const result = new CubicCatmullRomSplineEdge(project, {\n      source: source.uuid,\n      target: target.uuid,\n      text: \"\",\n      uuid: uuidv4(),\n      type: \"core:cublic_catmull_rom_spline_edge\",\n      alpha: 0.5,\n      tension: 0,\n      color: [0, 0, 0, 0],\n      sourceRectRate: [0.5, 0.5],\n      targetRectRate: [0.5, 0.5],\n      controlPoints: [\n        [startLocation.x, startLocation.y],\n        [line.start.x, line.start.y],\n        [line.end.x, line.end.y],\n        [endLocation.x, endLocation.y],\n      ],\n    });\n    return result;\n  }\n\n  constructor(\n    protected readonly project: Project,\n    {\n      uuid,\n      source,\n      target,\n      text,\n      alpha,\n      tension,\n      color,\n      controlPoints,\n      sourceRectRate,\n      targetRectRate,\n    }: Serialized.CubicCatmullRomSplineEdge /** true表示解析状态，false表示解析完毕 */,\n    public unknown = false,\n  ) {\n    super();\n    // this._source = this.project.stageManager.getTextNodeByUUID(source) as TextNode;\n    // this._target = this.project.stageManager.getTextNodeByUUID(target) as TextNode;\n    this._source = new TextNode(this.project, { uuid: source }, true);\n    this._target = new TextNode(this.project, { uuid: target }, true);\n    this.uuid = uuid;\n    this.text = text;\n    this.alpha = alpha;\n    this.color = new Color(...color);\n    this.tension = tension;\n    this.controlPoints = controlPoints.map((item) => new Vector(item[0], item[1]));\n    this.setSourceRectangleRate(new Vector(...sourceRectRate));\n    this.setTargetRectangleRate(new Vector(...targetRectRate));\n    this._collisionBox = new CollisionBox([new CubicCatmullRomSpline(this.controlPoints, this.alpha, this.tension)]);\n  }\n\n  public getShape(): CubicCatmullRomSpline {\n    // 重新计算形状\n    const crShape = this._collisionBox.shapes[0] as CubicCatmullRomSpline;\n    this.autoUpdateControlPoints(); // ?\n    return crShape;\n  }\n\n  /**\n   * 获取文字的矩形框的方法\n   */\n  get textRectangle(): Rectangle {\n    const textSize = getMultiLineTextSize(this.text, Renderer.FONT_SIZE, 1.2);\n    return new Rectangle(this.bodyLine.midPoint().subtract(textSize.divide(2)), textSize);\n  }\n\n  autoUpdateControlPoints() {\n    // 只更新起始点和结束点\n    const startLocation = this._source.collisionBox.getRectangle().center;\n    const endLocation = this._target.collisionBox.getRectangle().center;\n    const line = Edge.getCenterLine(this._source, this._target);\n    if (this.controlPoints.length <= 4) {\n      this.controlPoints = [startLocation, line.start, line.end, endLocation];\n    } else {\n      // 截取出除去前两个和最后两个控制点，获取全部的中间控制点\n      const middleControlPoints = this.controlPoints.slice(2, -2);\n      this.controlPoints = [startLocation, line.start].concat(middleControlPoints).concat([line.end, endLocation]);\n    }\n    // 重新生成新的形状\n    this._collisionBox.shapes = [new CubicCatmullRomSpline(this.controlPoints, this.alpha, this.tension)];\n  }\n\n  /**\n   * 获取箭头的位置和方向\n   */\n  getArrowHead(): { location: Vector; direction: Vector } {\n    const crShape = this._collisionBox.shapes[0] as CubicCatmullRomSpline;\n    const location = crShape.controlPoints[crShape.controlPoints.length - 2].clone();\n    const lines = crShape.computeFunction();\n    const funcs = lines[lines.length - 1];\n    return {\n      location,\n      direction: funcs.derivative(0.95),\n    };\n  }\n\n  adjustSizeByText(): void {}\n}\n"
  },
  {
    "path": "app/src/core/stage/stageObject/association/Edge.tsx",
    "content": "import { ConnectableAssociation } from \"@/core/stage/stageObject/abstract/Association\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { serializable } from \"@graphif/serializer\";\nimport { Line, Rectangle } from \"@graphif/shapes\";\nimport { ConnectPoint } from \"../entity/ConnectPoint\";\nimport { ImageNode } from \"../entity/ImageNode\";\n\n/**\n * 连接两个实体的有向边\n */\nexport abstract class Edge extends ConnectableAssociation {\n  public abstract uuid: string;\n  /**\n   * 线段上的文字\n   */\n  public abstract text: string;\n  abstract collisionBox: CollisionBox;\n\n  get isHiddenBySectionCollapse(): boolean {\n    return this.source.isHiddenBySectionCollapse && this.target.isHiddenBySectionCollapse;\n  }\n\n  /** region 选中状态 */\n  /**\n   * 是否被选中\n   */\n  _isSelected: boolean = false;\n  public get isSelected(): boolean {\n    return this._isSelected;\n  }\n  public set isSelected(value: boolean) {\n    this._isSelected = value;\n  }\n\n  /**\n   * 任何有向边都可以标注文字\n   * 进而获得该文字的外框矩形\n   */\n  abstract get textRectangle(): Rectangle;\n\n  /**\n   * 获取两个实体之间的直线\n   * 此直线两端在两个实体外接矩形的边缘，延长后可过两个实体外接矩形的中心\n   * 但对于图片节点，如果rate是精确值（不是旧的默认值），则直接使用内部位置\n   */\n  get bodyLine(): Line {\n    const sourceRectangle = this.source.collisionBox.getRectangle();\n    const targetRectangle = this.target.collisionBox.getRectangle();\n\n    const edgeCenterLine = new Line(\n      sourceRectangle.getInnerLocationByRateVector(this.sourceRectangleRate),\n      targetRectangle.getInnerLocationByRateVector(this.targetRectangleRate),\n    );\n    let startPoint: Vector;\n    let endPoint: Vector;\n\n    // 检查是否是旧的默认值\n    const isOldDefaultRate = (rate: Vector): boolean => {\n      // 旧的默认值：中心、左、右、上、下\n      return (\n        (rate.x === 0.5 && rate.y === 0.5) || // 中心\n        (rate.x === 0.01 && rate.y === 0.5) || // 左\n        (rate.x === 0.99 && rate.y === 0.5) || // 右\n        (rate.x === 0.5 && rate.y === 0.01) || // 上\n        (rate.x === 0.5 && rate.y === 0.99) // 下\n      );\n    };\n\n    if (this.source instanceof ConnectPoint) {\n      startPoint = this.source.geometryCenter;\n    } else if (\n      (this.source instanceof ImageNode || this.source.constructor.name === \"ReferenceBlockNode\") &&\n      !isOldDefaultRate(this.sourceRectangleRate)\n    ) {\n      // 对于图片或引用块节点，如果是精确值（不是旧的默认值），直接使用内部位置\n      startPoint = edgeCenterLine.start;\n    } else {\n      // 否则渲染在外接矩形边缘上\n      startPoint = sourceRectangle.getLineIntersectionPoint(edgeCenterLine);\n    }\n    if (this.target instanceof ConnectPoint) {\n      endPoint = this.target.geometryCenter;\n    } else if (\n      (this.target instanceof ImageNode || this.target.constructor.name === \"ReferenceBlockNode\") &&\n      !isOldDefaultRate(this.targetRectangleRate)\n    ) {\n      // 对于图片或引用块节点，如果是精确值（不是旧的默认值），直接使用内部位置\n      endPoint = edgeCenterLine.end;\n    } else {\n      // 否则渲染在外接矩形边缘上\n      endPoint = targetRectangle.getLineIntersectionPoint(edgeCenterLine);\n    }\n    return new Line(startPoint, endPoint);\n  }\n\n  /**\n   * 获取该连线的起始点位置对应的世界坐标\n   */\n  get sourceLocation(): Vector {\n    return this.source.collisionBox.getRectangle().getInnerLocationByRateVector(this.sourceRectangleRate);\n  }\n  /**\n   * 获取该连线的终止点位置对应的世界坐标\n   */\n  get targetLocation(): Vector {\n    return this.target.collisionBox.getRectangle().getInnerLocationByRateVector(this.targetRectangleRate);\n  }\n\n  @serializable\n  public targetRectangleRate: Vector = new Vector(0.5, 0.5);\n  @serializable\n  public sourceRectangleRate: Vector = new Vector(0.5, 0.5);\n\n  /**\n   * 静态方法：\n   * 获取两个实体外接矩形的连线线段，（只连接到两个边，不连到矩形中心）\n   * @param source\n   * @param target\n   * @returns\n   */\n  static getCenterLine(source: ConnectableEntity, target: ConnectableEntity): Line {\n    const sourceRectangle = source.collisionBox.getRectangle();\n    const targetRectangle = target.collisionBox.getRectangle();\n\n    const edgeCenterLine = new Line(sourceRectangle.center, targetRectangle.center);\n    const startPoint = sourceRectangle.getLineIntersectionPoint(edgeCenterLine);\n    const endPoint = targetRectangle.getLineIntersectionPoint(edgeCenterLine);\n    return new Line(startPoint, endPoint);\n  }\n\n  /** 线段上的文字相关 */\n  /**\n   * 调整线段上的文字的外框矩形\n   */\n  abstract adjustSizeByText(): void;\n\n  public rename(text: string) {\n    this.text = text;\n    this.adjustSizeByText();\n  }\n\n  /** 碰撞相关 */\n  /**\n   * 用于碰撞箱框选\n   * @param rectangle\n   */\n  public isIntersectsWithRectangle(rectangle: Rectangle): boolean {\n    return this.collisionBox.isIntersectsWithRectangle(rectangle);\n  }\n\n  /**\n   * 用于鼠标悬浮在线上的时候\n   * @param location\n   * @returns\n   */\n  public isIntersectsWithLocation(location: Vector): boolean {\n    return this.collisionBox.isContainsPoint(location);\n  }\n\n  /**\n   * 用于线段框选\n   * @param line\n   * @returns\n   */\n  public isIntersectsWithLine(line: Line): boolean {\n    return this.collisionBox.isIntersectsWithLine(line);\n  }\n\n  public isLeftToRight(): boolean {\n    return this.sourceRectangleRate.x === 0.99 && this.targetRectangleRate.x === 0.01;\n  }\n  public isRightToLeft(): boolean {\n    return this.sourceRectangleRate.x === 0.01 && this.targetRectangleRate.x === 0.99;\n  }\n\n  public isTopToBottom(): boolean {\n    return this.sourceRectangleRate.y === 0.99 && this.targetRectangleRate.y === 0.01;\n  }\n  public isBottomToTop(): boolean {\n    return this.sourceRectangleRate.y === 0.01 && this.targetRectangleRate.y === 0.99;\n  }\n\n  public isUnknownDirection(): boolean {\n    return (\n      this.sourceRectangleRate.x === 0.5 &&\n      this.targetRectangleRate.x === 0.5 &&\n      this.sourceRectangleRate.y === 0.5 &&\n      this.targetRectangleRate.y === 0.5\n    );\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageObject/association/EdgeCollisionBoxGetter.tsx",
    "content": "import { Settings } from \"@/core/service/Settings\";\nimport { LineEdge } from \"@/core/stage/stageObject/association/LineEdge\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { Circle, Line, SymmetryCurve } from \"@graphif/shapes\";\nimport { ConnectPoint } from \"../entity/ConnectPoint\";\nimport { Vector } from \"@graphif/data-structures\";\n\nexport namespace EdgeCollisionBoxGetter {\n  /**\n   * 初始化边的渲染器\n   */\n  export function init() {\n    Settings.watch(\"lineStyle\", updateState);\n  }\n\n  let currentStyle: Settings[\"lineStyle\"];\n\n  function updateState(style: Settings[\"lineStyle\"]) {\n    currentStyle = style;\n  }\n\n  /**\n   * 根据不同的设置状态，以及edge，动态获取edge的碰撞箱\n   * @param edge\n   */\n  export function getCollisionBox(edge: LineEdge): CollisionBox {\n    if (edge.source.uuid === edge.target.uuid) {\n      // 是一个自环，碰撞箱是圆形\n      const sourceEntityRect = edge.source.collisionBox.getRectangle();\n      return new CollisionBox([new Circle(sourceEntityRect.location, sourceEntityRect.size.y / 2)]);\n    } else {\n      if (currentStyle === \"bezier\") {\n        return getBezierCollisionBox(edge);\n      } else if (currentStyle === \"straight\") {\n        return getStraightCollisionBox(edge);\n      } else if (currentStyle === \"vertical\") {\n        return new CollisionBox([edge.bodyLine]);\n      } else {\n        return new CollisionBox([edge.bodyLine]);\n      }\n    }\n  }\n\n  function getBezierCollisionBox(edge: LineEdge): CollisionBox {\n    if (edge.isShifting) {\n      const shiftingMidPoint = edge.shiftingMidPoint;\n      // 从source.Center到shiftingMidPoint的线\n      const sourceRectangle = edge.source.collisionBox.getRectangle();\n      const targetRectangle = edge.target.collisionBox.getRectangle();\n\n      const startLine = new Line(sourceRectangle.center, shiftingMidPoint);\n      const endLine = new Line(shiftingMidPoint, targetRectangle.center);\n      let startPoint = sourceRectangle.getLineIntersectionPoint(startLine);\n      if (startPoint.equals(sourceRectangle.center)) {\n        startPoint = sourceRectangle.getLineIntersectionPoint(endLine);\n      }\n      let endPoint = targetRectangle.getLineIntersectionPoint(endLine);\n      if (endPoint.equals(targetRectangle.center)) {\n        endPoint = targetRectangle.getLineIntersectionPoint(startLine);\n      }\n      const curve = new SymmetryCurve(\n        startPoint,\n        startLine.direction(),\n        endPoint,\n        endLine.direction().multiply(-1),\n        Math.abs(endPoint.subtract(startPoint).magnitude()) / 2,\n      );\n      const size = 15; // 箭头大小\n      curve.end = curve.end.subtract(curve.endDirection.normalize().multiply(size / -2));\n      return new CollisionBox([curve]);\n    } else {\n      const start = edge.bodyLine.start;\n      const end = edge.bodyLine.end;\n      const startDirection =\n        edge.source instanceof ConnectPoint\n          ? Vector.getZero()\n          : edge.source.collisionBox.getRectangle().getNormalVectorAt(start);\n      const endDirection =\n        edge.target instanceof ConnectPoint\n          ? Vector.getZero()\n          : edge.target.collisionBox.getRectangle().getNormalVectorAt(end);\n\n      // const endNormal = edge.target.collisionBox.getRectangle().getNormalVectorAt(end);\n      return new CollisionBox([\n        new SymmetryCurve(\n          start,\n          startDirection,\n          end.add(endDirection.multiply(15 / 2)),\n          endDirection,\n          Math.max(50, Math.abs(Math.min(Math.abs(start.x - end.x), Math.abs(start.y - end.y))) / 2),\n        ),\n      ]);\n    }\n  }\n\n  function getStraightCollisionBox(edge: LineEdge): CollisionBox {\n    if (edge.isShifting) {\n      const shiftingMidPoint = edge.shiftingMidPoint;\n      // 从source.Center到shiftingMidPoint的线\n      const startLine = new Line(edge.source.collisionBox.getRectangle().center, shiftingMidPoint);\n      const endLine = new Line(shiftingMidPoint, edge.target.collisionBox.getRectangle().center);\n      return new CollisionBox([startLine, endLine]);\n    } else {\n      return new CollisionBox([edge.bodyLine]);\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageObject/association/LineEdge.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { Renderer } from \"@/core/render/canvas2d/renderer\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { Edge } from \"@/core/stage/stageObject/association/Edge\";\nimport { EdgeCollisionBoxGetter } from \"@/core/stage/stageObject/association/EdgeCollisionBoxGetter\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { getMultiLineTextSize } from \"@/utils/font\";\nimport { Color, Vector } from \"@graphif/data-structures\";\nimport { id, passExtraAtArg1, passObject, serializable } from \"@graphif/serializer\";\nimport { Rectangle } from \"@graphif/shapes\";\n\n@passExtraAtArg1\n@passObject\nexport class LineEdge extends Edge {\n  @id\n  @serializable\n  public uuid: string;\n  @serializable\n  public text: string;\n  @serializable\n  public color: Color = Color.Transparent;\n  @serializable\n  public lineType: string = \"solid\";\n\n  get collisionBox(): CollisionBox {\n    return EdgeCollisionBoxGetter.getCollisionBox(this);\n  }\n\n  /**\n   * 是否是偏移状态\n   * 偏移是为双向线准备的 A->B, B->A，防止重叠\n   */\n  get isShifting(): boolean {\n    return this._isShifting;\n  }\n  set isShifting(value: boolean) {\n    this._isShifting = value;\n  }\n  private _isShifting: boolean = false;\n\n  constructor(\n    protected readonly project: Project,\n    {\n      associationList = [] as ConnectableEntity[],\n      text = \"\",\n      uuid = crypto.randomUUID() as string,\n      color = Color.Transparent,\n      sourceRectangleRate = Vector.same(0.5),\n      targetRectangleRate = Vector.same(0.5),\n      lineType = \"solid\",\n    },\n    /** true表示解析状态，false表示解析完毕 */\n    public unknown = false,\n  ) {\n    super();\n    this.uuid = uuid;\n    this.associationList = associationList;\n    this.text = text;\n    this.color = color;\n    this.sourceRectangleRate = sourceRectangleRate;\n    this.targetRectangleRate = targetRectangleRate;\n    this.lineType = lineType;\n\n    this.adjustSizeByText();\n  }\n\n  // warn: 暂时无引用\n  static fromTwoEntity(project: Project, source: ConnectableEntity, target: ConnectableEntity): LineEdge {\n    const result = new LineEdge(project, {\n      associationList: [source, target],\n    });\n    return result;\n  }\n\n  public rename(text: string) {\n    this.text = text;\n    this.adjustSizeByText();\n  }\n\n  get textRectangle(): Rectangle {\n    // HACK: 这里会造成频繁渲染，频繁计算文字宽度进而可能出现性能问题\n    const textSize = getMultiLineTextSize(this.text, Renderer.FONT_SIZE, 1.2);\n    if (this.isShifting) {\n      return new Rectangle(this.shiftingMidPoint.subtract(textSize.divide(2)), textSize);\n    } else {\n      return new Rectangle(this.bodyLine.midPoint().subtract(textSize.divide(2)), textSize);\n    }\n  }\n\n  get shiftingMidPoint(): Vector {\n    const midPoint = Vector.average(\n      this.source.collisionBox.getRectangle().center,\n      this.target.collisionBox.getRectangle().center,\n    );\n    return midPoint.add(\n      this.target.collisionBox\n        .getRectangle()\n        .getCenter()\n        .subtract(this.source.collisionBox.getRectangle().getCenter())\n        .normalize()\n        .rotateDegrees(90)\n        .multiply(50),\n    );\n  }\n\n  adjustSizeByText(): void {}\n}\n"
  },
  {
    "path": "app/src/core/stage/stageObject/association/MutiTargetUndirectedEdge.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { ConvexHull } from \"@/core/algorithm/geometry/convexHull\";\nimport { Renderer } from \"@/core/render/canvas2d/renderer\";\nimport { ConnectableAssociation } from \"@/core/stage/stageObject/abstract/Association\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { getMultiLineTextSize } from \"@/utils/font\";\nimport { Color, Vector } from \"@graphif/data-structures\";\nimport { id, passExtraAtArg1, passObject, serializable } from \"@graphif/serializer\";\nimport { Line, Rectangle, Shape } from \"@graphif/shapes\";\n\n/**\n * 无向边的箭头类型\n * inner：--> xxx <--\n * outer：<-- xxx -->\n * none： --- xxx ---\n */\nexport type UndirectedEdgeArrowType = \"inner\" | \"outer\" | \"none\";\n/**\n * 无向边的渲染方式\n * line：内部连线式渲染\n * convex：凸包连线式渲染\n * circle：圆形包围渲染\n */\nexport type MultiTargetUndirectedEdgeRenderType = \"line\" | \"convex\" | \"circle\";\n\n/**\n * 多端无向边\n *\n * 超边。\n * 以后可以求最大强独立集\n */\n@passExtraAtArg1\n@passObject\nexport class MultiTargetUndirectedEdge extends ConnectableAssociation {\n  @id\n  @serializable\n  public uuid: string;\n\n  get collisionBox(): CollisionBox {\n    // 根据不同的渲染类型生成不同的碰撞箱\n    if (this.renderType === \"convex\") {\n      // 凸包类型：使用凸包边缘的折线段作为碰撞箱\n      const shapes: Shape[] = [];\n      if (this.associationList.length >= 2) {\n        // 计算凸包点\n        const convexPoints: Vector[] = [];\n        this.associationList.map((node) => {\n          const nodeRectangle = node.collisionBox.getRectangle().expandFromCenter(this.padding);\n          convexPoints.push(nodeRectangle.leftTop);\n          convexPoints.push(nodeRectangle.rightTop);\n          convexPoints.push(nodeRectangle.rightBottom);\n          convexPoints.push(nodeRectangle.leftBottom);\n        });\n        if (this.text !== \"\") {\n          const textRectangle = this.textRectangle.expandFromCenter(this.padding);\n          convexPoints.push(textRectangle.leftTop);\n          convexPoints.push(textRectangle.rightTop);\n          convexPoints.push(textRectangle.rightBottom);\n          convexPoints.push(textRectangle.leftBottom);\n        }\n        // 计算凸包\n        const convexHull = ConvexHull.computeConvexHull(convexPoints);\n        // 将凸包点转换为连续的线段\n        for (let i = 0; i < convexHull.length; i++) {\n          const start = convexHull[i];\n          const end = convexHull[(i + 1) % convexHull.length];\n          shapes.push(new Line(start, end));\n        }\n      }\n      return new CollisionBox(shapes);\n    } else if (this.renderType === \"circle\") {\n      // 圆形类型：使用圆形边缘的折线段（多边形近似）作为碰撞箱\n      const shapes: Shape[] = [];\n      if (this.associationList.length >= 2) {\n        // 计算所有点\n        const allPoints: Vector[] = [];\n        this.associationList.map((node) => {\n          const nodeRectangle = node.collisionBox.getRectangle().expandFromCenter(this.padding);\n          allPoints.push(nodeRectangle.leftTop);\n          allPoints.push(nodeRectangle.rightTop);\n          allPoints.push(nodeRectangle.rightBottom);\n          allPoints.push(nodeRectangle.leftBottom);\n        });\n        if (this.text !== \"\") {\n          const textRectangle = this.textRectangle.expandFromCenter(this.padding);\n          allPoints.push(textRectangle.leftTop);\n          allPoints.push(textRectangle.rightTop);\n          allPoints.push(textRectangle.rightBottom);\n          allPoints.push(textRectangle.leftBottom);\n        }\n        // 计算圆心和半径\n        const center = Vector.averageMultiple(allPoints);\n        let maxDistance = 0;\n        for (const point of allPoints) {\n          const distance = center.distance(point);\n          if (distance > maxDistance) {\n            maxDistance = distance;\n          }\n        }\n        // 生成多边形顶点（20个顶点近似圆形）\n        const vertexCount = 20;\n        const vertices: Vector[] = [];\n        for (let i = 0; i < vertexCount; i++) {\n          const angle = (i / vertexCount) * Math.PI * 2;\n          const x = center.x + maxDistance * Math.cos(angle);\n          const y = center.y + maxDistance * Math.sin(angle);\n          vertices.push(new Vector(x, y));\n        }\n        // 将顶点转换为连续的线段\n        for (let i = 0; i < vertices.length; i++) {\n          const start = vertices[i];\n          const end = vertices[(i + 1) % vertices.length];\n          shapes.push(new Line(start, end));\n        }\n      }\n      return new CollisionBox(shapes);\n    } else {\n      // line类型：保持现有实现\n      const center = this.centerLocation;\n      const shapes: Shape[] = [];\n      for (const node of this.associationList) {\n        const line = new Line(center, node.collisionBox.getRectangle().center);\n        shapes.push(line);\n      }\n      return new CollisionBox(shapes);\n    }\n  }\n\n  @serializable\n  public text: string;\n  @serializable\n  public color: Color;\n  @serializable\n  public rectRates: Vector[];\n  @serializable\n  public centerRate: Vector;\n  @serializable\n  public arrow: UndirectedEdgeArrowType = \"none\";\n  @serializable\n  public renderType: MultiTargetUndirectedEdgeRenderType = \"line\";\n  @serializable\n  public padding: number;\n\n  public rename(text: string) {\n    this.text = text;\n  }\n\n  constructor(\n    protected readonly project: Project,\n    {\n      associationList = [] as ConnectableEntity[],\n      text = \"\",\n      uuid = crypto.randomUUID() as string,\n      color = Color.Transparent,\n      rectRates = associationList.map(() => Vector.same(0.5)),\n      arrow = \"none\" as UndirectedEdgeArrowType,\n      centerRate = Vector.same(0.5),\n      padding = 10,\n      renderType = \"line\" as MultiTargetUndirectedEdgeRenderType,\n    }: {\n      associationList?: ConnectableEntity[];\n      text?: string;\n      uuid?: string;\n      color?: Color;\n      rectRates?: Vector[];\n      arrow?: UndirectedEdgeArrowType;\n      centerRate?: Vector;\n      padding?: number;\n      renderType?: MultiTargetUndirectedEdgeRenderType;\n    },\n    /** true表示解析状态，false表示解析完毕 */\n    public unknown = false,\n  ) {\n    super();\n\n    this.text = text;\n    this.uuid = uuid;\n    this.color = color;\n    this.associationList = associationList;\n    this.rectRates = rectRates;\n    this.centerRate = centerRate;\n    this.arrow = arrow;\n    this.renderType = renderType;\n    this.padding = padding;\n  }\n\n  /**\n   * 获取中心点\n   */\n  public get centerLocation(): Vector {\n    const boundingRectangle = Rectangle.getBoundingRectangle(\n      this.associationList.map((n) => n.collisionBox.getRectangle()),\n    );\n    return boundingRectangle.getInnerLocationByRateVector(this.centerRate);\n  }\n\n  get textRectangle(): Rectangle {\n    // HACK: 这里会造成频繁渲染，频繁计算文字宽度进而可能出现性能问题\n    const textSize = getMultiLineTextSize(this.text, Renderer.FONT_SIZE, 1.2);\n    return new Rectangle(this.centerLocation.subtract(textSize.divide(2)), textSize);\n  }\n\n  static createFromSomeEntity(project: Project, entities: ConnectableEntity[]) {\n    // 自动计算padding\n    let padding = 10;\n    for (const entity of entities) {\n      const hyperEdges = project.graphMethods.getHyperEdgesByNode(entity);\n      if (hyperEdges.length > 0) {\n        const maxPadding = Math.max(...hyperEdges.map((e) => e.padding));\n        padding = Math.max(maxPadding + 10, padding);\n      }\n    }\n\n    return new MultiTargetUndirectedEdge(project, {\n      associationList: entities,\n      padding,\n    });\n  }\n\n  /**\n   * 是否被选中\n   */\n  _isSelected: boolean = false;\n  public get isSelected(): boolean {\n    return this._isSelected;\n  }\n  public set isSelected(value: boolean) {\n    this._isSelected = value;\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageObject/collisionBox/collisionBox.tsx",
    "content": "import { Vector } from \"@graphif/data-structures\";\nimport { serializable } from \"@graphif/serializer\";\nimport { Line, Rectangle, Shape } from \"@graphif/shapes\";\n\n/**\n * 碰撞箱类\n */\nexport class CollisionBox {\n  @serializable\n  shapes: Shape[] = [];\n\n  constructor(shapes: Shape[]) {\n    this.shapes = shapes;\n  }\n\n  /**\n   *\n   * @param shapes 更新碰撞箱的形状列表\n   */\n  updateShapeList(shapes: Shape[]): void {\n    this.shapes = shapes;\n  }\n\n  public isContainsPoint(location: Vector): boolean {\n    for (const shape of this.shapes) {\n      if (shape.isPointIn(location)) {\n        return true;\n      }\n    }\n    return false;\n  }\n\n  /**\n   * 碰撞框选\n   * @param rectangle\n   * @returns\n   */\n  public isIntersectsWithRectangle(rectangle: Rectangle): boolean {\n    for (const shape of this.shapes) {\n      if (shape.isCollideWithRectangle(rectangle)) {\n        return true;\n      }\n    }\n    return false;\n  }\n\n  /**\n   * 完全覆盖框选\n   * @param rectangle\n   * @returns\n   */\n  public isContainedByRectangle(rectangle: Rectangle): boolean {\n    for (const shape of this.shapes) {\n      const shapeRectangle = shape.getRectangle();\n      const shapeLeftTop = shapeRectangle.location;\n      const shapeRightBottom = new Vector(\n        shapeLeftTop.x + shapeRectangle.size.x,\n        shapeLeftTop.y + shapeRectangle.size.y,\n      );\n\n      const rectLeftTop = rectangle.location;\n      const rectRightBottom = new Vector(rectLeftTop.x + rectangle.size.x, rectLeftTop.y + rectangle.size.y);\n\n      // 判断每个形状的最小外接矩形是否完全在给定矩形内\n      if (\n        shapeLeftTop.x < rectLeftTop.x ||\n        shapeLeftTop.y < rectLeftTop.y ||\n        shapeRightBottom.x > rectRightBottom.x ||\n        shapeRightBottom.y > rectRightBottom.y\n      ) {\n        return false;\n      }\n    }\n    return true;\n  }\n\n  public isIntersectsWithLine(line: Line): boolean {\n    for (const shape of this.shapes) {\n      if (shape.isCollideWithLine(line)) {\n        return true;\n      }\n    }\n    return false;\n  }\n\n  /**\n   * 获取碰撞箱们的最小外接矩形\n   * 如果形状数组为空，则返回00点的无大小矩形\n   */\n  getRectangle(): Rectangle {\n    if (this.shapes.length === 0) {\n      return new Rectangle(Vector.getZero(), Vector.getZero());\n    }\n    let minX = Infinity;\n    let minY = Infinity;\n    let maxX = -Infinity;\n    let maxY = -Infinity;\n    for (const shape of this.shapes) {\n      const rectangle = shape.getRectangle();\n      const x = rectangle.location.x,\n        y = rectangle.location.y;\n      const width = rectangle.size.x,\n        height = rectangle.size.y;\n      if (x < minX) {\n        minX = x;\n      }\n      if (y < minY) {\n        minY = y;\n      }\n      if (x + width > maxX) {\n        maxX = x + width;\n      }\n      if (y + height > maxY) {\n        maxY = y + height;\n      }\n    }\n    const leftTopLocation = new Vector(minX, minY);\n    const sizeVector = new Vector(maxX - minX, maxY - minY);\n    return new Rectangle(leftTopLocation, sizeVector);\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageObject/entity/ConnectPoint.tsx",
    "content": "import { Project } from \"@/core/Project\";\n// import { CircleChangeRadiusEffect } from \"@/core/service/feedbackService/effectEngine/concrete/CircleChangeRadiusEffect\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { id, passExtraAtArg1, passObject, serializable } from \"@graphif/serializer\";\nimport { Rectangle } from \"@graphif/shapes\";\n\n/**\n * 质点不再区分膨胀状态和收缩状态\n */\n@passExtraAtArg1\n@passObject\nexport class ConnectPoint extends ConnectableEntity {\n  // 坍缩状态半径\n  static CONNECT_POINT_SHRINK_RADIUS = 15;\n  // 膨胀状态半径\n  static CONNECT_POINT_EXPAND_RADIUS = 15;\n\n  get geometryCenter(): Vector {\n    return this.collisionBox.getRectangle().center;\n  }\n\n  isHiddenBySectionCollapse: boolean = false;\n\n  @serializable\n  collisionBox: CollisionBox;\n  @id\n  @serializable\n  uuid: string;\n\n  get radius(): number {\n    return this._isSelected ? ConnectPoint.CONNECT_POINT_EXPAND_RADIUS : ConnectPoint.CONNECT_POINT_SHRINK_RADIUS;\n  }\n\n  /**\n   * 节点是否被选中\n   */\n  _isSelected: boolean = false;\n\n  /**\n   * 获取节点的选中状态\n   */\n  public get isSelected() {\n    return this._isSelected;\n  }\n\n  public set isSelected(value: boolean) {\n    const oldValue = this._isSelected;\n    if (oldValue === value) {\n      return;\n    }\n    this._isSelected = value;\n\n    const rectangle = this.collisionBox.shapes[0];\n    if (!(rectangle instanceof Rectangle)) {\n      return;\n    }\n\n    const centerLocation = this.geometryCenter.clone();\n    if (value) {\n      // 变为选中，放大\n      rectangle.size = Vector.same(ConnectPoint.CONNECT_POINT_EXPAND_RADIUS * 2);\n      rectangle.location = centerLocation.subtract(Vector.same(ConnectPoint.CONNECT_POINT_EXPAND_RADIUS));\n    } else {\n      // 变为 未选中，缩小\n      rectangle.size = Vector.same(ConnectPoint.CONNECT_POINT_SHRINK_RADIUS * 2);\n      rectangle.location = centerLocation.subtract(Vector.same(ConnectPoint.CONNECT_POINT_SHRINK_RADIUS));\n    }\n  }\n\n  constructor(\n    protected readonly project: Project,\n    {\n      uuid = crypto.randomUUID() as string,\n      collisionBox = new CollisionBox([\n        new Rectangle(Vector.getZero(), Vector.same(ConnectPoint.CONNECT_POINT_SHRINK_RADIUS * 2)),\n      ]),\n      details = [],\n    },\n    public unknown = false,\n  ) {\n    super();\n    this.uuid = uuid;\n    this.collisionBox = collisionBox;\n    this.details = details;\n  }\n\n  move(delta: Vector): void {\n    const newRectangle = this.collisionBox.getRectangle();\n    newRectangle.location = newRectangle.location.add(delta);\n    this.collisionBox.shapes[0] = newRectangle;\n    this.updateFatherSectionByMove();\n  }\n\n  moveTo(location: Vector): void {\n    const newRectangle = this.collisionBox.getRectangle();\n    newRectangle.location = location;\n    this.collisionBox.shapes[0] = newRectangle;\n    this.updateFatherSectionByMove();\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageObject/entity/ImageNode.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { ResizeAble } from \"@/core/stage/stageObject/abstract/StageObjectInterface\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { id, passExtraAtArg1, passObject, serializable } from \"@graphif/serializer\";\nimport { Rectangle } from \"@graphif/shapes\";\n\n/**\n * 一个图片节点\n * 图片的路径字符串决定了这个图片是什么\n *\n * 有两个转换过程：\n *\n * 图片路径 -> base64字符串 -> 图片Element -> 完成\n *   gettingBase64\n *     |\n *     v\n *   fileNotfound\n *   base64EncodeError\n *\n */\n@passExtraAtArg1\n@passObject\nexport class ImageNode extends ConnectableEntity implements ResizeAble {\n  isHiddenBySectionCollapse: boolean = false;\n  @id\n  @serializable\n  public uuid: string;\n  @serializable\n  public collisionBox: CollisionBox;\n  @serializable\n  attachmentId: string;\n  @serializable\n  scale: number;\n  /**\n   * 是否为背景图片\n   */\n  @serializable\n  isBackground: boolean = false;\n  /**\n   * 节点是否被选中\n   */\n  _isSelected: boolean = false;\n\n  /**\n   * 获取节点的选中状态\n   */\n  public get isSelected() {\n    return this._isSelected;\n  }\n\n  public set isSelected(value: boolean) {\n    this._isSelected = value;\n  }\n\n  bitmap: ImageBitmap | undefined;\n  state: \"loading\" | \"success\" | \"notFound\" = \"loading\";\n\n  constructor(\n    protected readonly project: Project,\n    {\n      uuid = crypto.randomUUID() as string,\n      collisionBox = new CollisionBox([new Rectangle(Vector.getZero(), Vector.getZero())]),\n      details = [],\n      attachmentId = \"\",\n      scale = 1,\n      isBackground = false,\n    },\n    public unknown = false,\n  ) {\n    super();\n    this.uuid = uuid;\n    this.collisionBox = collisionBox;\n    this.details = details;\n    this.attachmentId = attachmentId;\n    this.scale = scale;\n    this.isBackground = isBackground;\n\n    const blob = project.attachments.get(attachmentId);\n    if (!blob) {\n      this.state = \"notFound\";\n      return;\n    }\n    createImageBitmap(blob).then((bitmap) => {\n      this.bitmap = bitmap;\n      this.state = \"success\";\n      // 设置碰撞箱\n      this.scaleUpdate(0);\n    });\n  }\n\n  public scaleUpdate(scaleDiff: number) {\n    this.scale += scaleDiff;\n    if (this.scale < 0.1) {\n      this.scale = 0.1;\n    }\n    if (this.scale > 10) {\n      this.scale = 10;\n    }\n    if (!this.bitmap) return;\n    this.collisionBox = new CollisionBox([\n      new Rectangle(this.rectangle.location, new Vector(this.bitmap.width, this.bitmap.height).multiply(this.scale)),\n    ]);\n  }\n\n  /**\n   * 只读，获取节点的矩形\n   * 若要修改节点的矩形，请使用 moveTo等 方法\n   */\n  public get rectangle(): Rectangle {\n    return this.collisionBox.shapes[0] as Rectangle;\n  }\n\n  public get geometryCenter() {\n    return this.rectangle.location.clone().add(this.rectangle.size.clone().multiply(0.5));\n  }\n\n  move(delta: Vector): void {\n    const newRectangle = this.rectangle.clone();\n    newRectangle.location = newRectangle.location.add(delta);\n    this.collisionBox.shapes[0] = newRectangle;\n    this.updateFatherSectionByMove();\n  }\n  moveTo(location: Vector): void {\n    const newRectangle = this.rectangle.clone();\n    newRectangle.location = location.clone();\n    this.collisionBox.shapes[0] = newRectangle;\n    this.updateFatherSectionByMove();\n  }\n\n  /**\n   * 反转图片颜色\n   * 将图片的RGB值转换为互补色（255-R, 255-G, 255-B）\n   * 并将反色后的图片数据保存到project.attachments中，实现持久化存储\n   */\n  reverseColors() {\n    if (!this.bitmap) return;\n\n    // 创建临时canvas\n    const canvas = document.createElement(\"canvas\");\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    // 设置canvas尺寸\n    canvas.width = this.bitmap.width;\n    canvas.height = this.bitmap.height;\n\n    // 绘制原图\n    ctx.drawImage(this.bitmap, 0, 0);\n\n    // 获取图像数据\n    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);\n    const data = imageData.data;\n\n    // 反转颜色（255-R, 255-G, 255-B）\n    for (let i = 0; i < data.length; i += 4) {\n      data[i] = 255 - data[i]; // R\n      data[i + 1] = 255 - data[i + 1]; // G\n      data[i + 2] = 255 - data[i + 2]; // B\n      // data[i + 3] 保持不变（alpha通道）\n    }\n\n    // 将修改后的图像数据绘制回canvas\n    ctx.putImageData(imageData, 0, 0);\n\n    // 创建新的ImageBitmap并保存到attachments中\n    createImageBitmap(imageData).then((newBitmap) => {\n      this.bitmap = newBitmap;\n\n      // 将canvas转换为Blob并保存到project.attachments中\n      canvas.toBlob((blob) => {\n        if (blob) {\n          // 创建新的attachmentId并替换原有数据\n          const newAttachmentId = this.project.addAttachment(blob);\n          // 更新当前节点的attachmentId\n          this.attachmentId = newAttachmentId;\n        }\n      }, \"image/png\");\n    });\n  }\n\n  /**\n   * 交换图片的红蓝通道\n   * 将图片的红色和蓝色通道对调，绿色和alpha通道保持不变\n   * 并将处理后的图片数据保存到project.attachments中，实现持久化存储\n   */\n  swapRedBlueChannels() {\n    if (!this.bitmap) return;\n\n    // 创建临时canvas\n    const canvas = document.createElement(\"canvas\");\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    // 设置canvas尺寸\n    canvas.width = this.bitmap.width;\n    canvas.height = this.bitmap.height;\n\n    // 绘制原图\n    ctx.drawImage(this.bitmap, 0, 0);\n\n    // 获取图像数据\n    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);\n    const data = imageData.data;\n\n    // 交换红色和蓝色通道\n    for (let i = 0; i < data.length; i += 4) {\n      const r = data[i]; // R\n      const b = data[i + 2]; // B\n      data[i] = b; // R = B\n      data[i + 2] = r; // B = R\n      // data[i + 1] 保持不变（绿色通道）\n      // data[i + 3] 保持不变（alpha通道）\n    }\n\n    // 将修改后的图像数据绘制回canvas\n    ctx.putImageData(imageData, 0, 0);\n\n    // 创建新的ImageBitmap并保存到attachments中\n    createImageBitmap(imageData).then((newBitmap) => {\n      this.bitmap = newBitmap;\n\n      // 将canvas转换为Blob并保存到project.attachments中\n      canvas.toBlob((blob) => {\n        if (blob) {\n          // 创建新的attachmentId并替换原有数据\n          const newAttachmentId = this.project.addAttachment(blob);\n          // 更新当前节点的attachmentId\n          this.attachmentId = newAttachmentId;\n        }\n      }, \"image/png\");\n    });\n  }\n\n  /**\n   * 处理拖拽缩放逻辑\n   * @param delta 拖拽距离向量\n   */\n  resizeHandle(delta: Vector) {\n    if (!this.bitmap) return;\n\n    // 计算当前图片的实际显示尺寸\n    const currentDisplayWidth = this.bitmap.width * this.scale;\n\n    // 根据delta计算新的显示尺寸（只使用delta.x，保持等比例缩放）\n    const newDisplayWidth = Math.max(currentDisplayWidth + delta.x, this.bitmap.width * 0.1);\n\n    // 计算新的缩放比例\n    const newScale = newDisplayWidth / this.bitmap.width;\n\n    // 更新缩放比例，使用现有的scaleUpdate方法保持一致性\n    const scaleDiff = newScale - this.scale;\n    this.scaleUpdate(scaleDiff);\n  }\n\n  /**\n   * 获取缩放控制点矩形\n   * 返回右下角的一个小矩形，用于拖拽缩放\n   */\n  getResizeHandleRect(): Rectangle {\n    const rect = this.collisionBox.getRectangle();\n    // 创建一个25x25的矩形，位于图片右下角\n    return new Rectangle(new Vector(rect.right - 25, rect.bottom - 25), new Vector(25, 25));\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageObject/entity/PenStroke.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { Color, Vector } from \"@graphif/data-structures\";\nimport { id, passExtraAtArg1, passObject, serializable } from \"@graphif/serializer\";\nimport { Line } from \"@graphif/shapes\";\n\n/**\n * 一笔画中的某一个小段\n * 起始点，结束点，宽度\n */\nexport class PenStrokeSegment {\n  @serializable\n  location: Vector;\n  @serializable\n  pressure: number;\n  constructor(location: Vector, pressure: number) {\n    this.location = location;\n    this.pressure = pressure;\n  }\n}\n\n@passExtraAtArg1\n@passObject\nexport class PenStroke extends Entity {\n  /** 涂鸦不参与吸附对齐 */\n  public isAlignExcluded: boolean = true;\n\n  public isHiddenBySectionCollapse: boolean = false;\n  // @serializable\n  collisionBox: CollisionBox = new CollisionBox([]);\n\n  @id\n  @serializable\n  public uuid: string;\n\n  move(delta: Vector): void {\n    // 移动每一个段\n    for (const segment of this.segments) {\n      segment.location = segment.location.add(delta);\n    }\n    this.updateCollisionBoxBySegmentList();\n  }\n  moveTo(location: Vector): void {\n    for (const segment of this.segments) {\n      const delta = location.subtract(segment.location);\n      segment.location = segment.location.add(delta);\n    }\n    this.updateCollisionBoxBySegmentList();\n  }\n\n  private updateCollisionBoxBySegmentList() {\n    this.collisionBox.shapes = [];\n    for (let i = 1; i < this.segments.length; i++) {\n      const segment = this.segments[i];\n      const previousSegment = this.segments[i - 1];\n      this.collisionBox.shapes.push(new Line(previousSegment.location, segment.location));\n    }\n  }\n\n  @serializable\n  public segments: PenStrokeSegment[] = [];\n  @serializable\n  public color: Color = Color.Transparent;\n\n  public getPath(): Vector[] {\n    return this.segments.map((it) => it.location);\n  }\n\n  constructor(\n    protected readonly project: Project,\n    { uuid = crypto.randomUUID() as string, segments = [] as PenStrokeSegment[], color = Color.White },\n  ) {\n    super();\n    this.uuid = uuid;\n    this.segments = segments;\n    this.color = color;\n    this.updateCollisionBoxBySegmentList();\n  }\n\n  getCollisionBoxFromSegmentList(segmentList: PenStrokeSegment[]): CollisionBox {\n    const result = new CollisionBox([]);\n    for (let i = 1; i < segmentList.length; i++) {\n      const segment = segmentList[i];\n      const previousSegment = segmentList[i - 1];\n      result.shapes.push(new Line(previousSegment.location, segment.location));\n    }\n    return result;\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageObject/entity/ReferenceBlockNode.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { RecentFileManager } from \"@/core/service/dataFileService/RecentFileManager\";\nimport { GenerateScreenshot } from \"@/core/service/dataGenerateService/generateScreenshot\";\nimport { onOpenFile } from \"@/core/service/GlobalMenu\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { ResizeAble } from \"@/core/stage/stageObject/abstract/StageObjectInterface\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { PathString } from \"@/utils/pathString\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { id, passExtraAtArg1, passObject, serializable } from \"@graphif/serializer\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { Section } from \"./Section\";\nimport { RectangleLittleNoteEffect } from \"@/core/service/feedbackService/effectEngine/concrete/RectangleLittleNoteEffect\";\n\n/**\n * 引用块节点\n * 用于跨文件引用其他prg文件中的Section内容\n * 以静态图片的方式渲染在舞台上\n */\n@passExtraAtArg1\n@passObject\nexport class ReferenceBlockNode extends ConnectableEntity implements ResizeAble {\n  isHiddenBySectionCollapse: boolean = false;\n  @id\n  @serializable\n  public uuid: string;\n  @serializable\n  public collisionBox: CollisionBox;\n\n  /**\n   * 引用的文件名，不包括文件扩展名\n   */\n  @serializable\n  public fileName: string;\n\n  /**\n   * 引用的Section框名，为空表示引用整个文件\n   */\n  @serializable\n  public sectionName: string;\n  @serializable\n  scale: number;\n  @serializable\n  attachmentId: string;\n\n  /**\n   * 节点是否被选中\n   */\n  _isSelected: boolean = false;\n\n  bitmap: ImageBitmap | undefined;\n  state: \"loading\" | \"success\" | \"notFound\" = \"loading\";\n\n  constructor(\n    protected readonly project: Project,\n    {\n      uuid = crypto.randomUUID() as string,\n      collisionBox = new CollisionBox([new Rectangle(Vector.getZero(), new Vector(400, 200))]),\n      fileName = \"\",\n      sectionName = \"\",\n      scale = 1,\n      attachmentId = \"\",\n      details = [],\n    },\n    public unknown = false,\n  ) {\n    super();\n    this.uuid = uuid;\n    this.collisionBox = collisionBox;\n    this.fileName = fileName;\n    this.sectionName = sectionName;\n    this.scale = scale;\n    this.attachmentId = attachmentId;\n    this.details = details;\n\n    // 如果已经有attachmentId，直接加载图片\n    if (attachmentId) {\n      this.loadImageFromAttachment();\n    } else {\n      // 否则生成截图\n      this.generateScreenshot();\n    }\n  }\n\n  public get isSelected() {\n    return this._isSelected;\n  }\n\n  public set isSelected(value: boolean) {\n    this._isSelected = value;\n  }\n\n  private loadImageFromAttachment() {\n    const blob = this.project.attachments.get(this.attachmentId);\n    if (!blob) {\n      this.state = \"notFound\";\n      return;\n    }\n    createImageBitmap(blob).then((bitmap) => {\n      this.bitmap = bitmap;\n      this.state = \"success\";\n      this.updateCollisionBox();\n    });\n  }\n\n  private async generateScreenshot() {\n    try {\n      this.state = \"loading\";\n      let screenshotBlob;\n\n      // 根据sectionName是否为空来决定调用哪个方法\n      if (this.sectionName) {\n        // 引用特定的Section\n        screenshotBlob = await GenerateScreenshot.generateSection(this.fileName, this.sectionName);\n      } else {\n        // 引用整个文件\n        screenshotBlob = await GenerateScreenshot.generateFullView(this.fileName);\n      }\n\n      if (screenshotBlob) {\n        // 保存到附件\n        const newAttachmentId = this.project.addAttachment(screenshotBlob);\n        this.attachmentId = newAttachmentId;\n        // 加载图片\n        this.loadImageFromAttachment();\n      } else {\n        this.state = \"notFound\";\n      }\n    } catch (error) {\n      console.error(\"Failed to generate screenshot:\", error);\n      this.state = \"notFound\";\n    }\n  }\n\n  private updateCollisionBox() {\n    if (!this.bitmap) return;\n    this.collisionBox = new CollisionBox([\n      new Rectangle(this.rectangle.location, new Vector(this.bitmap.width, this.bitmap.height).multiply(this.scale)),\n    ]);\n  }\n\n  public scaleUpdate(scaleDiff: number) {\n    this.scale += scaleDiff;\n    if (this.scale < 0.1) {\n      this.scale = 0.1;\n    }\n    if (this.scale > 10) {\n      this.scale = 10;\n    }\n    this.updateCollisionBox();\n  }\n\n  public get rectangle(): Rectangle {\n    return this.collisionBox.shapes[0] as Rectangle;\n  }\n\n  public get geometryCenter() {\n    return this.rectangle.location.clone().add(this.rectangle.size.clone().multiply(0.5));\n  }\n\n  move(delta: Vector): void {\n    const newRectangle = this.rectangle.clone();\n    newRectangle.location = newRectangle.location.add(delta);\n    this.collisionBox.shapes[0] = newRectangle;\n    this.updateFatherSectionByMove();\n  }\n\n  moveTo(location: Vector): void {\n    const newRectangle = this.rectangle.clone();\n    newRectangle.location = location.clone();\n    this.collisionBox.shapes[0] = newRectangle;\n    this.updateFatherSectionByMove();\n  }\n\n  /**\n   * 更新引用的内容\n   */\n  async refresh() {\n    await this.generateScreenshot();\n  }\n\n  /**\n   * 用户点击这个引用块，跳转到对应的跨文件的 地方\n   */\n  async goToSource() {\n    if (this.state !== \"success\") {\n      return;\n    }\n    const recentFiles = await RecentFileManager.getRecentFiles();\n    const file = recentFiles.find(\n      (file) =>\n        PathString.getFileNameFromPath(file.uri.path) === this.fileName ||\n        PathString.getFileNameFromPath(file.uri.fsPath) === this.fileName,\n    );\n    if (!file) {\n      return;\n    }\n    // 跳转到源头：对应的源头Section\n    const project = await onOpenFile(file.uri, \"ReferenceBlockNode跳转打开-prg文件\");\n    if (!project) {\n      return;\n    }\n    const targetSection = project.stage\n      .filter((obj) => obj instanceof Section)\n      .find((section) => section.text === this.sectionName);\n    if (!targetSection) {\n      return;\n    }\n    const center = targetSection.collisionBox.getRectangle().center;\n    project.camera.location = center;\n    project.effects.addEffect(RectangleLittleNoteEffect.fromUtilsSlowNote(targetSection));\n  }\n\n  /**\n   * 处理拖拽缩放逻辑\n   * @param delta 拖拽距离向量\n   */\n  resizeHandle(delta: Vector) {\n    if (!this.bitmap) return;\n\n    // 计算当前显示尺寸\n    const currentDisplayWidth = this.bitmap.width * this.scale;\n\n    // 根据delta计算新的显示尺寸（只使用delta.x，保持等比例缩放）\n    const newDisplayWidth = Math.max(currentDisplayWidth + delta.x, this.bitmap.width * 0.1);\n\n    // 计算新的缩放比例\n    const newScale = newDisplayWidth / this.bitmap.width;\n\n    // 更新缩放比例，使用现有的scaleUpdate方法保持一致性\n    const scaleDiff = newScale - this.scale;\n    this.scaleUpdate(scaleDiff);\n  }\n\n  /**\n   * 获取缩放控制点矩形\n   * 返回右下角的一个小矩形，用于拖拽缩放\n   */\n  getResizeHandleRect(): Rectangle {\n    const rect = this.collisionBox.getRectangle();\n    // 创建一个25x25的矩形，位于右下角\n    return new Rectangle(new Vector(rect.right - 25, rect.bottom - 25), new Vector(25, 25));\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageObject/entity/Section.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { Renderer } from \"@/core/render/canvas2d/renderer\";\nimport { NodeMoveShadowEffect } from \"@/core/service/feedbackService/effectEngine/concrete/NodeMoveShadowEffect\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { getTextSize } from \"@/utils/font\";\nimport { Color, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { id, passExtraAtArg1, passObject, serializable } from \"@graphif/serializer\";\nimport { Line, Rectangle, Shape } from \"@graphif/shapes\";\nimport { Value } from \"platejs\";\n\n@passExtraAtArg1\n@passObject\nexport class Section extends ConnectableEntity {\n  /**\n   * 节点是否被选中\n   */\n  _isSelected: boolean = false;\n  @id\n  @serializable\n  public uuid: string;\n  _isEditingTitle: boolean = false;\n  private _collisionBoxWhenCollapsed: CollisionBox;\n  private _collisionBoxNormal: CollisionBox;\n\n  public get isEditingTitle() {\n    return this._isEditingTitle;\n  }\n  public set isEditingTitle(value: boolean) {\n    this._isEditingTitle = value;\n    this.project.sectionRenderer.render(this);\n  }\n\n  /**\n   * 小于多少的情况下，开始渲染大标题\n   */\n  static bigTitleCameraScale = 0.2;\n\n  @serializable\n  public get collisionBox(): CollisionBox {\n    if (this.isCollapsed) {\n      return this._collisionBoxWhenCollapsed;\n    } else if (this.locked) {\n      // 锁定状态下使用整个矩形作为碰撞箱\n      return new CollisionBox([this.rectangle]);\n    } else {\n      return this._collisionBoxNormal;\n    }\n  }\n\n  /** 获取折叠状态下的碰撞箱 */\n  private collapsedCollisionBox(): CollisionBox {\n    const centerLocation = this._collisionBoxNormal.getRectangle().center;\n    const collapsedRectangleSize = getTextSize(this.text, Renderer.FONT_SIZE).add(\n      Vector.same(Renderer.NODE_PADDING).multiply(2),\n    );\n    const collapsedRectangle = new Rectangle(\n      centerLocation.clone().subtract(collapsedRectangleSize.multiply(0.5)),\n      collapsedRectangleSize,\n    );\n    return new CollisionBox([collapsedRectangle]);\n  }\n\n  @serializable\n  color: Color = Color.Transparent;\n  @serializable\n  text: string;\n  @serializable\n  children: Entity[];\n\n  /** 是否是折叠状态 */\n  @serializable\n  isCollapsed: boolean;\n  /**\n   * 是否锁定 Section 内部物体\n   * 当 locked 为 true 时，Section 内部的所有物体都不能移动或删除\n   */\n  @serializable\n  locked: boolean = false;\n  isHiddenBySectionCollapse = false;\n\n  constructor(\n    protected readonly project: Project,\n    {\n      uuid = crypto.randomUUID() as string,\n      text = \"\",\n      collisionBox = new CollisionBox([new Rectangle(new Vector(0, 0), new Vector(0, 0))]),\n      color = Color.Transparent,\n      locked = false,\n      isCollapsed = false,\n      children = [] as Entity[],\n      details = [] as Value,\n    } = {},\n    public unknown = false,\n  ) {\n    super();\n    this.uuid = uuid;\n\n    this._collisionBoxWhenCollapsed = collisionBox;\n\n    const rect = collisionBox.getRectangle();\n    const shapes: Shape[] = rect.getBoundingLines();\n    // shapes.push(\n    //   new Rectangle(rect.location, new Vector(rect.size.x, 50)),\n    // );\n    this._collisionBoxNormal = new CollisionBox(shapes);\n\n    this.color = color;\n    this.text = text;\n    this.locked = locked;\n    this.isCollapsed = isCollapsed;\n    this.details = details;\n    this.children = children;\n    // 一定要放在最后\n    this.adjustLocationAndSize();\n  }\n\n  /**\n   * 根据多个实体创建Section\n   * @param entities\n   */\n  static fromEntities(project: Project, entities: Entity[]): Section {\n    const section = new Section(project, {\n      text: \"section\",\n      children: entities,\n    });\n\n    return section;\n  }\n\n  rename(newName: string) {\n    this.text = newName;\n    this.adjustLocationAndSize();\n  }\n\n  /**\n   * 根据子内容 自动调整Section框的位置和大小\n   * 如果没有子内容，则\n   *   自动调整大小为 标题+padding，位置为 当前碰撞箱外接矩形的左上角\n   */\n  adjustLocationAndSize() {\n    let rectangle: Rectangle;\n    const titleSize = getTextSize(this.text, Renderer.FONT_SIZE);\n\n    if (this.children.length === 0) {\n      rectangle = new Rectangle(\n        this.collisionBox.getRectangle().location,\n        new Vector(Math.max(titleSize.x + Renderer.NODE_PADDING * 2, 100), 100),\n      );\n    } else {\n      // 调整展开状态\n      rectangle = Rectangle.getBoundingRectangle(\n        this.children.map((child) => child.collisionBox.getRectangle()),\n        30,\n      );\n      rectangle.size.x = Math.max(rectangle.size.x, titleSize.x + Renderer.NODE_PADDING * 2);\n      // 留白范围在上面调整\n      rectangle.location = rectangle.location.subtract(new Vector(0, 50));\n      rectangle.size = rectangle.size.add(new Vector(0, 50));\n    }\n\n    this._collisionBoxNormal.shapes = rectangle.getBoundingLines();\n    // 群友需求：希望Section框扩大交互范围，标题也能拖动\n    const newRect = new Rectangle(rectangle.location.clone(), new Vector(rectangle.size.x, 50));\n    this._collisionBoxNormal.shapes.push(newRect);\n    // 调整折叠状态\n    this._collisionBoxWhenCollapsed = this.collapsedCollisionBox();\n  }\n  /**\n   * 根据自身的折叠状态调整子节点的状态\n   * 以屏蔽触碰和显示\n   */\n  adjustChildrenStateByCollapse() {\n    if (this.isCollapsed) {\n      this.children.forEach((child) => {\n        if (child instanceof Section) {\n          child.adjustChildrenStateByCollapse();\n        }\n        child.isHiddenBySectionCollapse = true;\n      });\n    }\n  }\n\n  /**\n   * 获取节点的选中状态\n   */\n  public get isSelected() {\n    return this._isSelected;\n  }\n  public set isSelected(value: boolean) {\n    this._isSelected = value;\n  }\n\n  /**\n   * 只读，获取节点的矩形\n   * 若要修改节点的矩形，请使用 moveTo等 方法\n   */\n  public get rectangle(): Rectangle {\n    if (this.isCollapsed) {\n      return this._collisionBoxWhenCollapsed.getRectangle();\n    } else {\n      const topLine: Line = this._collisionBoxNormal.shapes[0] as Line;\n      const rightLine: Line = this._collisionBoxNormal.shapes[1] as Line;\n      const bottomLine: Line = this._collisionBoxNormal.shapes[2] as Line;\n      const leftLine: Line = this._collisionBoxNormal.shapes[3] as Line;\n      return new Rectangle(\n        new Vector(leftLine.start.x, topLine.start.y),\n        new Vector(rightLine.end.x - leftLine.start.x, bottomLine.end.y - topLine.start.y),\n      );\n    }\n  }\n\n  public get geometryCenter() {\n    return this.rectangle.location.clone().add(this.rectangle.size.clone().multiply(0.5));\n  }\n\n  move(delta: Vector): void {\n    // 让自己移动\n    for (const shape of this.collisionBox.shapes) {\n      if (shape instanceof Line) {\n        shape.start = shape.start.add(delta);\n        shape.end = shape.end.add(delta);\n      } else if (shape instanceof Rectangle) {\n        shape.location = shape.location.add(delta);\n      }\n    }\n    // 让内部元素也移动\n    for (const child of this.children) {\n      // 跳过已被选中的子元素，避免重复移动（解决速度叠加bug）\n      if (child.isSelected) {\n        continue;\n      }\n      child.move(delta);\n    }\n\n    // 移动雪花特效\n    this.project.effects.addEffect(new NodeMoveShadowEffect(new ProgressNumber(0, 30), this.rectangle, delta));\n    this.updateFatherSectionByMove();\n    // 移动其他实体，递归碰撞\n    this.updateOtherEntityLocationByMove();\n  }\n  protected override collideWithOtherEntity(other: Entity): void {\n    if (!Settings.isEnableEntityCollision) {\n      return;\n    }\n    if (other instanceof Section) {\n      if (this.project.sectionMethods.isEntityInSection(this, other)) {\n        return;\n      }\n    }\n    if (this.project.sectionMethods.isEntityInSection(other, this)) {\n      return;\n    }\n    super.collideWithOtherEntity(other);\n  }\n\n  /**\n   * 将某个物体 的最小外接矩形的左上角位置 移动到某个位置\n   * @param location\n   */\n  moveTo(location: Vector): void {\n    const currentLeftTop = this.rectangle.location;\n    const delta = location.clone().subtract(currentLeftTop);\n    this.move(delta);\n    this.updateFatherSectionByMove();\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageObject/entity/SvgNode.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { ResizeAble } from \"@/core/stage/stageObject/abstract/StageObjectInterface\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { Color, Vector } from \"@graphif/data-structures\";\nimport { id, passExtraAtArg1, passObject, serializable } from \"@graphif/serializer\";\nimport { Rectangle } from \"@graphif/shapes\";\n\n/**\n * Svg 节点\n */\n@passExtraAtArg1\n@passObject\nexport class SvgNode extends ConnectableEntity implements ResizeAble {\n  @serializable\n  color: Color = Color.Transparent;\n  @id\n  @serializable\n  uuid: string;\n  @serializable\n  scale: number;\n  @serializable\n  collisionBox: CollisionBox;\n  @serializable\n  attachmentId: string;\n  isHiddenBySectionCollapse: boolean = false;\n\n  originalSize: Vector = Vector.getZero();\n  image: HTMLImageElement = new Image();\n\n  constructor(\n    protected readonly project: Project,\n    {\n      uuid = crypto.randomUUID(),\n      details = [],\n      attachmentId = \"\",\n      collisionBox = new CollisionBox([new Rectangle(Vector.getZero(), Vector.getZero())]),\n      scale = 1,\n      color = Color.Transparent,\n    },\n  ) {\n    super();\n    this.uuid = uuid;\n    this.details = details;\n    this.scale = scale;\n    this.attachmentId = attachmentId;\n    this.collisionBox = collisionBox;\n    this.color = color;\n\n    const blob = project.attachments.get(attachmentId);\n    if (!blob) {\n      return;\n    }\n    const url = URL.createObjectURL(blob);\n    this.image = new Image();\n    this.image.src = url;\n    this.image.onload = () => {\n      this.originalSize = new Vector(this.image.naturalWidth, this.image.naturalHeight);\n      this.collisionBox = new CollisionBox([\n        new Rectangle(this.collisionBox.getRectangle().location, this.originalSize.multiply(this.scale)),\n      ]);\n    };\n  }\n\n  public get geometryCenter(): Vector {\n    return this.collisionBox.getRectangle().center;\n  }\n\n  public scaleUpdate(scaleDiff: number) {\n    this.scale += scaleDiff;\n    if (this.scale < 0.1) {\n      this.scale = 0.1;\n    }\n    if (this.scale > 10) {\n      this.scale = 10;\n    }\n\n    this.collisionBox = new CollisionBox([\n      new Rectangle(this.collisionBox.getRectangle().location, this.originalSize.multiply(this.scale)),\n    ]);\n  }\n\n  move(delta: Vector): void {\n    const newRectangle = this.collisionBox.getRectangle().clone();\n    newRectangle.location = newRectangle.location.add(delta);\n    this.collisionBox.shapes[0] = newRectangle;\n    this.updateFatherSectionByMove();\n  }\n\n  moveTo(location: Vector): void {\n    const newRectangle = this.collisionBox.getRectangle().clone();\n    newRectangle.location = location.clone();\n    this.collisionBox.shapes[0] = newRectangle;\n    this.updateFatherSectionByMove();\n  }\n\n  /**\n   * 修改SVG内容中的颜色\n   * @param newColor 新颜色\n   * 并将修改后的SVG内容保存到project.attachments中，实现持久化存储\n   */\n  async changeColor(newColor: Color, mode: \"fill\" | \"stroke\" = \"fill\") {\n    // 先释放原来的objecturl\n    URL.revokeObjectURL(this.image.src);\n    this.color = newColor;\n    const hexColor = newColor.toHexStringWithoutAlpha();\n    // 先转换回svg代码\n    const svgCode = await this.project.attachments.get(this.attachmentId)?.text();\n    if (!svgCode) {\n      return;\n    }\n    let newSvgCode = svgCode;\n    if (mode === \"fill\") {\n      // 替换所有fill=\"xxxx\"格式为fill=\"新颜色\"\n      newSvgCode = svgCode.replace(/fill=\"[^\"]*\"/g, `fill=\"${hexColor}\"`);\n    } else if (mode === \"stroke\") {\n      // 替换所有stroke=\"xxxx\"格式为stroke=\"新颜色\"\n      newSvgCode = svgCode.replace(/stroke=\"[^\"]*\"/g, `stroke=\"${hexColor}\"`);\n    }\n    // 创建新的Blob\n    const newBlob = new Blob([newSvgCode], { type: \"image/svg+xml\" });\n\n    // 将修改后的SVG内容保存到project.attachments中，实现持久化存储\n    const newAttachmentId = this.project.addAttachment(newBlob);\n    // 更新当前节点的attachmentId\n    this.attachmentId = newAttachmentId;\n\n    // 重新创建image对象\n    const newUrl = URL.createObjectURL(newBlob);\n    this.image = new Image();\n    this.image.src = newUrl;\n    // 因为只是改了颜色所以不用重新计算大小\n  }\n\n  /**\n   * 处理拖拽缩放逻辑\n   * @param delta 拖拽距离向量\n   */\n  resizeHandle(delta: Vector) {\n    if (this.originalSize.x === 0 || this.originalSize.y === 0) return;\n\n    // 计算当前显示尺寸\n    const currentDisplayWidth = this.originalSize.x * this.scale;\n\n    // 根据delta计算新的显示尺寸（只使用delta.x，保持等比例缩放）\n    const newDisplayWidth = Math.max(currentDisplayWidth + delta.x, this.originalSize.x * 0.1);\n\n    // 计算新的缩放比例\n    const newScale = newDisplayWidth / this.originalSize.x;\n\n    // 更新缩放比例，使用现有的scaleUpdate方法保持一致性\n    const scaleDiff = newScale - this.scale;\n    this.scaleUpdate(scaleDiff);\n  }\n\n  /**\n   * 获取缩放控制点矩形\n   * 返回右下角的一个小矩形，用于拖拽缩放\n   */\n  getResizeHandleRect(): Rectangle {\n    // 确保collisionBox和rectangle都已初始化\n    const rect = this.collisionBox.getRectangle();\n    if (!rect) {\n      // 如果rect不存在，返回一个默认的矩形\n      return new Rectangle(Vector.same(0), new Vector(25, 25));\n    }\n    // 创建一个25x25的矩形，位于右下角\n    return new Rectangle(new Vector(rect.right - 25, rect.bottom - 25), new Vector(25, 25));\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageObject/entity/TextNode.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { Renderer } from \"@/core/render/canvas2d/renderer\";\nimport { NodeMoveShadowEffect } from \"@/core/service/feedbackService/effectEngine/concrete/NodeMoveShadowEffect\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { ResizeAble } from \"@/core/stage/stageObject/abstract/StageObjectInterface\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { getMultiLineTextSize } from \"@/utils/font\";\nimport { Color, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { id, passExtraAtArg1, passObject, serializable } from \"@graphif/serializer\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { Value } from \"platejs\";\n\n/**\n *\n * 文字节点类\n * 2024年10月20日：Node 改名为 TextNode，防止与 原生 Node 类冲突\n */\n@passExtraAtArg1\n@passObject\nexport class TextNode extends ConnectableEntity implements ResizeAble {\n  @id\n  @serializable\n  uuid: string;\n  @serializable\n  text: string;\n  @serializable\n  public collisionBox: CollisionBox;\n  @serializable\n  color: Color = Color.Transparent;\n\n  /**\n   * 是否正在使用AI生成\n   */\n  public isAiGenerating: boolean = false;\n\n  /**\n   * 字体缩放级别，整数，基准值为0，对应默认字体大小\n   * 计算公式：finalFontSize = Renderer.FONT_SIZE * Math.pow(2, fontScaleLevel)\n   */\n  @serializable\n  public fontScaleLevel: number = 0;\n\n  public static enableResizeCharCount = 20;\n\n  /**\n   * 调整大小的模式\n   * auto：自动缩紧\n   * manual：手动调整宽度，高度自动撑开。\n   */\n  @serializable\n  public sizeAdjust: string = \"auto\";\n\n  /**\n   * 节点是否被选中\n   */\n  _isSelected: boolean = false;\n\n  /**\n   * 获取节点的选中状态\n   */\n  public get isSelected() {\n    return this._isSelected;\n  }\n\n  /**\n   * 只读，获取节点的矩形\n   * 若要修改节点的矩形，请使用 moveTo等 方法\n   */\n  public get rectangle(): Rectangle {\n    return this.collisionBox.shapes[0] as Rectangle;\n  }\n\n  public get geometryCenter() {\n    return this.rectangle.location.clone().add(this.rectangle.size.clone().multiply(0.5));\n  }\n\n  public set isSelected(value: boolean) {\n    this._isSelected = value;\n  }\n\n  /**\n   * 是否在编辑文字，编辑时不渲染文字\n   */\n  _isEditing: boolean = false;\n\n  public get isEditing() {\n    return this._isEditing;\n  }\n\n  public set isEditing(value: boolean) {\n    this._isEditing = value;\n    this.project.textNodeRenderer.renderTextNode(this);\n    // 再主动渲染一次，确保即使渲染引擎停止，文字也能显示出来\n  }\n  isHiddenBySectionCollapse = false;\n\n  constructor(\n    protected readonly project: Project,\n    {\n      uuid = crypto.randomUUID() as string,\n      text = \"\",\n      details = [],\n      collisionBox = new CollisionBox([new Rectangle(Vector.getZero(), Vector.getZero())]),\n      color = Color.Transparent,\n      sizeAdjust = \"auto\",\n      fontScaleLevel = 0,\n    }: {\n      uuid?: string;\n      text?: string;\n      details?: Value;\n      color?: Color;\n      sizeAdjust?: \"auto\" | \"manual\";\n      collisionBox?: CollisionBox;\n      fontScaleLevel?: number;\n    },\n    public unknown = false,\n  ) {\n    super();\n    this.uuid = uuid;\n    this.text = text;\n    this.details = details;\n    this.collisionBox = collisionBox;\n    this.color = color;\n    this.sizeAdjust = sizeAdjust;\n    this.fontScaleLevel = fontScaleLevel;\n    // 初始化字体大小缓存\n    this.updateFontSizeCache();\n    // if (this.text.length < TextNode.enableResizeCharCount) {\n    //   this.adjustSizeByText();\n    // }\n    if (this.sizeAdjust === \"auto\") {\n      this.adjustSizeByText();\n    } else if (this.sizeAdjust === \"manual\") {\n      this.resizeHandle(Vector.getZero());\n    }\n  }\n\n  /**\n   * 字体大小缓存，避免重复计算\n   */\n  private fontSizeCache: number = Renderer.FONT_SIZE;\n\n  /**\n   * 获取当前字体大小\n   */\n  public getFontSize(): number {\n    return this.fontSizeCache;\n  }\n\n  /**\n   * 更新字体大小缓存\n   */\n  private updateFontSizeCache(): void {\n    this.fontSizeCache = Renderer.FONT_SIZE * Math.pow(2, this.fontScaleLevel);\n  }\n\n  public setFontScaleLevel(level: number) {\n    this.fontScaleLevel = level;\n    this.updateFontSizeCache();\n  }\n\n  /**\n   * 放大字体\n   * @param anchorRate 可选。缩放时保持固定的锚点（矩形内比例，如 (0.5,0.5) 为中心）。不传则保持左上角不变。\n   */\n  public increaseFontSize(anchorRate?: Vector): void {\n    this.fontScaleLevel++;\n    this.updateFontSizeCache();\n    if (this.sizeAdjust === \"auto\") {\n      const oldRect = this.rectangle.clone();\n      this.adjustSizeByText();\n      if (anchorRate) {\n        this._adjustLocationToKeepAnchor(oldRect, anchorRate);\n      }\n    }\n  }\n\n  /**\n   * 缩小字体\n   * @param anchorRate 可选。缩放时保持固定的锚点（矩形内比例）。不传则保持左上角不变。\n   */\n  public decreaseFontSize(anchorRate?: Vector): void {\n    this.fontScaleLevel--;\n    this.updateFontSizeCache();\n    if (this.sizeAdjust === \"auto\") {\n      const oldRect = this.rectangle.clone();\n      this.adjustSizeByText();\n      if (anchorRate) {\n        this._adjustLocationToKeepAnchor(oldRect, anchorRate);\n      }\n    }\n  }\n\n  /**\n   * 在尺寸已变更后，根据旧矩形和锚点比例调整 location，使锚点在世界坐标中保持不变\n   */\n  private _adjustLocationToKeepAnchor(oldRect: Rectangle, anchorRate: Vector): void {\n    const newSize = this.rectangle.size;\n    const locationDelta = new Vector(\n      (oldRect.size.x - newSize.x) * anchorRate.x,\n      (oldRect.size.y - newSize.y) * anchorRate.y,\n    );\n    this.moveTo(oldRect.location.clone().add(locationDelta));\n  }\n\n  /**\n   * 调整后的矩形是当前文字加了一圈padding之后的大小\n   */\n  private adjustSizeByText() {\n    this.collisionBox.shapes[0] = new Rectangle(\n      this.rectangle.location.clone(),\n      getMultiLineTextSize(this.text, this.getFontSize(), 1.5).add(Vector.same(Renderer.NODE_PADDING).multiply(2)),\n    );\n  }\n  private adjustHeightByText() {\n    const wrapWidth = this.rectangle.size.x - Renderer.NODE_PADDING * 2;\n    const newTextSize = this.project.textRenderer.measureMultiLineTextSize(\n      this.text,\n      this.getFontSize(),\n      wrapWidth,\n      1.5,\n    );\n    this.collisionBox.shapes[0] = new Rectangle(\n      this.rectangle.location.clone(),\n      new Vector(this.rectangle.size.x, newTextSize.y + Renderer.NODE_PADDING * 2),\n    );\n    this.updateFatherSectionByMove();\n  }\n  /**\n   * 强制触发自动调整大小\n   */\n  public forceAdjustSizeByText() {\n    this.adjustSizeByText();\n  }\n\n  // private adjustSizeByTextWidthLimitWidth(width: number) {\n  //   const currentSize = this.project.textRenderer.measureMultiLineTextSize(this.text, Renderer.FONT_SIZE, width, 1.5);\n  //   this.collisionBox.shapes[0] = new Rectangle(\n  //     this.rectangle.location.clone(),\n  //     currentSize.clone().add(Vector.same(Renderer.NODE_PADDING).multiply(2)),\n  //   );\n  // }\n\n  rename(text: string) {\n    this.text = text;\n    // if (this.text.length < TextNode.enableResizeCharCount) {\n    //   this.adjustSizeByText();\n    // }\n    if (this.sizeAdjust === \"auto\") {\n      this.adjustSizeByText();\n    } else if (this.sizeAdjust === \"manual\") {\n      this.adjustHeightByText();\n    }\n  }\n\n  resizeHandle(delta: Vector) {\n    const currentRect: Rectangle = this.collisionBox.shapes[0] as Rectangle;\n    const newRectangle = currentRect.clone();\n    // todo：宽度能自定义控制，但是高度不能\n    const newSize = newRectangle.size.add(delta);\n    newSize.x = Math.max(75, newSize.x);\n    const newTextSize = this.project.textRenderer.measureMultiLineTextSize(\n      this.text,\n      this.getFontSize(),\n      newSize.x - Renderer.NODE_PADDING * 2,\n      1.5,\n    );\n    newSize.y = newTextSize.y + Renderer.NODE_PADDING * 2;\n    newRectangle.size = newSize;\n\n    this.collisionBox.shapes[0] = newRectangle;\n    this.updateFatherSectionByMove();\n  }\n\n  resizeWidthTo(width: number) {\n    const currentWidth = this.rectangle.size.x;\n    this.resizeHandle(new Vector(width - currentWidth, 0));\n  }\n\n  getResizeHandleRect(): Rectangle {\n    const rect = this.collisionBox.getRectangle();\n    return new Rectangle(rect.rightTop, new Vector(25, rect.size.y));\n  }\n\n  /**\n   * 将某个物体移动一小段距离\n   * @param delta\n   */\n  move(delta: Vector) {\n    const newRectangle = this.rectangle.clone();\n    newRectangle.location = newRectangle.location.add(delta);\n    this.collisionBox.shapes[0] = newRectangle;\n\n    // 移动雪花特效\n    this.project.effects.addEffect(new NodeMoveShadowEffect(new ProgressNumber(0, 30), this.rectangle, delta));\n    this.updateFatherSectionByMove();\n    // 移动其他实体，递归碰撞\n    this.updateOtherEntityLocationByMove();\n  }\n\n  protected override collideWithOtherEntity(other: Entity): void {\n    if (!Settings.isEnableEntityCollision) {\n      return;\n    }\n    if (other instanceof Section) {\n      // 如果碰撞的东西是一个section\n      // 如果自己是section的子节点，则不移动\n      if (this.project.sectionMethods.isEntityInSection(this, other)) {\n        return;\n      }\n    }\n    super.collideWithOtherEntity(other);\n  }\n\n  /**\n   * 将某个物体 的最小外接矩形的左上角位置 移动到某个位置\n   * @param location\n   */\n  moveTo(location: Vector) {\n    const newRectangle = this.rectangle.clone();\n    newRectangle.location = location.clone();\n    this.collisionBox.shapes[0] = newRectangle;\n    this.updateFatherSectionByMove();\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageObject/entity/UrlNode.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { Renderer } from \"@/core/render/canvas2d/renderer\";\nimport { NodeMoveShadowEffect } from \"@/core/service/feedbackService/effectEngine/concrete/NodeMoveShadowEffect\";\nimport { ConnectableEntity } from \"@/core/stage/stageObject/abstract/ConnectableEntity\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { getTextSize } from \"@/utils/font\";\nimport { Color, ProgressNumber, Vector } from \"@graphif/data-structures\";\nimport { id, passExtraAtArg1, passObject, serializable } from \"@graphif/serializer\";\nimport { Rectangle } from \"@graphif/shapes\";\n\n/**\n * 网页链接节点\n * 通过在舞台上ctrl+v触发创建\n * 一旦创建，url就不能改了，因为也不涉及修改。\n */\n@passExtraAtArg1\n@passObject\nexport class UrlNode extends ConnectableEntity {\n  @id\n  @serializable\n  uuid: string;\n  @serializable\n  title: string;\n  // 网页链接\n  @serializable\n  url: string;\n  @serializable\n  color: Color;\n  @serializable\n  public collisionBox: CollisionBox;\n\n  static width: number = 300;\n  static height: number = 150;\n  /** 上半部分的高度 */\n  static titleHeight: number = 100;\n  /** 是否正在编辑标题 */\n  _isEditingTitle: boolean = false;\n  /** 鼠标是否悬浮在标题上 */\n  isMouseHoverTitle: boolean = false;\n  /** 鼠标是否悬浮在url上 */\n  isMouseHoverUrl: boolean = false;\n\n  public get isEditingTitle() {\n    return this._isEditingTitle;\n  }\n  public set isEditingTitle(value: boolean) {\n    this._isEditingTitle = value;\n    this.project.urlNodeRenderer.render(this);\n  }\n  get geometryCenter(): Vector {\n    return this.collisionBox.getRectangle().center;\n  }\n  /**\n   * 获取上方标题部分的矩形区域\n   */\n  get titleRectangle(): Rectangle {\n    const rect = this.rectangle;\n    return new Rectangle(rect.location, new Vector(rect.size.x, UrlNode.titleHeight));\n  }\n  get urlRectangle(): Rectangle {\n    const rect = this.rectangle;\n    return new Rectangle(\n      rect.location.add(new Vector(0, UrlNode.titleHeight)),\n      new Vector(rect.size.x, UrlNode.height - UrlNode.titleHeight),\n    );\n  }\n  /**\n   * 只读，获取节点的矩形\n   * 若要修改节点的矩形，请使用 moveTo等 方法\n   */\n  public get rectangle(): Rectangle {\n    return this.collisionBox.shapes[0] as Rectangle;\n  }\n  move(delta: Vector): void {\n    const newRectangle = this.rectangle.clone();\n    newRectangle.location = newRectangle.location.add(delta);\n    this.collisionBox.shapes[0] = newRectangle;\n\n    // 移动雪花特效\n    this.project.effects.addEffect(new NodeMoveShadowEffect(new ProgressNumber(0, 30), this.rectangle, delta));\n    this.updateFatherSectionByMove();\n    // 移动其他实体，递归碰撞\n    this.updateOtherEntityLocationByMove();\n  }\n  /**\n   * 将某个物体 的最小外接矩形的左上角位置 移动到某个位置\n   * @param location\n   */\n  moveTo(location: Vector): void {\n    const newRectangle = this.rectangle.clone();\n    newRectangle.location = location.clone();\n    this.collisionBox.shapes[0] = newRectangle;\n    this.updateFatherSectionByMove();\n  }\n  isHiddenBySectionCollapse: boolean = false;\n\n  constructor(\n    protected readonly project: Project,\n    {\n      uuid = crypto.randomUUID() as string,\n      title = \"\",\n      details = [],\n      url = \"\",\n      collisionBox = new CollisionBox([new Rectangle(Vector.getZero(), new Vector(UrlNode.width, UrlNode.height))]),\n      color = Color.Transparent,\n    },\n  ) {\n    super();\n    this.uuid = uuid;\n    this.details = details;\n    this.title = title;\n    this.url = url;\n    this.color = color;\n    this.collisionBox = collisionBox;\n  }\n\n  rename(title: string): void {\n    this.title = title;\n    this.adjustSizeByText();\n  }\n\n  private adjustSizeByText() {\n    const newSize = getTextSize(this.title, Renderer.FONT_SIZE).add(Vector.same(Renderer.NODE_PADDING).multiply(2));\n    newSize.x = Math.max(newSize.x, UrlNode.width);\n    newSize.y = Math.max(newSize.y, UrlNode.height);\n    this.collisionBox.shapes[0] = new Rectangle(this.rectangle.location.clone(), newSize);\n  }\n}\n"
  },
  {
    "path": "app/src/core/stage/stageObject/tools/entityDetailsManager.tsx",
    "content": "import { Entity } from \"../abstract/StageEntity\";\nimport { BasicBlocksKit } from \"@/components/editor/plugins/basic-blocks-kit\";\nimport { BasicMarksKit } from \"@/components/editor/plugins/basic-marks-kit\";\nimport { CodeBlockKit } from \"@/components/editor/plugins/code-block-kit\";\nimport { FixedToolbarKit } from \"@/components/editor/plugins/fixed-toolbar-kit\";\nimport { FloatingToolbarKit } from \"@/components/editor/plugins/floating-toolbar-kit\";\nimport { FontKit } from \"@/components/editor/plugins/font-kit\";\nimport { LinkKit } from \"@/components/editor/plugins/link-kit\";\nimport { ListKit } from \"@/components/editor/plugins/list-kit\";\nimport { MathKit } from \"@/components/editor/plugins/math-kit\";\nimport { TableKit } from \"@/components/editor/plugins/table-kit\";\nimport { MarkdownPlugin } from \"@platejs/markdown\";\nimport remarkBreaks from \"remark-breaks\";\nimport remarkGfm from \"remark-gfm\";\nimport remarkMath from \"remark-math\";\nimport { Value } from \"platejs\";\nimport { createPlateEditor } from \"platejs/react\";\n\n/**\n * 详细信息管理器\n */\nexport class DetailsManager {\n  constructor(private entity: Entity) {}\n\n  public isEmpty() {\n    if (this.entity.details.length === 0) {\n      return true;\n    } else {\n      const firstItem = this.entity.details[0];\n      if (firstItem.type === \"p\") {\n        const firstChildren = firstItem.children[0];\n        if (firstChildren.text === \"\") {\n          return true;\n        }\n      }\n    }\n    return false;\n  }\n\n  /**\n   * 获取这个详细信息能被搜索到的字符串\n   * @returns\n   */\n  public getBeSearchingText(): string {\n    if (this.isEmpty()) {\n      return \"\";\n    } else {\n      return DetailsManager.detailsToMarkdown(this.entity.details);\n    }\n  }\n\n  private cacheMap: Map<Value, string> = new Map();\n  /**\n   * 获取用于渲染在舞台上的字符串\n   * @returns\n   */\n  public getRenderStageString(): string {\n    if (this.isEmpty()) {\n      return \"\";\n    } else {\n      if (this.cacheMap.has(this.entity.details)) {\n        return this.cacheMap.get(this.entity.details)!;\n      } else {\n        const markdown = DetailsManager.detailsToMarkdown(this.entity.details).replace(\"\\n\\n\", \"\\n\");\n        this.cacheMap.set(this.entity.details, markdown);\n        return markdown;\n      }\n    }\n  }\n\n  /**\n   * 将详细信息(platejs格式)转换为markdown字符串\n   * 可能用于：被搜索、渲染在舞台上、详略交换\n   * @param details platejs的Value格式内容\n   * @returns markdown字符串\n   */\n  public static detailsToMarkdown(details: Value) {\n    try {\n      const editor = createPlateEditor({\n        plugins: [\n          ...FloatingToolbarKit,\n          ...FixedToolbarKit,\n          ...BasicMarksKit,\n          ...BasicBlocksKit,\n          ...FontKit,\n          ...TableKit,\n          ...MathKit,\n          ...CodeBlockKit,\n          ...ListKit,\n          ...LinkKit,\n          MarkdownPlugin,\n        ],\n      });\n      editor.children = details;\n      const markdown = editor.api.markdown.serialize();\n      return markdown;\n    } catch (error) {\n      // TODO: 先暂时这样处理一下，后面再看如何导出成更好的markdown字符串\n      // 这里先记录一个触发错误的情况，就是富文本\n      // [{\"children\":[{\"text\":\"gfw的泄露\",\"fontFamily\":\"\\\"PingFang SC\\\", HarmonyOS_Regular, \\\"Helvetica Neue\\\", \\\"Microsoft YaHei\\\", sans-serif\",\"fontSize\":\"17px\",\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgb(47, 50, 56)\"}],\"type\":\"p\",\"id\":\"EVpKUIdRu5\"}]\n\n      console.error(error);\n      return JSON.stringify(details);\n    }\n  }\n\n  public static markdownToDetails(md: string) {\n    const editor = createPlateEditor({\n      plugins: [\n        ...FloatingToolbarKit,\n        ...FixedToolbarKit,\n        ...BasicMarksKit,\n        ...BasicBlocksKit,\n        ...FontKit,\n        ...TableKit,\n        ...MathKit,\n        ...CodeBlockKit,\n        ...ListKit,\n        ...LinkKit,\n        MarkdownPlugin,\n      ],\n    });\n    const value = editor.api.markdown.deserialize(md, {\n      remarkPlugins: [remarkGfm, remarkMath, remarkBreaks],\n    });\n    return value;\n  }\n\n  /**\n   * 合并多个详细信息为一个\n   * @param detailsList 要合并的详细信息列表\n   * @returns 合并后的详细信息\n   */\n  public static mergeDetails(detailsList: Value[]): Value {\n    // 创建一个空的Value数组\n    const mergedDetails: Value = [];\n\n    // 遍历所有details，将它们的内容合并到一个数组中\n    for (const details of detailsList) {\n      mergedDetails.push(...details);\n    }\n\n    return mergedDetails;\n  }\n}\n"
  },
  {
    "path": "app/src/css/index.css",
    "content": "@import \"tailwindcss\";\n\n@plugin \"tailwind-scrollbar-hide\";\n@import \"tw-animate-css\";\n\n@custom-variant page (&.active);\n/* @theme {\n  --ease-in-expo: cubic-bezier(0.95, 0.05, 0.795, 0.035);\n  --ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);\n} */\n\n@layer components {\n  * {\n    @apply box-border cursor-default select-none [-webkit-user-drag:none];\n    font-family: -apple-system, BlinkMacSystemFont, system-ui, \"MiSans\", \"Noto Sans CJK SC\", sans-serif;\n    font-antialiasing: none;\n    -webkit-font-smoothing: antialiased;\n    -moz-osx-font-smoothing: grayscale;\n  }\n  .cursor-pointer > svg.lucide,\n  .cursor-pointer > svg.lucide *,\n  .\\*\\:cursor-pointer > * > svg.lucide,\n  .\\*\\:cursor-pointer > * > svg.lucide * {\n    @apply cursor-pointer;\n  }\n  /* svg.lucide {\n    width: 1.5em;\n    height: 1.5em;\n  } */\n}\n\n@theme inline {\n  --radius-sm: calc(var(--radius) - 4px);\n  --radius-md: calc(var(--radius) - 2px);\n  --radius-lg: var(--radius);\n  --radius-xl: calc(var(--radius) + 4px);\n  --color-background: var(--background);\n  --color-foreground: var(--foreground);\n  --color-card: var(--card);\n  --color-card-foreground: var(--card-foreground);\n  --color-popover: var(--popover);\n  --color-popover-foreground: var(--popover-foreground);\n  --color-primary: var(--primary);\n  --color-primary-foreground: var(--primary-foreground);\n  --color-secondary: var(--secondary);\n  --color-secondary-foreground: var(--secondary-foreground);\n  --color-muted: var(--muted);\n  --color-muted-foreground: var(--muted-foreground);\n  --color-accent: var(--accent);\n  --color-accent-foreground: var(--accent-foreground);\n  --color-destructive: var(--destructive);\n  --color-border: var(--border);\n  --color-input: var(--input);\n  --color-ring: var(--ring);\n  --color-chart-1: var(--chart-1);\n  --color-chart-2: var(--chart-2);\n  --color-chart-3: var(--chart-3);\n  --color-chart-4: var(--chart-4);\n  --color-chart-5: var(--chart-5);\n  --color-sidebar: var(--sidebar);\n  --color-sidebar-foreground: var(--sidebar-foreground);\n  --color-sidebar-primary: var(--sidebar-primary);\n  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);\n  --color-sidebar-accent: var(--sidebar-accent);\n  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\n  --color-sidebar-border: var(--sidebar-border);\n  --color-sidebar-ring: var(--sidebar-ring);\n  --color-brand: var(--brand);\n  --color-highlight: var(--highlight);\n}\n\n:root {\n  --radius: 0.625rem;\n  --highlight: oklch(0.852 0.199 91.936);\n}\n\n@layer base {\n  * {\n    @apply border-border outline-ring/50;\n  }\n  body {\n    @apply bg-background text-foreground;\n  }\n}\n\n@theme {\n  --animate-collapsible-down: collapsible-down 0.2s ease-out;\n  --animate-collapsible-up: collapsible-up 0.2s ease-out;\n  --animate-blink: blink 1s infinite;\n\n  @keyframes collapsible-down {\n    from {\n      height: 0;\n      opacity: 0;\n    }\n    to {\n      height: var(--radix-collapsible-content-height);\n      opacity: 1;\n    }\n  }\n\n  @keyframes collapsible-up {\n    from {\n      height: var(--radix-collapsible-content-height);\n      opacity: 1;\n    }\n    to {\n      height: 0;\n      opacity: 0;\n    }\n  }\n\n  @keyframes blink {\n    0%, 100% {\n      opacity: 1;\n    }\n    50% {\n      opacity: 0.5;\n    }\n  }\n}\n\n.dark {\n  --highlight: oklch(0.852 0.199 91.936);\n}\n\n/* 赫然出现一些白色滚动条很不好看，先写一个基础样式覆盖一下 */\n::-webkit-scrollbar {\n  width: 4px;\n  height: 4px;\n}\n\n::-webkit-scrollbar-track {\n  background: rgba(87, 95, 107, 0.1);\n}\n\n::-webkit-scrollbar-thumb {\n  background: rgba(87, 95, 107, 0.4);\n  border-radius: 2px;\n}\n\n::-webkit-scrollbar-thumb:hover {\n  background: rgba(87, 95, 107, 0.6);\n}\n"
  },
  {
    "path": "app/src/css/markdown.css",
    "content": ".markdown-body {\n  margin: 0;\n  line-height: 1.5;\n  word-wrap: break-word;\n}\n\n.markdown-body details,\n.markdown-body figcaption,\n.markdown-body figure {\n  display: block;\n}\n\n.markdown-body summary {\n  display: list-item;\n}\n\n.markdown-body [hidden] {\n  display: none !important;\n}\n\n.markdown-body a {\n  background-color: transparent;\n  color: #4493f8;\n  text-decoration: none;\n}\n\n.markdown-body abbr[title] {\n  border-bottom: none;\n  -webkit-text-decoration: underline dotted;\n  text-decoration: underline dotted;\n}\n\n.markdown-body b,\n.markdown-body strong {\n  font-weight: 600;\n}\n\n.markdown-body dfn {\n  font-style: italic;\n}\n\n.markdown-body h1 {\n  margin: 0.67em 0;\n  font-weight: 600;\n  padding-bottom: 0.3em;\n  font-size: 2em;\n  border-bottom: 1px solid #3d444db3;\n}\n\n.markdown-body small {\n  font-size: 90%;\n}\n\n.markdown-body sub,\n.markdown-body sup {\n  font-size: 75%;\n  line-height: 0;\n  position: relative;\n  vertical-align: baseline;\n}\n\n.markdown-body sub {\n  bottom: -0.25em;\n}\n\n.markdown-body sup {\n  top: -0.5em;\n}\n\n.markdown-body img {\n  border-style: none;\n  max-width: 100%;\n  box-sizing: content-box;\n}\n\n.markdown-body code,\n.markdown-body kbd,\n.markdown-body pre,\n.markdown-body samp {\n  font-family: monospace !important;\n  font-size: 1em;\n}\n\n.markdown-body figure {\n  margin: 1em 2.5rem;\n}\n\n.markdown-body hr {\n  box-sizing: content-box;\n  overflow: hidden;\n  background: transparent;\n  border-bottom: 1px solid #3d444db3;\n  height: 0.25em;\n  padding: 0;\n  margin: 1.5rem 0;\n  background-color: #3d444d;\n  border: 0;\n}\n\n.markdown-body input {\n  font: inherit;\n  margin: 0;\n  overflow: visible;\n  font-family: inherit;\n  font-size: inherit;\n  line-height: inherit;\n}\n\n.markdown-body [type=\"button\"],\n.markdown-body [type=\"reset\"],\n.markdown-body [type=\"submit\"] {\n  -webkit-appearance: button;\n  appearance: button;\n}\n\n.markdown-body [type=\"checkbox\"],\n.markdown-body [type=\"radio\"] {\n  box-sizing: border-box;\n  padding: 0;\n}\n\n.markdown-body [type=\"number\"]::-webkit-inner-spin-button,\n.markdown-body [type=\"number\"]::-webkit-outer-spin-button {\n  height: auto;\n}\n\n.markdown-body [type=\"search\"]::-webkit-search-cancel-button,\n.markdown-body [type=\"search\"]::-webkit-search-decoration {\n  -webkit-appearance: none;\n  appearance: none;\n}\n\n.markdown-body ::-webkit-input-placeholder {\n  color: inherit;\n  opacity: 0.54;\n}\n\n.markdown-body ::-webkit-file-upload-button {\n  -webkit-appearance: button;\n  appearance: button;\n  font: inherit;\n}\n\n.markdown-body a:hover {\n  text-decoration: underline;\n}\n\n.markdown-body ::placeholder {\n  color: #9198a1;\n  opacity: 1;\n}\n\n.markdown-body hr::before {\n  display: table;\n  content: \"\";\n}\n\n.markdown-body hr::after {\n  display: table;\n  clear: both;\n  content: \"\";\n}\n\n.markdown-body table {\n  border-spacing: 0;\n  border-collapse: collapse;\n  display: block;\n  width: max-content;\n  max-width: 100%;\n  overflow: auto;\n  font-variant: tabular-nums;\n}\n\n.markdown-body td,\n.markdown-body th {\n  padding: 0;\n}\n\n.markdown-body details summary {\n  cursor: pointer;\n}\n\n.markdown-body a:focus,\n.markdown-body [role=\"button\"]:focus,\n.markdown-body input[type=\"radio\"]:focus,\n.markdown-body input[type=\"checkbox\"]:focus {\n  outline: 2px solid #1f6feb;\n  outline-offset: -2px;\n  box-shadow: none;\n}\n\n.markdown-body a:focus:not(:focus-visible),\n.markdown-body [role=\"button\"]:focus:not(:focus-visible),\n.markdown-body input[type=\"radio\"]:focus:not(:focus-visible),\n.markdown-body input[type=\"checkbox\"]:focus:not(:focus-visible) {\n  outline: solid 1px transparent;\n}\n\n.markdown-body a:focus-visible,\n.markdown-body [role=\"button\"]:focus-visible,\n.markdown-body input[type=\"radio\"]:focus-visible,\n.markdown-body input[type=\"checkbox\"]:focus-visible {\n  outline: 2px solid #1f6feb;\n  outline-offset: -2px;\n  box-shadow: none;\n}\n\n.markdown-body a:not([class]):focus,\n.markdown-body a:not([class]):focus-visible,\n.markdown-body input[type=\"radio\"]:focus,\n.markdown-body input[type=\"radio\"]:focus-visible,\n.markdown-body input[type=\"checkbox\"]:focus,\n.markdown-body input[type=\"checkbox\"]:focus-visible {\n  outline-offset: 0;\n}\n\n.markdown-body kbd {\n  display: inline-block;\n  padding: 0.25rem;\n  font:\n    11px ui-monospace,\n    SFMono-Regular,\n    SF Mono,\n    Menlo,\n    Consolas,\n    Liberation Mono,\n    monospace;\n  line-height: 10px;\n  color: #f0f6fc;\n  vertical-align: middle;\n  background-color: #151b23;\n  border: solid 1px #3d444db3;\n  border-bottom-color: #3d444db3;\n  border-radius: 6px;\n  box-shadow: inset 0 -1px 0 #3d444db3;\n}\n\n.markdown-body h1,\n.markdown-body h2,\n.markdown-body h3,\n.markdown-body h4,\n.markdown-body h5,\n.markdown-body h6 {\n  margin-top: 1.5rem;\n  margin-bottom: 1rem;\n  font-weight: 600;\n  line-height: 1.25;\n}\n\n.markdown-body h2 {\n  font-weight: 600;\n  padding-bottom: 0.3em;\n  font-size: 1.5em;\n  border-bottom: 1px solid #3d444db3;\n}\n\n.markdown-body h3 {\n  font-weight: 600;\n  font-size: 1.25em;\n}\n\n.markdown-body h4 {\n  font-weight: 600;\n  font-size: 1em;\n}\n\n.markdown-body h5 {\n  font-weight: 600;\n  font-size: 0.875em;\n}\n\n.markdown-body h6 {\n  font-weight: 600;\n  font-size: 0.85em;\n  color: #9198a1;\n}\n\n.markdown-body p {\n  margin-top: 0;\n  margin-bottom: 10px;\n}\n\n.markdown-body blockquote {\n  margin: 0;\n  padding: 0 1em;\n  color: #9198a1;\n  border-left: 0.25em solid #3d444d;\n}\n\n.markdown-body ul,\n.markdown-body ol {\n  margin-top: 0;\n  margin-bottom: 0;\n  padding-left: 2em;\n}\n\n.markdown-body ol ol,\n.markdown-body ul ol {\n  list-style-type: lower-roman;\n}\n\n.markdown-body ul ul ol,\n.markdown-body ul ol ol,\n.markdown-body ol ul ol,\n.markdown-body ol ol ol {\n  list-style-type: lower-alpha;\n}\n\n.markdown-body dd {\n  margin-left: 0;\n}\n\n.markdown-body tt,\n.markdown-body code,\n.markdown-body samp {\n  font-family:\n    ui-monospace,\n    SFMono-Regular,\n    SF Mono,\n    Menlo,\n    Consolas,\n    Liberation Mono,\n    monospace;\n  font-size: 12px;\n}\n\n.markdown-body pre {\n  margin-top: 0;\n  margin-bottom: 0;\n  font-family:\n    ui-monospace,\n    SFMono-Regular,\n    SF Mono,\n    Menlo,\n    Consolas,\n    Liberation Mono,\n    monospace;\n  font-size: 12px;\n  word-wrap: normal;\n}\n\n.markdown-body .octicon {\n  display: inline-block;\n  overflow: visible !important;\n  vertical-align: text-bottom;\n  fill: currentColor;\n}\n\n.markdown-body input::-webkit-outer-spin-button,\n.markdown-body input::-webkit-inner-spin-button {\n  margin: 0;\n  appearance: none;\n}\n\n.markdown-body .mr-2 {\n  margin-right: 0.5rem !important;\n}\n\n.markdown-body::before {\n  display: table;\n  content: \"\";\n}\n\n.markdown-body::after {\n  display: table;\n  clear: both;\n  content: \"\";\n}\n\n.markdown-body > *:first-child {\n  margin-top: 0 !important;\n}\n\n.markdown-body > *:last-child {\n  margin-bottom: 0 !important;\n}\n\n.markdown-body a:not([href]) {\n  color: inherit;\n  text-decoration: none;\n}\n\n.markdown-body .absent {\n  color: #f85149;\n}\n\n.markdown-body .anchor {\n  float: left;\n  padding-right: 0.25rem;\n  margin-left: -20px;\n  line-height: 1;\n}\n\n.markdown-body .anchor:focus {\n  outline: none;\n}\n\n.markdown-body p,\n.markdown-body blockquote,\n.markdown-body ul,\n.markdown-body ol,\n.markdown-body dl,\n.markdown-body table,\n.markdown-body pre,\n.markdown-body details {\n  margin-top: 0;\n  margin-bottom: 1rem;\n}\n\n.markdown-body blockquote > :first-child {\n  margin-top: 0;\n}\n\n.markdown-body blockquote > :last-child {\n  margin-bottom: 0;\n}\n\n.markdown-body h1 .octicon-link,\n.markdown-body h2 .octicon-link,\n.markdown-body h3 .octicon-link,\n.markdown-body h4 .octicon-link,\n.markdown-body h5 .octicon-link,\n.markdown-body h6 .octicon-link {\n  color: #f0f6fc;\n  vertical-align: middle;\n  visibility: hidden;\n}\n\n.markdown-body h1:hover .anchor,\n.markdown-body h2:hover .anchor,\n.markdown-body h3:hover .anchor,\n.markdown-body h4:hover .anchor,\n.markdown-body h5:hover .anchor,\n.markdown-body h6:hover .anchor {\n  text-decoration: none;\n}\n\n.markdown-body h1:hover .anchor .octicon-link,\n.markdown-body h2:hover .anchor .octicon-link,\n.markdown-body h3:hover .anchor .octicon-link,\n.markdown-body h4:hover .anchor .octicon-link,\n.markdown-body h5:hover .anchor .octicon-link,\n.markdown-body h6:hover .anchor .octicon-link {\n  visibility: visible;\n}\n\n.markdown-body h1 tt,\n.markdown-body h1 code,\n.markdown-body h2 tt,\n.markdown-body h2 code,\n.markdown-body h3 tt,\n.markdown-body h3 code,\n.markdown-body h4 tt,\n.markdown-body h4 code,\n.markdown-body h5 tt,\n.markdown-body h5 code,\n.markdown-body h6 tt,\n.markdown-body h6 code {\n  padding: 0 0.2em;\n  font-size: inherit;\n}\n\n.markdown-body summary h1,\n.markdown-body summary h2,\n.markdown-body summary h3,\n.markdown-body summary h4,\n.markdown-body summary h5,\n.markdown-body summary h6 {\n  display: inline-block;\n}\n\n.markdown-body summary h1 .anchor,\n.markdown-body summary h2 .anchor,\n.markdown-body summary h3 .anchor,\n.markdown-body summary h4 .anchor,\n.markdown-body summary h5 .anchor,\n.markdown-body summary h6 .anchor {\n  margin-left: -40px;\n}\n\n.markdown-body summary h1,\n.markdown-body summary h2 {\n  padding-bottom: 0;\n  border-bottom: 0;\n}\n\n.markdown-body ul.no-list,\n.markdown-body ol.no-list {\n  padding: 0;\n  list-style-type: none;\n}\n\n.markdown-body ol[type=\"a s\"] {\n  list-style-type: lower-alpha;\n}\n\n.markdown-body ol[type=\"A s\"] {\n  list-style-type: upper-alpha;\n}\n\n.markdown-body ol[type=\"i s\"] {\n  list-style-type: lower-roman;\n}\n\n.markdown-body ol[type=\"I s\"] {\n  list-style-type: upper-roman;\n}\n\n.markdown-body ol[type=\"1\"] {\n  list-style-type: decimal;\n}\n\n.markdown-body div > ol:not([type]) {\n  list-style-type: decimal;\n}\n\n.markdown-body ul ul,\n.markdown-body ul ol,\n.markdown-body ol ol,\n.markdown-body ol ul {\n  margin-top: 0;\n  margin-bottom: 0;\n}\n\n.markdown-body li > p {\n  margin-top: 1rem;\n}\n\n.markdown-body li + li {\n  margin-top: 0.25em;\n}\n\n.markdown-body dl {\n  padding: 0;\n}\n\n.markdown-body dl dt {\n  padding: 0;\n  margin-top: 1rem;\n  font-size: 1em;\n  font-style: italic;\n  font-weight: 600;\n}\n\n.markdown-body dl dd {\n  padding: 0 1rem;\n  margin-bottom: 1rem;\n}\n\n.markdown-body table th {\n  font-weight: 600;\n}\n\n.markdown-body table th,\n.markdown-body table td {\n  padding: 6px 13px;\n  border: 1px solid #3d444d;\n}\n\n.markdown-body table td > :last-child {\n  margin-bottom: 0;\n}\n\n.markdown-body table tr {\n  background-color: #0d1117;\n  border-top: 1px solid #3d444db3;\n}\n\n.markdown-body table tr:nth-child(2n) {\n  background-color: #151b23;\n}\n\n.markdown-body table img {\n  background-color: transparent;\n}\n\n.markdown-body img[align=\"right\"] {\n  padding-left: 20px;\n}\n\n.markdown-body img[align=\"left\"] {\n  padding-right: 20px;\n}\n\n.markdown-body .emoji {\n  max-width: none;\n  vertical-align: text-top;\n  background-color: transparent;\n}\n\n.markdown-body span.frame {\n  display: block;\n  overflow: hidden;\n}\n\n.markdown-body span.frame > span {\n  display: block;\n  float: left;\n  width: auto;\n  padding: 7px;\n  margin: 13px 0 0;\n  overflow: hidden;\n  border: 1px solid #3d444d;\n}\n\n.markdown-body span.frame span img {\n  display: block;\n  float: left;\n}\n\n.markdown-body span.frame span span {\n  display: block;\n  padding: 5px 0 0;\n  clear: both;\n  color: #f0f6fc;\n}\n\n.markdown-body span.align-center {\n  display: block;\n  overflow: hidden;\n  clear: both;\n}\n\n.markdown-body span.align-center > span {\n  display: block;\n  margin: 13px auto 0;\n  overflow: hidden;\n  text-align: center;\n}\n\n.markdown-body span.align-center span img {\n  margin: 0 auto;\n  text-align: center;\n}\n\n.markdown-body span.align-right {\n  display: block;\n  overflow: hidden;\n  clear: both;\n}\n\n.markdown-body span.align-right > span {\n  display: block;\n  margin: 13px 0 0;\n  overflow: hidden;\n  text-align: right;\n}\n\n.markdown-body span.align-right span img {\n  margin: 0;\n  text-align: right;\n}\n\n.markdown-body span.float-left {\n  display: block;\n  float: left;\n  margin-right: 13px;\n  overflow: hidden;\n}\n\n.markdown-body span.float-left span {\n  margin: 13px 0 0;\n}\n\n.markdown-body span.float-right {\n  display: block;\n  float: right;\n  margin-left: 13px;\n  overflow: hidden;\n}\n\n.markdown-body span.float-right > span {\n  display: block;\n  margin: 13px auto 0;\n  overflow: hidden;\n  text-align: right;\n}\n\n.markdown-body code,\n.markdown-body tt {\n  padding: 0.2em 0.4em;\n  margin: 0;\n  font-size: 85%;\n  white-space: break-spaces;\n  background-color: #656c7633;\n  border-radius: 6px;\n}\n\n.markdown-body code br,\n.markdown-body tt br {\n  display: none;\n}\n\n.markdown-body del code {\n  text-decoration: inherit;\n}\n\n.markdown-body samp {\n  font-size: 85%;\n}\n\n.markdown-body pre code {\n  font-size: 100%;\n}\n\n.markdown-body pre > code {\n  padding: 0;\n  margin: 0;\n  word-break: normal;\n  white-space: pre;\n  background: transparent;\n  border: 0;\n}\n\n.markdown-body .highlight {\n  margin-bottom: 1rem;\n}\n\n.markdown-body .highlight pre {\n  margin-bottom: 0;\n  word-break: normal;\n}\n\n.markdown-body .highlight pre,\n.markdown-body pre {\n  padding: 1rem;\n  overflow: auto;\n  font-size: 85%;\n  line-height: 1.45;\n  color: #f0f6fc;\n  background-color: #151b23;\n  border-radius: 6px;\n}\n\n.markdown-body pre code,\n.markdown-body pre tt {\n  display: inline;\n  max-width: auto;\n  padding: 0;\n  margin: 0;\n  overflow: visible;\n  line-height: inherit;\n  word-wrap: normal;\n  background-color: transparent;\n  border: 0;\n}\n\n.markdown-body .csv-data td,\n.markdown-body .csv-data th {\n  padding: 5px;\n  overflow: hidden;\n  font-size: 12px;\n  line-height: 1;\n  text-align: left;\n  white-space: nowrap;\n}\n\n.markdown-body .csv-data .blob-num {\n  padding: 10px 0.5rem 9px;\n  text-align: right;\n  background: #0d1117;\n  border: 0;\n}\n\n.markdown-body .csv-data tr {\n  border-top: 0;\n}\n\n.markdown-body .csv-data th {\n  font-weight: 600;\n  background: #151b23;\n  border-top: 0;\n}\n\n.markdown-body [data-footnote-ref]::before {\n  content: \"[\";\n}\n\n.markdown-body [data-footnote-ref]::after {\n  content: \"]\";\n}\n\n.markdown-body .footnotes {\n  font-size: 12px;\n  color: #9198a1;\n  border-top: 1px solid #3d444d;\n}\n\n.markdown-body .footnotes ol {\n  padding-left: 1rem;\n}\n\n.markdown-body .footnotes ol ul {\n  display: inline-block;\n  padding-left: 1rem;\n  margin-top: 1rem;\n}\n\n.markdown-body .footnotes li {\n  position: relative;\n}\n\n.markdown-body .footnotes li:target::before {\n  position: absolute;\n  top: calc(0.5rem * -1);\n  right: calc(0.5rem * -1);\n  bottom: calc(0.5rem * -1);\n  left: calc(1.5rem * -1);\n  pointer-events: none;\n  content: \"\";\n  border: 2px solid #1f6feb;\n  border-radius: 6px;\n}\n\n.markdown-body .footnotes li:target {\n  color: #f0f6fc;\n}\n\n.markdown-body .footnotes .data-footnote-backref g-emoji {\n  font-family: monospace;\n}\n\n.markdown-body body:has(:modal) {\n  padding-right: var(--dialog-scrollgutter) !important;\n}\n\n.markdown-body .pl-c {\n  color: #9198a1;\n}\n\n.markdown-body .pl-c1,\n.markdown-body .pl-s .pl-v {\n  color: #79c0ff;\n}\n\n.markdown-body .pl-e,\n.markdown-body .pl-en {\n  color: #d2a8ff;\n}\n\n.markdown-body .pl-smi,\n.markdown-body .pl-s .pl-s1 {\n  color: #f0f6fc;\n}\n\n.markdown-body .pl-ent {\n  color: #7ee787;\n}\n\n.markdown-body .pl-k {\n  color: #ff7b72;\n}\n\n.markdown-body .pl-s,\n.markdown-body .pl-pds,\n.markdown-body .pl-s .pl-pse .pl-s1,\n.markdown-body .pl-sr,\n.markdown-body .pl-sr .pl-cce,\n.markdown-body .pl-sr .pl-sre,\n.markdown-body .pl-sr .pl-sra {\n  color: #a5d6ff;\n}\n\n.markdown-body .pl-v,\n.markdown-body .pl-smw {\n  color: #ffa657;\n}\n\n.markdown-body .pl-bu {\n  color: #f85149;\n}\n\n.markdown-body .pl-ii {\n  color: #f0f6fc;\n  background-color: #8e1519;\n}\n\n.markdown-body .pl-c2 {\n  color: #f0f6fc;\n  background-color: #b62324;\n}\n\n.markdown-body .pl-sr .pl-cce {\n  font-weight: bold;\n  color: #7ee787;\n}\n\n.markdown-body .pl-ml {\n  color: #f2cc60;\n}\n\n.markdown-body .pl-mh,\n.markdown-body .pl-mh .pl-en,\n.markdown-body .pl-ms {\n  font-weight: bold;\n  color: #1f6feb;\n}\n\n.markdown-body .pl-mi {\n  font-style: italic;\n  color: #f0f6fc;\n}\n\n.markdown-body .pl-mb {\n  font-weight: bold;\n  color: #f0f6fc;\n}\n\n.markdown-body .pl-md {\n  color: #ffdcd7;\n  background-color: #67060c;\n}\n\n.markdown-body .pl-mi1 {\n  color: #aff5b4;\n  background-color: #033a16;\n}\n\n.markdown-body .pl-mc {\n  color: #ffdfb6;\n  background-color: #5a1e02;\n}\n\n.markdown-body .pl-mi2 {\n  color: #f0f6fc;\n  background-color: #1158c7;\n}\n\n.markdown-body .pl-mdr {\n  font-weight: bold;\n  color: #d2a8ff;\n}\n\n.markdown-body .pl-ba {\n  color: #9198a1;\n}\n\n.markdown-body .pl-sg {\n  color: #3d444d;\n}\n\n.markdown-body .pl-corl {\n  text-decoration: underline;\n  color: #a5d6ff;\n}\n\n.markdown-body [role=\"button\"]:focus:not(:focus-visible),\n.markdown-body [role=\"tabpanel\"][tabindex=\"0\"]:focus:not(:focus-visible),\n.markdown-body button:focus:not(:focus-visible),\n.markdown-body summary:focus:not(:focus-visible),\n.markdown-body a:focus:not(:focus-visible) {\n  outline: none;\n  box-shadow: none;\n}\n\n.markdown-body [tabindex=\"0\"]:focus:not(:focus-visible),\n.markdown-body details-dialog:focus:not(:focus-visible) {\n  outline: none;\n}\n\n.markdown-body g-emoji {\n  display: inline-block;\n  min-width: 1ch;\n  font-family: \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\";\n  font-size: 1em;\n  font-style: normal !important;\n  font-weight: 400;\n  line-height: 1;\n  vertical-align: -0.075em;\n}\n\n.markdown-body g-emoji img {\n  width: 1em;\n  height: 1em;\n}\n\n.markdown-body .task-list-item {\n  list-style-type: none;\n}\n\n.markdown-body .task-list-item label {\n  font-weight: 400;\n}\n\n.markdown-body .task-list-item.enabled label {\n  cursor: pointer;\n}\n\n.markdown-body .task-list-item + .task-list-item {\n  margin-top: 0.25rem;\n}\n\n.markdown-body .task-list-item .handle {\n  display: none;\n}\n\n.markdown-body .task-list-item-checkbox {\n  margin: 0 0.2em 0.25em -1.4em;\n  vertical-align: middle;\n}\n\n.markdown-body ul:dir(rtl) .task-list-item-checkbox {\n  margin: 0 -1.6em 0.25em 0.2em;\n}\n\n.markdown-body ol:dir(rtl) .task-list-item-checkbox {\n  margin: 0 -1.6em 0.25em 0.2em;\n}\n\n.markdown-body .contains-task-list:hover .task-list-item-convert-container,\n.markdown-body .contains-task-list:focus-within .task-list-item-convert-container {\n  display: block;\n  width: auto;\n  height: 24px;\n  overflow: visible;\n  clip: auto;\n}\n\n.markdown-body ::-webkit-calendar-picker-indicator {\n  filter: invert(50%);\n}\n\n.markdown-body .markdown-alert {\n  padding: 0.5rem 1rem;\n  margin-bottom: 1rem;\n  color: inherit;\n  border-left: 0.25em solid #3d444d;\n}\n\n.markdown-body .markdown-alert > :first-child {\n  margin-top: 0;\n}\n\n.markdown-body .markdown-alert > :last-child {\n  margin-bottom: 0;\n}\n\n.markdown-body .markdown-alert .markdown-alert-title {\n  display: flex;\n  font-weight: 500;\n  align-items: center;\n  line-height: 1;\n}\n\n.markdown-body .markdown-alert.markdown-alert-note {\n  border-left-color: #1f6feb;\n}\n\n.markdown-body .markdown-alert.markdown-alert-note .markdown-alert-title {\n  color: #4493f8;\n}\n\n.markdown-body .markdown-alert.markdown-alert-important {\n  border-left-color: #8957e5;\n}\n\n.markdown-body .markdown-alert.markdown-alert-important .markdown-alert-title {\n  color: #ab7df8;\n}\n\n.markdown-body .markdown-alert.markdown-alert-warning {\n  border-left-color: #9e6a03;\n}\n\n.markdown-body .markdown-alert.markdown-alert-warning .markdown-alert-title {\n  color: #d29922;\n}\n\n.markdown-body .markdown-alert.markdown-alert-tip {\n  border-left-color: #238636;\n}\n\n.markdown-body .markdown-alert.markdown-alert-tip .markdown-alert-title {\n  color: #3fb950;\n}\n\n.markdown-body .markdown-alert.markdown-alert-caution {\n  border-left-color: #da3633;\n}\n\n.markdown-body .markdown-alert.markdown-alert-caution .markdown-alert-title {\n  color: #f85149;\n}\n\n.markdown-body > *:first-child > .heading-element:first-child {\n  margin-top: 0 !important;\n}\n\n.markdown-body .highlight pre:has(+ .zeroclipboard-container) {\n  min-height: 52px;\n}\n"
  },
  {
    "path": "app/src/examples/tauri-global-shortcut-guide.md",
    "content": "# Tauri 全局快捷键使用指南\n\n## 什么是全局快捷键？\n\n全局快捷键允许用户在应用程序最小化或在后台运行时，通过键盘快捷键来触发应用程序的特定功能。这在提高用户体验和工作效率方面非常有用。\n\n## 在项目中添加全局快捷键支持\n\n要在Tauri应用程序中使用全局快捷键功能，需要执行以下几个步骤：\n\n### 步骤 1: 添加依赖\n\n首先，需要在项目中添加全局快捷键插件的依赖。\n\n#### 在 `Cargo.toml` 中添加 Rust 依赖\n\n```toml\n# 在 [dependencies] 部分添加\n[dependencies]\n# ... 其他依赖 ...\ntauri-plugin-global-shortcut = \"2.3.0\"\n```\n\n#### 在 `package.json` 中添加 JavaScript/TypeScript 依赖\n\n```json\n{\n  \"dependencies\": {\n    \"@tauri-apps/plugin-global-shortcut\": \"^2.3.0\"\n  }\n}\n```\n\n### 步骤 2: 初始化插件\n\n在 Rust 代码中初始化全局快捷键插件。\n\n```rust\n// src-tauri/src/lib.rs\n\n// 1. 导入必要的模块\nuse tauri_plugin_global_shortcut::GlobalShortcutExt;\n\n// 2. 在应用程序构建器中初始化插件\nfn run() {\n    tauri::Builder::default()\n        // ... 其他插件 ...\n        .plugin(tauri_plugin_global_shortcut::init())\n        .setup(|app| {\n            // ... 其他设置 ...\n\n            // 3. 可选：在 setup 函数中注册应用级别的全局快捷键\n            let app_handle = app.handle();\n            app_handle.global_shortcut().register(\"CommandOrControl+Shift+G\", move || {\n                println!(\"全局快捷键 CommandOrControl+Shift+G 被触发!\");\n\n                // 这里可以执行任何需要的操作\n                // 例如显示应用窗口、执行某个命令等\n            })?;\n\n            Ok(())\n        })\n        .run(tauri::generate_context!())\n        .expect(\"error while running tauri application\");\n}\n```\n\n### 步骤 3: 在前端代码中使用全局快捷键\n\n现在，您可以在前端JavaScript/TypeScript代码中使用全局快捷键了。\n\n```typescript\nimport { register, unregister, isRegistered } from \"@tauri-apps/plugin-global-shortcut\";\n\n// 注册快捷键\nasync function registerMyShortcut() {\n  try {\n    await register(\"CommandOrControl+Shift+G\", (event) => {\n      if (event.state === \"Pressed\") {\n        console.log(\"快捷键被触发!\");\n        // 执行您的操作\n      }\n    });\n    console.log(\"快捷键注册成功\");\n  } catch (error) {\n    console.error(\"注册快捷键失败:\", error);\n  }\n}\n\n// 检查快捷键是否已注册\nasync function checkShortcut() {\n  const registered = await isRegistered(\"CommandOrControl+Shift+G\");\n  console.log(`快捷键已注册: ${registered}`);\n}\n\n// 注销快捷键\nasync function unregisterMyShortcut() {\n  try {\n    await unregister(\"CommandOrControl+Shift+G\");\n    console.log(\"快捷键注销成功\");\n  } catch (error) {\n    console.error(\"注销快捷键失败:\", error);\n  }\n}\n```\n\n## 支持的修饰键\n\nTauri 全局快捷键插件支持以下修饰键：\n\n- `Command` (macOS)\n- `Control` (Windows/Linux)\n- `CommandOrControl` (自动根据平台选择 Command 或 Control)\n- `Alt`\n- `Option` (macOS)\n- `AltGr`\n- `Shift`\n- `Super`\n\n## 组合键示例\n\n- `CommandOrControl+C`: 复制\n- `CommandOrControl+Shift+S`: 另存为\n- `Alt+F4`: 关闭窗口\n- `CommandOrControl+Shift+G`: 自定义操作\n\n## 注意事项\n\n1. **权限问题**: 某些系统可能需要额外的权限才能使用全局快捷键。\n\n2. **快捷键冲突**: 如果注册的快捷键已被其他应用程序占用，Tauri应用程序将无法接收到该快捷键事件。确保选择独特的快捷键组合。\n\n3. **资源清理**: 记得在应用程序关闭或不再需要快捷键时注销它们，以避免资源泄漏。\n\n4. **平台差异**: 不同操作系统可能有不同的快捷键行为和限制。\n\n5. **安全考虑**: 谨慎使用全局快捷键，避免实现可能被滥用的功能。\n\n## 示例组件\n\n在项目中，我们提供了一个完整的示例组件 `GlobalShortcutExample.tsx`，展示了如何在React应用中实现全局快捷键功能，包括注册、检查、注销等操作。\n\n## 进一步阅读\n\n- [Tauri 官方文档](https://tauri.app/)\n- [Tauri 插件文档](https://tauri.app/v1/guides/plugins/)\n- [Tauri 全局快捷键插件文档](https://github.com/tauri-apps/tauri-plugin-global-shortcut)\n"
  },
  {
    "path": "app/src/hooks/use-debounce.ts",
    "content": "import * as React from \"react\";\n\nexport const useDebounce = <T>(value: T, delay = 500) => {\n  const [debouncedValue, setDebouncedValue] = React.useState(value);\n\n  React.useEffect(() => {\n    const handler: NodeJS.Timeout = setTimeout(() => {\n      setDebouncedValue(value);\n    }, delay);\n\n    // Cancel the timeout if value changes (also on delay change or unmount)\n    return () => {\n      clearTimeout(handler);\n    };\n  }, [value, delay]);\n\n  return debouncedValue;\n};\n"
  },
  {
    "path": "app/src/hooks/use-mobile.ts",
    "content": "import * as React from \"react\";\n\nconst MOBILE_BREAKPOINT = 768;\n\nexport function useIsMobile() {\n  const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);\n\n  React.useEffect(() => {\n    const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);\n    const onChange = () => {\n      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);\n    };\n    mql.addEventListener(\"change\", onChange);\n    setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);\n    return () => mql.removeEventListener(\"change\", onChange);\n  }, []);\n\n  return !!isMobile;\n}\n"
  },
  {
    "path": "app/src/hooks/use-mounted.ts",
    "content": "import * as React from \"react\";\n\nexport function useMounted() {\n  const [mounted, setMounted] = React.useState(false);\n\n  React.useEffect(() => {\n    setMounted(true);\n  }, []);\n\n  return mounted;\n}\n"
  },
  {
    "path": "app/src/locales/README.md",
    "content": "普通繁体中文\n\n嚴格規則：\n\n1. 只做字形轉換（如：開→開、關→關、圖→圖、軟→軟）\n\n2. 不要替換用詞（新建保持新建、文件保持文件、視頻保持視頻）\n\n3. 英文、數字、符號、程式碼完全不動\n\n4. 不加任何解釋，直接輸出結果\n\n5. 保持原格式和換行\n"
  },
  {
    "path": "app/src/locales/en.yml",
    "content": "welcome:\n  slogan: Infinite canvas mind mapping software based on graph theory\n  slogans:\n    - Infinite canvas mind mapping software based on graph theory\n    - Unleash your design on the infinite plane\n    - Let thoughts flow freely between nodes and edges\n    - Build your knowledge network with graph theory\n    - From chaos to order, from nodes to systems\n    - Visualize thinking, topological management\n    - Infinite canvas, infinite possibilities\n    - Connect ideas, sketch the big picture\n    - Not just mind maps, but thinking frameworks\n    - Graph theory-powered visual thinking tool\n  newDraft: New Draft\n  openFile: Open File\n  settings: Settings\n  about: About\n  language: Language\n  next: Next\n  website: Website\n  github: GitHub\n  bilibili: Bilibili\n  qq: QQ Group\n  title: Welcome to《Project Graph》\n  subtitle: Infinite canvas mind mapping software based on graph theory\n  openRecentFiles: Open Recent Files\n  newUserGuide: Open New User Guide\n\nabout:\n  updater:\n    available: New Version\n    downloading: Downloading\n    checkingUpdate: Checking for updates…\n    checkUpdateFail: Failed to retrieve the latest version information, please \n      check your network connection\n  links:\n    documentation: Documentation\n    github: GitHub\n    video: Video Tutorials\n    qq: QQ Group\n    forum: Forum\n    website: Official Website\n    community:\n      title: Community\n      description: Join the community, including the QQ Group, forum, Discord, \n        Telegram, and share your ideas, experiences, tutorials, resources, \n        problems, suggestions, etc.\n    importantAnnouncement: Important Announcement\n  intro:\n  - Software Introduction\n  - This is a tool based on the Tauri framework for quickly drawing node \n    diagrams, which can be used for project topology diagrams and quick \n    brainstorming drafts\n  - Xmind can only be used to draw tree-structured diagrams. FigJam and draw.io \n    can be used, but the web pages load a bit slowly\n  - That's why this software was created\n  contact:\n  - Contact Us\n  - We are committed to designing the fastest and most convenient drawing \n    methodology for \"graph theory\" and also exploring innovations in visual \n    thinking and topological to-do lists.\n  - \"If you want to get quick feedback, provide suggestions, or have any ideas or\n    questions, please join our QQ Group: 1018716404\"\n  techEnvironment: Technical Support and Ecosystem\n  developers:\n    title: Developer List\n    proposer: Project Proposal and Founding\n    conceptDesigner: Concept Design\n    featureDesigner: Feature Design\n    feedbackManager: Feedback Management\n    logoDesigner: Logo Design\n    uiDesigner: UI Design\n    softwareArchitect: Software Architecture\n    bezierCurveDesigner: Bezier Curve Design\n    animationEffectDesigner: Animation Effect Design\n    automationBuilder: Automation Build\n    xlings: Automation Environment Setup\n    websiteMaintainer: Website Maintenance\n    performanceSupervisor: Performance Supervision\n    videoPromoter: Video Promotion\n    translator: Translation\n    tester: Testing\n    encourager: Programmer Encourager\n    encouragerEncourager: Encourager of Encouragers\n    atmosphereAdjuster: Atmosphere Adjustment\n  ideaSources:\n  - Inspiration Sources\n  - FigJam without dark mode support\n  - Analysis framework video by Lin Chao\n  - Topological sorting, graph theory, state machines, and set theory concepts \n    from \"Data Structures and Algorithms\"\n  - Brainstorming methods, cognitive load theory, and card creativity methods\n  - The Command blocks in the game \"Minecraft\" and the Nether portal mod for \n    cross-world previews\n  - The slicing animation effect in the game \"Fruit Ninja\"\n  - The logical node design of factory assembly lines in the game \"Mindustry\"\n  team:\n  - Team Introduction\n  - LiRen Tech is a small team founded by Littlefean and Rutubet on May 1, 2017,\n    with ZTY joining later.\n  - Known for its youthful atmosphere, emphasis on innovation and creativity, \n    and rational thinking and cultural inclusiveness, it stands out in the field\n    of software development.\n  - It focuses not only on developing tool software and games but also on \n    developing Minecraft game plugins, operating game servers, and building \n    websites.\n  - Representative works include the rich content tower defense game CannonWar, \n    the algorithm competition website BitMountain, the desktop software Watch, \n    and the Minecraft PvP professional war server.\n  - The team's representative colors are light blue and code green, symbolizing \n    a team culture that combines logical rationality with humor and fun.\n\nsettings:\n  title: Settings\n  tabs:\n    about: About\n    visual: Visual\n    physical: Physical\n    performance: Performance\n    effects: Effects\n    automation: Automation\n    control: Control\n    keybinds: Keybinds\n    ai: AI\n    github: GitHub\n    sounds: Sounds\n    plugins: Plugins\n    themes: Themes\n    scripts: Scripts\n  language:\n    title: Language\n    options:\n      en: English\n      zh_CN: Simplified Chinese\n      zh_TW: Traditional Chinese\n      zh_TWC: 接地气繁体中文\n      id: Indonesian\n  themeMode:\n    title: Theme Mode\n    options:\n      light: Day\n      dark: Night\n  lightTheme:\n    title: Day Theme\n  darkTheme:\n    title: Night Theme\n  showTipsOnUI:\n    title: Show Tips on UI\n    description: |\n      When enabled, a line of tip text will be displayed on the screen.\n      If you are already familiar with the software, it is recommended to disable this option to reduce screen clutter.\n      For more detailed tips, it is still recommended to check the \"New User Guide\" in the menu bar or the official documentation.\n  isClassroomMode:\n    title: Focus Mode\n    description: |\n      For teaching, training.\n      When enabled, the buttons at the top of the window will become transparent, and they will revert when the mouse hovers over them. You can customize the shortcut keys to enter and exit focus mode.\n  showQuickSettingsToolbar:\n    title: Show Quick Settings Toolbar\n    description: |\n      Control whether to display the quick settings toolbar on the right side of the interface.\n      The quick settings toolbar allows you to quickly toggle common settings.\n  autoAdjustLineEndpointsByMouseTrack:\n    title: Auto Adjust Line Endpoints by Mouse Track\n    description: |\n      When enabled, the endpoints of the generated lines will be automatically adjusted based on the mouse drag track.\n  lineStyle:\n    title: Line Style\n    options:\n      straight: Straight Line\n      bezier: Bezier Curve\n      vertical: Vertical Zigzag Line\n  isRenderCenterPointer:\n    title: Show Center Crosshair\n    description: |\n      When enabled, a crosshair will be displayed at the center of the screen to indicate the position for creating nodes using shortcuts.\n  showGrid:\n    title: Show Grid\n  showBackgroundHorizontalLines:\n    title: Show Horizontal Background Lines\n    description: |\n      Horizontal and vertical lines can be enabled simultaneously to create a grid effect.\n  showBackgroundVerticalLines:\n    title: Show Vertical Background Lines\n  showBackgroundDots:\n    title: Show Background Dots\n    description: |\n      These background dots are the intersections of horizontal and vertical lines, creating a pegboard effect.\n  showBackgroundCartesian:\n    title: Show Background Cartesian Coordinates\n    description: |\n      When enabled, the x-axis, y-axis, and scale numbers will be displayed.\n      This can be used to observe the absolute coordinates of some nodes \n      and intuitively understand the current zoom level.\n  windowBackgroundAlpha:\n    title: Window Background Transparency\n  windowBackgroundOpacityAfterOpenClickThrough:\n    title: Window Background Opacity After Enabling Click-Through\n    description: |\n      Set the window background opacity when click-through is enabled\n  windowBackgroundOpacityAfterCloseClickThrough:\n    title: Window Background Opacity After Disabling Click-Through\n    description: |\n      Set the window background opacity when click-through is disabled\n  showDebug:\n    title: Show Debug Information\n    description: |\n      Typically for developers.\n      When enabled, debug information will be displayed in the top-left corner of the screen.\n      If you encounter a bug and need to take a screenshot for feedback, it is recommended to enable this option.\n  enableTagTextNodesBigDisplay:\n    title: Enlarge Tag Text Nodes Display\n    description: |\n      When enabled, tag text nodes will be enlarged when the canvas is zoomed out to a global view \n      to make it easier to recognize the layout of the entire file.\n  alwaysShowDetails:\n    title: Always Show Node Details\n    description: |\n      When enabled, node details will be displayed without needing to hover the mouse over the node.\n  nodeDetailsPanel:\n    title: Node Details Panel\n    options:\n      small: Small Panel\n      vditor: Vditor Markdown Editor\n  useNativeTitleBar:\n    title: Use Native Title Bar (Restart Required)\n    description: |\n      When enabled, the native title bar will appear at the top of the window instead of the simulated one.\n  protectingPrivacy:\n    title: Privacy Protection\n    description: |\n      When enabled for screenshot feedback, text will be replaced according to the selected mode to protect privacy.\n      This is only a display-level replacement and does not affect the actual data.\n      You can disable it after feedback to restore the original view.\n  protectingPrivacyMode:\n    title: Privacy Protection Mode\n    description: |\n      Select the text replacement method for privacy protection\n    options:\n      secretWord: Uniform Replacement (Chinese→㊙, Letters→a/A, Numbers→6)\n      caesar: Caesar Shift (all characters shifted one position back)\n  entityDetailsFontSize:\n    title: Entity Details Font Size\n    description: |\n      Unit is pixels.\n  entityDetailsLinesLimit:\n    title: Entity Details Lines Limit\n    description: |\n      Limits the maximum number of lines for entity details. Excess content will be truncated.\n  entityDetailsWidthLimit:\n    title: Entity Details Width Limit\n    description: |\n      Limits the maximum width of entity details. Excess content will be wrapped to the next line.\n  limitCameraInCycleSpace:\n    title: Enable Camera Movement Limit in Cycle Space\n    description: |\n      When enabled, the camera can only move within a rectangular area.\n      This prevents the camera from moving too far and getting lost.\n      The rectangular area forms a cycle space, similar to the map in the game \"Snake,\" \n      where moving to the top will bring you to the bottom, and moving to the left will bring you to the right.\n      Note: This feature is still in the experimental stage.\n  cameraCycleSpaceSizeX:\n    title: Cycle Space Width\n    description: |\n      The width of the cycle space, measured in pixels.\n  cameraCycleSpaceSizeY:\n    title: Cycle Space Height\n    description: |\n      The height of the cycle space, measured in pixels.\n  renderEffect:\n    title: Render Effects\n    description: Whether to render effects. If experiencing lag, this can be \n      disabled\n  historySize:\n    title: History Size\n    description: |\n      This value determines the maximum number of times you can undo using Ctrl+Z.\n      If your computer has very limited memory, you can reduce this value.\n  textIntegerLocationAndSizeRender:\n    title: Text Integer Position and Size Rendering\n    description: |\n      When enabled, all text sizes and positions will be integers to save rendering performance.\n      However, this may cause text jittering. It is recommended to use this with a zoom speed of 1.\n      If your computer is very laggy, especially when zooming and panning, you can enable this option.\n  isPauseRenderWhenManipulateOvertime:\n    title: Pause rendering if the stage has not been operated for a certain \n      period of time\n    description: |\n      When enabled, rendering will pause after a certain number of seconds without stage manipulation to save CPU/GPU resources.\n  renderOverTimeWhenNoManipulateTime:\n    title: Time to Stop Rendering Stage When Idle (Seconds)\n    description: |\n      Rendering will stop after a certain number of seconds without stage manipulation to save CPU/GPU resources.\n      This will only take effect if the \"Pause Rendering When Idle\" option is enabled.\n  ignoreTextNodeTextRenderLessThanFontSize:\n    title: Hide Text and Details of Text Nodes When Rendered Font Size Is Less Than a \n      Certain Value\n    description: |\n      When enabled, text and details of text nodes will not be rendered when the rendered font size is less than a certain value (i.e., when observing the macro view).\n      This can improve rendering performance, but will make the text content of text nodes invisible.\n  isEnableEntityCollision:\n    title: Enable Entity Collision Detection\n    description: |\n      When enabled, entities will collide and squeeze each other, which may affect performance.\n      It is recommended to disable this option, as entity collision and squeezing are not yet perfect and may cause stack overflow.\n  isEnableSectionCollision:\n    title: Enable Section Collision\n    description: |\n      When enabled, sibling sections will automatically push each other apart to avoid overlapping.\n  autoNamerTemplate:\n    title: Auto-Naming Template for Node Creation\n    description: |\n      Input `{{i}}` to automatically replace the node name with a number, which will increment automatically when creating by double-clicking.\n      For example, `n{{i}}` will be replaced with `n1`, `n2`, `n3`...\n      Input `{{date}}` to automatically replace with the current date, which will update automatically when creating by double-clicking.\n      Input `{{time}}` to automatically replace with the current time, which will update automatically when creating by double-clicking.\n      These can be combined, such as `{{i}}-{{date}}-{{time}}`.\n  autoNamerSectionTemplate:\n    title: Auto-Naming Template for Box Creation\n    description: |\n      Input `{{i}}` to automatically replace the node name with a number, which will increment automatically when creating by double-clicking.\n      For example, `n{{i}}` will be replaced with `n1`, `n2`, `n3`...\n      Input `{{date}}` to automatically replace with the current date, which will update automatically when creating by double-clicking.\n      Input `{{time}}` to automatically replace with the current time, which will update automatically when creating by double-clicking.\n      These can be combined, such as `{{i}}-{{date}}-{{time}}`.\n  autoSaveWhenClose:\n    title: Auto-Save When Closing\n    description: |\n      When closing the software, if there are unsaved project files, a prompt will appear asking whether to save.\n      Enabling this option will automatically save project files when closing the software.\n      It is recommended to enable this option.\n  autoSave:\n    title: Enable Auto-Save\n    description: |\n      Automatically save the current file.\n      This feature currently only works for files with a specified path and does not apply to draft files!\n  autoSaveInterval:\n    title: Auto-Save Interval (Seconds)\n    description: |\n      Note: The timer only counts when the software window is active and will not count when the software is minimized.\n  clearHistoryWhenManualSave:\n    title: Clear History When Manually Saving\n    description: |\n      Automatically clear operation history when manually saving files using Ctrl+S shortcut.\n      Enabling this option can reduce memory usage and keep the interface clean.\n  autoBackup:\n    title: Enable Auto-Backup\n    description: |\n      Automatically back up the current file.\n      Auto-backup will create a copy next to the project file.\n      If it is a draft, it will be stored in the specified path.\n  autoBackupInterval:\n    title: Auto-Backup Interval (Seconds)\n    description: |\n      Too frequent auto-backups may generate a large number of backup files, \n      which can occupy disk space.\n  scaleExponent:\n    title: Zoom Speed\n    description: |\n      The current zoom level will continuously approach the target zoom level at a certain rate.\n      When it gets close enough (less than 0.0001), it will automatically stop zooming.\n      A value of 1 means the zoom will be immediate with no transition effect.\n      A value of 0 means the zoom will never complete and can simulate a locked effect.\n      Note: If you experience lag while zooming, set this to 1.\n  cameraKeyboardScaleRate:\n    title: Keyboard Zoom Rate\n    description: |\n      The zoom rate of the viewport when using a key press.\n      A value of 0.2 means each zoom in will multiply the scale by 1.2, and each zoom out will multiply it by 0.8.\n      A value of 0 disables keyboard zooming.\n  scaleCameraByMouseLocation:\n    title: Zoom Based on Mouse Position\n    description: |\n      When enabled, the center of the zoom will be the mouse position.\n      When disabled, the center of the zoom will be the center of the current viewport.\n  allowMoveCameraByWSAD:\n    title: Allow Moving Camera with W S A D Keys\n    description: |\n      When enabled, you can move the viewport using the W S A D keys.\n      When disabled, you can only move the viewport using the mouse, which prevents infinite scrolling bugs.\n  cameraFollowsSelectedNodeOnArrowKeys:\n    title: Follow Selected Node with Arrow Keys\n    description: |\n      When enabled, the viewport will follow when switching selected nodes using the arrow keys.\n  arrowKeySelectOnlyInViewport:\n    title: Limit Arrow Key Selection to Viewport\n    description: |\n      When enabled, using arrow keys to switch selection will only select visible objects within the current viewport.\n      When disabled, objects outside the viewport can be selected (camera will follow automatically).\n  cameraKeyboardMoveReverse:\n    title: Reverse Camera Movement with Keyboard\n    description: |\n      When enabled, the movement direction of the W S A D keys will be reversed.\n      The original movement logic is to move the camera floating on the screen, but if it is considered moving the stage, it is reversed.\n      Hence, this option is provided.\n  cameraResetViewPaddingRate:\n    title: Padding Rate When Resetting View Based on Selected Nodes\n    description: |\n      When you select a group of nodes or a single node and press a shortcut key or click a button to reset the viewport,\n      the viewport will adjust its size and position to ensure all selected content is centered on the screen and fully visible.\n      Due to zoom level adjustments, there may be some padding around the edges.\n      A value of 1 means no padding at all (very close-up view).\n      A value of 2 means the padding is exactly one times the content size.\n  cameraResetMaxScale:\n    title: Maximum Zoom Level After Resetting Camera View\n    description: |\n      When you select a very small node, the camera won't fully cover the area of this node, otherwise it would be too large.\n      Instead, it will zoom in to a maximum value, which can be adjusted through this option.\n      It is recommended to observe the currentScale in debug mode to adjust this value.\n  allowAddCycleEdge:\n    title: Allow Adding Self-Loops Between Nodes\n    description: |\n      When enabled, nodes can have self-loops, i.e., connections to themselves, which is useful for state machine diagrams.\n      This is disabled by default because it is not commonly used and can be easily triggered by mistake.\n  enableDragEdgeRotateStructure:\n    title: Enable Drag Edge to Rotate Structure\n    description: |\n      When enabled, you can rotate the node structure by dragging selected edges.\n      This allows you to easily adjust the orientation of connected nodes.\n  enableCtrlWheelRotateStructure:\n    title: Enable Ctrl+Mouse Wheel to Rotate Structure\n    description: |\n      When enabled, you can rotate the node structure by holding Ctrl (Command on Mac) and scrolling the mouse wheel.\n      This allows you to precisely adjust the orientation of connected nodes.\n  moveAmplitude:\n    title: Camera Movement Amplitude\n    description: |\n      This setting is used when moving the viewport using the W S A D keys.\n      Think of the camera as a hovering aircraft that can thrust in four directions.\n      This amplitude value represents the power of the thrust, which needs to be adjusted in combination with the friction setting below.\n  moveFriction:\n    title: Camera Movement Friction Coefficient\n    description: |\n      This setting is used when moving the viewport using the W S A D keys.\n      The higher the friction coefficient, the shorter the sliding distance.\n      A value of 0 represents absolute smoothness.\n  gamepadDeadzone:\n    title: Gamepad Dead Zone\n    description: |\n      This setting is used when controlling the viewport with a gamepad.\n      The input value of the gamepad ranges from 0 to 1. The smaller this value, the more sensitive the gamepad input will be.\n      A larger dead zone means the input will tend more towards 0 or 1 with less variation.\n      A smaller dead zone means the input will tend more towards the middle value with greater variation.\n  mouseRightDragBackground:\n    title: Right-Click Drag Background Operation\n    options:\n      cut: Cut and Delete Objects\n      moveCamera: Move Viewport\n  enableSpaceKeyMouseLeftDrag:\n    title: Enable Space + Left Mouse Drag Move\n    description: Press space bar and drag with left mouse button to move the viewport\n  mouseLeftMode:\n    title: Mouse Left Button Mode\n    options:\n      selectAndMove: select and move\n      draw: draw\n      connectAndCut: connect and cut\n  doubleClickMiddleMouseButton:\n    title: Double-Click Middle Mouse Button\n    description: |\n      The action performed when the scroll wheel is quickly pressed twice. By default, it resets the viewport.\n      Disabling this option can prevent accidental triggers.\n    options:\n      adjustCamera: Adjust Viewport\n      none: No Operation\n  textNodeContentLineBreak:\n    title: Text Node Line Break Scheme\n    options:\n      enter: Enter\n      ctrlEnter: Ctrl + Enter\n      altEnter: Alt + Enter\n      shiftEnter: Shift + Enter\n    description: |\n      Do not set this to the same key as the one used to exit text node editing mode, \n      as this will cause conflicts and prevent line breaks.\n  textNodeStartEditMode:\n    title: Enter Text Node Editing Mode\n    options:\n      enter: Enter\n      ctrlEnter: Ctrl + Enter\n      altEnter: Alt + Enter\n      shiftEnter: Shift + Enter\n      space: Space Bar\n    description: |\n      You can also enter editing mode by pressing the F2 key. Another option can be selected here.\n  textNodeExitEditMode:\n    title: Exit Text Node Editing Mode\n    options:\n      enter: Enter\n      ctrlEnter: Ctrl + Enter\n      altEnter: Alt + Enter\n      shiftEnter: Shift + Enter\n    description: |\n      You can also exit by pressing the Esc key. Another option can be selected here.\n  textNodeSelectAllWhenStartEditByMouseClick:\n    title: Select All Text When Starting Edit by Mouse Click\n    description: |\n      When enabled, all text content will be selected when starting to edit a text node.\n      If you usually want to change the entire content when editing, enable this option.\n      If you are more likely to append content, disable this option.\n  textNodeSelectAllWhenStartEditByKeyboard:\n    title: Select All Text When Starting Edit by Keyboard\n    description: |\n      When enabled, all text content will be selected when you press the key to enter text node editing mode.\n  textNodeBackspaceDeleteWhenEmpty:\n    title: Delete Text Node When Backspace Pressed on Empty Content\n    description: |\n      When enabled, pressing the Backspace key on an empty text node in edit mode will delete the entire node.\n  textNodeBigContentThresholdWhenPaste:\n    title: Text Node Big Content Threshold When Paste\n    description: |\n      When pasting directly onto the stage, if the text length exceeds this value, manual line break mode will be used.\n  textNodePasteSizeAdjustMode:\n    title: Text Node Paste Size Adjust Mode\n    description: |\n      Control how text nodes adjust their size when pasted.\n    options:\n      auto: Always Auto Adjust\n      manual: Always Manual Adjust\n      autoByLength: Auto Adjust By Length\n  textNodeAutoFormatTreeWhenExitEdit:\n    title: Auto Format Tree Structure When Exiting Edit Mode\n    description: |\n      Automatically formats the tree structure when a text node exits edit mode.\n  treeGenerateCameraBehavior:\n    title: Camera Behavior After Tree Node Generation\n    description: |\n      Set the camera behavior after creating new nodes using tree depth or breadth generation functions.\n    options:\n      none: Camera Remains Stationary\n      moveToNewNode: Move Camera to New Node\n      resetToTree: Reset View to Cover Entire Tree Structure\n  enableDragAutoAlign:\n    title: Enable Automatic Alignment When Dragging Nodes\n    description: |\n      When enabled, nodes will automatically align with other nodes in x-axis and y-axis directions when you drag and release them.\n  reverseTreeMoveMode:\n    title: Reverse Tree Move Mode\n    description: |\n      When enabled, default movement is tree movement (with successor nodes), holding Ctrl key moves single object. When disabled, it's the opposite.\n  enableDragAlignToGrid:\n    title: Snap to Grid When Dragging Entities\n    description: |\n      It is recommended to enable horizontal and vertical grid lines in the display settings and disable automatic alignment.\n  enableWindowsTouchPad:\n    title: Enable Windows Touchpad Two-Finger Operation\n    description: |\n      On Windows systems, two-finger up and down movements are recognized as scroll wheel events.\n      Two-finger left and right movements are recognized as horizontal scroll wheel events.\n      If you are using an external mouse with a laptop, it is recommended to disable this option.\n  mouseWheelMode:\n    title: Mouse Wheel Mode\n    options:\n      zoom: Zoom\n      move: Vertical Movement\n      moveX: Horizontal Movement\n      none: No operation\n  mouseWheelWithShiftMode:\n    title: Mouse Wheel Mode with Shift Key\n    options:\n      zoom: Zoom\n      move: Vertical Movement\n      moveX: Horizontal Movement\n      none: No operation\n  mouseWheelWithCtrlMode:\n    title: Mouse Wheel Mode with Ctrl Key\n    options:\n      zoom: Zoom\n      move: Vertical Movement\n      moveX: Horizontal Movement\n      none: No operation\n    description: \"Hint: Here, Ctrl stands for Control\\n\"\n  rectangleSelectWhenLeft:\n    title: Strategy for Selecting to the Left\n    description: |\n      Left-click Selection Strategy\n      Full Containment: Selection box must fully enclose the entity's bounding box\n      Collision-based Selection: Selection box only needs to touch the entity's bounding box\n    options:\n      intersect: Collision-based Selection\n      contain: Full Coverage Selection\n  rectangleSelectWhenRight:\n    title: Strategy for Selecting to the Right\n    description: |\n      Choose the strategy for selecting to the right.\n    options:\n      intersect: Collision-based Selection\n      contain: Full Coverage Selection\n  isStealthModeEnabled:\n    title: Stealth Mode\n    description: |\n      Enable stealth mode.\n  stealthModeScopeRadius:\n    title: Stealth Mode Scope Radius\n    description: |\n      The radius of the stealth mode scope.\n  stealthModeReverseMask:\n    title: Reverse Mask\n    description: |\n      When enabled, the center area of the scope will be masked, only showing the surrounding area.\n  stealthModeMaskShape:\n    title: Stealth Mode Mask Shape\n    description: |\n      Choose the shape of the visible area in stealth mode\n    options:\n      circle: Circle\n      square: Square\n      topLeft: Top-Left Quadrant\n      smartContext: Smart Context (Section or Entity)\n\n  # Sounds\n  cuttingLineStartSoundFile:\n    title: Sound File for Cutting Line Start\n    description: |\n      The path to the sound file played when starting to cut a line with a right-click.\n  connectLineStartSoundFile:\n    title: Sound File for Connecting Line Start\n    description: |\n      The path to the sound file played when starting to connect a line with a right-click.\n  connectFindTargetSoundFile:\n    title: Sound File for Connecting Line Targeting\n    description: |\n      The path to the sound file played when the connecting line snaps to a target.\n  cuttingLineReleaseSoundFile:\n    title: Sound File for Cutting Line Release\n    description: |\n      The sound file played when releasing the cutting line (when the blade effect is visible).\n  alignAndAttachSoundFile:\n    title: Sound File for Alignment\n    description: |\n      The path to the sound file played when nodes are aligned during mouse dragging.\n  uiButtonEnterSoundFile:\n    title: Sound for Mouse Entering Button Area\n    description: |\n      The sound played when the mouse enters a button area.\n  uiButtonClickSoundFile:\n    title: Sound File for Button Click\n    description: |\n      The path to the sound file played when a button is clicked.\n  uiSwitchButtonOnSoundFile:\n    title: Sound File for Switching Button On\n    description: |\n      The path to the sound file played when a switch button is turned on.\n  uiSwitchButtonOffSoundFile:\n    title: Sound File for Switching Button Off\n    description: |\n      The path to the sound file played when a switch button is turned off\n  packEntityToSectionSoundFile:\n    title: Sound File for Packing to Section\n    description: |\n      The path to the sound file played when packing selected entities into a Section box\n  treeGenerateDeepSoundFile:\n    title: Sound File for Tree Deep Generation\n    description: |\n      The path to the sound file played when using Tab key for tree deep generation\n  treeGenerateBroadSoundFile:\n    title: Sound File for Tree Broad Generation\n    description: |\n      The path to the sound file played when using Enter key for tree broad generation\n  treeAdjustSoundFile:\n    title: Sound File for Tree Structure Adjustment\n    description: |\n      The path to the sound file played when formatting tree structure\n  viewAdjustSoundFile:\n    title: Sound File for View Adjustment\n    description: |\n      The path to the sound file played when adjusting view\n  entityJumpSoundFile:\n    title: Sound File for Entity Jump\n    description: |\n      The path to the sound file played when entity jumps\n  associationAdjustSoundFile:\n    title: Sound File for Association Adjustment\n    description: |\n      The path to the sound file played when adjusting connections, undirected edges, etc.\n  windowCollapsingWidth:\n    title: Width of Mini Window\n    description: \"When switches to Mini Window, The width of window in pixel\\n\"\n  windowCollapsingHeight:\n    title: The Height of Mini Window\n    description: \"When switches to Mini Window, the height of the window in pixel\\n\"\n  compatibilityMode:\n    description: \"When enabled, another render method will be used.\\n\"\n    title: Compatibility Mode\n  autoRefreshStageByMouseAction:\n    description: \"When enabled, mouse actions (camera view drags) will automatically\n      refresh the stage.\\nThis prevents manual refreshes for images that fail to load\n      when opening a file.\\n\"\n    title: Auto-Refresh Stage After Mouse Actions\n  mouseWheelWithAltMode:\n    description: \"This feature was added on April 10, 2025.\\nIssues discovered currently:\n      In Windows, a screen tap is needed to control the stage after a mouse wheel\n      roll.\\nTip: Alt here is Option\\n\"\n    options:\n      zoom: Zooming\n      move: Vertical Movement\n      moveX: Horizontal Movement\n      none: No operation\n    title: Mouse Wheel Mode with Alt Key\n  macTrackpadScaleSensitivity:\n    title: MacBook trackpad two-finger zoom sensitivity\n    description: The larger the value, the faster the scaling speed\n  macEnableControlToCut:\n    title: Enable Control Key to Start Cutting on Mac\n    description:\n      Press and hold the Control key, move the mouse on the stage, then release the Control key to complete a cut\n  mouseSideWheelMode:\n    title: Mouse Side Scroll Wheel Mode\n    description: \"The side scroll wheel is the roller on the thumb\\n\"\n    options:\n      zoom: Zoom\n      move: Vertical movement\n      moveX: Horizontal movement\n      none: No operation\n      cameraMoveToMouse: Move the view to the mouse position\n      adjustWindowOpacity: adjust window opacity\n      adjustPenStrokeWidth: Adjust brush thickness\n  macMouseWheelIsSmoothed:\n    description: Some MacBook mouse wheels are smooth, while others trigger a \n      scroll with each click, which may depend on whether you have installed \n      mouse modification software like Mos\n    title: Is the mouse scroll wheel of MacBook smooth\n  sectionBitTitleRenderType:\n    options:\n      none: No Rendering (Improve Performance)\n      top: Small text at the top\n      cover: Semi-transparent overlay frame (optimal effect)\n    title: The rendering type of the abbreviated main title in the frame\n  sectionBigTitleThresholdRatio:\n    title: Section Big Title Display Threshold\n    description: |\n      When the longest side of the section is less than this ratio of the longest side of the viewport, the big title will be displayed.\n      Range: 0-1\n  sectionBigTitleCameraScaleThreshold:\n    title: Section Big Title Camera Scale Threshold\n    description: |\n      When the camera scale is greater than this threshold, the big title will not be displayed.\n      The camera scale needs debugging information to be displayed.\n  sectionBigTitleOpacity:\n    title: Section Big Title Opacity\n    description: |\n      Control the opacity of the semi-transparent cover big title, range 0-1\n  sectionBackgroundFillMode:\n    title: Section Background Fill Mode\n    description: |\n      Control how the section background color is filled\n      Full: Fill the entire section background (default, with opacity and mask order judgment)\n      Title Only: Only fill the top title bar\n    options:\n      full: Full Fill\n      titleOnly: Title Only\n  macTrackpadAndMouseWheelDifference:\n    options:\n      tarckpadFloatAndWheelInt: The value of the trackpad is a decimal, while \n        the value of the mouse scroll is an integer\n      trackpadIntAndWheelFloat: The trackpad value is an integer and the mouse \n        scroll value is a decimal\n    title: The differentiation logic between the trackpad of MacBook and the \n      mouse scroll wheel\n    description: For some MacBooks, the mouse scroll wheel value is an integer \n      and the trackpad value is a decimal; for others, it's reversed. Select the\n      differentiation logic based on the actual situation. To differentiate, \n      click the software logo in the \"About\" interface seven times to enter the \n      \"Test Interface\", then scroll the wheel and trackpad to view the data \n      feedback\n  compressPastedImages:\n    title: Compress Pasted Images\n    description: \"When enabled, images pasted on the stage are compressed to save\n      memory and disk space when loading files.\\n\"\n  maxPastedImageSize:\n    description: \"For images whose width or height exceeds this size, their maximum\n      width or height is limited to this size.\\nMeanwhile, it keeps the aspect ratio,\n      but only takes effect when “Compress Pasted Images” is enabled.\\n\"\n    title: Max Pasted Image Size (In Pixels)\n  autoBackupLimitCount:\n    title: Max Auto Backup Count\n    description: \"The maximum number of auto-backups, beyond which old backup files\n      will be deleted.\"\n  autoBackupCustomPath:\n    title: Custom Auto Backup Path\n    description: \"Set the save path for auto backup files, use default path if empty.\"\n  showTextNodeBorder:\n    title: Show Text Node Border\n    description: \"Controls whether to show the text node border or not.\\n\"\n  showTreeDirectionHint:\n    title: Show Tree Growth Direction Hints\n    description: |\n      When a text node is selected, display keyboard hints (tab/W W/S S/A A/D D) around the node indicating tree growth directions.\n      When disabled, these hint labels will not be rendered.\n  autoLayoutWhenTreeGenerate:\n    title: Automatically update layout when growing nodes\n    description: \"After it is turned on, the layout will be automatically updated\n      when growing nodes\\nThe growing nodes here refer to the tab and \\\\ key growing\n      nodes\\n\"\n  enableBackslashGenerateNodeInInput:\n    title: Enable backslash to create sibling nodes while in input mode\n    description: |\n      When enabled, pressing the backslash key (\\) while editing a text node will also create a sibling node\n      When disabled, the backslash key can only create sibling nodes when not in edit mode\n  agreeTerms:\n    title: agree to the Terms of Service\n    description: \"please read carefully and agree to the Terms of Service\\n\"\n  allowTelemetry:\n    title: Participate in the User Experience Improvement Program\n    description: \"If you enable this, we will collect your usage data to help us improve\n      the software\\nThe data sent is only for statistics and will not include your\n      personal privacy information\\nYour data will be stored on cloud servers in Hong\n      Kong, China, and will not be sent abroad\\n\"\n  aiApiBaseUrl:\n    title: AI API Base URL\n    description: |\n      Currently only supports OpenAI format API\n  aiApiKey:\n    title: AI API Key\n    description: |\n      The key will be stored in plain text locally\n  aiModel:\n    title: AI Model\n  aiShowTokenCount:\n    title: Show AI token count\n    description: |\n      When enabled, shows the token count for AI operations\n  soundPitchVariationRange:\n    title: Sound Pitch Variation\n    description: Controls how much the pitch of sound effects varies randomly. Range for 0-1200 cents (1200 cents = 1 octave, 100 cents = 1 semitone). Higher values create more noticeable variations.\n  autoImportTxtFileWhenOpenPrg:\n    title: Auto Import TXT File When Opening PRG\n    description: When enabled, automatically imports content from a TXT file with the same name as the PRG file when opening it. The content will be added as text nodes at the bottom left corner of the stage.\nplugins:\n  welcome:\n    title: Welcome to the Plugin System\n    description: |\n      The plugin system is part of Project Graph v2, where developers can use JavaScript to create plugins to extend the functionality of the application.\n      The plugin system is not yet complete, so please stay tuned.\n  title: Plugin Management\n  install: Install Plugin\n  uninstall: Uninstall\n  documentation: Documentation\n\napp:\n  unsaved: \"(Unsaved)\"\n  comingSoon: \"Coming Soon...…\"\n  draftUnsaved: ' Drafts cannot be saved automatically and require manual saving '\nglobalMenu:\n  file:\n    title: File\n    new: New Draft\n    open: Open\n    recentFiles: Recent Files\n    clear: Clear\n    save: Save\n    saveAs: Save As\n    import: Import\n    importFromFolder: Generate Nested Graph from Folder\n    importTreeFromFolder: Generate Tree Diagram from Folder\n    generateKeyboardLayout: Generate Keyboard Layout from Current Keybindings\n    export: Export\n    exportAsSVG: Export as SVG\n    exportAll: Export All Content\n    plainTextType:\n      exportAllNodeGraph: Export All Node Graph as Plain Text\n      exportSelectedNodeGraph: Export Selected Node Graph as Plain Text\n      exportSelectedNodeTree: Export Selected Node Tree as Plain Text\n      exportSelectedNodeTreeMarkdown: Export Selected Node Tree as Markdown\n      exportSelectedNodeGraphMermaid: Export Selected Node Graph as Mermaid\n    exportSelected: Export Selected Content\n    plainText: Plain Text\n    exportSuccess: Export Successful\n    attachments: Attachment Manager\n    tags: Tag Manager\n  location:\n    title: Location\n    openConfigFolder: Open Software Configuration Folder\n    openCacheFolder: Open Software Cache Folder\n    openCurrentProjectFolder: Open Current Project Folder\n  view:\n    title: View\n    resetViewAll: Reset View Based on All Content\n    resetViewSelected: Reset View Based on Selected Content\n    resetViewScale: Reset View Scale to Standard Size\n    moveViewToOrigin: Move View to Coordinate Origin\n  actions:\n    title: Actions\n    search: Search\n    refresh: Refresh\n    undo: Undo\n    redo: Redo\n    releaseKeys: Release Keys\n    confirmClearStage: Confirm Clear Stage?\n    irreversible: This operation cannot be undone!\n    clearStage: Clear Stage\n    cancel: Cancel\n    confirm: Confirm\n    generating: Generating\n    success: Success\n    failed: Failed\n    generate:\n      generatedIn: Generated in\n      title: Generate\n      generateNodeTreeByText: Generate Tree Structure from Plain Text\n      generateNodeTreeByTextDescription: Enter tree structure text, each line represents a node, indentation represents hierarchical relationships\n      generateNodeTreeByTextPlaceholder: Enter tree structure text...\n      indention: Indentation Characters\n      generateNodeMermaidByText: Generate Section Nested Graph from Mermaid\n      generateNodeMermaidByTextDescription: Support graph TD format mermaid text, can automatically identify sections and create nested structures\n      generateNodeMermaidByTextPlaceholder: |\n        graph TD;\n          A[Section A] --> B[Section B];\n          A --> C[Regular Node];\n          B --> D[Another Node];\n        ;\n  settings:\n    title: Settings\n    appearance: Personalization\n  ai:\n    title: AI\n    openAIPanel: Open AI Panel\n  window:\n    title: Window\n    fullscreen: Fullscreen\n    classroomMode: Focus Mode\n    classroomModeHint: The top-left menu button is just transparent, not disappeared\n  about:\n    title: About\n    guide: New User Guide\n  unstable:\n    title: Beta Version\n    notRelease: This version is not official\n    mayHaveBugs: May contain bugs and unfinished features\n    reportBug: \"Report Bug: Comment in Issue #487\"\n    test: Test Function\n\ncontextMenu:\n  createTextNode: Create Text Node\n  createConnectPoint: Create Connection Point\n  packToSection: Pack into Section\n  createMTUEdgeLine: Create Undirected Edge\n  createMTUEdgeConvex: Create Convex Hull\n  convertToSection: Convert to Section\n  toggleSectionCollapse: Toggle Collapse State\n  changeColor: Change Color\n  resetColor: Reset Color\n  switchMTUEdgeArrow: Switch Arrow Direction\n  mtuEdgeArrowOuter: Arrow Pointing Outward\n  mtuEdgeArrowInner: Arrow Pointing Inward\n  mtuEdgeArrowNone: Disable Arrow Display\n  switchMTUEdgeRenderType: Switch Render Type\n  convertToDirectedEdge: Convert to Directed Edge\n\nappMenu:\n  file:\n    title: File\n    items:\n      new: New Draft\n      newFile: new File\n      open: Open\n      save: Save\n      saveAs: Save As\n      recent: Recent Files\n      backup: Manual Backup\n  location:\n    title: Location\n    items:\n      openDataFolder:\n        description: Open the user configuration data folder, which stores \n          user-customized settings, etc\n        title: Configuration Data\n      openProjectFolder:\n        title: Current Directory\n        description: Open the current editing project file's folder\n      openCacheFolder:\n        title: Cache Folder\n        description: Open the software cache folder, which stores cache data \n          generated during software operation, as well as default draft backup \n          files\n  export:\n    title: Export\n    items:\n      exportAsSvg: SVG\n      exportAsSVGByAll: SVG (All)\n      exportAsMarkdownBySelected: Markdown (Selected)\n      exportAsPlainText: Plain Text\n  view:\n    title: View\n    items:\n      resetByAll:\n        title: Reset by All\n        description: Reset view by all content\n      resetBySelect:\n        title: Reset by Selected\n        description: Reset view by selected content\n      resetScale:\n        title: Reset Scale\n        description: Reset the scale to the standard size\n      resetLocation:\n        title: Reset Position\n        description: Reset View to Origin\n  more:\n    title: More\n    items:\n      settings: Settings\n      about: About\n      welcome: Welcome Screen\n  window:\n    title: Window\n    items:\n      refresh: Refresh\n      fullscreen: Fullscreen\n      cancelFullscreen: Exit Fullscreen\n\n  import:\n    title: Import\n    items:\n      generateSectionByFolder:\n        title: Generate a block diagram from the local folder\n        description: After selecting a local folder, generate a nested box \n          diagram of all files in the folder\ntoolbar:\n  pinnedTools:\n    title: Pinned Tools\n    items:\n      generateTextNodeByText: Generate Node by Text\n      autoFillColorSettings: Set Automatic Fill Color for Entity Creation\n  stageObjects:\n    title: Stage Objects\n    items:\n      setColor: Set Color of Selected Stage Object, Note to Select First and \n        Then Click Color (F6)\n      delete: Delete Selected Stage Object\n      tag: Add or Remove Tags from Stage Object\n  multiTargetUndirectedEdges:\n    title: Multi-Source Undirected Edges\n    items:\n      switchToEdge: Switch to Directed Edge\n      arrowExterior: Arrow Pointing Outward\n      arrowInterior: Arrow Pointing Inward\n      noArrow: No Arrow\n      switchRenderState: Switch Render State\n  edge:\n    title: Directed Edge\n    items:\n      switchDirection: Reverse Direction of Selected Edge\n      switchToCrEdge: Switch to CR Curve (Under Development, Not Recommended for\n        Use)\n      switchToUndirectedEdge: Switch to Undirected Edge\n      setExtremePoint: Set Position of Selected Edge Endpoints\n  crEdge:\n    title: CR Curve\n    items:\n      addControlPoint: Add Control Point\n      tensionIncrease: Tighten Curve\n      tensionDecrease: Loosen Curve\n  entity:\n    title: Entity\n    items:\n      align: Entity Alignment Options\n      saveNew: Save Selected Node as New File\n      packSection: Pack Selected Node into Section (Customizable Shortcut)\n      createMultiTargetEdgeConvex: Create Undirected Edge (Convex Hull Type)\n      createMultiTargetEdgeLine: Create Undirected Edge (Line Type)\n      openPathByContent: Open File/Folder Based on First Line of Entity Details \n        as Local Absolute Path\n  imageNode:\n    title: Image Node\n    items:\n      refresh: Refresh Selected Content (Click this Button if Image Loading \n        Fails)\n      openImage: Open Selected Image\n  textNode:\n    title: Text Node\n    items:\n      switchWidthAdjustMode: Switch Width Adjustment Mode (ttt)\n      aiGenerateNewNode: AI Expand Node\n  section:\n    title: Box\n    items:\n      checkoutFolderState: Toggle Section Fold State (Customizable Shortcut)\n  mouseMode:\n    title: Mouse Mode\n    items:\n      leftMouseSelectMove: Selection/Move/Create Node Mode (Left Mouse Button)\n      leftMouseDraw: Draw Mode (Left Mouse Button)\n      leftMouseCutAndConnect: Connect/Cut Mode (Left Mouse Button)\n  drawColor:\n    title: Draw Stroke Color\n\n\nstartFilePanel:\n  title: Select Project File to Load Automatically on Startup\n  tips:\n  - \"Tip: The selected Project File will automatically load to the Stage when the\n    program starts, don't have to open it manually.\"\n  - 'Load: only load this file to Stage (you can check the switch result by making\n    this window transparent )'\n  - 'Pin: switch the project file for automatically loading, the status icon on the\n    left represent the current auto-loaded file.'\n  - 'Remove: this will only remove the project file from the list, which will not\n    affect the project file itself.'\n  buttons:\n    addFile: Add File\n    clearList: Clear List\n    showAbsolutePath: Show Absolute Path\n    showFileTime: Show File Time\n\nkeys:\n  none: No Key Binding. Click to Bind\n  mouse0: Mouse Left Button\n  mouse1: Mouse Middle Button\n  mouse2: Mouse Right Button\n  mouse3: Mouse Side Button 1\n  mouse4: Mouse Side Button 2\n  \" \": Space\n  arrowup: Up Arrow Key\n  arrowdown: Down Arrow Key\n  arrowleft: Left Arrow Key\n  arrowright: Right Arrow Key\n  wheelup: Scroll Wheel Up\n  wheeldown: Scroll Wheel Down\n\nkeyBinds:\n  title: Key Bindings\n  none: No Key Binding. Click to Bind\n  test:\n    title: Test\n    description: |\n      For testing custom key binding functionality only.\n  reload:\n    title: Reload Application\n    description: |\n      Reload the application and the current project file.\n      This is equivalent to refreshing a web page in a browser.\n      Warning: This action can cause loss of unsaved progress!\n  toggleCheckmarkOnTextNodes:\n    title: Toggle Checkmark on Text Nodes\n    description: Only works on text nodes, press again to remove checkmark\n  toggleCheckErrorOnTextNodes:\n    title: Toggle Error Mark on Text Nodes\n    description: Only works on text nodes, press again to remove error mark\n      \n  dagGraphAdjust:\n    title: Adjust DAG Layout\n    description: |\n      Automatically adjust the layout of selected nodes that form a Directed Acyclic Graph (DAG).\n      This feature is only available when the selected nodes form a DAG structure.\n  treeGraphAdjustSelectedAsRoot:\n    title: Format Tree Structure with Selected Node as Root\n    description: |\n      Format the tree structure using the currently selected node as the root node.\n      Does not search for the root of the entire tree, only formats the subtree rooted at the selected node.\n  undo:\n    title: Undo\n    description: Undo the last action\n  redo:\n    title: Redo\n    description: Cancel the last undo action\n  resetView:\n    title: Reset View\n    description: |\n      If no content is selected, reset the viewport based on all content.\n      If content is selected, reset the viewport based on the selected content.\n  restoreCameraState:\n    title: Restore View State\n    description: |\n      After pressing this, it restores to the camera position and zoom level recorded when pressing the F key earlier.\n  resetCameraScale:\n    title: Reset Zoom\n    description: Reset the viewport zoom to the standard size\n  folderSection:\n    title: Fold/Unfold Section Box\n    description: Pressing this will toggle the fold/unfold state of the selected\n      Section box\n  toggleSectionLock:\n    title: Lock/Unlock Section Box\n    description: Toggle lock state of selected section boxes. Locked sections prevent moving internal objects.\n  reverseEdges:\n    title: Reverse Connection Direction\n    description: |\n      When pressed, the direction of the selected connection will be reversed.\n      For example, if it was A -> B, it will become B -> A.\n      This feature is useful for quickly creating a node connected to multiple nodes.\n      Since connections can currently only be made one-to-many.\n  reverseSelectedNodeEdge:\n    title: Reverse All Connections of Selected Node\n    description: |\n      When pressed, all connections of each node in the selected group will be reversed.\n      This allows for faster one-to-many connections.\n  packEntityToSection:\n    title: Pack Selected Entities into Section Box\n    description: |\n      When pressed, the selected entities will be automatically wrapped into a new Section box.\n  unpackEntityFromSection:\n    title: Unpack Section Box and Convert to Text Node\n    description: |\n      When pressed, the entities inside the selected Section box will be unpacked, and the box itself will be converted into a text node.\n      The internal entities will be dropped outside.\n  textNodeToSection:\n    title: Convert Selected Text Node to Section Box\n    description: |\n      When pressed, the selected text node will be converted into a Section box.\n      This can be used for quick Section box creation.\n  deleteSelectedStageObjects:\n    title: Delete Selected Stage Objects\n    description: |\n      When pressed, the selected stage objects will be deleted.\n      Stage objects include entities (nodes and Sections as independent objects) and relationships (connections between nodes).\n      The default key is Delete, but you can change it to Backspace.\n  editEntityDetails:\n    title: Edit Details of Selected Entity\n    description: |\n      When pressed, the details of the selected entity will be opened for editing.\n      This is only effective when a single object is selected.\n  openColorPanel:\n    title: Open Color Panel Shortcut\n    description: |\n      When pressed, the color panel will be opened to quickly switch node colors.\n  switchDebugShow:\n    title: Toggle Debug Information Display\n    description: |\n      When pressed, toggle the display of debug information.\n      Debug information is displayed in the top-left corner of the screen and is typically for developers.\n      When enabled, debug information will be shown in the top-left corner of the screen.\n      If you encounter a bug and need to take a screenshot for feedback, it is recommended to enable this option.\n  keyboardOnlyGenerateNode:\n    title: Grow Node Using Keyboard Only\n    description: |\n      When pressed, grow a node using only the keyboard.\n      Release to complete the growth.\n  generateNodeTreeWithDeepMode:\n    title: Grow Node in Child Mode\n    description: |\n      When pressed, instantly grow a node and place it to the right of the currently selected node.\n      Automatically arrange the entire node tree structure to ensure it forms a rightward tree structure.\n      Before using this feature, ensure a node is selected and that the structure is tree-like.\n  generateNodeTreeWithBroadMode:\n    title: Grow Node in Brother Mode\n    description: |\n      When pressed, instantly grow a sibling node and place it below the currently selected node.\n      Automatically arrange the entire node tree structure to ensure it forms a downward tree structure.\n      Before using this feature, ensure a node is selected and that it has a parent node.\n  masterBrakeControl:\n    title: Master Brake Control for Camera Movement\n    description: |\n      When pressed, the camera the camera stops drifting and sets the speed to 0\n  selectAll:\n    title: Select All\n    description: Select all nodes and connections when pressed\n  selectAtCrosshair:\n    title: Select node at crosshair\n    description: |\n      Press Q to select the node pointed by the screen center crosshair\n      If there is a node at that location, select it (deselect others)\n  addSelectAtCrosshair:\n    title: Add selection at crosshair\n    description: |\n      Press Shift+Q to add the node pointed by the screen center crosshair to current selection\n      If the node is already selected, deselect it\n  createTextNodeFromCameraLocation:\n    title: Create Text Node at Camera Center\n    description: |\n      When pressed, a text node will be created at the center of the current viewport.\n      This is equivalent to the function of creating a node by double-clicking with the mouse.\n  createTextNodeFromSelectedTop:\n    title: Create Text Node Above Selected Node\n    description: |\n      When pressed, a text node will be created directly above the currently selected node.\n  createTextNodeFromSelectedDown:\n    title: Create Text Node Below Selected Node\n    description: |\n      When pressed, a text node will be created directly below the currently selected node.\n  createTextNodeFromSelectedLeft:\n    title: Create Text Node to the Left of Selected Node\n    description: |\n      When pressed, a text node will be created to the left of the currently selected node.\n  createTextNodeFromSelectedRight:\n    title: Create Text Node to the Right of Selected Node\n    description: |\n      When pressed, a text node will be created to the right of the currently selected node.\n  moveUpSelectedEntities:\n    title: Move Selected Entities Up\n    description: |\n      When pressed, all selected entities will move up by a fixed distance.\n  moveDownSelectedEntities:\n    title: Move Selected Entities Down\n    description: |\n      When pressed, all selected entities will move down by a fixed distance.\n  moveLeftSelectedEntities:\n    title: Move Selected Entities Left\n    description: |\n      When pressed, all selected entities will move left by a fixed distance.\n  moveRightSelectedEntities:\n    title: Move Selected Entities Right\n    description: |\n      When pressed, all selected entities will move right by a fixed distance.\n  jumpMoveUpSelectedEntities:\n    title: Jump Move Selected Entities Up\n    description: |\n      When pressed, all selected entities will jump up by a fixed distance, allowing them to enter or exit a Section box.\n  jumpMoveDownSelectedEntities:\n    title: Jump Move Selected Entities Down\n    description: |\n      When pressed, all selected entities will jump down by a fixed distance, allowing them to enter or exit a Section box.\n  jumpMoveLeftSelectedEntities:\n    title: Jump Move Selected Entities Left\n    description: |\n      When pressed, all selected entities will jump left by a fixed distance, allowing them to enter or exit a Section box.\n  jumpMoveRightSelectedEntities:\n    title: Jump Move Selected Entities Right\n    description: |\n      When pressed, all selected entities will jump right by a fixed distance, allowing them to enter or exit a Section box.\n  CameraScaleZoomIn:\n    title: Zoom In\n    description: Zoom in the viewport when pressed\n  CameraScaleZoomOut:\n    title: Zoom Out\n    description: Zoom out the viewport when pressed\n  exitSoftware:\n    title: Exit Software\n    description: Exit the software when pressed\n  decreaseFontSize:\n    title: Decrease Font Size\n    description: Decrease the font size of selected text nodes. Only works on text nodes. Press Ctrl+- to decrease.\n  increaseFontSize:\n    title: Increase Font Size\n    description: Increase the font size of selected text nodes. Only works on text nodes. Press Ctrl+= to increase.\n  checkoutProtectPrivacy:\n    title: Enter/Exit Privacy Protection Mode\n    description: |\n      When pressed, all text on the stage will be encrypted and invisible to others.\n      Press again to decrypt and make the text visible.\n      This can be used for screenshot feedback or when someone unexpectedly looks at your screen, especially if the content is personal.\n  openTextNodeByContentExternal:\n    title: Open Selected Node Content Externally\n    description: |\n      When pressed, all selected text nodes will be opened in the default manner or browser.\n      For example, if a node contains \"D:/Desktop/a.txt\", pressing this key will open the file with the system default method.\n      If the node content is a web address like \"https://project-graph.top\", it will open the webpage in the default browser.\n  checkoutClassroomMode:\n    title: Enter/Exit Focus Mode\n    description: |\n      When pressed, enter focus mode, where all UI elements will be hidden, and the top buttons will become transparent.\n      Press again to restore.\n  checkoutWindowOpacityMode:\n    title: Toggle Window Transparency Mode\n    description: |\n      When pressed, the window will enter full transparency mode. Press again to switch to full opacity mode.\n      This should be coordinated with the stage color scheme. For example, in dark mode, the text is white, and in paper white mode, the text is black.\n      If the underlying content of the window is a white background, it is recommended to switch the stage to paper white mode.\n  searchText:\n    title: Search Text\n    description: |\n      When pressed, open the search box where you can enter search terms.\n      The search box supports partial matching. For example, entering \"a\" will match \"apple\".\n  clickTagPanelButton:\n    title: Open/Close Tag Panel\n    description: |\n      Pressing this key will toggle the tag panel, similar to clicking the expand/collapse button on the page.\n  copy:\n    title: Copy\n    description: Copy the selected content when pressed\n  paste:\n    title: Paste\n    description: Paste the clipboard content when pressed\n  pasteWithOriginLocation:\n    title: Paste at Original Location\n    description: When pressed, the pasted content will overlap with the original\n      position\n  selectEntityByPenStroke:\n    title: Graffiti and Entity Diffusion Selection\n    description: |\n      After selecting a graffiti or entity, press this key to expand the selection to surrounding entities.\n      If the current selection is graffiti, it will select entities touched by the graffiti.\n      If the current selection is an entity, it will select all graffiti that touch it.\n      Pressing multiple times will alternate the diffusion selection\n  clickAppMenuSettingsButton:\n    title: Open settings page\n    description: \"When this key is pressed, the settings page opens without mouse\\n\"\n  createTextNodeFromMouseLocation:\n    title: Create Text Node at Mouse Position\n    description: \"After pressing, create a text node at the current position of the\n      mouse \\nEquivalent to the function of creating nodes with a mouse click\\n\"\n  clickAppMenuRecentFileButton:\n    title: Open the list of recently opened file\n    description: \"When this key is pressed, open the list of recently opened files\n      without mouse\\n\"\n  clickStartFilePanelButton:\n    title: Open/Close start file panel\n    description: \"When this key is pressed, change the state(Open/Close) of Start\n      File Panel without mouse\\n\"\n  checkoutLeftMouseToSelectAndMove:\n    title: Set the left mouse button to \"Select/Move\" mode\n    description: \"That is to say, the left mouse button switches to normal mode\\n\"\n  checkoutLeftMouseToDrawing:\n    title: Set the left mouse button to \"Drawing\" mode\n    description: \"That is to say, the left mouse button switches to Drawing Mode,\n      and there is a corresponding button in the toolbar\\n\"\n  checkoutLeftMouseToConnectAndCutting:\n    title: Set the left mouse button to \"Connect/Cutting\" Mode\n    description: \"That is to say, the left mouse button switches to connect/cutting\n      mode, and there is corresponding button in the toolbar\\n\"\n  penStrokeWidthIncrease:\n    title: Increase Pen Stroke Size\n    description: After pressing, the stroke becomes thicker\n  penStrokeWidthDecrease:\n    description: After pressing, the stroke becomes thinner\n    title: Decrease Pen Stroke Size\n  generateNodeGraph:\n    title: Grow Node in Free Mode\n    description: \"After pressing, a virtual growth position appears \\nPress the \\\"\\\n      I J K L\\\" key while holding down the key to freely adjust the growth position\\\n      \\ \\nAfter release, growth is complete \\nBefore using this feature, ensure that\n      a node has been selected\\n\"\n  createConnectPointWhenDragConnecting:\n    title: Create Connect Point When Drag Connecting\n    description: \"When dragging a connection, a connect point will be created at the\n      current position of the mouse.\\n\"\n  windowOpacityAlphaIncrease:\n    title: Increase window opacity\n    description: \"After pressing, the window opacity (alpha) value will be increased\n      by 0.2, with a maximum value of 1 \\nWhen it cannot be increased any further,\n      there will be a window edge hint\\n\"\n  windowOpacityAlphaDecrease:\n    title: Decrease window opacity\n    description: \"After pressing, the window opacity (alpha) value decreases by 0.2,\n      with a minimum value of 0 \\nIf your keyboard does not have a pure minus key\n      on the keypad, you can use the minus and underline key on the right side of\n      the upper digit 0 key instead\\n\"\n  openFile:\n    title: Open File\n    description: \"Select a previously saved json/prg file and open it.\\n\"\n  newDraft:\n    title: Create New Draft\n    description: \"Create a new draft file and switch to this file.\\nIf the current\n      file is not saved, you cannot switch.\\n\"\n  newFileAtCurrentProjectDir:\n    title: Create New File at Current Project Directory\n    description: \"Create a new project file in the current project directory and\n      switch to this file.\\nIf the current file is draft, you cannot create.\\n\"\n  saveFile:\n    description: \"Save the current project file, if the current file is a draft, then\n      save it as a new file.\\n\"\n    title: Save File\n  masterBrakeCheckout:\n    title: 'Handbrake: Open/Close Camera Movement Controlled by Button'\n  checkoutLeftMouseToConnectAndCuttingOnlyPressed:\n    description: \"Release to switch back to the default mouse mode\\n\"\n  reverseImageColors:\n    title: Reverse Image Colors\n    description: Reverse the colors of selected images (makes white background black and vice versa)\n  closeCurrentProjectTab:\n    title: Close Current Project Tab\n    description: Close the currently active project tab. If there are unsaved changes, you will be prompted to save. Disabled by default; can be enabled in settings.\n  closeAllSubWindows:\n    title: Close All Sub-Windows\n    description: Closes all currently open sub-windows (e.g., Settings, AI, Color panels) and restores focus to the main stage.\n  toggleFullscreen:\n    title: Toggle Fullscreen\n    description: Toggle the application window between fullscreen and windowed mode.\n  setWindowToMiniSize:\n    title: Set Window to Mini Size\n    description: Set the window size to the mini window width and height configured in settings.\n  \neffects:\n  RectangleLittleNoteEffect:\n    title: Rectangle Glimmer Hint Effect\n    description: \"When executing logical nodes, they will display a glimmer effect.\\n\"\n  RectangleNoteEffect:\n    description: \"Highlights a rectangular area when searching or locating nodes\\n\\\n      \\ Will not display the highlight effect if disabled\\n\"\n    title: Rectangle Existance Hint Effect\n  RectangleRenderEffect:\n    description: \"Used to display the alignment position of the entity snapping target\n      while dragging.\\n\"\n    title: Rectangle Position Prompt Effect\n  RectangleSplitTwoPartEffect:\n    description: \"Exists only for chop effects (or possibly four parts)\\n\"\n    title: Rectangle Chop Two-Part Effect\n  NodeMoveShadowEffect:\n    title: Node Moving Particles Effect\n    description: \"Node movement caused by aligning might produce a flash of particles\\n\"\n  EntityAlignEffect:\n    title: Entity Alignment Effect\n    description: \"Dashed snap lines displayed when dragging\\n\"\n  LineCuttingEffect:\n    description: \"When cutting, a blade light like the Fruit Ninja will appear.\\n\"\n    title: Line Cutting Blade Light Effect\n  LineEffect:\n    title: Line Fade Out Effect\n    description: \"Ghost Trails for Subtree Drag-Rotation\\n\"\n  PenStrokeDeletedEffect:\n    title: Pen Stroke Delete Effect\n    description: \"When a pen stroke is deleted, a fade away effect will appear.\\n\"\n  CircleChangeRadiusEffect:\n    title: Circular Transform Radius\n    description: \"Enhance the ripple effect after selecting the particle\\n\"\n  TextRiseEffect:\n    description: \"Floating effect on text nodes, used for highlighting important information\\n\"\n    title: Text Floating Effect\n  EntityCreateDashEffect:\n    title: Dust condensation effect during entity creation\n    description: \"When an entity is created, dust condensation appears around the\n      entity\\nDue to its lack of aesthetic appeal, it has been abandoned and will\n      not appear\\n\"\n  CircleFlameEffect:\n    description: \"Existing in various special effect details, such as the midpoint\n      flicker when pre-cutting straight lines and cutting through solid rectangles,\n      and when cutting through connections\\n\"\n    title: Radial Light Flash\n  EntityCreateFlashEffect:\n    title: Entity border glow effect\n    description: \"In situations such as rotating the entity tree with Ctrl+scroll\n      wheel, zooming in on images, creating nodes, etc.\\nIf the rendering performance\n      is poor, it is recommended to turn off this option\\n\"\n  PointDashEffect:\n    description: \"Due to the influence of universal gravitation on performance, this\n      effect has been turned off and will not appear.\\n\"\n    title: Singularity Burst\n  ZapLineEffect:\n    title: (Basic Effects) Zap Line Effect\n    description: \"This effect is a component of other effects; if it is turned off,\n      other effects may be affected.\\n\"\n  TechLineEffect:\n    description: \"This effect is a component of other effects; if it is turned off,\n      other effects may be affected.\\n\"\n  TextRaiseEffectLocated:\n    title: ''\n    description: \"Text node floating upward effect, used for prompting important information\\n\"\n  ViewOutlineFlashEffect:\n    title: ''\n  ViewFlashEffect:\n    description: \"Full-screen flash white/flash black effects,\\nplease turn off this\n      option for photosensitive epilepsy patients.\\n\"\n  MouseTipFeedbackEffect:\n    title: Mouse Interaction Feedback Effect\n    description: \"When the mouse performs operations such as zooming the view, an\n      effect prompt will appear next to the mouse, such as a circle that expands or\n      shrinks.\\n\"\n  EntityShakeEffect:\n    title: Entity judder effect\n    description: \"Warning Jitter Effect\\n\"\n  EntityShrinkEffect:\n    description: \"Press the Delete key to delete the entity, the entity will shrink\n      and disappear\\n\"\n    title: Entity Shrink and Disappear Effect\n  ExplodeDashEffect:\n    title: Dust Explosion Effect\n    description: 'Particle Burst Effect Triggered by Cleaving Elimination\n\n      '\n  EntityCreateLineEffect:\n    title: Entity emits circuit board-like line radiation effect\n    description: \"Already abandoned, will not appear\\n\"\n  EntityDashTipEffect:\n    description: \"When entering or ending input within an entity, dust particles shake\n      around the entity.\\n\"\n    title: Dust Particle Highlight Effect\n  EntityJumpMoveEffect:\n    title: Entity's jump move effect\n    description: \"When an entity performs a jump move, a symbolic jump arc shadow\n      appears\\n to represent the level crossing movement along the pseudo Z-axis.\\n\"\n  RectanglePushInEffect:\n    title: Quadrilateral Vertex Morphing\nkeyBindsGroup:\n  ui:\n    title: UI Controls\n    description: \"Used for controlling UI functions\\n\"\n  draw:\n    title: Drawing\n    description: \"Drawing related functions\\n\"\n  moveEntity:\n    description: \"Functions used for moving entities\\n\"\n    title: Entity Moving\n  generateTextNodeRoundedSelectedNode:\n    title: Generate Text Node Around Selected Node\n    description: \"When pressed, generate a text node around the selected node.\\n\"\n  aboutTextNode:\n    description: \"About Text Node\"\n    title: About Text Node\n  basic:\n    title: Basic Shortcut Keys\n    description: \"Basic shortcut keys for commonly used functions\\n\"\n  otherKeys:\n    title: Unclassified Shortcuts\n    description: \"Unclassified shortcuts.\\nIf invalid shortcut items without translation\n      are found here, it may be due to old shortcuts remaining after version upgrades,\\n\\\n      \\ which can be manually cleaned up by deleting the corresponding items in the\n      keybinds.json file.\\n\"\n  camera:\n    title: Camera Control\n    description: \"Used for controlling camera movement and zoom\\n\"\n  app:\n    title: Application Control\n    description: \"Some functions used to control the application\\n\"\n  section:\n    description: \"Functions about Section Box.\\n\"\n    title: Section Box\n  themes:\n    title: thems Checkout\n    description: |\n      about thems checkout\n  align:\n    title: Align Shortcuts\n    description: |\n      Functions used for aligning entities\n  image:\n    title: Image\n    description: |\n      Image related functions\n  node:\n    title: Node Related\n    description: |\n      Node related functions, such as grafting and removing nodes\ncontrolSettingsGroup:\n  mouse:\n    title: Mouse Settings\n  touchpad:\n    title: Touchpad Settings\n  textNode:\n    title: TextNode Settings\n  gamepad:\n    title: Gamepad Settings\nvisualSettingsGroup:\n  basic:\n    title: Basic Settings\n  background:\n    title: Background Settings\n\nsounds:\n  soundEnabled: Sound Effect Switch\n\n"
  },
  {
    "path": "app/src/locales/id.yml",
    "content": "welcome:\n  slogan: Perangkat Lunak Pemetaan Kerangka Berpikir Berbasis Teori Graf\n  slogans:\n    - Perangkat Lunak Pemetaan Kerangka Berpikir Berbasis Teori Graf\n    - Ekspresikan desain Anda di kanvas tak terbatas\n    - Biarkan pikiran mengalir bebas antar node dan garis\n    - Bangun jaringan pengetahuan Anda dengan pemikiran teori graf\n    - Dari kekacauan ke tatanan, dari node ke sistem\n    - Visualisasi berpikir, manajemen topologi\n    - Kanvas tak terbatas, kemungkinan tak terbatas\n    - Hubungkan titik-titik ide, gambar peta makro\n    - Bukan hanya peta pikiran, tapi kerangka berpikir\n    - Alat visualisasi berpikir yang didorong oleh teori graf\n  newDraft: Konsep Baru\n  openFile: Buka File\n  openRecentFiles: Buka Terbaru\n  newUserGuide: Panduan Fitur\n  settings: Pengaturan\n  about: Tentang\n  website: Situs Web\n  title: Project Graph\n  language: Bahasa\n  next: Selanjutnya\n  github: GitHub\n  bilibili: Bilibili\n  qq: Grup QQ\n  subtitle: Perangkat Lunak Peta Pikiran Kanvas Tak Terbatas Berbasis Teori Graf\n\nglobalMenu:\n  file:\n    title: File\n    new: Konsep Sementara Baru\n    open: Buka\n    recentFiles: File Terbaru\n    clear: Bersihkan\n    save: Simpan\n    saveAs: Simpan Sebagai\n    impor: Impor\n    importFromFolder: Hasilkan Diagram Bersarang dari Folder\n    importTreeFromFolder: Hasilkan Diagram Pohon dari Folder\n    generateKeyboardLayout: Hasilkan Layout Keyboard dari Konfigurasi Pintasan Saat Ini\n    export: Ekspor\n    exportAsSVG: Ekspor sebagai SVG\n    exportAll: Ekspor Semua Konten\n    plainTextType:\n      exportAllNodeGraph: Ekspor Seluruh Struktur Jaringan\n      exportSelectedNodeGraph: Ekspor Struktur Jaringan yang Dipilih\n      exportSelectedNodeTree: Ekspor Struktur Pohon yang Dipilih (Indentasi Teks Murni)\n      exportSelectedNodeTreeMarkdown: Ekspor Struktur Pohon yang Dipilih (Format Markdown)\n      exportSelectedNodeGraphMermaid: Ekspor Struktur Jaringan Bersarang yang Dipilih (Format Mermaid)\n    exportSelected: Ekspor Konten yang Dipilih\n    plainText: Teks Murni\n    exportSuccess: Ekspor Berhasil\n    attachments: Manajer Lampiran\n    tags: Manajer Tag\n  view:\n    title: Tampilan\n    resetViewAll: Atur Ulang Tampilan Berdasarkan Semua Konten\n    resetViewSelected: Atur Ulang Tampilan Berdasarkan Konten yang Dipilih\n    resetViewScale: Atur Ulang Skala Tampilan ke Ukuran Standar\n    moveViewToOrigin: Pindahkan Tampilan ke Titik Asal Koordinat\n  actions:\n    title: Tindakan\n    search: Cari\n    refresh: Segarkan\n    undo: Batalkan\n    redo: Ulangi\n    releaseKeys: Lepaskan Tombol\n    confirmClearStage: Konfirmasi Bersihkan Panggung?\n    irreversible: Tindakan ini tidak dapat dibatalkan!\n    clearStage: Bersihkan Panggung\n    cancel: Batal\n    confirm: Konfirmasi\n    generating: Menghasilkan\n    success: Berhasil\n    failed: Gagal\n    generate:\n      generatedIn: Waktu Pembuatan\n      title: Hasilkan\n      generateNodeTreeByText: Hasilkan Struktur Pohon dari Teks Murni\n      generateNodeTreeByTextDescription: Silakan masukkan teks struktur pohon, setiap baris mewakili satu node, indentasi menunjukkan level hierarki\n      generateNodeTreeByTextPlaceholder: Masukkan teks struktur pohon...\n      generateNodeTreeByMarkdown: Hasilkan Struktur Pohon dari Teks Markdown\n      generateNodeTreeByMarkdownDescription: Silakan masukkan string format markdown dengan judul level berbeda\n      generateNodeTreeByMarkdownPlaceholder: Masukkan teks format markdown...\n      indention: Jumlah Karakter Indentasi\n      generateNodeGraphByText: Hasilkan Struktur Jaringan dari Teks Murni\n      generateNodeGraphByTextDescription: Silakan masukkan teks struktur jaringan, setiap baris mewakili satu relasi, format setiap baris adalah `XXX --> XXX`\n      generateNodeGraphByTextPlaceholder: |\n        Zhang San -suka-> Li Si\n        Li Si -benci-> Wang Wu\n        Wang Wu -kagum-> Zhang San\n        A --> B\n        B --> C\n        C --> D\n      generateNodeMermaidByText: Hasilkan Struktur Bersarang dari Teks Mermaid\n      generateNodeMermaidByTextDescription: Mendukung teks mermaid format graph TD, dapat mengenali Section secara otomatis dan membuat struktur bersarang\n      generateNodeMermaidByTextPlaceholder: |\n        graph TD;\n          A[Section A] --> B[Section B];\n          A --> C[Node Biasa];\n          B --> D[Node Lain];\n        ;\n  settings:\n    title: Pengaturan\n    appearance: Personalisasi\n  ai:\n    title: AI\n    openAIPanel: Buka Panel AI\n  window:\n    title: Tampilan\n    fullscreen: Layar Penuh\n    classroomMode: Mode Fokus\n    classroomModeHint: Tombol menu kiri atas hanya transparan, tidak hilang\n  about:\n    title: Tentang\n    guide: Panduan Fitur\n  unstable:\n    title: Versi Beta\n    notRelease: Versi ini bukan versi resmi\n    mayHaveBugs: Mungkin mengandung Bug dan fitur yang belum sempurna\n    reportBug: \"Laporkan Bug: Komentar di Issue #487\"\n    test: Fitur Tes\n\ncontextMenu:\n  createTextNode: Buat Node Teks\n  createConnectPoint: Buat Titik Massa\n  packToSection: Kemas sebagai Kotak\n  createMTUEdgeLine: Buat Garis Tak Berarah\n  createMTUEdgeConvex: Buat Convex Hull\n  convertToSection: Konversi ke Kotak\n  toggleSectionCollapse: Alihkan Status Lipat\n  changeColor: Ubah Warna\n  resetColor: Atur Ulang\n  switchMTUEdgeArrow: Alihkan Bentuk Panah\n  mtuEdgeArrowOuter: Panah Keluar\n  mtuEdgeArrowInner: Panah Masuk\n  mtuEdgeArrowNone: Matikan Tampilan Panah\n  switchMTUEdgeRenderType: Alihkan Jenis Render\n  convertToDirectedEdge: Konversi ke Garis Berarah\n\nsettings:\n  title: Pengaturan\n  categories:\n    ai:\n      title: AI\n      api: API\n    automation:\n      title: Otomatisasi\n      autoNamer: Penamaan Otomatis\n      autoSave: Penyimpanan Otomatis\n      autoBackup: Pencadangan Otomatis\n      autoImport: Impor Otomatis\n    control:\n      title: Kontrol\n      mouse: Mouse\n      touchpad: Touchpad\n      cameraMove: Gerakan Tampilan\n      cameraZoom: Zoom Tampilan\n      objectSelect: Pemilihan Objek\n      textNode: Node Teks\n      section: Bagian\n      edge: Garis\n      generateNode: Pertumbuhan Node via Keyboard\n      gamepad: Gamepad\n    visual:\n      title: Visual\n      basic: Dasar\n      background: Latar Belakang\n      node: Gaya Node\n      edge: Gaya Garis\n      section: Gaya \"Kotak\"\n      entityDetails: Detail Entitas\n      debug: Debug\n      miniWindow: Jendela Mini\n      experimental: Fitur Eksperimental\n    performance:\n      title: Performa\n      memory: Memori\n      cpu: CPU\n      render: Render\n      experimental: Fitur dalam Pengembangan\n  language:\n    title: Bahasa\n    options:\n      en: English\n      zh_CN: 简体中文\n      zh_TW: 繁體中文\n      zh_TWC: 繁體中文 (Kasar)\n      id: Bahasa Indonesia\n  showTipsOnUI:\n    title: Tampilkan Tips di UI\n    description: |\n      Saat diaktifkan, akan ada baris teks petunjuk di layar.\n      Jika Anda sudah familiar dengan perangkat lunak ini, disarankan untuk menonaktifkan ini untuk mengurangi penggunaan layar\n      Untuk tips lebih detail, disarankan melihat \"Panduan Fitur\" di menu atau dokumentasi situs web.\n  isClassroomMode:\n    title: Mode Fokus\n    description: |\n      Untuk skenario pengajaran, pelatihan, dll.\n      Saat diaktifkan, tombol atas jendela akan transparan, akan pulih saat mouse diarahkan, dapat mengubah pintasan keyboard untuk masuk/keluar mode fokus\n  showQuickSettingsToolbar:\n    title: Tampilkan Bilah Pengaturan Cepat\n    description: |\n      Mengontrol apakah menampilkan bilah operasi cepat di sisi kanan antarmuka (bilah pengaturan cepat).\n      Bilah pengaturan cepat memungkinkan Anda mengalihkan status pengaturan umum dengan cepat.\n  autoAdjustLineEndpointsByMouseTrack:\n    title: Sesuaikan Posisi Ujung Garis Otomatis Berdasarkan Jejak Mouse\n    description: |\n      Saat diaktifkan, posisi ujung garis pada entitas akan disesuaikan secara otomatis berdasarkan jejak gerakan mouse saat menyeret garis\n      Saat dimatikan, ujung garis akan selalu berada di tengah entitas\n  enableRightClickConnect:\n    title: Aktifkan Fungsi Koneksi Klik Kanan\n    description: |\n      Saat diaktifkan, klik kanan entitas lain saat entitas dipilih akan membuat garis secara otomatis, dan menu konteks hanya muncul di area kosong\n      Saat dimatikan, menu dapat dibuka langsung di entitas dengan klik kanan, tidak akan membuat garis otomatis\n  lineStyle:\n    title: Gaya Garis\n    options:\n      straight: Garis Lurus\n      bezier: Kurva Bezier\n      vertical: Garis Patah Vertikal\n  isRenderCenterPointer:\n    title: Tampilkan Penanda Silang Pusat\n    description: |\n      Saat diaktifkan, penanda silang akan ditampilkan di tengah layar, digunakan untuk menunjukkan posisi pembuatan node via pintasan keyboard\n  showGrid:\n    title: Tampilkan Grid\n  showBackgroundHorizontalLines:\n    title: Tampilkan Garis Horizontal Latar\n    description: |\n      Garis horizontal dan vertikal dapat dibuka bersamaan untuk efek grid\n  showBackgroundVerticalLines:\n    title: Tampilkan Garis Vertikal Latar\n  showBackgroundDots:\n    title: Tampilkan Titik Latar\n    description: |\n      Titik-titik latar ini adalah perpotongan garis horizontal dan vertikal, menciptakan efek papan berlubang\n  showBackgroundCartesian:\n    title: Tampilkan Sistem Koordinat Kartesius Latar\n    description: |\n      Saat diaktifkan, akan menampilkan sumbu x, sumbu y dan angka skala\n      Dapat digunakan untuk mengamati posisi koordinat absolut beberapa node\n      Juga dapat dengan intuitif mengetahui kelipatan zoom tampilan saat ini\n  windowBackgroundAlpha:\n    title: Transparansi Latar Jendela\n    description: |\n      *Mengubah dari 1 ke nilai kurang dari 1 memerlukan pembukaan ulang file untuk efek\n  windowBackgroundOpacityAfterOpenClickThrough:\n    title: Transparansi Latar Jendela Setelah Aktifkan Klik Tembus\n    description: |\n      Mengatur transparansi latar jendela setelah fungsi klik tembus diaktifkan\n  windowBackgroundOpacityAfterCloseClickThrough:\n    title: Transparansi Latar Jendela Setelah Nonaktifkan Klik Tembus\n    description: |\n      Mengatur transparansi latar jendela setelah fungsi klik tembus dinonaktifkan\n  showDebug:\n    title: Tampilkan Informasi Debug\n    description: |\n      Biasanya digunakan oleh pengembang\n      Saat diaktifkan, informasi debug akan ditampilkan di sudut kiri atas layar.\n      Disarankan mengaktifkan ini saat melaporkan bug dengan screenshot.\n  enableTagTextNodesBigDisplay:\n    title: Tampilan Besar Node Teks Tag\n    description: |\n      Saat diaktifkan, saat kamera diperkecil ke tampilan global yang luas,\n      tag akan ditampilkan lebih besar untuk memudahkan identifikasi distribusi layout seluruh file\n  showTextNodeBorder:\n    title: Tampilkan Batas Node Teks\n    description: |\n      Mengontrol apakah menampilkan batas node teks\n  showTreeDirectionHint:\n    title: Tampilkan Petunjuk Arah Pertumbuhan Pohon\n    description: |\n      Saat node teks dipilih, tampilkan petunjuk keyboard (tab/W W/S S/A A/D D) di sekitar node yang menunjukkan arah pertumbuhan pohon.\n      Saat dinonaktifkan, label petunjuk ini tidak akan dirender.\n  sectionBitTitleRenderType:\n    title: Jenis Render Judul Besar Ringkasan Kotak\n    options:\n      none: Tidak Render (Hemat Performa)\n      top: Teks Kecil Atas\n      cover: Tutupi Kotak dengan Transparan (Efek Terbaik)\n  sectionBigTitleThresholdRatio:\n    title: Ambang Tampilan Judul Besar Ringkasan Kotak\n    description: |\n      Saat sisi terpanjang kotak lebih kecil dari rasio ini terhadap sisi terpanjang rentang tampilan, tampilkan judul besar ringkasan\n  sectionBigTitleCameraScaleThreshold:\n    title: Ambang Zoom Kamera Judul Besar Ringkasan Kotak\n    description: |\n      Saat rasio zoom kamera lebih besar dari ambang ini, jangan tampilkan judul besar ringkasan\n      Rasio zoom kamera perlu dibuka di mode debug untuk ditampilkan\n  sectionBigTitleOpacity:\n    title: Opasitas Judul Besar Ringkasan Kotak\n    description: |\n      Mengontrol opasitas tutupan judul besar transparan, rentang nilai 0-1\n  sectionBackgroundFillMode:\n    title: Mode Pengisian Warna Latar Kotak\n    description: |\n      Mengontrol cara pengisian warna latar kotak section\n      Isi Penuh: Isi seluruh latar kotak (cara default, dengan transparansi dan penentuan urutan mask)\n      Hanya Bilah Judul: Isi hanya bagian bilah judul di atas\n    options:\n      full: Isi Penuh\n      titleOnly: Hanya Bilah Judul\n  alwaysShowDetails:\n    title: Selalu Tampilkan Detail Node\n    description: |\n      Saat diaktifkan, detail node akan ditampilkan tanpa perlu mengarahkan mouse ke node.\n  nodeDetailsPanel:\n    title: Panel Detail Node\n    options:\n      small: Panel Kecil\n      vditor: Editor Markdown vditor\n  useNativeTitleBar:\n    title: Gunakan Bilah Judul Asli (Perlu Restart Aplikasi)\n    description: |\n      Saat diaktifkan, bilah judul asli akan muncul di atas jendela, bukan bilah judul simulasi.\n  protectingPrivacy:\n    title: Perlindungan Privasi\n    description: |\n      Digunakan saat screenshot untuk melaporkan masalah, setelah diaktifkan akan mengganti teks sesuai mode yang dipilih untuk melindungi privasi.\n      Hanya penggantian di level tampilan, tidak mempengaruhi data asli\n      Matikan kembali setelah selesai melaporkan untuk memulihkan\n  protectingPrivacyMode:\n    title: Mode Perlindungan Privasi\n    description: |\n      Pilih cara penggantian teks saat perlindungan privasi\n    options:\n      secretWord: Ganti Seragam (Hanzi→㊙, Huruf→a/A, Angka→6)\n      caesar: Pergeseran Caesar (Semua karakter digeser satu posisi)\n  entityDetailsFontSize:\n    title: Ukuran Font Detail Entitas\n    description: |\n      Mengatur ukuran teks informasi detail entitas yang dirender di panggung, dalam piksel\n  entityDetailsLinesLimit:\n    title: Batas Baris Detail Entitas\n    description: |\n      Membatasi jumlah baris maksimum informasi detail entitas yang dirender di panggung, bagian yang melebihi batas akan dihilangkan\n  entityDetailsWidthLimit:\n    title: Batas Lebar Detail Entitas\n    description: |\n      Membatasi lebar maksimum informasi detail entitas yang dirender di panggung (dalam piksel px, dapat mereferensi sumbu koordinat grid latar), bagian yang melebihi batas akan dipecah baris\n  windowCollapsingWidth:\n    title: Lebar Jendela Mini\n    description: |\n      Lebar jendela saat beralih ke jendela mini, dalam piksel\n  windowCollapsingHeight:\n    title: Tinggi Jendela Mini\n    description: |\n      Tinggi jendela saat beralih ke jendela mini, dalam piksel\n  limitCameraInCycleSpace:\n    title: Aktifkan Batasan Gerakan Kamera dalam Ruang Siklik\n    description: |\n      Saat diaktifkan, kamera hanya dapat bergerak dalam area persegi panjang\n      Dapat mencegah kamera tersesat saat bergerak terlalu jauh\n      Area persegi panjang ini akan membentuk ruang siklik, mirip dengan peta dalam game ular tanpa batas\n      Ke atas teratas akan kembali ke bawah, ke kiri terkiri akan kembali ke kanan\n      Perhatian: Fitur ini masih dalam tahap eksperimen\n  cameraCycleSpaceSizeX:\n    title: Lebar Ruang Siklik\n    description: |\n      Lebar ruang siklik, dalam piksel\n  cameraCycleSpaceSizeY:\n    title: Tinggi Ruang Siklik\n    description: |\n      Tinggi ruang siklik, dalam piksel\n  renderEffect:\n    title: Efek Render\n    description: Apakah merender efek, jika lag dapat dimatikan\n  compatibilityMode:\n    title: Mode Kompatibilitas\n    description: |\n      Saat diaktifkan, perangkat lunak akan menggunakan cara render lain\n  historySize:\n    title: Ukuran Riwayat\n    description: |\n      Nilai ini menentukan berapa kali maksimum Anda dapat membatalkan dengan ctrl+z\n      Jika memori komputer Anda sangat sedikit, dapat mengurangi nilai ini\n  compressPastedImages:\n    title: Kompres Gambar yang Ditempel ke Panggung\n    description: |\n      Saat diaktifkan, gambar yang ditempel ke panggung akan dikompres untuk menghemat tekanan memori saat memuat file dan tekanan disk\n  maxPastedImageSize:\n    title: Batas Ukuran Gambar yang Ditempel (piksel)\n    description: |\n      Gambar yang panjang atau lebarnya melebihi ukuran ini, nilai maksimum panjang atau lebarnya akan dibatasi ke ukuran ini\n      Sambil mempertahankan rasio aspek, hanya berlaku saat \"Kompres Gambar yang Ditempel\" diaktifkan\n  isPauseRenderWhenManipulateOvertime:\n    title: Jeda Render Saat Tidak Beroperasi Panggung Selama Waktu Tertentu\n    description: |\n      Saat diaktifkan, jika tidak ada operasi panggung selama beberapa detik, render panggung akan dijeda untuk menghemat sumber daya CPU/GPU.\n  renderOverTimeWhenNoManipulateTime:\n    title: Waktu Berhenti Render Saat Tidak Beroperasi (detik)\n    description: |\n      Setelah tidak ada operasi panggung selama waktu tertentu, render panggung akan dihentikan untuk menghemat sumber daya CPU/GPU.\n      Hanya berlaku setelah opsi \"Jeda Render Saat Tidak Beroperasi\" diaktifkan.\n  ignoreTextNodeTextRenderLessThanFontSize:\n    title: Tidak Render Teks dan Detail Node Teks Saat Ukuran Font Render Kurang dari Nilai Tertentu\n    description: |\n      Saat diaktifkan, saat ukuran font render node teks kurang dari nilai tertentu (yaitu saat mengamati status makro)\n      Tidak merender teks dan detail di dalam node teks, ini dapat meningkatkan performa render, tetapi menyebabkan konten teks node tidak dapat ditampilkan\n  isEnableEntityCollision:\n    title: Deteksi Tabrakan Entitas\n    description: |\n      Saat diaktifkan, entitas akan bertabrakan dan menekan satu sama lain, dapat mempengaruhi performa.\n      Disarankan menonaktifkan ini, saat ini penekanan tabrakan entitas belum sempurna, dapat menyebabkan stack overflow\n  isEnableSectionCollision:\n    title: Aktifkan Tabrakan Bagian\n    description: |\n      Saat diaktifkan, bagian-bagian yang berdampingan akan saling mendorong untuk menghindari tumpang tindih.\n  autoRefreshStageByMouseAction:\n    title: Segarkan Panggung Otomatis Saat Operasi Mouse\n    description: |\n      Saat diaktifkan, operasi mouse (seret untuk menggerakkan tampilan) akan menyegarkan panggung secara otomatis\n      Mencegah situasi setelah membuka file, gambar belum termuat dengan sukses dan perlu penyegaran manual\n  autoNamerTemplate:\n    title: Template Penamaan Otomatis Saat Membuat Node\n    description: |\n      Masukkan `{{i}}` mewakili nama node akan diganti dengan nomor, saat membuat dengan klik ganda dapat menambah angka secara otomatis.\n      Contoh `n{{i}}` akan diganti menjadi `n1`, `n2`, `n3`...\n      Masukkan `{{date}}` akan diganti dengan tanggal saat ini, saat membuat dengan klik ganda dapat memperbarui tanggal secara otomatis.\n      Masukkan `{{time}}` akan diganti dengan waktu saat ini, saat membuat dengan klik ganda dapat memperbarui waktu secara otomatis.\n      Dapat dikombinasikan, contoh `{{i}}-{{date}}-{{time}}`\n  autoNamerSectionTemplate:\n    title: Template Penamaan Otomatis Saat Membuat Kotak\n    description: |\n      Masukkan `{{i}}` mewakili nama node akan diganti dengan nomor, saat membuat dengan klik ganda dapat menambah angka secara otomatis.\n      Contoh `n{{i}}` akan diganti menjadi `n1`, `n2`, `n3`...\n      Masukkan `{{date}}` akan diganti dengan tanggal saat ini, saat membuat dengan klik ganda dapat memperbarui tanggal secara otomatis.\n      Masukkan `{{time}}` akan diganti dengan waktu saat ini, saat membuat dengan klik ganda dapat memperbarui waktu secara otomatis.\n      Dapat dikombinasikan, contoh `{{i}}-{{date}}-{{time}}`\n  autoSaveWhenClose:\n    title: Simpan File Proyek Otomatis Saat Klik Tombol Tutup Jendela\n    description: |\n      Saat menutup perangkat lunak, jika ada file proyek yang belum disimpan, akan muncul kotak dialog konfirmasi untuk menyimpan.\n      Saat opsi ini diaktifkan, file proyek akan disimpan secara otomatis saat menutup perangkat lunak.\n      Jadi, disarankan mengaktifkan opsi ini.\n  autoSave:\n    title: Aktifkan Penyimpanan Otomatis\n    description: |\n      Simpan file saat ini secara otomatis\n      Fitur ini saat ini hanya berlaku untuk file dengan path yang ada, tidak berlaku untuk file konsep!\n  autoSaveInterval:\n    title: Interval Penyimpanan Otomatis (detik)\n    description: |\n      Perhatian: Saat ini waktu hanya dihitung saat jendela perangkat lunak aktif, tidak dihitung saat perangkat lunak diminimalkan.\n  clearHistoryWhenManualSave:\n    title: Bersihkan Riwayat Otomatis Saat Simpan Manual dengan Pintasan\n    description: |\n      Saat menyimpan file dengan pintasan Ctrl+S, riwayat operasi akan dibersihkan secara otomatis.\n      Mengaktifkan opsi ini dapat mengurangi penggunaan memori dan menjaga antarmuka tetap rapi.\n  historyManagerMode:\n    title: Mode Manajer Riwayat\n    description: |\n      Pilih cara pengelolaan riwayat:\n      memoryEfficient - Mode Efisien Memori, menggunakan penyimpanan inkremental, hemat memori tetapi mungkin sedikit lambat saat membatalkan/mengulang\n      timeEfficient - Mode Efisien Waktu, menggunakan penyimpanan snapshot lengkap, respons operasi cepat tetapi mungkin menggunakan lebih banyak memori\n    options:\n      memoryEfficient: Mode Efisien Memori\n      timeEfficient: Mode Efisien Waktu\n  autoBackup:\n    title: Aktifkan Pencadangan Otomatis\n    description: |\n      Cadangkan file saat ini ke folder cadangan secara otomatis\n      Jika konsep, akan disimpan di path yang ditentukan\n  autoBackupInterval:\n    title: Interval Pencadangan Otomatis (detik)\n    description: |\n      Pencadangan yang terlalu sering dapat menghasilkan banyak file cadangan\n      sehingga menggunakan ruang disk\n  autoBackupLimitCount:\n    title: Jumlah Maksimum Cadangan Otomatis\n    description: |\n      Jumlah maksimum cadangan otomatis, jika melebihi jumlah ini file cadangan lama akan dihapus\n  autoBackupCustomPath:\n    title: Path Pencadangan Otomatis Kustom\n    description: |\n      Mengatur path penyimpanan file cadangan otomatis, jika kosong akan menggunakan path default\n  scaleExponent:\n    title: Kecepatan Zoom Tampilan\n    description: |\n      \"Kelipatan Zoom Saat Ini\" akan terus mendekati \"Kelipatan Zoom Target\" dengan tingkat tertentu secara tak terhingga\n      Saat cukup dekat (kurang dari 0,0001), zoom akan berhenti secara otomatis\n      Nilai 1 berarti zoom akan selesai seketika, tanpa efek transisi di tengah\n      Nilai 0 berarti zoom tidak akan pernah selesai, dapat mensimulasikan efek kunci\n      Perhatian: Jika Anda merasa lag saat zoom, atur ke 1\n  cameraKeyboardScaleRate:\n    title: Laju Zoom Tampilan via Keyboard\n    description: |\n      Kelipatan zoom tampilan setiap kali melakukan zoom via keyboard\n      Nilai 0,2 berarti setiap zoom in menjadi 1,2 kali lipat, zoom out menjadi 0,8 kali lipat\n      Nilai 0 berarti melarang zoom via keyboard\n  scaleCameraByMouseLocation:\n    title: Zoom Tampilan Berdasarkan Posisi Mouse\n    description: |\n      Saat diaktifkan, titik pusat zoom tampilan adalah posisi mouse\n      Saat dimatikan, titik pusat zoom tampilan adalah pusat tampilan saat ini\n  allowMoveCameraByWSAD:\n    title: Izinkan Gerakan Tampilan dengan Tombol W S A D\n    description: |\n      Saat diaktifkan, dapat menggunakan tombol W S A D untuk menggerakkan tampilan atas bawah kiri kanan\n      Saat dimatikan, hanya dapat menggunakan mouse untuk menggerakkan tampilan, tidak akan menyebabkan bug scroll tak terbatas\n  allowGlobalHotKeys:\n    title: Izinkan Penggunaan Hotkey Global\n    description: |\n      Saat diaktifkan, dapat menggunakan hotkey global untuk memicu beberapa operasi\n  cameraFollowsSelectedNodeOnArrowKeys:\n    title: Tampilan Mengikuti Saat Beralih Node yang Dipilih dengan Tombol Arah\n    description: |\n      Saat diaktifkan, saat menggunakan keyboard untuk menggerakkan kotak pemilihan node, tampilan mengikuti pergerakan\n  arrowKeySelectOnlyInViewport:\n    title: Batasi Peralihan Pilihan dengan Tombol Arah dalam Tampilan\n    description: |\n      Saat diaktifkan, saat beralih pilihan node dengan tombol arah (atas bawah kiri kanan), hanya akan memilih objek yang terlihat dalam tampilan saat ini.\n      Saat dimatikan, dapat memilih objek di luar tampilan (kamera akan mengikuti secara otomatis).\n  cameraKeyboardMoveReverse:\n    title: Balikkan Arah Gerakan Tampilan via Keyboard\n    description: |\n      Saat diaktifkan, arah gerakan tombol W S A D akan terbalik\n      Logika gerakan asli adalah menggerakkan kamera yang melayang di atas layar, tetapi jika dilihat sebagai menggerakkan seluruh panggung, maka ini terbalik\n      Maka ada opsi ini\n  cameraKeyboardScaleReverse:\n    title: Balikkan Arah Zoom Tampilan via Keyboard\n    description: |\n      Saat diaktifkan, [=lepas landas (perkecil), ]=turun (perbesar)\n      Saat dimatikan, [=turun (perbesar), ]=lepas landas (perkecil)\n  cameraResetViewPaddingRate:\n    title: Koefisien Ruang Tepi Saat Atur Ulang Tampilan Berdasarkan Node yang Dipilih\n    description: |\n      Setelah memilih sekelompok node atau satu node, dan menekan pintasan atau tombol untuk mengatur ulang tampilan\n      Tampilan akan menyesuaikan ukuran dan posisi, memastikan semua konten yang dipilih muncul di tengah layar dan sepenuhnya tercakup\n      Karena alasan ukuran zoom tampilan, mungkin ada ruang tepi saat ini\n      Nilai 1 berarti tidak ada ruang tepi sama sekali. (Pengamatan sangat diperbesar)\n      Nilai 2 berarti konten ruang tepi tepat satu kali lipat dari konten itu sendiri\n  cameraResetMaxScale:\n    title: Nilai Zoom Maksimum Setelah Kamera Atur Ulang Tampilan\n    description: |\n      Saat memilih node dengan area sangat kecil, kamera tidak akan sepenuhnya mencakup area node tersebut, jika tidak akan terlalu besar.\n      Melainkan akan diperbesar ke nilai maksimum, nilai maksimum ini dapat disesuaikan melalui opsi ini\n      Disarankan mengaktifkan mode debug untuk mengamati currentScale saat menyesuaikan nilai ini\n  allowAddCycleEdge:\n    title: Izinkan Penambahan Loop pada Node\n    description: |\n      Saat diaktifkan, dapat menambahkan loop pada node, yaitu node terhubung dengan dirinya sendiri, digunakan untuk menggambar mesin status\n      Default mati, karena jarang digunakan, mudah memicu secara tidak sengaja\n  enableDragEdgeRotateStructure:\n    title: Izinkan Rotasi Struktur dengan Menyeret Garis\n    description: |\n      Saat diaktifkan, dapat memutar struktur node dengan menyeret garis yang dipilih\n      Ini memungkinkan Anda menyesuaikan arah node yang terhubung dengan mudah\n  enableCtrlWheelRotateStructure:\n    title: Izinkan Rotasi Struktur dengan Ctrl+Scroll Mouse\n    description: |\n      Saat diaktifkan, dapat menahan tombol Ctrl (Command di Mac) dan menggulir roda mouse untuk memutar struktur node\n      Ini memungkinkan Anda menyesuaikan arah node yang terhubung dengan presisi\n  autoLayoutWhenTreeGenerate:\n    title: Perbarui Layout Otomatis Saat Node Tumbuh\n    description: |\n      Saat diaktifkan, layout akan diperbarui secara otomatis saat node tumbuh\n      Pertumbuhan node di sini merujuk pada pertumbuhan node dengan tombol tab dan \\\n  enableBackslashGenerateNodeInInput:\n    title: Buat Node Selevel dengan Backslash dalam Mode Input\n    description: |\n      Saat diaktifkan, dalam mode edit node teks, menekan tombol backslash (\\) juga dapat membuat node selevel\n      Saat dimatikan, hanya dapat membuat node selevel dengan tombol backslash dalam mode non-edit\n  moveAmplitude:\n    title: Percepatan Gerakan Tampilan\n    description: |\n      Pengaturan ini digunakan untuk skenario menggunakan tombol W S A D untuk menggerakkan tampilan atas bawah kiri kanan\n      Kamera dapat dilihat sebagai pesawat melayang yang dapat menyembur ke empat arah\n      Nilai percepatan ini mewakili kekuatan dorong semburan, perlu dikombinasikan dengan pengaturan gesekan di bawah untuk menyesuaikan kecepatan\n  moveFriction:\n    title: Koefisien Gesekan Gerakan Tampilan\n    description: |\n      Pengaturan ini digunakan untuk skenario menggunakan tombol W S A D untuk menggerakkan tampilan atas bawah kiri kanan\n      Semakin besar koefisien gesekan, semakin kecil jarak geser, semakin kecil koefisien gesekan, semakin jauh jarak geser\n      Nilai ini=0 berarti sangat halus\n  gamepadDeadzone:\n    title: Zona Mati Gamepad\n    description: |\n      Pengaturan ini digunakan untuk skenario kontrol tampilan dengan gamepad\n      Nilai input gamepad antara 0-1, semakin kecil nilai ini, semakin sensitif input gamepad\n      Semakin besar zona mati, semakin condong input gamepad ke 0 atau 1, tidak akan menghasilkan perubahan terlalu besar\n      Semakin kecil zona mati, semakin condong input gamepad ke nilai tengah, akan menghasilkan perubahan besar\n  mouseRightDragBackground:\n    title: Operasi Seret Latar dengan Klik Kanan\n    options:\n      cut: Potong dan Hapus Objek\n      moveCamera: Gerakkan Tampilan\n  enableSpaceKeyMouseLeftDrag:\n    title: Aktifkan Spasi+Klik Kiri Seret untuk Menggerakkan\n    description: Tahan tombol spasi dan gunakan klik kiri mouse untuk menyeret dan menggerakkan tampilan\n  mouseLeftMode:\n    title: Alihkan Mode Klik Kiri\n    options:\n      selectAndMove: Pilih dan Gerakkan\n      draw: Gambar\n      connectAndCut: Hubungkan dan Potong\n  doubleClickMiddleMouseButton:\n    title: Klik Ganda Tombol Tengah Mouse\n    description: |\n      Operasi yang dijalankan saat tombol gulir ditekan cepat dua kali. Default adalah mengatur ulang tampilan.\n      Menonaktifkan opsi ini dapat mencegah pemicu tidak sengaja.\n    options:\n      adjustCamera: Sesuaikan Tampilan\n      none: Tidak Ada Operasi\n  textNodeContentLineBreak:\n    title: Skema Pemisah Baris Node Teks\n    options:\n      enter: Enter\n      ctrlEnter: ctrl + Enter\n      altEnter: alt + Enter\n      shiftEnter: shift + Enter\n    description: |\n      Perhatikan agar tidak sama dengan tombol keluar mode edit node teks, ini akan menyebabkan konflik\n      sehingga tidak dapat memisah baris\n  textNodeStartEditMode:\n    title: Masuk Mode Edit Node Teks\n    options:\n      enter: Enter\n      ctrlEnter: ctrl + Enter\n      altEnter: alt + Enter\n      shiftEnter: shift + Enter\n      space: Tombol Spasi\n    description: |\n      Sebenarnya menekan tombol F2 juga dapat masuk mode edit, di sini dapat menambah satu lagi\n  textNodeExitEditMode:\n    title: Keluar Mode Edit Node Teks\n    options:\n      enter: Enter\n      ctrlEnter: ctrl + Enter\n      altEnter: alt + Enter\n      shiftEnter: shift + Enter\n    description: |\n      Sebenarnya menekan tombol Esc juga dapat keluar, di sini dapat menambah satu lagi\n  textNodeSelectAllWhenStartEditByMouseClick:\n    title: Pilih Semua Otomatis Saat Mulai Edit dengan Klik Ganda\n    description: |\n      Saat diaktifkan, saat mulai edit node teks, akan memilih semua konten teks\n      Jika Anda biasanya mengedit konten untuk langsung mengubah seluruh konten, disarankan mengaktifkan ini\n      Jika lebih mungkin untuk menambah konten, disarankan menonaktifkan ini\n  textNodeSelectAllWhenStartEditByKeyboard:\n    title: Pilih Semua Otomatis Saat Mulai Edit dengan Keyboard\n    description: |\n      Saat diaktifkan, saat Anda menekan tombol mode edit node teks, akan memilih semua konten teks\n  textNodeBackspaceDeleteWhenEmpty:\n    title: Hapus Seluruh Node dengan Backspace Saat Kosong dalam Mode Edit\n    description: |\n      Saat diaktifkan, saat mengedit node teks dan konten kosong, menekan tombol Backspace akan menghapus seluruh node secara otomatis\n  textNodeBigContentThresholdWhenPaste:\n    title: Ambang Konten Besar Node Teks Saat Tempel\n    description: |\n      Saat menempel teks langsung di panggung, jika panjang teks melebihi nilai ini, akan menggunakan mode pemisah baris manual\n  textNodePasteSizeAdjustMode:\n    title: Mode Penyesuaian Ukuran Tempel Node Teks\n    description: |\n      Mengontrol cara penyesuaian ukuran saat menempel node teks\n    options:\n      auto: Selalu Sesuaikan Otomatis\n      manual: Selalu Sesuaikan Manual\n      autoByLength: Sesuaikan Otomatis Berdasarkan Panjang\n  textNodeAutoFormatTreeWhenExitEdit:\n    title: Format Struktur Pohon Otomatis Saat Keluar Mode Edit\n    description: |\n      Saat node teks keluar mode edit, otomatis melakukan layout format pada struktur pohon tempatnya berada\n  treeGenerateCameraBehavior:\n    title: Opsi Perilaku Kamera Setelah Node Pohon Tumbuh\n    description: |\n      Mengatur perilaku kamera setelah membuat node baru menggunakan fungsi pertumbuhan kedalaman pohon atau lebar pohon\n    options:\n      none: Kamera Tidak Bergerak\n      moveToNewNode: Kamera Bergerak ke Node Baru\n      resetToTree: Atur Ulang Tampilan, membuat tampilan mencakup persegi panjang bounding struktur pohon saat ini\n  enableDragAutoAlign:\n    title: Penjajaran Otomatis Saat Menyeret Node dengan Mouse\n    description: |\n      Saat diaktifkan, saat menyeret node dan melepaskannya akan menyejajarkan dengan node lain di sumbu x, sumbu y\n  reverseTreeMoveMode:\n    title: Balikkan Mode Gerakan Pohon\n    description: |\n      Saat diaktifkan, gerakan default adalah gerakan pohon (termasuk node penerus), tahan tombol Ctrl untuk gerakan objek tunggal. Saat mati sebaliknya.\n  enableDragAlignToGrid:\n    title: Tempel ke Grid Saat Menyeret Entitas\n    description: |\n      Disarankan mengaktifkan garis grid horizontal dan vertikal dalam tampilan, dan menonaktifkan penjajaran otomatis\n  enableWindowsTouchPad:\n    title: Izinkan Operasi Gerakan Dua Jari Touchpad\n    description: |\n      Dalam sistem Windows, gerakan dua jari atas bawah akan dikenali sebagai event roda.\n      Gerakan dua jari kiri kanan akan dikenali sebagai event gulir roda horizontal mouse.\n      Jika Anda menggunakan laptop dan mouse eksternal, disarankan menonaktifkan opsi ini.\n  macTrackpadAndMouseWheelDifference:\n    title: Logika Pembedaan Touchpad dan Roda Mouse MacBook\n    description:\n      Beberapa mouse roda MacBook adalah bilangan bulat, touchpad adalah desimal, beberapa sebaliknya\n      Anda perlu memilih logika pembedaan berdasarkan kondisi aktual\n      Cara pembedaan dapat masuk \"Antarmuka Tes\" dengan mengklik logo perangkat lunak di antarmuka tentang 7 kali, lalu menggulir roda dan touchpad untuk melihat umpan balik data\n    options:\n      trackpadIntAndWheelFloat: Touchpad bilangan bulat, gulir mouse desimal\n      tarckpadFloatAndWheelInt: Touchpad desimal, gulir mouse bilangan bulat\n  macTrackpadScaleSensitivity:\n    title: Sensitivitas Zoom Dua Jari Touchpad MacBook\n    description:\n      Semakin besar nilai, semakin cepat kecepatan zoom\n  macEnableControlToCut:\n    title: Aktifkan Potong dengan Tombol Control di Mac\n    description:\n      Tahan tombol control, gerakkan mouse di panggung, lepaskan tombol control, selesaikan satu kali potong\n  macMouseWheelIsSmoothed:\n    title: Apakah Roda Mouse MacBook Halus\n    description:\n      Beberapa roda mouse MacBook halus, beberapa bergulir satu kali memicu satu kali\n      Mungkin tergantung pada apakah Anda menginstal perangkat lunak modifikasi mouse seperti Mos\n  mouseSideWheelMode:\n    title: Mode Roda Samping Mouse\n    description: |\n      Roda samping adalah roda di ibu jari\n    options:\n      zoom: Zoom\n      move: Gerakkan Vertikal\n      moveX: Gerakkan Horizontal\n      none: Tidak Ada Operasi\n      cameraMoveToMouse: Gerakkan Tampilan ke Posisi Mouse\n      adjustWindowOpacity: Sesuaikan Opasitas Jendela\n      adjustPenStrokeWidth: Sesuaikan Ketebalan Kuas\n  mouseWheelMode:\n    title: Mode Roda Mouse\n    options:\n      zoom: Zoom\n      move: Gerakkan Vertikal\n      moveX: Gerakkan Horizontal\n      none: Tidak Ada Operasi\n  mouseWheelWithShiftMode:\n    title: Mode Roda Mouse dengan Shift\n    options:\n      zoom: Zoom\n      move: Gerakkan Vertikal\n      moveX: Gerakkan Horizontal\n      none: Tidak Ada Operasi\n  mouseWheelWithCtrlMode:\n    title: Mode Roda Mouse dengan Ctrl\n    description: |\n      Tips: Ctrl di sini adalah Control\n    options:\n      zoom: Zoom\n      move: Gerakkan Vertikal\n      moveX: Gerakkan Horizontal\n      none: Tidak Ada Operasi\n  mouseWheelWithAltMode:\n    title: Mode Roda Mouse dengan Alt\n    description: |\n      Fitur ini ditambahkan pada 10 April 2025\n      Saat ini ditemukan masih ada masalah: di sistem Windows perlu klik layar sekali lagi setelah menggulir roda untuk mengoperasikan panggung\n      Tips: Alt di sini adalah Option\n    options:\n      zoom: Zoom\n      move: Gerakkan Vertikal\n      moveX: Gerakkan Horizontal\n      none: Tidak Ada Operasi\n  rectangleSelectWhenLeft:\n    title: Strategi Seleksi Kotak ke Kiri\n    description: |\n      Pilih strategi seleksi kotak mouse ke kiri, termasuk seleksi tutup penuh dan seleksi tabrakan\n      Seleksi tutup penuh berarti kotak seleksi persegi panjang harus sepenuhnya menutupi persegi panjang bounding entitas\n      Seleksi tabrakan berarti kotak seleksi persegi panjang hanya perlu menyentuh sedikit persegi panjang bounding entitas untuk dapat dipilih\n    options:\n      intersect: Seleksi Tabrakan\n      contain: Seleksi Tutup Penuh\n  rectangleSelectWhenRight:\n    title: Strategi Seleksi Kotak ke Kanan\n    description: |\n      Pilih strategi seleksi kotak mouse ke kanan\n    options:\n      intersect: Seleksi Tabrakan\n      contain: Seleksi Tutup Penuh\n  # Suara\n  cuttingLineStartSoundFile:\n    title: File Suara Mulai Potong Garis\n    description: |\n      Path file suara yang diputar saat tombol kanan ditekan untuk memulai potong garis\n  connectLineStartSoundFile:\n    title: File Suara Mulai Hubungkan Garis\n    description: |\n      Path file suara yang diputar saat tombol kanan ditekan untuk memulai hubungkan garis\n  connectFindTargetSoundFile:\n    title: File Suara Garis Menempel ke Target\n    description: |\n      Path file suara yang diputar saat garis menempel ke target\n  cuttingLineReleaseSoundFile:\n    title: File Suara Lepas Potong Garis\n    description: |\n      Saat melepaskan adalah saat melihat efek pedang cahaya\n  alignAndAttachSoundFile:\n    title: File Suara Penjajaran\n    description: |\n      Path file suara yang diputar saat menyeret mouse, menyejajarkan node dan\n  uiButtonEnterSoundFile:\n    title: Suara Mouse Masuk Area Tombol\n    description: |\n      Suara saat mouse masuk area tombol\n  uiButtonClickSoundFile:\n    title: File Suara Klik Tombol\n    description: |\n      Path file suara yang diputar saat tombol diklik\n  uiSwitchButtonOnSoundFile:\n    title: Suara Buka Tombol Alihkan\n    description: |\n      Path file suara yang diputar saat tombol alihkan dibuka\n  uiSwitchButtonOffSoundFile:\n    title: Suara Tutup Tombol Alihkan\n    description: |\n      Path file suara yang diputar saat tombol alihkan ditutup\n  packEntityToSectionSoundFile:\n    title: Suara untuk Mengemas ke Section\n    description: |\n      Path file suara yang diputar saat mengemas entitas yang dipilih ke dalam kotak Section\n  treeGenerateDeepSoundFile:\n    title: Suara untuk Generasi Pohon Dalam\n    description: |\n      Path file suara yang diputar saat menggunakan tombol Tab untuk generasi pohon dalam\n  treeGenerateBroadSoundFile:\n    title: Suara untuk Generasi Pohon Lebar\n    description: |\n      Path file suara yang diputar saat menggunakan tombol Enter untuk generasi pohon lebar\n  treeAdjustSoundFile:\n    title: Suara untuk Penyesuaian Struktur Pohon\n    description: |\n      Path file suara yang diputar saat memformat struktur pohon\n  viewAdjustSoundFile:\n    title: Suara untuk Penyesuaian Tampilan\n    description: |\n      Path file suara yang diputar saat menyesuaikan tampilan\n  entityJumpSoundFile:\n    title: Suara untuk Lompatan Entitas\n    description: |\n      Path file suara yang diputar saat entitas melompat\n  associationAdjustSoundFile:\n    title: Suara untuk Penyesuaian Asosiasi\n    description: |\n      Path file suara yang diputar saat menyesuaikan koneksi, tepi tak terarah, dll.\n  agreeTerms:\n    title: Setujui Perjanjian Pengguna\n    description: |\n      Silakan baca dan setujui perjanjian pengguna dengan cermat\n  allowTelemetry:\n    title: Ikut Serta dalam Program Peningkatan Pengalaman Pengguna\n    description: |\n      Jika Anda mengaktifkan ini, kami akan mengumpulkan data penggunaan Anda untuk membantu kami meningkatkan perangkat lunak\n      Data yang dikirim hanya digunakan untuk statistik, tidak akan berisi informasi pribadi Anda\n      Data Anda akan disimpan di server cloud Hong Kong Tiongkok, tidak akan dikirim ke luar negeri\n  aiApiBaseUrl:\n    title: Alamat API AI\n    description: |\n      Saat ini hanya mendukung API format OpenAI\n  aiApiKey:\n    title: Kunci API AI\n    description: |\n      Kunci akan disimpan sebagai teks biasa secara lokal\n  aiModel:\n    title: Model AI\n  aiShowTokenCount:\n    title: Tampilkan Jumlah Token AI\n    description: |\n      Saat diaktifkan, tampilkan jumlah token untuk operasi AI\n  cacheTextAsBitmap:\n    title: Aktifkan Render Teks sebagai Bitmap\n    description: |\n      Saat diaktifkan, cache bitmap node teks akan dipertahankan untuk meningkatkan kecepatan render\n      tetapi mungkin menyebabkan kabur\n  textCacheSize:\n    title: Batas Cache Teks\n    description: |\n      Secara default, teks yang sering dirender akan di-cache untuk meningkatkan kecepatan render\n      Jika nilai terlalu besar dapat menyebabkan penggunaan memori tinggi dan lag atau crash\n      Nilai 0 berarti nonaktifkan cache, setiap render akan merender ulang teks\n      *Berlaku saat membuka ulang file\n  textScalingBehavior:\n    title: Cara Render Teks saat Zoom Kamera\n    options:\n      temp: Render ulang teks setiap frame, tidak menggunakan cache\n      nearestCache: Gunakan cache dengan ukuran teks terdekat, zoom lalu render\n      cacheEveryTick: Render ulang teks setiap frame, dan cache\n  textIntegerLocationAndSizeRender:\n    title: Render Posisi dan Ukuran Bilangan Bulat Teks\n    description: |\n      Saat diaktifkan, semua ukuran dan posisi teks adalah bilangan bulat untuk menghemat performa render.\n      tetapi akan muncul fenomena goyangan teks. Disarankan menggunakan bersama dengan pengaturan kecepatan zoom tampilan ke 1.\n      Jika pengalaman penggunaan komputer Anda sangat lag, terutama saat zoom dan gerakan, dapat mengaktifkan opsi ini.\n  antialiasing:\n    title: Anti-aliasing\n    description: |\n      *Berlaku saat membuka ulang file\n    options:\n      disabled: Mati\n      low: Rendah\n      medium: Sedang\n      high: Tinggi\n  isStealthModeEnabled:\n    title: Mode Siluman\n    description: Saat diaktifkan, mask muncul di tengah mouse (bentuk spesifik dapat diubah di pengaturan), dapat digunakan untuk latihan menghafal dan skenario tutupi lainnya.\n  stealthModeScopeRadius:\n    title: Radius Cakupan Mode Siluman\n    description: |\n      Radius teropong\n  stealthModeReverseMask:\n    title: Balikkan Mask\n    description: |\n      Saat diaktifkan, area tengah teropong akan di-mask, hanya menampilkan area sekitarnya\n  stealthModeMaskShape:\n    title: Bentuk Mask Mode Siluman\n    description: |\n      Pilih bentuk area tampilan dalam mode siluman\n    options:\n      circle: Lingkaran\n      square: Persegi\n      topLeft: Kuadran Kiri Atas\n      smartContext: Konteks Cerdas (Kotak atau Entitas)\n  soundPitchVariationRange:\n    title: Rentang Variasi Nada Suara\n    description: \"Mengontrol tingkat variasi nada suara saat diputar. Rentang: 0-1200 sen (1200 sen=1 oktaf, 100 sen=1 nada setengah). Semakin besar nilai, semakin jelas perubahan nada, semakin mirip permainan yang menyenangkan.\"\n  autoImportTxtFileWhenOpenPrg:\n    title: Impor File TXT dengan Nama Sama Otomatis Saat Buka File PRG\n    description: Saat diaktifkan, saat membuka file PRG akan mengimpor konten file TXT dengan nama sama di folder yang sama secara otomatis, dan menambahkannya ke sudut kiri bawah panggung sebagai node teks.\n\neffects:\n  CircleChangeRadiusEffect:\n    title: Efek Perubahan Radius Lingkaran\n    description: |\n      Riak pembesaran setelah titik massa dipilih\n  CircleFlameEffect:\n    title: Kilatan Gradien Radial Lingkaran\n    description: |\n      Ada dalam berbagai detail efek, seperti garis pra-potong lurus dengan persegi panjang entitas, kilatan tengah saat memotong garis, dll.\n  EntityAlignEffect:\n    title: Efek Penjajaran Entitas\n    description: |\n      Garis putus-putus sorot yang dihasilkan saat menyeret mouse untuk penjajaran menempel\n  EntityCreateDashEffect:\n    title: Efek Konsentrasi Debu Pembuatan Entitas\n    description: |\n      Saat entitas dibuat, debu muncul di sekitar entitas\n      Karena tidak cukup estetis, sudah ditinggalkan, tidak akan muncul\n  EntityCreateFlashEffect:\n    title: Efek Bercahaya Batas Entitas\n    description: |\n      Muncul dalam situasi seperti putar pohon entitas dengan ctrl+roda, zoom gambar, buat node, dll.\n      Jika performa render buruk, disarankan menonaktifkan ini\n  EntityCreateLineEffect:\n    title: Efek Radiasi Garis Sirkuit dari Entitas\n    description: |\n      Sudah ditinggalkan, tidak akan muncul\n  EntityDashTipEffect:\n    title: Goyangan Debu Petunjuk Entitas\n    description: |\n      Muncul saat entitas masuk atau keluar edit, goyangan debu di sekitar entitas\n  EntityJumpMoveEffect:\n    title: Efek Gerakan Lompat Entitas\n    description: |\n      Saat entitas bergerak lompat, bayangan busur lompatan simbolis muncul di entitas\n      Digunakan untuk menunjukkan gerakan melintasi level sumbu z palsu\n  EntityShakeEffect:\n    title: Efek Goyangan Entitas\n    description: |\n      Efek goyangan seperti Logo \"TickTock\", digunakan untuk peringatan muncul entitas\n  EntityShrinkEffect:\n    title: Efek Menciut Entitas\n    description: |\n      Saat menghapus entitas dengan tombol Delete, efek menciut menghilang muncul\n  ExplodeDashEffect:\n    title: Efek Ledakan Debu\n    description: |\n      Saat menghapus entitas dengan potong, ledakan debu muncul\n  LineCuttingEffect:\n    title: Cahaya Pedang Saat Potong\n    description: |\n      Saat memotong, cahaya pedang seperti Fruit Ninja muncul\n  LineEffect:\n    title: Efek Memudar Garis Lurus\n    description: |\n      Digunakan saat menyeret untuk memutar subpohon, bayangan sapuan garis\n  NodeMoveShadowEffect:\n    title: Efek Partikel Gesekan Tanah Saat Node Bergerak\n    description: |\n      Gerakan yang disebabkan oleh layout juga mungkin muncul partikel kilatan\n  PenStrokeDeletedEffect:\n    title: Efek Hilang Saat Coretan Dihapus\n    description: |\n      Saat coretan dihapus, efek menghilang muncul\n  PointDashEffect:\n    title: Efek Partikel Gaya Gravitasi yang Meletus di Titik\n    description: |\n      Karena gaya gravitasi mempengaruhi performa, efek ini sudah ditutup, tidak akan muncul\n  RectangleLittleNoteEffect:\n    title: Efek Kilatan Petunjuk Persegi Panjang\n    description: |\n      Saat node logika dijalankan, node logika akan berkilatan efek ini\n  RectangleNoteEffect:\n    title: Efek Petunjuk Keberadaan Persegi Panjang\n    description: |\n      Sorotan rentang persegi panjang, efek sorotan saat mencari node atau menentukan posisi\n      Jika ditutup tidak akan melihat efek sorotan persegi panjang\n  RectanglePushInEffect:\n    title: Efek Empat Titik Sudut Persegi Panjang ke Persegi Panjang Lain\n    description: |\n      Digunakan untuk menunjukkan gerakan melintasi level kotak entitas, beralih pilihan dengan tombol arah\n      Saat ini pengembang karena malas, efek ini merujuk empat efek garis potong.\n      Jika efek garis potong ditutup, maka efek ini tidak akan terlihat\n  RectangleRenderEffect:\n    title: Efek Petunjuk Posisi Persegi Panjang\n    description: |\n      Digunakan untuk menampilkan posisi target yang akan ditempel entitas saat menyeret penjajaran\n  RectangleSplitTwoPartEffect:\n    title: Efek Persegi Panjang Terpotong Dua\n    description: |\n      Hanya ada dalam efek potong (mungkin juga empat bagian)\n  TechLineEffect:\n    title: (Efek Dasar) Efek Garis Patah\n    description: |\n      Efek ini adalah komponen efek lain, jika ditutup efek lain mungkin terpengaruh\n  TextRaiseEffectLocated:\n    title: Efek Naik Melayang Node Teks Posisi Tetap\n    description: |\n      Efek melayang naik node teks, digunakan untuk menunjukkan informasi penting\n  ViewFlashEffect:\n    title: Efek Kilatan Tampilan\n    description: |\n      Efek kilat putih/hitam layar penuh\n      Penderita epilepsi fotosensitif harap tutup opsi ini\n  ViewOutlineFlashEffect:\n    title: Efek Kilatan Kontur Tampilan\n    description: |\n      Efek kilatan kontur tampilan\n  ZapLineEffect:\n    title: (Efek Dasar) Efek Garis Petir\n    description: |\n      Efek ini adalah komponen efek lain, jika ditutup efek lain mungkin terpengaruh\n  MouseTipFeedbackEffect:\n    title: Efek Umpan Balik Petunjuk Interaksi Mouse\n    description: |\n      Saat mouse melakukan operasi seperti zoom tampilan, petunjuk efek muncul di samping mouse, seperti lingkaran membesar atau mengecil\n  RectangleSlideEffect:\n    title: Efek Ekor Geser Persegi Panjang\n    description: |\n      Digunakan untuk menggerakkan entitas secara vertikal dengan keyboard\n\nkeyBindsGroup:\n  otherKeys:\n    title: Pintasan Tidak Terkategori\n    description: |\n      Pintasan yang tidak terkategori,\n      Jika menemukan item pintasan tidak valid tanpa terjemahan di sini, mungkin karena sisa yang belum dibersihkan saat upgrade versi\n      Dapat membersihkan item yang sesuai di file keybinds.json secara manual\n  basic:\n    title: Pintasan Dasar\n    description: |\n      Pintasan dasar, untuk fungsi yang sering digunakan\n  camera:\n    title: Kontrol Kamera\n    description: |\n      Untuk mengontrol gerakan kamera, zoom\n  app:\n    title: Kontrol Aplikasi\n    description: |\n      Untuk mengontrol beberapa fungsi aplikasi\n  ui:\n    title: Kontrol UI\n    description: |\n      Untuk mengontrol beberapa fungsi UI\n  draw:\n    title: Coretan\n    description: |\n      Fungsi terkait coretan\n  select:\n    title: Beralih Pilihan\n    description: |\n      Menggunakan keyboard untuk beralih entitas yang dipilih\n  moveEntity:\n    title: Gerakkan Entitas\n    description: |\n      Untuk beberapa fungsi menggerakkan entitas\n  generateTextNodeInTree:\n    title: Tumbuhkan Node\n    description: |\n      Tumbuhkan node via keyboard (kebiasaan pengguna Xmind)\n  generateTextNodeRoundedSelectedNode:\n    title: Hasilkan Node di Sekitar Node yang Dipilih\n    description: |\n      Setelah ditekan, hasilkan node di sekitar node yang dipilih\n  aboutTextNode:\n    title: Tentang Node Teks\n    description: |\n      Semua pintasan terkait node teks, pemisahan, penggabungan, pembuatan, dll.\n  section:\n    title: Kotak Section\n    description: |\n      Fungsi terkait kotak Section\n  leftMouseModeCheckout:\n    title: Alihkan Mode Klik Kiri\n    description: |\n      Tentang peralihan mode klik kiri\n  edge:\n    title: Terkait Garis\n    description: |\n      Beberapa fungsi tentang garis\n  expandSelect:\n    title: Perluas Pilihan\n    description: |\n      Pintasan terkait perluasan pilihan node\n  themes:\n    title: Alihkan Tema\n    description: |\n      Pintasan terkait peralihan tema\n  align:\n    title: Terkait Penjajaran\n    description: |\n      Beberapa fungsi tentang penjajaran entitas\n  image:\n    title: Terkait Gambar\n    description: |\n      Beberapa fungsi tentang gambar\n  node:\n    title: Terkait Node\n    description: |\n      Beberapa fungsi tentang node, seperti grafting, pencabutan, dll.\n\ncontrolSettingsGroup:\n  mouse:\n    title: Pengaturan Mouse\n  touchpad:\n    title: Pengaturan Touchpad\n  textNode:\n    title: Pengaturan Node Teks\n  gamepad:\n    title: Pengaturan Gamepad\n\nvisualSettingsGroup:\n  basic:\n    title: Pengaturan Dasar\n  background:\n    title: Pengaturan Latar Belakang\n\nkeyBinds:\n  title: Pengikatan Pintasan\n  none: Tidak Ada Pintasan\n  test:\n    title: Tes\n    description: |\n      Hanya untuk menguji fungsi pengikatan pintasan kustom\n  reload:\n    title: Muat Ulang Aplikasi\n    description: |\n      Muat ulang aplikasi, muat ulang file proyek saat ini\n      Sama dengan menyegarkan halaman web browser\n      Fungsi ini berbahaya! Akan menyebabkan kehilangan kemajuan yang belum disimpan!\n  saveFile:\n    title: Simpan File\n    description: |\n      Simpan file proyek saat ini, jika file saat ini adalah konsep maka simpan sebagai\n  newDraft:\n    title: Konsep Baru\n    description: |\n      Buat file konsep baru, dan beralih ke file tersebut\n      Jika file saat ini belum disimpan tidak dapat beralih\n  newFileAtCurrentProjectDir:\n    title: Buat File Baru di Direktori Proyek Saat Ini\n    description: |\n      Buat file proyek baru di direktori proyek saat ini, dan beralih ke file tersebut (untuk pembuatan file cepat)\n      Jika file saat ini adalah status konsep tidak dapat membuat\n  openFile:\n    title: Buka File\n    description: |\n      Pilih file json/prg yang pernah disimpan dan buka\n  undo:\n    title: Batalkan\n    description: Batalkan operasi sebelumnya\n  redo:\n    title: Ulangi Pembatalan\n    description: Ulangi operasi pembatalan sebelumnya\n  resetView:\n    title: Atur Ulang Tampilan\n    description: |\n      Jika tidak memilih apa pun, atur ulang tampilan berdasarkan semua konten;\n      Jika ada konten yang dipilih, atur ulang tampilan berdasarkan konten yang dipilih\n  restoreCameraState:\n    title: Pulihkan Status Tampilan\n    description: |\n      Setelah ditekan, pulihkan ke posisi kamera dan ukuran zoom saat menekan tombol F sebelumnya\n  resetCameraScale:\n    title: Atur Ulang Zoom\n    description: Atur ulang zoom tampilan ke ukuran standar\n  folderSection:\n    title: Lipat atau Buka Kotak Section\n    description: Setelah ditekan, kotak Section yang dipilih akan beralih status lipat atau buka\n  toggleSectionLock:\n    title: Kunci/Buka Kunci Kotak Section\n    description: Alihkan status kunci kotak section yang dipilih. Section terkunci mencegah pergerakan objek internal.\n  reverseEdges:\n    title: Balikkan Arah Garis\n    description: |\n      Setelah ditekan, arah garis yang dipilih akan menjadi arah berlawanan\n      Contoh, semula A -> B, setelah ditekan menjadi B -> A\n      Makna fungsi ini adalah membuat situasi satu node menunjuk ke banyak node dengan cepat\n      Karena garis saat ini hanya dapat melakukan banyak-ke-satu sekaligus.\n  reverseSelectedNodeEdge:\n    title: Balikkan Semua Garis Node yang Dipilih\n    description: |\n      Setelah ditekan, dalam semua node yang dipilih dengan seleksi kotak\n      Setiap garis setiap node akan dibalikkan arahnya\n      Sehingga mewujudkan satu-ke-banyak yang lebih cepat\n  createUndirectedEdgeFromEntities:\n    title: Buat Garis Tak Berarah antar Entitas yang Dipilih\n    description: |\n      Setelah ditekan, akan dibuat garis tak berarah antara dua atau lebih entitas yang dipilih\n  packEntityToSection:\n    title: Kemas Entitas yang Dipilih ke dalam Kotak Section\n    description: |\n      Setelah ditekan, entitas yang dipilih akan otomatis dibungkus ke kotak Section baru\n  unpackEntityFromSection:\n    title: Buka Kemas Kotak Section, Konversi ke Node Teks\n    description: |\n      Setelah ditekan, entitas dalam kotak Section yang dipilih akan dibuka kemas, dirinya sendiri dikonversi menjadi node teks\n      Entitas di dalamnya akan jatuh di luar\n  textNodeToSection:\n    title: Konversi Node Teks yang Dipilih ke Kotak Section\n    description: |\n      Setelah ditekan, node teks yang dipilih akan dikonversi ke kotak Section\n      Dapat digunakan untuk pembuatan cepat kotak section\n  deleteSelectedStageObjects:\n    title: Hapus Objek Panggung yang Dipilih\n    description: |\n      Setelah ditekan, objek panggung yang dipilih akan dihapus\n      Objek panggung termasuk entitas (node, Section, dll. yang ada secara independen) dan relasi (garis antar node)\n      Default adalah tombol delete, Anda dapat mengubahnya ke tombol backspace\n  editEntityDetails:\n    title: Edit Detail Entitas yang Dipilih\n    description: |\n      Setelah ditekan, detail entitas yang dipilih akan dibuka untuk diedit\n      Hanya berlaku saat jumlah objek yang dipilih adalah 1\n  openColorPanel:\n    title: Pintasan Buka Panel Warna\n    description: |\n      Setelah ditekan, buka panel warna, dapat digunakan untuk beralih warna node dengan cepat\n  switchDebugShow:\n    title: Alihkan Tampilan Informasi Debug\n    description: |\n      Setelah ditekan, alihkan tampilan informasi debug\n      Informasi debug ditampilkan di sudut kiri atas layar, biasanya digunakan oleh pengembang\n      Saat diaktifkan, informasi debug akan ditampilkan di sudut kiri atas layar.\n      Disarankan mengaktifkan ini saat melaporkan bug dengan screenshot.\n  generateNodeTreeWithDeepMode:\n    title: Tumbuhkan Node Anak\n    description: |\n      Setelah ditekan, tumbuhkan node secara instan dan letakkan di kanan node yang dipilih saat ini\n      Jika arah tumbuh diatur pada node, maka akan tumbuh ke arah yang ditentukan (jika tidak diatur default ke kanan)\n      Sambil otomatis melayout struktur seluruh pohon node, memastikannya adalah struktur pohon ke kanan\n      Sebelum menggunakan fungsi ini pastikan telah memilih satu node, dan struktur tempat node tersebut berada adalah struktur pohon\n  generateNodeTreeWithBroadMode:\n    title: Tumbuhkan Node Selevel\n    description: |\n      Setelah ditekan, tumbuhkan node selevel secara instan dan letakkan di bawah node yang dipilih saat ini\n      Sambil otomatis melayout struktur seluruh pohon node, memastikannya adalah struktur pohon ke bawah\n      Sebelum menggunakan fungsi ini pastikan telah memilih satu node, dan node tersebut memiliki node induk\n  generateNodeGraph:\n    title: Tumbuhkan Node Bebas\n    description: |\n      Setelah ditekan, muncul posisi tumbuh virtual\n      Saat ini, tekan tombol \"I J K L\" untuk menyesuaikan posisi tumbuh secara bebas\n      Tekan tombol ini lagi, keluar dari mode tumbuh bebas\n      Sebelum menggunakan fungsi ini pastikan telah memilih satu node\n  createConnectPointWhenDragConnecting:\n    title: Saat Menyeret Garis, Tekan Tombol Ini untuk Buat Titik Massa Transit\n    description: |\n      Saat menyeret garis, tekan tombol ini untuk buat titik massa transit\n  treeGraphAdjust:\n    title: Sesuaikan Layout Pohon Struktur Node Saat Ini\n    description: |\n      Terutama digunakan saat menutup penyesuaian layout pohon yang dipicu oleh pertumbuhan node keyboard\n      Dapat memicu penyesuaian layout secara manual melalui pintasan ini\n  treeGraphAdjustSelectedAsRoot:\n    title: Format Struktur Pohon dengan Node yang Dipilih sebagai Akar\n    description: |\n      Format struktur pohon dengan node yang dipilih saat ini sebagai node akar\n      Tidak akan mencari akar seluruh pohon, hanya memformat subpohon dengan node yang dipilih sebagai akar\n  dagGraphAdjust:\n    title: Sesuaikan Layout DAG Node yang Dipilih Saat Ini\n    description: |\n      Lakukan penyesuaian layout otomatis pada struktur graf asiklik berarah (DAG) yang dipilih\n      Hanya tersedia saat node yang dipilih membentuk struktur DAG\n  gravityLayout:\n    title: Tahan Layout Gaya Gravitasi\n    description: |\n      Setelah memilih sekelompok node yang terhubung garis, tahan tombol ini untuk melakukan layout gaya gravitasi.\n      Perhatian jika ingin menyesuaikan kustom, hanya dapat diatur ke tombol non-modifier tunggal, jika tidak mungkin ada bug yang tidak diketahui.\n  setNodeTreeDirectionLeft:\n    title: Atur Arah Tumbuh Pohon Node Saat Ini ke Kiri\n    description: |\n      Perlu memilih node lalu tekan pintasan ini\n      Setelah diatur, saat menekan Tab pada node ini, akan tumbuh ke kiri\n  setNodeTreeDirectionRight:\n    title: Atur Arah Tumbuh Pohon Node Saat Ini ke Kanan\n    description: |\n      Perlu memilih node lalu tekan pintasan ini\n      Setelah diatur, saat menekan Tab pada node ini, akan tumbuh ke kanan\n  setNodeTreeDirectionUp:\n    title: Atur Arah Tumbuh Pohon Node Saat Ini ke Atas\n    description: |\n      Perlu memilih node lalu tekan pintasan ini\n      Setelah diatur, saat menekan Tab pada node ini, akan tumbuh ke atas\n  setNodeTreeDirectionDown:\n    title: Atur Arah Tumbuh Pohon Node Saat Ini ke Bawah\n    description: |\n      Perlu memilih node lalu tekan pintasan ini\n      Setelah diatur, saat menekan Tab pada node ini, akan tumbuh ke bawah\n\n  masterBrakeCheckout:\n    title: \"Rem Tangan: Aktifkan/Nonaktifkan Gerakan Kamera via Tombol\"\n    description: |\n      Setelah ditekan akan beralih apakah mengizinkan tombol \"W S A D\" mengontrol gerakan kamera\n      Dapat digunakan untuk melarang gerakan kamera sementara, mencegah pergerakan tampilan saat memasukkan tombol rahasia atau pintasan yang mengandung WSAD\n  masterBrakeControl:\n    title: \"Rem Kaki: Hentikan Melayang Kamera\"\n    description: |\n      Setelah ditekan, hentikan melayang kamera, dan atur kecepatan ke 0\n  selectAll:\n    title: Pilih Semua\n    description: Setelah ditekan, semua node dan garis akan dipilih\n  createTextNodeFromCameraLocation:\n    title: Buat Node Teks di Posisi Pusat Tampilan\n    description: |\n      Setelah ditekan, buat node teks di posisi pusat tampilan saat ini\n      Sama dengan fungsi buat node dengan klik ganda mouse\n  createTextNodeFromMouseLocation:\n    title: Buat Node Teks di Posisi Mouse\n    description: |\n      Setelah ditekan, buat node teks di posisi mouse mengambang\n      Sama dengan fungsi buat node dengan klik mouse\n  createTextNodeFromSelectedTop:\n    title: Buat Node Teks Tepat di Atas Node yang Dipilih Saat Ini\n    description: |\n      Setelah ditekan, buat node teks tepat di atas node yang dipilih saat ini\n  createTextNodeFromSelectedDown:\n    title: Buat Node Teks Tepat di Bawah Node yang Dipilih Saat Ini\n    description: |\n      Setelah ditekan, buat node teks tepat di bawah node yang dipilih saat ini\n  createTextNodeFromSelectedLeft:\n    title: Buat Node Teks di Kiri Node yang Dipilih Saat Ini\n    description: |\n      Setelah ditekan, buat node teks di kiri node yang dipilih saat ini\n  createTextNodeFromSelectedRight:\n    title: Buat Node Teks di Kanan Node yang Dipilih Saat Ini\n    description: |\n      Setelah ditekan, buat node teks di kanan node yang dipilih saat ini\n  selectUp:\n    title: Pilih Node Atas\n    description: Setelah ditekan, pilih node atas\n  selectDown:\n    title: Pilih Node Bawah\n    description: Setelah ditekan, pilih node bawah\n  selectLeft:\n    title: Pilih Node Kiri\n    description: Setelah ditekan, pilih node kiri\n  selectRight:\n    title: Pilih Node Kanan\n    description: Setelah ditekan, pilih node kanan\n  selectAdditionalUp:\n    title: Tambah Pilih Node Atas\n    description: Setelah ditekan, tambah pilih node atas\n  selectAdditionalDown:\n    title: Tambah Pilih Node Bawah\n    description: Setelah ditekan, tambah pilih node bawah\n  selectAdditionalLeft:\n    title: Tambah Pilih Node Kiri\n    description: Setelah ditekan, tambah pilih node kiri\n  selectAdditionalRight:\n    title: Tambah Pilih Node Kanan\n    description: Setelah ditekan, tambah pilih node kanan\n  moveUpSelectedEntities:\n    title: Gerakkan Semua Entitas yang Dipilih ke Atas\n    description: |\n      Setelah ditekan, semua entitas yang dipilih akan bergerak ke atas satu jarak tetap\n  moveDownSelectedEntities:\n    title: Gerakkan Semua Entitas yang Dipilih ke Bawah\n    description: |\n      Setelah ditekan, semua entitas yang dipilih akan bergerak ke bawah satu jarak tetap\n  moveLeftSelectedEntities:\n    title: Gerakkan Semua Entitas yang Dipilih ke Kiri\n    description: |\n      Setelah ditekan, semua entitas yang dipilih akan bergerak ke kiri satu jarak tetap\n  moveRightSelectedEntities:\n    title: Gerakkan Semua Entitas yang Dipilih ke Kanan\n    description: |\n      Setelah ditekan, semua entitas yang Dipilih akan bergerak ke kanan satu jarak tetap\n  jumpMoveUpSelectedEntities:\n    title: Lompat Gerakkan Semua Entitas yang Dipilih ke Atas\n    description: |\n      Setelah ditekan, semua entitas yang dipilih akan melompat ke atas satu jarak tetap, dapat melompat masuk atau keluar kotak Section\n  jumpMoveDownSelectedEntities:\n    title: Lompat Gerakkan Semua Entitas yang Dipilih ke Bawah\n    description: |\n      Setelah ditekan, semua entitas yang dipilih akan melompat ke bawah satu jarak tetap, dapat melompat masuk atau keluar kotak Section\n  jumpMoveLeftSelectedEntities:\n    title: Lompat Gerakkan Semua Entitas yang Dipilih ke Kiri\n    description: |\n      Setelah ditekan, semua entitas yang dipilih akan melompat ke kiri satu jarak tetap, dapat melompat masuk atau keluar kotak Section\n  jumpMoveRightSelectedEntities:\n    title: Lompat Gerakkan Semua Entitas yang Dipilih ke Kanan\n    description: |\n      Setelah ditekan, semua entitas yang dipilih akan melompat ke kanan satu jarak tetap, dapat melompat masuk atau keluar kotak Section\n  CameraScaleZoomIn:\n    title: Perbesar Tampilan\n    description: Setelah ditekan, perbesar tampilan\n  CameraScaleZoomOut:\n    title: Perkecil Tampilan\n    description: Setelah ditekan, perkecil tampilan\n  CameraPageMoveUp:\n    title: Gerakkan Tampilan ke Atas seperti Halaman\n  CameraPageMoveDown:\n    title: Gerakkan Tampilan ke Bawah seperti Halaman\n  CameraPageMoveLeft:\n    title: Gerakkan Tampilan ke Kiri seperti Halaman\n  CameraPageMoveRight:\n    title: Gerakkan Tampilan ke Kanan seperti Halaman\n  exitSoftware:\n    title: Keluar Perangkat Lunak\n    description: Setelah ditekan, keluar perangkat lunak\n  checkoutProtectPrivacy:\n    title: Masuk atau Keluar Mode Perlindungan Privasi\n    description: |\n      Setelah ditekan, semua teks di panggung akan dienkripsi, tidak dapat dilihat orang lain\n      Setelah ditekan, semua teks di panggung akan didekripsi, orang lain dapat melihat\n      Dapat digunakan saat screenshot untuk melaporkan masalah, tiba-tiba ada orang melihat layar Anda dan konten Anda adalah masalah perasaan (?) saat digunakan\n  openTextNodeByContentExternal:\n    title: Buka Konten Node yang Dipilih dalam Browser Web atau File Lokal\n    description: |\n      Setelah ditekan, semua node teks yang dipilih di panggung akan dibuka dengan cara default atau browser.\n      Contoh konten node adalah \"D:/Desktop/a.txt\", setelah memilih node ini dan menekan pintasan, dapat membuka file ini dengan cara default sistem\n      Jika konten node adalah alamat web \"https://project-graph.top\", akan membuka konten web dengan browser default sistem\n  checkoutClassroomMode:\n    title: Masuk atau Keluar Mode Fokus\n    description: |\n      Setelah ditekan, masuk mode fokus, semua UI akan disembunyikan, tombol atas akan diolah transparan\n      Tekan lagi untuk pulih\n  checkoutWindowOpacityMode:\n    title: Alihkan Mode Transparansi Jendela\n    description: |\n      Setelah ditekan, jendela masuk mode transparan penuh, tekan lagi akan masuk mode tidak transparan penuh\n      Perhatian perlu mengatur dengan gaya warna panggung. Contoh: mode hitam teks putih, mode putih makalah teks hitam.\n      Jika konten di bawah jendela adalah latar belakang putih, disarankan beralih panggung ke mode putih makalah.\n  windowOpacityAlphaIncrease:\n    title: Tingkatkan Opasitas Jendela\n    description: |\n      Setelah ditekan, nilai opasitas jendela (alpha) bertambah 0,2, berubah ke arah tidak transparan, nilai maksimum 1\n      Ketika tidak dapat ditambah lagi, akan ada petunjuk kilatan tepi jendela\n  windowOpacityAlphaDecrease:\n    title: Kurangi Opasitas Jendela\n    description: |\n      Setelah ditekan, nilai opasitas jendela (alpha) berkurang 0,2, berubah ke arah transparan, nilai minimum 0\n      Jika keyboard Anda tidak memiliki tombol minus murni numpad, dapat diubah ke tombol minus dan garis bawah di kanan angka 0 baris horizontal\n  searchText:\n    title: Cari Teks\n    description: |\n      Setelah ditekan, buka kotak pencarian, dapat memasukkan konten pencarian\n      Kotak pencarian mendukung pencocokan parsial, contoh memasukkan \"a\" dapat mencari \"apple\" dll.\n  clickAppMenuSettingsButton:\n    title: Buka Halaman Pengaturan\n    description: |\n      Tekan tombol ini dapat menggantikan klik tombol antarmuka pengaturan di menu bilah dengan mouse\n  clickTagPanelButton:\n    title: Buka/Tutup Panel Tag\n    description: |\n      Tekan tombol ini dapat menggantikan klik tombol buka tutup panel tag di halaman dengan mouse\n  clickAppMenuRecentFileButton:\n    title: Buka Daftar File Terbaru\n    description: |\n      Tekan tombol ini dapat menggantikan klik tombol daftar file terbaru di menu bilah dengan mouse\n  clickStartFilePanelButton:\n    title: Buka/Tutup Daftar File Mulai\n    description: |\n      Tekan tombol ini dapat menggantikan klik tombol buka tutup daftar file mulai di menu bilah dengan mouse\n  copy:\n    title: Salin\n    description: Setelah ditekan, salin konten yang dipilih\n  paste:\n    title: Tempel\n    description: Setelah ditekan, tempel konten clipboard\n  pasteWithOriginLocation:\n    title: Tempel dengan Posisi Asli\n    description: Setelah ditekan, konten yang ditempel akan tumpang tindih dengan posisi asli\n  selectEntityByPenStroke:\n    title: Perluas Pilihan Coretan dan Entitas\n    description: |\n      Setelah memilih satu coretan atau entitas, tekan tombol ini, akan memperluas pilihan entitas di sekitar entitas tersebut\n      Jika yang dipilih saat ini adalah coretan, maka perluas pilihan entitas yang disentuh coretan\n      Jika yang dipilih saat ini adalah entitas, maka perluas pilihan semua coretan yang disentuh\n      Dapat memperluas secara bergantian berkali-kali dengan menekan berulang kali\n  expandSelectEntity:\n    title: Perluas Pilihan Node\n    description: |\n      Setelah ditekan, status pilihan entitas akan dipindahkan ke node anak\n  expandSelectEntityReversed:\n    title: Perluas Pilihan Node Terbalik\n    description: |\n      Setelah ditekan, status pilihan entitas akan dipindahkan ke node induk\n  expandSelectEntityKeepLastSelected:\n    title: Perluas Pilihan Node (Pertahankan Status Pilihan Node Saat Ini)\n    description: |\n      Setelah ditekan, status pilihan entitas akan dipindahkan ke node anak, sambil mempertahankan status pilihan node saat ini\n  expandSelectEntityReversedKeepLastSelected:\n    title: Perluas Pilihan Node Terbalik (Pertahankan Status Pilihan Node Saat Ini)\n    description: |\n      Setelah ditekan, status pilihan entitas akan dipindahkan ke node induk, sambil mempertahankan status pilihan node saat ini\n  checkoutLeftMouseToSelectAndMove:\n    title: Atur Klik Kiri ke Mode \"Pilih/Gerakkan\"\n    description: |\n      Yaitu klik kiri mouse beralih ke mode normal\n  checkoutLeftMouseToDrawing:\n    title: Atur Klik Kiri ke Mode \"Coretan\"\n    description: |\n      Yaitu klik kiri mouse beralih ke mode coretan, ada tombol yang sesuai di bilah alat\n  checkoutLeftMouseToConnectAndCutting:\n    title: Atur Klik Kiri ke Mode \"Hubungkan/Potong\"\n    description: |\n      Yaitu klik kiri mouse beralih ke mode hubungkan/potong, ada tombol yang sesuai di bilah alat\n  checkoutLeftMouseToConnectAndCuttingOnlyPressed:\n    title: Atur Klik Kiri ke Mode \"Hubungkan/Potong\" (Hanya Saat Ditekan)\n    description: |\n      Saat dilepaskan beralih kembali ke mode mouse default\n  penStrokeWidthIncrease:\n    title: Tebalkan Coretan\n    description: Setelah ditekan, coretan menjadi tebal\n  penStrokeWidthDecrease:\n    title: Tipiskan Coretan\n    description: Setelah ditekan, coretan menjadi tipis\n  screenFlashEffect:\n    title: Efek Kilatan Layar Hitam\n    description: Mirip dengan hello world di tombol rahasia, tes efek layar hitam muncul untuk membuktikan sistem tombol rahasia berjalan normal\n  alignNodesToInteger:\n    title: Jajarkan Posisi Koordinat Semua Node yang Dapat Dihubungkan ke Bilangan Bulat\n    description: Dapat mengurangi volume file json secara signifikan\n  toggleCheckmarkOnTextNodes:\n    title: Beri Tanda Centang ✅ pada Semua Node Teks yang Dipilih, dan Tandai Hijau\n    description: Hanya berlaku untuk node teks, tekan lagi setelah memilih dapat membatalkan tanda centang\n  toggleCheckErrorOnTextNodes:\n    title: Beri Tanda Silang ❌ pada Semua Node Teks yang Dipilih, dan Tandai Merah\n    description: Hanya berlaku untuk node teks, tekan lagi setelah memilih dapat membatalkan tanda silang\n  switchToDarkTheme:\n    title: Beralih ke Tema Gelap\n    description: Setelah beralih perlu menggesek pedang di panggung untuk efek\n  switchToLightTheme:\n    title: Beralih ke Tema Terang\n    description: Setelah beralih perlu menggesek pedang di panggung untuk efek\n  switchToParkTheme:\n    title: Beralih ke Tema Taman\n    description: Setelah beralih perlu menggesek pedang di panggung untuk efek\n  switchToMacaronTheme:\n    title: Beralih ke Tema Makaron\n    description: Setelah beralih perlu menggesek pedang di panggung untuk efek\n  switchToMorandiTheme:\n    title: Beralih ke Tema Morandi\n    description: Setelah beralih perlu menggesek pedang di panggung untuk efek\n  increasePenAlpha:\n    title: Tingkatkan Nilai Kanal Opasitas Kuas\n    description: \"\"\n  decreasePenAlpha:\n    title: Kurangi Nilai Kanal Opasitas Kuas\n    description: \"\"\n  alignTop:\n    title: Rata Atas\n    description: Panah atas numpad\n  alignBottom:\n    title: Rata Bawah\n    description: Panah bawah numpad\n  alignLeft:\n    title: Rata Kiri\n    description: Panah kiri numpad\n  alignRight:\n    title: Rata Kanan\n    description: Panah kanan numpad\n  alignHorizontalSpaceBetween:\n    title: Rata Spasi Sama Horizontal\n    description: Kiri kanan kiri kanan numpad, goyangkan sedikit untuk jarak sama\n  alignVerticalSpaceBetween:\n    title: Rata Spasi Sama Vertikal\n    description: Atas bawah atas bawah numpad, goyangkan sedikit untuk jarak sama\n  alignCenterHorizontal:\n    title: Rata Tengah Horizontal\n    description: \"Numpad: tengah dulu, kemudian kiri kanan\"\n  alignCenterVertical:\n    title: Rata Tengah Vertikal\n    description: \"Numpad: tengah dulu, kemudian atas bawah\"\n  alignLeftToRightNoSpace:\n    title: Tumpuk Rapat ke Kanan dalam Satu Baris\n    description: Numpad horizontal dari kiri ke kanan menyusun satu string\n  alignTopToBottomNoSpace:\n    title: Tumpuk Rapat ke Bawah dalam Satu Kolom\n    description: Numpad vertikal dari atas ke bawah menyusun satu string\n  layoutToSquare:\n    title: Susunan Persegi Longgar\n  layoutToTightSquare:\n    title: Tumpuk Rapat\n  layoutToTightSquareDeep:\n    title: Tumpuk Rapat Rekursif\n  adjustSelectedTextNodeWidthMin:\n    title: Seragamkan Lebar ke Nilai Minimum\n    description: Hanya berlaku untuk node teks, seragamkan lebar semua node yang dipilih ke nilai minimum\n  adjustSelectedTextNodeWidthMax:\n    title: Seragamkan Lebar ke Nilai Maksimum\n    description: Hanya berlaku untuk node teks, seragamkan lebar semua node yang dipilih ke nilai maksimum\n  adjustSelectedTextNodeWidthAverage:\n    title: Seragamkan Lebar ke Nilai Rata-rata\n    description: Hanya berlaku untuk node teks, seragamkan lebar semua node yang dipilih ke nilai rata-rata\n  connectAllSelectedEntities:\n    title: Hubungkan Semua Entitas yang Dipilih Secara Penuh\n    description: Untuk skenario pengajaran khusus atau pengajaran teori graf, \"--\" di awal menunjukkan terkait garis\n  connectLeftToRight:\n    title: Hubungkan Semua Entitas yang Dipilih Berdasarkan Posisi dari Kiri ke Kanan\n    description: \"\"\n  connectTopToBottom:\n    title: Hubungkan Semua Entitas yang Dipilih Berdasarkan Posisi dari Atas ke Bawah\n    description: \"\"\n  selectAllEdges:\n    title: Pilih Semua Garis\n    description: Hanya pilih semua garis dalam tampilan\n  colorSelectedRed:\n    title: Warnai Semua Objek yang Dipilih menjadi Merah Murni\n    description: \"Spesifiknya: (239, 68, 68), hanya untuk penandaan cepat\"\n  increaseBrightness:\n    title: Tingkatkan Kecerahan Warna Entitas yang Dipilih\n    description: Tidak dapat digunakan pada entitas yang tidak berwarna atau transparan, b adalah brightness, tombol titik juga adalah tombol >, dapat dilihat sebagai bergerak ke kanan, nilai bertambah\n  decreaseBrightness:\n    title: Kurangi Kecerahan Warna Entitas yang Dipilih\n    description: Tidak dapat digunakan pada entitas yang tidak berwarna atau transparan, b adalah brightness, tombol koma juga adalah tombol <, dapat dilihat sebagai bergerak ke kiri, nilai berkurang\n  gradientColor:\n    title: Gradasi Warna Entitas yang Dipilih\n    description: Rencana selanjutnya adalah mengubah cincin warna, saat ini belum sempurna\n  changeColorHueUp:\n    title: Tingkatkan Rona Warna Entitas yang Dipilih\n    description: Tidak dapat digunakan pada entitas yang tidak berwarna atau transparan\n  changeColorHueDown:\n    title: Kurangi Rona Warna Entitas yang Dipilih\n    description: Tidak dapat digunakan pada entitas yang tidak berwarna atau transparan\n  changeColorHueMajorUp:\n    title: Tingkatkan Rona Warna Entitas yang Dipilih Secara Signifikan\n    description: Tidak dapat digunakan pada entitas yang tidak berwarna atau transparan\n  changeColorHueMajorDown:\n    title: Kurangi Rona Warna Entitas yang Dipilih Secara Signifikan\n    description: Tidak dapat digunakan pada entitas yang tidak berwarna atau transparan\n  graftNodeToTree:\n    title: Grafting Node ke Pohon\n    description: |\n      Graft node yang dipilih ke garis yang ditabrak, mempertahankan arah garis asli\n  removeNodeFromTree:\n    title: Cabut Node dari Pohon\n    description: |\n      Cabut node yang dipilih dari pohon, dan sambungkan kembali node sebelum dan sesudahnya\n\n  toggleTextNodeSizeMode:\n    title: Alihkan Mode Penyesuaian Ukuran Node Teks yang Dipilih\n    description: \"Hanya berlaku untuk node teks, mode auto: input teks tidak dapat memisah baris otomatis, mode manual: lebar adalah lebar kotak, lebihan lebar memisah baris otomatis\"\n  decreaseFontSize:\n    title: Kurangi Ukuran Font Node Teks yang Dipilih\n    description: \"Hanya berlaku untuk node teks, tekan Ctrl+- untuk mengurangi ukuran font node teks yang dipilih\"\n  increaseFontSize:\n    title: Tingkatkan Ukuran Font Node Teks yang Dipilih\n    description: Hanya berlaku untuk node teks, tekan Ctrl+= untuk meningkatkan ukuran font node teks yang dipilih\n  splitTextNodes:\n    title: Pecah Node Teks yang Dipilih menjadi Potongan Kecil\n    description: Hanya berlaku untuk node teks, berdasarkan tanda baca, spasi, pemisah baris, dll. untuk memecah menjadi potongan kecil\n  mergeTextNodes:\n    title: Gabungkan Beberapa Node Teks yang Dipilih menjadi Satu Node Teks, warna juga akan mengambil rata-rata\n    description: Hanya berlaku untuk node teks, urutan sesuai susunan atas ke bawah, posisi node mengacu pada koordinat titik sudut kiri atas persegi panjang node\n  swapTextAndDetails:\n    title: Tukar Ringkas dan Detail\n    description: Tukar informasi detail dan konten aktual semua node teks yang dipilih, tekan e 5 kali berturut-turut, terutama digunakan untuk konten teks yang ditempel langsung ingin ditulis ke informasi detail\n  reverseImageColors:\n    title: Balikkan Warna Gambar\n    description: Balikkan warna gambar yang dipilih (ubah latar belakang putih menjadi hitam, atau sebaliknya)\n  treeReverseY:\n    title: Balikkan Struktur Pohon Secara Vertikal\n    description: Pilih node akar struktur pohon, balikkan secara vertikal\n  treeReverseX:\n    title: Balikkan Struktur Pohon Secara Horizontal\n    description: Pilih node akar struktur pohon, balikkan secara horizontal\n  textNodeTreeToSection:\n    title: Konversi Pohon Node Teks ke Struktur Bersarang Kotak\n    description: Konversi struktur pohon node teks yang dipilih ke struktur bersarang kotak\n\n  switchActiveProject:\n    title: Alihkan Proyek Saat Ini\n    description: Setelah ditekan, beralih ke proyek berikutnya\n  switchActiveProjectReversed:\n    title: Alihkan Proyek Saat Ini (Urutan Terbalik)\n    description: Setelah ditekan, beralih ke proyek sebelumnya\n  closeCurrentProjectTab:\n    title: Tutup Tab Proyek Saat Ini\n    description: Tutup tab proyek yang sedang aktif. Jika ada perubahan yang belum disimpan, Anda akan diminta untuk menyimpan. Dinonaktifkan secara default; dapat diaktifkan di pengaturan.\n  closeAllSubWindows:\n    title: Tutup Semua Jendela Anak\n    description: Tutup semua jendela anak yang terbuka saat ini (seperti pengaturan, AI, panel warna, dll.), dan kembalikan fokus ke kanvas utama.\n  toggleFullscreen:\n    title: Alihkan Layar Penuh\n    description: Beralih antara mode layar penuh dan mode jendela aplikasi.\n  setWindowToMiniSize:\n    title: Atur Jendela ke Ukuran Mini\n    description: Atur ukuran jendela ke lebar dan tinggi jendela mini yang dikonfigurasi dalam pengaturan.\n\nsounds:\n  soundEnabled: Sakelar Suara"
  },
  {
    "path": "app/src/locales/zh_CN.yml",
    "content": "welcome:\n  slogan: 基于图论的思维框架图绘制软件\n  slogans:\n    - 基于图论的思维框架图绘制软件\n    - 在无限大的平面上发挥你的设计\n    - 让思维在节点与连线间自由流动\n    - 用图论思想构建你的知识网络\n    - 从混沌到秩序，从节点到体系\n    - 可视化思维，拓扑化管理\n    - 无限画布，无限可能\n    - 连接点滴想法，绘制宏观蓝图\n    - 不只是思维导图，更是思维框架\n    - 图论驱动的视觉思考工具\n  newDraft: 新建草稿\n  openFile: 打开文件\n  openRecentFiles: 打开最近\n  newUserGuide: 功能说明书\n  settings: 设置\n  about: 关于\n  website: 官网\n  title: Project Graph\n  language: 语言\n  next: 下一步\n  github: GitHub\n  bilibili: Bilibili\n  qq: QQ群\n  subtitle: 基于图论的无限画布思维导图软件\n\nglobalMenu:\n  file:\n    title: 文件\n    new: 新建临时草稿\n    open: 打开\n    recentFiles: 最近打开的文件\n    clear: 清空\n    save: 保存\n    saveAs: 另存为\n    import: 导入\n    importFromFolder: 根据文件夹生成框框嵌套图\n    importTreeFromFolder: 根据文件夹生成树状图\n    generateKeyboardLayout: 根据当前快捷键配置生成键盘布局图\n    export: 导出\n    exportAsSVG: 导出为 SVG\n    exportAll: 导出全部内容\n    plainTextType:\n      exportAllNodeGraph: 导出 全部的 网状关系\n      exportSelectedNodeGraph: 导出 选中的 网状关系\n      exportSelectedNodeTree: 导出 选中的 树状关系（纯文本缩进）\n      exportSelectedNodeTreeMarkdown: 导出 选中的 树状关系（Markdown格式）\n      exportSelectedNodeGraphMermaid: 根据 选中的 嵌套网状关系（Mermaid格式）\n    exportSelected: 导出选中内容\n    plainText: 纯文本\n    exportSuccess: 导出成功\n    attachments: 附件管理器\n    tags: 标签管理器\n  view:\n    title: 视野\n    resetViewAll: 根据全部内容重置视野\n    resetViewSelected: 根据选中内容重置视野\n    resetViewScale: 重置视野缩放到标准大小\n    moveViewToOrigin: 移动视野到坐标轴原点\n  actions:\n    title: 操作\n    search: 搜索\n    refresh: 刷新\n    undo: 撤销\n    redo: 重做\n    releaseKeys: 释放按键\n    confirmClearStage: 确认清空舞台？\n    irreversible: 此操作无法撤销！\n    clearStage: 清空舞台\n    cancel: 取消\n    confirm: 确定\n    generating: 生成中\n    success: 成功\n    failed: 失败\n    generate:\n      generatedIn: 生成耗时\n      title: 生成\n      generateNodeTreeByText: 根据纯文本生成树状结构\n      generateNodeTreeByTextDescription: 请输入树状结构文本，每行代表一个节点，缩进表示层级关系\n      generateNodeTreeByTextPlaceholder: 输入树状结构文本...\n      generateNodeTreeByMarkdown: 根据Markdown文本生成树状结构\n      generateNodeTreeByMarkdownDescription: 请输入markdown格式的字符串，要有不同层级的标题\n      generateNodeTreeByMarkdownPlaceholder: 输入markdown格式文本...\n      indention: 缩进字符数\n      generateNodeGraphByText: 根据纯文本生成网状结构\n      generateNodeGraphByTextDescription: 请输入网状结构文本，每行代表一个关系，每一行的格式为 `XXX --> XXX`\n      generateNodeGraphByTextPlaceholder: |\n        张三 -喜欢-> 李四\n        李四 -讨厌-> 王五\n        王五 -欣赏-> 张三\n        A --> B\n        B --> C\n        C --> D\n      generateNodeMermaidByText: 根据mermaid文本生成框嵌套网状结构\n      generateNodeMermaidByTextDescription: 支持graph TD格式的mermaid文本，可自动识别Section并创建嵌套结构\n      generateNodeMermaidByTextPlaceholder: |\n        graph TD;\n          A[Section A] --> B[Section B];\n          A --> C[普通节点];\n          B --> D[另一个节点];\n        ;\n  settings:\n    title: 设置\n    appearance: 个性化\n  ai:\n    title: AI\n    openAIPanel: 打开 AI 面板\n  window:\n    title: 视图\n    fullscreen: 全屏\n    classroomMode: 专注模式\n    classroomModeHint: 左上角菜单按钮仅仅是透明了，并没有消失\n  about:\n    title: 关于\n    guide: 功能说明书\n  unstable:\n    title: 测试版\n    notRelease: 此版本并非正式版\n    mayHaveBugs: 可能包含 Bug 和未完善的功能\n    reportBug: \"报告 Bug: 在 Issue #487 中评论\"\n    test: 测试功能\n\ncontextMenu:\n  createTextNode: 创建文本节点\n  createConnectPoint: 创建质点\n  packToSection: 打包为框\n  createMTUEdgeLine: 创建无向边\n  createMTUEdgeConvex: 创建凸包\n  convertToSection: 转换为框\n  toggleSectionCollapse: 切换折叠状态\n  changeColor: 更改颜色\n  resetColor: 重置\n  switchMTUEdgeArrow: 切换箭头形态\n  mtuEdgeArrowOuter: 箭头外向\n  mtuEdgeArrowInner: 箭头内向\n  mtuEdgeArrowNone: 关闭箭头显示\n  switchMTUEdgeRenderType: 切换渲染形态\n  convertToDirectedEdge: 转换为有向边\n\nsettings:\n  title: 设置\n  categories:\n    ai:\n      title: AI\n      api: API\n    automation:\n      title: 自动化\n      autoNamer: 自动命名\n      autoSave: 自动保存\n      autoBackup: 自动备份\n      autoImport: 自动导入\n    control:\n      title: 控制\n      mouse: 鼠标\n      touchpad: 触摸板\n      cameraMove: 视野移动\n      cameraZoom: 视野缩放\n      objectSelect: 物体选择\n      textNode: 文本节点\n      section: 框\n      edge: 连线\n      generateNode: 通过键盘生长节点\n      gamepad: 游戏手柄\n    visual:\n      title: 视觉\n      basic: 基础\n      background: 背景\n      node: 节点样式\n      edge: 连线样式\n      section: “框”的样式\n      entityDetails: 实体详情\n      debug: 调试\n      miniWindow: 迷你窗口\n      experimental: 实验性功能\n    performance:\n      title: 性能\n      memory: 内存\n      cpu: CPU\n      render: 渲染\n      experimental: 开发中的功能\n  language:\n    title: 语言\n    options:\n      en: English\n      zh_CN: 简体中文\n      zh_TW: 繁體中文\n      zh_TWC: 接地气繁体中文\n      id: 印度尼西亚语\n  themeMode:\n    title: 主题模式\n    options:\n      light: 白天模式\n      dark: 黑夜模式\n  lightTheme:\n    title: 白天主题\n  darkTheme:\n    title: 黑夜主题\n  showTipsOnUI:\n    title: 在 UI 中显示提示信息\n    description: |\n      开启后，屏幕上会有一行提示文本。\n      如果您已经熟悉了软件，建议关闭此项以减少屏幕占用\n      更多更详细的提示还是建议看菜单栏中的“功能说明书”或官网文档。\n  isClassroomMode:\n    title: 专注模式\n    description: |\n      用于教学、培训等场景。\n      开启后窗口顶部按钮会透明，鼠标悬浮上去会恢复，可以修改进入退出专注模式的快捷键\n  showQuickSettingsToolbar:\n    title: 显示快捷设置栏\n    description: |\n      控制是否在界面右侧显示快捷操作栏（快捷设置栏）。\n      快捷设置栏可以让您快速切换常用设置项的开关状态。\n  autoAdjustLineEndpointsByMouseTrack:\n    title: 根据鼠标拖动轨迹自动调整生成连线的端点位置\n    description: |\n      开启后，在拖拽连线时会根据鼠标移动轨迹自动调整连线端点在实体上的位置\n      关闭后，连线端点将始终位于实体中心\n  enableRightClickConnect:\n    title: 启用右键点击式连线功能\n    description: |\n      开启后，选中实体并右键点击其他实体时会自动创建连线，且右键菜单仅在空白处显示\n      关闭后，可以在实体上右键直接打开菜单，不会自动创建连线\n  lineStyle:\n    title: 连线样式\n    options:\n      straight: 直线\n      bezier: 贝塞尔曲线\n      vertical: 垂直折线\n  isRenderCenterPointer:\n    title: 显示中心十字准星\n    description: |\n      开启后，屏幕中心中心会显示一个十字准星，用于用于指示快捷键创建节点的位置\n  showGrid:\n    title: 显示网格\n  showBackgroundHorizontalLines:\n    title: 显示水平背景线\n    description: |\n      水平线和垂直线可以同时打开，实现网格效果\n  showBackgroundVerticalLines:\n    title: 显示垂直背景线\n  showBackgroundDots:\n    title: 显示背景点\n    description: |\n      这些背景点是水平线和垂直线的交点，实现洞洞板的效果\n  showBackgroundCartesian:\n    title: 显示背景直角坐标系\n    description: |\n      开启后，将会显示x轴、y轴和刻度数字\n      可以用于观测一些节点的绝对坐标位置\n      也能很直观的知道当前的视野缩放倍数\n  windowBackgroundAlpha:\n    title: 窗口背景透明度\n    description: |\n      *从1改到小于1的值需要重新打开文件才能生效\n  windowBackgroundOpacityAfterOpenClickThrough:\n    title: 开启点击穿透后的窗口背景透明度\n    description: |\n      设置在开启点击穿透功能后窗口背景的透明度\n  windowBackgroundOpacityAfterCloseClickThrough:\n    title: 关闭点击穿透后的窗口背景透明度\n    description: |\n      设置在关闭点击穿透功能后窗口背景的透明度\n  showDebug:\n    title: 显示调试信息\n    description: |\n      通常为开发者使用\n      开启后，屏幕左上角将会显示调试信息。\n      若您遇到bug截图反馈时，建议开启此选项。\n  enableTagTextNodesBigDisplay:\n    title: 标签文本节点巨大化显示\n    description: |\n      开启后，标签文本节点的显示在摄像机缩小到广袤的全局视野时，\n      标签会巨大化显示，以便更容易辨识整个文件的布局分布\n  showTextNodeBorder:\n    title: 显示文本节点边框\n    description: |\n      控制是否显示文本节点的边框\n  showTreeDirectionHint:\n    title: 显示树形生长方向提示\n    description: |\n      选中文本节点时，在节点四周显示 tab/W W/S S/A A/D D 等键盘树形生长方向提示。\n      关闭后不再渲染这些提示文字。\n  sectionBitTitleRenderType:\n    title: 框的缩略大标题渲染类型\n    options:\n      none: 不渲染（节省性能）\n      top: 顶部小字\n      cover: 半透明覆盖框体（最佳效果）\n  sectionBigTitleThresholdRatio:\n    title: 框的缩略大标题显示阈值\n    description: |\n      当框的最长边小于视野范围最长边的此比例时，显示缩略大标题\n  sectionBigTitleCameraScaleThreshold:\n    title: 框的缩略大标题相机缩放阈值\n    description: |\n      当摄像机缩放比例大于此阈值时，不显示缩略大标题\n      摄像机缩放比例需要打开调试信息才能显示\n  sectionBigTitleOpacity:\n    title: 框的缩略大标题透明度\n    description: |\n      控制半透明覆盖大标题的透明度，取值范围0-1\n  sectionBackgroundFillMode:\n    title: 框的背景颜色填充方式\n    description: |\n      控制section框的背景颜色填充方式\n      完整填充：填充整个框的背景（默认方式，有透明度化和遮罩顺序判断）\n      仅标题条：只填充顶部标题那一小条的部分\n    options:\n      full: 完整填充\n      titleOnly: 仅标题条\n  alwaysShowDetails:\n    title: 始终显示节点详细信息\n    description: |\n      开启后，无需鼠标移动到节点上时，才显示节点的详细信息。\n  nodeDetailsPanel:\n    title: 节点详细信息面板\n    options:\n      small: 小型面板\n      vditor: vditor markdown编辑器\n  useNativeTitleBar:\n    title: 使用原生标题栏（需要重启应用）\n    description: |\n      开启后，窗口顶部将会出现原生的标题栏，而不是模拟的标题栏。\n  protectingPrivacy:\n    title: 隐私保护\n    description: |\n      用于反馈问题截图时，开启此项之后将根据所选模式替换文字，以保护隐私。\n      仅作显示层面的替换，不会影响真实数据\n      反馈完毕后可再关闭，复原\n  protectingPrivacyMode:\n    title: 隐私保护模式\n    description: |\n      选择隐私保护时的文字替换方式\n    options:\n      secretWord: 统一替换（汉字→㊙，字母→a/A，数字→6）\n      caesar: 凯撒移位（所有字符往后移动一位）\n  entityDetailsFontSize:\n    title: 实体详细信息字体大小\n    description: |\n      设置舞台上渲染的实体详细信息的文字大小，单位为像素\n  entityDetailsLinesLimit:\n    title: 实体详细信息行数限制\n    description: |\n      限制舞台上渲染的实体详细信息的最大行数，超过限制的部分将被省略\n  entityDetailsWidthLimit:\n    title: 实体详细信息宽度限制\n    description: |\n      限制舞台上渲染的实体详细信息的最大宽度（单位为px像素，可参考背景网格坐标轴），超过限制的部分将被换行\n  windowCollapsingWidth:\n    title: 迷你窗口的宽度\n    description: |\n      点击切换至迷你窗口时，窗口的宽度，单位为像素\n  windowCollapsingHeight:\n    title: 迷你窗口的高度\n    description: |\n      点击切换至迷你窗口时，窗口的高度，单位为像素\n  limitCameraInCycleSpace:\n    title: 开启循环空间限制摄像机移动\n    description: |\n      开启后，摄像机只能在一个矩形区域内移动\n      可以防止摄像机移动到很远的地方迷路\n      该矩形区域会形成一个循环空间，类似于无边贪吃蛇游戏中的地图\n      走到最上面会回到最下面，走到最左边会回到最右边\n      注意：该功能还在实验阶段\n  cameraCycleSpaceSizeX:\n    title: 循环空间宽度\n    description: |\n      循环空间的宽度，单位为像素\n  cameraCycleSpaceSizeY:\n    title: 循环空间高度\n    description: |\n      循环空间的高度，单位为像素\n  renderEffect:\n    title: 渲染特效\n    description: 是否渲染特效，如果卡顿可以关闭\n  compatibilityMode:\n    title: 兼容模式\n    description: |\n      开启后，软件会使用另一种渲染方式\n  historySize:\n    title: 历史记录大小\n    description: |\n      这个数值决定了您最多ctrl+z撤销的次数\n      如果您的电脑内存非常少，可以适当调小这个值\n  compressPastedImages:\n    title: 是否压缩粘贴到舞台的图片\n    description: |\n      开启后，粘贴到舞台的图片会被压缩，以节省加载文件时的内存压力和磁盘压力\n  maxPastedImageSize:\n    title: 粘贴到舞台的图片的尺寸限制（像素）\n    description: |\n      长或宽超过此尺寸的图片，其长或宽的最大值将会被限制为此大小\n      同时保持长宽比不变，仅在开启“压缩粘贴到舞台的图片”时生效\n  isPauseRenderWhenManipulateOvertime:\n    title: 超过一定时间未操作舞台，暂停渲染\n    description: |\n      开启后，超过若干秒未做出舞台操作，舞台渲染会暂停，以节省CPU/GPU资源。\n  renderOverTimeWhenNoManipulateTime:\n    title: 超时停止渲染舞台的时间（秒）\n    description: |\n      超过一定时间未做出舞台操作，舞台渲染会停止，以节省CPU/GPU资源。\n      必须在上述“超时暂停渲染”选项开启后才会生效。\n  ignoreTextNodeTextRenderLessThanFontSize:\n    title: 当渲染字体大小小于一定值时，不渲染文本节点内的文字及其详细信息\n    description: |\n      开启后，当文本节点的渲染字体大小小于一定值时，(也就是观察宏观状态时)\n      不渲染文本节点内的文字及其详细信息，这样可以提高渲染性能，但会导致文本节点的文字内容无法显示\n  isEnableEntityCollision:\n    title: 实体碰撞检测\n    description: |\n      开启后，实体之间会进行碰撞挤压移动，可能会影响性能。\n      建议关闭此项，目前实体碰撞挤压还不完善，可能导致爆栈\n  isEnableSectionCollision:\n    title: 启用框碰撞\n    description: |\n      开启后，框与框之间会自动进行碰撞排斥（推开重叠的同级框），避免框重叠。\n  autoRefreshStageByMouseAction:\n    title: 鼠标操作时自动刷新舞台\n    description: |\n      开启后，鼠标操作(拖拽移动视野)会自动刷新舞台\n      防止出现打开某个文件后，图片未加载成功还需手动刷新的情况\n  autoNamerTemplate:\n    title: 创建节点时自动命名模板\n    description: |\n      输入`{{i}}` 代表节点名称会自动替换为编号，双击创建时可以自动累加数字。\n      例如`n{{i}}` 会自动替换为`n1`, `n2`, `n3`…\n      输入`{{date}}` 会自动替换为当前日期，双击创建时可以自动更新日期。autoNamerTemplate\n      输入`{{time}}` 会自动替换为当前时间，双击创建时可以自动更新时间。\n      可以组合使用，例如`{{i}}-{{date}}-{{time}}`\n  autoNamerSectionTemplate:\n    title: 创建框时自动命名模板\n    description: |\n      输入`{{i}}` 代表节点名称会自动替换为编号，双击创建时可以自动累加数字。\n      例如`n{{i}}` 会自动替换为`n1`, `n2`, `n3`…\n      输入`{{date}}` 会自动替换为当前日期，双击创建时可以自动更新日期。\n      输入`{{time}}` 会自动替换为当前时间，双击创建时可以自动更新时间。\n      可以组合使用，例如`{{i}}-{{date}}-{{time}}`\n  autoSaveWhenClose:\n    title: 点击窗口右上角关闭按钮时自动保存工程文件\n    description: |\n      关闭软件时，如果有未保存的工程文件，会弹出提示框询问是否保存。\n      开启此选项后，关闭软件时会自动保存工程文件。\n      所以，建议开启此选项。\n  autoSave:\n    title: 开启自动保存\n    description: |\n      自动保存当前文件\n      此功能目前仅对已有路径的文件有效，不对草稿文件生效！\n  autoSaveInterval:\n    title: 开启自动保存间隔（秒）\n    description: |\n      注意：目前计时时间仅在软件窗口激活时计时，软件最小化后不会计时。\n  clearHistoryWhenManualSave:\n    title: 使用快捷键手动保存时，自动清空历史记录\n    description: |\n      当使用Ctrl+S快捷键手动保存文件时，自动清空操作历史记录。\n      开启此选项可以减少内存占用并保持界面整洁。\n  historyManagerMode:\n    title: 历史记录管理器模式\n    description: |\n      选择历史记录的管理方式：\n      memoryEfficient - 内存高效模式，使用增量存储，省内存但可能在撤销/重做时稍慢\n      timeEfficient - 时间高效模式，使用完整快照存储，操作响应快但可能占用更多内存\n    options:\n      memoryEfficient: 内存高效模式\n      timeEfficient: 时间高效模式\n  autoBackup:\n    title: 开启自动备份\n    description: |\n      自动备份当前文件到备份文件夹\n      如果是草稿，则会存储在指定的路径\n  autoBackupInterval:\n    title: 自动备份间隔（秒）\n    description: |\n      自动备份过于频繁可能会产生大量的备份文件\n      进而占用磁盘空间\n  autoBackupLimitCount:\n    title: 自动备份最大数量\n    description: |\n      自动备份的最大数量，超过此数量将会删除旧的备份文件\n  autoBackupCustomPath:\n    title: 自定义自动备份路径\n    description: |\n      设置自动备份文件的保存路径，如果为空则使用默认路径\n  scaleExponent:\n    title: 视角缩放速度\n    description: |\n      《当前缩放倍数》会不断的以一定倍率无限逼近《目标缩放倍数》\n      当逼近的足够近时（小于0.0001），会自动停止缩放\n      值为1代表缩放会立刻完成，没有中间的过渡效果\n      值为0代表缩放永远都不会完成，可模拟锁死效果\n      注意：若您在缩放画面时感到卡顿，请调成1\n  cameraKeyboardScaleRate:\n    title: 视角缩放键盘速率\n    description: |\n      每次通过一次按键来缩放视野时，视野的缩放倍率\n      值为0.2代表每次放大会变为原来的1.2倍，缩小为原来的0.8倍\n      值为0代表禁止通过键盘缩放\n  scaleCameraByMouseLocation:\n    title: 视角缩放根据鼠标位置\n    description: |\n      开启后，缩放视角的中心点是鼠标的位置\n      关闭后，缩放视角的中心点是当前视野的中心\n  allowMoveCameraByWSAD:\n    title: 允许使用W S A D按键移动视角\n    description: |\n      开启后，可以使用W S A D按键来上下左右移动视角\n      关闭后，只能使用鼠标来移动视角，不会造成无限滚屏bug\n  allowGlobalHotKeys:\n    title: 允许使用全局热键\n    description: |\n      开启后，可以使用全局热键来触发一些操作\n  cameraFollowsSelectedNodeOnArrowKeys:\n    title: 通过方向键切换选中节点时，视野跟随移动\n    description: |\n      开启后，使用键盘移动节点选择框时，视野跟随移动\n  arrowKeySelectOnlyInViewport:\n    title: 方向键切换选择限制在视野内\n    description: |\n      开启后，使用方向键（上下左右）切换选择节点时，只会选择当前视野内可见的物体。\n      关闭后，可以选择到视野外的物体（相机会自动跟随）。\n  cameraKeyboardMoveReverse:\n    title: 视角移动键盘反向\n    description: |\n      开启后，W S A D按键的移动视角方向会相反\n      原本的移动逻辑是移动悬浮在画面上的摄像机，但如果看成是移动整个舞台，这样就反了\n      于是就有了这个选项\n  cameraKeyboardScaleReverse:\n    title: 视角缩放键盘反向\n    description: |\n      开启后，[=起飞（缩小），]=降落（放大）\n      关闭后，[=降落（放大），]=起飞（缩小）\n  cameraResetViewPaddingRate:\n    title: 根据选择节点重置视野时，边缘留白系数\n    description: |\n      框选一堆节点或一个节点，并按下快捷键或点击按钮来重置视野后\n      视野会调整大小和位置，确保所有选中内容出现在屏幕中央并完全涵盖\n      由于视野缩放大小原因，此时边缘可能会有留白\n      值为1 表示边缘完全不留白。（非常放大的观察）\n      值为2 表示留白内容恰好为自身内容的一倍\n  cameraResetMaxScale:\n    title: 摄像机重置视野后最大的缩放值\n    description: |\n      选中一个面积很小的节点时，摄像机不会完全覆盖这个节点的面积范围，否则太大了。\n      而是会放大到一个最大值，这个最大值可以通过此选项来调整\n      建议开启debug模式下观察 currentScale 来调整此值\n  allowAddCycleEdge:\n    title: 允许在节点之间添加自环\n    description: |\n      开启后，节点之间可以添加自环，即节点与自身相连，用于状态机绘制\n      默认关闭，因为不常用，容易误触发\n  enableDragEdgeRotateStructure:\n    title: 允许拖拽连线旋转结构\n    description: |\n      开启后，可以通过拖拽选中的连线来旋转节点结构\n      这允许您轻松调整相连节点的方向\n  enableCtrlWheelRotateStructure:\n    title: 允许Ctrl+鼠标滚轮旋转结构\n    description: |\n      开启后，可以按住Ctrl键（Mac系统为Command键）并滚动鼠标滚轮来旋转节点结构\n      这允许您精确调整相连节点的方向\n  autoLayoutWhenTreeGenerate:\n    title: 生长节点时自动更新布局\n    description: |\n      开启后，生长节点时自动更新布局\n      此处的生长节点指tab和\\键生长节点\n  enableBackslashGenerateNodeInInput:\n    title: 在输入状态下也能通过反斜杠创建同级节点\n    description: |\n      开启后，在文本节点编辑状态下，按下反斜杠键（\\）也可以创建同级节点\n      关闭后，只有在非编辑状态下才能通过反斜杠键创建同级节点\n  moveAmplitude:\n    title: 视角移动加速度\n    description: |\n      此设置项用于 使用W S A D按键来上下左右移动视角时的情景\n      可将摄像机看成一个能朝四个方向喷气的 悬浮飞机\n      此加速度值代表着喷气的动力大小，需要结合下面的摩擦力设置来调整速度\n  moveFriction:\n    title: 视角移动摩擦力系数\n    description: |\n      此设置项用于 使用W S A D按键来上下左右移动视角时的情景\n      摩擦系数越大，滑动的距离越小，摩擦系数越小，滑动的距离越远\n      此值=0时代表 绝对光滑\n  gamepadDeadzone:\n    title: 游戏手柄死区\n    description: |\n      此设置项用于 游戏手柄控制视角时的情景\n      手柄的输入值在0-1之间，此值越小，手柄的输入越敏感\n      死区越大，手柄的输入越趋于0或1，不会产生太大的变化\n      死区越小，手柄的输入越趋于中间值，会产生较大的变化\n  mouseRightDragBackground:\n    title: 右键拖动背景的操作\n    options:\n      cut: 斩断并删除物体\n      moveCamera: 移动视野\n  enableSpaceKeyMouseLeftDrag:\n    title: 启用空格键+鼠标左键拖拽移动\n    description: 按下空格键并使用鼠标左键拖拽来移动视野\n  mouseLeftMode:\n    title: 左键模式切换\n    options:\n      selectAndMove: 选择并移动\n      draw: 画图\n      connectAndCut: 连线与劈砍\n  doubleClickMiddleMouseButton:\n    title: 双击中键鼠标\n    description: |\n      将滚轮键快速按下两次时执行的操作。默认是重置视野。\n      关闭此选项，可以防止误触发。\n    options:\n      adjustCamera: 调整视野\n      none: 无操作\n  textNodeContentLineBreak:\n    title: 文本节点换行方案\n    options:\n      enter: Enter\n      ctrlEnter: ctrl + Enter\n      altEnter: alt + Enter\n      shiftEnter: shift + Enter\n    description: |\n      注意不要和文本节点退出编辑模式的按键一样了，这样会导致冲突\n      进而导致无法换行\n  textNodeStartEditMode:\n    title: 文本节点进入编辑模式\n    options:\n      enter: Enter\n      ctrlEnter: ctrl + Enter\n      altEnter: alt + Enter\n      shiftEnter: shift + Enter\n      space: 空格键\n    description: |\n      实际上按F2键也可以进入编辑模式，这里还可以再加选一种\n  textNodeExitEditMode:\n    title: 文本节点退出编辑模式\n    options:\n      enter: Enter\n      ctrlEnter: ctrl + Enter\n      altEnter: alt + Enter\n      shiftEnter: shift + Enter\n    description: |\n      实际上按Esc键也可以退出，这里还可以再加选一种\n  textNodeSelectAllWhenStartEditByMouseClick:\n    title: 文本节点通过双击开始编辑时自动全选内容\n    description: |\n      开启后，在文本节点开始编辑时，会全选文本内容\n      如果您编辑内容通常是想为了直接更改全部内容，建议开启此选项\n      如果更可能是想为了追加内容，建议关闭此选项\n  textNodeSelectAllWhenStartEditByKeyboard:\n    title: 文本节点通过键盘开始编辑时自动全选内容\n    description: |\n      开启后，在您按下文本节点编辑模式的按键时，会全选文本内容\n  textNodeBackspaceDeleteWhenEmpty:\n    title: 当在编辑模式下文本节点无内容时按Backspace键自动删除整个节点\n    description: |\n      开启后，在编辑文本节点且内容为空时，按下Backspace键会自动删除整个节点\n  textNodeBigContentThresholdWhenPaste:\n    title: 粘贴时文本节点大内容阈值\n    description: |\n      当直接在舞台上粘贴文本时，如果文本长度超过此值，将使用手动换行模式\n  textNodePasteSizeAdjustMode:\n    title: 文本节点粘贴大小调整模式\n    description: |\n      控制粘贴文本节点时的大小调整方式\n    options:\n      auto: 总是自动调整\n      manual: 总是手动调整\n      autoByLength: 根据长度自动调整\n  textNodeAutoFormatTreeWhenExitEdit:\n    title: 退出编辑模式时自动格式化树形结构\n    description: |\n      当文本节点退出编辑模式时，自动对其所在的树形结构进行格式化布局\n  treeGenerateCameraBehavior:\n    title: 树形生长节点后的镜头行为选项\n    description: |\n      设置在使用树形深度生长或广度生长功能创建新节点后，镜头的行为方式\n    options:\n      none: 镜头不动\n      moveToNewNode: 镜头移动向新创建的节点\n      resetToTree: 重置视野，使视野覆盖当前树形结构的外接矩形\n  enableDragAutoAlign:\n    title: 鼠标拖动自动吸附对齐节点\n    description: |\n      开启后，拖动节点并松开时会与其他节点在x轴、y轴方向对齐\n  reverseTreeMoveMode:\n    title: 反转树形移动模式\n    description: |\n      开启后，默认移动为树形移动（连带后继节点），按住Ctrl键移动为单一物体移动。关闭时相反。\n  enableDragAlignToGrid:\n    title: 拖动实体时，吸附到网格\n    description: |\n      建议在显示中开启横向和纵向网格线，并关闭自动吸附对齐\n  enableWindowsTouchPad:\n    title: 允许触摸板双指移动操作\n    description: |\n      在windows系统中，双指上下移动会被识别为滚轮事件。\n      双指左右移动会被识别成鼠标横向滚轮的滚动事件。\n      如果您是笔记本操作并使用外部鼠标，建议关闭此选项。\n  macTrackpadAndMouseWheelDifference:\n    title: macbook 的触摸版与鼠标滚轮区分逻辑\n    description:\n      有的macbook鼠标滚轮是整数，触摸版是小数，有的则相反\n      您需要根据实际情况选择一下区分逻辑\n      区分方法可点击7次关于界面的软件logo进入“测试界面”后，滑动滚轮和触摸板查看数据反馈\n    options:\n      trackpadIntAndWheelFloat: 触摸版是整数，鼠标滚动是小数\n      tarckpadFloatAndWheelInt: 触摸版是小数，鼠标滚动是整数\n  macTrackpadScaleSensitivity:\n    title: macbook 的触摸板双指缩放灵敏度\n    description:\n      值越大，缩放的速度越快\n  macEnableControlToCut:\n    title: mac下是否启用 control键按下来开始刀斩\n    description:\n      按下control键，在舞台上移动鼠标，再松开control键，完成一次刀斩\n  macMouseWheelIsSmoothed:\n    title: macbook 的鼠标滚轮是否平滑\n    description:\n      有的macbook鼠标滚轮是平滑的，有的则是滚动一格触发一次\n      可能取决于您是否安装了Mos等鼠标修改软件\n  mouseSideWheelMode:\n    title: 鼠标侧边滚轮模式\n    description: |\n      侧边滚轮就是大拇指上的滚轮\n    options:\n      zoom: 缩放\n      move: 纵向移动\n      moveX: 横向移动\n      none: 无操作\n      cameraMoveToMouse: 将视野向鼠标位置移动\n      adjustWindowOpacity: 调整窗口透明度\n      adjustPenStrokeWidth: 调整画笔粗细\n  mouseWheelMode:\n    title: 鼠标滚轮模式\n    options:\n      zoom: 缩放\n      move: 纵向移动\n      moveX: 横向移动\n      none: 无操作\n  mouseWheelWithShiftMode:\n    title: 按住 Shift 时，鼠标滚轮模式\n    options:\n      zoom: 缩放\n      move: 纵向移动\n      moveX: 横向移动\n      none: 无操作\n  mouseWheelWithCtrlMode:\n    title: 按住 Ctrl 时，鼠标滚轮模式\n    description: |\n      提示：这里的 Ctrl 是 Control\n    options:\n      zoom: 缩放\n      move: 纵向移动\n      moveX: 横向移动\n      none: 无操作\n  mouseWheelWithAltMode:\n    title: 按住 Alt 时，鼠标滚轮模式\n    description: |\n      此功能于2025年4月10日新增\n      目前发现还存在问题：win系统下滑动滚轮后需要再点击一次屏幕才能操作舞台\n      提示：这里的 Alt 是 Option\n    options:\n      zoom: 缩放\n      move: 纵向移动\n      moveX: 横向移动\n      none: 无操作\n  rectangleSelectWhenLeft:\n    title: 向左框选的策略\n    description: |\n      选择鼠标向左框选的策略，包含完全覆盖框选和碰撞框选\n      完全覆盖框选是指矩形框选框必须完全覆盖实体的外接矩形\n      碰撞框选是指矩形框选框只要碰到一点点实体的外接矩形，就能够选中了\n    options:\n      intersect: 碰撞框选\n      contain: 完全覆盖框选\n  rectangleSelectWhenRight:\n    title: 向右框选的策略\n    description: |\n      选择鼠标向右框选的策略\n    options:\n      intersect: 碰撞框选\n      contain: 完全覆盖框选\n  # 声音\n  cuttingLineStartSoundFile:\n    title: 斩断线开始的声音文件\n    description: |\n      斩断线右键按下开始时播放的声音文件路径\n  connectLineStartSoundFile:\n    title: 连接线开始的声音文件\n    description: |\n      连接线右键按下开始时播放的声音文件路径\n  connectFindTargetSoundFile:\n    title: 连接线吸附到目标上的声音文件\n    description: |\n      连接线吸附到目标上时播放的声音文件路径\n  cuttingLineReleaseSoundFile:\n    title: 斩断线释放的声音文件\n    description: |\n      释放的时候就是看到刀光刃特效的时候\n  alignAndAttachSoundFile:\n    title: 对齐的声音文件\n    description: |\n      鼠标拖动时，对齐节点和时播放的声音文件路径\n  uiButtonEnterSoundFile:\n    title: 鼠标进入按钮区域的声音\n    description: |\n      鼠标进入按钮区域的声音\n  uiButtonClickSoundFile:\n    title: 按钮点击时的声音文件\n    description: |\n      按钮点击时播放的声音文件路径\n  uiSwitchButtonOnSoundFile:\n    title: 按钮点击开关按钮时打开的声音\n    description: |\n      按钮点击开关按钮时打开的声音文件路径\n  uiSwitchButtonOffSoundFile:\n    title: 按钮点击开关按钮时关闭的声音\n    description: |\n      按钮点击开关按钮时关闭的声音文件路径\n  packEntityToSectionSoundFile:\n    title: 打包为框的声音文件\n    description: |\n      将选中的实体打包到Section框中时播放的声音文件路径\n  treeGenerateDeepSoundFile:\n    title: 树形深度生长的声音文件\n    description: |\n      使用Tab键进行树形深度生长时播放的声音文件路径\n  treeGenerateBroadSoundFile:\n    title: 树形广度生长的声音文件\n    description: |\n      使用Enter键进行树形广度生长时播放的声音文件路径\n  treeAdjustSoundFile:\n    title: 树形结构调整的声音文件\n    description: |\n      格式化树形结构时播放的声音文件路径\n  viewAdjustSoundFile:\n    title: 视图调整的声音文件\n    description: |\n      调整视图时播放的声音文件路径\n  entityJumpSoundFile:\n    title: 物体跳跃的声音文件\n    description: |\n      物体跳跃移动时播放的声音文件路径\n  associationAdjustSoundFile:\n    title: 连线调整的声音文件\n    description: |\n      调整连线、无向边等关联元素时播放的声音文件路径\n  agreeTerms:\n    title: 同意用户协议\n    description: |\n      请您仔细阅读并同意用户协议\n  allowTelemetry:\n    title: 参与用户体验改进计划\n    description: |\n      如果您启用此项，我们会收集您的使用数据，帮助我们改进软件\n      发送的数据仅用于统计，不会包含您的个人隐私信息\n      您的数据会在中国香港的云服务器上存储，不会发送到国外\n  aiApiBaseUrl:\n    title: AI API 地址\n    description: |\n      目前仅支持 OpenAI 格式的 API\n  aiApiKey:\n    title: AI API 密钥\n    description: |\n      密钥将会明文存储在本地\n  aiModel:\n    title: AI 模型\n  aiShowTokenCount:\n    title: 显示 AI 消耗的token数\n    description: |\n      启用后，在 AI 操作时显示消耗的token数\n  cacheTextAsBitmap:\n    title: 开启位图式渲染文本\n    description: |\n      开启后，文本节点的位图缓存将会被保留，以提升渲染速度\n      但可能会造成模糊\n  textCacheSize:\n    title: 文本缓存限制\n    description: |\n      默认情况下会对频繁渲染的文本进行缓存，以提升渲染速度\n      如果值过大可能会导致内存占用过高造成卡顿甚至崩溃\n      值为0代表禁用缓存，每次渲染都会重新渲染文本\n      *重新打开文件时生效\n  textScalingBehavior:\n    title: 摄像机缩放时的文本渲染方式\n    options:\n      temp: 每一帧都重新渲染文本，不使用缓存\n      nearestCache: 使用文本大小最接近的缓存，缩放再渲染\n      cacheEveryTick: 每一帧都重新渲染文本，并且缓存\n  textIntegerLocationAndSizeRender:\n    title: 文本整数位置和大小渲染\n    description: |\n      开启后，一切文字的大小和位置都是整数，以节省渲染性能。\n      但会出现文字抖动现象。建议配合视角缩放速度调整成1一起使用。\n      如果您的电脑使用体验非常卡顿，尤其是在缩放和移动的情况下，可以开启此选项。\n  antialiasing:\n    title: 抗锯齿\n    description: |\n      *重新打开文件时生效\n    options:\n      disabled: 关闭\n      low: 低\n      medium: 中\n      high: 高\n  isStealthModeEnabled:\n    title: 潜行模式\n    description: 开启后鼠标中心出现遮罩（具体形状可在设置中修改），可用于记忆化练习等遮盖场景。\n  stealthModeScopeRadius:\n    title: 潜行模式范围半径\n    description: |\n      狙击镜的半径\n  stealthModeReverseMask:\n    title: 反向遮罩\n    description: |\n      开启后，狙击镜中心区域会被遮罩，只显示周围区域\n  stealthModeMaskShape:\n    title: 潜行模式遮罩形状\n    description: |\n      选择潜行模式下显示区域的形状\n    options:\n      circle: 圆形\n      square: 正方形\n      topLeft: 左上角象限\n      smartContext: 智能上下文（框或实体）\n  soundPitchVariationRange:\n    title: 音效音调随机变化范围\n    description: 控制音效播放时音调随机变化的程度。范围：0-1200音分（1200音分=1个八度，100音分=1个半音）。值越大，音调变化越明显，越像游戏一样有趣。\n  autoImportTxtFileWhenOpenPrg:\n    title: 打开PRG文件时自动导入同名TXT文件\n    description: 启用后，打开PRG文件时会自动导入同一文件夹下同名TXT文件的内容，并以文本节点形式添加到舞台左下角。\n\n\neffects:\n  CircleChangeRadiusEffect:\n    title: 圆形变换半径效果\n    description: |\n      质点被框选后波纹放大\n  CircleFlameEffect:\n    title: 圆形径向渐变光闪\n    description: |\n      存在于各种特效细节中，预劈砍直线与实体矩形切割时、斩断连线时的中点闪烁等\n  EntityAlignEffect:\n    title: 实体对齐效果\n    description: |\n      鼠标拖动吸附对齐时产生的高亮虚线\n  EntityCreateDashEffect:\n    title: 实体创建粉尘凝聚效果\n    description: |\n      实体创建时，实体周围出现粉尘凝聚\n      由于不够美观，已经废弃，不会出现\n  EntityCreateFlashEffect:\n    title: 实体边框发光效果\n    description: |\n      在ctrl+滚轮转动实体树、缩放图片、创建节点等情况下出现\n      若渲染性能较差，建议关闭此选项\n  EntityCreateLineEffect:\n    title: 实体散发电路板式线条辐射效果\n    description: |\n      已经废弃，不会出现\n  EntityDashTipEffect:\n    title: 实体提示性的粉尘抖动\n    description: |\n      出现在实体输入编辑结束或进入时，实体周围出现抖动的粉尘\n  EntityJumpMoveEffect:\n    title: 实体跳跃移动效果\n    description: |\n      实体跳跃移动时，实体出现一个象征性的跳跃弧线幻影\n      用来表示伪z轴的跨越层级移动\n  EntityShakeEffect:\n    title: 实体抖动效果\n    description: |\n      像“TickTock”Logo 一样的抖动特效，用于实体出现警告性质的提示\n  EntityShrinkEffect:\n    title: 实体缩小消失效果\n    description: |\n      使用Delete键删除实体时，实体出现缩小消失的效果\n  ExplodeDashEffect:\n    title: 粉尘爆炸效果\n    description: |\n      用劈砍删除实体时，出现粉尘爆炸\n  LineCuttingEffect:\n    title: 劈砍时的刀光\n    description: |\n      劈砍时，出现类似水果忍者一样的刀光\n  LineEffect:\n    title: 直线段淡出效果\n    description: |\n      用于拖拽旋转子树时，连线划过虚影\n  NodeMoveShadowEffect:\n    title: 节点移动时摩擦地面的粒子效果\n    description: |\n      布局造成的移动可能也会出现一闪而过的粒子\n  PenStrokeDeletedEffect:\n    title: 涂鸦被删除时的消失特效\n    description: |\n      涂鸦被删除时，出现消失的特效\n  PointDashEffect:\n    title: 在某点出迸发万有引力式的粒子效果\n    description: |\n      由于万有引力影响性能，此特效已关闭，不会出现\n  RectangleLittleNoteEffect:\n    title: 矩形闪烁提示效果\n    description: |\n      在逻辑节点执行时，逻辑节点会闪烁此效果\n  RectangleNoteEffect:\n    title: 矩形存在提示效果\n    description: |\n      高亮提示某个矩形范围，搜索节点或定位时会高亮提示\n      关闭后会看不到矩形高亮效果\n  RectanglePushInEffect:\n    title: 矩形四顶点划至另一矩形四顶点的效果\n    description: |\n      用于提示实体的跨越框层移动、方向键切换选中\n      目前开发者由于偷懒，此效果引用了四个劈砍线效果。\n      若关闭了劈砍线效果，则此效果会看不见\n  RectangleRenderEffect:\n    title: 矩形位置提示效果\n    description: |\n      用于在吸附拖拽对齐时，显示实体即将吸附到的目标位置\n  RectangleSplitTwoPartEffect:\n    title: 矩形被切成两块的特效\n    description: |\n      仅存在于劈砍特效（也有可能是四块）\n  TechLineEffect:\n    title: （基础特效）折线段效果\n    description: |\n      此特效时其他特效的组成部分，若关闭则其他特效可能会受到影响\n  TextRaiseEffectLocated:\n    title: 固定位置的文本节点悬浮上升效果\n    description: |\n      文本节点悬浮上升效果，用于提示重要信息\n  ViewFlashEffect:\n    title: 视野闪烁效果\n    description: |\n      全屏闪白/闪黑等效果\n      光敏癫痫症患者请关闭此选项\n  ViewOutlineFlashEffect:\n    title: 视野轮廓闪烁效果\n    description: |\n      视野轮廓闪烁效果\n  ZapLineEffect:\n    title: （基础特效）闪电线效果\n    description: |\n      此特效时其他特效的组成部分，若关闭则其他特效可能会受到影响\n  MouseTipFeedbackEffect:\n    title: 鼠标交互提示特效\n    description: |\n      在鼠标进行缩放视野等操作时，鼠标旁边会出现特效提示，例如一个变大或变小的圆圈\n  RectangleSlideEffect:\n    title: 矩形滑动尾翼特效\n    description: |\n      用于垂直方向键盘移动实体\n\nkeyBindsGroup:\n  otherKeys:\n    title: 未分类的快捷键\n    description: |\n      未分类的快捷键，\n      此处若发现无翻译的无效快捷键项，可能是由于版本升级而未清理旧快捷键导致出现的残留\n      可手动清理 keybinds.json 文件中的对应项\n  basic:\n    title: 基础快捷键\n    description: |\n      基本的快捷键，用于常用的功能\n  camera:\n    title: 摄像机控制\n    description: |\n      用于控制摄像机移动、缩放\n  app:\n    title: 应用控制\n    description: |\n      用于控制应用的一些功能\n  ui:\n    title: UI控制\n    description: |\n      用于控制UI的一些功能\n  draw:\n    title: 涂鸦\n    description: |\n      涂鸦相关功能\n  select:\n    title: 切换选择\n    description: |\n      使用键盘来切换选中的实体\n  moveEntity:\n    title: 移动实体\n    description: |\n      用于移动实体的一些功能\n  generateTextNodeInTree:\n    title: 生长节点\n    description: |\n      通过键盘生长节点（Xmind用户习惯）\n  generateTextNodeRoundedSelectedNode:\n    title: 在选中节点周围生成节点\n    description: |\n      按下后，在选中节点周围生成节点\n  aboutTextNode:\n    title: 关于文本节点\n    description: |\n      和文本节点相关的一切快捷键，分割、合并、创建等\n  section:\n    title: Section框\n    description: |\n      Section框相关功能\n  leftMouseModeCheckout:\n    title: 左键模式切换\n    description: |\n      关于左键模式的切换\n  edge:\n    title: 连线相关\n    description: |\n      关于连线的一些功能\n  expandSelect:\n    title: 扩散选择\n    description: |\n      扩散选择节点相关的快捷键\n  themes:\n    title: 主题切换\n    description: |\n      切换主题相关的快捷键\n  align:\n    title: 对齐相关\n    description: |\n      关于实体对齐的一些功能\n  image:\n    title: 图片相关\n    description: |\n      关于图片的一些功能\n  node:\n    title: 节点相关\n    description: |\n      关于节点的一些功能，如嫁接、摘除等\n\ncontrolSettingsGroup:\n  mouse:\n    title: 鼠标设置\n  touchpad:\n    title: 触摸板设置\n  textNode:\n    title: 文本节点设置\n  gamepad:\n    title: 游戏手柄设置\n\nvisualSettingsGroup:\n  basic:\n    title: 基本设置\n  background:\n    title: 背景设置\n\nkeyBinds:\n  title: 快捷键绑定\n  none: 未绑定快捷键\n  test:\n    title: 测试\n    description: |\n      仅用于测试快捷键自定义绑定功能功能\n  reload:\n    title: 重载应用\n    description: |\n      重载应用，重新加载当前工程文件\n      等同于浏览器刷新网页\n      这个功能很危险！会导致未保存的进度丢失！\n  saveFile:\n    title: 保存文件\n    description: |\n      保存当前工程文件，若当前文件是草稿则另存为\n  newDraft:\n    title: 新建草稿\n    description: |\n      新建一个草稿文件，并切换到该文件\n      若当前文件未保存则无法切换\n  newFileAtCurrentProjectDir:\n    title: 在当前项目目录下新建文件\n    description: |\n      在当前项目目录下新建一个工程文件，并切换到该文件（用于快速创建文件）\n      若当前文件为草稿状态存则无法创建\n  openFile:\n    title: 打开文件\n    description: |\n      选择一个曾经保存的json/prg文件并打开\n  undo:\n    title: 撤销\n    description: 撤销上一次操作\n  redo:\n    title: 取消撤销\n    description: 取消上一次撤销操作\n  resetView:\n    title: 重置视野\n    description: |\n      如果没有选择任何内容，则根据全部内容重置视野；\n      如果有选择内容，则根据选中内容重置视野\n  restoreCameraState:\n    title: 恢复视野状态\n    description: |\n      按下后，恢复到之前按下F键时记录的摄像机位置和缩放大小\n  resetCameraScale:\n    title: 重置缩放\n    description: 将视野缩放重置为标准大小\n  folderSection:\n    title: 折叠或展开Section框\n    description: 按下后选中的Section框会切换折叠或展开状态\n  toggleSectionLock:\n    title: 锁定/解锁Section框\n    description: 切换选中Section框的锁定状态，锁定后内部物体不可移动\n  reverseEdges:\n    title: 反转连线的方向\n    description: |\n      按下后，选中的连线的方向会变成相反方向\n      例如，原先是 A -> B，按下后变成 B -> A\n      此功能的意义在于快速创建一个节点连向多个节点的情况\n      因为目前的连线只能一次性做到多连一。\n  reverseSelectedNodeEdge:\n    title: 反转选中的节点的所有连线的方向\n    description: |\n      按下后，所有框选选中的节点中\n      每个节点的所有连线都会反转方向\n      进而实现更快捷的一连多\n  createUndirectedEdgeFromEntities:\n    title: 选中的实体之间创建无向连线\n    description: |\n      按下后，选中的两个或者多个实体之间会创建一条无向连线\n  packEntityToSection:\n    title: 将选中的实体打包到Section框中\n    description: |\n      按下后，选中的实体会自动包裹到新Section框中\n  unpackEntityFromSection:\n    title: Section框拆包，转换为文本节点\n    description: |\n      按下后，选中的Section框中的实体会被拆包，自身转换成一个文本节点\n      内部的实体将会掉落在外面\n  textNodeToSection:\n    title: 将选中的文本节点转换成Section框\n    description: |\n      按下后，选中的文本节点会被转换成Section框\n      可以用于section框的快速创建\n  deleteSelectedStageObjects:\n    title: 删除选中的舞台物体\n    description: |\n      按下后，选中的舞台物体会被删除\n      舞台物体包括实体（节点和Section等独立存在的东西）和关系（节点之间的连线）\n      默认是delete键，您可以改成backspace键\n  editEntityDetails:\n    title: 编辑选中的实体的详细信息\n    description: |\n      按下后，选中的实体的详细信息会被打开编辑\n      只有选中的物体数量为1时才有效\n  openColorPanel:\n    title: 打开颜色面板快捷键\n    description: |\n      按下后，打开颜色面板，可以用于快速切换节点颜色\n  switchDebugShow:\n    title: 切换调试信息显示\n    description: |\n      按下后，切换调试信息显示\n      调试信息显示在屏幕左上角，通常为开发者使用\n      开启后，屏幕左上角将会显示调试信息。\n      若您遇到bug截图反馈时，建议开启此选项。\n  generateNodeTreeWithDeepMode:\n    title: 生长子级节点\n    description: |\n      按下后，瞬间生长一个节点并放置在当前选中节点的右侧\n      如果对节点设置了生长方向，则会按照生长方向生长子节点（若无设置则默认向右生长）\n      同时自动排版整个节点树的结构，确保其是一个向右的树型结构\n      使用此功能前先确保已选中一个节点、且该节点所在结构为树形结构\n  generateNodeTreeWithBroadMode:\n    title: 生长同级节点\n    description: |\n      按下后，瞬间生长一个同级节点并放置在当前选中节点的下方\n      同时自动排版整个节点树的结构，确保其是一个向下的树型结构\n      使用此功能前先确保已选中一个节点、且该节点存在父级节点\n  generateNodeGraph:\n    title: 生长自由节点\n    description: |\n      按下后，出现一个虚拟生长位置\n      此时，按下“I J K L”键自由调整生长位置\n      再次按下该键，退出自由生长模式\n      使用此功能前先确保已选中一个节点\n  createConnectPointWhenDragConnecting:\n    title: 拖拽连线时，按下此键，创建质点中转\n    description: |\n      当拖拽连线时，按下此键，创建质点中转\n  treeGraphAdjust:\n    title: 调整当前节点所在树形结构的树形排布\n    description: |\n      主要用于关闭了键盘生长节点时触发的树形排布调整时使用\n      可以通过此快捷键手动触发布局调整\n  treeGraphAdjustSelectedAsRoot:\n    title: 以选中节点为根节点格式化树形结构\n    description: |\n      以当前选中的节点作为根节点进行树形结构格式化\n      不会查找整个树的根节点，只格式化以选中节点为根的子树\n  dagGraphAdjust:\n    title: 调整当前选中节点群的DAG布局\n    description: |\n      对选中的有向无环图（DAG）结构进行自动布局调整\n      仅当选中节点构成DAG结构时可用\n  gravityLayout:\n    title: 持续按下引力式布局\n    description: |\n      选中一堆连线的节点群后，持续按下该键可以进行引力式布局。\n      注意此键若想自定义，只能设置为单个非修饰键，否则可能有未知bug。zh\n  setNodeTreeDirectionLeft:\n    title: 设置当前节点的树形生长方向为向左\n    description: |\n      需要选中节点后按下此快捷键\n      设置后，在此节点上按Tab生长时，会向左生长子节点\n  setNodeTreeDirectionRight:\n    title: 设置当前节点的树形生长方向为向右\n    description: |\n      需要选中节点后按下此快捷键\n      设置后，在此节点上按Tab生长时，会向右生长子节点\n  setNodeTreeDirectionUp:\n    title: 设置当前节点的树形生长方向为向上\n    description: |\n      需要选中节点后按下此快捷键\n      设置后，在此节点上按Tab生长时，会向上生长子节点\n  setNodeTreeDirectionDown:\n    title: 设置当前节点的树形生长方向为向下\n    description: |\n      需要选中节点后按下此快捷键\n      设置后，在此节点上按Tab生长时，会向下生长子节点\n\n  masterBrakeCheckout:\n    title: 手刹：开启/关闭通过按键控制摄像机移动\n    description: |\n      按下后会切换是否允许 “W S A D”键控制摄像机移动\n      可以用于临时禁止摄像机移动，在输入秘籍键或含有WSAD的快捷键时防止视野移动\n  masterBrakeControl:\n    title: 脚刹：停止摄像机飘移\n    description: |\n      按下后，停止摄像机飘移，并将速度置为0\n  selectAll:\n    title: 全选\n    description: 按下后，所有节点和连线都会被选中\n  selectAtCrosshair:\n    title: 选择十字准心对准的节点\n    description: |\n      选择屏幕中心十字准心指向的节点\n      如果该位置有节点，则选中它（取消其他选择）\n  addSelectAtCrosshair:\n    title: 添加选择十字准心对准的节点\n    description: |\n      将屏幕中心十字准心指向的节点添加到当前选择中\n      如果该节点已被选中，则取消选中\n  createTextNodeFromCameraLocation:\n    title: 在视野中心位置创建文本节点\n    description: |\n      按下后，在当前视野中心的位置创建一个文本节点\n      等同于鼠标双击创建节点的功能\n  createTextNodeFromMouseLocation:\n    title: 在鼠标位置创建文本节点\n    description: |\n      按下后，在鼠标悬浮位置创建一个文本节点\n      等同于鼠标单击创建节点的功能\n  createTextNodeFromSelectedTop:\n    title: 在当前选中的节点正上方创建文本节点\n    description: |\n      按下后，在当前选中的节点正上方创建一个文本节点\n  createTextNodeFromSelectedDown:\n    title: 在当前选中的节点正下方创建文本节点\n    description: |\n      按下后，在当前选中的节点正下方创建一个文本节点\n  createTextNodeFromSelectedLeft:\n    title: 在当前选中的节点左侧创建文本节点\n    description: |\n      按下后，在当前选中的节点左侧创建一个文本节点\n  createTextNodeFromSelectedRight:\n    title: 在当前选中的节点右侧创建文本节点\n    description: |\n      按下后，在当前选中的节点右侧创建一个文本节点\n  selectUp:\n    title: 选中上方节点\n    description: 按下后，选中上方节点\n  selectDown:\n    title: 选中下方节点\n    description: 按下后，选中下方节点\n  selectLeft:\n    title: 选中左侧节点\n    description: 按下后，选中左侧节点\n  selectRight:\n    title: 选中右侧节点\n    description: 按下后，选中右侧节点\n  selectAdditionalUp:\n    title: 附加选中上方节点\n    description: 按下后，附加选中上方节点\n  selectAdditionalDown:\n    title: 附加选中下方节点\n    description: 按下后，附加选中下方节点\n  selectAdditionalLeft:\n    title: 附加选中左侧节点\n    description: 按下后，附加选中左侧节点\n  selectAdditionalRight:\n    title: 附加选中右侧节点\n    description: 按下后，附加选中右侧节点\n  moveUpSelectedEntities:\n    title: 向上移动所有选中的实体\n    description: |\n      按下后，所有选中的实体会向上移动一个固定距离\n  moveDownSelectedEntities:\n    title: 向下移动所有选中的实体\n    description: |\n      按下后，所有选中的实体会向下移动一个固定距离\n  moveLeftSelectedEntities:\n    title: 向左移动所有选中的实体\n    description: |\n      按下后，所有选中的实体会向左移动一个固定距离\n  moveRightSelectedEntities:\n    title: 向右移动所有选中的实体\n    description: |\n      按下后，所有选中的实体会向右移动一个固定距离\n  jumpMoveUpSelectedEntities:\n    title: 跳跃向上移动所有选中的实体\n    description: |\n      按下后，所有选中的实体会跳跃向上移动一个固定距离，能够跳入或者跳出Section框\n  jumpMoveDownSelectedEntities:\n    title: 跳跃向下移动所有选中的实体\n    description: |\n      按下后，所有选中的实体会跳跃向下移动一个固定距离，能够跳入或者跳出Section框\n  jumpMoveLeftSelectedEntities:\n    title: 跳跃向左移动所有选中的实体\n    description: |\n      按下后，所有选中的实体会跳跃向左移动一个固定距离，能够跳入或者跳出Section框\n  jumpMoveRightSelectedEntities:\n    title: 跳跃向右移动所有选中的实体\n    description: |\n      按下后，所有选中的实体会跳跃向右移动一个固定距离，能够跳入或者跳出Section框\n  CameraScaleZoomIn:\n    title: 视野放大\n    description: 按下后，视野放大\n  CameraScaleZoomOut:\n    title: 视野缩小\n    description: 按下后，视野缩小\n  CameraPageMoveUp:\n    title: 视野向上翻页式移动\n  CameraPageMoveDown:\n    title: 视野向下翻页式移动\n  CameraPageMoveLeft:\n    title: 视野向左翻页式移动\n  CameraPageMoveRight:\n    title: 视野向右翻页式移动\n  exitSoftware:\n    title: 退出软件\n    description: 按下后，退出软件\n  checkoutProtectPrivacy:\n    title: 进入或退出隐私保护模式\n    description: |\n      按下后，舞台上的全部文字将会被加密，无法被其他人看到\n      按下后，舞台上的全部文字将会解密，其他人可以看到\n      可以用于截图反馈问题、突然有人看你的屏幕时使用并且你的内容是感情问题（？）时使用\n  openTextNodeByContentExternal:\n    title: 以网页浏览器或本地文件形式打开选中节点的内容\n    description: |\n      按下后，舞台上所有选中的文本节点都会被以默认方式或浏览器方式打开。\n      例如一个节点内容为 \"D:/Desktop/a.txt\"，选中此节点按下快捷键之后，能以系统默认方式打开此文件\n      如果节点内容为网页地址 \"https://project-graph.top\"，会以系统默认浏览器打开网页内容\n  checkoutClassroomMode:\n    title: 进入或退出专注模式\n    description: |\n      按下后，进入专注模式，所有UI都会隐藏，顶部按钮会透明化处理\n      再按一次恢复\n  checkoutWindowOpacityMode:\n    title: 切换窗口透明度模式\n    description: |\n      按下后，窗口进入完全透明模式，再按一次将进入完全不透明模式\n      注意要配合舞台颜色风格进行设置。例如：黑色模式下文字为白色，论文白模式下文字为黑色。\n      如果窗口下层内容为白色背景，建议切换舞台到论文白模式。\n  windowOpacityAlphaIncrease:\n    title: 窗口不透明度增加\n    description: |\n      按下后，窗口不透明度（alpha）值增加0.2，往不透明的方向改变，最大值为1\n      当不能再增加时，会有窗口边缘闪烁提示\n  windowOpacityAlphaDecrease:\n    title: 窗口不透明度减小\n    description: |\n      按下后，窗口不透明度（alpha）值减小0.2，往透明的方向改变，最小值为0\n      如果您的键盘没有小键盘的纯减号键，可以改成横排数字0右侧的减号与下划线公用键\n  searchText:\n    title: 搜索文本\n    description: |\n      按下后，打开搜索框，可以输入搜索内容\n      搜索框支持部分匹配，例如输入 \"a\" 能搜索到 \"apple\" 等\n  clickAppMenuSettingsButton:\n    title: 打开设置页面\n    description: |\n      按下此键可代替鼠标点击菜单栏里的设置界面按钮\n  clickTagPanelButton:\n    title: 打开/关闭标签面板\n    description: |\n      按下此键可代替鼠标点击页面上的标签面板展开关闭按钮\n  clickAppMenuRecentFileButton:\n    title: 打开最近打开文件列表\n    description: |\n      按下此键可代替鼠标点击菜单栏里的最近打开文件列表按钮\n  clickStartFilePanelButton:\n    title: 打开/关闭启动文件列表\n    description: |\n      按下此键可代替鼠标点击菜单栏里的启动文件列表展开关闭按钮\n  copy:\n    title: 复制\n    description: 按下后，复制选中的内容\n  paste:\n    title: 粘贴\n    description: 按下后，粘贴剪贴板内容\n  pasteWithOriginLocation:\n    title: 原位粘贴\n    description: 按下后，粘贴的内容会与原位置重叠\n  selectEntityByPenStroke:\n    title: 涂鸦与实体的扩散选择\n    description: |\n      选中一个涂鸦或者实体后，按下此键，会扩散选择该实体周围的实体\n      如果当前选择的是涂鸦，则扩散选择涂鸦触碰到的实体\n      如果当前选择的是实体，则扩散选触碰到的所有涂鸦\n      多次按下后可以多次交替扩散\n  expandSelectEntity:\n    title: 扩散选择节点\n    description: |\n      按下后，实体的选择状态会转移到子级节点上\n  expandSelectEntityReversed:\n    title: 反向扩散选择节点\n    description: |\n      按下后，实体的选择状态会转移到父级节点上\n  expandSelectEntityKeepLastSelected:\n    title: 扩散选择节点（保留当前节点的选择状态）\n    description: |\n      按下后，实体的选择状态会转移到子级节点上，同时保留当前节点的选择状态\n  expandSelectEntityReversedKeepLastSelected:\n    title: 反向扩散选择节点（保留当前节点的选择状态）\n    description: |\n      按下后，实体的选择状态会转移到父级节点上，同时保留当前节点的选择状态\n  checkoutLeftMouseToSelectAndMove:\n    title: 设置左键为“选中/移动”模式\n    description: |\n      也就是鼠标左键切换为正常模式\n  checkoutLeftMouseToDrawing:\n    title: 设置左键为“涂鸦”模式\n    description: |\n      也就是鼠标左键切换为涂鸦模式，在工具栏中有对应按钮\n  checkoutLeftMouseToConnectAndCutting:\n    title: 设置左键为“连线/斩断”模式\n    description: |\n      也就是鼠标左键切换为连线/斩断 模式，在工具栏中有对应按钮\n  checkoutLeftMouseToConnectAndCuttingOnlyPressed:\n    title: 设置左键为“连线/斩断”模式（仅按下时）\n    description: |\n      松开时切换回默认的鼠标模式\n  penStrokeWidthIncrease:\n    title: 涂鸦笔画变粗\n    description: 按下后，笔画变粗\n  penStrokeWidthDecrease:\n    title: 涂鸦笔画变细\n    description: 按下后，笔画变细\n  screenFlashEffect:\n    title: 屏幕闪黑特效\n    description: 类似于秘籍键中的hello world，测试出现黑屏的效果时则证明秘籍键系统正常运行了\n  alignNodesToInteger:\n    title: 将所有可连接节点的坐标位置对齐到整数\n    description: 可以大幅度减小json文件的体积\n  toggleCheckmarkOnTextNodes:\n    title: 将选中的文本节点都打上对勾✅，并标为绿色\n    description: 仅对文本节点生效，选中后再输入一次可以取消对勾\n  toggleCheckErrorOnTextNodes:\n    title: 将选中的文本节点都打上错误❌，并标为红色\n    description: 仅对文本节点生效，选中后再输入一次可以取消错误标记\n  switchToDarkTheme:\n    title: 切换成黑色主题\n    description: 切换后需要在舞台上划一刀才生生效\n  switchToLightTheme:\n    title: 切换成白色主题\n    description: 切换后需要在舞台上划一刀才生生效\n  switchToParkTheme:\n    title: 切换成公园主题\n    description: 切换后需要在舞台上划一刀才生生效\n  switchToMacaronTheme:\n    title: 切换成马卡龙主题\n    description: 切换后需要在舞台上划一刀才生生效\n  switchToMorandiTheme:\n    title: 切换成莫兰迪主题\n    description: 切换后需要在舞台上划一刀才生生效\n  increasePenAlpha:\n    title: 增加笔刷不透明度通道值\n    description: \"\"\n  decreasePenAlpha:\n    title: 减少笔刷不透明度通道值\n    description: \"\"\n  alignTop:\n    title: 上对齐\n    description: 小键盘的向上\n  alignBottom:\n    title: 下对齐\n    description: 小键盘的向下\n  alignLeft:\n    title: 左对齐\n    description: 小键盘的向左\n  alignRight:\n    title: 右对齐\n    description: 小键盘的向右\n  alignHorizontalSpaceBetween:\n    title: 相等间距水平对齐\n    description: 小键盘的左右左右，晃一晃就等间距了\n  alignVerticalSpaceBetween:\n    title: 相等间距垂直对齐\n    description: 小键盘的上下上下，晃一晃就等间距了\n  alignCenterHorizontal:\n    title: 中心水平对齐\n    description: 小键盘：先中，然后左右\n  alignCenterVertical:\n    title: 中心垂直对齐\n    description: 小键盘：先中，然后上下\n  alignLeftToRightNoSpace:\n    title: 向右紧密堆积一排\n    description: 小键盘横着从左到右穿一串\n  alignTopToBottomNoSpace:\n    title: 向下紧密堆积一列\n    description: 小键盘竖着从上到下穿一串\n  layoutToSquare:\n    title: 松散方阵排列\n  layoutToTightSquare:\n    title: 紧密堆积\n  layoutToTightSquareDeep:\n    title: 递归紧密堆积\n  adjustSelectedTextNodeWidthMin:\n    title: 统一宽度为最小值\n    description: 仅对文本节点生效，将所有选中节点的宽度统一为最小值\n  adjustSelectedTextNodeWidthMax:\n    title: 统一宽度为最大值\n    description: 仅对文本节点生效，将所有选中节点的宽度统一为最大值\n  adjustSelectedTextNodeWidthAverage:\n    title: 统一宽度为平均值\n    description: 仅对文本节点生效，将所有选中节点的宽度统一为平均值\n  connectAllSelectedEntities:\n    title: 将所有选中实体进行全连接\n    description: 用于特殊教学场景或图论教学，“- -”开头表示连线相关\n  connectLeftToRight:\n    title: 将所有选中实体按照从左到右的摆放位置进行连接\n    description: \"\"\n  connectTopToBottom:\n    title: 将所有选中实体按照从上到下的摆放位置进行连接\n    description: \"\"\n  selectAllEdges:\n    title: 选中所有连线\n    description: 仅选择所有视野内的连线\n  colorSelectedRed:\n    title: 将所有选中物体染色为纯红色\n    description: 具体为：(239, 68, 68)，仅作快速标注用\n  increaseBrightness:\n    title: 将所选实体的颜色亮度增加\n    description: 不能对没有上色的或者透明的实体使用，b是brightness，句号键也是>键，可以看成往右走，数值增大\n  decreaseBrightness:\n    title: 将所选实体的颜色亮度减少\n    description: 不能对没有上色的或者透明的实体使用，b是brightness，逗号键也是<键，可以看成往左走，数值减小\n  gradientColor:\n    title: 将所选实体的颜色渐变\n    description: 后续打算做成更改色相环，目前还不完善\n  changeColorHueUp:\n    title: 将所选实体的颜色色相增加\n    description: 不能对没有上色的或者透明的实体使用\n  changeColorHueDown:\n    title: 将所选实体的颜色色相减少\n    description: 不能对没有上色的或者透明的实体使用\n  changeColorHueMajorUp:\n    title: 将所选实体的颜色色相大幅增加\n    description: 不能对没有上色的或者透明的实体使用\n  changeColorHueMajorDown:\n    title: 将所选实体的颜色色相大幅减少\n    description: 不能对没有上色的或者透明的实体使用\n  graftNodeToTree:\n    title: 嫁接节点到树\n    description: |\n      将选中的节点嫁接到碰撞到的连线上，保持原连线方向\n  removeNodeFromTree:\n    title: 从树中摘除节点\n    description: |\n      将选中的节点从树中摘出来，并重新连接前后节点\n\n  toggleTextNodeSizeMode:\n    title: 将选中的文本节点，切换大小调整模式\n    description: 仅对文本节点生效，auto模式：输入文字不能自动换行，manual模式：宽度为框的宽度，宽度超出自动换行\n  decreaseFontSize:\n    title: 减小选中的文本节点字体大小\n    description: 仅对文本节点生效，按下Ctrl+-减小选中的文本节点字体大小\n  increaseFontSize:\n    title: 增大选中的文本节点字体大小\n    description: 仅对文本节点生效，按下Ctrl+=增大选中的文本节点字体大小\n  splitTextNodes:\n    title: 将选中的文本节点，剋(kēi)成小块\n    description: 仅对文本节点生效，根据标点符号，空格、换行符等进行分割，将其分割成小块\n  mergeTextNodes:\n    title: 将选中的多个文本节点，挼ruá (合并)成一个文本节点，颜色也会取平均值\n    description: 仅对文本节点生效，顺序按从上到下排列，节点的位置按节点矩形左上角顶点坐标为准\n  swapTextAndDetails:\n    title: 详略交换\n    description: 将所有选中的文本节点的详细信息和实际内容进行交换，连按5次e，主要用于直接粘贴进来的文本内容想写入详细信息\n  reverseImageColors:\n    title: 反转图片颜色\n    description: 反转选中图片的颜色（将白色背景变为黑色，反之亦然）\n  treeReverseY:\n    title: 纵向反转树形结构\n    description: 选中树形结构的根节点，将其纵向反转\n  treeReverseX:\n    title: 横向反转树形结构\n    description: 选中树形结构的根节点，将其横向反转\n  textNodeTreeToSection:\n    title: 将文本节点树转换为框嵌套结构\n    description: 将选中的文本节点树结构转换为框嵌套结构\n\n  switchActiveProject:\n    title: 切换当前项目\n    description: 按下后，切换到下一个项目\n  switchActiveProjectReversed:\n    title: 切换当前项目（反序）\n    description: 按下后，切换到上一个项目\n  closeCurrentProjectTab:\n    title: 关闭当前项目标签页\n    description: 关闭当前激活的项目标签页。若有未保存更改会提示保存。默认关闭，可在设置中启用。\n  closeAllSubWindows:\n    title: 关闭所有子窗口\n    description: 关闭当前所有打开的子窗口（如设置、AI、颜色面板等），并将焦点恢复至主画布。\n  toggleFullscreen:\n    title: 切换全屏\n    description: 在全屏和窗口模式之间切换应用窗口。\n  setWindowToMiniSize:\n    title: 设置窗口为迷你大小\n    description: 将窗口大小设置为设置中配置的迷你窗口宽度和高度。\n\nsounds:\n  soundEnabled: 音效开关\n\n"
  },
  {
    "path": "app/src/locales/zh_TW.yml",
    "content": "welcome:\n  slogan: 基於圖論的思維框架圖繪製軟件\n  slogans:\n    - 基於圖論的思維框架圖繪製軟件\n    - 在無限大的平面上發揮你的設計\n    - 讓思維在節點與連線間自由流動\n    - 用圖論思想構建你的知識網絡\n    - 從混沌到秩序，從節點到體系\n    - 可視化思維，拓撲化管理\n    - 無限畫布，無限可能\n    - 連接點滴想法，繪製宏觀藍圖\n    - 不只是思維導圖，更是思維框架\n    - 圖論驅動的視覺思考工具\n  newDraft: 新建草稿\n  openFile: 打開文件\n  openRecentFiles: 打開最近\n  newUserGuide: 功能說明書\n  settings: 設置\n  about: 關於\n  website: 官網\n  title: Project Graph\n  language: 語言\n  next: 下一步\n  github: GitHub\n  bilibili: Bilibili\n  qq: QQ群\n  subtitle: 基於圖論的無限畫布思維導圖軟件\n\nglobalMenu:\n  file:\n    title: 文件\n    new: 新建臨時草稿\n    open: 打開\n    recentFiles: 最近打開的文件\n    clear: 清空\n    save: 保存\n    saveAs: 另存為\n    import: 導入\n    importFromFolder: 根據文件夾生成框框嵌套圖\n    importTreeFromFolder: 根據文件夾生成樹狀圖\n    generateKeyboardLayout: 根據當前快捷鍵配置生成鍵盤佈局圖\n    export: 導出\n    exportAsSVG: 導出為 SVG\n    exportAll: 導出全部內容\n    plainTextType:\n      exportAllNodeGraph: 導出 全部的 網狀關係\n      exportSelectedNodeGraph: 導出 選中的 網狀關係\n      exportSelectedNodeTree: 導出 選中的 樹狀關係（純文本縮進）\n      exportSelectedNodeTreeMarkdown: 導出 選中的 樹狀關係（Markdown格式）\n      exportSelectedNodeGraphMermaid: 根據 選中的 嵌套網狀關係（Mermaid格式）\n    exportSelected: 導出選中內容\n    plainText: 純文本\n    exportSuccess: 導出成功\n    attachments: 附件管理器\n    tags: 標籤管理器\n  view:\n    title: 視野\n    resetViewAll: 根據全部內容重置視野\n    resetViewSelected: 根據選中內容重置視野\n    resetViewScale: 重置視野縮放到標準大小\n    moveViewToOrigin: 移動視野到座標軸原點\n  actions:\n    title: 操作\n    search: 搜索\n    refresh: 刷新\n    undo: 撤銷\n    redo: 重做\n    releaseKeys: 釋放按鍵\n    confirmClearStage: 確認清空舞臺？\n    irreversible: 此操作無法撤銷！\n    clearStage: 清空舞臺\n    cancel: 取消\n    confirm: 確定\n    generating: 生成中\n    success: 成功\n    failed: 失敗\n    generate:\n      generatedIn: 生成耗時\n      title: 生成\n      generateNodeTreeByText: 根據純文本生成樹狀結構\n      generateNodeTreeByTextDescription: 請輸入樹狀結構文本，每行代表一個節點，縮進表示層級關係\n      generateNodeTreeByTextPlaceholder: 輸入樹狀結構文本...\n      generateNodeTreeByMarkdown: 根據Markdown文本生成樹狀結構\n      generateNodeTreeByMarkdownDescription: 請輸入markdown格式的字符串，要有不同層級的標題\n      generateNodeTreeByMarkdownPlaceholder: 輸入markdown格式文本...\n      indention: 縮進字符數\n      generateNodeGraphByText: 根據純文本生成網狀結構\n      generateNodeGraphByTextDescription: 請輸入網狀結構文本，每行代表一個關係，每一行的格式為 `XXX --> XXX`\n      generateNodeGraphByTextPlaceholder: |\n        張三 -喜歡-> 李四\n        李四 -討厭-> 王五\n        王五 -欣賞-> 張三\n        A --> B\n        B --> C\n        C --> D\n      generateNodeMermaidByText: 根據mermaid文本生成框嵌套網狀結構\n      generateNodeMermaidByTextDescription: 支持graph TD格式的mermaid文本，可自動識別Section並創建嵌套結構\n      generateNodeMermaidByTextPlaceholder: |\n        graph TD;\n          A[Section A] --> B[Section B];\n          A --> C[普通節點];\n          B --> D[另一個節點];\n        ;\n  settings:\n    title: 設置\n    appearance: 個性化\n  ai:\n    title: AI\n    openAIPanel: 打開 AI 面板\n  window:\n    title: 視圖\n    fullscreen: 全屏\n    classroomMode: 專注模式\n    classroomModeHint: 左上角菜單按鈕僅僅是透明瞭，並沒有消失\n  about:\n    title: 關於\n    guide: 功能說明書\n  unstable:\n    title: 測試版\n    notRelease: 此版本並非正式版\n    mayHaveBugs: 可能包含 Bug 和未完善的功能\n    reportBug: \"報告 Bug: 在 Issue #487 中評論\"\n    test: 測試功能\n\ncontextMenu:\n  createTextNode: 創建文本節點\n  createConnectPoint: 創建質點\n  packToSection: 打包為框\n  createMTUEdgeLine: 創建無向邊\n  createMTUEdgeConvex: 創建凸包\n  convertToSection: 轉換為框\n  toggleSectionCollapse: 切換摺疊狀態\n  changeColor: 更改顏色\n  resetColor: 重置\n  switchMTUEdgeArrow: 切換箭頭形態\n  mtuEdgeArrowOuter: 箭頭外向\n  mtuEdgeArrowInner: 箭頭內向\n  mtuEdgeArrowNone: 關閉箭頭顯示\n  switchMTUEdgeRenderType: 切換渲染形態\n  convertToDirectedEdge: 轉換為有向邊\n\nsettings:\n  title: 設置\n  categories:\n    ai:\n      title: AI\n      api: API\n    automation:\n      title: 自動化\n      autoNamer: 自動命名\n      autoSave: 自動保存\n      autoBackup: 自動備份\n      autoImport: 自動導入\n    control:\n      title: 控制\n      mouse: 鼠標\n      touchpad: 觸摸板\n      cameraMove: 視野移動\n      cameraZoom: 視野縮放\n      objectSelect: 物體選擇\n      textNode: 文本節點\n      section: 框\n      edge: 連線\n      generateNode: 通過鍵盤生長節點\n      gamepad: 遊戲手柄\n    visual:\n      title: 視覺\n      basic: 基礎\n      background: 背景\n      node: 節點樣式\n      edge: 連線樣式\n      section: “框”的樣式\n      entityDetails: 實體詳情\n      debug: 調試\n      miniWindow: 迷你窗口\n      experimental: 實驗性功能\n    performance:\n      title: 性能\n      memory: 內存\n      cpu: CPU\n      render: 渲染\n      experimental: 開發中的功能\n  language:\n    title: 語言\n    options:\n      en: English\n      zh_CN: 簡體中文\n      zh_TW: 繁體中文\n      zh_TWC: 接地氣繁體中文\n      id: 印度尼西亞語\n  themeMode:\n    title: 主題模式\n    options:\n      light: 白天模式\n      dark: 黑夜模式\n  lightTheme:\n    title: 白天主題\n  darkTheme:\n    title: 黑夜主題\n  showTipsOnUI:\n    title: 在 UI 中顯示提示信息\n    description: |\n      開啟後，屏幕上會有一行提示文本。\n      如果您已經熟悉了軟件，建議關閉此項以減少屏幕佔用\n      更多更詳細的提示還是建議看菜單欄中的“功能說明書”或官網文檔。\n  isClassroomMode:\n    title: 專注模式\n    description: |\n      用於教學、培訓等場景。\n      開啟後窗口頂部按鈕會透明，鼠標懸浮上去會恢復，可以修改進入退出專注模式的快捷鍵\n  showQuickSettingsToolbar:\n    title: 顯示快捷設置欄\n    description: |\n      控制是否在界面右側顯示快捷操作欄（快捷設置欄）。\n      快捷設置欄可以讓您快速切換常用設置項的開關狀態。\n  autoAdjustLineEndpointsByMouseTrack:\n    title: 根據鼠標拖動軌跡自動調整生成連線的端點位置\n    description: |\n      開啟後，在拖拽連線時會根據鼠標移動軌跡自動調整連線端點在實體上的位置\n      關閉後，連線端點將始終位於實體中心\n  enableRightClickConnect:\n    title: 啟用右鍵點擊式連線功能\n    description: |\n      開啟後，選中實體並右鍵點擊其他實體時會自動創建連線，且右鍵菜單僅在空白處顯示\n      關閉後，可以在實體上右鍵直接打開菜單，不會自動創建連線\n  lineStyle:\n    title: 連線樣式\n    options:\n      straight: 直線\n      bezier: 貝塞爾曲線\n      vertical: 垂直折線\n  isRenderCenterPointer:\n    title: 顯示中心十字準星\n    description: |\n      開啟後，屏幕中心中心會顯示一個十字準星，用於用於指示快捷鍵創建節點的位置\n  showGrid:\n    title: 顯示網格\n  showBackgroundHorizontalLines:\n    title: 顯示水平背景線\n    description: |\n      水平線和垂直線可以同時打開，實現網格效果\n  showBackgroundVerticalLines:\n    title: 顯示垂直背景線\n  showBackgroundDots:\n    title: 顯示背景點\n    description: |\n      這些背景點是水平線和垂直線的交點，實現洞洞板的效果\n  showBackgroundCartesian:\n    title: 顯示背景直角座標系\n    description: |\n      開啟後，將會顯示x軸、y軸和刻度數字\n      可以用於觀測一些節點的絕對座標位置\n      也能很直觀的知道當前的視野縮放倍數\n  windowBackgroundAlpha:\n    title: 窗口背景透明度\n    description: |\n      *從1改到小於1的值需要重新打開文件才能生效\n  windowBackgroundOpacityAfterOpenClickThrough:\n    title: 開啟點擊穿透後的窗口背景透明度\n    description: |\n      設置在開啟點擊穿透功能後窗口背景的透明度\n  windowBackgroundOpacityAfterCloseClickThrough:\n    title: 關閉點擊穿透後的窗口背景透明度\n    description: |\n      設置在關閉點擊穿透功能後窗口背景的透明度\n  showDebug:\n    title: 顯示調試信息\n    description: |\n      通常為開發者使用\n      開啟後，屏幕左上角將會顯示調試信息。\n      若您遇到bug截圖反饋時，建議開啟此選項。\n  enableTagTextNodesBigDisplay:\n    title: 標籤文本節點巨大化顯示\n    description: |\n      開啟後，標籤文本節點的顯示在攝像機縮小到廣袤的全局視野時，\n      標籤會巨大化顯示，以便更容易辨識整個文件的佈局分佈\n  showTextNodeBorder:\n    title: 顯示文本節點邊框\n    description: |\n      控制是否顯示文本節點的邊框\n  showTreeDirectionHint:\n    title: 顯示樹形生長方向提示\n    description: |\n      選中文本節點時，在節點四周顯示 tab/W W/S S/A A/D D 等鍵盤樹形生長方向提示。\n      關閉後不再渲染這些提示文字。\n  sectionBitTitleRenderType:\n    title: 框的縮略大標題渲染類型\n    options:\n      none: 不渲染（節省性能）\n      top: 頂部小字\n      cover: 半透明覆蓋框體（最佳效果）\n  sectionBigTitleThresholdRatio:\n    title: 框的縮略大標題顯示閾值\n    description: |\n      當框的最長邊小於視野範圍最長邊的此比例時，顯示縮略大標題\n  sectionBigTitleCameraScaleThreshold:\n    title: 框的縮略大標題相機縮放閾值\n    description: |\n      當攝像機縮放比例大於此閾值時，不顯示縮略大標題\n      攝像機縮放比例需要打開調試信息才能顯示\n  sectionBigTitleOpacity:\n    title: 框的縮略大標題透明度\n    description: |\n      控制半透明覆蓋大標題的透明度，取值範圍0-1\n  sectionBackgroundFillMode:\n    title: 框的背景顏色填充方式\n    description: |\n      控制section框的背景顏色填充方式\n      完整填充：填充整個框的背景（默認方式，有透明度化和遮罩順序判斷）\n      僅標題條：只填充頂部標題那一小條的部分\n    options:\n      full: 完整填充\n      titleOnly: 僅標題條\n  alwaysShowDetails:\n    title: 始終顯示節點詳細信息\n    description: |\n      開啟後，無需鼠標移動到節點上時，才顯示節點的詳細信息。\n  nodeDetailsPanel:\n    title: 節點詳細信息面板\n    options:\n      small: 小型面板\n      vditor: vditor markdown編輯器\n  useNativeTitleBar:\n    title: 使用原生標題欄（需要重啟應用）\n    description: |\n      開啟後，窗口頂部將會出現原生的標題欄，而不是模擬的標題欄。\n  protectingPrivacy:\n    title: 隱私保護\n    description: |\n      用於反饋問題截圖時，開啟此項之後將根據所選模式替換文字，以保護隱私。\n      僅作顯示層面的替換，不會影響真實數據\n      反饋完畢後可再關閉，復原\n  protectingPrivacyMode:\n    title: 隱私保護模式\n    description: |\n      選擇隱私保護時的文字替換方式\n    options:\n      secretWord: 統一替換（漢字→㊙，字母→a/A，數字→6）\n      caesar: 凱撒移位（所有字符往後移動一位）\n  entityDetailsFontSize:\n    title: 實體詳細信息字體大小\n    description: |\n      設置舞臺上渲染的實體詳細信息的文字大小，單位為像素\n  entityDetailsLinesLimit:\n    title: 實體詳細信息行數限制\n    description: |\n      限制舞臺上渲染的實體詳細信息的最大行數，超過限制的部分將被省略\n  entityDetailsWidthLimit:\n    title: 實體詳細信息寬度限制\n    description: |\n      限制舞臺上渲染的實體詳細信息的最大寬度（單位為px像素，可參考背景網格座標軸），超過限制的部分將被換行\n  windowCollapsingWidth:\n    title: 迷你窗口的寬度\n    description: |\n      點擊切換至迷你窗口時，窗口的寬度，單位為像素\n  windowCollapsingHeight:\n    title: 迷你窗口的高度\n    description: |\n      點擊切換至迷你窗口時，窗口的高度，單位為像素\n  limitCameraInCycleSpace:\n    title: 開啟循環空間限制攝像機移動\n    description: |\n      開啟後，攝像機只能在一個矩形區域內移動\n      可以防止攝像機移動到很遠的地方迷路\n      該矩形區域會形成一個循環空間，類似於無邊貪吃蛇遊戲中的地圖\n      走到最上面會回到最下面，走到最左邊會回到最右邊\n      注意：該功能還在實驗階段\n  cameraCycleSpaceSizeX:\n    title: 循環空間寬度\n    description: |\n      循環空間的寬度，單位為像素\n  cameraCycleSpaceSizeY:\n    title: 循環空間高度\n    description: |\n      循環空間的高度，單位為像素\n  renderEffect:\n    title: 渲染特效\n    description: 是否渲染特效，如果卡頓可以關閉\n  compatibilityMode:\n    title: 兼容模式\n    description: |\n      開啟後，軟件會使用另一種渲染方式\n  historySize:\n    title: 歷史記錄大小\n    description: |\n      這個數值決定了您最多ctrl+z撤銷的次數\n      如果您的電腦內存非常少，可以適當調小這個值\n  compressPastedImages:\n    title: 是否壓縮粘貼到舞臺的圖片\n    description: |\n      開啟後，粘貼到舞臺的圖片會被壓縮，以節省加載文件時的內存壓力和磁盤壓力\n  maxPastedImageSize:\n    title: 粘貼到舞臺的圖片的尺寸限制（像素）\n    description: |\n      長或寬超過此尺寸的圖片，其長或寬的最大值將會被限制為此大小\n      同時保持長寬比不變，僅在開啟“壓縮粘貼到舞臺的圖片”時生效\n  isPauseRenderWhenManipulateOvertime:\n    title: 超過一定時間未操作舞臺，暫停渲染\n    description: |\n      開啟後，超過若干秒未做出舞臺操作，舞臺渲染會暫停，以節省CPU/GPU資源。\n  renderOverTimeWhenNoManipulateTime:\n    title: 超時停止渲染舞臺的時間（秒）\n    description: |\n      超過一定時間未做出舞臺操作，舞臺渲染會停止，以節省CPU/GPU資源。\n      必須在上述“超時暫停渲染”選項開啟後才會生效。\n  ignoreTextNodeTextRenderLessThanFontSize:\n    title: 當渲染字體大小小於一定值時，不渲染文本節點內的文字及其詳細信息\n    description: |\n      開啟後，當文本節點的渲染字體大小小於一定值時，(也就是觀察宏觀狀態時)\n      不渲染文本節點內的文字及其詳細信息，這樣可以提高渲染性能，但會導致文本節點的文字內容無法顯示\n  isEnableEntityCollision:\n    title: 實體碰撞檢測\n    description: |\n      開啟後，實體之間會進行碰撞擠壓移動，可能會影響性能。\n      建議關閉此項，目前實體碰撞擠壓還不完善，可能導致爆棧\n  isEnableSectionCollision:\n    title: 啟用框碰撞\n    description: |\n      開啟後，框與框之間會自動進行碰撞排斥（推開重疊的同級框），避免框重疊。\n  autoRefreshStageByMouseAction:\n    title: 鼠標操作時自動刷新舞臺\n    description: |\n      開啟後，鼠標操作(拖拽移動視野)會自動刷新舞臺\n      防止出現打開某個文件後，圖片未加載成功還需手動刷新的情況\n  autoNamerTemplate:\n    title: 創建節點時自動命名模板\n    description: |\n      輸入`{{i}}` 代表節點名稱會自動替換為編號，雙擊創建時可以自動累加數字。\n      例如`n{{i}}` 會自動替換為`n1`, `n2`, `n3`…\n      輸入`{{date}}` 會自動替換為當前日期，雙擊創建時可以自動更新日期。autoNamerTemplate\n      輸入`{{time}}` 會自動替換為當前時間，雙擊創建時可以自動更新時間。\n      可以組合使用，例如`{{i}}-{{date}}-{{time}}`\n  autoNamerSectionTemplate:\n    title: 創建框時自動命名模板\n    description: |\n      輸入`{{i}}` 代表節點名稱會自動替換為編號，雙擊創建時可以自動累加數字。\n      例如`n{{i}}` 會自動替換為`n1`, `n2`, `n3`…\n      輸入`{{date}}` 會自動替換為當前日期，雙擊創建時可以自動更新日期。\n      輸入`{{time}}` 會自動替換為當前時間，雙擊創建時可以自動更新時間。\n      可以組合使用，例如`{{i}}-{{date}}-{{time}}`\n  autoSaveWhenClose:\n    title: 點擊窗口右上角關閉按鈕時自動保存工程文件\n    description: |\n      關閉軟件時，如果有未保存的工程文件，會彈出提示框詢問是否保存。\n      開啟此選項後，關閉軟件時會自動保存工程文件。\n      所以，建議開啟此選項。\n  autoSave:\n    title: 開啟自動保存\n    description: |\n      自動保存當前文件\n      此功能目前僅對已有路徑的文件有效，不對草稿文件生效！\n  autoSaveInterval:\n    title: 開啟自動保存間隔（秒）\n    description: |\n      注意：目前計時時間僅在軟件窗口激活時計時，軟件最小化後不會計時。\n  clearHistoryWhenManualSave:\n    title: 使用快捷鍵手動保存時，自動清空歷史記錄\n    description: |\n      當使用Ctrl+S快捷鍵手動保存文件時，自動清空操作歷史記錄。\n      開啟此選項可以減少內存佔用並保持界面整潔。\n  historyManagerMode:\n    title: 歷史記錄管理器模式\n    description: |\n      選擇歷史記錄的管理方式：\n      memoryEfficient - 內存高效模式，使用增量存儲，省內存但可能在撤銷/重做時稍慢\n      timeEfficient - 時間高效模式，使用完整快照存儲，操作響應快但可能佔用更多內存\n    options:\n      memoryEfficient: 內存高效模式\n      timeEfficient: 時間高效模式\n  autoBackup:\n    title: 開啟自動備份\n    description: |\n      自動備份當前文件到備份文件夾\n      如果是草稿，則會存儲在指定的路徑\n  autoBackupInterval:\n    title: 自動備份間隔（秒）\n    description: |\n      自動備份過於頻繁可能會產生大量的備份文件\n      進而佔用磁盤空間\n  autoBackupLimitCount:\n    title: 自動備份最大數量\n    description: |\n      自動備份的最大數量，超過此數量將會刪除舊的備份文件\n  autoBackupCustomPath:\n    title: 自定義自動備份路徑\n    description: |\n      設置自動備份文件的保存路徑，如果為空則使用默認路徑\n  scaleExponent:\n    title: 視角縮放速度\n    description: |\n      《當前縮放倍數》會不斷的以一定倍率無限逼近《目標縮放倍數》\n      當逼近的足夠近時（小於0.0001），會自動停止縮放\n      值為1代表縮放會立刻完成，沒有中間的過渡效果\n      值為0代表縮放永遠都不會完成，可模擬鎖死效果\n      注意：若您在縮放畫面時感到卡頓，請調成1\n  cameraKeyboardScaleRate:\n    title: 視角縮放鍵盤速率\n    description: |\n      每次通過一次按鍵來縮放視野時，視野的縮放倍率\n      值為0.2代表每次放大會變為原來的1.2倍，縮小為原來的0.8倍\n      值為0代表禁止通過鍵盤縮放\n  scaleCameraByMouseLocation:\n    title: 視角縮放根據鼠標位置\n    description: |\n      開啟後，縮放視角的中心點是鼠標的位置\n      關閉後，縮放視角的中心點是當前視野的中心\n  allowMoveCameraByWSAD:\n    title: 允許使用W S A D按鍵移動視角\n    description: |\n      開啟後，可以使用W S A D按鍵來上下左右移動視角\n      關閉後，只能使用鼠標來移動視角，不會造成無限滾屏bug\n  allowGlobalHotKeys:\n    title: 允許使用全局熱鍵\n    description: |\n      開啟後，可以使用全局熱鍵來觸發一些操作\n  cameraFollowsSelectedNodeOnArrowKeys:\n    title: 通過方向鍵切換選中節點時，視野跟隨移動\n    description: |\n      開啟後，使用鍵盤移動節點選擇框時，視野跟隨移動\n  arrowKeySelectOnlyInViewport:\n    title: 方向鍵切換選擇限制在視野內\n    description: |\n      開啟後，使用方向鍵（上下左右）切換選擇節點時，只會選擇當前視野內可見的物體。\n      關閉後，可以選擇到視野外的物體（相機會自動跟隨）。\n  cameraKeyboardMoveReverse:\n    title: 視角移動鍵盤反向\n    description: |\n      開啟後，W S A D按鍵的移動視角方向會相反\n      原本的移動邏輯是移動懸浮在畫面上的攝像機，但如果看成是移動整個舞臺，這樣就反了\n      於是就有了這個選項\n  cameraKeyboardScaleReverse:\n    title: 視角縮放鍵盤反向\n    description: |\n      開啟後，[=起飛（縮小），]=降落（放大）\n      關閉後，[=降落（放大），]=起飛（縮小）\n  cameraResetViewPaddingRate:\n    title: 根據選擇節點重置視野時，邊緣留白係數\n    description: |\n      框選一堆節點或一個節點，並按下快捷鍵或點擊按鈕來重置視野後\n      視野會調整大小和位置，確保所有選中內容出現在屏幕中央並完全涵蓋\n      由於視野縮放大小原因，此時邊緣可能會有留白\n      值為1 表示邊緣完全不留白。（非常放大的觀察）\n      值為2 表示留白內容恰好為自身內容的一倍\n  cameraResetMaxScale:\n    title: 攝像機重置視野後最大的縮放值\n    description: |\n      選中一個面積很小的節點時，攝像機不會完全覆蓋這個節點的面積範圍，否則太大了。\n      而是會放大到一個最大值，這個最大值可以通過此選項來調整\n      建議開啟debug模式下觀察 currentScale 來調整此值\n  allowAddCycleEdge:\n    title: 允許在節點之間添加自環\n    description: |\n      開啟後，節點之間可以添加自環，即節點與自身相連，用於狀態機繪製\n      默認關閉，因為不常用，容易誤觸發\n  enableDragEdgeRotateStructure:\n    title: 允許拖拽連線旋轉結構\n    description: |\n      開啟後，可以通過拖拽選中的連線來旋轉節點結構\n      這允許您輕鬆調整相連節點的方向\n  enableCtrlWheelRotateStructure:\n    title: 允許Ctrl+鼠標滾輪旋轉結構\n    description: |\n      開啟後，可以按住Ctrl鍵（Mac系統為Command鍵）並滾動鼠標滾輪來旋轉節點結構\n      這允許您精確調整相連節點的方向\n  autoLayoutWhenTreeGenerate:\n    title: 生長節點時自動更新佈局\n    description: |\n      開啟後，生長節點時自動更新佈局\n      此處的生長節點指tab和\\鍵生長節點\n  enableBackslashGenerateNodeInInput:\n    title: 在輸入狀態下也能通過反斜槓創建同級節點\n    description: |\n      開啟後，在文本節點編輯狀態下，按下反斜槓鍵（\\）也可以創建同級節點\n      關閉後，只有在非編輯狀態下才能通過反斜槓鍵創建同級節點\n  moveAmplitude:\n    title: 視角移動加速度\n    description: |\n      此設置項用於 使用W S A D按鍵來上下左右移動視角時的情景\n      可將攝像機看成一個能朝四個方向噴氣的 懸浮飛機\n      此加速度值代表著噴氣的動力大小，需要結合下面的摩擦力設置來調整速度\n  moveFriction:\n    title: 視角移動摩擦力系數\n    description: |\n      此設置項用於 使用W S A D按鍵來上下左右移動視角時的情景\n      摩擦係數越大，滑動的距離越小，摩擦係數越小，滑動的距離越遠\n      此值=0時代表 絕對光滑\n  gamepadDeadzone:\n    title: 遊戲手柄死區\n    description: |\n      此設置項用於 遊戲手柄控制視角時的情景\n      手柄的輸入值在0-1之間，此值越小，手柄的輸入越敏感\n      死區越大，手柄的輸入越趨於0或1，不會產生太大的變化\n      死區越小，手柄的輸入越趨於中間值，會產生較大的變化\n  mouseRightDragBackground:\n    title: 右鍵拖動背景的操作\n    options:\n      cut: 斬斷並刪除物體\n      moveCamera: 移動視野\n  enableSpaceKeyMouseLeftDrag:\n    title: 啟用空格鍵+鼠標左鍵拖拽移動\n    description: 按下空格鍵並使用鼠標左鍵拖拽來移動視野\n  mouseLeftMode:\n    title: 左鍵模式切換\n    options:\n      selectAndMove: 選擇並移動\n      draw: 畫圖\n      connectAndCut: 連線與劈砍\n  doubleClickMiddleMouseButton:\n    title: 雙擊中鍵鼠標\n    description: |\n      將滾輪鍵快速按下兩次時執行的操作。默認是重置視野。\n      關閉此選項，可以防止誤觸發。\n    options:\n      adjustCamera: 調整視野\n      none: 無操作\n  textNodeContentLineBreak:\n    title: 文本節點換行方案\n    options:\n      enter: Enter\n      ctrlEnter: ctrl + Enter\n      altEnter: alt + Enter\n      shiftEnter: shift + Enter\n    description: |\n      注意不要和文本節點退出編輯模式的按鍵一樣了，這樣會導致衝突\n      進而導致無法換行\n  textNodeStartEditMode:\n    title: 文本節點進入編輯模式\n    options:\n      enter: Enter\n      ctrlEnter: ctrl + Enter\n      altEnter: alt + Enter\n      shiftEnter: shift + Enter\n      space: 空格鍵\n    description: |\n      實際上按F2鍵也可以進入編輯模式，這裡還可以再加選一種\n  textNodeExitEditMode:\n    title: 文本節點退出編輯模式\n    options:\n      enter: Enter\n      ctrlEnter: ctrl + Enter\n      altEnter: alt + Enter\n      shiftEnter: shift + Enter\n    description: |\n      實際上按Esc鍵也可以退出，這裡還可以再加選一種\n  textNodeSelectAllWhenStartEditByMouseClick:\n    title: 文本節點通過雙擊開始編輯時自動全選內容\n    description: |\n      開啟後，在文本節點開始編輯時，會全選文本內容\n      如果您編輯內容通常是想為了直接更改全部內容，建議開啟此選項\n      如果更可能是想為了追加內容，建議關閉此選項\n  textNodeSelectAllWhenStartEditByKeyboard:\n    title: 文本節點通過鍵盤開始編輯時自動全選內容\n    description: |\n      開啟後，在您按下文本節點編輯模式的按鍵時，會全選文本內容\n  textNodeBackspaceDeleteWhenEmpty:\n    title: 當在編輯模式下文本節點無內容時按Backspace鍵自動刪除整個節點\n    description: |\n      開啟後，在編輯文本節點且內容為空時，按下Backspace鍵會自動刪除整個節點\n  textNodeBigContentThresholdWhenPaste:\n    title: 粘貼時文本節點大內容閾值\n    description: |\n      當直接在舞臺上粘貼文本時，如果文本長度超過此值，將使用手動換行模式\n  textNodePasteSizeAdjustMode:\n    title: 文本節點粘貼大小調整模式\n    description: |\n      控制粘貼文本節點時的大小調整方式\n    options:\n      auto: 總是自動調整\n      manual: 總是手動調整\n      autoByLength: 根據長度自動調整\n  textNodeAutoFormatTreeWhenExitEdit:\n    title: 退出編輯模式時自動格式化樹形結構\n    description: |\n      當文本節點退出編輯模式時，自動對其所在的樹形結構進行格式化佈局\n  treeGenerateCameraBehavior:\n    title: 樹形生長節點後的鏡頭行為選項\n    description: |\n      設置在使用樹形深度生長或廣度生長功能創建新節點後，鏡頭的行為方式\n    options:\n      none: 鏡頭不動\n      moveToNewNode: 鏡頭移動向新創建的節點\n      resetToTree: 重置視野，使視野覆蓋當前樹形結構的外接矩形\n  enableDragAutoAlign:\n    title: 鼠標拖動自動吸附對齊節點\n    description: |\n      開啟後，拖動節點並鬆開時會與其他節點在x軸、y軸方向對齊\n  reverseTreeMoveMode:\n    title: 反轉樹形移動模式\n    description: |\n      開啟後，默認移動為樹形移動（連帶後繼節點），按住Ctrl鍵移動為單一物體移動。關閉時相反。\n  enableDragAlignToGrid:\n    title: 拖動實體時，吸附到網格\n    description: |\n      建議在顯示中開啟橫向和縱向網格線，並關閉自動吸附對齊\n  enableWindowsTouchPad:\n    title: 允許觸摸板雙指移動操作\n    description: |\n      在windows系統中，雙指上下移動會被識別為滾輪事件。\n      雙指左右移動會被識別成鼠標橫向滾輪的滾動事件。\n      如果您是筆記本操作並使用外部鼠標，建議關閉此選項。\n  macTrackpadAndMouseWheelDifference:\n    title: macbook 的觸摸版與鼠標滾輪區分邏輯\n    description:\n      有的macbook鼠標滾輪是整數，觸摸版是小數，有的則相反\n      您需要根據實際情況選擇一下區分邏輯\n      區分方法可點擊7次關於界面的軟件logo進入“測試界面”後，滑動滾輪和觸摸板查看數據反饋\n    options:\n      trackpadIntAndWheelFloat: 觸摸版是整數，鼠標滾動是小數\n      tarckpadFloatAndWheelInt: 觸摸版是小數，鼠標滾動是整數\n  macTrackpadScaleSensitivity:\n    title: macbook 的觸摸板雙指縮放靈敏度\n    description:\n      值越大，縮放的速度越快\n  macEnableControlToCut:\n    title: mac下是否啟用 control鍵按下來開始刀斬\n    description:\n      按下control鍵，在舞臺上移動鼠標，再鬆開control鍵，完成一次刀斬\n  macMouseWheelIsSmoothed:\n    title: macbook 的鼠標滾輪是否平滑\n    description:\n      有的macbook鼠標滾輪是平滑的，有的則是滾動一格觸發一次\n      可能取決於您是否安裝了Mos等鼠標修改軟件\n  mouseSideWheelMode:\n    title: 鼠標側邊滾輪模式\n    description: |\n      側邊滾輪就是大拇指上的滾輪\n    options:\n      zoom: 縮放\n      move: 縱向移動\n      moveX: 橫向移動\n      none: 無操作\n      cameraMoveToMouse: 將視野向鼠標位置移動\n      adjustWindowOpacity: 調整窗口透明度\n      adjustPenStrokeWidth: 調整畫筆粗細\n  mouseWheelMode:\n    title: 鼠標滾輪模式\n    options:\n      zoom: 縮放\n      move: 縱向移動\n      moveX: 橫向移動\n      none: 無操作\n  mouseWheelWithShiftMode:\n    title: 按住 Shift 時，鼠標滾輪模式\n    options:\n      zoom: 縮放\n      move: 縱向移動\n      moveX: 橫向移動\n      none: 無操作\n  mouseWheelWithCtrlMode:\n    title: 按住 Ctrl 時，鼠標滾輪模式\n    description: |\n      提示：這裡的 Ctrl 是 Control\n    options:\n      zoom: 縮放\n      move: 縱向移動\n      moveX: 橫向移動\n      none: 無操作\n  mouseWheelWithAltMode:\n    title: 按住 Alt 時，鼠標滾輪模式\n    description: |\n      此功能於2025年4月10日新增\n      目前發現還存在問題：win系統下滑動滾輪後需要再點擊一次屏幕才能操作舞臺\n      提示：這裡的 Alt 是 Option\n    options:\n      zoom: 縮放\n      move: 縱向移動\n      moveX: 橫向移動\n      none: 無操作\n  rectangleSelectWhenLeft:\n    title: 向左框選的策略\n    description: |\n      選擇鼠標向左框選的策略，包含完全覆蓋框選和碰撞框選\n      完全覆蓋框選是指矩形框選框必須完全覆蓋實體的外接矩形\n      碰撞框選是指矩形框選框只要碰到一點點實體的外接矩形，就能夠選中了\n    options:\n      intersect: 碰撞框選\n      contain: 完全覆蓋框選\n  rectangleSelectWhenRight:\n    title: 向右框選的策略\n    description: |\n      選擇鼠標向右框選的策略\n    options:\n      intersect: 碰撞框選\n      contain: 完全覆蓋框選\n  # 聲音\n  cuttingLineStartSoundFile:\n    title: 斬斷線開始的聲音文件\n    description: |\n      斬斷線右鍵按下開始時播放的聲音文件路徑\n  connectLineStartSoundFile:\n    title: 連接線開始的聲音文件\n    description: |\n      連接線右鍵按下開始時播放的聲音文件路徑\n  connectFindTargetSoundFile:\n    title: 連接線吸附到目標上的聲音文件\n    description: |\n      連接線吸附到目標上時播放的聲音文件路徑\n  cuttingLineReleaseSoundFile:\n    title: 斬斷線釋放的聲音文件\n    description: |\n      釋放的時候就是看到刀光刃特效的時候\n  alignAndAttachSoundFile:\n    title: 對齊的聲音文件\n    description: |\n      鼠標拖動時，對齊節點和時播放的聲音文件路徑\n  uiButtonEnterSoundFile:\n    title: 鼠標進入按鈕區域的聲音\n    description: |\n      鼠標進入按鈕區域的聲音\n  uiButtonClickSoundFile:\n    title: 按鈕點擊時的聲音文件\n    description: |\n      按鈕點擊時播放的聲音文件路徑\n  uiSwitchButtonOnSoundFile:\n    title: 按鈕點擊開關按鈕時打開的聲音\n    description: |\n      按鈕點擊開關按鈕時打開的聲音文件路徑\n  uiSwitchButtonOffSoundFile:\n    title: 按鈕點擊開關按鈕時關閉的聲音\n    description: |\n      按鈕點擊開關按鈕時關閉的聲音文件路徑\n  packEntityToSectionSoundFile:\n    title: 打包為框的聲音文件\n    description: |\n      將選中的實體打包到Section框中時播放的聲音文件路徑\n  treeGenerateDeepSoundFile:\n    title: 樹形深度生長的聲音文件\n    description: |\n      使用Tab鍵進行樹形深度生長時播放的聲音文件路徑\n  treeGenerateBroadSoundFile:\n    title: 樹形廣度生長的聲音文件\n    description: |\n      使用Enter鍵進行樹形廣度生長時播放的聲音文件路徑\n  treeAdjustSoundFile:\n    title: 樹形結構調整的聲音文件\n    description: |\n      格式化樹形結構時播放的聲音文件路徑\n  viewAdjustSoundFile:\n    title: 視圖調整的聲音文件\n    description: |\n      調整視圖時播放的聲音文件路徑\n  entityJumpSoundFile:\n    title: 物體跳躍的聲音文件\n    description: |\n      物體跳躍移動時播放的聲音文件路徑\n  associationAdjustSoundFile:\n    title: 連線調整的聲音文件\n    description: |\n      調整連線、無向邊等關聯元素時播放的聲音文件路徑\n  agreeTerms:\n    title: 同意用戶協議\n    description: |\n      請您仔細閱讀並同意用戶協議\n  allowTelemetry:\n    title: 參與用戶體驗改進計劃\n    description: |\n      如果您啟用此項，我們會收集您的使用數據，幫助我們改進軟件\n      發送的數據僅用於統計，不會包含您的個人隱私信息\n      您的數據會在中國香港的雲服務器上存儲，不會發送到國外\n  aiApiBaseUrl:\n    title: AI API 地址\n    description: |\n      目前僅支持 OpenAI 格式的 API\n  aiApiKey:\n    title: AI API 密鑰\n    description: |\n      密鑰將會明文存儲在本地\n  aiModel:\n    title: AI 模型\n  aiShowTokenCount:\n    title: 顯示 AI 消耗的token數\n    description: |\n      啟用後，在 AI 操作時顯示消耗的token數\n  cacheTextAsBitmap:\n    title: 開啟位圖式渲染文本\n    description: |\n      開啟後，文本節點的位圖緩存將會被保留，以提升渲染速度\n      但可能會造成模糊\n  textCacheSize:\n    title: 文本緩存限制\n    description: |\n      默認情況下會對頻繁渲染的文本進行緩存，以提升渲染速度\n      如果值過大可能會導致內存佔用過高造成卡頓甚至崩潰\n      值為0代表禁用緩存，每次渲染都會重新渲染文本\n      *重新打開文件時生效\n  textScalingBehavior:\n    title: 攝像機縮放時的文本渲染方式\n    options:\n      temp: 每一幀都重新渲染文本，不使用緩存\n      nearestCache: 使用文本大小最接近的緩存，縮放再渲染\n      cacheEveryTick: 每一幀都重新渲染文本，並且緩存\n  textIntegerLocationAndSizeRender:\n    title: 文本整數位置和大小渲染\n    description: |\n      開啟後，一切文字的大小和位置都是整數，以節省渲染性能。\n      但會出現文字抖動現象。建議配合視角縮放速度調整成1一起使用。\n      如果您的電腦使用體驗非常卡頓，尤其是在縮放和移動的情況下，可以開啟此選項。\n  antialiasing:\n    title: 抗鋸齒\n    description: |\n      *重新打開文件時生效\n    options:\n      disabled: 關閉\n      low: 低\n      medium: 中\n      high: 高\n  isStealthModeEnabled:\n    title: 潛行模式\n    description: 開啟後鼠標中心出現遮罩（具體形狀可在設置中修改），可用於記憶化練習等遮蓋場景。\n  stealthModeScopeRadius:\n    title: 潛行模式範圍半徑\n    description: |\n      狙擊鏡的半徑\n  stealthModeReverseMask:\n    title: 反向遮罩\n    description: |\n      開啟後，狙擊鏡中心區域會被遮罩，只顯示周圍區域\n  stealthModeMaskShape:\n    title: 潛行模式遮罩形狀\n    description: |\n      選擇潛行模式下顯示區域的形狀\n    options:\n      circle: 圓形\n      square: 正方形\n      topLeft: 左上角象限\n      smartContext: 智能上下文（框或實體）\n  soundPitchVariationRange:\n    title: 音效音調隨機變化範圍\n    description: 控制音效播放時音調隨機變化的程度。範圍：0-1200音分（1200音分=1個八度，100音分=1個半音）。值越大，音調變化越明顯，越像遊戲一樣有趣。\n  autoImportTxtFileWhenOpenPrg:\n    title: 打開PRG文件時自動導入同名TXT文件\n    description: 啟用後，打開PRG文件時會自動導入同一文件夾下同名TXT文件的內容，並以文本節點形式添加到舞臺左下角。\n\n\neffects:\n  CircleChangeRadiusEffect:\n    title: 圓形變換半徑效果\n    description: |\n      質點被框選後波紋放大\n  CircleFlameEffect:\n    title: 圓形徑向漸變光閃\n    description: |\n      存在於各種特效細節中，預劈砍直線與實體矩形切割時、斬斷連線時的中點閃爍等\n  EntityAlignEffect:\n    title: 實體對齊效果\n    description: |\n      鼠標拖動吸附對齊時產生的高亮虛線\n  EntityCreateDashEffect:\n    title: 實體創建粉塵凝聚效果\n    description: |\n      實體創建時，實體周圍出現粉塵凝聚\n      由於不夠美觀，已經廢棄，不會出現\n  EntityCreateFlashEffect:\n    title: 實體邊框發光效果\n    description: |\n      在ctrl+滾輪轉動實體樹、縮放圖片、創建節點等情況下出現\n      若渲染性能較差，建議關閉此選項\n  EntityCreateLineEffect:\n    title: 實體散發電路板式線條輻射效果\n    description: |\n      已經廢棄，不會出現\n  EntityDashTipEffect:\n    title: 實體提示性的粉塵抖動\n    description: |\n      出現在實體輸入編輯結束或進入時，實體周圍出現抖動的粉塵\n  EntityJumpMoveEffect:\n    title: 實體跳躍移動效果\n    description: |\n      實體跳躍移動時，實體出現一個象徵性的跳躍弧線幻影\n      用來表示偽z軸的跨越層級移動\n  EntityShakeEffect:\n    title: 實體抖動效果\n    description: |\n      像“TickTock”Logo 一樣的抖動特效，用於實體出現警告性質的提示\n  EntityShrinkEffect:\n    title: 實體縮小消失效果\n    description: |\n      使用Delete鍵刪除實體時，實體出現縮小消失的效果\n  ExplodeDashEffect:\n    title: 粉塵爆炸效果\n    description: |\n      用劈砍刪除實體時，出現粉塵爆炸\n  LineCuttingEffect:\n    title: 劈砍時的刀光\n    description: |\n      劈砍時，出現類似水果忍者一樣的刀光\n  LineEffect:\n    title: 直線段淡出效果\n    description: |\n      用於拖拽旋轉子樹時，連線劃過虛影\n  NodeMoveShadowEffect:\n    title: 節點移動時摩擦地面的粒子效果\n    description: |\n      佈局造成的移動可能也會出現一閃而過的粒子\n  PenStrokeDeletedEffect:\n    title: 塗鴉被刪除時的消失特效\n    description: |\n      塗鴉被刪除時，出現消失的特效\n  PointDashEffect:\n    title: 在某點出迸發萬有引力式的粒子效果\n    description: |\n      由於萬有引力影響性能，此特效已關閉，不會出現\n  RectangleLittleNoteEffect:\n    title: 矩形閃爍提示效果\n    description: |\n      在邏輯節點執行時，邏輯節點會閃爍此效果\n  RectangleNoteEffect:\n    title: 矩形存在提示效果\n    description: |\n      高亮提示某個矩形範圍，搜索節點或定位時會高亮提示\n      關閉後會看不到矩形高亮效果\n  RectanglePushInEffect:\n    title: 矩形四頂點劃至另一矩形四頂點的效果\n    description: |\n      用於提示實體的跨越框層移動、方向鍵切換選中\n      目前開發者由於偷懶，此效果引用了四個劈砍線效果。\n      若關閉了劈砍線效果，則此效果會看不見\n  RectangleRenderEffect:\n    title: 矩形位置提示效果\n    description: |\n      用於在吸附拖拽對齊時，顯示實體即將吸附到的目標位置\n  RectangleSplitTwoPartEffect:\n    title: 矩形被切成兩塊的特效\n    description: |\n      僅存在於劈砍特效（也有可能是四塊）\n  TechLineEffect:\n    title: （基礎特效）折線段效果\n    description: |\n      此特效時其他特效的組成部分，若關閉則其他特效可能會受到影響\n  TextRaiseEffectLocated:\n    title: 固定位置的文本節點懸浮上升效果\n    description: |\n      文本節點懸浮上升效果，用於提示重要信息\n  ViewFlashEffect:\n    title: 視野閃爍效果\n    description: |\n      全屏閃白/閃黑等效果\n      光敏癲癇症患者請關閉此選項\n  ViewOutlineFlashEffect:\n    title: 視野輪廓閃爍效果\n    description: |\n      視野輪廓閃爍效果\n  ZapLineEffect:\n    title: （基礎特效）閃電線效果\n    description: |\n      此特效時其他特效的組成部分，若關閉則其他特效可能會受到影響\n  MouseTipFeedbackEffect:\n    title: 鼠標交互提示特效\n    description: |\n      在鼠標進行縮放視野等操作時，鼠標旁邊會出現特效提示，例如一個變大或變小的圓圈\n  RectangleSlideEffect:\n    title: 矩形滑動尾翼特效\n    description: |\n      用於垂直方向鍵盤移動實體\n\nkeyBindsGroup:\n  otherKeys:\n    title: 未分類的快捷鍵\n    description: |\n      未分類的快捷鍵，\n      此處若發現無翻譯的無效快捷鍵項，可能是由於版本升級而未清理舊快捷鍵導致出現的殘留\n      可手動清理 keybinds.json 文件中的對應項\n  basic:\n    title: 基礎快捷鍵\n    description: |\n      基本的快捷鍵，用於常用的功能\n  camera:\n    title: 攝像機控制\n    description: |\n      用於控制攝像機移動、縮放\n  app:\n    title: 應用控制\n    description: |\n      用於控制應用的一些功能\n  ui:\n    title: UI控制\n    description: |\n      用於控制UI的一些功能\n  draw:\n    title: 塗鴉\n    description: |\n      塗鴉相關功能\n  select:\n    title: 切換選擇\n    description: |\n      使用鍵盤來切換選中的實體\n  moveEntity:\n    title: 移動實體\n    description: |\n      用於移動實體的一些功能\n  generateTextNodeInTree:\n    title: 生長節點\n    description: |\n      通過鍵盤生長節點（Xmind用戶習慣）\n  generateTextNodeRoundedSelectedNode:\n    title: 在選中節點周圍生成節點\n    description: |\n      按下後，在選中節點周圍生成節點\n  aboutTextNode:\n    title: 關於文本節點\n    description: |\n      和文本節點相關的一切快捷鍵，分割、合併、創建等\n  section:\n    title: Section框\n    description: |\n      Section框相關功能\n  leftMouseModeCheckout:\n    title: 左鍵模式切換\n    description: |\n      關於左鍵模式的切換\n  edge:\n    title: 連線相關\n    description: |\n      關於連線的一些功能\n  expandSelect:\n    title: 擴散選擇\n    description: |\n      擴散選擇節點相關的快捷鍵\n  themes:\n    title: 主題切換\n    description: |\n      切換主題相關的快捷鍵\n  align:\n    title: 對齊相關\n    description: |\n      關於實體對齊的一些功能\n  image:\n    title: 圖片相關\n    description: |\n      關於圖片的一些功能\n  node:\n    title: 節點相關\n    description: |\n      關於節點的一些功能，如嫁接、摘除等\n\ncontrolSettingsGroup:\n  mouse:\n    title: 鼠標設置\n  touchpad:\n    title: 觸摸板設置\n  textNode:\n    title: 文本節點設置\n  gamepad:\n    title: 遊戲手柄設置\n\nvisualSettingsGroup:\n  basic:\n    title: 基本設置\n  background:\n    title: 背景設置\n\nkeyBinds:\n  title: 快捷鍵綁定\n  none: 未綁定快捷鍵\n  test:\n    title: 測試\n    description: |\n      僅用於測試快捷鍵自定義綁定功能功能\n  reload:\n    title: 重載應用\n    description: |\n      重載應用，重新加載當前工程文件\n      等同於瀏覽器刷新網頁\n      這個功能很危險！會導致未保存的進度丟失！\n  saveFile:\n    title: 保存文件\n    description: |\n      保存當前工程文件，若當前文件是草稿則另存為\n  newDraft:\n    title: 新建草稿\n    description: |\n      新建一個草稿文件，並切換到該文件\n      若當前文件未保存則無法切換\n  newFileAtCurrentProjectDir:\n    title: 在當前項目目錄下新建文件\n    description: |\n      在當前項目目錄下新建一個工程文件，並切換到該文件（用於快速創建文件）\n      若當前文件為草稿狀態存則無法創建\n  openFile:\n    title: 打開文件\n    description: |\n      選擇一個曾經保存的json/prg文件並打開\n  undo:\n    title: 撤銷\n    description: 撤銷上一次操作\n  redo:\n    title: 取消撤銷\n    description: 取消上一次撤銷操作\n  resetView:\n    title: 重置視野\n    description: |\n      如果沒有選擇任何內容，則根據全部內容重置視野；\n      如果有選擇內容，則根據選中內容重置視野\n  restoreCameraState:\n    title: 恢復視野狀態\n    description: |\n      按下後，恢復到之前按下F鍵時記錄的攝像機位置和縮放大小\n  resetCameraScale:\n    title: 重置縮放\n    description: 將視野縮放重置為標準大小\n  folderSection:\n    title: 摺疊或展開Section框\n    description: 按下後選中的Section框會切換摺疊或展開狀態\n  toggleSectionLock:\n    title: 鎖定/解鎖Section框\n    description: 切換選中Section框的鎖定狀態，鎖定後內部物體不可移動\n  reverseEdges:\n    title: 反轉連線的方向\n    description: |\n      按下後，選中的連線的方向會變成相反方向\n      例如，原先是 A -> B，按下後變成 B -> A\n      此功能的意義在於快速創建一個節點連向多個節點的情況\n      因為目前的連線只能一次性做到多連一。\n  reverseSelectedNodeEdge:\n    title: 反轉選中的節點的所有連線的方向\n    description: |\n      按下後，所有框選選中的節點中\n      每個節點的所有連線都會反轉方向\n      進而實現更快捷的一連多\n  createUndirectedEdgeFromEntities:\n    title: 選中的實體之間創建無向連線\n    description: |\n      按下後，選中的兩個或者多個實體之間會創建一條無向連線\n  packEntityToSection:\n    title: 將選中的實體打包到Section框中\n    description: |\n      按下後，選中的實體會自動包裹到新Section框中\n  unpackEntityFromSection:\n    title: Section框拆包，轉換為文本節點\n    description: |\n      按下後，選中的Section框中的實體會被拆包，自身轉換成一個文本節點\n      內部的實體將會掉落在外面\n  textNodeToSection:\n    title: 將選中的文本節點轉換成Section框\n    description: |\n      按下後，選中的文本節點會被轉換成Section框\n      可以用於section框的快速創建\n  deleteSelectedStageObjects:\n    title: 刪除選中的舞臺物體\n    description: |\n      按下後，選中的舞臺物體會被刪除\n      舞臺物體包括實體（節點和Section等獨立存在的東西）和關係（節點之間的連線）\n      默認是delete鍵，您可以改成backspace鍵\n  editEntityDetails:\n    title: 編輯選中的實體的詳細信息\n    description: |\n      按下後，選中的實體的詳細信息會被打開編輯\n      只有選中的物體數量為1時才有效\n  openColorPanel:\n    title: 打開顏色面板快捷鍵\n    description: |\n      按下後，打開顏色面板，可以用於快速切換節點顏色\n  switchDebugShow:\n    title: 切換調試信息顯示\n    description: |\n      按下後，切換調試信息顯示\n      調試信息顯示在屏幕左上角，通常為開發者使用\n      開啟後，屏幕左上角將會顯示調試信息。\n      若您遇到bug截圖反饋時，建議開啟此選項。\n  generateNodeTreeWithDeepMode:\n    title: 生長子級節點\n    description: |\n      按下後，瞬間生長一個節點並放置在當前選中節點的右側\n      如果對節點設置了生長方向，則會按照生長方向生長子節點（若無設置則默認向右生長）\n      同時自動排版整個節點樹的結構，確保其是一個向右的樹型結構\n      使用此功能前先確保已選中一個節點、且該節點所在結構為樹形結構\n  generateNodeTreeWithBroadMode:\n    title: 生長同級節點\n    description: |\n      按下後，瞬間生長一個同級節點並放置在當前選中節點的下方\n      同時自動排版整個節點樹的結構，確保其是一個向下的樹型結構\n      使用此功能前先確保已選中一個節點、且該節點存在父級節點\n  generateNodeGraph:\n    title: 生長自由節點\n    description: |\n      按下後，出現一個虛擬生長位置\n      此時，按下“I J K L”鍵自由調整生長位置\n      再次按下該鍵，退出自由生長模式\n      使用此功能前先確保已選中一個節點\n  createConnectPointWhenDragConnecting:\n    title: 拖拽連線時，按下此鍵，創建質點中轉\n    description: |\n      當拖拽連線時，按下此鍵，創建質點中轉\n  treeGraphAdjust:\n    title: 調整當前節點所在樹形結構的樹形排布\n    description: |\n      主要用於關閉了鍵盤生長節點時觸發的樹形排布調整時使用\n      可以通過此快捷鍵手動觸發佈局調整\n  treeGraphAdjustSelectedAsRoot:\n    title: 以選中節點為根節點格式化樹形結構\n    description: |\n      以當前選中的節點作為根節點進行樹形結構格式化\n      不會查找整個樹的根節點，只格式化以選中節點為根的子樹\n  dagGraphAdjust:\n    title: 調整當前選中節點群的DAG佈局\n    description: |\n      對選中的有向無環圖（DAG）結構進行自動佈局調整\n      僅當選中節點構成DAG結構時可用\n  gravityLayout:\n    title: 持續按下引力式佈局\n    description: |\n      選中一堆連線的節點群后，持續按下該鍵可以進行引力式佈局。\n      注意此鍵若想自定義，只能設置為單個非修飾鍵，否則可能有未知bug。zh\n  setNodeTreeDirectionLeft:\n    title: 設置當前節點的樹形生長方向為向左\n    description: |\n      需要選中節點後按下此快捷鍵\n      設置後，在此節點上按Tab生長時，會向左生長子節點\n  setNodeTreeDirectionRight:\n    title: 設置當前節點的樹形生長方向為向右\n    description: |\n      需要選中節點後按下此快捷鍵\n      設置後，在此節點上按Tab生長時，會向右生長子節點\n  setNodeTreeDirectionUp:\n    title: 設置當前節點的樹形生長方向為向上\n    description: |\n      需要選中節點後按下此快捷鍵\n      設置後，在此節點上按Tab生長時，會向上生長子節點\n  setNodeTreeDirectionDown:\n    title: 設置當前節點的樹形生長方向為向下\n    description: |\n      需要選中節點後按下此快捷鍵\n      設置後，在此節點上按Tab生長時，會向下生長子節點\n\n  masterBrakeCheckout:\n    title: 手剎：開啟/關閉通過按鍵控制攝像機移動\n    description: |\n      按下後會切換是否允許 “W S A D”鍵控制攝像機移動\n      可以用於臨時禁止攝像機移動，在輸入秘籍鍵或含有WSAD的快捷鍵時防止視野移動\n  masterBrakeControl:\n    title: 腳剎：停止攝像機飄移\n    description: |\n      按下後，停止攝像機飄移，並將速度置為0\n  selectAll:\n    title: 全選\n    description: 按下後，所有節點和連線都會被選中\n  selectAtCrosshair:\n    title: 選擇十字準心對準的節點\n    description: |\n      選擇屏幕中心十字準心指向的節點\n      如果該位置有節點，則選中它（取消其他選擇）\n  addSelectAtCrosshair:\n    title: 添加選擇十字準心對準的節點\n    description: |\n      將屏幕中心十字準心指向的節點添加到當前選擇中\n      如果該節點已被選中，則取消選中\n  createTextNodeFromCameraLocation:\n    title: 在視野中心位置創建文本節點\n    description: |\n      按下後，在當前視野中心的位置創建一個文本節點\n      等同於鼠標雙擊創建節點的功能\n  createTextNodeFromMouseLocation:\n    title: 在鼠標位置創建文本節點\n    description: |\n      按下後，在鼠標懸浮位置創建一個文本節點\n      等同於鼠標單擊創建節點的功能\n  createTextNodeFromSelectedTop:\n    title: 在當前選中的節點正上方創建文本節點\n    description: |\n      按下後，在當前選中的節點正上方創建一個文本節點\n  createTextNodeFromSelectedDown:\n    title: 在當前選中的節點正下方創建文本節點\n    description: |\n      按下後，在當前選中的節點正下方創建一個文本節點\n  createTextNodeFromSelectedLeft:\n    title: 在當前選中的節點左側創建文本節點\n    description: |\n      按下後，在當前選中的節點左側創建一個文本節點\n  createTextNodeFromSelectedRight:\n    title: 在當前選中的節點右側創建文本節點\n    description: |\n      按下後，在當前選中的節點右側創建一個文本節點\n  selectUp:\n    title: 選中上方節點\n    description: 按下後，選中上方節點\n  selectDown:\n    title: 選中下方節點\n    description: 按下後，選中下方節點\n  selectLeft:\n    title: 選中左側節點\n    description: 按下後，選中左側節點\n  selectRight:\n    title: 選中右側節點\n    description: 按下後，選中右側節點\n  selectAdditionalUp:\n    title: 附加選中上方節點\n    description: 按下後，附加選中上方節點\n  selectAdditionalDown:\n    title: 附加選中下方節點\n    description: 按下後，附加選中下方節點\n  selectAdditionalLeft:\n    title: 附加選中左側節點\n    description: 按下後，附加選中左側節點\n  selectAdditionalRight:\n    title: 附加選中右側節點\n    description: 按下後，附加選中右側節點\n  moveUpSelectedEntities:\n    title: 向上移動所有選中的實體\n    description: |\n      按下後，所有選中的實體會向上移動一個固定距離\n  moveDownSelectedEntities:\n    title: 向下移動所有選中的實體\n    description: |\n      按下後，所有選中的實體會向下移動一個固定距離\n  moveLeftSelectedEntities:\n    title: 向左移動所有選中的實體\n    description: |\n      按下後，所有選中的實體會向左移動一個固定距離\n  moveRightSelectedEntities:\n    title: 向右移動所有選中的實體\n    description: |\n      按下後，所有選中的實體會向右移動一個固定距離\n  jumpMoveUpSelectedEntities:\n    title: 跳躍向上移動所有選中的實體\n    description: |\n      按下後，所有選中的實體會跳躍向上移動一個固定距離，能夠跳入或者跳出Section框\n  jumpMoveDownSelectedEntities:\n    title: 跳躍向下移動所有選中的實體\n    description: |\n      按下後，所有選中的實體會跳躍向下移動一個固定距離，能夠跳入或者跳出Section框\n  jumpMoveLeftSelectedEntities:\n    title: 跳躍向左移動所有選中的實體\n    description: |\n      按下後，所有選中的實體會跳躍向左移動一個固定距離，能夠跳入或者跳出Section框\n  jumpMoveRightSelectedEntities:\n    title: 跳躍向右移動所有選中的實體\n    description: |\n      按下後，所有選中的實體會跳躍向右移動一個固定距離，能夠跳入或者跳出Section框\n  CameraScaleZoomIn:\n    title: 視野放大\n    description: 按下後，視野放大\n  CameraScaleZoomOut:\n    title: 視野縮小\n    description: 按下後，視野縮小\n  CameraPageMoveUp:\n    title: 視野向上翻頁式移動\n  CameraPageMoveDown:\n    title: 視野向下翻頁式移動\n  CameraPageMoveLeft:\n    title: 視野向左翻頁式移動\n  CameraPageMoveRight:\n    title: 視野向右翻頁式移動\n  exitSoftware:\n    title: 退出軟件\n    description: 按下後，退出軟件\n  checkoutProtectPrivacy:\n    title: 進入或退出隱私保護模式\n    description: |\n      按下後，舞臺上的全部文字將會被加密，無法被其他人看到\n      按下後，舞臺上的全部文字將會解密，其他人可以看到\n      可以用於截圖反饋問題、突然有人看你的屏幕時使用並且你的內容是感情問題（？）時使用\n  openTextNodeByContentExternal:\n    title: 以網頁瀏覽器或本地文件形式打開選中節點的內容\n    description: |\n      按下後，舞臺上所有選中的文本節點都會被以默認方式或瀏覽器方式打開。\n      例如一個節點內容為 \"D:/Desktop/a.txt\"，選中此節點按下快捷鍵之後，能以系統默認方式打開此文件\n      如果節點內容為網頁地址 \"https://project-graph.top\"，會以系統默認瀏覽器打開網頁內容\n  checkoutClassroomMode:\n    title: 進入或退出專注模式\n    description: |\n      按下後，進入專注模式，所有UI都會隱藏，頂部按鈕會透明化處理\n      再按一次恢復\n  checkoutWindowOpacityMode:\n    title: 切換窗口透明度模式\n    description: |\n      按下後，窗口進入完全透明模式，再按一次將進入完全不透明模式\n      注意要配合舞臺顏色風格進行設置。例如：黑色模式下文字為白色，論文白模式下文字為黑色。\n      如果窗口下層內容為白色背景，建議切換舞臺到論文白模式。\n  windowOpacityAlphaIncrease:\n    title: 窗口不透明度增加\n    description: |\n      按下後，窗口不透明度（alpha）值增加0.2，往不透明的方向改變，最大值為1\n      當不能再增加時，會有窗口邊緣閃爍提示\n  windowOpacityAlphaDecrease:\n    title: 窗口不透明度減小\n    description: |\n      按下後，窗口不透明度（alpha）值減小0.2，往透明的方向改變，最小值為0\n      如果您的鍵盤沒有小鍵盤的純減號鍵，可以改成橫排數字0右側的減號與下劃線公用鍵\n  searchText:\n    title: 搜索文本\n    description: |\n      按下後，打開搜索框，可以輸入搜索內容\n      搜索框支持部分匹配，例如輸入 \"a\" 能搜索到 \"apple\" 等\n  clickAppMenuSettingsButton:\n    title: 打開設置頁面\n    description: |\n      按下此鍵可代替鼠標點擊菜單欄裡的設置界面按鈕\n  clickTagPanelButton:\n    title: 打開/關閉標籤面板\n    description: |\n      按下此鍵可代替鼠標點擊頁面上的標籤面板展開關閉按鈕\n  clickAppMenuRecentFileButton:\n    title: 打開最近打開文件列表\n    description: |\n      按下此鍵可代替鼠標點擊菜單欄裡的最近打開文件列表按鈕\n  clickStartFilePanelButton:\n    title: 打開/關閉啟動文件列表\n    description: |\n      按下此鍵可代替鼠標點擊菜單欄裡的啟動文件列表展開關閉按鈕\n  copy:\n    title: 複製\n    description: 按下後，複製選中的內容\n  paste:\n    title: 粘貼\n    description: 按下後，粘貼剪貼板內容\n  pasteWithOriginLocation:\n    title: 原位粘貼\n    description: 按下後，粘貼的內容會與原位置重疊\n  selectEntityByPenStroke:\n    title: 塗鴉與實體的擴散選擇\n    description: |\n      選中一個塗鴉或者實體後，按下此鍵，會擴散選擇該實體周圍的實體\n      如果當前選擇的是塗鴉，則擴散選擇塗鴉觸碰到的實體\n      如果當前選擇的是實體，則擴散選觸碰到的所有塗鴉\n      多次按下後可以多次交替擴散\n  expandSelectEntity:\n    title: 擴散選擇節點\n    description: |\n      按下後，實體的選擇狀態會轉移到子級節點上\n  expandSelectEntityReversed:\n    title: 反向擴散選擇節點\n    description: |\n      按下後，實體的選擇狀態會轉移到父級節點上\n  expandSelectEntityKeepLastSelected:\n    title: 擴散選擇節點（保留當前節點的選擇狀態）\n    description: |\n      按下後，實體的選擇狀態會轉移到子級節點上，同時保留當前節點的選擇狀態\n  expandSelectEntityReversedKeepLastSelected:\n    title: 反向擴散選擇節點（保留當前節點的選擇狀態）\n    description: |\n      按下後，實體的選擇狀態會轉移到父級節點上，同時保留當前節點的選擇狀態\n  checkoutLeftMouseToSelectAndMove:\n    title: 設置左鍵為“選中/移動”模式\n    description: |\n      也就是鼠標左鍵切換為正常模式\n  checkoutLeftMouseToDrawing:\n    title: 設置左鍵為“塗鴉”模式\n    description: |\n      也就是鼠標左鍵切換為塗鴉模式，在工具欄中有對應按鈕\n  checkoutLeftMouseToConnectAndCutting:\n    title: 設置左鍵為“連線/斬斷”模式\n    description: |\n      也就是鼠標左鍵切換為連線/斬斷 模式，在工具欄中有對應按鈕\n  checkoutLeftMouseToConnectAndCuttingOnlyPressed:\n    title: 設置左鍵為“連線/斬斷”模式（僅按下時）\n    description: |\n      鬆開時切換回默認的鼠標模式\n  penStrokeWidthIncrease:\n    title: 塗鴉筆畫變粗\n    description: 按下後，筆畫變粗\n  penStrokeWidthDecrease:\n    title: 塗鴉筆畫變細\n    description: 按下後，筆畫變細\n  screenFlashEffect:\n    title: 屏幕閃黑特效\n    description: 類似於秘籍鍵中的hello world，測試出現黑屏的效果時則證明秘籍鍵系統正常運行了\n  alignNodesToInteger:\n    title: 將所有可連接節點的座標位置對齊到整數\n    description: 可以大幅度減小json文件的體積\n  toggleCheckmarkOnTextNodes:\n    title: 將選中的文本節點都打上對勾✅，並標為綠色\n    description: 僅對文本節點生效，選中後再輸入一次可以取消對勾\n  toggleCheckErrorOnTextNodes:\n    title: 將選中的文本節點都打上錯誤❌，並標為紅色\n    description: 僅對文本節點生效，選中後再輸入一次可以取消錯誤標記\n  switchToDarkTheme:\n    title: 切換成黑色主題\n    description: 切換後需要在舞臺上劃一刀才生生效\n  switchToLightTheme:\n    title: 切換成白色主題\n    description: 切換後需要在舞臺上劃一刀才生生效\n  switchToParkTheme:\n    title: 切換成公園主題\n    description: 切換後需要在舞臺上劃一刀才生生效\n  switchToMacaronTheme:\n    title: 切換成馬卡龍主題\n    description: 切換後需要在舞臺上劃一刀才生生效\n  switchToMorandiTheme:\n    title: 切換成莫蘭迪主題\n    description: 切換後需要在舞臺上劃一刀才生生效\n  increasePenAlpha:\n    title: 增加筆刷不透明度通道值\n    description: \"\"\n  decreasePenAlpha:\n    title: 減少筆刷不透明度通道值\n    description: \"\"\n  alignTop:\n    title: 上對齊\n    description: 小鍵盤的向上\n  alignBottom:\n    title: 下對齊\n    description: 小鍵盤的向下\n  alignLeft:\n    title: 左對齊\n    description: 小鍵盤的向左\n  alignRight:\n    title: 右對齊\n    description: 小鍵盤的向右\n  alignHorizontalSpaceBetween:\n    title: 相等間距水平對齊\n    description: 小鍵盤的左右左右，晃一晃就等間距了\n  alignVerticalSpaceBetween:\n    title: 相等間距垂直對齊\n    description: 小鍵盤的上下上下，晃一晃就等間距了\n  alignCenterHorizontal:\n    title: 中心水平對齊\n    description: 小鍵盤：先中，然後左右\n  alignCenterVertical:\n    title: 中心垂直對齊\n    description: 小鍵盤：先中，然後上下\n  alignLeftToRightNoSpace:\n    title: 向右緊密堆積一排\n    description: 小鍵盤橫著從左到右穿一串\n  alignTopToBottomNoSpace:\n    title: 向下緊密堆積一列\n    description: 小鍵盤豎著從上到下穿一串\n  layoutToSquare:\n    title: 鬆散方陣排列\n  layoutToTightSquare:\n    title: 緊密堆積\n  layoutToTightSquareDeep:\n    title: 遞歸緊密堆積\n  adjustSelectedTextNodeWidthMin:\n    title: 統一寬度為最小值\n    description: 僅對文本節點生效，將所有選中節點的寬度統一為最小值\n  adjustSelectedTextNodeWidthMax:\n    title: 統一寬度為最大值\n    description: 僅對文本節點生效，將所有選中節點的寬度統一為最大值\n  adjustSelectedTextNodeWidthAverage:\n    title: 統一寬度為平均值\n    description: 僅對文本節點生效，將所有選中節點的寬度統一為平均值\n  connectAllSelectedEntities:\n    title: 將所有選中實體進行全連接\n    description: 用於特殊教學場景或圖論教學，“- -”開頭表示連線相關\n  connectLeftToRight:\n    title: 將所有選中實體按照從左到右的擺放位置進行連接\n    description: \"\"\n  connectTopToBottom:\n    title: 將所有選中實體按照從上到下的擺放位置進行連接\n    description: \"\"\n  selectAllEdges:\n    title: 選中所有連線\n    description: 僅選擇所有視野內的連線\n  colorSelectedRed:\n    title: 將所有選中物體染色為純紅色\n    description: 具體為：(239, 68, 68)，僅作快速標註用\n  increaseBrightness:\n    title: 將所選實體的顏色亮度增加\n    description: 不能對沒有上色的或者透明的實體使用，b是brightness，句號鍵也是>鍵，可以看成往右走，數值增大\n  decreaseBrightness:\n    title: 將所選實體的顏色亮度減少\n    description: 不能對沒有上色的或者透明的實體使用，b是brightness，逗號鍵也是<鍵，可以看成往左走，數值減小\n  gradientColor:\n    title: 將所選實體的顏色漸變\n    description: 後續打算做成更改色相環，目前還不完善\n  changeColorHueUp:\n    title: 將所選實體的顏色色相增加\n    description: 不能對沒有上色的或者透明的實體使用\n  changeColorHueDown:\n    title: 將所選實體的顏色色相減少\n    description: 不能對沒有上色的或者透明的實體使用\n  changeColorHueMajorUp:\n    title: 將所選實體的顏色色相大幅增加\n    description: 不能對沒有上色的或者透明的實體使用\n  changeColorHueMajorDown:\n    title: 將所選實體的顏色色相大幅減少\n    description: 不能對沒有上色的或者透明的實體使用\n  graftNodeToTree:\n    title: 嫁接節點到樹\n    description: |\n      將選中的節點嫁接到碰撞到的連線上，保持原連線方向\n  removeNodeFromTree:\n    title: 從樹中摘除節點\n    description: |\n      將選中的節點從樹中摘出來，並重新連接前後節點\n\n  toggleTextNodeSizeMode:\n    title: 將選中的文本節點，切換大小調整模式\n    description: 僅對文本節點生效，auto模式：輸入文字不能自動換行，manual模式：寬度為框的寬度，寬度超出自動換行\n  decreaseFontSize:\n    title: 減小選中的文本節點字體大小\n    description: 僅對文本節點生效，按下Ctrl+-減小選中的文本節點字體大小\n  increaseFontSize:\n    title: 增大選中的文本節點字體大小\n    description: 僅對文本節點生效，按下Ctrl+=增大選中的文本節點字體大小\n  splitTextNodes:\n    title: 將選中的文本節點，剋(kēi)成小塊\n    description: 僅對文本節點生效，根據標點符號，空格、換行符等進行分割，將其分割成小塊\n  mergeTextNodes:\n    title: 將選中的多個文本節點，挼ruá (合併)成一個文本節點，顏色也會取平均值\n    description: 僅對文本節點生效，順序按從上到下排列，節點的位置按節點矩形左上角頂點座標為準\n  swapTextAndDetails:\n    title: 詳略交換\n    description: 將所有選中的文本節點的詳細信息和實際內容進行交換，連按5次e，主要用於直接粘貼進來的文本內容想寫入詳細信息\n  reverseImageColors:\n    title: 反轉圖片顏色\n    description: 反轉選中圖片的顏色（將白色背景變為黑色，反之亦然）\n  treeReverseY:\n    title: 縱向反轉樹形結構\n    description: 選中樹形結構的根節點，將其縱向反轉\n  treeReverseX:\n    title: 橫向反轉樹形結構\n    description: 選中樹形結構的根節點，將其橫向反轉\n  textNodeTreeToSection:\n    title: 將文本節點樹轉換為框嵌套結構\n    description: 將選中的文本節點樹結構轉換為框嵌套結構\n\n  switchActiveProject:\n    title: 切換當前項目\n    description: 按下後，切換到下一個項目\n  switchActiveProjectReversed:\n    title: 切換當前項目（反序）\n    description: 按下後，切換到上一個項目\n  closeCurrentProjectTab:\n    title: 關閉當前項目標籤頁\n    description: 關閉當前激活的項目標籤頁。若有未保存更改會提示保存。默認關閉，可在設置中啟用。\n  closeAllSubWindows:\n    title: 關閉所有子窗口\n    description: 關閉當前所有打開的子窗口（如設置、AI、顏色面板等），並將焦點恢復至主畫布。\n  toggleFullscreen:\n    title: 切換全屏\n    description: 在全屏和窗口模式之間切換應用窗口。\n  setWindowToMiniSize:\n    title: 設置窗口為迷你大小\n    description: 將窗口大小設置為設置中配置的迷你窗口寬度和高度。\n\nsounds:\n  soundEnabled: 音效開關\n\n"
  },
  {
    "path": "app/src/locales/zh_TWC.yml",
    "content": "welcome:\n  slogan: 基於圖論的思維框架圖繪製軟體\n  slogans:\n    - 基於圖論的思維框架圖繪製軟體\n    - 在無限大的平面上發揮你的設計\n    - 讓思維在節點與連線間自由流動\n    - 用圖論思想建構你的知識網路\n    - 從混沌到秩序，從節點到體系\n    - 視覺化思維，拓撲化管理\n    - 無限畫布，無限可能\n    - 連接點滴想法，繪製宏觀藍圖\n    - 不只是心智圖，更是思維框架\n    - 圖論驅動的視覺思考工具\n  newDraft: 新增草稿\n  openFile: 開啟檔案\n  openRecentFiles: 開啟最近\n  newUserGuide: 功能說明書\n  settings: 設定\n  about: 關於\n  website: 官網\n  title: Project Graph\n  language: 語言\n  next: 下一步\n  github: GitHub\n  bilibili: Bilibili\n  qq: QQ群\n  subtitle: 基於圖論的無限畫布心智圖軟體\nglobalMenu:\n  file:\n    title: 文件\n    new: 新增臨時草稿\n    open: 打開\n    recentFiles: 最近開啟的檔案\n    clear: 清空\n    save: 儲存\n    saveAs: 另存新檔\n    import: 匯入\n    importFromFolder: 根據資料夾生成框框巢狀圖\n    importTreeFromFolder: 根據資料夾生成樹狀圖\n    generateKeyboardLayout: 根據目前快速鍵設定產生鍵盤佈局圖\n    export: 匯出\n    exportAsSVG: 匯出為 SVG\n    exportAll: 匯出全部內容\n    plainTextType:\n      exportAllNodeGraph: 匯出 全部的 網狀關係\n      exportSelectedNodeGraph: 匯出 選取的 網狀關係\n      exportSelectedNodeTree: 匯出 選取的 樹狀關係（純文字縮排）\n      exportSelectedNodeTreeMarkdown: 匯出選取的樹狀關係（Markdown 格式）\n      exportSelectedNodeGraphMermaid: 根據 選中的 巢狀網狀關係（Mermaid格式）\n    exportSelected: 匯出選取內容\n    plainText: 純文字\n    exportSuccess: 匯出成功\n    attachments: 附件管理器\n    tags: 標籤管理器\n  view:\n    title: 視野\n    resetViewAll: 根據全部內容重設視野\n    resetViewSelected: 根據選取內容重設視野\n    resetViewScale: 重置視野縮放至標準大小\n    moveViewToOrigin: 移動視野到座標軸原點\n  actions:\n    title: 操作\n    search: 搜尋\n    refresh: 重新整理\n    undo: 撤销\n    redo: 重做\n    releaseKeys: 放開按鍵\n    confirmClearStage: 確認清空舞台？\n    irreversible: 此操作無法撤銷！\n    clearStage: 清空舞台\n    cancel: 取消\n    confirm: 確定\n    generating: 生成中\n    success: 成功\n    failed: 失敗\n    generate:\n      generatedIn: 生成耗時\n      title: 生成\n      generateNodeTreeByText: 根據純文字生成樹狀結構\n      generateNodeTreeByTextDescription: 請輸入樹狀結構文字，每行代表一個節點，縮排表示層級關係\n      generateNodeTreeByTextPlaceholder: 輸入樹狀結構文本...\n      generateNodeTreeByMarkdown: 根據 Markdown 文本生成樹狀結構\n      generateNodeTreeByMarkdownDescription: '# 標題一：軟體開發文件範例\n\n\n        ## 標題二：專案環境設定\n\n        在開始開發之前，請確保您的電腦已安裝必要的軟體與工具。\n\n\n        ### 標題三：安裝步驟\n\n        1. 下載並安裝最新版本的 Node.js。\n\n        2. 使用 `npm install` 指令安裝專案所需的相依套件。\n\n\n        ## 標題二：程式碼規範\n\n        請遵循以下字串處理的規範：\n\n        *   使用樣板字面值（Template Literals）進行字串拼接。\n\n        *   確保所有變數名稱皆具備語意化。\n\n\n        ### 標題三：範例程式碼\n\n        ```javascript\n\n        const message = \"歡迎來到專案開發中心\";\n\n        console.log(message);\n\n        ```\n\n\n        #### 標題四：聯絡資訊\n\n        若有任何問題，請查閱專案文件或聯繫系統管理員。'\n      generateNodeTreeByMarkdownPlaceholder: '將以下簡體中文轉換成繁體中文（台灣用語）。\n\n        規則：\n\n        1. 只轉換簡體中文為繁體中文\n\n        2. 英文、數字、符號、程式碼保持不變\n\n        3. 不要添加任何解釋，只輸出轉換後的結果\n\n        4. 保持原有的格式和換行\n\n\n        輸入markdown格式文本...'\n      indention: 縮排字元數\n      generateNodeGraphByText: 根據純文字生成網狀結構\n      generateNodeGraphByTextDescription: 請輸入網狀結構文字，每行代表一個關係，每一行的格式為 `XXX --> XXX`\n      generateNodeGraphByTextPlaceholder: '張三 -喜歡-> 李四\n\n        李四 -討厭-> 王五\n\n        王五 -欣賞-> 張三\n\n        A --> B\n\n        B --> C\n\n        C --> D'\n      generateNodeMermaidByText: 根據mermaid文本生成框巢狀網狀結構\n      generateNodeMermaidByTextDescription: 支援 graph TD 格式的 mermaid 文字，可自動辨識 Section\n        並建立巢狀結構\n      generateNodeMermaidByTextPlaceholder: \"graph TD;\\n  A[Section A] --> B[Section\\\n        \\ B];\\n  A --> C[普通節點];\\n  B --> D[另一個節點];\\n;\"\n  settings:\n    title: 設定\n    appearance: 個人化\n  ai:\n    title: AI\n    openAIPanel: 打開 AI 面板\n  window:\n    title: 視圖\n    fullscreen: 全螢幕\n    classroomMode: 專注模式\n    classroomModeHint: 左上角選單按鈕僅僅是透明了，並沒有消失\n  about:\n    title: 關於\n    guide: 功能說明書\n  unstable:\n    title: 測試版\n    notRelease: 此版本並非正式版\n    mayHaveBugs: 可能包含 Bug 和未完善的功能\n    reportBug: '回報 Bug: 在 Issue #487 中留言'\n    test: 測試功能\ncontextMenu:\n  createTextNode: 建立文字節點\n  createConnectPoint: 建立質點\n  packToSection: 打包為框\n  createMTUEdgeLine: 建立無向邊\n  createMTUEdgeConvex: 建立凸包\n  convertToSection: 轉換為框\n  toggleSectionCollapse: 切換摺疊狀態\n  changeColor: 更改顏色\n  resetColor: 重設\n  switchMTUEdgeArrow: 切換箭頭形態\n  mtuEdgeArrowOuter: 箭頭外向\n  mtuEdgeArrowInner: 箭頭內向\n  mtuEdgeArrowNone: 關閉箭頭顯示\n  switchMTUEdgeRenderType: 切換渲染形態\n  convertToDirectedEdge: 轉換為有向邊\nsettings:\n  title: 設定\n  categories:\n    ai:\n      title: AI\n      api: API\n    automation:\n      title: 自動化\n      autoNamer: 自動命名\n      autoSave: 自動儲存\n      autoBackup: 自動備份\n      autoImport: 自動匯入\n    control:\n      title: 控制\n      mouse: 滑鼠\n      touchpad: 觸控板\n      cameraMove: 視野移動\n      cameraZoom: 視野縮放\n      objectSelect: 選\n      textNode: 文字節點\n      section: 框\n      edge: 連線\n      generateNode: 透過鍵盤生長節點\n      gamepad: 遊戲手把\n    visual:\n      title: 視覺\n      basic: 基礎\n      background: 背景\n      node: 節點樣式\n      edge: 連線樣式\n      section: “框”的樣式\n      entityDetails: 實體詳情\n      debug: 偵錯\n      miniWindow: 迷你視窗\n      experimental: 實驗性功能\n    performance:\n      title: 效能\n      memory: 記憶體\n      cpu: CPU\n      render: 渲染\n      experimental: 開發中的功能\n  language:\n    title: 語言\n    options:\n      en: English\n      zh_CN: 簡體中文\n      zh_TW: 繁體中文\n      zh_TWC: 接地氣繁體中文\n      id: 印度尼西亞語\n  themeMode:\n    title: 主題模式\n    options:\n      light: 白天模式\n      dark: 黑夜模式\n  lightTheme:\n    title: 白天主題\n  darkTheme:\n    title: 黑夜主題\n  showTipsOnUI:\n    title: 在 UI 中顯示提示訊息\n    description: '開啟後，螢幕上會有一行提示文字。\n\n      如果您已經熟悉了軟體，建議關閉此項以減少螢幕佔用\n\n      更多更詳細的提示還是建議看功能表列中的“功能說明書”或官網文件。'\n  isClassroomMode:\n    title: 專注模式\n    description: '用於教學、培訓等場景。\n\n      開啟後視窗頂部按鈕會透明，滑鼠懸浮上去會恢復，可以修改進入退出專注模式的快捷鍵'\n  showQuickSettingsToolbar:\n    title: 顯示快捷設置欄\n    description: '控制是否在介面右側顯示快捷操作欄（快捷設置欄）。\n\n      快捷設置欄可以讓您快速切換常用設置項的開關狀態。'\n  autoAdjustLineEndpointsByMouseTrack:\n    title: 根據滑鼠拖曳軌跡自動調整生成連線的端點位置\n    description: '開啟後，在拖曳連線時會根據滑鼠移動軌跡自動調整連線端點在實體上的位置\n\n      關閉後，連線端點將始終位於實體中心'\n  enableRightClickConnect:\n    title: 啟用右鍵點擊式連線功能\n    description: '開啟後，選取實體並右鍵點擊其他實體時會自動建立連線，且右鍵選單僅在空白處顯示\n\n      關閉後，可以在實體上右鍵直接開啟選單，不會自動建立連線'\n  lineStyle:\n    title: 連線樣式\n    options:\n      straight: 直線\n      bezier: 貝茲曲線\n      vertical: 垂直折線\n  isRenderCenterPointer:\n    title: 顯示中心十字準星\n    description: 開啟後，螢幕中心中心會顯示一個十字準星，用於用於指示快捷鍵建立節點的位置\n  showGrid:\n    title: 顯示網格\n  showBackgroundHorizontalLines:\n    title: 顯示水平背景線\n    description: 水平線和垂直線可以同時開啟，實現網格效果\n  showBackgroundVerticalLines:\n    title: 顯示垂直背景線\n  showBackgroundDots:\n    title: 顯示背景點\n    description: 這些背景點是水平線和垂直線的交點，實現洞洞板的效果\n  showBackgroundCartesian:\n    title: 顯示背景直角座標系\n    description: '開啟後，將會顯示x軸、y軸和刻度數字\n\n      可以用於觀測一些節點的絕對座標位置\n\n      也能很直觀地知道當前的視野縮放倍數'\n  windowBackgroundAlpha:\n    title: 視窗背景透明度\n    description: '*從1改到小於1的值需要重新開啟檔案才能生效'\n  windowBackgroundOpacityAfterOpenClickThrough:\n    title: 開啟點擊穿透後的視窗背景透明度\n    description: 設定在開啟點擊穿透功能後視窗背景的透明度\n  windowBackgroundOpacityAfterCloseClickThrough:\n    title: 關閉點擊穿透後的視窗背景透明度\n    description: 設定在關閉點擊穿透功能後視窗背景的透明度\n  showDebug:\n    title: 顯示偵錯資訊\n    description: '通常為開發者使用\n\n      開啟後，螢幕左上角將會顯示偵錯資訊。\n\n      若您遇到bug截圖回饋時，建議開啟此選項。'\n  enableTagTextNodesBigDisplay:\n    title: 標籤文字節點巨大化顯示\n    description: '開啟後，標籤文字節點的顯示在攝影機縮小到廣袤的全域視野時，\n\n      標籤會巨大化顯示，以便更容易辨識整個檔案的佈局分佈'\n  showTextNodeBorder:\n    title: 顯示文字節點邊框\n    description: 控制是否顯示文字節點的邊框\n  showTreeDirectionHint:\n    title: 顯示樹形生長方向提示\n    description: 選中文字節點時，在節點四周顯示 tab/W W/S S/A A/D D 等鍵盤樹形生長方向提示。關閉後不再渲染這些提示文字。\n  sectionBitTitleRenderType:\n    title: 框的縮略大標題渲染類型\n    options:\n      none: 不渲染（節省效能）\n      top: 頂部小字\n      cover: 半透明覆蓋框體（最佳效果）\n  sectionBigTitleThresholdRatio:\n    title: 框的縮略大標題顯示閾值\n    description: 當框的最長邊小於視野範圍最長邊的此比例時，顯示縮略大標題\n  sectionBigTitleCameraScaleThreshold:\n    title: 框的縮略大標題相機縮放閾值\n    description: '當攝影機縮放比例大於此閾值時，不顯示縮略大標題\n\n      攝影機縮放比例需要打開偵錯資訊才能顯示'\n  sectionBigTitleOpacity:\n    title: 框的縮略大標題透明度\n    description: 控制半透明覆蓋大標題的透明度，取值範圍0-1\n  sectionBackgroundFillMode:\n    title: 框的背景顏色填充方式\n    description: |\n      控制section框的背景顏色填充方式\n      完整填充：填充整個框的背景（默認方式，有透明度化和遮罩順序判斷）\n      僅標題條：只填充頂部標題那一小條的部分\n    options:\n      full: 完整填充\n      titleOnly: 僅標題條\n  alwaysShowDetails:\n    title: 始終顯示節點詳細資訊\n    description: 開啟後，無需滑鼠移動到節點上時，才顯示節點的詳細資訊。\n  nodeDetailsPanel:\n    title: 節點詳細資訊面板\n    options:\n      small: 小型面板\n      vditor: vditor markdown 編輯器\n  useNativeTitleBar:\n    title: 使用原生標題列（需要重新啟動應用程式）\n    description: 開啟後，視窗頂端將會出現原生的標題列，而不是模擬的標題列。\n  protectingPrivacy:\n    title: 隱私保護\n    description: '用於回報問題截圖時，開啟此項之後將根據所選模式取代文字，以保護隱私。\n\n      僅作顯示層面的取代，不會影響真實資料\n\n      回報完畢後可再關閉，復原'\n  protectingPrivacyMode:\n    title: 隱私保護模式\n    description: 選擇隱私保護時的文字替換方式\n    options:\n      secretWord: 統一替換（漢字→㊙，字母→a/A，數字→6）\n      caesar: 凱撒位移（所有字元往後移動一位）\n  entityDetailsFontSize:\n    title: 實體詳細資訊字體大小\n    description: 設定舞台上渲染的實體詳細資訊的文字大小，單位為畫素\n  entityDetailsLinesLimit:\n    title: 實體詳細資訊行數限制\n    description: 限制舞台上渲染的實體詳細資訊的最大行數，超過限制的部分將被省略\n  entityDetailsWidthLimit:\n    title: 實體詳細資訊寬度限制\n    description: 限制舞台上渲染的實體詳細資訊的最大寬度（單位為px像素，可參考背景網格座標軸），超過限制的部分將被換行\n  windowCollapsingWidth:\n    title: 迷你視窗的寬度\n    description: 點擊切換至迷你視窗時，視窗的寬度，單位為像素\n  windowCollapsingHeight:\n    title: 迷你視窗的高度\n    description: 點擊切換至迷你視窗時，視窗的高度，單位為像素\n  limitCameraInCycleSpace:\n    title: 開啟循環空間限制攝影機移動\n    description: '開啟後，攝影機只能在一個矩形區域內移動\n\n      可以防止攝影機移動到很遠的地方迷路\n\n      該矩形區域會形成一個循環空間，類似於無邊貪食蛇遊戲中的地圖\n\n      走到最上面會回到最下面，走到最左邊會回到最右邊\n\n      注意：該功能還在實驗階段'\n  cameraCycleSpaceSizeX:\n    title: 迴圈空間寬度\n    description: 迴圈空間的寬度，單位為像素\n  cameraCycleSpaceSizeY:\n    title: 循環空間高度\n    description: 迴圈空間的高度，單位為畫素\n  renderEffect:\n    title: 渲染特效\n    description: 是否渲染特效，如果卡頓可以關閉\n  compatibilityMode:\n    title: 相容模式\n    description: 開啟後，軟體會使用另一種渲染方式\n  historySize:\n    title: 歷史紀錄大小\n    description: '這個數值決定了您最多ctrl+z復原的次數\n\n      如果您的電腦記憶體非常少，可以適當調小這個值'\n  compressPastedImages:\n    title: 是否壓縮貼上到舞台的圖片\n    description: 開啟後，貼上到舞台的圖片會被壓縮，以節省載入檔案時的記憶體壓力和磁碟壓力\n  maxPastedImageSize:\n    title: 貼上到舞台的圖片的尺寸限制（像素）\n    description: '長或寬超過此尺寸的圖片，其長或寬的最大值將會被限制為此大小\n\n      同時保持長寬比不變，僅在開啟“壓縮貼上到舞台的圖片”時生效'\n  isPauseRenderWhenManipulateOvertime:\n    title: 超過一定時間未操作舞台，暫停渲染\n    description: 開啟後，超過若干秒未做出舞台操作，舞台渲染會暫停，以節省CPU/GPU資源。\n  renderOverTimeWhenNoManipulateTime:\n    title: 超時停止渲染舞台的時間（秒）\n    description: '超過一定時間未做出舞台操作，舞台渲染會停止，以節省CPU/GPU資源。\n\n      必須在上述“逾時暫停渲染”選項開啟後才會生效。'\n  ignoreTextNodeTextRenderLessThanFontSize:\n    title: 當渲染字體大小小於一定值時，不渲染文字節點內的文字及其詳細資訊\n    description: '開啟後，當文字節點的渲染字體大小小於一定值時，(也就是觀察宏觀狀態時)\n\n      不渲染文字節點內的文字及其詳細資訊，這樣可以提高渲染效能，但會導致文字節點的文字內容無法顯示'\n  isEnableEntityCollision:\n    title: 實體碰撞偵測\n    description: '開啟後，實體之間會進行碰撞擠壓移動，可能會影響效能。\n\n      建議關閉此項，目前實體碰撞擠壓還不完善，可能導致堆疊溢位'\n  isEnableSectionCollision:\n    title: 启用框碰撞\n    description: |\n      开启后，框与框之间会自动进行碰撞排斥（推开重叠的同级框），避免框重叠。\n  autoRefreshStageByMouseAction:\n    title: 滑鼠操作時自動刷新舞台\n    description: '開啟後，滑鼠操作(拖曳移動視野)會自動重新整理舞台\n\n      防止出現開啟某個檔案後，圖片未載入成功還需手動重新整理的情況'\n  autoNamerTemplate:\n    title: 建立節點時自動命名範本\n    description: '輸入`{{i}}` 代表節點名稱會自動替換為編號，雙擊建立時可以自動累加數字。\n\n      例如`n{{i}}` 會自動替換為`n1`, `n2`, `n3`…\n\n      輸入`{{date}}` 會自動替換為目前日期，雙擊建立時可以自動更新日期。autoNamerTemplate\n\n      輸入`{{time}}` 會自動替換為目前時間，雙擊建立時可以自動更新時間。\n\n      可以組合使用，例如`{{i}}-{{date}}-{{time}}`'\n  autoNamerSectionTemplate:\n    title: 建立框時自動命名範本\n    description: '輸入`{{i}}` 代表節點名稱會自動替換為編號，雙擊建立時可以自動累加數字。\n\n      例如`n{{i}}` 會自動替換為`n1`, `n2`, `n3`…\n\n      輸入`{{date}}` 會自動替換為目前日期，雙擊建立時可以自動更新日期。\n\n      輸入`{{time}}` 會自動替換為目前時間，雙擊建立時可以自動更新時間。\n\n      可以組合使用，例如`{{i}}-{{date}}-{{time}}`'\n  autoSaveWhenClose:\n    title: 點擊視窗右上角關閉按鈕時自動儲存專案檔案\n    description: '關閉軟體時，如果有未儲存的專案檔案，會彈出提示框詢問是否儲存。\n\n      開啟此選項後，關閉軟體時會自動儲存專案檔案。\n\n      所以，建議開啟此選項。'\n  autoSave:\n    title: 開啟自動儲存\n    description: '自動儲存目前檔案\n\n      此功能目前僅對已有路徑的檔案有效，不對草稿檔案生效！'\n  autoSaveInterval:\n    title: 開啟自動儲存間隔（秒）\n    description: 注意：目前計時時間僅在軟體視窗啟動時計時，軟體最小化後不會計時。\n  clearHistoryWhenManualSave:\n    title: 使用快捷鍵手動儲存時，自動清空歷史紀錄\n    description: '當使用Ctrl+S快捷鍵手動儲存檔案時，自動清空操作歷史紀錄。\n\n      開啟此選項可以減少記憶體佔用並保持介面整潔。'\n  historyManagerMode:\n    title: 歷史紀錄管理員模式\n    description: '選擇歷史紀錄的管理方式：\n\n      memoryEfficient - 記憶體高效模式，使用增量儲存，省記憶體但可能在復原/重做時稍慢\n\n      timeEfficient - 時間高效模式，使用完整快照儲存，操作回應快但可能佔用更多記憶體'\n    options:\n      memoryEfficient: 記憶體高效模式\n      timeEfficient: 時間高效模式\n  autoBackup:\n    title: 開啟自動備份\n    description: '自動備份目前檔案到備份資料夾\n\n      如果是草稿，則會儲存在指定的路徑'\n  autoBackupInterval:\n    title: 自動備份間隔（秒）\n    description: '自動備份過於頻繁可能會產生大量的備份檔案\n\n      進而佔用磁碟空間'\n  autoBackupLimitCount:\n    title: 自動備份最大數量\n    description: 自動備份的最大數量，超過此數量將會刪除舊的備份檔案\n  autoBackupCustomPath:\n    title: 自訂自動備份路徑\n    description: 設定自動備份檔案的儲存路徑，如果為空則使用預設路徑\n  scaleExponent:\n    title: 視角縮放速度\n    description: '《目前縮放倍數》會不斷地以一定倍率無限逼近《目標縮放倍數》\n\n      當逼近得足夠近時（小於0.0001），會自動停止縮放\n\n      值為1代表縮放會立刻完成，沒有中間的過渡效果\n\n      值為0代表縮放永遠都不會完成，可模擬鎖死效果\n\n      注意：若您在縮放畫面時感到卡頓，請調成1'\n  cameraKeyboardScaleRate:\n    title: 視角縮放鍵盤速率\n    description: '每次透過一次按鍵來縮放視野時，視野的縮放倍率\n\n      值為0.2代表每次放大會變為原本的1.2倍，縮小為原本的0.8倍\n\n      值為0代表禁止透過鍵盤縮放'\n  scaleCameraByMouseLocation:\n    title: 視角縮放根據滑鼠位置\n    description: '開啟後，縮放視角的中心點是滑鼠的位置\n\n      關閉後，縮放視角的中心點是目前視野的中心'\n  allowMoveCameraByWSAD:\n    title: 允許使用W S A D按鍵移動視角\n    description: '開啟後，可以使用W S A D按鍵來上下左右移動視角\n\n      關閉後，只能使用滑鼠來移動視角，不會造成無限捲動螢幕bug'\n  allowGlobalHotKeys:\n    title: 允許使用全域熱鍵\n    description: 開啟後，可以使用全域熱鍵來觸發一些操作\n  cameraFollowsSelectedNodeOnArrowKeys:\n    title: 透過方向鍵切換選取節點時，視野跟隨移動\n    description: 開啟後，使用鍵盤移動節點選取框時，視野跟隨移動\n  arrowKeySelectOnlyInViewport:\n    title: 方向鍵切換選擇限制在視野內\n    description: '開啟後，使用方向鍵（上下左右）切換選擇節點時，只會選擇目前視野內可見的物體。\n\n      關閉後，可以選擇到視野外的物體（相機會自動跟隨）。'\n  cameraKeyboardMoveReverse:\n    title: 視角移動鍵盤反向\n    description: '開啟後，W S A D按鍵的移動視角方向會相反\n\n      原本的移動邏輯是移動懸浮在畫面上的攝影機，但如果看成是移動整個舞台，這樣就反了\n\n      於是就有了這個選項'\n  cameraResetViewPaddingRate:\n    title: 根據選擇節點重置視野時，邊緣留白係數\n    description: '框選一堆節點或一個節點，並按下快速鍵或點擊按鈕來重設視野後\n\n      視野會調整大小和位置，確保所有選取內容出現在螢幕中央並完全涵蓋\n\n      由於視野縮放大小原因，此時邊緣可能會有留白\n\n      值為 1 表示邊緣完全不留白。（非常放大的觀察）\n\n      值為 2 表示留白內容恰好為自身內容的一倍'\n  cameraResetMaxScale:\n    title: 攝影機重設視野後最大的縮放值\n    description: '選取一個面積很小的節點時，攝影機不會完全覆蓋這個節點的面積範圍，否則太大了。\n\n      而是會放大到一個最大值，這個最大值可以透過此選項來調整\n\n      建議開啟 debug 模式下觀察 currentScale 來調整此值'\n  allowAddCycleEdge:\n    title: 允許在節點之間添加自環\n    description: '開啟後，節點之間可以添加自環，即節點與自身相連，用於狀態機繪製\n\n      預設關閉，因為不常用，容易誤觸發'\n  enableDragEdgeRotateStructure:\n    title: 允許拖曳連線旋轉結構\n    description: '開啟後，可以透過拖曳選取的連線來旋轉節點結構\n\n      這允許您輕鬆調整相連節點的方向'\n  enableCtrlWheelRotateStructure:\n    title: 允許Ctrl+滑鼠滾輪旋轉結構\n    description: '開啟後，可以按住 Ctrl 鍵（Mac 系統為 Command 鍵）並捲動滑鼠滾輪來旋轉節點結構\n\n      這允許您精確調整相連節點的方向'\n  autoLayoutWhenTreeGenerate:\n    title: 生長節點時自動更新佈局\n    description: '開啟後，生長節點時自動更新佈局\n\n      此處的生長節點指tab和\\鍵生長節點'\n  enableBackslashGenerateNodeInInput:\n    title: 在輸入狀態下也能透過反斜線建立同級節點\n    description: '開啟後，在文字節點編輯狀態下，按下反斜線鍵（\\）也可以建立同級節點\n\n      關閉後，只有在非編輯狀態下才能透過反斜線鍵建立同級節點'\n  moveAmplitude:\n    title: 視角移動加速度\n    description: '此設定項用於 使用W S A D按鍵來上下左右移動視角時的情境\n\n      可將攝影機看成一個能朝四個方向噴氣的 懸浮飛機\n\n      此加速度值代表著噴氣的動力大小，需要結合下面的摩擦力設定來調整速度'\n  moveFriction:\n    title: 視角移動摩擦力係數\n    description: '此設定項用於 使用W S A D按鍵來上下左右移動視角時的情景\n\n      摩擦係數越大，滑動的距離越小，摩擦係數越小，滑動的距離越遠\n\n      此值=0時代表 絕對光滑'\n  gamepadDeadzone:\n    title: 遊戲搖桿死區\n    description: '此設定項用於 遊戲手把控制視角時的情境\n\n      手把的輸入值在0-1之間，此值越小，手把的輸入越敏感\n\n      死區越大，手把的輸入越趨於0或1，不會產生太大的變化\n\n      死區越小，手把的輸入越趨於中間值，會產生較大的變化'\n  mouseRightDragBackground:\n    title: 右鍵拖曳背景的操作\n    options:\n      cut: 斬斷並刪除物體\n      moveCamera: 移動視野\n  enableSpaceKeyMouseLeftDrag:\n    title: 啟用空白鍵+滑鼠左鍵拖曳移動\n    description: 按下空白鍵並使用滑鼠左鍵拖曳來移動視野\n  mouseLeftMode:\n    title: 左鍵模式切換\n    options:\n      selectAndMove: 選擇並移動\n      draw: 畫圖\n      connectAndCut: 連線與劈砍\n  doubleClickMiddleMouseButton:\n    title: 雙擊滑鼠中鍵\n    description: '將滾輪鍵快速按下兩次時執行的操作。預設是重設視野。\n\n      關閉此選項，可以防止誤觸發。'\n    options:\n      adjustCamera: 調整視野\n      none: 无操作\n  textNodeContentLineBreak:\n    title: 文字節點換行方案\n    options:\n      enter: Enter\n      ctrlEnter: ctrl + Enter\n      altEnter: alt + Enter\n      shiftEnter: shift + Enter\n    description: '注意不要和文字節點退出編輯模式的按鍵一樣了，這樣會導致衝突\n\n      進而導致無法換行'\n  textNodeStartEditMode:\n    title: 文字節點進入編輯模式\n    options:\n      enter: Enter\n      ctrlEnter: ctrl + Enter\n      altEnter: alt + Enter\n      shiftEnter: shift + Enter\n      space: 空白鍵\n    description: 實際上按F2鍵也可以進入編輯模式，這裡還可以再加選一種\n  textNodeExitEditMode:\n    title: 文字節點退出編輯模式\n    options:\n      enter: Enter\n      ctrlEnter: ctrl + Enter\n      altEnter: alt + Enter\n      shiftEnter: shift + Enter\n    description: 實際上按Esc鍵也可以退出，這裡還可以再加選一種\n  textNodeSelectAllWhenStartEditByMouseClick:\n    title: 文字節點透過按兩下開始編輯時自動全選內容\n    description: '開啟後，在文字節點開始編輯時，會全選文字內容\n\n      如果您編輯內容通常是為了直接更改全部內容，建議開啟此選項\n\n      如果更可能是為了追加內容，建議關閉此選項'\n  textNodeSelectAllWhenStartEditByKeyboard:\n    title: 文本節點透過鍵盤開始編輯時自動全選內容\n    description: 開啟後，在您按下文字節點編輯模式的按鍵時，會全選文字內容\n  textNodeBackspaceDeleteWhenEmpty:\n    title: 當在編輯模式下文字節點無內容時按 Backspace 鍵自動刪除整個節點\n    description: 開啟後，在編輯文字節點且內容為空時，按下 Backspace 鍵會自動刪除整個節點\n  textNodeBigContentThresholdWhenPaste:\n    title: 貼上時文字節點大內容閾值\n    description: 當直接在舞台上貼上文字時，如果文字長度超過此值，將使用手動換行模式\n  textNodePasteSizeAdjustMode:\n    title: 文字節點貼上大小調整模式\n    description: 控制貼上文字節點時的大小調整方式\n    options:\n      auto: 總是自動調整\n      manual: 總是手動調整\n      autoByLength: 根據長度自動調整\n  textNodeAutoFormatTreeWhenExitEdit:\n    title: 退出編輯模式時自動格式化樹狀結構\n    description: 當文字節點退出編輯模式時，自動對其所在的樹狀結構進行格式化佈局\n  treeGenerateCameraBehavior:\n    title: 樹狀生長節點後的鏡頭行為選項\n    description: 設定在使用樹狀深度生長或廣度生長功能建立新節點後，鏡頭的行為方式\n    options:\n      none: 鏡頭不動\n      moveToNewNode: 鏡頭移動向新建立的節點\n      resetToTree: 重置視野，使視野覆蓋目前樹狀結構的外接矩形\n  enableDragAutoAlign:\n    title: 滑鼠拖曳自動吸附對齊節點\n    description: 開啟後，拖曳節點並放開時會與其他節點在x軸、y軸方向對齊\n  reverseTreeMoveMode:\n    title: 反轉樹形移動模式\n    description: 開啟後，預設移動為樹形移動（連帶後續節點），按住Ctrl鍵移動為單一物體移動。關閉時相反。\n  enableDragAlignToGrid:\n    title: 拖曳實體時，貼齊網格\n    description: 建議在顯示中開啟橫向和縱向格線，並關閉自動貼齊對齊\n  enableWindowsTouchPad:\n    title: 允許觸控板雙指移動操作\n    description: '在 Windows 系統中，雙指上下移動會被辨識為滾輪事件。\n\n      雙指左右移動會被辨識成滑鼠橫向滾輪的捲動事件。\n\n      如果您是筆記型電腦操作並使用外接滑鼠，建議關閉此選項。'\n  macTrackpadAndMouseWheelDifference:\n    title: macbook 的觸控板與滑鼠滾輪區分邏輯\n    description: 有的macbook滑鼠滾輪是整數，觸控板是小數，有的則相反 您需要根據實際情況選擇一下區分邏輯 區分方法可點擊7次關於介面的軟體logo進入“測試介面”後，滑動滾輪和觸控板查看數據回饋\n    options:\n      trackpadIntAndWheelFloat: 觸控板是整數，滑鼠捲動是小數\n      tarckpadFloatAndWheelInt: 觸控板是小數，滑鼠捲動是整數\n  macTrackpadScaleSensitivity:\n    title: macbook 的觸控板雙指縮放靈敏度\n    description: 值越大，缩放的速度越快\n  macEnableControlToCut:\n    title: mac下是否啟用 control鍵按下來開始刀斬\n    description: 按下 Control 鍵，在舞台上移動滑鼠，再鬆開 Control 鍵，完成一次刀斬\n  macMouseWheelIsSmoothed:\n    title: macbook 的滑鼠滾輪是否平滑\n    description: 有的macbook滑鼠滾輪是平滑的，有的則是捲動一格觸發一次 可能取決於您是否安裝了Mos等滑鼠修改軟體\n  mouseSideWheelMode:\n    title: 滑鼠側邊滾輪模式\n    description: '侧边滚轮就是大拇指上的滚轮\n\n      '\n    options:\n      zoom: 缩放\n      move: 縱向移動\n      moveX: 橫向移動\n      none: 无操作\n      cameraMoveToMouse: 將視野向滑鼠位置移動\n      adjustWindowOpacity: 調整視窗透明度\n      adjustPenStrokeWidth: 调整画笔粗细\n  mouseWheelMode:\n    title: 滑鼠滾輪模式\n    options:\n      zoom: 缩放\n      move: 縱向移動\n      moveX: 橫向移動\n      none: 无操作\n  mouseWheelWithShiftMode:\n    title: 按住 Shift 時，滑鼠滾輪模式\n    options:\n      zoom: 缩放\n      move: 縱向移動\n      moveX: 橫向移動\n      none: 无操作\n  mouseWheelWithCtrlMode:\n    title: 按住 Ctrl 時，滑鼠滾輪模式\n    description: '提示：这里的 Ctrl 是 Control\n\n      '\n    options:\n      zoom: 缩放\n      move: 縱向移動\n      moveX: 橫向移動\n      none: 无操作\n  mouseWheelWithAltMode:\n    title: 按住 Alt 時，滑鼠滾輪模式\n    description: '此功能於2025年4月10日新增\n\n      目前發現還存在問題：win系統下滑動滾輪後需要再點擊一次螢幕才能操作舞台\n\n      提示：這裡的 Alt 是 Option'\n    options:\n      zoom: 缩放\n      move: 縱向移動\n      moveX: 橫向移動\n      none: 无操作\n  rectangleSelectWhenLeft:\n    title: 向左框選的策略\n    description: '選擇滑鼠向左框選的策略，包含完全覆蓋框選和碰撞框選\n\n      完全覆蓋框選是指矩形框選框必須完全覆蓋實體的外接矩形\n\n      碰撞框選是指矩形框選框只要碰到一點點實體的外接矩形，就能夠選中了'\n    options:\n      intersect: 碰撞框選\n      contain: 完全覆蓋框選\n  rectangleSelectWhenRight:\n    title: 向右框選的策略\n    description: 選擇滑鼠向右框選的策略\n    options:\n      intersect: 碰撞框選\n      contain: 完全覆蓋框選\n  cuttingLineStartSoundFile:\n    title: 斬斷線開始的聲音檔案\n    description: 斬斷線右鍵按下開始時播放的聲音檔案路徑\n  connectLineStartSoundFile:\n    title: 連接線開始的聲音檔案\n    description: 連接線右鍵按下開始時播放的聲音檔案路徑\n  connectFindTargetSoundFile:\n    title: 連接線吸附到目標上的聲音檔案\n    description: 連接線吸附到目標上時播放的聲音檔案路徑\n  cuttingLineReleaseSoundFile:\n    title: 斩断线释放的声音文件\n    description: 釋放的時候就是看到刀光刃特效的時候\n  alignAndAttachSoundFile:\n    title: 對齊的聲音檔案\n    description: 滑鼠拖曳時，對齊節點時播放的聲音檔案路徑\n  uiButtonEnterSoundFile:\n    title: 滑鼠進入按鈕區域的聲音\n    description: 滑鼠進入按鈕區域的聲音\n  uiButtonClickSoundFile:\n    title: 按鈕點擊時的聲音檔案\n    description: 按鈕點擊時播放的聲音檔案路徑\n  uiSwitchButtonOnSoundFile:\n    title: 按鈕點擊開關按鈕時打開的聲音\n    description: 按鈕點擊開關按鈕時開啟的聲音檔案路徑\n  uiSwitchButtonOffSoundFile:\n    title: 按鈕點擊開關按鈕時關閉的聲音\n    description: 按鈕點擊開關按鈕時關閉的聲音檔案路徑\n  packEntityToSectionSoundFile:\n    title: 打包成框的聲音檔\n    description: 把選中的東西打包到Section框裡時播放的聲音檔路徑\n  treeGenerateDeepSoundFile:\n    title: 樹狀深度生長的聲音檔\n    description: 用Tab鍵做樹狀深度生長時播放的聲音檔路徑\n  treeGenerateBroadSoundFile:\n    title: 樹狀廣度生長的聲音檔\n    description: 用Enter鍵做樹狀廣度生長時播放的聲音檔路徑\n  treeAdjustSoundFile:\n    title: 樹狀結構調整的聲音檔\n    description: 格式化樹狀結構時播放的聲音檔路徑\n  viewAdjustSoundFile:\n    title: 視角調整的聲音檔\n    description: 調整視角時播放的聲音檔路徑\n  entityJumpSoundFile:\n    title: 物體跳躍的聲音檔\n    description: 物體跳躍移動時播放的聲音檔路徑\n  associationAdjustSoundFile:\n    title: 連線調整的聲音檔\n    description: 調整連線、無向邊這些東西時播放的聲音檔路徑\n  agreeTerms:\n    title: 同意用户协议\n    description: '请您仔细阅读并同意用户协议\n\n      '\n  allowTelemetry:\n    title: 參與使用者體驗改進計畫\n    description: '如果您啟用此項，我們會收集您的使用資料，幫助我們改進軟體\n\n      傳送的資料僅用於統計，不會包含您的個人隱私資訊\n\n      您的資料會在中國香港的雲端伺服器上儲存，不會傳送到國外'\n  aiApiBaseUrl:\n    title: AI API 地址\n    description: 目前僅支援 OpenAI 格式的 API\n  aiApiKey:\n    title: AI API 密钥\n    description: '密钥将会明文存储在本地\n\n      '\n  aiModel:\n    title: AI 模型\n  aiShowTokenCount:\n    title: 顯示 AI 消耗的token數\n    description: |\n      啟用後，在 AI 操作時顯示消耗的token數\n  cacheTextAsBitmap:\n    title: 開啟點陣式渲染文字\n    description: '開啟後，文字節點的點陣圖快取將會被保留，以提升渲染速度\n\n      但可能會造成模糊'\n  textCacheSize:\n    title: 文本缓存限制\n    description: '預設情況下會對頻繁渲染的文本進行快取，以提升渲染速度\n\n      如果值過大可能會導致記憶體佔用過高造成卡頓甚至當機\n\n      值為 0 代表停用快取，每次渲染都會重新渲染文本\n\n      *重新開啟檔案時生效'\n  textScalingBehavior:\n    title: 攝影機縮放時的文字渲染方式\n    options:\n      temp: 每一帧都重新渲染文本，不使用缓存\n      nearestCache: 使用文本大小最接近的缓存，缩放再渲染\n      cacheEveryTick: 每一帧都重新渲染文本，并且缓存\n  textIntegerLocationAndSizeRender:\n    title: 文字整數位置和大小渲染\n    description: '開啟後，一切文字的大小和位置都是整數，以節省渲染效能。\n\n      但會出現文字抖動現象。建議配合視角縮放速度調整成1一起使用。\n\n      如果您的電腦使用體驗非常卡頓，尤其是在縮放和移動的情況下，可以開啟此選項。'\n  antialiasing:\n    title: 抗锯齿\n    description: '*重新開啟檔案時生效'\n    options:\n      disabled: 關閉\n      low: 低\n      medium: 中\n      high: 高\n  isStealthModeEnabled:\n    title: 潜行模式\n    description: 摸魚專用，配合視窗透明效果更佳\n  stealthModeScopeRadius:\n    title: 潜行模式范围半径\n    description: 狙擊鏡的半徑\n  stealthModeReverseMask:\n    title: 反向遮罩\n    description: 開啟後，狙擊鏡中心區域會被遮罩，只顯示周圍區域\n  stealthModeMaskShape:\n    title: 潜行模式遮罩形状\n    description: 選擇潜行模式下顯示區域的形狀\n    options:\n      circle: 圓形\n      square: 正方形\n      topLeft: 左上角象限\n      smartContext: 智能上下文（框或實體）\n  soundPitchVariationRange:\n    title: 音效音调随机变化范围\n    description: 控制音效播放時音高隨機變化的程度。範圍：0-1200音分（1200音分=1個八度，100音分=1個半音）。值越大，音高變化越明顯，越像遊戲一樣有趣。\n  autoImportTxtFileWhenOpenPrg:\n    title: 開啟 PRG 檔案時自動匯入同名 TXT 檔案\n    description: 啟用後，開啟 PRG 檔案時會自動匯入同一資料夾下同名 TXT 檔案的內容，並以文字節點形式新增到舞台左下角。\neffects:\n  CircleChangeRadiusEffect:\n    title: 圆形变换半径效果\n    description: 質點被框選後波紋放大\n  CircleFlameEffect:\n    title: 圆形径向渐变光闪\n    description: 存在於各種特效細節中，預劈砍直線與實體矩形切割時、斬斷連線時的中點閃爍等\n  EntityAlignEffect:\n    title: 實體對齊效果\n    description: 滑鼠拖曳吸附對齊時產生的高亮虛線\n  EntityCreateDashEffect:\n    title: 實體建立粉塵凝聚效果\n    description: '實體建立時，實體周圍出現粉塵凝聚\n\n      由於不夠美觀，已經廢棄，不會出現'\n  EntityCreateFlashEffect:\n    title: 实体边框发光效果\n    description: '在ctrl+滾輪轉動實體樹、縮放圖片、建立節點等情況下出現\n\n      若渲染效能較差，建議關閉此選項'\n  EntityCreateLineEffect:\n    title: 实体散发电路板式线条辐射效果\n    description: '已经废弃，不会出现\n\n      '\n  EntityDashTipEffect:\n    title: 實體提示性的粉塵抖動\n    description: 出現在實體輸入編輯結束或進入時，實體周圍出現抖動的粉塵\n  EntityJumpMoveEffect:\n    title: 實體跳躍移動效果\n    description: '實體跳躍移動時，實體出現一個象徵性的跳躍弧線幻影\n\n      用來表示偽 z 軸的跨越層級移動'\n  EntityShakeEffect:\n    title: 實體抖動效果\n    description: 像“TickTock”Logo 一樣的抖動特效，用於實體出現警告性質的提示\n  EntityShrinkEffect:\n    title: 实体缩小消失效果\n    description: 使用Delete鍵刪除實體時，實體出現縮小消失的效果\n  ExplodeDashEffect:\n    title: 粉尘爆炸效果\n    description: 用劈砍刪除實體時，出現粉塵爆炸\n  LineCuttingEffect:\n    title: 劈砍時的刀光\n    description: 劈砍時，出現類似水果忍者一樣的刀光\n  LineEffect:\n    title: 直线段淡出效果\n    description: 用於拖曳旋轉子樹時，連線劃過虛影\n  NodeMoveShadowEffect:\n    title: 節點移動時摩擦地面的粒子效果\n    description: 佈局造成的移動可能也會出現一閃而過的粒子\n  PenStrokeDeletedEffect:\n    title: 塗鴉被刪除時的消失特效\n    description: 塗鴉被刪除時，出現消失的特效\n  PointDashEffect:\n    title: 在某点出迸发万有引力式的粒子效果\n    description: 由於萬有引力影響效能，此特效已關閉，不會出現\n  RectangleLittleNoteEffect:\n    title: 矩形闪烁提示效果\n    description: 在邏輯節點執行時，邏輯節點會閃爍此效果\n  RectangleNoteEffect:\n    title: 矩形存在提示效果\n    description: '高亮提示某個矩形範圍，搜尋節點或定位時會高亮提示\n\n      關閉後會看不到矩形高亮效果'\n  RectanglePushInEffect:\n    title: 矩形四顶点划至另一矩形四顶点的效果\n    description: '用於提示實體的跨越框層移動、方向鍵切換選取\n\n      目前開發者由於偷懶，此效果引用了四個劈砍線效果。\n\n      若關閉了劈砍線效果，則此效果會看不見'\n  RectangleRenderEffect:\n    title: 矩形位置提示效果\n    description: 用於在吸附拖曳對齊時，顯示實體即將吸附到的目標位置\n  RectangleSplitTwoPartEffect:\n    title: 矩形被切成兩塊的特效\n    description: 僅存在於劈砍特效（也有可能是四塊）\n  TechLineEffect:\n    title: （基础特效）折线段效果\n    description: 此特效是其他特效的組成部分，若關閉則其他特效可能會受到影響\n  TextRaiseEffectLocated:\n    title: 固定位置的文字節點懸浮上升效果\n    description: 文字節點懸浮上升效果，用於提示重要資訊\n  ViewFlashEffect:\n    title: 視野閃爍效果\n    description: '全螢幕閃白/閃黑等效果\n\n      光敏性癲癇患者請關閉此選項'\n  ViewOutlineFlashEffect:\n    title: 視野輪廓閃爍效果\n    description: 視野輪廓閃爍效果\n  ZapLineEffect:\n    title: （基础特效）闪电线效果\n    description: 此特效是其他特效的組成部分，若關閉則其他特效可能會受到影響\n  MouseTipFeedbackEffect:\n    title: 滑鼠互動提示特效\n    description: 在滑鼠進行縮放視野等操作時，滑鼠旁邊會出現特效提示，例如一個變大或變小的圓圈\n  RectangleSlideEffect:\n    title: 矩形滑動尾翼特效\n    description: 用於垂直方向鍵盤移動實體\nkeyBindsGroup:\n  otherKeys:\n    title: 未分類的快速鍵\n    description: '未分類的快捷鍵，\n\n      此處若發現無翻譯的無效快捷鍵項，可能是由於版本升級而未清理舊快捷鍵導致出現的殘留\n\n      可手動清理 keybinds.json 檔案中的對應項'\n  basic:\n    title: 基礎快捷鍵\n    description: 基本的快速鍵，用於常用的功能\n  camera:\n    title: 摄像机控制\n    description: 用於控制攝影機移動、縮放\n  app:\n    title: 应用控制\n    description: '用于控制应用的一些功能\n\n      '\n  ui:\n    title: UI控制\n    description: '用于控制UI的一些功能\n\n      '\n  draw:\n    title: 涂鸦\n    description: 塗鴉相關功能\n  select:\n    title: 切換選擇\n    description: 使用鍵盤來切換選取的實體\n  moveEntity:\n    title: 移動實體\n    description: 用於移動實體的一些功能\n  generateTextNodeInTree:\n    title: 生長節點\n    description: 透過鍵盤生長節點（Xmind使用者習慣）\n  generateTextNodeRoundedSelectedNode:\n    title: 在選取節點周圍生成節點\n    description: 按下後，在選取節點周圍生成節點\n  aboutTextNode:\n    title: 關於文字節點\n    description: 和文字節點相關的一切快捷鍵，分割、合併、建立等\n  section:\n    title: Section框\n    description: Section框相關功能\n  leftMouseModeCheckout:\n    title: 左鍵模式切換\n    description: 關於左鍵模式的切換\n  edge:\n    title: 連線相關\n    description: 關於連線的一些功能\n  expandSelect:\n    title: 擴散選擇\n    description: 擴散選取節點相關的快速鍵\n  themes:\n    title: 主题切换\n    description: 切換主題相關的快捷鍵\n  align:\n    title: 對齊相關\n    description: 關於實體對齊的一些功能\n  image:\n    title: 圖片相關\n    description: 關於圖片的一些功能\n  node:\n    title: 節點相關\n    description: 關於節點的一些功能，如嫁接、摘除等\ncontrolSettingsGroup:\n  mouse:\n    title: 滑鼠設定\n  touchpad:\n    title: 觸控板設定\n  textNode:\n    title: 文字節點設定\n  gamepad:\n    title: 遊戲手把設定\nvisualSettingsGroup:\n  basic:\n    title: 基本設定\n  background:\n    title: 背景設定\nkeyBinds:\n  title: 快速鍵綁定\n  none: 未綁定快捷鍵\n  test:\n    title: 测试\n    description: 僅用於測試快速鍵自定義綁定功能功能\n  reload:\n    title: 重载应用\n    description: '重新載入應用程式，重新載入目前專案檔案\n\n      等同於瀏覽器重新整理網頁\n\n      這個功能很危險！會導致未儲存的進度遺失！'\n  saveFile:\n    title: 保存文件\n    description: 儲存目前專案檔案，若目前檔案是草稿則另存新檔\n  newDraft:\n    title: 新建草稿\n    description: '新增一個草稿檔案，並切換到該檔案\n\n      若目前檔案未儲存則無法切換'\n  newFileAtCurrentProjectDir:\n    title: 在目前專案目錄下新增檔案\n    description: '在目前專案目錄下建立一個專案檔，並切換到該檔案（用於快速建立檔案）\n\n      若目前檔案為草稿狀態則無法建立'\n  openFile:\n    title: 開啟檔案\n    description: 選擇一個曾經儲存的json/prg檔案並開啟\n  undo:\n    title: 撤销\n    description: 撤销上一次操作\n  redo:\n    title: 取消撤销\n    description: 取消上一次撤销操作\n  resetView:\n    title: 重置視野\n    description: '如果沒有選擇任何內容，則根據全部內容重置視野；\n\n      如果有選擇內容，則根據選取內容重置視野'\n  restoreCameraState:\n    title: 恢復視野狀態\n    description: 按下後，恢復到之前按下F鍵時記錄的攝影機位置和縮放大小\n  resetCameraScale:\n    title: 重置缩放\n    description: 將視野縮放重設為標準大小\n  folderSection:\n    title: 摺疊或展開Section框\n    description: 按下後選取的 Section 框會切換摺疊或展開狀態\n  toggleSectionLock:\n    title: 鎖定/解鎖Section框\n    description: 切換選中Section框的鎖定狀態，鎖定後內部物體不可移動\n  reverseEdges:\n    title: 反转连线的方向\n    description: '按下後，選取的連線的方向會變成相反方向\n\n      例如，原先是 A -> B，按下後變成 B -> A\n\n      此功能的意義在於快速建立一個節點連向多個節點的情況\n\n      因為目前的連線只能一次性做到多連一。'\n  reverseSelectedNodeEdge:\n    title: 反轉選取的節點的所有連線的方向\n    description: '按下後，所有框選選中的節點中\n\n      每個節點的所有連線都會反轉方向\n\n      進而實現更快捷的一連多'\n  createUndirectedEdgeFromEntities:\n    title: 選取的實體之間建立無向連線\n    description: 按下後，選取的兩個或多個實體之間會建立一條無向連線\n  packEntityToSection:\n    title: 將選取的實體打包到Section框中\n    description: 按下後，選取的實體會自動包裹到新 Section 框中\n  unpackEntityFromSection:\n    title: Section框拆包，轉換為文字節點\n    description: '按下後，選取的Section框中的實體會被拆解，自身轉換成一個文字節點\n\n      內部的實體將會掉落在外面'\n  textNodeToSection:\n    title: 將選取的文字節點轉換成Section框\n    description: '按下後，選取的文字節點會被轉換成 Section 框\n\n      可以用於 section 框的快速建立'\n  deleteSelectedStageObjects:\n    title: 刪除選取的舞台物件\n    description: '按下後，選取的舞台物件會被刪除\n\n      舞台物件包括實體（節點和Section等獨立存在的東西）和關係（節點之間的連線）\n\n      預設是delete鍵，您可以改成backspace鍵'\n  editEntityDetails:\n    title: 編輯選取實體的詳細資訊\n    description: '按下後，選取的實體的詳細資訊會被開啟編輯\n\n      只有選取的物件數量為1時才有效'\n  openColorPanel:\n    title: 開啟顏色面板快速鍵\n    description: 按下後，開啟顏色面板，可用於快速切換節點顏色\n  switchDebugShow:\n    title: 切换调试信息显示\n    description: '按下後，切換偵錯資訊顯示\n\n      偵錯資訊顯示在螢幕左上角，通常為開發者使用\n\n      開啟後，螢幕左上角將會顯示偵錯資訊。\n\n      若您遇到bug截圖回報時，建議開啟此選項。'\n  generateNodeTreeWithDeepMode:\n    title: 生長子層節點\n    description: '按下後，瞬間生長一個節點並放置在當前選取節點的右側\n\n      如果對節點設定了生長方向，則會按照生長方向生長子節點（若無設定則預設向右生長）\n\n      同時自動排版整個節點樹的結構，確保其是一個向右的樹狀結構\n\n      使用此功能前先確保已選取一個節點、且該節點所在結構為樹狀結構'\n  generateNodeTreeWithBroadMode:\n    title: 生長同級節點\n    description: '按下後，瞬間生長一個同級節點並放置在目前選取節點的下方\n\n      同時自動排版整個節點樹的結構，確保其是一個向下的樹狀結構\n\n      使用此功能前先確保已選取一個節點、且該節點存在父級節點'\n  generateNodeGraph:\n    title: 生長自由節點\n    description: '按下後，出現一個虛擬生長位置\n\n      此時，按下“I J K L”鍵自由調整生長位置\n\n      再次按下該鍵，退出自由生長模式\n\n      使用此功能前先確保已選中一個節點'\n  createConnectPointWhenDragConnecting:\n    title: 拖曳連線時，按下此鍵，建立質點中轉\n    description: 當拖曳連線時，按下此鍵，建立質點中轉\n  treeGraphAdjustSelectedAsRoot:\n    title: 以選中節點為根節點格式化樹形結構\n    description: |\n      以當前選中的節點作為根節點進行樹形結構格式化\n      不會查找整個樹的根節點，只格式化以選中節點為根的子樹\n  treeGraphAdjust:\n    title: 調整目前節點所在樹狀結構的樹狀佈局\n    description: '主要用於關閉了鍵盤生長節點時觸發的樹狀佈局調整時使用\n\n      可以透過此快速鍵手動觸發佈局調整'\n  dagGraphAdjust:\n    title: 調整目前選取節點群的DAG佈局\n    description: '對選取的有向無環圖（DAG）結構進行自動佈局調整\n\n      僅當選取節點構成 DAG 結構時可用'\n  gravityLayout:\n    title: 持續按下引力式佈局\n    description: '選取一堆連線的節點群後，持續按下該鍵可以進行引力式佈局。\n\n      注意此鍵若想自訂，只能設定為單個非修飾鍵，否則可能有未知bug。'\n  setNodeTreeDirectionLeft:\n    title: 設定目前節點的樹狀生長方向為向左\n    description: '需要選取節點後按下此快捷鍵\n\n      設定後，在此節點上按Tab生長時，會向左生長子節點'\n  setNodeTreeDirectionRight:\n    title: 設定目前節點的樹狀生長方向為向右\n    description: '需要選取節點後按下此快速鍵\n\n      設定後，在此節點上按 Tab 生長時，會向右生長子節點'\n  setNodeTreeDirectionUp:\n    title: 設定目前節點的樹狀生長方向為向上\n    description: '需要選取節點後按下此快捷鍵\n\n      設定後，在此節點上按 Tab 生長時，會向上生長子節點'\n  setNodeTreeDirectionDown:\n    title: 設定目前節點的樹狀生長方向為向下\n    description: '需要選取節點後按下此快捷鍵\n\n      設定後，在此節點上按 Tab 生長時，會向下生長子節點'\n  masterBrakeCheckout:\n    title: 手煞：開啟/關閉透過按鍵控制攝影機移動\n    description: '按下後會切換是否允許 “W S A D”鍵控制攝影機移動\n\n      可以用於臨時禁止攝影機移動，在輸入密技鍵或含有WSAD的快捷鍵時防止視野移動'\n  masterBrakeControl:\n    title: 脚刹：停止摄像机飘移\n    description: 按下後，停止攝影機飄移，並將速度設為0\n  selectAll:\n    title: 全選\n    description: 按下後，所有節點和連線都會被選取\n  selectAtCrosshair:\n    title: 選擇十字準心對準的節點\n    description: |\n      選擇螢幕中心十字準心指向的節點\n      如果該位置有節點，則選取它（取消其他選取）\n  addSelectAtCrosshair:\n    title: 加入選擇十字準心對準的節點\n    description: |\n      將螢幕中心十字準心指向的節點加入到目前選取中\n      如果該節點已被選取，則取消選取\n  createTextNodeFromCameraLocation:\n    title: 在視野中心位置建立文字節點\n    description: '按下後，在目前視野中心的位置建立一個文字節點\n\n      等同於滑鼠雙擊建立節點的功能'\n  createTextNodeFromMouseLocation:\n    title: 在滑鼠位置建立文字節點\n    description: '按下後，在滑鼠懸浮位置建立一個文字節點\n\n      等同於滑鼠單擊建立節點的功能'\n  createTextNodeFromSelectedTop:\n    title: 在目前選取的節點正上方建立文字節點\n    description: 按下後，在目前選取的節點正上方建立一個文字節點\n  createTextNodeFromSelectedDown:\n    title: 在目前選取的節點正下方建立文字節點\n    description: 按下後，在目前選取的節點正下方建立一個文字節點\n  createTextNodeFromSelectedLeft:\n    title: 在目前選取的節點左側建立文字節點\n    description: 按下後，在目前選取的節點左側建立一個文字節點\n  createTextNodeFromSelectedRight:\n    title: 在目前選取的節點右側建立文字節點\n    description: 按下後，在目前選取的節點右側建立一個文字節點\n  selectUp:\n    title: 選取上方節點\n    description: 按下後，選取上方節點\n  selectDown:\n    title: 選取下方節點\n    description: 按下後，選取下方節點\n  selectLeft:\n    title: 選取左側節點\n    description: 按下後，選取左側節點\n  selectRight:\n    title: 選取右側節點\n    description: 按下後，選取右側節點\n  selectAdditionalUp:\n    title: 附加選取上方節點\n    description: 按下後，附加選取上方節點\n  selectAdditionalDown:\n    title: 附加選取下方節點\n    description: 按下後，附加選取下方節點\n  selectAdditionalLeft:\n    title: 附加選取左側節點\n    description: 按下後，附加選取左側節點\n  selectAdditionalRight:\n    title: 附加選取右側節點\n    description: 按下後，附加選取右側節點\n  moveUpSelectedEntities:\n    title: 向上移動所有選取的實體\n    description: 按下後，所有選取的實體會向上移動一個固定距離\n  moveDownSelectedEntities:\n    title: 向下移動所有選取的實體\n    description: 按下後，所有選取的實體會向下移動一個固定距離\n  moveLeftSelectedEntities:\n    title: 向左移動所有選取的實體\n    description: 按下後，所有選取的實體會向左移動一個固定距離\n  moveRightSelectedEntities:\n    title: 向右移動所有選取的實體\n    description: 按下後，所有選取的實體會向右移動一個固定距離\n  jumpMoveUpSelectedEntities:\n    title: 跳躍向上移動所有選取的實體\n    description: 按下後，所有選取的實體會跳躍向上移動一個固定距離，能夠跳入或者跳出Section框\n  jumpMoveDownSelectedEntities:\n    title: 跳躍向下移動所有選取的實體\n    description: 按下後，所有選取的實體會跳躍向下移動一個固定距離，能夠跳入或者跳出Section框\n  jumpMoveLeftSelectedEntities:\n    title: 跳躍向左移動所有選取的實體\n    description: 按下後，所有選取的實體會跳躍向左移動一個固定距離，能夠跳入或者跳出 Section 框\n  jumpMoveRightSelectedEntities:\n    title: 跳躍向右移動所有選取的實體\n    description: 按下後，所有選取的實體會跳躍向右移動一個固定距離，能夠跳入或者跳出Section框\n  CameraScaleZoomIn:\n    title: 視野放大\n    description: 按下後，視野放大\n  CameraScaleZoomOut:\n    title: 視野縮小\n    description: 按下後，視野縮小\n  CameraPageMoveUp:\n    title: 視野向上翻頁式移動\n  CameraPageMoveDown:\n    title: 視野向下翻頁式移動\n  CameraPageMoveLeft:\n    title: 視野向左翻頁式移動\n  CameraPageMoveRight:\n    title: 視野向右翻頁式移動\n  exitSoftware:\n    title: 退出软件\n    description: 按下后，退出软件\n  checkoutProtectPrivacy:\n    title: 进入或退出隐私保护模式\n    description: '按下後，舞台上的全部文字將會被加密，無法被其他人看到\n\n      按下後，舞台上的全部文字將會解密，其他人可以看到\n\n      可以用於截圖回報問題、突然有人看你的螢幕時使用，並且你的內容是感情問題（？）時使用'\n  openTextNodeByContentExternal:\n    title: 以網頁瀏覽器或本機檔案形式開啟選取節點的內容\n    description: '按下後，舞台上所有選取的文字節點都會被以預設方式或瀏覽器方式開啟。\n\n      例如一個節點內容為 \"D:/Desktop/a.txt\"，選取此節點按下快捷鍵之後，能以系統預設方式開啟此檔案\n\n      如果節點內容為網頁網址 \"https://project-graph.top\"，會以系統預設瀏覽器開啟網頁內容'\n  checkoutClassroomMode:\n    title: 进入或退出专注模式\n    description: '按下后，进入专注模式，所有UI都会隐藏，顶部按钮会透明化处理\n\n      再按一次恢复\n\n      '\n  checkoutWindowOpacityMode:\n    title: 切換視窗透明度模式\n    description: '按下後，視窗進入完全透明模式，再按一次將進入完全不透明模式\n\n      注意要配合舞台顏色風格進行設置。例如：黑色模式下文字為白色，論文白模式下文字為黑色。\n\n      如果視窗下層內容為白色背景，建議切換舞台到論文白模式。'\n  windowOpacityAlphaIncrease:\n    title: 視窗不透明度增加\n    description: '按下後，視窗不透明度（alpha）值增加0.2，往不透明的方向改變，最大值為1\n\n      當不能再增加時，會有視窗邊緣閃爍提示'\n  windowOpacityAlphaDecrease:\n    title: 視窗不透明度減少\n    description: '按下後，視窗不透明度（alpha）值減少0.2，往透明的方向改變，最小值為0\n\n      如果您的鍵盤沒有小鍵盤的純減號鍵，可以改成橫排數字0右側的減號與底線共用鍵'\n  searchText:\n    title: 搜索文本\n    description: '按下後，打開搜尋框，可以輸入搜尋內容\n\n      搜尋框支援部分比對，例如輸入 \"a\" 能搜尋到 \"apple\" 等'\n  clickAppMenuSettingsButton:\n    title: 打開設定頁面\n    description: 按下此鍵可代替滑鼠點擊功能表列裡的設定介面按鈕\n  clickTagPanelButton:\n    title: 開啟/關閉標籤面板\n    description: 按下此鍵可代替滑鼠點擊頁面上的標籤面板展開關閉按鈕\n  clickAppMenuRecentFileButton:\n    title: 開啟最近開啟檔案清單\n    description: 按下此鍵可代替滑鼠點擊功能表列裡的最近開啟檔案列表按鈕\n  clickStartFilePanelButton:\n    title: 開啟/關閉啟動檔案清單\n    description: 按下此鍵可代替滑鼠點擊功能表列裡的啟動檔案列表展開關閉按鈕\n  copy:\n    title: 复制\n    description: 按下後，複製選取的內容\n  paste:\n    title: 粘贴\n    description: 按下后，粘贴剪贴板内容\n  pasteWithOriginLocation:\n    title: 原位粘贴\n    description: 按下后，粘贴的内容会与原位置重叠\n  selectEntityByPenStroke:\n    title: 塗鴉與實體的擴散選擇\n    description: '選取一個塗鴉或者實體後，按下此鍵，會擴散選取該實體周圍的實體\n\n      如果目前選取的是塗鴉，則擴散選取塗鴉觸碰到的實體\n\n      如果目前選取的是實體，則擴散選取觸碰到的所有塗鴉\n\n      多次按下後可以多次交替擴散'\n  expandSelectEntity:\n    title: 擴散選擇節點\n    description: 按下後，實體的選取狀態會轉移到子節點上\n  expandSelectEntityReversed:\n    title: 反向擴散選擇節點\n    description: 按下後，實體的選取狀態會轉移到父節點上\n  expandSelectEntityKeepLastSelected:\n    title: 擴散選擇節點（保留目前節點的選擇狀態）\n    description: 按下後，實體的選取狀態會轉移到子節點上，同時保留目前節點的選取狀態\n  expandSelectEntityReversedKeepLastSelected:\n    title: 反向擴散選擇節點（保留目前節點的選擇狀態）\n    description: 按下後，實體的選取狀態會轉移到父節點上，同時保留目前節點的選取狀態\n  checkoutLeftMouseToSelectAndMove:\n    title: 設定左鍵為“選取/移動”模式\n    description: 也就是滑鼠左鍵切換為正常模式\n  checkoutLeftMouseToDrawing:\n    title: 設定左鍵為“塗鴉”模式\n    description: 也就是滑鼠左鍵切換為塗鴉模式，在工具列中有對應按鈕\n  checkoutLeftMouseToConnectAndCutting:\n    title: 設定左鍵為“連線/斬斷”模式\n    description: 也就是滑鼠左鍵切換為連線/斬斷 模式，在工具列中有對應按鈕\n  checkoutLeftMouseToConnectAndCuttingOnlyPressed:\n    title: 設定左鍵為“連線/斬斷”模式（僅按下時）\n    description: 放開時切換回預設的滑鼠模式\n  penStrokeWidthIncrease:\n    title: 涂鸦笔画变粗\n    description: 按下后，笔画变粗\n  penStrokeWidthDecrease:\n    title: 涂鸦笔画变细\n    description: 按下后，笔画变细\n  screenFlashEffect:\n    title: 屏幕闪黑特效\n    description: 類似於秘笈鍵中的hello world，測試出現黑屏的效果時則證明秘笈鍵系統正常運行了\n  alignNodesToInteger:\n    title: 將所有可連接節點的座標位置對齊到整數\n    description: 可以大幅度减小json文件的体积\n  toggleCheckmarkOnTextNodes:\n    title: 將選取的文字節點都打上勾✅，並標為綠色\n    description: 僅對文字節點生效，選取後再輸入一次可以取消勾選\n  toggleCheckErrorOnTextNodes:\n    title: 將選取的文字節點都打上錯誤❌，並標為紅色\n    description: 僅對文字節點生效，選取後再輸入一次可以取消錯誤標記\n  switchToDarkTheme:\n    title: 切换成黑色主题\n    description: 切换后需要在舞台上划一刀才生生效\n  switchToLightTheme:\n    title: 切换成白色主题\n    description: 切换后需要在舞台上划一刀才生生效\n  switchToParkTheme:\n    title: 切换成公园主题\n    description: 切换后需要在舞台上划一刀才生生效\n  switchToMacaronTheme:\n    title: 切换成马卡龙主题\n    description: 切换后需要在舞台上划一刀才生生效\n  switchToMorandiTheme:\n    title: 切换成莫兰迪主题\n    description: 切换后需要在舞台上划一刀才生生效\n  increasePenAlpha:\n    title: 增加笔刷不透明度通道值\n    description: ''\n  decreasePenAlpha:\n    title: 减少笔刷不透明度通道值\n    description: ''\n  alignTop:\n    title: 頂端對齊\n    description: 數字鍵盤的向上\n  alignBottom:\n    title: 下對齊\n    description: 數字小鍵盤的向下\n  alignLeft:\n    title: 左對齊\n    description: 小鍵盤的向左\n  alignRight:\n    title: 右對齊\n    description: 小鍵盤的向右\n  alignHorizontalSpaceBetween:\n    title: 相等間距水平對齊\n    description: 小鍵盤的左右左右，晃一晃就等間距了\n  alignVerticalSpaceBetween:\n    title: 相等間距垂直對齊\n    description: 小鍵盤的上下上下，晃一晃就等間距了\n  alignCenterHorizontal:\n    title: 水平置中對齊\n    description: 小鍵盤：先中，然後左右\n  alignCenterVertical:\n    title: 垂直置中對齊\n    description: 小鍵盤：先中，然後上下\n  alignLeftToRightNoSpace:\n    title: 向右緊密堆積一排\n    description: 小鍵盤橫著從左到右穿一串\n  alignTopToBottomNoSpace:\n    title: 向下緊密堆積一列\n    description: 小鍵盤直著從上到下穿一串\n  layoutToSquare:\n    title: 松散方阵排列\n  layoutToTightSquare:\n    title: 緊密堆積\n  layoutToTightSquareDeep:\n    title: 遞迴緊密堆積\n  adjustSelectedTextNodeWidthMin:\n    title: 統一寬度為最小值\n    description: 僅對文字節點生效，將所有選取節點的寬度統一為最小值\n  adjustSelectedTextNodeWidthMax:\n    title: 統一寬度為最大值\n    description: 僅對文字節點生效，將所有選取節點的寬度統一為最大值\n  adjustSelectedTextNodeWidthAverage:\n    title: 統一寬度為平均值\n    description: 僅對文字節點生效，將所有選取節點的寬度統一為平均值\n  connectAllSelectedEntities:\n    title: 將所有選取實體進行全連接\n    description: 用於特殊教學場景或圖論教學，\"- -\"開頭表示連線相關\n  connectLeftToRight:\n    title: 將所有選取實體按照從左到右的擺放位置進行連接\n    description: ''\n  connectTopToBottom:\n    title: 將所有選取實體按照從上到下的擺放位置進行連接\n    description: ''\n  selectAllEdges:\n    title: 選取所有連線\n    description: 僅選擇所有視野內的連線\n  colorSelectedRed:\n    title: 將所有選取物體染色為純紅色\n    description: 具體為：(239, 68, 68)，僅作快速標註用\n  increaseBrightness:\n    title: 將所選實體的顏色亮度增加\n    description: 不能對沒有上色的或者透明的實體使用，b是brightness，句號鍵也是>鍵，可以看成往右走，數值增大\n  decreaseBrightness:\n    title: 將所選實體的顏色亮度減少\n    description: 不能對沒有上色的或者透明的實體使用，b是brightness，逗號鍵也是<鍵，可以看成往左走，數值減小\n  gradientColor:\n    title: 將所選實體的顏色漸變\n    description: 後續打算做成更改色相環，目前還不完善\n  changeColorHueUp:\n    title: 將所選實體的顏色色相增加\n    description: 不能對沒有上色的或者透明的實體使用\n  changeColorHueDown:\n    title: 將所選實體的顏色色相減少\n    description: 不能對沒有上色的或者透明的實體使用\n  changeColorHueMajorUp:\n    title: 將所選實體的顏色色相大幅增加\n    description: 不能對沒有上色的或者透明的實體使用\n  changeColorHueMajorDown:\n    title: 將所選實體的顏色色相大幅減少\n    description: 不能對沒有上色的或者透明的實體使用\n  graftNodeToTree:\n    title: 嫁接節點到樹\n    description: 將選取的節點嫁接到碰撞到的連線上，保持原連線方向\n  removeNodeFromTree:\n    title: 從樹中摘除節點\n    description: 將選取的節點從樹中摘出來，並重新連接前後節點\n  toggleTextNodeSizeMode:\n    title: 將選取的文字節點切換大小調整模式\n    description: 僅對文字節點生效，auto模式：輸入文字不能自動換行，manual模式：寬度為框的寬度，寬度超出自動換行\n  decreaseFontSize:\n    title: 减小選取的文字節點字體大小\n    description: 僅對文字節點生效，按下Ctrl+-减小選取的文字節點字體大小\n  increaseFontSize:\n    title: 增大選取的文字節點字體大小\n    description: 僅對文字節點生效，按下Ctrl+=增大選取的文字節點字體大小\n  splitTextNodes:\n    title: 將選取的文字節點，剋(kēi)成小塊\n    description: 僅對文字節點生效，根據標點符號、空格、換行符等進行分割，將其分割成小塊\n  mergeTextNodes:\n    title: 將選取的多個文字節點，挼ruá（合併）成一個文字節點，顏色也會取平均值\n    description: 僅對文字節點生效，順序按從上到下排列，節點的位置按節點矩形左上角頂點座標為準\n  swapTextAndDetails:\n    title: 詳略交換\n    description: 將所有選取的文字節點的詳細資訊和實際內容進行交換，連按5次e，主要用於直接貼上的文字內容想寫入詳細資訊\n  reverseImageColors:\n    title: 反轉圖片顏色\n    description: 反轉選取圖片的顏色（將白色背景變為黑色，反之亦然）\n  treeReverseY:\n    title: 縱向反轉樹狀結構\n    description: 選取樹狀結構的根節點，將其縱向反轉\n  treeReverseX:\n    title: 橫向反轉樹狀結構\n    description: 選取樹狀結構的根節點，將其橫向反轉\n  textNodeTreeToSection:\n    title: 將文字節點樹轉換為框巢狀結構\n    description: 將選取的文字節點樹結構轉換為框巢狀結構\n  switchActiveProject:\n    title: 切換目前專案\n    description: 按下後，切換到下一個專案\n  switchActiveProjectReversed:\n    title: 切換目前專案（反序）\n    description: 按下後，切換到上一個專案\n  closeCurrentProjectTab:\n    title: 關閉目前專案標籤頁\n    description: 關閉目前啟用的專案標籤頁。若有未儲存變更會提示儲存。預設關閉，可在設定中啟用。\n  closeAllSubWindows:\n    title: 關閉所有子窗口\n    description: 關閉當前所有打開的子窗口（如設置、AI、顏色面板等），並將焦點恢復至主畫布。\n  toggleFullscreen:\n    title: 切換全屏\n    description: 在全屏和窗口模式之間切換應用窗口。\n  setWindowToMiniSize:\n    title: 設置窗口為迷你大小\n    description: 將窗口大小設置為設置中配置的迷你窗口寬度和高度。\nsounds:\n  soundEnabled: 音效開關\n"
  },
  {
    "path": "app/src/main.tsx",
    "content": "import { runCli } from \"@/cli\";\nimport { Toaster } from \"@/components/ui/sonner\";\nimport { UserScriptsManager } from \"@/core/plugin/UserScriptsManager\";\nimport { MouseLocation } from \"@/core/service/controlService/MouseLocation\";\nimport { RecentFileManager } from \"@/core/service/dataFileService/RecentFileManager\";\nimport { StartFilesManager } from \"@/core/service/dataFileService/StartFilesManager\";\nimport { ColorManager } from \"@/core/service/feedbackService/ColorManager\";\nimport { QuickSettingsManager } from \"@/core/service/QuickSettingsManager\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { Tutorials } from \"@/core/service/Tourials\";\nimport { UserState } from \"@/core/service/UserState\";\nimport { EdgeCollisionBoxGetter } from \"@/core/stage/stageObject/association/EdgeCollisionBoxGetter\";\nimport { store } from \"@/state\";\nimport { exit, writeStderr } from \"@/utils/otherApi\";\nimport { isDesktop, isMobile, isWeb } from \"@/utils/platform\";\nimport { getCurrentWindow } from \"@tauri-apps/api/window\";\nimport { getMatches } from \"@tauri-apps/plugin-cli\";\nimport { exists } from \"@tauri-apps/plugin-fs\";\nimport \"driver.js/dist/driver.css\";\nimport i18next from \"i18next\";\nimport { Provider } from \"jotai\";\nimport { createRoot } from \"react-dom/client\";\nimport { initReactI18next } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport VConsole from \"vconsole\";\nimport { URI } from \"vscode-uri\";\nimport App from \"./App\";\nimport { onOpenFile } from \"./core/service/GlobalMenu\";\nimport \"./css/index.css\";\n\nif (import.meta.env.DEV && isMobile) {\n  new VConsole();\n}\n\nconst el = document.getElementById(\"root\")!;\n\n// 建议挂载根节点前的一系列操作统一写成函数，\n// 在这里看着清爽一些，像一个列表清单一样。也方便调整顺序\n\n(async () => {\n  const matches = !isWeb && isDesktop ? await getMatches() : null;\n  const isCliMode = isDesktop && matches?.args.output?.occurrences === 1;\n  await Promise.all([\n    RecentFileManager.init(),\n    StartFilesManager.init(),\n    ColorManager.init(),\n    Tutorials.init(),\n    UserScriptsManager.init(),\n    UserState.init(),\n    QuickSettingsManager.init(),\n  ]);\n  // 这些东西依赖上面的东西，所以单独一个Promise.all\n  await Promise.all([loadLanguageFiles(), loadSyncModules()]);\n  await renderApp(isCliMode);\n  await loadStartFile();\n  if (isCliMode) {\n    try {\n      await runCli(matches);\n      exit();\n    } catch (e) {\n      writeStderr(String(e));\n      exit(1);\n    }\n  }\n})();\n\n/** 加载同步初始化的模块 */\nasync function loadSyncModules() {\n  EdgeCollisionBoxGetter.init();\n  // SoundService.init();\n  MouseLocation.init();\n}\n\n/** 加载语言文件 */\nasync function loadLanguageFiles() {\n  i18next.use(initReactI18next).init({\n    lng: Settings.language,\n    // debug会影响性能，并且没什么用，所以关掉\n    // debug: import.meta.env.DEV,\n    debug: false,\n    defaultNS: \"\",\n    fallbackLng: false,\n    saveMissing: false,\n    resources: {\n      en: await import(\"./locales/en.yml\").then((m) => m.default),\n      zh_CN: await import(\"./locales/zh_CN.yml\").then((m) => m.default),\n      zh_TW: await import(\"./locales/zh_TW.yml\").then((m) => m.default),\n      zh_TWC: await import(\"./locales/zh_TWC.yml\").then((m) => m.default),\n      id: await import(\"./locales/id.yml\").then((m) => m.default),\n    },\n  });\n}\n\n/** 渲染应用 */\nasync function renderApp(cli: boolean = false) {\n  const root = createRoot(el);\n  if (cli) {\n    await getCurrentWindow().hide();\n    await getCurrentWindow().setSkipTaskbar(true);\n    root.render(<></>);\n  } else {\n    // if (isMobile) {\n    //   document.querySelector<HTMLMetaElement>(\"meta[name=viewport]\")!.content =\n    //     \"width=device-width, initial-scale=0.5, maximum-scale=0.5, user-scalable=yes, interactive-widget=overlays-content\";\n    //   document.documentElement.style.transform = \"scale(0.5)\";\n    //   document.documentElement.style.transformOrigin = \"top left\";\n    //   document.documentElement.style.overflow = \"hidden\";\n    // }\n    root.render(\n      <Provider store={store}>\n        <Toaster richColors visibleToasts={5} expand />\n        <App />\n      </Provider>,\n    );\n  }\n}\n\nasync function loadStartFile() {\n  const cliMatches = await getMatches();\n  if (cliMatches.args.path.value) {\n    const path = cliMatches.args.path.value as string;\n    const isExists = await exists(path);\n    if (isExists) {\n      onOpenFile(URI.file(path), \"CLI或双击文件\");\n    } else {\n      toast.error(\"文件不存在\");\n    }\n  }\n}\n"
  },
  {
    "path": "app/src/state.tsx",
    "content": "import { atom, createStore } from \"jotai\";\nimport { Project } from \"@/core/Project\";\n/**\n * 全局状态管理\n */\n\nexport const store = createStore();\n\nexport const projectsAtom = atom<Project[]>([]);\nexport const activeProjectAtom = atom<Project | undefined>(undefined);\nexport const isClassroomModeAtom = atom(false);\n// export const isPrivacyModeAtom = atom(false);\nexport const nextProjectIdAtom = atom(1);\nexport const contextMenuTooltipWordsAtom = atom<string>(\"\");\nexport const isWindowAlwaysOnTopAtom = atom<boolean>(false);\n\nexport const isWindowMaxsizedAtom = atom<boolean>(false);\n// 窗口穿透点击相关\nexport const isClickThroughEnabledAtom = atom<boolean>(false);\n\nexport const isDevAtom = atom<boolean>(false);\n"
  },
  {
    "path": "app/src/sub/AIToolsWindow.tsx",
    "content": "import { Collapsible, CollapsibleContent, CollapsibleTrigger } from \"@/components/ui/collapsible\";\nimport { AITools } from \"@/core/service/dataManageService/aiEngine/AITools\";\nimport { SubWindow } from \"@/core/service/SubWindow\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { ChevronRight, Wrench } from \"lucide-react\";\n\nexport default function AIToolsWindow() {\n  const tools = AITools.tools;\n\n  return (\n    <div className=\"flex h-full flex-col gap-2 overflow-y-auto p-3\">\n      <div className=\"text-muted-foreground mb-1 text-sm\">\n        共 {tools.length} 个工具，您可以通过查看AI工具，了解AI的能力范围\n      </div>\n      {tools.map((tool) => {\n        const params = tool.function.parameters as Record<string, any> | undefined;\n        const props = params?.properties as Record<string, any> | undefined;\n        const required: string[] = params?.required ?? [];\n        const hasParams = props && Object.keys(props).length > 0;\n\n        return (\n          <div key={tool.function.name} className=\"rounded-lg border px-3 py-2\">\n            <div className=\"flex items-center gap-2 font-mono text-sm font-semibold\">\n              <Wrench className=\"h-4 w-4 shrink-0\" />\n              {tool.function.name}\n            </div>\n            {tool.function.description && (\n              <div className=\"text-muted-foreground mt-1 text-sm\">{tool.function.description}</div>\n            )}\n            {hasParams && (\n              <Collapsible className=\"group/collapsible mt-2\">\n                <CollapsibleTrigger className=\"text-muted-foreground hover:text-foreground flex cursor-pointer items-center gap-1 text-xs\">\n                  <ChevronRight className=\"h-3 w-3 transition-transform group-data-[state=open]/collapsible:rotate-90\" />\n                  参数\n                </CollapsibleTrigger>\n                <CollapsibleContent className=\"animate-none! mt-1\">\n                  <div className=\"flex flex-col gap-1\">\n                    {Object.entries(props!).map(([key, schema]) => {\n                      const isRequired = required.includes(key);\n                      const type: string =\n                        schema.type ??\n                        (schema.anyOf ? schema.anyOf.map((s: any) => s.type ?? s.const).join(\" | \") : \"any\");\n                      const desc: string | undefined = schema.description;\n                      return (\n                        <div key={key} className=\"bg-muted rounded px-2 py-1 font-mono text-xs\">\n                          <span className=\"text-foreground font-semibold\">{key}</span>\n                          <span className=\"text-muted-foreground ml-1\">{type}</span>\n                          {isRequired && <span className=\"ml-1 text-red-400\">*</span>}\n                          {desc && <div className=\"text-muted-foreground mt-0.5 font-sans\">{desc}</div>}\n                        </div>\n                      );\n                    })}\n                  </div>\n                </CollapsibleContent>\n              </Collapsible>\n            )}\n            {!hasParams && <div className=\"text-muted-foreground mt-1 text-xs\">无参数</div>}\n          </div>\n        );\n      })}\n    </div>\n  );\n}\nAIToolsWindow.open = () => {\n  SubWindow.create({\n    title: \"AI 工具列表\",\n    children: <AIToolsWindow />,\n    rect: new Rectangle(new Vector(100, 100), new Vector(400, 500)),\n  });\n};\n"
  },
  {
    "path": "app/src/sub/AIWindow.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from \"@/components/ui/collapsible\";\nimport Markdown from \"@/components/ui/markdown\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { AITools } from \"@/core/service/dataManageService/aiEngine/AITools\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { SubWindow } from \"@/core/service/SubWindow\";\nimport { activeProjectAtom } from \"@/state\";\nimport SettingsWindow from \"@/sub/SettingsWindow\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { useAtom } from \"jotai\";\nimport { Bot, BrainCircuit, ChevronRight, FolderOpen, Loader2, Send, SettingsIcon, User, Wrench } from \"lucide-react\";\nimport OpenAI from \"openai\";\nimport { useRef, useState } from \"react\";\nimport { toast } from \"sonner\";\n\nexport default function AIWindow() {\n  const [project] = useAtom(activeProjectAtom);\n  const [inputValue, setInputValue] = useState(\"\");\n  const [messages, setMessages] = useState<(OpenAI.ChatCompletionMessageParam & { tokens?: number })[]>([\n    {\n      role: \"system\",\n      content:\n        \"尽可能尝试使用工具解决问题，如果实在不行才能问用户。TextNode正常情况下高度为75，多个节点叠起来时需要适当留padding。节点正常情况下的颜色应该是透明[0,0,0,0]，注意透明色并非是“看不见文本”\",\n    },\n  ]);\n  const [requesting, setRequesting] = useState(false);\n  const [totalInputTokens, setTotalInputTokens] = useState(0);\n  const [totalOutputTokens, setTotalOutputTokens] = useState(0);\n  const [executingToolIds, setExecutingToolIds] = useState<Set<string>>(new Set());\n  const messagesElRef = useRef<HTMLDivElement>(null);\n  const [showTokenCount] = Settings.use(\"aiShowTokenCount\");\n\n  /**\n   * 添加一条消息到消息列表中\n   * @param message\n   */\n  function addMessage(message: OpenAI.ChatCompletionMessageParam & { tokens?: number }) {\n    setMessages((prev) => [...prev, message]);\n  }\n  function setLastMessage(msg: OpenAI.ChatCompletionMessageParam) {\n    setMessages((prev) => {\n      const newMessages = [...prev];\n      newMessages[newMessages.length - 1] = msg;\n      return newMessages;\n    });\n  }\n\n  function scrollToBottom() {\n    if (messagesElRef.current) {\n      messagesElRef.current.scrollTo({ top: messagesElRef.current.scrollHeight });\n    }\n  }\n\n  async function run(msgs: OpenAI.ChatCompletionMessageParam[] = [...messages, { role: \"user\", content: inputValue }]) {\n    if (!project) return;\n    scrollToBottom();\n    setRequesting(true);\n    try {\n      // 清理消息：移除空的 tool_calls 数组\n      const cleanedMsgs = msgs.map((msg) => {\n        if (msg.role === \"assistant\" && Array.isArray(msg.tool_calls) && msg.tool_calls.length === 0) {\n          // 创建新对象，删除 tool_calls 字段\n          // eslint-disable-next-line @typescript-eslint/no-unused-vars\n          const { tool_calls, ...rest } = msg;\n          return rest;\n        }\n        return msg;\n      });\n      const stream = await project.aiEngine.chat(cleanedMsgs);\n      addMessage({\n        role: \"assistant\",\n        content: \"Requesting...\",\n      });\n      const streamingMsg: OpenAI.ChatCompletionAssistantMessageParam = {\n        role: \"assistant\",\n        content: \"\",\n      };\n      let lastChunk: OpenAI.ChatCompletionChunk | null = null;\n      for await (const chunk of stream) {\n        // 当 stream_options.include_usage=true 时，最后一个 chunk 的 choices 为空数组，仅携带 usage\n        if (!chunk.choices || chunk.choices.length === 0) {\n          lastChunk = chunk;\n          continue;\n        }\n        const delta = chunk.choices[0].delta;\n        streamingMsg.content! += delta.content ?? \"\";\n        const toolCalls = delta.tool_calls || [];\n\n        // 如果有工具调用，确保 tool_calls 数组已初始化\n        if (toolCalls.length > 0 && streamingMsg.tool_calls === undefined) {\n          streamingMsg.tool_calls = [];\n        }\n\n        for (const toolCall of toolCalls) {\n          // 此时 tool_calls 数组一定存在\n          const index =\n            toolCall.index !== undefined\n              ? toolCall.index\n              : toolCall.type\n                ? streamingMsg.tool_calls!.length\n                : streamingMsg.tool_calls!.length - 1;\n\n          // 确保索引有效\n          if (index >= streamingMsg.tool_calls!.length) {\n            streamingMsg.tool_calls![index] = {\n              id: toolCall.id || crypto.randomUUID(),\n              type: \"function\",\n              function: {\n                name: \"\",\n                arguments: \"\",\n              },\n            };\n          }\n\n          // 更新工具调用信息\n          if (toolCall.id) streamingMsg.tool_calls![index].id = toolCall.id;\n          if (toolCall.function?.name) streamingMsg.tool_calls![index].function.name += toolCall.function.name;\n          if (toolCall.function?.arguments)\n            streamingMsg.tool_calls![index].function.arguments += toolCall.function.arguments;\n        }\n\n        setLastMessage(streamingMsg);\n        scrollToBottom();\n        lastChunk = chunk;\n      }\n      setRequesting(false);\n      if (!lastChunk) return;\n      if (!lastChunk.usage) return;\n      setTotalInputTokens((v) => v + lastChunk.usage!.prompt_tokens);\n      setTotalOutputTokens((v) => v + lastChunk.usage!.completion_tokens);\n      scrollToBottom();\n      // 如果有工具调用，执行工具调用\n      console.log(streamingMsg.tool_calls);\n      if (streamingMsg.tool_calls && streamingMsg.tool_calls.length > 0) {\n        const toolMsgs: OpenAI.ChatCompletionToolMessageParam[] = [];\n        for (const toolCall of streamingMsg.tool_calls) {\n          // 添加工具ID到执行中集合\n          setExecutingToolIds((prev) => new Set([...prev, toolCall.id!]));\n          const tool = AITools.handlers.get(toolCall.function.name);\n          if (!tool) {\n            setExecutingToolIds((prev) => {\n              const newSet = new Set(prev);\n              newSet.delete(toolCall.id!);\n              return newSet;\n            });\n            return;\n          }\n          let observation = \"\";\n          try {\n            const result = await tool(project, JSON.parse(toolCall.function.arguments));\n            if (typeof result === \"string\") {\n              observation = result;\n            } else if (typeof result === \"object\") {\n              observation = JSON.stringify(result);\n            } else {\n              observation = String(result);\n            }\n          } catch (e) {\n            observation = `工具调用失败：${(e as Error).message}`;\n          } finally {\n            // 无论成功还是失败，都从执行中集合移除\n            setExecutingToolIds((prev) => {\n              const newSet = new Set(prev);\n              newSet.delete(toolCall.id!);\n              return newSet;\n            });\n          }\n          const msg = {\n            role: \"tool\" as const,\n            content: observation,\n            tool_call_id: toolCall.id!,\n          };\n          addMessage(msg);\n          toolMsgs.push(msg);\n        }\n        // 工具调用结束后，重新发送消息，让模型继续思考\n        run([...msgs, streamingMsg, ...toolMsgs]);\n      }\n    } catch (e) {\n      setRequesting(false);\n      if (e instanceof Error) {\n        console.error(e);\n      }\n      toast.error(String(e));\n      addMessage({\n        role: \"assistant\",\n        content: String(e),\n      });\n    }\n  }\n\n  function handleUserSend() {\n    if (!inputValue.trim()) return;\n    addMessage({ role: \"user\", content: inputValue });\n    setInputValue(\"\");\n    run();\n  }\n\n  function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {\n    if (e.key === \"Enter\" && !e.shiftKey) {\n      e.preventDefault();\n      handleUserSend();\n    }\n    // shift+enter 允许默认行为（换行）\n  }\n\n  return project ? (\n    <div className=\"flex h-full flex-col p-2\">\n      {/* 消息列表 */}\n      <div className=\"flex flex-1 select-text flex-col gap-2 overflow-y-auto\" ref={messagesElRef}>\n        {messages.map((msg, i) =>\n          msg.role === \"user\" ? (\n            <div key={i} className=\"flex justify-end\">\n              <div className=\"max-w-11/12 bg-accent text-accent-foreground rounded-2xl rounded-br-none px-3 py-2\">\n                {msg.content as string}\n              </div>\n            </div>\n          ) : msg.role === \"assistant\" ? (\n            <div key={i} className=\"flex flex-col gap-2\">\n              {msg.content && typeof msg.content === \"string\" && (\n                <>\n                  {msg.content.startsWith(\"<think>\") && (\n                    <Collapsible className=\"group/collapsible\" defaultOpen={!msg.content.includes(\"</think>\")}>\n                      <CollapsibleTrigger className=\"flex items-center gap-2\">\n                        <BrainCircuit />\n                        <span>思考中</span>\n                        <ChevronRight className=\"transition-transform group-data-[state=open]/collapsible:rotate-90\" />\n                      </CollapsibleTrigger>\n                      <CollapsibleContent className=\"animate-none! mt-2 rounded-lg border px-3 py-2 opacity-50\">\n                        <span className=\"text-sm\">\n                          <Markdown source={msg.content.split(\"<think>\")[1].split(\"</think>\")[0]} />\n                        </span>\n                      </CollapsibleContent>\n                    </Collapsible>\n                  )}\n                  <Markdown\n                    source={msg.content.includes(\"</think>\") ? msg.content.split(\"</think>\")[1] : msg.content}\n                  />\n                </>\n              )}\n              {msg.tool_calls &&\n                msg.tool_calls.map((toolCall) => (\n                  <Collapsible className=\"group/collapsible\" key={toolCall.id}>\n                    <CollapsibleTrigger\n                      className={`flex cursor-pointer items-center gap-2 ${executingToolIds.has(toolCall.id!) ? \"animate-blink\" : \"\"}`}\n                    >\n                      <Wrench />\n                      <span>{toolCall.function.name}</span>\n                      <ChevronRight className=\"transition-transform group-data-[state=open]/collapsible:rotate-90\" />\n                    </CollapsibleTrigger>\n                    <CollapsibleContent className=\"animate-none! mt-2 rounded-lg border px-3 py-2 opacity-50\">\n                      <div className=\"overflow-visible whitespace-pre-wrap break-words text-sm\">\n                        <Markdown source={`\\`\\`\\`json\\n${toolCall.function.arguments}\\n\\`\\`\\``} />\n                      </div>\n                    </CollapsibleContent>\n                  </Collapsible>\n                ))}\n            </div>\n          ) : (\n            <></>\n          ),\n        )}\n      </div>\n      {/* 输入框 */}\n      <div className=\"mb-2 flex gap-2\">\n        <SettingsIcon className=\"cursor-pointer\" onClick={() => SettingsWindow.open(\"settings\")} />\n        {showTokenCount && (\n          <>\n            <div className=\"flex-1\"></div>\n            <User />\n            <span>{totalInputTokens}</span>\n            <Bot />\n            <span>{totalOutputTokens}</span>\n          </>\n        )}\n        <div className=\"flex-1\"></div>\n        {requesting ? (\n          <Loader2 className=\"animate-spin\" />\n        ) : (\n          <Button className=\"cursor-pointer\" onClick={handleUserSend}>\n            <Send />\n          </Button>\n        )}\n      </div>\n      <Textarea\n        placeholder=\"What can I say?\"\n        onChange={(e) => setInputValue(e.target.value)}\n        onKeyDown={handleKeyDown}\n        value={inputValue}\n      />\n    </div>\n  ) : (\n    <div className=\"flex flex-col gap-2 p-8\">\n      <FolderOpen />\n      请先打开一个文件\n    </div>\n  );\n}\n\nAIWindow.open = () => {\n  SubWindow.create({\n    title: \"AI\",\n    children: <AIWindow />,\n    rect: new Rectangle(new Vector(8, 88), new Vector(350, window.innerHeight - 96)),\n  });\n};\n"
  },
  {
    "path": "app/src/sub/AttachmentsWindow.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { Card } from \"@/components/ui/card\";\nimport { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from \"@/components/ui/context-menu\";\nimport { Dialog } from \"@/components/ui/dialog\";\nimport { Popover } from \"@/components/ui/popover\";\nimport { SubWindow } from \"@/core/service/SubWindow\";\nimport { activeProjectAtom } from \"@/state\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { open, save } from \"@tauri-apps/plugin-dialog\";\nimport { readFile, writeFile } from \"@tauri-apps/plugin-fs\";\nimport { useAtom } from \"jotai\";\nimport { BrushCleaning, FileOutput, Plus, RefreshCcw, Trash } from \"lucide-react\";\nimport mime from \"mime\";\nimport { useEffect, useState } from \"react\";\nimport { toast } from \"sonner\";\n\nexport default function AttachmentsWindow() {\n  const [project] = useAtom(activeProjectAtom);\n  if (!project) return <></>;\n  const [attachments, setAttachments] = useState(new Map<string, Blob>());\n  const [urls, setUrls] = useState(new Map<string, string>());\n\n  function refresh() {\n    setAttachments(project!.attachments);\n  }\n  useEffect(() => {\n    refresh();\n  }, []);\n\n  useEffect(() => {\n    const newUrls = new Map<string, string>();\n    attachments.forEach((blob, id) => {\n      const url = URL.createObjectURL(blob);\n      newUrls.set(id, url);\n    });\n    setUrls(newUrls);\n\n    return () => {\n      newUrls.forEach((url) => {\n        URL.revokeObjectURL(url);\n      });\n    };\n  }, [attachments]);\n\n  return (\n    <div className=\"flex flex-col gap-2 p-2\">\n      <div className=\"flex gap-3\">\n        <Button\n          onClick={async () => {\n            const path = await open();\n            if (!path) return;\n            const uuid = await Dialog.input(\"附件 ID\", \"如果不需要自定义就直接点确定\", {\n              defaultValue: crypto.randomUUID(),\n            });\n            if (!uuid) return;\n            const u8a = await readFile(path);\n            const blob = new Blob([new Uint8Array(u8a)], { type: mime.getType(path) || \"application/octet-stream\" });\n            project.attachments.set(uuid, blob);\n            refresh();\n          }}\n        >\n          <Plus />\n          添加\n        </Button>\n        <Button onClick={refresh} variant=\"outline\">\n          <RefreshCcw />\n          刷新\n        </Button>\n        <Popover.Confirm\n          title=\"清理附件\"\n          description=\"删除所有未被实体引用的附件，且此操作不可撤销，是否继续？\"\n          onConfirm={async () => {\n            let deletedCount = 0;\n            const referencedAttachmentIds = project.stageManager\n              .getEntities()\n              .map((it) => (\"attachmentId\" in it ? (it.attachmentId as string) : \"\"))\n              .filter(Boolean);\n            for (const id of project.attachments.keys()) {\n              if (!referencedAttachmentIds.includes(id)) {\n                project.attachments.delete(id);\n                deletedCount++;\n              }\n            }\n            toast.success(`已清理 ${deletedCount} 个未被引用的附件`);\n            setTimeout(() => {\n              refresh();\n            }, 500); // TODO: 在windows上未生效\n          }}\n          destructive\n        >\n          <Button variant=\"outline\">\n            <BrushCleaning />\n            清理\n          </Button>\n        </Popover.Confirm>\n      </div>\n      <div>\n        <span className=\"text-xs opacity-50\">提示：对着附件右键可进行操作</span>\n      </div>\n\n      {/* 一个又一个的附件展示 */}\n      <div className=\"flex flex-wrap gap-2\">\n        {attachments.entries().map(([id, blob]) => (\n          <ContextMenu key={id}>\n            {/* 非右键的直接展示部分 */}\n            <ContextMenuTrigger>\n              <Card className=\"gap-2 p-2\">\n                {/* <Separator /> */}\n                {blob.type.startsWith(\"image\") && (\n                  <img src={urls.get(id)} alt={id} className=\"max-w-full rounded-lg object-contain\" />\n                )}\n                <div className=\"flex flex-col gap-0.5\">\n                  <span className=\"text-[8px] opacity-50\">{id}</span>\n                  <div className=\"flex flex-wrap gap-x-2 text-sm\">\n                    <span>{blob.type}</span>\n                    <span>{formatBytes(blob.size)}</span>\n                  </div>\n                </div>\n              </Card>\n            </ContextMenuTrigger>\n\n            {/* 右键内容 */}\n            <ContextMenuContent>\n              <ContextMenuItem\n                onClick={async () => {\n                  const path = await save({\n                    filters: [\n                      {\n                        name: blob.type,\n                        extensions: [...(mime.getAllExtensions(blob.type) ?? [])],\n                      },\n                    ],\n                  });\n                  if (!path) return;\n                  await writeFile(path, new Uint8Array(await blob.arrayBuffer()));\n                }}\n              >\n                <FileOutput />\n                导出\n              </ContextMenuItem>\n              <ContextMenuItem\n                variant=\"destructive\"\n                onClick={async () => {\n                  if (await Dialog.confirm(\"删除附件\", \"所有引用了此附件的实体将无法正常渲染\", { destructive: true })) {\n                    project.attachments.delete(id);\n                    refresh();\n                  }\n                }}\n              >\n                <Trash />\n                删除\n              </ContextMenuItem>\n            </ContextMenuContent>\n          </ContextMenu>\n        ))}\n      </div>\n    </div>\n  );\n}\n\n/** 将bytes转换为人类可读形式 */\nfunction formatBytes(bytes: number, decimals = 2): string {\n  if (bytes === 0) return \"0 Bytes\";\n  const k = 1024;\n  const dm = decimals < 0 ? 0 : decimals;\n  const sizes = [\"Bytes\", \"KB\", \"MB\", \"GB\", \"TB\", \"PB\", \"EB\", \"ZB\", \"YB\"];\n  const i = Math.floor(Math.log(bytes) / Math.log(k));\n  return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;\n}\n\nAttachmentsWindow.open = () => {\n  SubWindow.create({\n    title: \"附件管理器\",\n    children: <AttachmentsWindow />,\n    rect: new Rectangle(new Vector(100, 100), new Vector(300, 600)),\n  });\n};\n"
  },
  {
    "path": "app/src/sub/AutoCompleteWindow.tsx",
    "content": "import { SubWindow } from \"@/core/service/SubWindow\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\n\nexport default function AutoCompleteWindow({\n  // winId = \"\",\n  items = {},\n  onSelect = () => {},\n}: {\n  // winId?: string;\n  items: Record<string, string>;\n  onSelect: (value: string) => void;\n}) {\n  return (\n    <div className=\"flex max-h-96 flex-col gap-1 p-2\">\n      {Object.entries(items).map(([k, v]) => (\n        <div key={k} className=\"flex justify-between\" onClick={() => onSelect(k)}>\n          <span className=\"mr-2\">{k}</span>\n          <span className=\"opacity-75\">{v}</span>\n        </div>\n      ))}\n    </div>\n  );\n}\n\nAutoCompleteWindow.open = (location: Vector, items: Record<string, string>, onSelect: (value: string) => void) => {\n  return SubWindow.create({\n    children: <AutoCompleteWindow items={items} onSelect={onSelect} />,\n    rect: new Rectangle(location, Vector.same(-1)),\n    closeWhenClickOutside: true,\n    titleBarOverlay: true,\n    closable: false,\n  });\n};\n"
  },
  {
    "path": "app/src/sub/AutoComputeWindow.tsx",
    "content": "import {\n  LogicNodeNameEnum,\n  LogicNodeNameToArgsTipsMap,\n  LogicNodeNameToRenderNameMap,\n} from \"@/core/service/dataGenerateService/autoComputeEngine/logicNodeNameEnum\";\nimport { SubWindow } from \"@/core/service/SubWindow\";\nimport { CollisionBox } from \"@/core/stage/stageObject/collisionBox/collisionBox\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { activeProjectAtom } from \"@/state\";\nimport { cn } from \"@/utils/cn\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { useAtom } from \"jotai\";\n\n/**\n *\n */\nexport default function LogicNodePanel({ className = \"\" }: { className?: string }) {\n  const [project] = useAtom(activeProjectAtom);\n  return (\n    <div className={cn(\"flex h-full w-full flex-col p-2 pb-32 transition-all\", className)}>\n      <table className=\"w-full\">\n        <thead>\n          <tr className=\"text-left\">\n            <th>节点名称</th>\n            <th>参数说明</th>\n          </tr>\n        </thead>\n        <tbody>\n          {Object.values(LogicNodeNameEnum).map((name) => {\n            return (\n              <tr\n                key={name}\n                className=\"text-xs opacity-80 hover:opacity-100\"\n                onClick={() => {\n                  project?.stageManager.add(\n                    new TextNode(project, {\n                      collisionBox: new CollisionBox([\n                        new Rectangle(\n                          new Vector(project.camera.location.x, project.camera.location.y),\n                          Vector.getZero(),\n                        ),\n                      ]),\n                      text: name,\n                    }),\n                  );\n                }}\n              >\n                <td className=\"cursor-pointer\">{LogicNodeNameToRenderNameMap[name]}</td>\n                <td className=\"cursor-pointer\">{LogicNodeNameToArgsTipsMap[name]}</td>\n              </tr>\n            );\n          })}\n        </tbody>\n      </table>\n    </div>\n  );\n}\n\nLogicNodePanel.open = () => {\n  SubWindow.create({\n    title: \"逻辑节点\",\n    children: <LogicNodePanel />,\n    rect: new Rectangle(new Vector(100, 100), new Vector(500, 600)),\n    // closeWhenClickOutside: true,\n    // closeWhenClickInside: true,\n  });\n};\n"
  },
  {
    "path": "app/src/sub/BackgroundManagerWindow.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { SubWindow } from \"@/core/service/SubWindow\";\nimport { ImageNode } from \"@/core/stage/stageObject/entity/ImageNode\";\nimport { activeProjectAtom } from \"@/state\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { useAtom } from \"jotai\";\nimport { useEffect, useState } from \"react\";\nimport { toast } from \"sonner\";\n\nexport default function BackgroundManagerWindow() {\n  const [project] = useAtom(activeProjectAtom);\n  const [backgroundImages, setBackgroundImages] = useState<ImageNode[]>([]);\n\n  useEffect(() => {\n    if (project) {\n      // 获取所有背景化的图片\n      const images = project.stageManager.getImageNodes().filter((imageNode) => imageNode.isBackground);\n      setBackgroundImages(images);\n    }\n  }, [project]);\n\n  const handleRemoveBackground = (imageNode: ImageNode) => {\n    if (project) {\n      imageNode.isBackground = false;\n      project.historyManager.recordStep();\n      toast.success(\"已取消图片的背景化\");\n      // 刷新背景图片列表\n      const images = project.stageManager.getImageNodes().filter((imageNode) => imageNode.isBackground);\n      setBackgroundImages(images);\n    }\n  };\n\n  return (\n    <div className=\"flex flex-col gap-4 p-4\">\n      <h1 className=\"text-xl font-semibold\">背景管理器</h1>\n      <div className=\"flex-1\">\n        {backgroundImages.length === 0 ? (\n          <p className=\"text-muted-foreground text-center\">当前舞台上没有背景化的图片</p>\n        ) : (\n          <div className=\"space-y-4\">\n            {backgroundImages.map((imageNode) => (\n              <div key={imageNode.uuid} className=\"flex items-center justify-between rounded-lg border p-3\">\n                <div className=\"flex items-center space-x-3\">\n                  <div className=\"bg-muted flex h-16 w-16 items-center justify-center rounded\">\n                    <span className=\"text-muted-foreground text-sm\">图片</span>\n                  </div>\n                  <div>\n                    <p className=\"font-medium\">图片节点</p>\n                    <p className=\"text-muted-foreground text-xs\">{imageNode.uuid.substring(0, 8)}...</p>\n                  </div>\n                </div>\n                <Button variant=\"destructive\" size=\"sm\" onClick={() => handleRemoveBackground(imageNode)}>\n                  取消背景化\n                </Button>\n              </div>\n            ))}\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\nBackgroundManagerWindow.open = () => {\n  SubWindow.create({\n    title: \"背景管理器\",\n    children: <BackgroundManagerWindow />,\n    rect: new Rectangle(new Vector(100, 100), new Vector(400, 500)),\n  });\n};\n"
  },
  {
    "path": "app/src/sub/ColorPaletteWindow.tsx",
    "content": "import { MouseLocation } from \"@/core/service/controlService/MouseLocation\";\nimport { SubWindow } from \"@/core/service/SubWindow\";\nimport { activeProjectAtom } from \"@/state\";\nimport { Color, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { useAtom } from \"jotai\";\nimport { useEffect, useState } from \"react\";\n\ninterface ColorInfo {\n  color: Color;\n  count: number;\n}\n\n/**\n * 颜色表窗口 - 显示当前文件中所有颜色及其使用数量\n */\nexport default function ColorPaletteWindow() {\n  const [project] = useAtom(activeProjectAtom);\n  const [colorInfos, setColorInfos] = useState<ColorInfo[]>([]);\n\n  useEffect(() => {\n    if (!project) return;\n\n    // 统计所有颜色及其使用数量\n    // 使用颜色字符串作为key，格式：r,g,b,a\n    const colorMap = new Map<string, { color: Color; count: number }>();\n\n    // 获取颜色key的函数\n    const getColorKey = (color: Color): string => {\n      return `${color.r},${color.g},${color.b},${color.a}`;\n    };\n\n    // 遍历所有实体\n    for (const entity of project.stageManager.getEntities()) {\n      if (\"color\" in entity && entity.color instanceof Color) {\n        const colorKey = getColorKey(entity.color);\n        if (colorMap.has(colorKey)) {\n          colorMap.get(colorKey)!.count++;\n        } else {\n          colorMap.set(colorKey, { color: entity.color, count: 1 });\n        }\n      }\n    }\n\n    // 遍历所有关系\n    for (const association of project.stageManager.getAssociations()) {\n      if (\"color\" in association && association.color instanceof Color) {\n        const colorKey = getColorKey(association.color);\n        if (colorMap.has(colorKey)) {\n          colorMap.get(colorKey)!.count++;\n        } else {\n          colorMap.set(colorKey, { color: association.color, count: 1 });\n        }\n      }\n    }\n\n    // 转换为数组并按使用数量排序（从多到少）\n    const colors = Array.from(colorMap.values())\n      .map((item) => ({ color: item.color, count: item.count }))\n      .sort((a, b) => b.count - a.count);\n\n    setColorInfos(colors);\n  }, [project]);\n\n  const handleColorClick = (color: Color) => {\n    if (project) {\n      project.stageObjectColorManager.setSelectedStageObjectColor(color);\n    }\n  };\n\n  return (\n    <div className=\"bg-panel-bg flex flex-col p-4\">\n      <div className=\"mb-2 text-sm font-semibold\">当前文件包含的所有颜色：</div>\n      {colorInfos.length === 0 ? (\n        <div className=\"text-center text-sm text-gray-500\">暂无颜色</div>\n      ) : (\n        <div className=\"flex max-w-96 flex-wrap gap-2\">\n          {colorInfos.map((colorInfo, index) => {\n            const { color, count } = colorInfo;\n            return (\n              <div\n                key={`${color.r},${color.g},${color.b},${color.a}-${index}`}\n                className=\"relative flex cursor-pointer flex-col items-center\"\n                onClick={() => handleColorClick(color)}\n              >\n                <div\n                  className=\"h-12 w-12 rounded border-2 border-gray-300 hover:scale-110 hover:border-blue-500\"\n                  style={{\n                    backgroundColor: `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`,\n                  }}\n                />\n                <div className=\"mt-1 text-xs font-semibold\">{count}</div>\n              </div>\n            );\n          })}\n        </div>\n      )}\n      <div className=\"mt-4 text-xs text-gray-500\">提示：点击颜色块可以设置当前选中对象的颜色</div>\n    </div>\n  );\n}\n\nColorPaletteWindow.open = () => {\n  SubWindow.create({\n    title: \"当前文件颜色表\",\n    children: <ColorPaletteWindow />,\n    rect: new Rectangle(MouseLocation.vector().clone(), new Vector(400, 400)),\n    closeWhenClickOutside: true,\n    closeWhenClickInside: false,\n  });\n};\n"
  },
  {
    "path": "app/src/sub/ColorWindow.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { MouseLocation } from \"@/core/service/controlService/MouseLocation\";\nimport { ColorManager } from \"@/core/service/feedbackService/ColorManager\";\nimport { SubWindow } from \"@/core/service/SubWindow\";\nimport { LineEdge } from \"@/core/stage/stageObject/association/LineEdge\";\nimport { Section } from \"@/core/stage/stageObject/entity/Section\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { activeProjectAtom } from \"@/state\";\nimport { Color, Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { useAtom } from \"jotai\";\nimport { ArrowRightLeft, Blend, Pipette } from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\nimport { toast } from \"sonner\";\n\n/**\n * 上色盘面板\n * @param param0\n * @returns\n */\nexport default function ColorWindow() {\n  const [currentColors, setCurrentColors] = useState<Color[]>([]);\n  const [project] = useAtom(activeProjectAtom);\n\n  useEffect(() => {\n    ColorManager.getUserEntityFillColors().then((colors) => {\n      setCurrentColors(colors);\n    });\n  }, []);\n\n  const handleChagneColor = (color: Color) => {\n    return () => {\n      project?.stageObjectColorManager.setSelectedStageObjectColor(color);\n    };\n  };\n\n  return (\n    <div className=\"flex flex-col\">\n      {/* 官方提供的默认颜色 */}\n      <div className=\"flex flex-wrap items-center justify-center\">\n        <div\n          className=\"m-1 h-5 w-5 cursor-pointer rounded bg-red-500 hover:scale-125\"\n          onClick={handleChagneColor(new Color(239, 68, 68))}\n          onMouseEnter={handleChagneColor(new Color(239, 68, 68))}\n        />\n        <div\n          className=\"m-1 h-5 w-5 cursor-pointer rounded bg-yellow-500 hover:scale-125\"\n          onClick={handleChagneColor(new Color(234, 179, 8))}\n          onMouseEnter={handleChagneColor(new Color(234, 179, 8))}\n        />\n        <div\n          className=\"m-1 h-5 w-5 cursor-pointer rounded bg-green-600 hover:scale-125\"\n          onClick={handleChagneColor(new Color(22, 163, 74))}\n          onMouseEnter={handleChagneColor(new Color(22, 163, 74))}\n        />\n        <div\n          className=\"m-1 h-5 w-5 cursor-pointer rounded bg-blue-500 hover:scale-125\"\n          onClick={handleChagneColor(new Color(59, 130, 246))}\n          onMouseEnter={handleChagneColor(new Color(59, 130, 246))}\n        />\n        <div\n          className=\"m-1 h-5 w-5 cursor-pointer rounded bg-purple-500 hover:scale-125\"\n          onClick={handleChagneColor(new Color(168, 85, 247))}\n          onMouseEnter={handleChagneColor(new Color(168, 85, 247))}\n        />\n        {/* 清除颜色 */}\n        <div\n          className=\"m-1 h-5 w-5 animate-pulse cursor-pointer rounded bg-transparent text-center text-sm hover:scale-125\"\n          onClick={handleChagneColor(Color.Transparent)}\n          onMouseEnter={handleChagneColor(Color.Transparent)}\n        >\n          <Blend className=\"h-5 w-5\" />\n        </div>\n      </div>\n      {/* 按钮 */}\n      <div className=\"flex flex-wrap items-center justify-center\">\n        {/* 临时自定义 */}\n        <input\n          type=\"color\"\n          id=\"colorPicker\"\n          value=\"#ff0000\"\n          onChange={(e) => {\n            const color = e.target.value;\n            const r = parseInt(color.slice(1, 3), 16);\n            const g = parseInt(color.slice(3, 5), 16);\n            const b = parseInt(color.slice(5, 7), 16);\n            project?.stageObjectColorManager.setSelectedStageObjectColor(new Color(r, g, b));\n          }}\n          onClick={(e) => e.stopPropagation()}\n        ></input>\n        <Button\n          onClick={() => {\n            ColorManagerPanel.open();\n          }}\n        >\n          打开颜色管理\n        </Button>\n      </div>\n      {/* <hr className=\"text-panel-details-text my-2\" /> */}\n      {/* 用户颜色库 */}\n      <div className=\"flex max-w-64 flex-1 flex-wrap items-center justify-center\">\n        {currentColors.length === 0 && (\n          <div className=\"m-1 h-5 w-5 rounded bg-transparent text-center text-sm\">暂无颜色</div>\n        )}\n        {currentColors.map((color) => {\n          return (\n            <div\n              className=\"m-1 h-5 w-5 cursor-pointer rounded hover:scale-125\"\n              key={color.toString()}\n              style={{\n                backgroundColor: `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`,\n              }}\n              onClick={() => {\n                project?.stageObjectColorManager.setSelectedStageObjectColor(color);\n              }}\n            />\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n\nColorWindow.open = () => {\n  SubWindow.create({\n    title: \"调色盘\",\n    children: <ColorWindow />,\n    rect: new Rectangle(MouseLocation.vector().clone(), new Vector(256, 256)),\n    closeWhenClickOutside: true,\n    closeWhenClickInside: true,\n  });\n};\n\n// ======= 颜色管理面板 =======\n\n/**\n * 自定义颜色设置面板\n *\n *\n */\nfunction ColorManagerPanel() {\n  useEffect(() => {\n    ColorManager.getUserEntityFillColors().then((colors) => {\n      setCurrentColorList(colors);\n    });\n  });\n  const [preAddColor, setPreAddColor] = useState(\"#000000\");\n  const [currentColorList, setCurrentColorList] = useState<Color[]>([]);\n  const [project] = useAtom(activeProjectAtom);\n\n  return (\n    <div className=\"bg-panel-bg flex flex-col p-4\">\n      <div>\n        <p>我的颜色库：</p>\n        {/* <ColorDotElement color={Color.Red} /> */}\n        <div className=\"flex flex-wrap items-center justify-center\">\n          {currentColorList.map((color) => (\n            <ColorDotElement\n              key={color.toString()}\n              color={color}\n              onclick={() => {\n                const rgbSharpString = color.toHexString();\n                if (rgbSharpString.length === 9) {\n                  // 去掉透明度\n                  setPreAddColor(rgbSharpString.slice(0, 7));\n                }\n              }}\n            />\n          ))}\n        </div>\n        {currentColorList.length !== 0 && (\n          <div className=\"text-panel-details-text text-center text-xs\">提示：点击颜色可以复制颜色值到待添加颜色</div>\n        )}\n      </div>\n      <div className=\"flex items-center justify-center\">\n        <p>添加颜色：</p>\n        <input\n          type=\"color\"\n          id=\"colorPicker\"\n          value={preAddColor}\n          onChange={(e) => {\n            const color = e.target.value;\n            setPreAddColor(color);\n          }}\n        ></input>\n        <Button\n          className=\"text-xs\"\n          onClick={() => {\n            const color = new Color(\n              parseInt(preAddColor.slice(1, 3), 16),\n              parseInt(preAddColor.slice(3, 5), 16),\n              parseInt(preAddColor.slice(5, 7), 16),\n            );\n            ColorManager.addUserEntityFillColor(color).then((res) => {\n              // setPreAddColor(Color.getRandom().toHexString());\n              if (!res) {\n                toast.warning(\"颜色已存在\");\n              }\n            });\n          }}\n        >\n          确认添加\n        </Button>\n      </div>\n\n      <div className=\"flex flex-col\">\n        <Button\n          onClick={() => {\n            if (!project) return;\n            const selectedStageObjects = project.stageManager.getSelectedStageObjects();\n            if (selectedStageObjects.length === 0) {\n              toast.warning(\"请先选择一个或多个有颜色的节点或连线\");\n              return;\n            }\n            selectedStageObjects.forEach((stageObject) => {\n              if (stageObject instanceof TextNode) {\n                ColorManager.addUserEntityFillColor(stageObject.color);\n              } else if (stageObject instanceof Section) {\n                ColorManager.addUserEntityFillColor(stageObject.color);\n              } else if (stageObject instanceof LineEdge) {\n                ColorManager.addUserEntityFillColor(stageObject.color);\n              }\n            });\n          }}\n        >\n          <Pipette />\n          将选中的节点颜色添加到库\n        </Button>\n        <Button\n          onClick={() => {\n            ColorManager.organizeUserEntityFillColors();\n          }}\n        >\n          <ArrowRightLeft />\n          整理顺序\n        </Button>\n      </div>\n    </div>\n  );\n}\n\nColorManagerPanel.open = () => {\n  SubWindow.create({\n    title: \"调色盘\",\n    children: <ColorManagerPanel />,\n    rect: new Rectangle(MouseLocation.vector().clone(), new Vector(256, 500)),\n    closeWhenClickOutside: false,\n    closeWhenClickInside: false,\n  });\n};\n\nfunction ColorDotElement({ color, onclick }: { color: Color; onclick: (e: any) => void }) {\n  const r = color.r;\n  const g = color.g;\n  const b = color.b;\n  const a = color.a;\n  return (\n    <div className=\"my-1\">\n      <div\n        className=\"relative mx-1 h-4 min-w-4 rounded-full hover:cursor-pointer\"\n        style={{ backgroundColor: `rgba(${r}, ${g}, ${b}, ${a})` }}\n        onClick={onclick}\n      >\n        <Button\n          className=\"absolute -right-2 -top-2 h-2 w-2 rounded-full text-xs\"\n          onClick={() => {\n            ColorManager.removeUserEntityFillColor(color);\n          }}\n        >\n          x\n        </Button>\n      </div>\n      <span className=\"mx-0.5 cursor-text select-all rounded bg-black px-1 text-xs text-neutral-300\">{`${r}, ${g}, ${b}`}</span>\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/src/sub/ExportPngWindow.tsx",
    "content": "import { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport { Progress } from \"@/components/ui/progress\";\nimport { Slider } from \"@/components/ui/slider\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { SubWindow } from \"@/core/service/SubWindow\";\nimport { activeProjectAtom } from \"@/state\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { save } from \"@tauri-apps/plugin-dialog\";\nimport { writeFile } from \"@tauri-apps/plugin-fs\";\nimport { useAtom } from \"jotai\";\nimport { FileWarning, Info } from \"lucide-react\";\nimport { useEffect, useMemo, useState } from \"react\";\nimport { toast } from \"sonner\";\n\nexport default function ExportPngWindow() {\n  const [project] = useAtom(activeProjectAtom);\n  if (!project) return <></>;\n  const [scale, setScale] = useState(1);\n  const scaleBefore = useMemo(() => project.camera.targetScale, []);\n  const [transparentBg, setTransparentBg] = useState(false);\n  const windowBackgroundAlphaBefore = useMemo(() => Settings.windowBackgroundAlpha, []);\n  const [showGrid, setShowGrid] = useState(false);\n  const showBackgroundCartesianBefore = useMemo(() => Settings.showBackgroundCartesian, []);\n  const showBackgroundDotsBefore = useMemo(() => Settings.showBackgroundDots, []);\n  const showBackgroundHorizontalLinesBefore = useMemo(() => Settings.showBackgroundHorizontalLines, []);\n  const showBackgroundVerticalLinesBefore = useMemo(() => Settings.showBackgroundVerticalLines, []);\n  const [progress, setProgress] = useState(-1);\n  const [imageResolution, setImageResolution] = useState(new Vector(0, 0));\n  const [overSized, setOverSized] = useState(false);\n  const [abortController, setAbortController] = useState<AbortController | null>(null);\n  const [sleepTime, setSleepTime] = useState(2); // 默认2毫秒\n\n  useEffect(() => {\n    project.camera.targetScale = scale;\n    project.camera.currentScale = scale;\n    Settings.windowBackgroundAlpha = transparentBg ? 0 : 1;\n    Settings.showBackgroundCartesian = showGrid;\n    Settings.showBackgroundDots = showGrid;\n    Settings.showBackgroundHorizontalLines = showGrid;\n    Settings.showBackgroundVerticalLines = showGrid;\n    return () => {\n      project.camera.targetScale = scaleBefore;\n      Settings.windowBackgroundAlpha = windowBackgroundAlphaBefore;\n      Settings.showBackgroundCartesian = showBackgroundCartesianBefore;\n      Settings.showBackgroundDots = showBackgroundDotsBefore;\n      Settings.showBackgroundHorizontalLines = showBackgroundHorizontalLinesBefore;\n      Settings.showBackgroundVerticalLines = showBackgroundVerticalLinesBefore;\n    };\n  }, [scale, transparentBg, showGrid]);\n  useEffect(() => {\n    setImageResolution(\n      project.stageManager\n        .getSize()\n        .add(new Vector(100 * 2, 100 * 2))\n        .multiply(scale),\n    );\n  }, [scale]);\n  useEffect(() => {\n    setOverSized(\n      imageResolution.x > 2 ** 15 - 1 ||\n        imageResolution.y > 2 ** 15 - 1 ||\n        imageResolution.x * imageResolution.y > 2 ** 28,\n    );\n  }, [imageResolution]);\n\n  function startExport() {\n    const ac = new AbortController();\n    setAbortController(ac);\n    project?.stageExportPng\n      .exportStage(ac.signal, sleepTime)\n      .on(\"progress\", setProgress)\n      .on(\"error\", (err) => {\n        toast.error(\"渲染失败: \" + err.message);\n        setProgress(-1);\n      })\n      .on(\"complete\", (blob) => {\n        toast(\"complete\");\n        setProgress(-1);\n        const reader = new FileReader();\n        reader.onload = () => {\n          const u8a = new Uint8Array(reader.result as ArrayBuffer);\n          save({\n            filters: [\n              {\n                name: \"PNG\",\n                extensions: [\"png\"],\n              },\n            ],\n          }).then((path) => {\n            if (!path) return;\n            writeFile(path, u8a);\n          });\n        };\n        reader.readAsArrayBuffer(blob);\n      });\n  }\n\n  return (\n    <div className=\"flex flex-col gap-4 p-4\">\n      <div className=\"flex items-center gap-2\">\n        @{scale}x\n        <Slider value={[scale]} onValueChange={([v]) => setScale(v)} min={0.1} max={4} step={0.1} />\n      </div>\n      <span>\n        实际图片分辨率: {imageResolution.x.toFixed()}x{imageResolution.y.toFixed()}\n      </span>\n      {overSized && (\n        <Alert variant=\"destructive\">\n          <FileWarning />\n          <AlertTitle>图片过大</AlertTitle>\n          <AlertDescription>可尝试调小图像比例</AlertDescription>\n        </Alert>\n      )}\n      <Alert>\n        <Info />\n        <AlertTitle>图像比例</AlertTitle>\n        <AlertDescription>此值越大，图片越清晰</AlertDescription>\n      </Alert>\n      <div className=\"flex items-center gap-2\">\n        <Checkbox checked={transparentBg} onCheckedChange={(it) => setTransparentBg(!!it)} />\n        <span>透明背景</span>\n      </div>\n      <div className=\"flex items-center gap-2\">\n        <Checkbox checked={showGrid} onCheckedChange={(it) => setShowGrid(!!it)} />\n        <span>显示网格</span>\n      </div>\n      <div className=\"flex flex-col gap-2\">\n        <div className=\"flex items-center gap-2\">\n          <span>渲染间隔: {sleepTime}ms</span>\n          <Slider value={[sleepTime]} onValueChange={([v]) => setSleepTime(v)} min={1} max={1000} step={1} />\n        </div>\n        <Alert>\n          <Info />\n          <AlertDescription>\n            间隔时间越小，渲染速度越快，但可能导致渲染不完整；间隔时间越大，渲染越稳定，但速度越慢\n          </AlertDescription>\n        </Alert>\n      </div>\n      {progress === -1 ? (\n        <div className=\"flex gap-2\">\n          <Button onClick={startExport} disabled={overSized}>\n            开始渲染\n          </Button>\n        </div>\n      ) : (\n        <div className=\"flex items-center gap-2\">\n          <Button variant=\"destructive\" onClick={() => abortController?.abort()}>\n            取消\n          </Button>\n          <Progress value={progress * 100} />\n        </div>\n      )}\n      <Alert>\n        <Info />\n        <AlertDescription>\n          渲染图片时，会逐个拼接小块，需要等待若干秒才能完成渲染，图像比例越大，渲染时间越长，画面分辨率越高\n        </AlertDescription>\n      </Alert>\n    </div>\n  );\n}\n\nExportPngWindow.open = () => {\n  SubWindow.create({\n    title: \"导出 PNG\",\n    children: <ExportPngWindow />,\n    rect: new Rectangle(new Vector(100, 100), new Vector(600, 700)),\n  });\n};\n"
  },
  {
    "path": "app/src/sub/FindWindow.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { Dialog } from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@/components/ui/tooltip\";\nimport { SearchScope } from \"@/core/service/dataManageService/contentSearchEngine/contentSearchEngine\";\nimport { RectangleLittleNoteEffect } from \"@/core/service/feedbackService/effectEngine/concrete/RectangleLittleNoteEffect\";\nimport { SubWindow } from \"@/core/service/SubWindow\";\nimport { activeProjectAtom } from \"@/state\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { useAtom } from \"jotai\";\nimport {\n  CaseSensitive,\n  MessageCircleQuestionMark,\n  Square,\n  SquareDashedTopSolid,\n  SquareDashedMousePointer,\n  Telescope,\n} from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\nimport { toast } from \"sonner\";\n\n/**\n * 搜索内容的面板\n */\nexport default function FindWindow() {\n  const [isCaseSensitive, setIsCaseSensitive] = useState(false);\n  const [searchString, setSearchString] = useState(\"\");\n  const [searchResults, setSearchResults] = useState<{ title: string; uuid: string }[]>([]);\n  // 是否开启快速瞭望模式\n  const [isMouseEnterCameraMovable, setIsMouseEnterCameraMovable] = useState(false);\n  // 搜索范围\n  const [searchScope, setSearchScope] = useState<SearchScope>(SearchScope.ALL);\n  const [project] = useAtom(activeProjectAtom);\n\n  const selectAllResult = () => {\n    for (const result of searchResults) {\n      const node = project?.stageManager.get(result.uuid);\n      if (node) {\n        node.isSelected = true;\n      }\n    }\n    toast.success(`${searchResults.length} 个结果已全部选中`);\n  };\n\n  useEffect(() => {\n    if (!project) return;\n    project.contentSearch.isCaseSensitive = isCaseSensitive;\n  }, [project, isCaseSensitive]);\n\n  useEffect(() => {\n    if (!project) return;\n    project.contentSearch.searchScope = searchScope;\n  }, [project, searchScope]);\n\n  const clearSearch = () => {\n    setSearchString(\"\");\n    setSearchResults([]);\n    project?.contentSearch.startSearch(\"\", false);\n  };\n\n  useEffect(() => {\n    clearSearch();\n    return () => {\n      clearSearch();\n    };\n  }, []);\n\n  if (!project) return <></>;\n  return (\n    <div className=\"flex flex-col gap-2 p-4\">\n      <Input\n        placeholder=\"请输入要在舞台上搜索的内容\"\n        autoFocus\n        type=\"search\"\n        onChange={(e) => {\n          setSearchString(e.target.value);\n          project.contentSearch.startSearch(e.target.value, false);\n          setSearchResults(\n            project.contentSearch.searchResultNodes.map((node) => ({\n              title: project.contentSearch.getStageObjectText(node),\n              uuid: node.uuid,\n            })),\n          );\n        }}\n        onKeyDown={(e) => {\n          if (e.key === \"Escape\") {\n            if (searchString !== \"\") {\n              clearSearch();\n            }\n          }\n        }}\n        value={searchString}\n      />\n      <div className=\"my-1 flex flex-wrap gap-3\">\n        <Tooltip>\n          <TooltipTrigger>\n            <Button\n              size=\"icon\"\n              variant={isCaseSensitive ? \"default\" : \"outline\"}\n              onClick={() => {\n                const currentResult = !isCaseSensitive;\n                setIsCaseSensitive(currentResult);\n              }}\n            >\n              <CaseSensitive />\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent>区分大小写</TooltipContent>\n        </Tooltip>\n\n        {/* 搜索范围选择按钮组 */}\n        <Tooltip>\n          <TooltipTrigger>\n            <Button\n              size=\"icon\"\n              variant={searchScope === SearchScope.ALL ? \"default\" : \"outline\"}\n              onClick={() => {\n                setSearchScope(SearchScope.ALL);\n              }}\n            >\n              <Square />\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent>搜索整个舞台</TooltipContent>\n        </Tooltip>\n\n        <Tooltip>\n          <TooltipTrigger>\n            <Button\n              size=\"icon\"\n              variant={searchScope === SearchScope.SELECTED ? \"default\" : \"outline\"}\n              onClick={() => {\n                setSearchScope(SearchScope.SELECTED);\n              }}\n            >\n              <SquareDashedTopSolid />\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent>只搜索选中的内容</TooltipContent>\n        </Tooltip>\n\n        <Tooltip>\n          <TooltipTrigger>\n            <Button\n              size=\"icon\"\n              variant={searchScope === SearchScope.SELECTED_BOUNDS ? \"default\" : \"outline\"}\n              onClick={() => {\n                setSearchScope(SearchScope.SELECTED_BOUNDS);\n              }}\n            >\n              <SquareDashedMousePointer />\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent>搜索选中内容的外接矩形范围</TooltipContent>\n        </Tooltip>\n\n        {searchResults.length > 0 && (\n          <Tooltip>\n            <TooltipTrigger>\n              <Button size=\"icon\" variant=\"outline\" onClick={selectAllResult}>\n                <SquareDashedTopSolid strokeWidth={1.5} />\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent>将全部结果选中</TooltipContent>\n          </Tooltip>\n        )}\n\n        {searchResults.length >= 3 && (\n          <Tooltip>\n            <TooltipTrigger>\n              <Button\n                size=\"icon\"\n                variant={isMouseEnterCameraMovable ? \"default\" : \"outline\"}\n                onClick={() => {\n                  setIsMouseEnterCameraMovable(!isMouseEnterCameraMovable);\n                }}\n              >\n                <Telescope />\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent>快速瞭望模式</TooltipContent>\n          </Tooltip>\n        )}\n        <Tooltip>\n          <TooltipTrigger>\n            <Button\n              size=\"icon\"\n              variant=\"outline\"\n              onClick={() => {\n                Dialog.confirm(\n                  \"帮助和提示\",\n                  \"如果关闭窗口后发现红框闪烁残留，可以尝试再打开一次搜索窗口，在搜索框中随便输入点东西然后再删除干净。\",\n                );\n              }}\n            >\n              <MessageCircleQuestionMark />\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent>使用说明</TooltipContent>\n        </Tooltip>\n      </div>\n      <div className=\"flex flex-col gap-1 overflow-y-auto\">\n        {searchString === \"\" && <div className=\"text-center\">请输入搜索内容</div>}\n        {searchResults.length === 0 && searchString !== \"\" && (\n          <div className=\"text-center\">没有搜索结果:`{searchString}`</div>\n        )}\n        {searchResults.map((result, index) => (\n          <div\n            key={result.uuid}\n            className=\"hover:text-panel-success-text flex cursor-pointer truncate text-xs hover:underline\"\n            onMouseEnter={() => {\n              project.controller.resetCountdownTimer();\n              if (isMouseEnterCameraMovable) {\n                const node = project.stageManager.get(result.uuid);\n                if (node) {\n                  project.camera.bombMove(node.collisionBox.getRectangle().center);\n                  project.effects.addEffect(RectangleLittleNoteEffect.fromSearchNode(node));\n                }\n              }\n            }}\n            onClick={() => {\n              project.controller.resetCountdownTimer();\n              const node = project.stageManager.get(result.uuid);\n              if (node) {\n                project.camera.bombMove(node.collisionBox.getRectangle().center);\n                project.effects.addEffect(RectangleLittleNoteEffect.fromSearchNode(node));\n              }\n            }}\n          >\n            <span className=\"bg-secondary rounded-sm px-2\">{index + 1}</span>\n            {result.title}\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n\nFindWindow.open = () => {\n  SubWindow.create({\n    title: \"搜索\",\n    children: <FindWindow />,\n    rect: new Rectangle(new Vector(100, 100), new Vector(300, 600)),\n  });\n};\n"
  },
  {
    "path": "app/src/sub/GenerateNodeWindow.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { SubWindow } from \"@/core/service/SubWindow\";\nimport { Telemetry } from \"@/core/service/Telemetry\";\nimport { activeProjectAtom } from \"@/state\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { useAtom } from \"jotai\";\nimport { useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\n\n/**\n * 根据纯文本生成树状结构\n * @returns\n */\nexport default function GenerateNodeTree() {\n  const [text, setText] = useState(\"\");\n  const [indention, setIndention] = useState(\"4\");\n  const [isLoading, setIsLoading] = useState(false);\n  const { t } = useTranslation(\"globalMenu\");\n\n  const [activeProject] = useAtom(activeProjectAtom);\n\n  const handleGenerate = async () => {\n    if (!activeProject) return;\n\n    setIsLoading(true);\n    try {\n      const startTime = Date.now();\n      const lineCount = text.split(\"\\n\").length;\n      activeProject.stageManager.generateNodeTreeByText(text, parseInt(indention) || 4);\n      const endTime = Date.now();\n      const duration = Math.round(endTime - startTime);\n\n      Telemetry.event(\"generate_node_tree_by_text\", {\n        line_count: lineCount,\n        duration: duration,\n        success: true,\n      });\n\n      toast.success(`${t(\"actions.generate.generateNodeTreeByText\")} ${t(\"actions.success\")}`, {\n        description: `${t(\"actions.generate.generatedIn\")} ${duration}ms`,\n      });\n    } catch {\n      const lineCount = text.split(\"\\n\").length;\n\n      Telemetry.event(\"generate_node_tree_by_text\", {\n        line_count: lineCount,\n        success: false,\n      });\n      toast.error(`${t(\"actions.generate.generateNodeTreeByText\")} ${t(\"actions.failed\")}`);\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  return (\n    <div className=\"space-y-4 p-6\">\n      <div>\n        <h3 className=\"mb-2 text-xl font-semibold\">{t(\"actions.generate.generateNodeTreeByText\")}</h3>\n        <p className=\"text-muted-foreground mb-4\">{t(\"actions.generate.generateNodeTreeByTextDescription\")}</p>\n        <p className=\"text-xs opacity-50\">提示：若想让节点内容本身换行，可以输入\\n</p>\n        <p className=\"text-xs opacity-50\">\n          注意：2.0.20+版本，生成树形结构后，先框选所有节点，再按ctrl键+框选所有节点，变成选中所有连线，将所有树内的连线改为从右侧发出，左侧接收，然后再alt\n          shift f\n          格式化，即可自动布局向右的树状结构（若感到疑惑可进群提问管理员或群主，后期此功能将会继续完善和提高新手友好性）\n        </p>\n      </div>\n      <Textarea\n        value={text}\n        onChange={(e) => setText(e.target.value)}\n        placeholder={t(\"actions.generate.generateNodeTreeByTextPlaceholder\")}\n        className=\"min-h-[200px]\"\n      />\n      <div className=\"flex items-center gap-2\">\n        <label htmlFor=\"indention\">{t(\"actions.generate.indention\")}:</label>\n        <Input\n          id=\"indention\"\n          type=\"number\"\n          value={indention}\n          onChange={(e) => setIndention(e.target.value)}\n          min=\"1\"\n          max=\"10\"\n          className=\"w-20\"\n        />\n      </div>\n      <div className=\"flex justify-end gap-2\">\n        <Button onClick={handleGenerate} disabled={isLoading} className=\"relative\">\n          {isLoading && (\n            <span className=\"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 animate-spin\"></span>\n          )}\n          {isLoading ? `${t(\"actions.generating\")}...` : t(\"actions.confirm\")}\n        </Button>\n      </div>\n    </div>\n  );\n}\n\n/**\n * 根据纯文本生成树状结构\n * @returns\n */\nexport function GenerateNodeTreeByMarkdown() {\n  const [text, setText] = useState(\"\");\n  const [isLoading, setIsLoading] = useState(false);\n  const { t } = useTranslation(\"globalMenu\");\n\n  const [activeProject] = useAtom(activeProjectAtom);\n\n  const handleGenerate = async () => {\n    if (!activeProject) return;\n\n    setIsLoading(true);\n    try {\n      const startTime = Date.now();\n      const lineCount = text.split(\"\\n\").length;\n      activeProject.stageManager.generateNodeByMarkdown(text);\n      const endTime = Date.now();\n      const duration = Math.round(endTime - startTime);\n\n      Telemetry.event(\"generate_node_tree_by_markdown\", {\n        line_count: lineCount,\n        duration: duration,\n        success: true,\n      });\n\n      toast.success(`${t(\"actions.generate.generateNodeTreeByMarkdown\")} ${t(\"actions.success\")}`, {\n        description: `${t(\"actions.generate.generatedIn\")} ${duration}ms`,\n      });\n    } catch {\n      const lineCount = text.split(\"\\n\").length;\n\n      Telemetry.event(\"generate_node_tree_by_markdown\", {\n        line_count: lineCount,\n        success: false,\n      });\n      toast.error(`${t(\"actions.generate.generateNodeTreeByMarkdown\")} ${t(\"actions.failed\")}`);\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  return (\n    <div className=\"space-y-4 p-6\">\n      <div>\n        <h3 className=\"mb-2 text-xl font-semibold\">{t(\"actions.generate.generateNodeTreeByMarkdown\")}</h3>\n        <p className=\"text-muted-foreground mb-4\">{t(\"actions.generate.generateNodeTreeByMarkdownDescription\")}</p>\n        <p className=\"text-xs opacity-50\">\n          注意：2.0.20+版本，生成树形结构后，先框选所有节点，再按ctrl键+框选所有节点，变成选中所有连线，将所有树内的连线改为从右侧发出，左侧接收，然后再alt\n          shift f\n          格式化，即可自动布局向右的树状结构（若感到疑惑可进群提问管理员或群主，后期此功能将会继续完善和提高新手友好性）\n        </p>\n      </div>\n      <Textarea\n        value={text}\n        onChange={(e) => setText(e.target.value)}\n        placeholder={t(\"actions.generate.generateNodeTreeByMarkdownPlaceholder\")}\n        className=\"min-h-[200px]\"\n      />\n      <div className=\"flex justify-end gap-2\">\n        <Button onClick={handleGenerate} disabled={isLoading} className=\"relative\">\n          {isLoading && (\n            <span className=\"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 animate-spin\"></span>\n          )}\n          {isLoading ? `${t(\"actions.generating\")}...` : t(\"actions.confirm\")}\n        </Button>\n      </div>\n    </div>\n  );\n}\n\nGenerateNodeTree.open = () => {\n  SubWindow.create({\n    title: \"生成节点群\",\n    children: <GenerateNodeTree />,\n    rect: new Rectangle(new Vector(100, 100), new Vector(500, 600)),\n  });\n};\n\nGenerateNodeTreeByMarkdown.open = () => {\n  SubWindow.create({\n    title: \"通过Markdown生成节点群\",\n    children: <GenerateNodeTreeByMarkdown />,\n    rect: new Rectangle(new Vector(100, 100), new Vector(500, 600)),\n  });\n};\n\nexport function GenerateNodeGraph() {\n  const [text, setText] = useState(\"\");\n  const [isLoading, setIsLoading] = useState(false);\n  const { t } = useTranslation(\"globalMenu\");\n\n  const [activeProject] = useAtom(activeProjectAtom);\n\n  const handleGenerate = async () => {\n    if (!activeProject) return;\n\n    setIsLoading(true);\n    try {\n      const startTime = Date.now();\n      const lineCount = text.split(\"\\n\").length;\n      activeProject.stageManager.generateNodeGraphByText(text);\n      const endTime = Date.now();\n      const duration = Math.round(endTime - startTime);\n\n      Telemetry.event(\"generate_node_graph_by_text\", {\n        line_count: lineCount,\n        duration: duration,\n        success: true,\n      });\n\n      toast.success(`${t(\"actions.generate.generateNodeGraphByText\")} ${t(\"actions.success\")}`, {\n        description: `${t(\"actions.generate.generatedIn\")} ${duration}ms`,\n      });\n    } catch {\n      const lineCount = text.split(\"\\n\").length;\n\n      Telemetry.event(\"generate_node_graph_by_text\", {\n        line_count: lineCount,\n        success: false,\n      });\n      toast.error(`${t(\"actions.generate.generateNodeGraphByText\")} ${t(\"actions.failed\")}`);\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  return (\n    <div>\n      <div>\n        <h3 className=\"mb-2 text-xl font-semibold\">{t(\"actions.generate.generateNodeGraphByText\")}</h3>\n        <p className=\"text-muted-foreground mb-4\">{t(\"actions.generate.generateNodeGraphByTextDescription\")}</p>\n      </div>\n      <Textarea\n        value={text}\n        onChange={(e) => setText(e.target.value)}\n        placeholder={t(\"actions.generate.generateNodeGraphByTextPlaceholder\")}\n        className=\"min-h-[200px]\"\n      />\n      <div className=\"flex justify-end gap-2\">\n        <Button onClick={handleGenerate} disabled={isLoading} className=\"relative\">\n          {isLoading && (\n            <span className=\"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 animate-spin\"></span>\n          )}\n          {isLoading ? `${t(\"actions.generating\")}...` : t(\"actions.confirm\")}\n        </Button>\n      </div>\n    </div>\n  );\n}\n\nGenerateNodeGraph.open = () => {\n  SubWindow.create({\n    title: \"生成节点网\",\n    children: <GenerateNodeGraph />,\n    rect: new Rectangle(new Vector(100, 100), new Vector(600, 600)),\n  });\n};\n\n/**\n * 根据mermaid文本生成框嵌套网状结构\n * @returns\n */\nexport function GenerateNodeMermaid() {\n  const [text, setText] = useState(\"\");\n  const [isLoading, setIsLoading] = useState(false);\n  const { t } = useTranslation(\"globalMenu\");\n\n  const [activeProject] = useAtom(activeProjectAtom);\n\n  const handleGenerate = async () => {\n    if (!activeProject) return;\n\n    setIsLoading(true);\n    try {\n      const startTime = Date.now();\n      const lineCount = text.split(\"\\n\").length;\n      activeProject.stageManager.generateNodeMermaidByText(text);\n      const endTime = Date.now();\n      const duration = Math.round(endTime - startTime);\n\n      Telemetry.event(\"generate_node_mermaid_by_text\", {\n        line_count: lineCount,\n        duration: duration,\n        success: true,\n      });\n\n      toast.success(`${t(\"actions.generate.generateNodeMermaidByText\")} ${t(\"actions.success\")}`, {\n        description: `${t(\"actions.generate.generatedIn\")} ${duration}ms`,\n      });\n    } catch {\n      const lineCount = text.split(\"\\n\").length;\n\n      Telemetry.event(\"generate_node_mermaid_by_text\", {\n        line_count: lineCount,\n        success: false,\n      });\n      toast.error(`${t(\"actions.generate.generateNodeMermaidByText\")} ${t(\"actions.failed\")}`);\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  return (\n    <div>\n      <div>\n        <h3 className=\"mb-2 text-xl font-semibold\">{t(\"actions.generate.generateNodeMermaidByText\")}</h3>\n        <p className=\"text-muted-foreground mb-4\">{t(\"actions.generate.generateNodeMermaidByTextDescription\")}</p>\n        <p className=\"text-xs opacity-50\">示例格式：</p>\n        <pre className=\"bg-muted mb-4 rounded p-2 text-xs opacity-50\">\n          graph TD; A[Section A] --{\">\"} B[Section B]; A --{\">\"} C[普通节点]; B --{\">\"} D[另一个节点]; E[Section E] --\n          {\">\"} F[F];\n        </pre>\n        <p className=\"text-xs opacity-50\">\n          注意：节点名称中包含 Section 、 章节 、组 或 容器 关键词的将被创建为框（Section）。\n        </p>\n      </div>\n      <Textarea\n        value={text}\n        onChange={(e) => setText(e.target.value)}\n        placeholder={t(\"actions.generate.generateNodeMermaidByTextPlaceholder\")}\n        className=\"min-h-[200px]\"\n      />\n      <div className=\"flex justify-end gap-2\">\n        <Button onClick={handleGenerate} disabled={isLoading} className=\"relative\">\n          {isLoading && (\n            <span className=\"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 animate-spin\"></span>\n          )}\n          {isLoading ? `${t(\"actions.generating\")}...` : t(\"actions.confirm\")}\n        </Button>\n      </div>\n    </div>\n  );\n}\n\nGenerateNodeMermaid.open = () => {\n  SubWindow.create({\n    title: \"生成框嵌套网状结构(Mermaid格式)\",\n    children: <GenerateNodeMermaid />,\n    rect: new Rectangle(new Vector(100, 100), new Vector(600, 600)),\n  });\n};\n"
  },
  {
    "path": "app/src/sub/KeyboardRecentFilesWindow.tsx",
    "content": "import { RecentFileManager } from \"@/core/service/dataFileService/RecentFileManager\";\nimport { onOpenFile } from \"@/core/service/GlobalMenu\";\nimport { SubWindow } from \"@/core/service/SubWindow\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { useEffect, useState } from \"react\";\nimport { toast } from \"sonner\";\n\nexport default function KeyboardRecentFilesWindow({ winId = \"\" }: { winId?: string }) {\n  const [recentFiles, setRecentFiles] = useState<RecentFileManager.RecentFile[]>([]);\n  const [isLoading, setIsLoading] = useState(true);\n\n  useEffect(() => {\n    refresh();\n  }, []);\n  useEffect(() => {\n    window.addEventListener(\"keydown\", onKeyDown);\n    return () => {\n      window.removeEventListener(\"keydown\", onKeyDown);\n    };\n  }, [recentFiles]);\n\n  async function refresh() {\n    setIsLoading(true);\n    await RecentFileManager.validAndRefreshRecentFiles();\n    await RecentFileManager.sortTimeRecentFiles();\n    const files = await RecentFileManager.getRecentFiles();\n    setRecentFiles(files);\n    setIsLoading(false);\n  }\n\n  function onKeyDown(event: KeyboardEvent) {\n    // 按下数字键1-9时，打开对应的文件\n    toast(event.key);\n    if (event.key >= \"1\" && event.key <= \"9\") {\n      const index = parseInt(event.key) - 1; // 将键值转换为索引\n      if (index >= 0 && index < recentFiles.length) {\n        const file = recentFiles[index];\n        if (file.uri) {\n          toast(`打开第 ${event.key} 项`);\n          onOpenFile(file.uri, \"KeyboardRecentFilesWindow\");\n          SubWindow.close(winId);\n        }\n      }\n    }\n  }\n\n  return (\n    <div className=\"flex flex-col gap-2 p-4\">\n      {isLoading && \"loading\"}\n      {recentFiles.map((it, index) => (\n        <span key={index}>\n          [{index + 1}] {decodeURI(it.uri.toString())}\n        </span>\n      ))}\n    </div>\n  );\n}\n\nKeyboardRecentFilesWindow.open = () => {\n  SubWindow.create({\n    title: \"最近打开的文件\",\n    children: <KeyboardRecentFilesWindow />,\n    rect: new Rectangle(Vector.same(100), Vector.same(-1)),\n    closeWhenClickInside: true,\n    closeWhenClickOutside: true,\n  });\n};\n"
  },
  {
    "path": "app/src/sub/LoginWindow.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { SubWindow } from \"@/core/service/SubWindow\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { fetch } from \"@tauri-apps/plugin-http\";\nimport { open } from \"@tauri-apps/plugin-shell\";\nimport { Check, ExternalLink, KeyRound, User } from \"lucide-react\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\n\nexport default function LoginWindow() {\n  const [username, setUsername] = useState(\"\");\n  const [password, setPassword] = useState(\"\");\n\n  async function login() {\n    // 获取 CSRF Token\n    const loginPageHtml = await (await fetch(\"https://bbs.project-graph.top/login\")).text();\n    const csrfTokenMatch = loginPageHtml.match(/\"csrf_token\":\"([a-z0-9]+)\"/);\n    if (!csrfTokenMatch) {\n      throw new Error(\"获取 CSRF Token 失败\");\n    }\n    const csrfToken = csrfTokenMatch[1];\n    // 发送登录请求，获取 Cookie\n    const response = await fetch(\"https://bbs.project-graph.top/login\", {\n      method: \"POST\",\n      headers: {\n        \"Content-Type\": \"application/x-www-form-urlencoded\",\n      },\n      body: new URLSearchParams({\n        username: username,\n        password: password,\n        noscript: \"false\",\n        remember: \"on\",\n        _csrf: csrfToken,\n      }).toString(),\n    });\n    if (response.status !== 200) {\n      const data = await response.text();\n      if (data === \"Forbidden\") {\n        throw new Error(\"CSRF Token 不正确\");\n      } else if (data === \"[[error:invalid-login-credentials]]\") {\n        throw new Error(\"无效的登录凭证\");\n      }\n      return;\n    }\n  }\n\n  return (\n    <div className=\"flex flex-col items-center gap-4 p-4\">\n      <span className=\"flex items-center gap-4\">\n        <User />\n        <Input placeholder=\"邮箱 / 用户名\" value={username} onChange={(e) => setUsername(e.target.value)} />\n      </span>\n      <span className=\"flex items-center gap-4\">\n        <KeyRound />\n        <Input placeholder=\"密码\" type=\"password\" value={password} onChange={(e) => setPassword(e.target.value)} />\n      </span>\n      <span className=\"flex gap-4\">\n        <Button\n          onClick={() =>\n            toast.promise(login, {\n              loading: \"正在登录...\",\n              success: \"登录成功\",\n              error: (err) => `登录失败: ${err.message}`,\n            })\n          }\n        >\n          <Check />\n          登录\n        </Button>\n        <Button variant=\"outline\" onClick={() => open(\"https://bbs.project-graph.top/register\")}>\n          <ExternalLink />\n          前往注册\n        </Button>\n      </span>\n    </div>\n  );\n}\n\nLoginWindow.open = () => {\n  SubWindow.create({\n    title: \"登录\",\n    children: <LoginWindow />,\n    rect: Rectangle.inCenter(new Vector(230, 240)),\n  });\n};\n"
  },
  {
    "path": "app/src/sub/NewExportPngWindow.tsx",
    "content": "import { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Slider } from \"@/components/ui/slider\";\nimport { SubWindow } from \"@/core/service/SubWindow\";\nimport { activeProjectAtom } from \"@/state\";\nimport { GenerateScreenshot } from \"@/core/service/dataGenerateService/generateScreenshot\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { save } from \"@tauri-apps/plugin-dialog\";\nimport { writeFile } from \"@tauri-apps/plugin-fs\";\nimport { useAtom } from \"jotai\";\nimport { Info } from \"lucide-react\";\nimport { useState } from \"react\";\nimport { toast } from \"sonner\";\n\nexport default function NewExportPngWindow() {\n  const [project] = useAtom(activeProjectAtom);\n  if (!project) return <></>;\n\n  const [maxDimension, setMaxDimension] = useState(1920);\n  const [isExporting, setIsExporting] = useState(false);\n\n  const handleExport = async () => {\n    setIsExporting(true);\n    try {\n      // 获取选中内容\n      const selectedEntities = project.stageManager.getSelectedEntities();\n      if (selectedEntities.length === 0) {\n        toast.warning(\"没有选中任何内容\");\n        return;\n      }\n\n      // 保存原始选中状态\n      const originalSelectedObjects = project.stageManager.getSelectedStageObjects().map((obj) => obj.uuid);\n\n      // 获取外接矩形\n      const targetRect: Rectangle = project.stageManager.getBoundingBoxOfSelected();\n\n      // 清理选中状态，防止渲染时出现绿色框框\n      project.stageManager.clearSelectAll();\n\n      try {\n        // 生成截图\n        const blob = await GenerateScreenshot.generateFromActiveProject(project, targetRect, maxDimension);\n        if (!blob) {\n          toast.error(\"生成截图失败\");\n          return;\n        }\n\n        // 保存文件\n        const path = await save({\n          title: `导出为 PNG`,\n          filters: [{ name: \"Portable Network Graphics\", extensions: [\"png\"] }],\n        });\n        if (!path) return;\n\n        const arrayBuffer = await blob.arrayBuffer();\n        const u8a = new Uint8Array(arrayBuffer);\n        await writeFile(path, u8a);\n\n        toast.success(\"导出成功\");\n      } finally {\n        // 恢复原始选中状态\n        project.stageManager.clearSelectAll();\n        originalSelectedObjects.forEach((uuid) => {\n          const obj = project.stageManager.get(uuid);\n          if (obj) {\n            obj.isSelected = true;\n          }\n        });\n      }\n    } catch (error) {\n      console.error(\"导出PNG失败\", error);\n      toast.error(\"导出PNG失败\");\n    } finally {\n      setIsExporting(false);\n    }\n  };\n\n  return (\n    <div className=\"flex flex-col gap-4 p-4\">\n      <div className=\"flex flex-col gap-2\">\n        <div className=\"flex items-center gap-2\">\n          <span className=\"w-32\">最大边长度:</span>\n          <Input\n            type=\"number\"\n            min=\"100\"\n            max=\"8192\"\n            value={maxDimension}\n            onChange={(e) => setMaxDimension(Math.max(100, Math.min(8192, parseInt(e.target.value) || 1920)))}\n            className=\"w-24\"\n          />\n          <span className=\"text-muted-foreground text-sm\">像素</span>\n        </div>\n        <div className=\"flex items-center gap-2\">\n          <span className=\"w-32\">调整大小:</span>\n          <Slider\n            min={100}\n            max={8192}\n            step={100}\n            value={[maxDimension]}\n            onValueChange={(value) => setMaxDimension(value[0])}\n            className=\"flex-1\"\n          />\n        </div>\n      </div>\n      <Alert>\n        <Info />\n        <AlertTitle>提示</AlertTitle>\n        <AlertDescription>过大的尺寸可能导致性能问题或失败，建议不超过4096像素</AlertDescription>\n      </Alert>\n      <div className=\"flex gap-2\">\n        <Button type=\"button\" onClick={handleExport} disabled={isExporting}>\n          {isExporting ? \"导出中...\" : \"导出\"}\n        </Button>\n      </div>\n    </div>\n  );\n}\n\n// 导出打开窗口的函数\nNewExportPngWindow.open = (type: \"selected\" | \"all\") => {\n  SubWindow.create({\n    title: `导出 ${type === \"selected\" ? \"选中内容\" : \"全部内容\"} 为 PNG`,\n    children: <NewExportPngWindow />,\n    rect: new Rectangle(new Vector(100, 100), new Vector(600, 400)),\n  });\n};\n"
  },
  {
    "path": "app/src/sub/NodeDetailsWindow.tsx",
    "content": "import { BasicBlocksKit } from \"@/components/editor/plugins/basic-blocks-kit\";\nimport { BasicMarksKit } from \"@/components/editor/plugins/basic-marks-kit\";\nimport { CodeBlockKit } from \"@/components/editor/plugins/code-block-kit\";\nimport { FixedToolbarKit } from \"@/components/editor/plugins/fixed-toolbar-kit\";\nimport { FloatingToolbarKit } from \"@/components/editor/plugins/floating-toolbar-kit\";\nimport { FontKit } from \"@/components/editor/plugins/font-kit\";\nimport { LinkKit } from \"@/components/editor/plugins/link-kit\";\nimport { ListKit } from \"@/components/editor/plugins/list-kit\";\nimport { MathKit } from \"@/components/editor/plugins/math-kit\";\nimport { TableKit } from \"@/components/editor/plugins/table-kit\";\nimport { Editor, EditorContainer } from \"@/components/ui/editor\";\nimport { SubWindow } from \"@/core/service/SubWindow\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { Value } from \"platejs\";\nimport { Plate, usePlateEditor } from \"platejs/react\";\n\nexport default function NodeDetailsWindow({\n  value = [],\n  onChange = () => {},\n}: {\n  value?: Value;\n  onChange?: (value: Value) => void;\n}) {\n  const editor = usePlateEditor({\n    plugins: [\n      ...FloatingToolbarKit,\n      ...FixedToolbarKit,\n      ...BasicMarksKit,\n      ...BasicBlocksKit,\n      ...FontKit,\n      ...TableKit,\n      ...MathKit,\n      ...CodeBlockKit,\n      ...ListKit,\n      ...LinkKit,\n    ],\n    value,\n  });\n\n  return (\n    <Plate editor={editor} onChange={({ value }) => onChange(value)}>\n      <EditorContainer>\n        <Editor variant=\"nodeDetails\" />\n      </EditorContainer>\n    </Plate>\n  );\n}\n\nNodeDetailsWindow.open = (value?: Value, onChange?: (value: Value) => void) => {\n  SubWindow.create({\n    children: <NodeDetailsWindow value={value} onChange={onChange} />,\n    // rect: Rectangle.inCenter(new Vector(innerWidth * 0.625, innerHeight * 0.74)),\n    rect: new Rectangle(\n      new Vector(innerWidth * 0.75, innerHeight * 0.1),\n      new Vector(innerWidth * 0.25, innerHeight * 0.9),\n    ),\n    titleBarOverlay: true,\n  });\n};\n"
  },
  {
    "path": "app/src/sub/OnboardingWindow.tsx",
    "content": "import { SubWindow } from \"@/core/service/SubWindow\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\n\nexport default function OnboardingWindow() {\n  return <></>;\n}\n\nOnboardingWindow.open = () => {\n  SubWindow.create({\n    children: <OnboardingWindow />,\n    rect: Rectangle.inCenter(new Vector(innerWidth > 1653 ? 1240 : innerWidth * 0.75, innerHeight * 0.875)),\n    titleBarOverlay: true,\n    closable: true,\n  });\n};\n"
  },
  {
    "path": "app/src/sub/RecentFilesWindow.tsx",
    "content": "import { Input } from \"@/components/ui/input\";\nimport { RecentFileManager } from \"@/core/service/dataFileService/RecentFileManager\";\nimport { SubWindow } from \"@/core/service/SubWindow\";\nimport { cn } from \"@/utils/cn\";\nimport { PathString } from \"@/utils/pathString\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport {\n  DoorClosed,\n  DoorOpen,\n  Import,\n  LoaderPinwheel,\n  Trash2,\n  X,\n  Link,\n  HardDriveDownload,\n  Eye,\n  EyeOff,\n} from \"lucide-react\";\nimport React, { ChangeEventHandler, useEffect } from \"react\";\nimport { Dialog } from \"@/components/ui/dialog\";\nimport { toast } from \"sonner\";\nimport { onOpenFile } from \"@/core/service/GlobalMenu\";\nimport { open } from \"@tauri-apps/plugin-dialog\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport { URI } from \"vscode-uri\";\nimport { SoundService } from \"@/core/service/feedbackService/SoundService\";\nimport { useAtom } from \"jotai\";\nimport { activeProjectAtom } from \"@/state\";\nimport { DragFileIntoStageEngine } from \"@/core/service/dataManageService/dragFileIntoStageEngine/dragFileIntoStageEngine\";\n\n/**\n * 文件名隐私保护加密函数（强制使用凯撒移位）\n * @param fileName 文件名\n * @returns 加密后的文件名\n */\nfunction encryptFileName(fileName: string): string {\n  // 凯撒移位加密：所有字符往后移动一位\n  return fileName\n    .split(\"\")\n    .map((char) => {\n      const code = char.charCodeAt(0);\n\n      // 对于可打印ASCII字符进行移位\n      if (code >= 32 && code <= 126) {\n        // 特殊处理：'z' 移到 'a'，'Z' 移到 'A'，'9' 移到 '0'\n        if (char === \"z\") return \"a\";\n        if (char === \"Z\") return \"A\";\n        if (char === \"9\") return \"0\";\n        // 其他字符直接 +1\n        return String.fromCharCode(code + 1);\n      }\n\n      // 对于中文字符，进行移位加密\n      if (code >= 0x4e00 && code <= 0x9fa5) {\n        // 中文字符在Unicode范围内循环移位\n        // 0x4e00是汉字起始，0x9fa5是汉字结束，总共约20902个汉字\n        const shiftedCode = code + 1;\n        // 如果超过汉字范围，则回到起始位置\n        return String.fromCharCode(shiftedCode <= 0x9fa5 ? shiftedCode : 0x4e00);\n      }\n\n      // 其他字符保持不变\n      return char;\n    })\n    .join(\"\");\n}\n\n// 嵌套文件夹结构类型\ntype FolderNode = {\n  name: string;\n  path: string;\n  files: RecentFileManager.RecentFile[];\n  subFolders: Record<string, FolderNode>;\n};\n\n/**\n * 最近文件面板按钮\n * @returns\n */\nexport default function RecentFilesWindow({ winId = \"\" }: { winId?: string }) {\n  const [activeProject] = useAtom(activeProjectAtom);\n  /**\n   * 数据中有多少就是多少\n   */\n  const [recentFiles, setRecentFiles] = React.useState<RecentFileManager.RecentFile[]>([]);\n  /**\n   * 经过搜索字符串过滤后的\n   */\n  const [recentFilesFiltered, setRecentFilesFiltered] = React.useState<RecentFileManager.RecentFile[]>([]);\n  const [isLoading, setIsLoading] = React.useState(true);\n\n  // 当前预选中的文件下标\n  const [currentPreselect, setCurrentPreselect] = React.useState<number>(0);\n  const [searchString, setSearchString] = React.useState(\"\");\n\n  const [currentShowPath, setCurrentShowPath] = React.useState<string>(\"\");\n  const [currentShowTime, setCurrentShowTime] = React.useState<string>(\"\");\n\n  const [isShowDeleteEveryItem, setIsShowDeleteEveryItem] = React.useState<boolean>(false);\n  const [isShowDoorEveryItem, setIsShowDoorEveryItem] = React.useState<boolean>(false);\n  const [isNestedView, setIsNestedView] = React.useState<boolean>(false);\n  const [isLocalPrivacyMode, setIsLocalPrivacyMode] = React.useState<boolean>(false);\n\n  // 选择文件夹并导入PRG文件\n  const importPrgFilesFromFolder = async () => {\n    try {\n      // 打开文件夹选择对话框\n      const folderPath = await open({\n        directory: true,\n        multiple: false,\n      });\n\n      if (!folderPath) return;\n\n      // 递归读取文件夹中的所有.prg文件\n      setIsLoading(true);\n      const files: string[] = await invoke(\"read_folder_recursive\", {\n        path: folderPath,\n        fileExts: [\".prg\"],\n      });\n\n      if (files.length === 0) {\n        toast.info(\"未找到.prg文件\");\n        return;\n      }\n\n      // 转换文件路径为URI并添加到最近文件历史\n      const uris = files.map((filePath) => URI.file(filePath));\n      await RecentFileManager.addRecentFilesByUris(uris);\n\n      // 更新列表\n      await updateRecentFiles();\n\n      toast.success(`成功导入 ${files.length} 个.prg文件`);\n    } catch (error) {\n      console.error(\"导入文件失败:\", error);\n      toast.error(\"导入文件失败\");\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  /**\n   * 用于刷新页面显示\n   */\n  const updateRecentFiles = async () => {\n    setIsLoading(true);\n    await RecentFileManager.validAndRefreshRecentFiles();\n    await RecentFileManager.sortTimeRecentFiles();\n    const files = await RecentFileManager.getRecentFiles();\n    setRecentFiles(files);\n    setRecentFilesFiltered(files);\n    setIsLoading(false);\n  };\n\n  const onInputChange: ChangeEventHandler<HTMLInputElement> = (event: React.ChangeEvent<HTMLInputElement>) => {\n    const inputString: string = event.target.value;\n    console.log(inputString, \"inputContent\");\n    if (inputString === \"#\") {\n      // 默认的shift + 3 会触发井号\n      return;\n    }\n    setCurrentPreselect(0); // 一旦有输入，就设置下标为0\n    setSearchString(inputString);\n    setRecentFilesFiltered(recentFiles.filter((file) => decodeURI(file.uri.toString()).includes(inputString)));\n  };\n\n  useEffect(() => {\n    updateRecentFiles();\n  }, []);\n\n  useEffect(() => {\n    if (isLoading || recentFilesFiltered.length === 0) return;\n    // 确保currentPreselect在有效范围内\n    const validIndex = Math.min(currentPreselect, recentFilesFiltered.length - 1);\n    setCurrentShowPath(decodeURI(recentFilesFiltered[validIndex].uri.toString()));\n    setCurrentShowTime(new Date(recentFilesFiltered[validIndex].time).toLocaleString());\n  }, [currentPreselect, isLoading, recentFilesFiltered]);\n\n  const checkoutFile = async (file: RecentFileManager.RecentFile) => {\n    try {\n      await onOpenFile(file.uri, \"历史界面-最近打开的文件\");\n      SubWindow.close(winId);\n    } catch (error) {\n      toast.error(error as string);\n    }\n  };\n\n  // 清空所有历史记录\n  const clearAllRecentHistory = async () => {\n    try {\n      // 弹出确认框\n      const confirmed = await Dialog.confirm(\n        \"确认清空\",\n        \"此操作不可撤销，确定要清空历史记录吗？仅仅是清空此列表，不是删除文件本身。\",\n        {\n          destructive: true,\n        },\n      );\n\n      if (!confirmed) {\n        return; // 用户取消操作\n      }\n\n      await RecentFileManager.clearAllRecentFiles();\n      // 清空后重置currentPreselect以避免访问无效索引\n      setCurrentPreselect(0);\n      await updateRecentFiles();\n      toast.success(\"已清空所有历史记录\");\n    } catch (error) {\n      toast.error(`清空历史记录失败 ${error}`);\n    }\n  };\n\n  const addCurrentFileToCurrentProject = (fileAbsolutePath: string, isAbsolute: boolean) => {\n    if (!activeProject) {\n      toast.error(\"当前没有激活的项目，无法添加传送门\");\n      return;\n    }\n    if (isAbsolute) {\n      DragFileIntoStageEngine.handleDropFileAbsolutePath(activeProject, [fileAbsolutePath]);\n    } else {\n      if (activeProject.isDraft) {\n        toast.error(\"草稿是未保存文件，没有路径，不能用相对路径导入\");\n        return;\n      }\n      DragFileIntoStageEngine.handleDropFileRelativePath(activeProject, [fileAbsolutePath]);\n    }\n  };\n\n  // 将最近文件转换为嵌套文件夹结构\n  const buildFolderTree = (files: RecentFileManager.RecentFile[]): Record<string, FolderNode> => {\n    const root: Record<string, FolderNode> = {};\n\n    files.forEach((file) => {\n      try {\n        const fsPath = file.uri.fsPath;\n\n        // 获取目录路径 - 使用完整的文件路径\n        const dirPath = PathString.dirPath(fsPath);\n        const dirParts = dirPath.split(/[\\\\/]/).filter(Boolean);\n\n        // 获取磁盘根目录（如C:, D:）\n        const diskRoot = dirParts[0] || \"unknown\";\n\n        if (!root[diskRoot]) {\n          root[diskRoot] = {\n            name: diskRoot,\n            path: diskRoot + \"/\",\n            files: [],\n            subFolders: {},\n          };\n        }\n\n        let currentFolder = root[diskRoot];\n\n        // 构建子文件夹结构\n        for (let i = 1; i < dirParts.length; i++) {\n          const folderName = dirParts[i];\n          const folderPath = dirParts.slice(0, i + 1).join(\"/\");\n\n          if (!currentFolder.subFolders[folderName]) {\n            currentFolder.subFolders[folderName] = {\n              name: folderName,\n              path: folderPath,\n              files: [],\n              subFolders: {},\n            };\n          }\n\n          currentFolder = currentFolder.subFolders[folderName];\n        }\n\n        // 添加文件到当前文件夹\n        currentFolder.files.push(file);\n      } catch (error) {\n        console.error(\"处理文件路径时出错:\", error, file);\n        // 跳过这个文件，继续处理其他文件\n      }\n    });\n\n    return root;\n  };\n\n  // 递归渲染文件夹组件\n  const FolderComponent: React.FC<{ folder: FolderNode; isPrivacyMode?: boolean }> = ({\n    folder,\n    isPrivacyMode = false,\n  }) => {\n    // 检查是否有子文件夹\n    const hasSubFolders = Object.values(folder.subFolders).length > 0;\n    // 如果没有子文件夹，只包含文件，设置最大宽度\n\n    return (\n      <div\n        className={cn(\n          \"bg-muted/50 m-1 inline-block rounded-lg border p-1\",\n          !hasSubFolders && \"max-w-96\",\n          hasSubFolders && \"\",\n        )}\n      >\n        <div className=\"mb-2 ml-1 font-bold\">{isPrivacyMode ? encryptFileName(folder.name) : folder.name}</div>\n\n        {/* 显示当前文件夹中的文件 */}\n        {folder.files.length > 0 && (\n          <div className=\"mb-4 flex flex-wrap gap-2\">\n            {folder.files.map((file, index) => (\n              <div\n                key={index}\n                className={cn(\n                  \"bg-muted relative flex max-w-48 origin-left cursor-pointer flex-col items-center gap-2 rounded-lg border p-1 px-2 py-1 opacity-75 transition-opacity hover:opacity-100\",\n                )}\n                onMouseEnter={() => {\n                  // 在嵌套视图中，我们不需要跟踪当前选中的索引\n                  SoundService.play.mouseEnterButton();\n                }}\n                onMouseDown={() => {\n                  if (isShowDeleteEveryItem) {\n                    toast.warning(\"当前正在删除阶段，请退出删除阶段才能打开文件，或点击删除按钮删除该文件\");\n                    return;\n                  }\n                  if (isShowDoorEveryItem) {\n                    toast.warning(\"当前正在添加传送门阶段，请退出添加传送门阶段才能打开文件，或点击按钮添加传送门\");\n                    return;\n                  }\n                  checkoutFile(file);\n                  SoundService.play.mouseClickButton();\n                }}\n              >\n                {isPrivacyMode\n                  ? encryptFileName(\n                      PathString.getShortedFileName(PathString.absolute2file(decodeURI(file.uri.toString())), 12),\n                    )\n                  : PathString.getShortedFileName(PathString.absolute2file(decodeURI(file.uri.toString())), 12)}\n                {isShowDeleteEveryItem && (\n                  <button\n                    onClick={async (e) => {\n                      e.stopPropagation();\n                      const result = await RecentFileManager.removeRecentFileByUri(file.uri);\n                      if (result) {\n                        updateRecentFiles();\n                      } else {\n                        toast.warning(\"删除失败\");\n                      }\n                    }}\n                    className=\"bg-destructive absolute -right-2 -top-2 cursor-pointer rounded-full transition-colors hover:scale-110\"\n                  >\n                    <X size={16} />\n                  </button>\n                )}\n                {isShowDoorEveryItem && (\n                  <>\n                    <button\n                      onClick={async (e) => {\n                        e.stopPropagation();\n                        const filePath = PathString.uppercaseAbsolutePathDiskChar(file.uri.fsPath).replaceAll(\n                          \"\\\\\",\n                          \"/\",\n                        );\n                        addCurrentFileToCurrentProject(filePath, false);\n                      }}\n                      className=\"bg-primary absolute -top-2 right-4 cursor-pointer rounded-full transition-colors hover:scale-110\"\n                    >\n                      <Link size={16} />\n                    </button>\n                    <button\n                      onClick={async (e) => {\n                        e.stopPropagation();\n                        const filePath = PathString.uppercaseAbsolutePathDiskChar(file.uri.fsPath).replaceAll(\n                          \"\\\\\",\n                          \"/\",\n                        );\n                        addCurrentFileToCurrentProject(filePath, true);\n                      }}\n                      className=\"bg-primary absolute -right-2 -top-2 cursor-pointer rounded-full transition-colors hover:scale-110\"\n                    >\n                      <HardDriveDownload size={16} />\n                    </button>\n                  </>\n                )}\n              </div>\n            ))}\n          </div>\n        )}\n\n        {/* 递归渲染子文件夹 */}\n        {Object.values(folder.subFolders).length > 0 && (\n          <div className=\"pl-2\">\n            {Object.values(folder.subFolders)\n              .sort((a, b) => a.name.localeCompare(b.name))\n              .map((subFolder) => (\n                <FolderComponent key={subFolder.path} folder={subFolder} isPrivacyMode={isPrivacyMode} />\n              ))}\n          </div>\n        )}\n      </div>\n    );\n  };\n\n  return (\n    <div className={cn(\"flex h-full flex-col items-center gap-2\")}>\n      <div className=\"flex w-full flex-wrap items-center gap-2 p-1\">\n        <Input\n          placeholder=\"请输入要筛选的文件\"\n          onChange={onInputChange}\n          onKeyDown={(e) => {\n            if (e.key === \"Escape\") {\n              SubWindow.close(winId);\n            }\n            if (e.key === \"Enter\" && recentFilesFiltered.length === 1) {\n              checkoutFile(recentFilesFiltered[0]);\n            }\n          }}\n          value={searchString}\n          autoFocus\n          // 搜索结果只有一条的时候，在页面下方文字中提示一下用户说按下回车键直接能够打开这个文件\n          className={cn(\"min-w-32 max-w-96 flex-1\", {\n            \"border-green-500 bg-green-500/10\": recentFilesFiltered.length === 1,\n          })}\n        />\n\n        <button\n          onClick={importPrgFilesFromFolder}\n          className=\"bg-primary/10 hover:bg-primary/20 flex gap-2 rounded-md p-2 transition-colors\"\n          title=\"递归导入文件夹中的所有.prg文件\"\n        >\n          <Import />\n          <span>递归导入文件夹中的所有.prg文件</span>\n        </button>\n\n        <button\n          onClick={clearAllRecentHistory}\n          className=\"bg-destructive/10 hover:bg-destructive/20 flex gap-2 rounded-md p-2 transition-colors\"\n          title=\"清空所有历史记录\"\n        >\n          <Trash2 />\n          <span>清空所有历史记录</span>\n        </button>\n        <button\n          onClick={() => {\n            setIsShowDeleteEveryItem((prev) => !prev);\n          }}\n          className=\"bg-destructive/10 hover:bg-destructive/20 flex gap-2 rounded-md p-2 transition-colors\"\n        >\n          {isShowDeleteEveryItem ? (\n            <>\n              <Trash2 />\n              <span>停止删除指定记录</span>\n            </>\n          ) : (\n            <>\n              <Trash2 />\n              <span>开始删除指定记录</span>\n            </>\n          )}\n        </button>\n        <button\n          onClick={() => {\n            setIsShowDoorEveryItem((prev) => !prev);\n          }}\n          className=\"bg-primary/10 flex gap-2 rounded-md p-2 transition-colors\"\n        >\n          {isShowDoorEveryItem ? (\n            <>\n              <DoorOpen />\n              <span>停止添加传送门</span>\n            </>\n          ) : (\n            <>\n              <DoorClosed />\n              <span>开始添加传送门</span>\n            </>\n          )}\n        </button>\n        <button\n          onClick={() => {\n            setIsNestedView((prev) => !prev);\n          }}\n          className=\"bg-primary/10 flex gap-2 rounded-md p-2 transition-colors\"\n        >\n          {isNestedView ? (\n            <>\n              <HardDriveDownload />\n              <span>切换到平铺视图</span>\n            </>\n          ) : (\n            <>\n              <HardDriveDownload />\n              <span>切换到嵌套视图</span>\n            </>\n          )}\n        </button>\n        <button\n          onClick={() => {\n            setIsLocalPrivacyMode((prev) => !prev);\n          }}\n          className=\"bg-primary/10 flex gap-2 rounded-md p-2 transition-colors\"\n        >\n          {isLocalPrivacyMode ? (\n            <>\n              <EyeOff />\n              <span>关闭隐私模式</span>\n            </>\n          ) : (\n            <>\n              <Eye />\n              <span>开启隐私模式</span>\n            </>\n          )}\n        </button>\n      </div>\n      <div className=\"flex w-full flex-col items-baseline justify-center px-4 text-xs\">\n        <p>{currentShowPath}</p>\n        <p>{currentShowTime}</p>\n        {recentFilesFiltered.length === 1 && (\n          <p className=\"animate-pulse font-bold text-green-500\">按下回车键直接能够打开这个文件</p>\n        )}\n      </div>\n\n      {/* 加载中提示 */}\n      {isLoading && (\n        <div className=\"flex h-full items-center justify-center text-8xl\">\n          <LoaderPinwheel className=\"scale-200 animate-spin\" />\n        </div>\n      )}\n      {/* 滚动区域单独封装 */}\n      {!isLoading && recentFilesFiltered.length === 0 && (\n        <div className=\"flex h-full items-center justify-center text-8xl\">\n          <span>NULL</span>\n        </div>\n      )}\n\n      {/* 根据视图模式渲染不同的内容 */}\n      {isNestedView ? (\n        <div className=\"flex w-full flex-col overflow-auto p-1\">\n          {Object.values(buildFolderTree(recentFilesFiltered)).map((rootFolder) => (\n            <FolderComponent key={rootFolder.path} folder={rootFolder} isPrivacyMode={isLocalPrivacyMode} />\n          ))}\n        </div>\n      ) : (\n        <div className=\"flex w-full flex-wrap gap-2 p-1\">\n          {recentFilesFiltered.map((file, index) => (\n            <div\n              key={index}\n              className={cn(\n                \"bg-muted/50 relative flex max-w-64 origin-left cursor-pointer flex-col items-center gap-2 rounded-lg border p-1 px-2 py-1 opacity-75\",\n                {\n                  \"opacity-100\": index === currentPreselect,\n                },\n              )}\n              onMouseEnter={() => {\n                setCurrentPreselect(index);\n                SoundService.play.mouseEnterButton();\n              }}\n              onClick={() => {\n                if (isShowDeleteEveryItem) {\n                  toast.warning(\"当前正在删除阶段，请退出删除阶段才能打开文件，或点击删除按钮删除该文件\");\n                  return;\n                }\n                if (isShowDoorEveryItem) {\n                  toast.warning(\"当前正在添加传送门阶段，请退出添加传送门阶段才能打开文件，或点击按钮添加传送门\");\n                  return;\n                }\n                checkoutFile(file);\n                SoundService.play.mouseClickButton();\n              }}\n            >\n              {isLocalPrivacyMode\n                ? encryptFileName(\n                    PathString.getShortedFileName(PathString.absolute2file(decodeURI(file.uri.toString())), 15),\n                  )\n                : PathString.getShortedFileName(PathString.absolute2file(decodeURI(file.uri.toString())), 15)}\n              {isShowDeleteEveryItem && (\n                <button\n                  onClick={async (e) => {\n                    e.stopPropagation();\n                    const result = await RecentFileManager.removeRecentFileByUri(file.uri);\n                    if (result) {\n                      updateRecentFiles();\n                    } else {\n                      toast.warning(\"删除失败\");\n                    }\n                  }}\n                  className=\"bg-destructive absolute -right-2 -top-2 cursor-pointer rounded-full transition-colors hover:scale-110\"\n                >\n                  <X size={20} />\n                </button>\n              )}\n              {isShowDoorEveryItem && (\n                <button\n                  onClick={async (e) => {\n                    e.stopPropagation();\n                    const filePath = PathString.uppercaseAbsolutePathDiskChar(file.uri.fsPath).replaceAll(\"\\\\\", \"/\");\n                    addCurrentFileToCurrentProject(filePath, false);\n                  }}\n                  className=\"bg-primary absolute -top-2 right-4 cursor-pointer rounded-full transition-colors hover:scale-110\"\n                >\n                  <Link size={20} />\n                </button>\n              )}\n              {isShowDoorEveryItem && (\n                <button\n                  onClick={async (e) => {\n                    e.stopPropagation();\n                    const filePath = PathString.uppercaseAbsolutePathDiskChar(file.uri.fsPath).replaceAll(\"\\\\\", \"/\");\n                    addCurrentFileToCurrentProject(filePath, true);\n                  }}\n                  className=\"bg-primary absolute -right-2 -top-2 cursor-pointer rounded-full transition-colors hover:scale-110\"\n                >\n                  <HardDriveDownload size={20} />\n                </button>\n              )}\n            </div>\n          ))}\n        </div>\n      )}\n    </div>\n  );\n}\n\nRecentFilesWindow.open = () => {\n  SubWindow.create({\n    title: \"最近打开的文件\",\n    children: <RecentFilesWindow />,\n    rect: new Rectangle(new Vector(50, 50), new Vector(window.innerWidth - 100, window.innerHeight - 100)),\n    // 不要点击外面就关闭当前面板，不太好用\n    // closeWhenClickOutside: true,\n  });\n};\n"
  },
  {
    "path": "app/src/sub/ReferencesWindow.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { SubWindow } from \"@/core/service/SubWindow\";\nimport { activeProjectAtom } from \"@/state\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { RefreshCcw } from \"lucide-react\";\nimport { useAtom } from \"jotai\";\nimport { useState, useEffect } from \"react\";\nimport { PathString } from \"@/utils/pathString\";\nimport { URI } from \"vscode-uri\";\nimport { RecentFileManager } from \"@/core/service/dataFileService/RecentFileManager\";\n\nexport default function ReferencesWindow(props: { currentProjectFileName: string }) {\n  const currentProjectFileName = props.currentProjectFileName;\n  const [project] = useAtom(activeProjectAtom);\n  if (!project) return <></>;\n\n  const [references, setReferences] = useState(project.references);\n  const [isUpdating, setIsUpdating] = useState(false);\n\n  async function refresh() {\n    setIsUpdating(true);\n    await project?.referenceManager.updateCurrentProjectReference();\n    setReferences({ ...project!.references });\n    setIsUpdating(false);\n  }\n\n  useEffect(() => {\n    setReferences({ ...project!.references });\n  }, []);\n\n  return (\n    <div className=\"flex flex-col gap-2 p-2\">\n      <div className=\"flex gap-3\">\n        <Button onClick={refresh} variant=\"outline\">\n          <RefreshCcw />\n          刷新\n        </Button>\n      </div>\n      {isUpdating ? (\n        <div className=\"text-muted-foreground text-sm\">正在刷新中...</div>\n      ) : (\n        <>\n          {/* 引用信息展示 */}\n          <div className=\"flex-1 overflow-y-auto\">\n            <div className=\"mb-4\">\n              <h3 className=\"mb-2 text-lg font-semibold\">直接引用{currentProjectFileName}的文件</h3>\n              {references.files.length === 0 ? (\n                <p className=\"text-muted-foreground text-sm\">当前项目中没有引用{currentProjectFileName}的文件</p>\n              ) : (\n                <div className=\"space-y-1\">\n                  {references.files.map((filePath) => {\n                    const fileName = PathString.getFileNameFromPath(filePath);\n                    return (\n                      <div\n                        key={filePath}\n                        className=\"text-select-option-text flex cursor-pointer items-center gap-2 rounded p-1 text-sm *:cursor-pointer hover:ring\"\n                        onClick={() => project.referenceManager.jumpToReferenceLocation(fileName, \"\")}\n                      >\n                        <span className=\"font-medium\">{fileName}</span>\n                        <span className=\"text-muted-foreground text-xs\">{filePath}</span>\n                      </div>\n                    );\n                  })}\n                </div>\n              )}\n            </div>\n\n            <div>\n              <h3 className=\"mb-2 text-lg font-semibold\">引用此文件中一些Section框的文件</h3>\n              {Object.keys(references.sections).length === 0 ? (\n                <p className=\"text-muted-foreground text-sm\">{currentProjectFileName}中没有被引用的Section</p>\n              ) : (\n                <div className=\"space-y-3\">\n                  {Object.entries(references.sections).map(([referencedSectionName, sections]) => (\n                    <div key={referencedSectionName}>\n                      <div className=\"text-select-option-text rounded p-1 font-medium\">{referencedSectionName}</div>\n                      <div className=\"my-1 ml-4 space-y-1\">\n                        {sections.map((fileName) => (\n                          <div\n                            onClick={() =>\n                              project.referenceManager.jumpToReferenceLocation(fileName, referencedSectionName)\n                            }\n                            key={fileName}\n                            className=\"border-muted text-select-option-text cursor-pointer rounded border-l-2 p-1 pl-2 text-sm hover:ring\"\n                          >\n                            {fileName}\n                          </div>\n                        ))}\n                      </div>\n                    </div>\n                  ))}\n                </div>\n              )}\n            </div>\n          </div>\n        </>\n      )}\n    </div>\n  );\n}\n\nexport function SectionReferencePanel(props: { currentProjectFileName: string; sectionName: string }) {\n  // const currentProjectFileName = props.currentProjectFileName;\n  const sectionName = props.sectionName;\n  const [project] = useAtom(activeProjectAtom);\n  if (!project) return <></>;\n  const [references, setReferences] = useState(project.references);\n  const [isUpdating, setIsUpdating] = useState(false);\n\n  async function refresh() {\n    setIsUpdating(true);\n    await project?.referenceManager.updateOneSectionReferenceInfo(\n      await RecentFileManager.getRecentFiles(),\n      sectionName,\n    );\n    setReferences({ ...project!.references });\n    setIsUpdating(false);\n  }\n\n  useEffect(() => {\n    setReferences({ ...project!.references });\n  }, []);\n\n  return (\n    <div className=\"flex flex-col gap-2 p-2\">\n      {isUpdating ? (\n        <span>正在刷新中...</span>\n      ) : (\n        <>\n          {references.sections[sectionName] &&\n            references.sections[sectionName].map((fileName) => (\n              <div\n                onClick={() => project.referenceManager.jumpToReferenceLocation(fileName, sectionName)}\n                key={fileName}\n                className=\"border-muted text-select-option-text w-full cursor-pointer rounded p-1 text-sm hover:ring\"\n              >\n                {fileName}\n              </div>\n            ))}\n          <Button onClick={refresh} variant=\"outline\">\n            <RefreshCcw />\n            刷新\n          </Button>\n        </>\n      )}\n    </div>\n  );\n}\n\nSectionReferencePanel.open = (currentURI: URI, sectionName: string, sectionViewLocation: Vector) => {\n  const fileName = PathString.getFileNameFromPath(currentURI.path);\n  SubWindow.create({\n    title: `引用它的地方`,\n    children: <SectionReferencePanel currentProjectFileName={fileName} sectionName={sectionName} />,\n    rect: new Rectangle(sectionViewLocation, new Vector(150, 150)),\n    closeWhenClickOutside: true,\n    closeWhenClickInside: true,\n  });\n};\n\nReferencesWindow.open = (currentURI: URI) => {\n  const fileName = PathString.getFileNameFromPath(currentURI.path);\n  SubWindow.create({\n    title: \"引用管理器：\" + fileName,\n    children: <ReferencesWindow currentProjectFileName={fileName} />,\n    rect: new Rectangle(new Vector(100, 100), new Vector(300, 600)),\n  });\n};\n"
  },
  {
    "path": "app/src/sub/SettingsWindow/about.tsx",
    "content": "import logoUrl from \"@/assets/icon.png\";\nimport { Dialog } from \"@/components/ui/dialog\";\nimport { getVersion } from \"@tauri-apps/api/app\";\nimport { open } from \"@tauri-apps/plugin-shell\";\nimport { useEffect, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\n\nexport default function AboutTab() {\n  const [appVersion, setAppVersion] = useState(\"unknown\");\n  const [logoClickCount, setLogoClickCount] = useState(0);\n  const { t } = useTranslation(\"welcome\");\n\n  useEffect(() => {\n    (async () => {\n      setAppVersion(await getVersion());\n    })();\n  }, []);\n  useEffect(() => {\n    (async () => {\n      if (logoClickCount >= 10) {\n        const url = await Dialog.input(\n          \"navigate\",\n          \"此操作将放弃所有未保存的文件，使用此功能打开其他人给你的网址可能会导致感染计算机病毒！\",\n          { destructive: true },\n        );\n        if (url && url.length > 5) {\n          window.location.href = url;\n        }\n        setLogoClickCount(0);\n      }\n    })();\n  }, [logoClickCount]);\n\n  return (\n    <div className=\"max-w-1/2 items-between mx-auto flex h-full w-full flex-col justify-center gap-4 text-white\">\n      <img src={logoUrl} alt=\"Project Graph Logo\" className=\"absolute inset-0 -z-10 size-full blur-[150px]\" />\n      <img\n        src={logoUrl}\n        alt=\"Project Graph Logo\"\n        className=\"mx-auto size-64\"\n        onClick={() => setLogoClickCount((it) => it + 1)}\n      />\n\n      <header className=\"flex items-center justify-between\">\n        <div>\n          <h1 className=\"flex items-center gap-2 text-3xl font-semibold\">\n            <span>Project Graph</span>\n            {/* 把版本号调大一些，因为一些用户录屏反馈的时候会主动打开这个页面，展示版本号。如果字太小了，在手机上看用户录屏视频就看不清了 */}\n            <span className=\"border-border inline-flex items-center rounded-md border bg-gray-800 px-2 py-1 text-xl\">\n              v{appVersion}\n            </span>\n          </h1>\n          <p className=\"text-sm opacity-50\">{t(\"slogan\")}</p>\n        </div>\n      </header>\n\n      <section className=\"text-sm leading-6\">\n        <p>\n          Project Graph 是一个图形化思维桌面工具和知识管理系统，支持节点连接、图形渲染和自动布局等功能， 基于 Tauri +\n          React 技术栈构建。它旨在提供一个高效、直观的方式来组织和管理个人知识。\n        </p>\n      </section>\n\n      <section className=\"grid grid-cols-1 gap-4 text-sm sm:grid-cols-2\">\n        <div className=\"rounded-md border p-4\">\n          <div className=\"text-xs opacity-50\">开发者</div>\n          <div className=\"mt-1 font-medium\">\n            <Author name=\"Littlefean(阿岳)\" url=\"https://github.com/Littlefean\" />,{\" \"}\n            <Author name=\"zty012\" url=\"https://github.com/zty012\" />,{\" \"}\n            <Author name=\"Rutubet(小劫)\" url=\"https://github.com/Rutubet\" /> 以及所有贡献者\n          </div>\n        </div>\n        <div className=\"**:cursor-pointer cursor-pointer rounded-md border p-4\">\n          <div className=\"text-xs opacity-50\">官网与仓库</div>\n          <div className=\"mt-1 font-medium underline underline-offset-4\" onClick={() => open(\"https://graphif.dev/\")}>\n            graphif.dev\n          </div>\n          <div\n            className=\"mt-1 font-medium underline underline-offset-4\"\n            onClick={() => open(\"https://github.com/graphif/project-graph\")}\n          >\n            graphif/project-graph\n          </div>\n        </div>\n      </section>\n\n      <footer className=\"text-xs opacity-50\">{/**/}</footer>\n    </div>\n  );\n}\n\nconst Author: React.FC<{ name: string; url: string }> = ({ name, url }: { name: string; url: string }) => {\n  return (\n    <span className=\"mt-1 font-medium underline underline-offset-4 hover:cursor-pointer\" onClick={() => open(url)}>\n      {name}\n    </span>\n  );\n};\n"
  },
  {
    "path": "app/src/sub/SettingsWindow/appearance/effects.tsx",
    "content": "import { ButtonField, Field } from \"@/components/ui/field\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { Check, Stars, X } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { getOriginalNameOf } from \"virtual:original-class-name\";\n\n/**\n * 实测log一下发现它是：\n * [\"CircleChangeRadiusEffect\", ...] 这样的格式\n */\nconst effects: string[] = Object.values(\n  import.meta.glob(\"../../../core/service/feedbackService/effectEngine/concrete/*.tsx\", {\n    eager: true,\n  }),\n).map((module: any) => getOriginalNameOf(Object.values(module)[0] as any));\n\nexport default function EffectsPage() {\n  const { t } = useTranslation(\"effects\");\n  const [effectsPerferences, setEffectsPerferences] = Settings.use(\"effectsPerferences\");\n\n  return (\n    <>\n      <ButtonField\n        icon={<Check />}\n        title=\"全开\"\n        onClick={() => {\n          setEffectsPerferences(\n            effects.reduce(\n              (acc, effectName) => {\n                acc[effectName] = true;\n                return acc;\n              },\n              {} as Record<string, boolean>,\n            ),\n          );\n        }}\n        label=\"全开\"\n      />\n      <ButtonField\n        icon={<X />}\n        title=\"全关\"\n        onClick={() => {\n          setEffectsPerferences(\n            effects.reduce(\n              (acc, effectName) => {\n                acc[effectName] = false;\n                return acc;\n              },\n              {} as Record<string, boolean>,\n            ),\n          );\n        }}\n        label=\"全关\"\n      />\n      <Field color=\"warning\" title=\"如果修改了效果设置，需要重启软件才能生效。\" />\n      {effects.map((effectName) => (\n        <Field\n          key={effectName}\n          icon={<Stars />}\n          title={t(`${effectName}.title`)}\n          description={t(`${effectName}.description`)}\n        >\n          <Switch\n            checked={effectsPerferences[effectName] ?? true}\n            onCheckedChange={(value: boolean) => {\n              setEffectsPerferences({\n                ...effectsPerferences,\n                [effectName]: value,\n              });\n            }}\n          />\n        </Field>\n      ))}\n    </>\n  );\n}\n"
  },
  {
    "path": "app/src/sub/SettingsWindow/appearance/index.tsx",
    "content": "import {\n  Sidebar,\n  SidebarContent,\n  SidebarGroup,\n  SidebarGroupContent,\n  SidebarMenu,\n  SidebarMenuButton,\n  SidebarMenuItem,\n} from \"@/components/ui/sidebar\";\nimport { Sparkles, Volume2 } from \"lucide-react\";\nimport { Fragment, useState } from \"react\";\nimport EffectsPage from \"./effects\";\nimport SoundEffectsPage from \"./sounds\";\n\nexport default function AppearanceTab() {\n  const [currentCategory, setCurrentCategory] = useState(\"\");\n\n  // @ts-expect-error fuck ts\n  const Component = currentCategory && currentCategory in categories ? categories[currentCategory].component : Fragment;\n  return (\n    <div className=\"flex h-full\">\n      <Sidebar className=\"h-full overflow-auto\">\n        <SidebarContent>\n          <SidebarGroup>\n            <SidebarGroupContent>\n              <SidebarMenu>\n                {Object.entries(categories).map(([k, v]) => (\n                  <SidebarMenuItem key={k}>\n                    <SidebarMenuButton asChild onClick={() => setCurrentCategory(k)} isActive={currentCategory === k}>\n                      <div>\n                        <v.icon />\n                        <span>{v.name}</span>\n                      </div>\n                    </SidebarMenuButton>\n                  </SidebarMenuItem>\n                ))}\n              </SidebarMenu>\n            </SidebarGroupContent>\n          </SidebarGroup>\n        </SidebarContent>\n      </Sidebar>\n      <div className=\"mx-auto flex w-2/3 flex-col overflow-auto\">\n        <Component />\n      </div>\n    </div>\n  );\n}\n\nconst categories = {\n  effects: {\n    name: \"特效\",\n    icon: Sparkles,\n    component: EffectsPage,\n  },\n  sounds: {\n    name: \"音效\",\n    icon: Volume2,\n    component: SoundEffectsPage,\n  },\n};\n"
  },
  {
    "path": "app/src/sub/SettingsWindow/appearance/sounds.tsx",
    "content": "import { SettingField } from \"@/components/ui/field\";\nimport FileChooser from \"@/components/ui/file-chooser\";\nimport { Popover } from \"@/components/ui/popover\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { AssetsRepository } from \"@/core/service/AssetsRepository\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { SoundService } from \"@/core/service/feedbackService/SoundService\";\nimport { appLocalDataDir, join } from \"@tauri-apps/api/path\";\nimport { writeFile } from \"@tauri-apps/plugin-fs\";\nimport { open } from \"@tauri-apps/plugin-shell\";\nimport { Download, ExternalLink, Volume2, VolumeX } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\n\n// 音效配置列表\nconst SOUND_CONFIGS = [\n  {\n    settingKey: \"cuttingLineStartSoundFile\",\n    name: \"开始切割\",\n    testFunction: SoundService.play.cuttingLineStart,\n    fileName: \"cuttingLineStart.mp3\",\n  },\n  {\n    settingKey: \"cuttingLineReleaseSoundFile\",\n    name: \"释放切割\",\n    testFunction: SoundService.play.cuttingLineRelease,\n    fileName: \"cuttingLineRelease.mp3\",\n  },\n  {\n    settingKey: \"connectLineStartSoundFile\",\n    name: \"开始连接\",\n    testFunction: SoundService.play.connectLineStart,\n    fileName: \"connectLineStart.mp3\",\n  },\n  {\n    settingKey: \"connectFindTargetSoundFile\",\n    name: \"找到连接目标\",\n    testFunction: SoundService.play.connectFindTarget,\n    fileName: \"connectFindTarget.mp3\",\n  },\n  {\n    settingKey: \"alignAndAttachSoundFile\",\n    name: \"对齐吸附\",\n    testFunction: SoundService.play.alignAndAttach,\n    fileName: \"alignAndAttach.mp3\",\n  },\n  {\n    settingKey: \"uiButtonEnterSoundFile\",\n    name: \"按钮悬停\",\n    testFunction: SoundService.play.mouseEnterButton,\n    fileName: \"uiButtonEnter.mp3\",\n  },\n  {\n    settingKey: \"uiButtonClickSoundFile\",\n    name: \"按钮点击\",\n    testFunction: SoundService.play.mouseClickButton,\n    fileName: \"uiButtonClick.mp3\",\n  },\n  {\n    settingKey: \"uiSwitchButtonOnSoundFile\",\n    name: \"开关开启\",\n    testFunction: SoundService.play.mouseClickSwitchButtonOn,\n    fileName: \"uiSwitchButtonOn.mp3\",\n  },\n  {\n    settingKey: \"uiSwitchButtonOffSoundFile\",\n    name: \"开关关闭\",\n    testFunction: SoundService.play.mouseClickSwitchButtonOff,\n    fileName: \"uiSwitchButtonOff.mp3\",\n  },\n  {\n    settingKey: \"packEntityToSectionSoundFile\",\n    name: \"打包为框\",\n    testFunction: SoundService.play.packEntityToSectionSoundFile,\n    fileName: \"packEntityToSection.mp3\",\n  },\n  {\n    settingKey: \"treeGenerateDeepSoundFile\",\n    name: \"树形深度生长\",\n    testFunction: SoundService.play.treeGenerateDeepSoundFile,\n    fileName: \"treeGenerateDeep.mp3\",\n  },\n  {\n    settingKey: \"treeGenerateBroadSoundFile\",\n    name: \"树形广度生长\",\n    testFunction: SoundService.play.treeGenerateBroadSoundFile,\n    fileName: \"treeGenerateBroad.mp3\",\n  },\n  {\n    settingKey: \"treeAdjustSoundFile\",\n    name: \"树形结构调整\",\n    testFunction: SoundService.play.treeAdjustSoundFile,\n    fileName: \"treeAdjust.mp3\",\n  },\n  {\n    settingKey: \"viewAdjustSoundFile\",\n    name: \"视图调整\",\n    testFunction: SoundService.play.viewAdjustSoundFile,\n    fileName: \"viewAdjust.mp3\",\n  },\n  {\n    settingKey: \"entityJumpSoundFile\",\n    name: \"物体跳跃\",\n    testFunction: SoundService.play.entityJumpSoundFile,\n    fileName: \"entityJump.mp3\",\n  },\n  {\n    settingKey: \"associationAdjustSoundFile\",\n    name: \"连线调整\",\n    testFunction: SoundService.play.associationAdjustSoundFile,\n    fileName: \"associationAdjust.mp3\",\n  },\n];\n\n// 一键下载并设置所有音效\nconst downloadAndSetAllSounds = async () => {\n  try {\n    toast.promise(\n      async () => {\n        // 创建临时目录用于存储音效文件\n        const dir = await appLocalDataDir();\n\n        // 逐个下载音效文件并设置\n        for (const config of SOUND_CONFIGS) {\n          try {\n            // 从GitHub仓库下载音效文件\n            const u8a = await AssetsRepository.fetchFile(`sfx/${config.fileName}`);\n            const path = await join(dir, config.fileName);\n            await writeFile(path, u8a);\n\n            // 设置音效文件路径\n            // @ts-expect-error settingKey is keyof Settings\n            Settings[config.settingKey] = path;\n          } catch (error) {\n            console.error(`下载音效文件 ${config.fileName} 失败:`, error);\n            throw new Error(`下载音效文件 ${config.fileName} 失败`);\n          }\n        }\n\n        // 播放一个音效来验证设置成功\n        if (Settings.soundEnabled && SOUND_CONFIGS.length > 0) {\n          SOUND_CONFIGS[0].testFunction();\n        }\n\n        return true;\n      },\n      {\n        loading: \"正在下载并设置音效文件...\",\n        success: \"所有音效文件已成功下载并设置！\",\n        error: (err) => `设置音效失败: ${err.message}`,\n      },\n    );\n  } catch (error) {\n    console.error(\"一键设置音效失败:\", error);\n    toast.error(\"一键设置音效失败，请稍后重试\");\n  }\n};\n\nexport default function SoundEffectsPage() {\n  const { t } = useTranslation(\"sounds\");\n  const [soundEnabled] = Settings.use(\"soundEnabled\");\n\n  // 在组件顶层预先调用所有需要的Settings.use，避免在循环中调用Hooks\n  const soundFilePaths = SOUND_CONFIGS.reduce(\n    (acc, config) => {\n      acc[config.settingKey] = Settings.use(config.settingKey as any)[0];\n      return acc;\n    },\n    {} as Record<string, string>,\n  );\n\n  // 测试音效\n  const handleTestSound = (testFunction: () => void) => {\n    if (soundEnabled) {\n      testFunction();\n    }\n  };\n\n  return (\n    <div className=\"space-y-4\">\n      <p>提示：目前此页面有一个bug：需要切换一下页面再切回来，才能看到改动的效果</p>\n      <div className=\"bg-muted flex items-center justify-between rounded-lg p-4\">\n        <div className=\"flex items-center gap-2\">\n          {soundEnabled ? <Volume2 /> : <VolumeX />}\n          <span>{t(\"soundEnabled\")}</span>\n        </div>\n        <Switch\n          checked={soundEnabled}\n          onCheckedChange={(value: boolean) => {\n            Settings.soundEnabled = value;\n          }}\n        />\n      </div>\n      <SettingField settingKey={\"soundPitchVariationRange\"} />\n\n      {soundEnabled && (\n        <div className=\"space-y-2\">\n          {/* 一键设置所有音效按钮 */}\n          <button\n            className=\"bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-primary/50 flex w-full items-center justify-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium shadow transition-colors focus-visible:outline-none focus-visible:ring-2\"\n            onClick={downloadAndSetAllSounds}\n          >\n            <Download className=\"h-4 w-4\" />\n            <span>一键下载并设置所有官方音效</span>\n          </button>\n        </div>\n      )}\n      <Popover.Confirm\n        title=\"提示\"\n        description=\"即将跳转github页面。如果github页面无法打开，请自行解决或使用自定义音效。\"\n        onConfirm={() => open(\"https://github.com/graphif/assets\")}\n      >\n        <div className=\"bg-muted/50 **:cursor-pointer group flex flex-1 cursor-pointer flex-col justify-center gap-2 rounded-lg border p-4\">\n          <div className=\"flex items-center justify-center gap-2\">\n            <ExternalLink className=\"h-5 w-5\" />\n            <span className=\"text-lg\">前往官方静态资源Github仓库:</span>\n          </div>\n          <div className=\"flex items-end justify-center gap-2 text-center\">\n            <span className=\"underline-offset-4 group-hover:underline\">https://github.com/graphif/assets</span>\n          </div>\n        </div>\n      </Popover.Confirm>\n      {SOUND_CONFIGS.map(({ settingKey, name, testFunction }) => {\n        const filePath = soundFilePaths[settingKey];\n        return (\n          <div key={settingKey} className=\"bg-muted flex items-center justify-between rounded-lg p-4\">\n            <div className=\"flex w-full flex-col\">\n              <span>{name}</span>\n              <FileChooser\n                kind=\"file\"\n                value={filePath || \"\"}\n                onChange={(value) => {\n                  // @ts-expect-error settingKey is keyof Settings\n                  Settings[settingKey] = value;\n                }}\n              />\n            </div>\n            <div className=\"flex items-center gap-2\">\n              <button\n                className=\"hover:bg-accent rounded-full p-2\"\n                onClick={() => handleTestSound(testFunction)}\n                disabled={!filePath}\n                title={t(\"testSound\")}\n              >\n                {soundEnabled ? <Volume2 size={16} /> : <VolumeX size={16} />}\n              </button>\n            </div>\n          </div>\n        );\n      })}\n\n      {!soundEnabled && (\n        <div className=\"bg-muted/50 text-muted-foreground rounded-lg p-4 text-center\">\n          <p>{t(\"soundDisabledHint\")}</p>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/src/sub/SettingsWindow/assets/font.css",
    "content": "@font-face {\n  font-family: \"DINPro\";\n  src: url(\"./dinpro_medium.subset.woff2\") format(\"woff2\");\n}\n"
  },
  {
    "path": "app/src/sub/SettingsWindow/credits.tsx",
    "content": "import { Popover } from \"@/components/ui/popover\";\nimport { cn } from \"@/utils/cn\";\nimport { open } from \"@tauri-apps/plugin-shell\";\nimport { AlertCircle, Calendar, ExternalLink, Heart, Loader, Server, User } from \"lucide-react\";\nimport { Telemetry } from \"@/core/service/Telemetry\";\nimport \"./assets/font.css\";\nimport { isDevAtom } from \"@/state\";\nimport { useAtom } from \"jotai\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { fetch } from \"@tauri-apps/plugin-http\";\n\ninterface DonationData {\n  user: string;\n  note?: string;\n  amount: number;\n  currency?: string;\n}\n\n// 此列表为2025年的捐赠记录，自2026年起将不再写入源代码，转为云控。\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\nconst donations_: DonationData[] = [\n  { user: \"购买服务器\", note: \"zty012\", amount: -480 },\n  // { user: \"域名 2y.nz\", note: \"zty012\", amount: -151.8 },\n  // { user: \"MacBook\", note: \"littlefean\", amount: -7599.2 },\n  { user: \"域名 project-graph.top\", note: \"zty012\", amount: -13.66 },\n  // 以下为捐赠用户\n  { user: \"zlj\", note: \"还没开始用，先表示支持\", amount: 5 },\n  { user: \".\", note: \"支持\", amount: 50 },\n  { user: \"曾佳浩\", note: \"加油，希望可以加上关于html，css，js相关节点\", amount: 1 },\n  { user: \"focus inner\", note: \"祝pg长长久久\", amount: 9.99 },\n  { user: \"\", note: \"这个软件帮我解决了一些难题，期待有更多实用的功能，加油！\", amount: 5 },\n  { user: \"楼\", note: \"干得好\", amount: 20 },\n  { user: \"鹏鹏\", note: \"感谢二位付出，软件特别好用\", amount: 50 },\n  { user: \"Strivy\", note: \"希望越来越好\", amount: 10 },\n  { user: \"陈海洋\", note: \"加油\", amount: 5 },\n  { user: \"S.\", note: \"赞！\", amount: 5 },\n  { user: \"松\", note: \"见龙在田\", amount: 200 },\n  { user: \"松\", note: \"\", amount: 200 },\n  { user: \"心弦\", note: \"\", amount: 10 },\n  { user: \"腾\", note: \"PG，就是我想要的功能\", amount: 50 },\n  { user: \"Entropy\", note: \"大千世界中难得的，我心目中的完美软件～感谢大佬开发！\", amount: 20 },\n  { user: \"潮汐\", note: \"在思维导图方面比obsidian的白板及插件更强\", amount: 20 },\n  { user: \"V\", note: \"pg这软件太棒了，感谢你们\", amount: 20 },\n  { user: \"量子易学\", note: \"牛逼！牛逼！牛逼！\", amount: 8.88 },\n  { user: \"YAO.🤖\", note: \"加油，加油，加油\", amount: 20 },\n  { user: \"潺湲\", note: \"一定要坚持下去，功能比市面上其他的都好多了而且还免费！！！\", amount: 20 },\n  { user: \"巷间陌人\", note: \"pg 学生时代遇到最宝藏的作品\", amount: 5 },\n  { user: \"瓶子\", note: \"软件体验绝佳，期待未来表现更加出色👍\", amount: 3 },\n  { user: \"路过的鸦大\", note: \"pg 好软件，加油加油\", amount: 50 },\n  { user: \"𩽾𩾌鱼\", note: \"你们团队的创造力是我生平仅见的达到天花板的那一小搓为你们鼓掌\", amount: 20 },\n  { user: \"𩽾𩾌鱼\", note: \"为你天才般的想法与西西弗斯般的毅力而震撼，使用过程中时时惊叹\", amount: 20 },\n  { user: \"张锋律师\", note: \"张锋律师\", amount: 88 },\n  { user: \"RoLinAike\", note: \"make it better! --RoLinAike\", amount: 100.1 },\n  // --- 分割线以上待添加到云控 ---\n  { user: \"bona\", note: \"祝pg长长久久\", amount: 9.9 },\n  { user: \"星尘_\", note: \"brokenstring加油！\", amount: 30 },\n  { user: \"百乐\", note: \"\", amount: 50 },\n  { user: \"Ryouji\", note: \"项目很好用，期待超越xmind\", amount: 20 },\n  { user: \"熱切\", note: \"\", amount: 20 },\n  { user: \"熊猫斯坦\", note: \"啥时候考虑上架思源笔记挂件市场\", amount: 50 },\n  { user: \"\", note: \"66\", amount: 1 },\n  { user: \"量子易学\", note: \"牛逼！发发发\", amount: 8.88 },\n  { user: \"盲\", note: \"太牛掰了希望你们越来越好\", amount: 20 },\n  { user: \"Li寻文\", note: \"谢谢你\", amount: 6.66 },\n  { user: \"清云\", note: \"很好的软件，支持一下\", amount: 5 },\n  { user: \"马达\", note: \"\", amount: 20 },\n  { user: \"大纯\", note: \"希望软件越来越好\", amount: 6.6 },\n  { user: \"爱和小狗打招呼\", note: \"厉害厉害\", amount: 6.8 },\n  { user: \"傅劲 Jin Fu\", note: \"加油，坚持下去。很要用的软件\", amount: 20 },\n  { user: \"\", note: \"\", amount: 3 },\n  { user: \"凯尔希握力计\", note: \"希望能在力所能及的前提下，能支持对draw.io的导入\", amount: 20 },\n  { user: \"凯尔希握力计\", note: \"\", amount: 0.01 },\n  { user: \"李康\", note: \"非常好用的一款软件，希望越做越好\", amount: 20 },\n  { user: \"X.\", note: \"牛逼666\", amount: 6.66 },\n  { user: \"LzM\", note: \"希望会有导出PDF格式的功能\", amount: 20 },\n  { user: \"LzM\", note: \"希望持续更新，太好使了\", amount: 30 },\n  { user: \"黄泳\", note: \"希望未来越做越好\", amount: 50 },\n  { user: \"Mr.Rove Rabbit\", note: \"大佬，感谢，加油很喜欢这个软件\", amount: 10 },\n  { user: \"沐影\", note: \"感谢大佬的软件，很好用\", amount: 20 },\n  { user: \"\", note: \"\", amount: 6 },\n  { user: \"Clay\", note: \"支持开源，希望PG走的更远\", amount: 50 },\n  { user: \"闪光的波动\", note: \"既然有okk打对勾，怎么能没有一个err来打叉呢\", amount: 6 },\n  { user: \"云深不知处\", note: \"很棒的软件，非常的帅希望能走更远\", amount: 20 },\n  { user: \"MoneyFL\", note: \"支持一下，希望越来越好\", amount: 5 },\n  { user: \"同创伟业\", note: \"非常感谢带来如此好用的画布软件加油！！\", amount: 10 },\n  { user: \"\", note: \"牛逼！牛逼！牛逼！\", amount: 6.66 },\n  { user: \"离人心上秋\", note: \"\", amount: 5 },\n  { user: \"Z.z.\", note: \"求求加个pdf定位功能🙏\", amount: 50 },\n  { user: \"ckismet\", note: \"感谢开发\", amount: 10 },\n  { user: \"\", note: \"加油大伙，你们是最帅的，希望这个最快的开发越来越好\", amount: 20 },\n  { user: \"xiazhan\", note: \"\", amount: 40 },\n  { user: \"专心神游\", note: \"感谢你们带来的如此简约而强大的应用，感谢你们的无私奉献\", amount: 10 },\n  { user: \"🍒\", note: \"感谢开源！\", amount: 50 },\n  { user: \"虹色之梦\", note: \"超棒的软件，开发速度超乎想象，我喜欢这个\", amount: 10 },\n  { user: \"狸猫\", note: \"自由思维，自由记录记录思绪的自然律动，捕捉灵感的无限扩散\", amount: 20 },\n  { user: \"季不是鸡\", note: \"蛙趣……？原来这里才是捐赠界面……\", amount: 10 },\n  { user: \"隔壁小王\", note: \"老哥能不能构建个Linux arm版本的呢？\", amount: 50 },\n  { user: \"田子\", note: \"优秀的开源项目！\", amount: 20 },\n  { user: \"\", note: \"非常感谢，软件真的很好用！！\", amount: 20 },\n  { user: \"\", note: \"请你喝瓶好的\", amount: 20 },\n  { user: \"葉谋\", note: \"软件很棒，加油\", amount: 5 },\n  { user: \"yunlunnn\", note: \"没什么钱，潜力很大，浅浅支持一下\", amount: 10 },\n  { user: \"韭莲宝灯\", note: \"\", amount: 10 },\n  { user: \"Wall\", note: \"非常喜欢的产品，加油\", amount: 100 },\n  { user: \"旅人与猫&\", note: \"感谢开发这么好用的软件，对于知识框架搭建有着极好的帮助\", amount: 50 },\n  { user: \"djh\", note: \"\", amount: 8.88 },\n  { user: \"beta Orionis\", note: \"pg神软！可否新增vim键位？\", amount: 20 },\n  { user: \"DeDo\", note: \"加油加油🐱\", amount: 8.88 },\n  { user: \"\", note: \"比市面上常见的那几个软件好用\", amount: 20 },\n  { user: \"hussein\", note: \"做大做强\", amount: 5 },\n  { user: \"Shawnpoo\", note: \"PRG很棒，加油\", amount: 5 },\n  { user: \"Yun Ti\", note: \"希望大佬以后添加子舞台嵌套功能\", amount: 6.66 },\n  { user: \"张新磊\", note: \"解密加群\", amount: 20 },\n  { user: \"小马\", note: \"感谢开源带来的便利与惊喜，期待越来越好\", amount: 200 },\n  { user: \"天行健\", note: \"伟大之作\", amount: 20.01 },\n  { user: \"弘毅\", note: \"pg大佬们加油\", amount: 6.66 },\n  { user: \"Yahha\", note: \"\", amount: 10 },\n  { user: \"X-rayDK 小风\", note: \"捐赠一波\", amount: 50 },\n  { user: \"1\", note: \"感谢开发project graph\", amount: 5 },\n  { user: \"xxx\", note: \"\", amount: 5 },\n  { user: \"马栋\", note: \"祝软件越来越好，主要是太好用了\", amount: 10 },\n  { user: \"荔枝2333\", note: \"好东西，期待更完善的功能\", amount: 50 },\n  { user: \"Amayer\", note: \"支持一下\", amount: 10 },\n  { user: \"Freaky Forward.\", note: \"软件及理念深得我心是我寻找已久的软件！希望能走得更远\", amount: 25 },\n  { user: \"至岸\", note: \"\", amount: 2 },\n  { user: \" \", note: \"很棒的酷东西，不是吗？\", amount: 100 },\n  { user: \"MT-F不觉💯\", note: \"非常牛逼的应用\", amount: 6.66 },\n  { user: \"巴巴拉斯\", note: \"加油！\", amount: 20 },\n  { user: \"丞相何故发笑\", note: \"\", amount: 6.66 },\n  { user: \"宏坤\", note: \"\", amount: 10 },\n  { user: \"\", note: \"刚开始用就被作者的思维导图震撼到了，还是小学生支持一下\", amount: 1 },\n  { user: \"好吃的琵琶腿\", note: \"感谢大佬\", amount: 1 },\n  { user: \"[C-S-Z]\", note: \"我喜欢这个ui设计\", amount: 10 },\n  { user: \"今晚打老虎\", note: \"支持\", amount: 20 },\n  { user: \"山东扣扣人\", note: \"很简洁明了 好\", amount: 5 },\n  { user: \"程彦轲\", note: \"pg是一个极其有潜力的项目，期待继续更新新的功能\", amount: 50 },\n  { user: \"Oxygen_Retrain\", note: \"感谢开发者们为Linux提供支持，加油\", amount: 10 },\n  { user: \"末影\", note: \"\", amount: 20 },\n  { user: \"不入\", note: \"希望可以考虑 32 64版本适用以及贝塞尔曲线自定义形状问题\", amount: 30 },\n  { user: \"\", note: \"加油加油\", amount: 20 },\n  { user: \"🍀🌟🏅 叶善译\", note: \"开源万岁，加油加油\", amount: 20 },\n  { user: \"asasasasaa\", note: \"加油，希望你们做的更好\", amount: 5 },\n  { user: \"韩淼\", note: \"pg软件挺好用\", amount: 40 },\n  { user: \"番茄炒土豆\", note: \"希望越来越好\", amount: 5 },\n  { user: \"V_V\", note: \"\", amount: 5 },\n  { user: \"哈士基🐶\", note: \"知识没有这么廉价，但这个月太穷\", amount: 50 },\n  { user: \"端点\", note: \"希望能一直做下去，请加油\", amount: 50 }, // 9.5\n  { user: \"Fush1d5\", note: \"\", amount: 88 }, // 9.5\n  { user: \"20\", note: \"感谢开源，你的劳动应得回报\", amount: 50 }, // 9.4\n  { user: \"三知六应\", note: \"感谢群主一直耐心倾听我的需求，并给我解答\", amount: 20 }, // 9.3\n  { user: \"闫刚\", note: \"感谢🙏\", amount: 5 }, // 9.2\n  { user: \"\", note: \"\", amount: 20 }, // 8.31\n  { user: \"天\", note: \"能设置连线不穿过文本框就好了\", amount: 5 },\n  { user: \"\", note: \"用了半年，非常好用，由于经济能力有限，只能捐些小钱\", amount: 5 },\n  { user: \"余伟锋\", note: \"\", amount: 5 },\n  { user: \"墨水云裳\", note: \"\", amount: 5 },\n  { user: \"ShawnSnow\", note: \"感谢PG\", amount: 40 },\n  { user: \"飞度\", note: \"做的很酷，真的谢谢你们\", amount: 50 },\n  { user: \"鳕鱼\", note: \"支持开源支持国产，加油\", amount: 70 },\n  { user: \"木头\", amount: 100 },\n  { user: \"林檎LOKI\", amount: 5 },\n  { user: \"Edelweiß\", amount: 5 },\n  { user: \"Z·z.\", note: \"求个ipad版本的\", amount: 5 },\n  { user: \"\", note: \"太酷了哥们\", amount: 5 },\n  { user: \"蓝海\", amount: 10 },\n  { user: \"渡己\", amount: 5 },\n  { user: \"微角秒\", note: \"希望这个项目越做越好\", amount: 50 },\n  { user: \"安麒文\", note: \"感谢您的软件，加油\", amount: 5 },\n  { user: \"\", note: \"SVG\", amount: 16 },\n  { user: \"💥知识学爆💥\", note: \"你们的软件很好用，给你们点赞\", amount: 20 },\n  { user: \"点正🌛🌛🌛\", note: \"膜拜一下\", amount: 10 },\n  { user: \"米虫先生\", amount: 100 },\n  { user: \"星尘_\", note: \"加油，看好你们\", amount: 5 },\n  { user: \"可乐mono\", note: \"加油，目前用过最好的导图类软件\", amount: 5 },\n  { user: \"62.3%\", note: \"Up要加油呀，我换新电脑第一个装的就是你的软件\", amount: 5 },\n  { user: \"All the luck\", note: \"感谢你的存在让世界更美好，我希望也在努力的做到\", amount: 30 },\n  { user: \"胡俊海\", amount: 5 },\n  { user: \"人\", amount: 20 },\n  { user: \"木棉\", note: \"谢谢up主的软件\", amount: 20 },\n  { user: \"Distance\", note: \"加油！！！还没用，先捐赠\", amount: 5 },\n  { user: \"xxx\", amount: 5 },\n  { user: \"\", amount: 5 },\n  { user: \"\", amount: 10 },\n  { user: \"chocolate\", amount: 20 },\n  { user: \"Think\", amount: 100 },\n  { user: \"Sullivan\", note: \"为知识付费\", amount: 5 },\n  { user: \"天涯\", note: \"为知识付费\", amount: 2.33 },\n  { user: \"\", note: \"66666666\", amount: 6.66 },\n  { user: \"阿龙\", note: \"好，请继续努力！\", amount: 20 },\n  { user: \"把验航\", amount: 5 },\n  { user: \"全沾工程师\", note: \"太棒啦，能力有限，先小小支持一波\", amount: 20 },\n  { user: \"耀轩之\", note: \"祝你越来越好\", amount: 5 },\n  { user: \"otto pan\", note: \"求mac缩放优化\", amount: 50 },\n  { user: \"llll\", note: \"支持\", amount: 5 },\n  { user: \"透明\", amount: 8.88 },\n  { user: \"七侠镇的小智\", amount: 20 },\n  { user: \"\", amount: 20 },\n  { user: \"ifelse\", note: \"keep dev\", amount: 20 },\n  { user: \"Ray\", note: \"继续加油[加油]\", amount: 18 },\n  { user: \"耀辰\", note: \"思维导图太牛了\", amount: 5 },\n  { user: \"云深不知处\", note: \"帅\", amount: 5 },\n  { user: \"好的名字\", note: \"pg太好用了，只能说\", amount: 5 },\n  { user: \"\", note: \"好用\", amount: 10 },\n  { user: \"解京\", note: \"感谢软件，祝早日多平台通用\", amount: 50 },\n  { user: \"唐扬睡醒了\", note: \"我会互相嵌套了(开心)\", amount: 0.01 },\n  { user: \"唐扬睡醒了\", note: \"很好用，请问如何交叉嵌套\", amount: 6.66 },\n  { user: \"Kelton\", note: \"很棒的软件，感谢开发者！\", amount: 5 },\n  { user: \"\", amount: 50 },\n  { user: \"斑驳窖藏\", amount: 5 },\n  { user: \"灰烬\", amount: 20 },\n  { user: \"赵长江\", amount: 50 },\n  { user: \"cityoasis\", note: \"感谢你的付出。这是一个很好的软件。希望能尽快做到美观成熟\", amount: 5 },\n  { user: \"A许诺溪\", note: \"希望能和obsidian完美协同\", amount: 20 },\n  { user: \"L.L.\", note: \"加油小小心思，不成敬意\", amount: 20 },\n];\n\n/**\n * 鸣谢界面\n * @returns\n */\nexport default function CreditsTab() {\n  const [donations, setDonations] = useState<DonationData[]>([]);\n  const totalAmount = donations.reduce((sum, donation) => sum + donation.amount, 0);\n  const [isDev] = useAtom(isDevAtom);\n  const [hasSentScrollToBottom, setHasSentScrollToBottom] = useState(false);\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  const [isLoading, setIsLoading] = useState(true);\n  const [isError, setIsError] = useState(false);\n\n  useEffect(() => {\n    setIsLoading(true);\n\n    fetch(import.meta.env.LR_API_BASE_URL + \"/api/donations\")\n      .then((res) => res.json())\n      .then((data) => {\n        setDonations(data);\n      })\n      .catch((e) => {\n        console.log(e);\n        setIsError(true);\n      })\n      .finally(() => {\n        setIsLoading(false);\n      });\n  }, []);\n\n  // 计算从2024年9月1日到现在的天数\n  const startDate = new Date(2024, 8, 1);\n  const currentDate = new Date();\n  const monthsDiff =\n    (currentDate.getFullYear() - startDate.getFullYear()) * 12 +\n    (currentDate.getMonth() - startDate.getMonth()) +\n    (currentDate.getDate() >= startDate.getDate() ? 0 : -1);\n  const actualMonths = Math.max(monthsDiff + 1, 1); // 至少为1个月\n  const averageMonthlyAmount = totalAmount / actualMonths;\n  const diffTime = currentDate.getTime() - startDate.getTime();\n  const daysDiff = Math.floor(diffTime / (1000 * 60 * 60 * 24));\n  const actualDays = Math.max(daysDiff + 1, 1); // 至少为1天\n\n  useEffect(() => {\n    Telemetry.event(\"credits_opened\");\n  }, []);\n\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container) return;\n\n    const handleScroll = () => {\n      if (hasSentScrollToBottom) return;\n\n      const { scrollTop, scrollHeight, clientHeight } = container;\n      const isBottom = scrollTop + clientHeight >= scrollHeight - 20;\n\n      if (isBottom) {\n        Telemetry.event(\"credits_scrolled_to_bottom\");\n        setHasSentScrollToBottom(true);\n      }\n    };\n\n    container.addEventListener(\"scroll\", handleScroll);\n    return () => container.removeEventListener(\"scroll\", handleScroll);\n  }, [hasSentScrollToBottom]);\n\n  return (\n    <div ref={containerRef} className=\"mx-auto flex w-2/3 flex-col overflow-auto py-4\" style={{ maxHeight: \"80vh\" }}>\n      <div className=\"mb-4 flex gap-4\">\n        {isDev ? (\n          <>\n            <div className=\"bg-muted/50 flex flex-1 flex-col gap-2 rounded-lg border p-4\">\n              <div className=\"flex items-center justify-center gap-2\">\n                <Heart className=\"h-5 w-5\" />\n                <span className=\"text-lg\">合计</span>\n              </div>\n              <div\n                className={cn(\n                  \"flex items-end justify-center gap-2 text-center *:font-[DINPro]\",\n                  totalAmount < 0 ? \"text-red-500\" : \"text-green-500\",\n                )}\n              >\n                <span className=\"text-3xl\">{totalAmount.toFixed(2)}</span>\n                <span className=\"text-xl\">CNY</span>\n              </div>\n            </div>\n            <div className=\"bg-muted/50 flex flex-1 flex-col gap-2 rounded-lg border p-4\">\n              <div className=\"flex items-center justify-center gap-2\">\n                <Calendar className=\"h-5 w-5\" />\n                <span className=\"text-lg\">平均每月</span>\n              </div>\n              <div\n                className={cn(\n                  \"flex items-end justify-center gap-2 text-center *:font-[DINPro]\",\n                  averageMonthlyAmount < 0 ? \"text-red-500\" : \"text-green-500\",\n                )}\n              >\n                <span className=\"text-3xl\">{averageMonthlyAmount.toFixed(2)}</span>\n                <span className=\"text-xl\">CNY</span>\n              </div>\n            </div>\n          </>\n        ) : (\n          <div className=\"bg-muted/50 flex flex-1 flex-col items-center gap-2 rounded-lg border p-4 text-sm\">\n            <p className=\"text-center\">在过去的{actualDays}个日夜中，是屏幕前您的认可与支持，给了我们最温暖的鼓励</p>\n            <p className=\"text-xs opacity-50\">您的支持可以让开发者的维护更持久，激励我们研究并创新</p>\n            <div className=\"flex flex-nowrap items-center justify-center gap-1\">\n              <Heart className=\"size-4\" />\n              <span className=\"text-sm\">谨以此墙，致敬所有同行者</span>\n            </div>\n          </div>\n        )}\n\n        <Popover.Confirm\n          title=\"提示\"\n          description=\"此列表并不是实时更新的，开发者将在您捐赠后的下一个版本中手动更新此列表，当您选择要捐赠时，请在开头添加备注“pg”，以便开发者能区分您的捐赠的项目是project-graph。\"\n          onConfirm={() => {\n            Telemetry.event(\"credits_donate_clicked\");\n            open(\"https://2y.nz/pgdonate\");\n          }}\n        >\n          <div className=\"bg-muted/50 **:cursor-pointer group flex flex-1 cursor-pointer flex-col justify-center gap-2 rounded-lg border p-4\">\n            <div className=\"flex items-center justify-center gap-2\">\n              <ExternalLink className=\"h-5 w-5\" />\n              <span className=\"text-lg\">前往捐赠页面</span>\n            </div>\n            <div className=\"flex items-end justify-center gap-2 text-center\">\n              <span className=\"underline-offset-4 group-hover:underline\">2y.nz/pgdonate</span>\n            </div>\n          </div>\n        </Popover.Confirm>\n      </div>\n      {isLoading && (\n        <div className=\"bg-muted/50 mb-4 inline-flex w-full break-inside-avoid flex-col gap-2 rounded-lg border p-4\">\n          <div className=\"flex items-center justify-center gap-2\">\n            <Loader className=\"h-5 w-5 animate-spin\" />\n            <span className=\"text-lg\">加载中...</span>\n          </div>\n        </div>\n      )}\n      <div className=\"columns-1 gap-4 sm:columns-2 md:columns-3 lg:columns-4 xl:columns-5\">\n        {!isLoading &&\n          !isError &&\n          donations.map((donation, index) => (\n            <Donation\n              key={index}\n              user={donation.user}\n              note={donation.note}\n              amount={donation.amount}\n              currency={donation.currency}\n            />\n          ))}\n      </div>\n      {!isLoading && isError && (\n        <div className=\"flex h-64 w-full flex-col justify-center\">\n          <div className=\"flex items-center justify-center gap-2\">\n            <AlertCircle className=\"h-5 w-5\" />\n            <span className=\"text-lg\">支持者名单加载失败，请检查网络，或更新到最新版本，或联系开发者以获取帮助</span>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction Donation({\n  user,\n  note = \"\",\n  amount,\n  currency = \"CNY\",\n}: {\n  user: string;\n  note?: string;\n  amount: number;\n  currency?: string;\n}) {\n  return (\n    <div\n      className={cn(\n        \"bg-muted/50 mb-4 inline-flex w-full break-inside-avoid flex-col gap-2 rounded-lg border p-4\",\n        amount < 0 && \"bg-destructive/25\",\n      )}\n    >\n      <div className=\"flex items-center gap-2\">\n        {amount < 0 ? <Server className=\"size-4\" /> : <User className=\"size-4\" />}\n        <span className=\"text-sm font-medium\">{user || \"匿名\"}</span>\n      </div>\n\n      <div className=\"flex items-end justify-between\">\n        <div className=\"flex items-center gap-1 *:font-[DINPro]\">\n          <span className=\"text-lg font-bold\">{amount}</span>\n          <span className=\"text-muted-foreground text-sm\">{currency}</span>\n        </div>\n      </div>\n\n      {note && <div className=\"text-muted-foreground bg-background/50 rounded p-2 text-xs md:text-sm\">{note}</div>}\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/src/sub/SettingsWindow/index.tsx",
    "content": "import { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { SubWindow } from \"@/core/service/SubWindow\";\nimport { activeProjectAtom, store } from \"@/state\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { useState } from \"react\";\nimport AboutTab from \"./about\";\nimport AppearanceTab from \"./appearance\";\nimport CreditsTab from \"./credits\";\nimport KeyBindsPage from \"./keybinds\";\nimport SettingsTab from \"./settings\";\nimport ThemesTab from \"./themes\";\nimport KeyBindsGlobalPage from \"./keybindsGlobal\";\nimport QuickSettingsTab from \"./quick-settings\";\n\ntype TabName = \"settings\" | \"keybinds\" | \"appearance\" | \"about\" | \"quickSettings\";\n\nexport default function SettingsWindow({ defaultTab = \"settings\" }: { defaultTab?: TabName }) {\n  const [currentTab, setCurrentTab] = useState<TabName>(defaultTab);\n\n  return (\n    <Tabs value={currentTab} onValueChange={setCurrentTab as any} className=\"h-full gap-0 overflow-hidden\">\n      <div className=\"flex\">\n        <TabsList>\n          <TabsTrigger value=\"settings\">设置</TabsTrigger>\n          <TabsTrigger value=\"keybinds\">快捷键</TabsTrigger>\n          <TabsTrigger value=\"keybindsGlobal\">全局快捷键</TabsTrigger>\n          <TabsTrigger value=\"appearance\">个性化</TabsTrigger>\n          <TabsTrigger value=\"themes\">主题</TabsTrigger>\n          <TabsTrigger value=\"quickSettings\">快捷设置</TabsTrigger>\n          <TabsTrigger value=\"about\">关于</TabsTrigger>\n          <TabsTrigger value=\"credits\">鸣谢</TabsTrigger>\n        </TabsList>\n        <div data-pg-drag-region className=\"h-full flex-1\" />\n      </div>\n      <TabsContent value=\"settings\" className=\"overflow-auto\">\n        <SettingsTab />\n      </TabsContent>\n      <TabsContent value=\"keybinds\" className=\"overflow-auto\">\n        <KeyBindsPage />\n      </TabsContent>\n      <TabsContent value=\"keybindsGlobal\" className=\"overflow-auto\">\n        <KeyBindsGlobalPage />\n      </TabsContent>\n      <TabsContent value=\"appearance\" className=\"overflow-auto\">\n        <AppearanceTab />\n      </TabsContent>\n      <TabsContent value=\"themes\" className=\"overflow-auto\">\n        <ThemesTab />\n      </TabsContent>\n      <TabsContent value=\"quickSettings\" className=\"overflow-auto\">\n        <QuickSettingsTab />\n      </TabsContent>\n      <TabsContent value=\"about\" className=\"overflow-auto\">\n        <AboutTab />\n      </TabsContent>\n      <TabsContent value=\"credits\" className=\"overflow-auto\">\n        <CreditsTab />\n      </TabsContent>\n    </Tabs>\n  );\n}\n\n// TODO: page参数\nSettingsWindow.open = (tab: TabName = \"settings\") => {\n  store.get(activeProjectAtom)?.pause();\n  SubWindow.create({\n    children: <SettingsWindow defaultTab={tab} />,\n    rect: Rectangle.inCenter(new Vector(innerWidth > 1653 ? 1240 : innerWidth * 0.75, innerHeight * 0.875)),\n    titleBarOverlay: true,\n  });\n};\n"
  },
  {
    "path": "app/src/sub/SettingsWindow/keybinds.tsx",
    "content": "import {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Field } from \"@/components/ui/field\";\nimport { Input } from \"@/components/ui/input\";\nimport KeyBind from \"@/components/ui/key-bind\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { AlertCircle } from \"lucide-react\";\nimport {\n  Sidebar,\n  SidebarContent,\n  SidebarGroup,\n  SidebarGroupContent,\n  SidebarMenu,\n  SidebarMenuButton,\n  SidebarMenuItem,\n} from \"@/components/ui/sidebar\";\nimport { KeyBindsUI } from \"@/core/service/controlService/shortcutKeysEngine/KeyBindsUI\";\nimport { allKeyBinds, getKeyBindTypeById } from \"@/core/service/controlService/shortcutKeysEngine/shortcutKeysRegister\";\nimport Fuse from \"fuse.js\";\n\nimport {\n  AppWindow,\n  Brush,\n  FileQuestion,\n  Fullscreen,\n  Image,\n  Keyboard,\n  MousePointer,\n  Move,\n  Network,\n  PanelsTopLeft,\n  RotateCw,\n  Scan,\n  Search as SearchIcon,\n  SendToBack,\n  Spline,\n  Split,\n  SquareDashed,\n  SquareMenu,\n  SunMoon,\n} from \"lucide-react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { createStore } from \"@/utils/store\";\nimport { isMac } from \"@/utils/platform\";\nimport { transEmacsKeyWinToMac } from \"@/utils/emacs\";\n\ninterface KeyBindData {\n  id: string;\n  key: string;\n  isEnabled: boolean;\n}\n\nexport default function KeyBindsPage() {\n  const [data, setData] = useState<KeyBindData[]>([]);\n  const [currentGroup, setCurrentGroup] = useState<string>(\"search\");\n  const [searchKeyword, setSearchKeyword] = useState(\"\");\n  const [searchResult, setSearchResult] = useState<string[]>([]);\n  const [conflictDialogOpen, setConflictDialogOpen] = useState(false);\n  const [currentConflicts, setCurrentConflicts] = useState<KeyBindData[]>([]);\n  const [currentConflictKey, setCurrentConflictKey] = useState(\"\");\n  const fuse = useRef<\n    Fuse<{\n      key: string;\n      value: string;\n      isEnabled: boolean;\n      i18n: { title: string; description: string };\n    }>\n  >(null);\n\n  const { t } = useTranslation(\"keyBinds\");\n  const { t: t2 } = useTranslation(\"keyBindsGroup\");\n\n  // 加载所有快捷键设置\n  useEffect(() => {\n    const loadKeyBinds = async () => {\n      const store = await createStore(\"keybinds2.json\");\n      const keyBinds: KeyBindData[] = [];\n      for (const keyBind of allKeyBinds.filter((keybindItem) => !keybindItem.isGlobal)) {\n        const savedData = await store.get<any>(keyBind.id);\n        let key: string;\n        let isEnabled: boolean;\n\n        if (!savedData) {\n          // 没有保存过，走默认设置\n          key = keyBind.defaultKey;\n          isEnabled = keyBind.defaultEnabled !== false;\n        } else if (typeof savedData === \"string\") {\n          // 兼容旧数据结构\n          key = savedData;\n          isEnabled = keyBind.defaultEnabled !== false;\n        } else {\n          // 已经保存过完整配置\n          key = savedData.key;\n          isEnabled = savedData.isEnabled !== false;\n        }\n\n        keyBinds.push({ id: keyBind.id, key, isEnabled });\n      }\n      setData(keyBinds);\n    };\n    loadKeyBinds();\n  }, []);\n\n  useEffect(() => {\n    (async () => {\n      fuse.current = new Fuse(\n        data.map(\n          (item) =>\n            ({\n              key: item.id,\n              value: item.key,\n              isEnabled: item.isEnabled,\n              i18n: t(item.id, { returnObjects: true }),\n            }) as any,\n        ),\n        {\n          keys: [\"key\", \"value\", \"isEnabled\", \"i18n.title\", \"i18n.description\"],\n          useExtendedSearch: true,\n        },\n      );\n    })();\n  }, [data, t]);\n\n  // 搜索逻辑\n  useEffect(() => {\n    if (!fuse.current || !searchKeyword) {\n      setSearchResult([]);\n      return;\n    }\n    const result = fuse.current.search(searchKeyword).map((it) => it.item.key);\n    setSearchResult(result);\n  }, [searchKeyword]);\n\n  const getUnGroupedKeys = () => {\n    return data\n      .filter((item) => {\n        return !shortcutKeysGroups.some((group) => group.keys.includes(item.id));\n      })\n      .map((item) => item.id);\n  };\n\n  // 判断两个快捷键是否重叠（完全相同，或一方是另一方的序列前缀，如 \"q\" 与 \"q e\"）\n  const isKeyOverlap = (key1: string, key2: string) => {\n    if (key1 === key2) return true;\n    // 序列快捷键：一方是另一方的前缀则重叠（先按 q 再按 e 与 单按 q 会冲突）\n    return key2.startsWith(key1 + \" \") || key1.startsWith(key2 + \" \");\n  };\n\n  // 检测快捷键冲突（含完全一致 + 序列前缀重叠）\n  const detectKeyConflicts = (targetKey: string, targetId: string) => {\n    return data.filter((item) => item.id !== targetId && item.isEnabled && isKeyOverlap(item.key, targetKey));\n  };\n\n  // 处理冲突提示点击\n  const handleConflictClick = (conflicts: KeyBindData[], key: string) => {\n    setCurrentConflicts(conflicts);\n    setCurrentConflictKey(key);\n    setConflictDialogOpen(true);\n  };\n\n  const allGroups = [\n    ...shortcutKeysGroups.map((group) => ({\n      title: group.title,\n      icon: group.icon,\n      keys: group.keys,\n      isOther: false,\n    })),\n    {\n      title: \"otherKeys\",\n      icon: <FileQuestion />,\n      keys: getUnGroupedKeys(),\n      isOther: true,\n    },\n  ];\n\n  // 渲染快捷键项\n  const renderKeyFields = (keys: string[]) =>\n    keys.map((id) => {\n      const keyBindData = data.find((item) => item.id === id);\n      const keyBind = allKeyBinds.find((kb) => kb.id === id);\n      const conflicts = keyBindData ? detectKeyConflicts(keyBindData.key, id) : [];\n      return (\n        <Field\n          key={id}\n          icon={<Keyboard />}\n          title={t(`${id}.title`, { defaultValue: id })}\n          description={t(`${id}.description`, { defaultValue: \"\" })}\n          className=\"border-accent border-b\"\n          extra={\n            conflicts.length > 0 ? (\n              <div className=\"w-full\">\n                <div\n                  className=\"bg-primary/10 text-primary hover:bg-primary/20 flex cursor-pointer items-center rounded px-3 py-1.5 text-xs\"\n                  onClick={() => handleConflictClick(conflicts, keyBindData?.key || \"\")}\n                >\n                  <AlertCircle className=\"mr-1 h-3 w-3\" />与 {conflicts.length} 个快捷键重叠\n                </div>\n              </div>\n            ) : (\n              <></>\n            )\n          }\n        >\n          <div className=\"flex items-center gap-2\">\n            <RotateCw\n              className=\"text-panel-details-text h-4 w-4 cursor-pointer opacity-0 transition-all hover:rotate-180 group-hover/field:opacity-100\"\n              onClick={() => {\n                if (keyBind) {\n                  let defaultValue = keyBind.defaultKey;\n                  // 应用Mac键位转换\n                  if (isMac) {\n                    defaultValue = transEmacsKeyWinToMac(defaultValue);\n                  }\n                  setData((data) => data.map((item) => (item.id === id ? { ...item, key: defaultValue } : item)));\n                  KeyBindsUI.changeOneUIKeyBind(id, defaultValue);\n                  Dialog.confirm(\n                    `已重置为 '${defaultValue}'，但需要刷新页面后生效`,\n                    \"切换左侧选项卡即可更新页面显示，看到效果。\",\n                  );\n                }\n              }}\n            />\n            <KeyBind\n              defaultValue={keyBindData?.key}\n              onChange={(value) => {\n                setData((data) =>\n                  data.map((item) => {\n                    if (item.id === id) {\n                      return { ...item, key: value };\n                    }\n                    return item;\n                  }),\n                );\n                const keyBindType = getKeyBindTypeById(id);\n                if (keyBindType === \"global\") {\n                  Dialog.confirm(`已重置为 '${value}'，但需要重启软件才能生效`, \"\");\n                } else {\n                  KeyBindsUI.changeOneUIKeyBind(id, value);\n                }\n              }}\n            />\n            <div className=\"flex items-center gap-2\">\n              <span className=\"text-muted-foreground text-sm\">启用</span>\n              <Switch\n                checked={keyBindData?.isEnabled || false}\n                onCheckedChange={async (checked) => {\n                  setData((data) =>\n                    data.map((item) => {\n                      if (item.id === id) {\n                        return { ...item, isEnabled: checked };\n                      }\n                      return item;\n                    }),\n                  );\n                  await KeyBindsUI.toggleEnabled(id);\n                }}\n              />\n            </div>\n          </div>\n        </Field>\n      );\n    });\n\n  return (\n    <div className=\"flex h-full\">\n      <Sidebar className=\"h-full overflow-auto\">\n        <SidebarContent>\n          <SidebarGroup>\n            <SidebarGroupContent>\n              <SidebarMenu>\n                <SidebarMenuItem>\n                  <SidebarMenuButton\n                    asChild\n                    isActive={currentGroup === \"search\"}\n                    onClick={() => setCurrentGroup(\"search\")}\n                  >\n                    <div>\n                      <SearchIcon />\n                      <span>搜索</span>\n                    </div>\n                  </SidebarMenuButton>\n                </SidebarMenuItem>\n                {allGroups.map((group) => (\n                  <SidebarMenuItem key={group.title}>\n                    <SidebarMenuButton\n                      asChild\n                      isActive={currentGroup === group.title}\n                      onClick={() => setCurrentGroup(group.title)}\n                    >\n                      <div>\n                        {group.icon}\n                        <span>{t2(`${group.title}.title`)}</span>\n                      </div>\n                    </SidebarMenuButton>\n                  </SidebarMenuItem>\n                ))}\n                <div className=\"bg-border my-2 h-px w-full\"></div>\n                <div className=\"text-muted-foreground p-2 text-sm\">重置选项：</div>\n                <SidebarMenuItem>\n                  <SidebarMenuButton\n                    asChild\n                    onClick={async () => {\n                      const confirmed = await Dialog.confirm(\n                        \"确认重置所有快捷键？\",\n                        \"此操作将重置所有快捷键的值和启用状态为默认值，是否继续？\",\n                      );\n                      if (confirmed) {\n                        await KeyBindsUI.resetAllKeyBinds();\n                        // 重新加载数据\n                        const store = await createStore(\"keybinds2.json\");\n                        const keyBinds: KeyBindData[] = [];\n                        for (const keyBind of allKeyBinds.filter((keybindItem) => !keybindItem.isGlobal)) {\n                          const savedData = await store.get<any>(keyBind.id);\n                          let key: string;\n                          let isEnabled: boolean;\n\n                          if (!savedData) {\n                            // 没有保存过，走默认设置\n                            key = keyBind.defaultKey;\n                            isEnabled = keyBind.defaultEnabled !== false;\n                          } else if (typeof savedData === \"string\") {\n                            // 兼容旧数据结构\n                            key = savedData;\n                            isEnabled = keyBind.defaultEnabled !== false;\n                          } else {\n                            // 已经保存过完整配置\n                            key = savedData.key;\n                            isEnabled = savedData.isEnabled !== false;\n                          }\n\n                          keyBinds.push({ id: keyBind.id, key, isEnabled });\n                        }\n                        setData(keyBinds);\n                        Dialog.confirm(\"已重置所有快捷键\", \"所有快捷键的值和启用状态已恢复为默认值。\");\n                      }\n                    }}\n                  >\n                    <div>\n                      <RotateCw className=\"h-4 w-4\" />\n                      <span>重置所有（值+状态）</span>\n                    </div>\n                  </SidebarMenuButton>\n                </SidebarMenuItem>\n                <SidebarMenuItem>\n                  <SidebarMenuButton\n                    asChild\n                    onClick={async () => {\n                      const confirmed = await Dialog.confirm(\n                        \"确认仅重置快捷键值？\",\n                        \"此操作将仅重置所有快捷键的值为默认值，保留当前启用状态，是否继续？\",\n                      );\n                      if (confirmed) {\n                        await KeyBindsUI.resetAllKeyBindsValues();\n                        // 重新加载数据\n                        const store = await createStore(\"keybinds2.json\");\n                        const keyBinds: KeyBindData[] = [];\n                        for (const keyBind of allKeyBinds.filter((keybindItem) => !keybindItem.isGlobal)) {\n                          const savedData = await store.get<any>(keyBind.id);\n                          let key: string;\n                          let isEnabled: boolean;\n\n                          if (!savedData) {\n                            // 没有保存过，走默认设置\n                            key = keyBind.defaultKey;\n                            isEnabled = keyBind.defaultEnabled !== false;\n                          } else if (typeof savedData === \"string\") {\n                            // 兼容旧数据结构\n                            key = savedData;\n                            isEnabled = keyBind.defaultEnabled !== false;\n                          } else {\n                            // 已经保存过完整配置\n                            key = savedData.key;\n                            isEnabled = savedData.isEnabled !== false;\n                          }\n\n                          keyBinds.push({ id: keyBind.id, key, isEnabled });\n                        }\n                        setData(keyBinds);\n                        Dialog.confirm(\"已重置快捷键值\", \"所有快捷键的值已恢复为默认值，启用状态保持不变。\");\n                      }\n                    }}\n                  >\n                    <div>\n                      <Keyboard className=\"h-4 w-4\" />\n                      <span>仅重置快捷键值</span>\n                    </div>\n                  </SidebarMenuButton>\n                </SidebarMenuItem>\n                <SidebarMenuItem>\n                  <SidebarMenuButton\n                    asChild\n                    onClick={async () => {\n                      const confirmed = await Dialog.confirm(\n                        \"确认仅重置启用状态？\",\n                        \"此操作将仅重置所有快捷键的启用状态为默认值，保留当前快捷键值，是否继续？\",\n                      );\n                      if (confirmed) {\n                        await KeyBindsUI.resetAllKeyBindsEnabledState();\n                        // 重新加载数据\n                        const store = await createStore(\"keybinds2.json\");\n                        const keyBinds: KeyBindData[] = [];\n                        for (const keyBind of allKeyBinds.filter((keybindItem) => !keybindItem.isGlobal)) {\n                          const savedData = await store.get<any>(keyBind.id);\n                          let key: string;\n                          let isEnabled: boolean;\n\n                          if (!savedData) {\n                            // 没有保存过，走默认设置\n                            key = keyBind.defaultKey;\n                            isEnabled = keyBind.defaultEnabled !== false;\n                          } else if (typeof savedData === \"string\") {\n                            // 兼容旧数据结构\n                            key = savedData;\n                            isEnabled = keyBind.defaultEnabled !== false;\n                          } else {\n                            // 已经保存过完整配置\n                            key = savedData.key;\n                            isEnabled = savedData.isEnabled !== false;\n                          }\n\n                          keyBinds.push({ id: keyBind.id, key, isEnabled });\n                        }\n                        setData(keyBinds);\n                        Dialog.confirm(\"已重置启用状态\", \"所有快捷键的启用状态已恢复为默认值，快捷键值保持不变。\");\n                      }\n                    }}\n                  >\n                    <div>\n                      <Switch className=\"h-4 w-4\" />\n                      <span>仅重置启用状态</span>\n                    </div>\n                  </SidebarMenuButton>\n                </SidebarMenuItem>\n              </SidebarMenu>\n            </SidebarGroupContent>\n          </SidebarGroup>\n        </SidebarContent>\n      </Sidebar>\n      <div className=\"mx-auto flex w-2/3 flex-col overflow-auto\">\n        {currentGroup === \"search\" ? (\n          <>\n            <Input\n              value={searchKeyword}\n              onChange={(e) => setSearchKeyword(e.target.value)}\n              placeholder=\"搜索...\"\n              autoFocus\n            />\n            {searchKeyword === \"\" && (\n              <>\n                <span className=\"h-4\"></span>\n                <span>直接输入: 模糊匹配</span>\n                <span>空格分割: “与”</span>\n                <span>竖线分割: “或”</span>\n                <span>=: 精确匹配</span>\n                <span>&apos;: 包含</span>\n                <span>!: 反向匹配</span>\n                <span>^: 匹配开头</span>\n                <span>!^: 反向匹配开头</span>\n                <span>$: 匹配结尾</span>\n                <span>!$: 反向匹配结尾</span>\n              </>\n            )}\n            {searchResult.length > 0\n              ? renderKeyFields(searchResult)\n              : searchKeyword !== \"\" && <span>没有匹配的快捷键</span>}\n          </>\n        ) : currentGroup ? (\n          <>\n            {t2(`${currentGroup}.description`, { defaultValue: \"\" })}\n            {renderKeyFields(allGroups.find((g) => g.title === currentGroup)?.keys ?? [])}\n          </>\n        ) : (\n          <div className=\"text-muted-foreground flex h-full items-center justify-center\">\n            <span>请选择左侧分组</span>\n          </div>\n        )}\n      </div>\n      {/* 重叠详情对话框 */}\n      <Dialog open={conflictDialogOpen} onOpenChange={setConflictDialogOpen}>\n        <DialogContent>\n          <DialogHeader>\n            <DialogTitle>快捷键重叠详情</DialogTitle>\n            <DialogDescription>\n              以下快捷键与 {currentConflictKey} 重叠：\n              <div className=\"mt-2 text-sm\">\n                注意：完全相等的快捷键会一起执行所有相关功能、前缀重叠的快捷键会执行较短的那个快捷键\n              </div>\n            </DialogDescription>\n          </DialogHeader>\n          <div className=\"max-h-96 overflow-y-auto\">\n            {currentConflicts.map((conflict) => {\n              const conflictGroup = shortcutKeysGroups.find((group) => group.keys.includes(conflict.id));\n              return (\n                <div key={conflict.id} className=\"border-border border-b p-2 last:border-0\">\n                  <div className=\"font-medium\">{t(`${conflict.id}.title`, { defaultValue: conflict.id })}</div>\n                  <div className=\"text-muted-foreground text-sm\">\n                    键位: {conflict.key}\n                    {conflictGroup && ` | 分组: ${t2(`${conflictGroup.title}.title`)}`}\n                  </div>\n                </div>\n              );\n            })}\n          </div>\n          <DialogClose asChild>\n            <button className=\"bg-primary text-primary-foreground hover:bg-primary/90 mt-4 rounded px-4 py-2\">\n              关闭\n            </button>\n          </DialogClose>\n        </DialogContent>\n      </Dialog>\n    </div>\n  );\n}\ntype ShortcutKeysGroup = {\n  title: string;\n  icon: React.ReactNode;\n  keys: string[];\n};\n\nexport const shortcutKeysGroups: ShortcutKeysGroup[] = [\n  {\n    title: \"basic\",\n    icon: <Keyboard />,\n    keys: [\n      \"saveFile\",\n      \"openFile\",\n      \"newDraft\",\n      \"newFileAtCurrentProjectDir\",\n      \"undo\",\n      \"redo\",\n      \"selectAll\",\n      \"searchText\",\n      \"copy\",\n      \"paste\",\n      \"pasteWithOriginLocation\",\n      \"deleteSelectedStageObjects\",\n    ],\n  },\n  {\n    title: \"camera\",\n    icon: <Fullscreen />,\n    keys: [\n      \"resetView\",\n      \"restoreCameraState\",\n      \"resetCameraScale\",\n      \"masterBrakeCheckout\",\n      \"masterBrakeControl\",\n      \"CameraScaleZoomIn\",\n      \"CameraScaleZoomOut\",\n      \"CameraPageMoveUp\",\n      \"CameraPageMoveDown\",\n      \"CameraPageMoveLeft\",\n      \"CameraPageMoveRight\",\n    ],\n  },\n  {\n    title: \"app\",\n    icon: <AppWindow />,\n    keys: [\"switchDebugShow\", \"exitSoftware\", \"checkoutProtectPrivacy\", \"reload\"],\n  },\n  {\n    title: \"ui\",\n    icon: <PanelsTopLeft />,\n    keys: [\n      \"checkoutClassroomMode\",\n      \"checkoutWindowOpacityMode\",\n      \"windowOpacityAlphaIncrease\",\n      \"windowOpacityAlphaDecrease\",\n      \"openColorPanel\",\n      \"clickAppMenuSettingsButton\",\n      \"clickTagPanelButton\",\n      \"clickAppMenuRecentFileButton\",\n      \"clickStartFilePanelButton\",\n      \"switchActiveProject\",\n      \"switchActiveProjectReversed\",\n      \"closeCurrentProjectTab\",\n      \"switchStealthMode\",\n      \"closeAllSubWindows\",\n      \"toggleFullscreen\",\n      \"setWindowToMiniSize\",\n    ],\n  },\n  {\n    title: \"draw\",\n    icon: <Brush />,\n    keys: [\"selectEntityByPenStroke\", \"penStrokeWidthIncrease\", \"penStrokeWidthDecrease\"],\n  },\n  {\n    title: \"select\",\n    icon: <Scan />,\n    keys: [\n      \"selectUp\",\n      \"selectDown\",\n      \"selectLeft\",\n      \"selectRight\",\n      \"selectAdditionalUp\",\n      \"selectAdditionalDown\",\n      \"selectAdditionalLeft\",\n      \"selectAdditionalRight\",\n      \"selectAtCrosshair\",\n      \"addSelectAtCrosshair\",\n    ],\n  },\n  {\n    title: \"expandSelect\",\n    icon: <Split className=\"rotate-90\" />,\n    keys: [\n      \"expandSelectEntity\",\n      \"expandSelectEntityReversed\",\n      \"expandSelectEntityKeepLastSelected\",\n      \"expandSelectEntityReversedKeepLastSelected\",\n    ],\n  },\n  {\n    title: \"moveEntity\",\n    icon: <Move />,\n    keys: [\n      \"moveUpSelectedEntities\",\n      \"moveDownSelectedEntities\",\n      \"moveLeftSelectedEntities\",\n      \"moveRightSelectedEntities\",\n      \"jumpMoveUpSelectedEntities\",\n      \"jumpMoveDownSelectedEntities\",\n      \"jumpMoveLeftSelectedEntities\",\n      \"jumpMoveRightSelectedEntities\",\n    ],\n  },\n  {\n    title: \"generateTextNodeInTree\",\n    icon: <Network className=\"-rotate-90\" />,\n    keys: [\n      \"generateNodeTreeWithDeepMode\",\n      \"generateNodeTreeWithBroadMode\",\n      \"generateNodeGraph\",\n      \"treeGraphAdjust\",\n      \"treeGraphAdjustSelectedAsRoot\",\n      \"gravityLayout\",\n      \"setNodeTreeDirectionUp\",\n      \"setNodeTreeDirectionDown\",\n      \"setNodeTreeDirectionLeft\",\n      \"setNodeTreeDirectionRight\",\n    ],\n  },\n  {\n    title: \"generateTextNodeRoundedSelectedNode\",\n    icon: <SendToBack />,\n    keys: [\n      \"createTextNodeFromSelectedTop\",\n      \"createTextNodeFromSelectedDown\",\n      \"createTextNodeFromSelectedLeft\",\n      \"createTextNodeFromSelectedRight\",\n    ],\n  },\n  {\n    title: \"aboutTextNode\",\n    icon: <SquareMenu />,\n    keys: [\n      \"createTextNodeFromCameraLocation\",\n      \"createTextNodeFromMouseLocation\",\n      \"toggleTextNodeSizeMode\",\n      \"splitTextNodes\",\n      \"mergeTextNodes\",\n      \"swapTextAndDetails\",\n      \"decreaseFontSize\",\n      \"increaseFontSize\",\n    ],\n  },\n  {\n    title: \"section\",\n    icon: <SquareDashed />,\n    keys: [\"folderSection\", \"packEntityToSection\", \"unpackEntityFromSection\", \"textNodeToSection\", \"toggleSectionLock\"],\n  },\n  {\n    title: \"leftMouseModeCheckout\",\n    icon: <MousePointer />,\n    keys: [\n      \"checkoutLeftMouseToSelectAndMove\",\n      \"checkoutLeftMouseToDrawing\",\n      \"checkoutLeftMouseToConnectAndCutting\",\n      \"checkoutLeftMouseToConnectAndCuttingOnlyPressed\",\n    ],\n  },\n  {\n    title: \"edge\",\n    icon: <Spline />,\n    keys: [\n      \"reverseEdges\",\n      \"reverseSelectedNodeEdge\",\n      \"createUndirectedEdgeFromEntities\",\n      \"selectAllEdges\",\n      \"createConnectPointWhenDragConnecting\",\n    ],\n  },\n  {\n    title: \"node\",\n    icon: <Network />,\n    keys: [\n      \"graftNodeToTree\",\n      \"removeNodeFromTree\",\n      \"connectTopToBottom\",\n      \"connectLeftToRight\",\n      \"connectAllSelectedEntities\",\n    ],\n  },\n  {\n    title: \"themes\",\n    icon: <SunMoon />,\n    keys: [\n      \"switchToDarkTheme\",\n      \"switchToLightTheme\",\n      \"switchToParkTheme\",\n      \"switchToMacaronTheme\",\n      \"switchToMorandiTheme\",\n    ],\n  },\n  {\n    title: \"align\",\n    icon: <Spline />,\n    keys: [\n      \"alignTop\",\n      \"alignBottom\",\n      \"alignLeft\",\n      \"alignRight\",\n      \"alignHorizontalSpaceBetween\",\n      \"alignVerticalSpaceBetween\",\n      \"alignCenterHorizontal\",\n      \"alignCenterVertical\",\n      \"alignLeftToRightNoSpace\",\n      \"alignTopToBottomNoSpace\",\n    ],\n  },\n  { title: \"image\", icon: <Image />, keys: [\"reverseImageColors\"] },\n];\n"
  },
  {
    "path": "app/src/sub/SettingsWindow/keybindsGlobal.tsx",
    "content": "import { SettingField } from \"@/components/ui/field\";\n// import { Settings } from \"@/core/service/Settings\";\n\nexport default function KeyBindsGlobalPage() {\n  return (\n    <div className=\"p-4\">\n      <h2>全局快捷键</h2>\n      <p>说明：目前仅有两个全局快捷键：Alt+1打开软件窗口，Alt+2的开启/关闭窗口穿透点击</p>\n      {/* <p>当前状态：{Settings.allowGlobalHotKeys ? \"开启\" : \"关闭\"}</p> */}\n      <SettingField settingKey=\"allowGlobalHotKeys\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/src/sub/SettingsWindow/quick-settings.tsx",
    "content": "import { settingsSchema } from \"@/core/service/Settings\";\nimport { QuickSettingsManager } from \"@/core/service/QuickSettingsManager\";\nimport { settingsIcons } from \"@/core/service/SettingsIcons\";\nimport { Button } from \"@/components/ui/button\";\nimport { useTranslation } from \"react-i18next\";\nimport { useEffect, useState } from \"react\";\nimport { Fragment } from \"react\";\nimport { GripVertical, Plus, Trash2 } from \"lucide-react\";\n\n/**\n * 快捷设置项管理页面\n */\nexport default function QuickSettingsTab() {\n  const { t } = useTranslation(\"settings\");\n  const [quickSettings, setQuickSettings] = useState<QuickSettingsManager.QuickSettingItem[]>([]);\n  const [availableSettings, setAvailableSettings] = useState<Array<keyof ReturnType<typeof settingsSchema._def.shape>>>(\n    [],\n  );\n\n  useEffect(() => {\n    loadData();\n  }, []);\n\n  const loadData = async () => {\n    const items = await QuickSettingsManager.getQuickSettings();\n    setQuickSettings(items);\n\n    const allBooleanSettings = QuickSettingsManager.getAllAvailableBooleanSettings();\n    const currentKeys = new Set(items.map((it) => it.settingKey));\n    const available = allBooleanSettings.filter((key) => !currentKeys.has(key));\n    setAvailableSettings(available);\n  };\n\n  const handleAdd = async (settingKey: keyof ReturnType<typeof settingsSchema._def.shape>) => {\n    await QuickSettingsManager.addQuickSetting({ settingKey });\n    await loadData();\n  };\n\n  const handleRemove = async (settingKey: keyof ReturnType<typeof settingsSchema._def.shape>) => {\n    await QuickSettingsManager.removeQuickSetting(settingKey);\n    await loadData();\n  };\n\n  const handleMoveUp = async (index: number) => {\n    if (index === 0) return;\n    const newOrder = [...quickSettings];\n    [newOrder[index - 1], newOrder[index]] = [newOrder[index], newOrder[index - 1]];\n    await QuickSettingsManager.reorderQuickSettings(newOrder);\n    await loadData();\n  };\n\n  const handleMoveDown = async (index: number) => {\n    if (index === quickSettings.length - 1) return;\n    const newOrder = [...quickSettings];\n    [newOrder[index], newOrder[index + 1]] = [newOrder[index + 1], newOrder[index]];\n    await QuickSettingsManager.reorderQuickSettings(newOrder);\n    await loadData();\n  };\n\n  return (\n    <div className=\"flex h-full flex-col gap-4 p-4\">\n      <div>\n        <h2 className=\"text-lg font-semibold\">快捷设置项管理</h2>\n        <p className=\"text-muted-foreground text-sm\">管理右侧工具栏中显示的快捷设置项。您可以添加、删除和调整顺序。</p>\n      </div>\n\n      <div className=\"flex-1 overflow-auto\">\n        <div className=\"space-y-2\">\n          <h3 className=\"text-sm font-medium\">当前快捷设置项</h3>\n          {quickSettings.length === 0 ? (\n            <p className=\"text-muted-foreground text-sm\">暂无快捷设置项</p>\n          ) : (\n            <div className=\"space-y-2\">\n              {quickSettings.map((item, index) => {\n                const settingKey = item.settingKey;\n                const Icon = settingsIcons[settingKey as keyof typeof settingsIcons] ?? Fragment;\n                const title = t(`${settingKey}.title` as string);\n\n                return (\n                  <div key={settingKey as string} className=\"flex items-center gap-2 rounded-lg border p-3\">\n                    <GripVertical className=\"text-muted-foreground h-4 w-4 cursor-move\" />\n                    {Icon !== Fragment ? <Icon className=\"h-4 w-4\" /> : <div className=\"h-4 w-4\" />}\n                    <span className=\"flex-1 text-sm\">{title}</span>\n                    <div className=\"flex gap-1\">\n                      <Button\n                        variant=\"ghost\"\n                        size=\"icon\"\n                        className=\"h-8 w-8\"\n                        onClick={() => handleMoveUp(index)}\n                        disabled={index === 0}\n                      >\n                        ↑\n                      </Button>\n                      <Button\n                        variant=\"ghost\"\n                        size=\"icon\"\n                        className=\"h-8 w-8\"\n                        onClick={() => handleMoveDown(index)}\n                        disabled={index === quickSettings.length - 1}\n                      >\n                        ↓\n                      </Button>\n                      <Button\n                        variant=\"ghost\"\n                        size=\"icon\"\n                        className=\"text-destructive hover:text-destructive h-8 w-8\"\n                        onClick={() => handleRemove(settingKey)}\n                      >\n                        <Trash2 className=\"h-4 w-4\" />\n                      </Button>\n                    </div>\n                  </div>\n                );\n              })}\n            </div>\n          )}\n        </div>\n\n        {availableSettings.length > 0 && (\n          <div className=\"mt-6 space-y-2\">\n            <h3 className=\"text-sm font-medium\">可添加的设置项</h3>\n            <div className=\"space-y-2\">\n              {availableSettings.map((settingKey) => {\n                const Icon = settingsIcons[settingKey as keyof typeof settingsIcons] ?? Fragment;\n                const title = t(`${settingKey}.title` as string);\n\n                return (\n                  <div key={settingKey as string} className=\"flex items-center gap-2 rounded-lg border p-3\">\n                    {Icon !== Fragment ? <Icon className=\"h-4 w-4\" /> : <div className=\"h-4 w-4\" />}\n                    <span className=\"flex-1 text-sm\">{title}</span>\n                    <Button variant=\"outline\" size=\"sm\" onClick={() => handleAdd(settingKey)}>\n                      <Plus className=\"mr-1 h-4 w-4\" />\n                      添加\n                    </Button>\n                  </div>\n                );\n              })}\n            </div>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/src/sub/SettingsWindow/settings.tsx",
    "content": "import { Collapsible, CollapsibleContent, CollapsibleTrigger } from \"@/components/ui/collapsible\";\nimport { SettingField } from \"@/components/ui/field\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n  Sidebar,\n  SidebarContent,\n  SidebarGroup,\n  SidebarGroupContent,\n  SidebarMenu,\n  SidebarMenuButton,\n  SidebarMenuItem,\n  SidebarMenuSub,\n  SidebarMenuSubButton,\n  SidebarMenuSubItem,\n} from \"@/components/ui/sidebar\";\nimport { SoundService } from \"@/core/service/feedbackService/SoundService\";\nimport { settingsSchema } from \"@/core/service/Settings\";\nimport Fuse from \"fuse.js\";\nimport {\n  AppWindowMac,\n  ArrowUpRight,\n  Bot,\n  Box,\n  Brain,\n  Bug,\n  Camera,\n  ChevronRight,\n  Clock,\n  Cpu,\n  Eye,\n  File,\n  Folder,\n  Gamepad,\n  Layers,\n  MemoryStick,\n  Mouse,\n  Network,\n  PictureInPicture,\n  ReceiptText,\n  Save,\n  Search,\n  Sparkle,\n  SquareDashedMousePointer,\n  Text,\n  TextSelect,\n  Touchpad,\n  Workflow,\n  Wrench,\n  Zap,\n  ZoomIn,\n} from \"lucide-react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\n\nexport default function SettingsTab() {\n  const { t } = useTranslation(\"settings\");\n  const [currentCategory, setCurrentCategory] = useState(\"search\");\n  const [currentGroup, setCurrentGroup] = useState(\"\");\n  const [searchKeyword, setSearchKeyword] = useState(\"\");\n  const [searchResult, setSearchResult] = useState<string[]>([]);\n  const fuse = useRef<Fuse<{ key: string; i18n: { title: string; description: string } }>>(null);\n\n  useEffect(() => {\n    fuse.current = new Fuse(\n      Object.keys(settingsSchema._def.shape()).map(\n        (key) =>\n          ({\n            key,\n            i18n: t(key, { returnObjects: true }),\n          }) as any,\n      ),\n      { keys: [\"key\", \"i18n.title\", \"i18n.description\"], useExtendedSearch: true },\n    );\n  }, []);\n  useEffect(() => {\n    if (!fuse.current) return;\n    const result = fuse.current.search(searchKeyword).map((it) => it.item.key);\n    setSearchResult(result);\n  }, [searchKeyword, fuse]);\n\n  return (\n    <div className=\"flex h-full\">\n      <Sidebar className=\"h-full overflow-auto\">\n        <SidebarContent>\n          <SidebarGroup>\n            <SidebarGroupContent>\n              <SidebarMenu>\n                <SidebarMenuItem>\n                  <SidebarMenuButton\n                    asChild\n                    isActive={currentCategory === \"search\"}\n                    onClick={() => setCurrentCategory(\"search\")}\n                  >\n                    <div>\n                      <Search />\n                      <span>搜索</span>\n                    </div>\n                  </SidebarMenuButton>\n                </SidebarMenuItem>\n                {Object.entries(categories).map(([category, value]) => {\n                  // @ts-expect-error fuck ts\n                  const CategoryIcon = categoryIcons[category].icon;\n                  return (\n                    <Collapsible key={category} defaultOpen className=\"group/collapsible\">\n                      <SidebarMenuItem>\n                        <SidebarMenuButton asChild>\n                          <CollapsibleTrigger\n                            onMouseEnter={() => {\n                              SoundService.play.mouseEnterButton();\n                            }}\n                            onMouseDown={() => {\n                              SoundService.play.mouseClickButton();\n                            }}\n                          >\n                            <CategoryIcon />\n                            <span>{t(`categories.${category}.title`)}</span>\n                            <ChevronRight className=\"ml-auto transition-transform group-data-[state=open]/collapsible:rotate-90\" />\n                          </CollapsibleTrigger>\n                        </SidebarMenuButton>\n                        <SidebarMenuSub>\n                          <CollapsibleContent>\n                            {Object.entries(value).map(([group]) => {\n                              // @ts-expect-error fuck ts\n                              const GroupIcon = categoryIcons[category][group];\n                              return (\n                                <SidebarMenuSubItem key={group}>\n                                  <SidebarMenuSubButton\n                                    onMouseEnter={() => {\n                                      SoundService.play.mouseEnterButton();\n                                    }}\n                                    onMouseDown={() => {\n                                      SoundService.play.mouseClickButton();\n                                    }}\n                                    asChild\n                                    isActive={category === currentCategory && group === currentGroup}\n                                    onClick={() => {\n                                      setCurrentCategory(category);\n                                      setCurrentGroup(group);\n                                    }}\n                                  >\n                                    <a href=\"#\">\n                                      <GroupIcon />\n                                      <span>{t(`categories.${category}.${group}`)}</span>\n                                    </a>\n                                  </SidebarMenuSubButton>\n                                </SidebarMenuSubItem>\n                              );\n                            })}\n                          </CollapsibleContent>\n                        </SidebarMenuSub>\n                      </SidebarMenuItem>\n                    </Collapsible>\n                  );\n                })}\n              </SidebarMenu>\n            </SidebarGroupContent>\n          </SidebarGroup>\n        </SidebarContent>\n      </Sidebar>\n      <div className=\"mx-auto flex w-2/3 flex-col overflow-auto\">\n        {currentCategory === \"search\" ? (\n          <>\n            <Input\n              value={searchKeyword}\n              onChange={(e) => setSearchKeyword(e.target.value)}\n              placeholder=\"搜索...\"\n              autoFocus\n            />\n            {searchResult.length === 0 && (\n              <>\n                <span className=\"h-4\"></span>\n                <span>直接输入: 模糊匹配</span>\n                <span>空格分割: “与”</span>\n                <span>竖线分割: “或”</span>\n                <span>=: 精确匹配</span>\n                <span>&apos;: 包含</span>\n                <span>!: 反向匹配</span>\n                <span>^: 匹配开头</span>\n                <span>!^: 反向匹配开头</span>\n                <span>$: 匹配结尾</span>\n                <span>!$: 反向匹配结尾</span>\n              </>\n            )}\n            {searchResult.map((it) => (\n              <SettingField key={it} settingKey={it as any} />\n            ))}\n          </>\n        ) : (\n          currentCategory &&\n          currentGroup &&\n          // @ts-expect-error fuck ts\n          categories[currentCategory][currentGroup]?.map((key) => <SettingField key={key} settingKey={key} />)\n        )}\n      </div>\n    </div>\n  );\n}\n\nconst categories = {\n  visual: {\n    basic: [\n      \"language\",\n      \"isClassroomMode\",\n      \"showQuickSettingsToolbar\",\n      \"windowBackgroundAlpha\",\n      \"windowBackgroundOpacityAfterOpenClickThrough\",\n      \"windowBackgroundOpacityAfterCloseClickThrough\",\n    ],\n    background: [\n      \"isRenderCenterPointer\",\n      \"showBackgroundHorizontalLines\",\n      \"showBackgroundVerticalLines\",\n      \"showBackgroundDots\",\n      \"showBackgroundCartesian\",\n      \"isStealthModeEnabled\",\n      \"stealthModeScopeRadius\",\n      \"stealthModeReverseMask\",\n      \"stealthModeMaskShape\",\n    ],\n    node: [\"enableTagTextNodesBigDisplay\", \"showTextNodeBorder\", \"showTreeDirectionHint\"],\n    edge: [\"lineStyle\"],\n    section: [\n      \"sectionBitTitleRenderType\",\n      \"sectionBigTitleThresholdRatio\",\n      \"sectionBigTitleCameraScaleThreshold\",\n      \"sectionBigTitleOpacity\",\n      \"sectionBackgroundFillMode\",\n    ],\n    entityDetails: [\n      \"nodeDetailsPanel\",\n      \"alwaysShowDetails\",\n      \"entityDetailsFontSize\",\n      \"entityDetailsLinesLimit\",\n      \"entityDetailsWidthLimit\",\n    ],\n    debug: [\"showDebug\", \"protectingPrivacy\", \"protectingPrivacyMode\"],\n    miniWindow: [\"windowCollapsingWidth\", \"windowCollapsingHeight\"],\n    experimental: [\"limitCameraInCycleSpace\", \"cameraCycleSpaceSizeX\", \"cameraCycleSpaceSizeY\"],\n  },\n  automation: {\n    autoNamer: [\"autoNamerTemplate\", \"autoNamerSectionTemplate\"],\n    autoSave: [\"autoSaveWhenClose\", \"autoSave\", \"autoSaveInterval\"],\n    autoBackup: [\"autoBackup\", \"autoBackupInterval\", \"autoBackupLimitCount\", \"autoBackupCustomPath\"],\n    autoImport: [\"autoImportTxtFileWhenOpenPrg\"],\n  },\n  control: {\n    mouse: [\n      \"mouseRightDragBackground\",\n      \"mouseLeftMode\",\n      \"enableSpaceKeyMouseLeftDrag\",\n      \"enableDragAutoAlign\",\n      \"reverseTreeMoveMode\",\n      \"mouseWheelMode\",\n      \"mouseWheelWithShiftMode\",\n      \"mouseWheelWithCtrlMode\",\n      \"mouseWheelWithAltMode\",\n      \"doubleClickMiddleMouseButton\",\n      \"mouseSideWheelMode\",\n      \"macMouseWheelIsSmoothed\",\n      \"macEnableControlToCut\",\n      \"enableCtrlWheelRotateStructure\",\n    ],\n    touchpad: [\"enableWindowsTouchPad\", \"macTrackpadAndMouseWheelDifference\", \"macTrackpadScaleSensitivity\"],\n    cameraMove: [\"allowMoveCameraByWSAD\", \"cameraKeyboardMoveReverse\", \"moveAmplitude\", \"moveFriction\"],\n    cameraZoom: [\n      \"scaleExponent\",\n      \"cameraResetViewPaddingRate\",\n      \"cameraResetMaxScale\",\n      \"scaleCameraByMouseLocation\",\n      \"cameraKeyboardScaleRate\",\n      \"cameraKeyboardScaleReverse\",\n    ],\n    objectSelect: [\n      \"rectangleSelectWhenRight\",\n      \"rectangleSelectWhenLeft\",\n      \"cameraFollowsSelectedNodeOnArrowKeys\",\n      \"arrowKeySelectOnlyInViewport\",\n    ],\n    textNode: [\n      \"textNodeStartEditMode\",\n      \"textNodeContentLineBreak\",\n      \"textNodeExitEditMode\",\n      \"textNodeSelectAllWhenStartEditByMouseClick\",\n      \"textNodeSelectAllWhenStartEditByKeyboard\",\n      \"textNodeBackspaceDeleteWhenEmpty\",\n      \"textNodeBigContentThresholdWhenPaste\",\n      \"textNodePasteSizeAdjustMode\",\n    ],\n    section: [\"isEnableSectionCollision\"],\n    edge: [\n      \"allowAddCycleEdge\",\n      \"autoAdjustLineEndpointsByMouseTrack\",\n      \"enableRightClickConnect\",\n      \"enableDragEdgeRotateStructure\",\n    ],\n    generateNode: [\n      \"autoLayoutWhenTreeGenerate\",\n      \"enableBackslashGenerateNodeInInput\",\n      \"textNodeAutoFormatTreeWhenExitEdit\",\n      \"treeGenerateCameraBehavior\",\n    ],\n    gamepad: [\"gamepadDeadzone\"],\n  },\n  performance: {\n    memory: [\"historySize\", \"clearHistoryWhenManualSave\", \"historyManagerMode\"],\n    cpu: [\"autoRefreshStageByMouseAction\"],\n    render: [\n      \"isPauseRenderWhenManipulateOvertime\",\n      \"renderOverTimeWhenNoManipulateTime\",\n      \"scaleExponent\",\n      \"ignoreTextNodeTextRenderLessThanFontSize\",\n      \"cacheTextAsBitmap\",\n      \"textCacheSize\",\n      \"textScalingBehavior\",\n      \"textIntegerLocationAndSizeRender\",\n      \"antialiasing\",\n    ],\n    experimental: [\"compatibilityMode\", \"isEnableEntityCollision\"],\n  },\n  ai: {\n    api: [\"aiApiBaseUrl\", \"aiApiKey\", \"aiModel\", \"aiShowTokenCount\"],\n  },\n};\n\nconst categoryIcons = {\n  ai: {\n    icon: Brain,\n    api: Network,\n  },\n  automation: {\n    icon: Bot,\n    autoNamer: Text,\n    autoSave: Save,\n    autoBackup: Folder,\n    autoImport: File,\n  },\n  control: {\n    icon: Wrench,\n    mouse: Mouse,\n    touchpad: Touchpad,\n    cameraMove: Camera,\n    cameraZoom: ZoomIn,\n    objectSelect: SquareDashedMousePointer,\n    textNode: TextSelect,\n    section: Box,\n    edge: ArrowUpRight,\n    generateNode: Network,\n    gamepad: Gamepad,\n  },\n  performance: {\n    icon: Zap,\n    memory: MemoryStick,\n    cpu: Cpu,\n    render: Clock,\n    experimental: Sparkle,\n  },\n  visual: {\n    icon: Eye,\n    basic: AppWindowMac,\n    background: Layers,\n    node: Workflow,\n    edge: ArrowUpRight,\n    section: Box,\n    entityDetails: ReceiptText,\n    debug: Bug,\n    miniWindow: PictureInPicture,\n    experimental: Sparkle,\n  },\n};\n"
  },
  {
    "path": "app/src/sub/SettingsWindow/themes/editor.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from \"@/components/ui/collapsible\";\nimport { Input } from \"@/components/ui/input\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { Themes } from \"@/core/service/Themes\";\nimport _ from \"lodash\";\nimport { ChevronRight, MinusCircle, Palette, Plus, Save, Tag } from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\nimport { toast } from \"sonner\";\n\nexport default function ThemeEditor({ themeId }: { themeId: string }) {\n  const [theme, setTheme] = useState<Themes.Theme | null>(null);\n\n  useEffect(() => {\n    Themes.getThemeById(themeId).then(setTheme);\n  }, [themeId]);\n\n  function save() {\n    if (!theme) return;\n    // 检查是否修改了id\n    if (theme.metadata.id !== themeId) {\n      // 删除原来的主题\n      Themes.deleteCustomTheme(themeId).then(() => {\n        // 保存新的主题\n        Themes.writeCustomTheme(theme).then(() => {\n          toast.success(\"主题 ID 已修改，请重新打开此页面，否则可能出现问题\");\n        });\n      });\n    } else {\n      Themes.writeCustomTheme(theme).then(() => {\n        toast.success(\"主题已保存，部分更改将在重新打开此页面后生效\");\n      });\n    }\n  }\n  function apply() {\n    if (!theme) return;\n    Settings.theme = theme.metadata.id;\n  }\n\n  return (\n    <div className=\"flex w-full flex-col gap-4\">\n      <div className=\"flex gap-2\">\n        <Button onClick={save}>\n          <Save />\n          保存\n        </Button>\n        <Button\n          variant=\"outline\"\n          onClick={() => {\n            save();\n            apply();\n          }}\n        >\n          <Palette />\n          保存并应用\n        </Button>\n      </div>\n      <Collapsible className=\"group/collapsible rounded-lg border px-4 py-3\">\n        <CollapsibleTrigger>\n          <div className=\"flex items-center gap-2\">\n            <span>元数据</span>\n            <ChevronRight className=\"transition-transform group-data-[state=open]/collapsible:rotate-90\" />\n          </div>\n        </CollapsibleTrigger>\n        <CollapsibleContent className=\"flex flex-col gap-2 pt-2\">\n          <div className=\"flex items-center gap-2\">\n            <Tag />\n            <span>ID</span>\n            <Input\n              value={theme?.metadata.id ?? \"\"}\n              onChange={(e) => {\n                setTheme((theme) => {\n                  if (!theme) return theme;\n                  return _.set(_.cloneDeep(theme), \"metadata.id\", e.target.value);\n                });\n              }}\n            />\n          </div>\n          <span>名称</span>\n          <KeyValueEditor\n            value={theme?.metadata.name ?? {}}\n            onChange={(newData) => {\n              setTheme((theme) => {\n                if (!theme) return theme;\n                return _.set(_.cloneDeep(theme), \"metadata.name\", newData);\n              });\n            }}\n          />\n          <span>描述</span>\n          <KeyValueEditor\n            value={theme?.metadata.description ?? {}}\n            onChange={(newData) => {\n              setTheme((theme) => {\n                if (!theme) return theme;\n                return _.set(_.cloneDeep(theme), \"metadata.description\", newData);\n              });\n            }}\n          />\n          <span>作者</span>\n          <KeyValueEditor\n            value={theme?.metadata.author ?? {}}\n            onChange={(newData) => {\n              setTheme((theme) => {\n                if (!theme) return theme;\n                return _.set(_.cloneDeep(theme), \"metadata.author\", newData);\n              });\n            }}\n          />\n        </CollapsibleContent>\n      </Collapsible>\n      <ColorsEditor\n        value={theme?.content ?? {}}\n        onChange={(newData) => {\n          setTheme((theme) => {\n            if (!theme) return theme;\n            return _.set(_.cloneDeep(theme), \"content\", newData);\n          });\n        }}\n      />\n    </div>\n  );\n}\n\nfunction KeyValueEditor({\n  value: data,\n  onChange,\n}: {\n  value: Record<string, string>;\n  onChange: (newData: Record<string, string>) => void;\n}) {\n  return (\n    <div className=\"flex flex-col gap-2\">\n      {Object.entries(data).map(([lang, value]) => (\n        <div className=\"flex gap-2\" key={lang}>\n          <Input\n            value={lang}\n            onChange={(e) => {\n              const newData = { ...data };\n              delete newData[lang];\n              onChange({\n                ...newData,\n                [e.target.value]: value,\n              });\n            }}\n            className=\"w-32\"\n          />\n          <Input\n            value={value}\n            onChange={(e) => {\n              onChange({\n                ...data,\n                [lang]: e.target.value,\n              });\n            }}\n          />\n          <Button\n            variant=\"destructive\"\n            size=\"icon\"\n            onClick={() => {\n              const newData = { ...data };\n              delete newData[lang];\n              onChange(newData);\n            }}\n          >\n            <MinusCircle />\n          </Button>\n        </div>\n      ))}\n      <Button\n        variant=\"ghost\"\n        onClick={() => {\n          onChange({\n            ...data,\n            \"\": \"\",\n          });\n        }}\n      >\n        <Plus />\n        添加语言\n      </Button>\n    </div>\n  );\n}\n\nfunction ColorsEditor({\n  value,\n  onChange,\n}: {\n  value: Record<string, any>;\n  onChange: (newValue: Record<string, any>) => void;\n}) {\n  return (\n    <div className=\"flex flex-col gap-2\">\n      {Object.entries(value).map(([k, v]) => (\n        <div className=\"hover:bg-accent/50 flex items-center gap-2 transition-colors\" key={k}>\n          <span className=\"w-64 shrink-0\">{k}</span>\n          {typeof v === \"string\" ? (\n            <div className=\"flex items-center gap-2\">\n              <div className=\"aspect-square size-8 rounded-full\" style={{ background: v }} />\n              <Input value={v} onChange={(e) => onChange({ ...value, [k]: e.target.value })} />\n              <div className=\"relative\">\n                <Palette />\n                <input\n                  type=\"color\"\n                  value={v}\n                  onChange={(e) => onChange({ ...value, [k]: e.target.value })}\n                  className=\"absolute inset-0 z-10 opacity-0\"\n                />\n              </div>\n            </div>\n          ) : (\n            <ColorsEditor value={v} onChange={(newV) => onChange({ ...value, [k]: newV })} />\n          )}\n        </div>\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/src/sub/SettingsWindow/themes/index.tsx",
    "content": "import { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\nimport { Button } from \"@/components/ui/button\";\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@/components/ui/select\";\nimport { Switch } from \"@/components/ui/switch\";\nimport {\n  Sidebar,\n  SidebarContent,\n  SidebarGroup,\n  SidebarGroupContent,\n  SidebarMenu,\n  SidebarMenuButton,\n  SidebarMenuItem,\n  SidebarSeparator,\n} from \"@/components/ui/sidebar\";\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/ui/tabs\";\nimport { Settings } from \"@/core/service/Settings\";\nimport { Themes } from \"@/core/service/Themes\";\nimport { parseYamlWithFrontmatter } from \"@/utils/yaml\";\nimport { open } from \"@tauri-apps/plugin-dialog\";\nimport { readTextFile } from \"@tauri-apps/plugin-fs\";\nimport _ from \"lodash\";\nimport { Check, Copy, Delete, FileInput, Info, Moon, Palette, Sun } from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport ThemeEditor from \"./editor\";\n\nexport default function ThemesTab() {\n  const [selectedThemeId, setSelectThemeId] = useState(Settings.theme);\n  const [selectedTheme, setSelectedTheme] = useState<Themes.Theme | null>(null);\n  const { i18n } = useTranslation();\n  const [currentTab, setCurrentTab] = useState(\"preview\");\n  const [themes, setThemes] = useState<Themes.Theme[]>([]);\n  const [currentTheme] = Settings.use(\"theme\");\n  const [themeMode] = Settings.use(\"themeMode\");\n  const [lightTheme] = Settings.use(\"lightTheme\");\n  const [darkTheme] = Settings.use(\"darkTheme\");\n\n  useEffect(() => {\n    Themes.getThemeById(selectedThemeId).then(setSelectedTheme);\n  }, [selectedThemeId]);\n  useEffect(() => {\n    updateThemeIds();\n  }, []);\n\n  function updateThemeIds() {\n    Themes.list().then(setThemes);\n  }\n\n  return (\n    <div className=\"flex h-full\">\n      <Sidebar className=\"h-full overflow-auto\">\n        <SidebarContent>\n          <SidebarGroup>\n            <SidebarGroupContent>\n              <SidebarMenu>\n                <SidebarMenuItem>\n                  <SidebarMenuButton\n                    asChild\n                    onClick={async () => {\n                      const path = await open({\n                        multiple: false,\n                        title: \"选择主题文件\",\n                        filters: [{ name: \"Project Graph 主题文件\", extensions: [\"pg-theme\"] }],\n                      });\n                      if (!path) return;\n                      const fileContent = await readTextFile(path);\n                      const data = parseYamlWithFrontmatter<Themes.Metadata, any>(fileContent);\n                      Themes.writeCustomTheme({\n                        metadata: data.frontmatter,\n                        content: data.content,\n                      }).then(updateThemeIds);\n                    }}\n                  >\n                    <div>\n                      <FileInput />\n                      <span>导入主题</span>\n                    </div>\n                  </SidebarMenuButton>\n                </SidebarMenuItem>\n                <SidebarSeparator />\n                {themes.map(({ metadata }) => (\n                  <SidebarMenuItem key={metadata.id}>\n                    <SidebarMenuButton\n                      asChild\n                      onClick={() => setSelectThemeId(metadata.id)}\n                      isActive={selectedThemeId === metadata.id}\n                    >\n                      <div>\n                        {currentTheme === metadata.id ? <Check /> : metadata.type === \"dark\" ? <Moon /> : <Sun />}\n                        <span>{metadata.name[i18n.language]}</span>\n                      </div>\n                    </SidebarMenuButton>\n                  </SidebarMenuItem>\n                ))}\n              </SidebarMenu>\n            </SidebarGroupContent>\n          </SidebarGroup>\n        </SidebarContent>\n      </Sidebar>\n      <div className=\"mx-auto flex w-2/3 flex-col gap-2 overflow-auto\">\n        <div className=\"flex gap-2\">\n          <Button\n            onClick={() => {\n              Settings.theme = selectedThemeId;\n            }}\n          >\n            <Palette />\n            应用\n          </Button>\n          <Button\n            variant=\"outline\"\n            onClick={() => {\n              if (!selectedTheme) return;\n              Themes.writeCustomTheme(_.set(_.cloneDeep(selectedTheme), \"metadata.id\", crypto.randomUUID())).then(\n                updateThemeIds,\n              );\n            }}\n          >\n            <Copy />\n            复制\n          </Button>\n          <Button\n            variant=\"destructive\"\n            disabled={Themes.builtinThemes.some((it) => it.metadata.id === selectedThemeId)}\n            onClick={() => {\n              if (!selectedTheme) return;\n              Themes.deleteCustomTheme(selectedThemeId).then(updateThemeIds);\n            }}\n          >\n            <Delete />\n            删除\n          </Button>\n        </div>\n        <div className=\"flex flex-col gap-4 rounded-lg border p-4\">\n          <div className=\"flex items-center justify-between\">\n            <label className=\"text-sm font-medium\">主题模式</label>\n            <div className=\"flex items-center gap-2\">\n              <span className=\"text-sm\">白天</span>\n              <Switch\n                checked={themeMode === \"dark\"}\n                onCheckedChange={(checked) => {\n                  Settings.themeMode = checked ? \"dark\" : \"light\";\n                }}\n              />\n              <span className=\"text-sm\">黑夜</span>\n            </div>\n          </div>\n          <div className=\"grid grid-cols-2 gap-4\">\n            <div className=\"space-y-2\">\n              <label className=\"text-sm font-medium\">浅色主题</label>\n              <Select\n                value={lightTheme}\n                onValueChange={(value) => {\n                  Settings.lightTheme = value;\n                }}\n              >\n                <SelectTrigger>\n                  <SelectValue placeholder=\"选择浅色主题\" />\n                </SelectTrigger>\n                <SelectContent>\n                  {themes\n                    .filter((theme) => theme.metadata.type === \"light\")\n                    .map((theme) => (\n                      <SelectItem key={theme.metadata.id} value={theme.metadata.id}>\n                        {theme.metadata.name[i18n.language]}\n                      </SelectItem>\n                    ))}\n                </SelectContent>\n              </Select>\n            </div>\n            <div className=\"space-y-2\">\n              <label className=\"text-sm font-medium\">深色主题</label>\n              <Select\n                value={darkTheme}\n                onValueChange={(value) => {\n                  Settings.darkTheme = value;\n                }}\n              >\n                <SelectTrigger>\n                  <SelectValue placeholder=\"选择深色主题\" />\n                </SelectTrigger>\n                <SelectContent>\n                  {themes\n                    .filter((theme) => theme.metadata.type === \"dark\")\n                    .map((theme) => (\n                      <SelectItem key={theme.metadata.id} value={theme.metadata.id}>\n                        {theme.metadata.name[i18n.language]}\n                      </SelectItem>\n                    ))}\n                </SelectContent>\n              </Select>\n            </div>\n          </div>\n          <div className=\"text-muted-foreground text-xs\">\n            当前主题模式为 {themeMode === \"light\" ? \"白天\" : \"黑夜\"}，使用{\" \"}\n            {themeMode === \"light\" ? lightTheme : darkTheme} 主题\n          </div>\n        </div>\n        <Tabs value={currentTab} onValueChange={setCurrentTab as any}>\n          <TabsList>\n            <TabsTrigger value=\"preview\">预览</TabsTrigger>\n            <TabsTrigger value=\"edit\">编辑</TabsTrigger>\n          </TabsList>\n          <TabsContent value=\"preview\" className=\"mt-8 flex flex-col gap-2\">\n            <span className=\"mb-2 text-4xl font-bold\">{selectedTheme?.metadata.name[i18n.language]}</span>\n            <span>{selectedTheme?.metadata.description[i18n.language]}</span>\n            <span>作者: {selectedTheme?.metadata.author[i18n.language]}</span>\n          </TabsContent>\n          <TabsContent value=\"edit\">\n            {Themes.builtinThemes.some((it) => it.metadata.id === selectedThemeId) ? (\n              <Alert>\n                <Info />\n                <AlertTitle>内置主题</AlertTitle>\n                <AlertDescription>这是一个内置的主题，需要复制后再编辑</AlertDescription>\n              </Alert>\n            ) : (\n              <ThemeEditor themeId={selectedThemeId} />\n            )}\n          </TabsContent>\n        </Tabs>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "app/src/sub/TagWindow.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { SubWindow } from \"@/core/service/SubWindow\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { Angry, MousePointerClick, RefreshCcw, Smile, Table, Tags, Telescope } from \"lucide-react\";\nimport React from \"react\";\nimport { useAtom } from \"jotai\";\nimport { activeProjectAtom } from \"@/state\";\nimport { toast } from \"sonner\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@/components/ui/tooltip\";\n\n/**\n * 标签相关面板\n * @param param0\n */\nexport default function TagWindow() {\n  const [project] = useAtom(activeProjectAtom);\n  if (!project) return <></>;\n\n  const [tagNameList, setTagNameList] = React.useState<\n    { tagName: string; uuid: string; color: [number, number, number, number] }[]\n  >([]);\n  // 是否开启允许滑动移动摄像机\n  const [isMouseEnterMoveCameraAble, setIsMouseEnterMoveCameraAble] = React.useState(false);\n  // 是否开启透视\n  const [isPerspective, setIsPerspective] = React.useState(false);\n\n  function refreshTagNameList() {\n    setTagNameList(project!.tagManager.refreshTagNamesUI());\n  }\n\n  React.useEffect(() => {\n    refreshTagNameList();\n  }, []);\n\n  const handleMoveCameraToTag = (tagUUID: string) => {\n    return () => {\n      // 跳转到对应位置\n      project.tagManager.moveCameraToTag(tagUUID);\n      project.controller.resetCountdownTimer();\n    };\n  };\n\n  const handleMouseEnterTag = (tagUUID: string) => {\n    return () => {\n      if (isMouseEnterMoveCameraAble) {\n        project.tagManager.moveCameraToTag(tagUUID);\n        project.controller.resetCountdownTimer();\n      }\n    };\n  };\n\n  const handleClickAddTag = () => {\n    // 检查是否有选中的entity或连线\n    if (\n      project.stageManager.getSelectedEntities().length === 0 &&\n      project.stageManager.getSelectedAssociations().length === 0\n    ) {\n      toast.error(\"请先选中舞台上的物体, 选中后再点此按钮，即可添标签\");\n    }\n    project.tagManager.changeTagBySelected();\n    project.controller.resetCountdownTimer();\n    refreshTagNameList();\n  };\n\n  return (\n    <div className=\"flex flex-col\">\n      <div className=\"flex justify-center gap-2\">\n        <Tooltip>\n          <TooltipTrigger>\n            <Button size=\"icon\" onClick={handleClickAddTag}>\n              <Tags />\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent>将选中的节点添加到标签，如果选中了已经是标签的节点，则会移出标签</TooltipContent>\n        </Tooltip>\n\n        <Tooltip>\n          <TooltipTrigger>\n            <Button size=\"icon\" onClick={refreshTagNameList}>\n              <RefreshCcw />\n            </Button>\n          </TooltipTrigger>\n          <TooltipContent>如果舞台上的标签发生变更但此处未更新，可以手动刷新</TooltipContent>\n        </Tooltip>\n\n        {tagNameList.length >= 3 && (\n          <Tooltip>\n            <TooltipTrigger>\n              <Button\n                size=\"icon\"\n                onClick={() => {\n                  setIsMouseEnterMoveCameraAble(!isMouseEnterMoveCameraAble);\n                }}\n              >\n                {isMouseEnterMoveCameraAble ? <Telescope /> : <MousePointerClick />}\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent>{isMouseEnterMoveCameraAble ? \"快速瞭望模式\" : \"点击跳转模式\"}</TooltipContent>\n          </Tooltip>\n        )}\n        {tagNameList.length > 0 && (\n          <Tooltip>\n            <TooltipTrigger>\n              <Button\n                size=\"icon\"\n                onClick={() => {\n                  setIsPerspective(!isPerspective);\n                }}\n              >\n                {isPerspective ? <Angry /> : <Smile />}\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent>{isPerspective ? \"透视已开启\" : \"开启透视眼\"}</TooltipContent>\n          </Tooltip>\n        )}\n        {tagNameList.length > 0 && (\n          <Tooltip>\n            <TooltipTrigger>\n              <Button\n                size=\"icon\"\n                onClick={() => {\n                  let y = 0;\n                  for (const tag of tagNameList) {\n                    LittleTagWindow.open(tag.uuid, tag.tagName, 80, y);\n                    y += 80;\n                  }\n                }}\n              >\n                <Table />\n              </Button>\n            </TooltipTrigger>\n            <TooltipContent>分裂每个标签为单独的小按钮</TooltipContent>\n          </Tooltip>\n        )}\n      </div>\n\n      {/* 标签列表 */}\n      {tagNameList.length === 0 ? (\n        <div>\n          <h3 className=\"text-select-text text-lg\">当前还没有标签</h3>\n          <p className=\"text-select-option-hover-text text-sm\">\n            给节点添加标签后会显示在左侧面板中，方便知道舞台上都有哪些主要内容，点击内容即可跳转\n          </p>\n        </div>\n      ) : (\n        <div className=\"mt-2 flex-1 flex-col justify-center overflow-y-auto p-2\">\n          {tagNameList.map((tag) => {\n            return (\n              <div\n                key={tag.uuid}\n                style={{ color: tag.color[3] === 0 ? \"\" : `rgba(${tag.color.join(\",\")})` }}\n                className=\"text-select-option-text hover:text-select-option-hover-text hover:bg-icon-button-bg group flex cursor-pointer items-center text-left\"\n                onMouseEnter={handleMouseEnterTag(tag.uuid)}\n              >\n                <span\n                  onClick={handleMoveCameraToTag(tag.uuid)}\n                  className=\"flex-1 cursor-pointer truncate hover:underline\"\n                >\n                  {tag.tagName}\n                </span>\n              </div>\n            );\n          })}\n        </div>\n      )}\n    </div>\n  );\n}\n\nTagWindow.open = () => {\n  SubWindow.create({\n    title: \"标签管理器\",\n    children: <TagWindow />,\n    rect: new Rectangle(new Vector(100, 100), new Vector(300, 600)),\n  });\n};\n\n/**\n * 单个的标签成一个子窗口\n */\nfunction LittleTagWindow({ uuid, tagName }: { uuid: string; tagName: string }) {\n  const [project] = useAtom(activeProjectAtom);\n  if (!project) return <></>;\n  const onClick = () => {\n    project.tagManager.moveCameraToTag(uuid);\n    project.controller.resetCountdownTimer();\n  };\n  return (\n    <div className=\"cursor-pointer\" onClick={onClick}>\n      {tagName}\n    </div>\n  );\n}\n\nLittleTagWindow.open = (uuid: string, tagName: string, height: number, y: number) => {\n  SubWindow.create({\n    title: \"\",\n    children: <LittleTagWindow uuid={uuid} tagName={tagName} />,\n    rect: new Rectangle(new Vector(100, y), new Vector(150, height)),\n  });\n};\n"
  },
  {
    "path": "app/src/sub/TestWindow.tsx",
    "content": "import Tree from \"@/components/ui/tree\";\nimport { SubWindow } from \"@/core/service/SubWindow\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\n\n/**\n * 测试使用\n * @returns\n */\nexport default function TestWindow() {\n  return (\n    <div>\n      <h1>测试窗口</h1>\n      <Tree obj={{ a: 1, b: { c: 2, d: [3, 4, 5] } }} />\n    </div>\n  );\n}\n\nTestWindow.open = () => {\n  SubWindow.create({\n    title: \"测试\",\n    children: <TestWindow />,\n    rect: new Rectangle(new Vector(100, 100), new Vector(150, 500)),\n  });\n};\n"
  },
  {
    "path": "app/src/sub/UserWindow.tsx",
    "content": "import { Button } from \"@/components/ui/button\";\nimport { SubWindow } from \"@/core/service/SubWindow\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"@graphif/shapes\";\nimport { fetch } from \"@tauri-apps/plugin-http\";\nimport { LogOut } from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\nimport { toast } from \"sonner\";\n\nexport default function UserWindow() {\n  const [status, setStatus] = useState<\"loading\" | \"ok\" | \"out\">(\"loading\");\n  const [user, setUser] = useState<any>({});\n\n  useEffect(() => {\n    (async () => {\n      const loginApiResponse = await (await fetch(\"https://bbs.project-graph.top/api/login\")).text();\n      if (loginApiResponse.startsWith('\"')) {\n        setStatus(\"ok\");\n        // \"/user/<username>\"\n        const userDataEndpoint = loginApiResponse.slice(1, -1);\n        const userData = await (await fetch(`https://bbs.project-graph.top/api${userDataEndpoint}`)).json();\n        setUser(userData);\n      } else {\n        setStatus(\"out\");\n      }\n    })();\n  }, []);\n\n  return (\n    <div className=\"flex flex-col items-center gap-4 p-4\">\n      {status === \"loading\" && <span>加载中...</span>}\n      {status === \"out\" && <span>未登录</span>}\n      {status === \"ok\" && (\n        <>\n          <div className=\"flex items-center gap-2\">\n            <img src={`https://bbs.project-graph.top${user.uploadedpicture}`} crossOrigin=\"\" className=\"size-12\" />\n            <div className=\"flex flex-col gap-1\">\n              <span>{user.username}</span>\n              <span className=\"text-sm opacity-50\">UID: {user.uid}</span>\n            </div>\n          </div>\n          <Button\n            onClick={() => {\n              fetch(\"https://bbs.project-graph.top/logout\", {\n                method: \"POST\",\n              });\n              toast.success(\"已退出登录\");\n            }}\n          >\n            <LogOut />\n            退出登录\n          </Button>\n        </>\n      )}\n    </div>\n  );\n}\n\nUserWindow.open = () => {\n  SubWindow.create({\n    title: \"用户\",\n    children: <UserWindow />,\n    rect: Rectangle.inCenter(new Vector(230, 240)),\n  });\n};\n"
  },
  {
    "path": "app/src/themes/dark-blue.pg-theme",
    "content": "---\nid: dark-blue\ntype: dark\nauthor:\n  en: Official\n  zh_CN: 官方\ndescription:\n  en: The default look of shadcn/ui, but with a blue theme color\n  zh_CN: shadcn/ui 的默认外观，但是以蓝色为主题色\nname:\n  en: Dark Blue\n  zh_CN: 蓝色黑夜\n---\nbackground: oklch(0.141 0.005 285.823)\nforeground: oklch(0.985 0 0)\ncard: oklch(0.21 0.006 285.885)\ncard-foreground: oklch(0.985 0 0)\npopover: oklch(0.21 0.006 285.885)\npopover-foreground: oklch(0.985 0 0)\nprimary: oklch(0.546 0.245 262.881)\nprimary-foreground: oklch(0.985 0 0)\nsecondary: oklch(0.274 0.006 286.033)\nsecondary-foreground: oklch(0.985 0 0)\nmuted: oklch(0.274 0.006 286.033)\nmuted-foreground: oklch(0.705 0.015 286.067)\naccent: oklch(0.274 0.006 286.033)\naccent-foreground: oklch(0.985 0 0)\ndestructive: oklch(0.704 0.191 22.216)\nborder: oklch(1 0 0 / 10%)\ninput: oklch(1 0 0 / 15%)\nring: oklch(0.488 0.243 264.376)\nchart-1: oklch(0.488 0.243 264.376)\nchart-2: oklch(0.696 0.17 162.48)\nchart-3: oklch(0.769 0.188 70.08)\nchart-4: oklch(0.627 0.265 303.9)\nchart-5: oklch(0.645 0.246 16.439)\nsidebar: oklch(0.21 0.006 285.885)\nsidebar-foreground: oklch(0.985 0 0)\nsidebar-primary: oklch(0.546 0.245 262.881)\nsidebar-primary-foreground: oklch(0.379 0.146 265.522)\nsidebar-accent: oklch(0.274 0.006 286.033)\nsidebar-accent-foreground: oklch(0.985 0 0)\nsidebar-border: oklch(1 0 0 / 10%)\nsidebar-ring: oklch(0.488 0.243 264.376)\nbrand: oklch(0.707 0.165 254.624)\n\nstage:\n  Background: \"#000000\"\n  GridNormal: \"rgba(255, 255, 255, 0.2)\"\n  GridHeavy: \"rgba(255, 255, 255, 0.3)\"\n  DetailsDebugText: \"rgba(255, 255, 255, 0.5)\"\n  SelectRectangleBorder: \"rgba(255, 255, 255, 0.5)\"\n  SelectRectangleFill: \"rgba(255, 255, 255, 0.08)\"\n  StageObjectBorder: \"#cccccc\"\n  NodeDetailsText: \"#ffffff\"\n  CollideBoxSelected: \"rgba(70, 185, 255, 0.8)\"\n  CollideBoxPreSelected: \"rgba(70, 185, 255, 0.8)\"\n  CollideBoxPreDelete: \"rgba(255, 255, 0, 0.2)\"\n\neffects:\n  flash: \"#fff\"\n  dash: \"#fff\"\n  windowFlash: \"#fff\"\n  warningShadow: \"#f00\"\n  successShadow: oklch(0.546 0.245 262.881)\n"
  },
  {
    "path": "app/src/themes/dark.pg-theme",
    "content": "---\nid: dark\ntype: dark\nauthor:\n  en: Official\n  zh_CN: 官方\ndescription:\n  en: The default look of shadcn/ui\n  zh_CN: shadcn/ui 的默认外观\nname:\n  en: Dark\n  zh_CN: 黑夜\n---\nbackground: oklch(0.145 0 0)\nforeground: oklch(0.985 0 0)\ncard: oklch(0.205 0 0)\ncard-foreground: oklch(0.985 0 0)\npopover: oklch(0.205 0 0)\npopover-foreground: oklch(0.985 0 0)\nprimary: oklch(0.428 0.206 181.3)\nprimary-foreground: oklch(0.985 0 0)\nsecondary: oklch(0.269 0 0)\nsecondary-foreground: oklch(0.985 0 0)\nmuted: oklch(0.269 0 0)\nmuted-foreground: oklch(0.708 0 0)\naccent: oklch(0.269 0 0)\naccent-foreground: oklch(0.985 0 0)\ndestructive: oklch(0.704 0.191 22.216)\nborder: oklch(1 0 0 / 10%)\ninput: oklch(1 0 0 / 15%)\nring: oklch(0.596 0.17 162.48)\nchart-1: oklch(0.488 0.243 264.376)\nchart-2: oklch(0.696 0.17 162.48)\nchart-3: oklch(0.769 0.188 70.08)\nchart-4: oklch(0.627 0.265 303.9)\nchart-5: oklch(0.645 0.246 16.439)\nsidebar: oklch(0.205 0 0)\nsidebar-foreground: oklch(0.985 0 0)\nsidebar-primary: oklch(0.488 0.243 264.376)\nsidebar-primary-foreground: oklch(0.985 0 0)\nsidebar-accent: oklch(0.269 0 0)\nsidebar-accent-foreground: oklch(0.985 0 0)\nsidebar-border: oklch(1 0 0 / 10%)\nsidebar-ring: oklch(0.556 0 0)\nbrand: oklch(0.707 0.165 254.624)\n\nstage:\n  Background: \"#171717\"\n  GridNormal: \"rgba(255, 255, 255, 0.2)\"\n  GridHeavy: \"rgba(255, 255, 255, 0.3)\"\n  DetailsDebugText: \"rgba(255, 255, 255, 0.5)\"\n  SelectRectangleBorder: \"rgba(255, 255, 255, 0.5)\"\n  SelectRectangleFill: \"rgba(255, 255, 255, 0.08)\"\n  StageObjectBorder: \"#cccccc\"\n  NodeDetailsText: \"#ffffff\"\n  CollideBoxSelected: \"rgba(34, 217, 110, 0.5)\"\n  CollideBoxPreSelected: \"rgba(0, 255, 0, 0.2)\"\n  CollideBoxPreDelete: \"rgba(255, 255, 0, 0.2)\"\n\neffects:\n  flash: \"#fff\"\n  dash: \"#fff\"\n  windowFlash: \"#fff\"\n  warningShadow: \"#f00\"\n  successShadow: \"#0f0\"\n"
  },
  {
    "path": "app/src/themes/light.pg-theme",
    "content": "---\nid: light\ntype: light\nauthor:\n  en: Official\n  zh_CN: 官方\ndescription:\n  en: The light theme with a white background for printing.\n  zh_CN: 舞台背景为#FFF绝对白色，用于在论文中插图、打印时使用\nname:\n  en: Light\n  zh_CN: 明亮\n---\nbackground: oklch(1 0 0)\nforeground: oklch(0.145 0 0)\ncard: oklch(0.985 0 0)\ncard-foreground: oklch(0.145 0 0)\npopover: oklch(0.985 0 0)\npopover-foreground: oklch(0.145 0 0)\nprimary: oklch(0.205 0 0)\nprimary-foreground: oklch(0.985 0 0)\nsecondary: oklch(0.95 0 0)\nsecondary-foreground: oklch(0.145 0 0)\nmuted: oklch(0.95 0 0)\nmuted-foreground: oklch(0.556 0 0)\naccent: oklch(0.95 0 0)\naccent-foreground: oklch(0.145 0 0)\ndestructive: oklch(0.704 0.191 22.216)\nborder: oklch(0.145 0 0 / 10%)\ninput: oklch(0.145 0 0 / 15%)\nring: oklch(0.708 0 0)\nchart-1: oklch(0.488 0.243 264.376)\nchart-2: oklch(0.696 0.17 162.48)\nchart-3: oklch(0.769 0.188 70.08)\nchart-4: oklch(0.627 0.265 303.9)\nchart-5: oklch(0.645 0.246 16.439)\nsidebar: oklch(0.985 0 0)\nsidebar-foreground: oklch(0.145 0 0)\nsidebar-primary: oklch(0.488 0.243 264.376)\nsidebar-primary-foreground: oklch(0.985 0 0)\nsidebar-accent: oklch(0.95 0 0)\nsidebar-accent-foreground: oklch(0.145 0 0)\nsidebar-border: oklch(0.145 0 0 / 10%)\nsidebar-ring: oklch(0.708 0 0)\nbrand: oklch(0.623 0.214 259.815)\n\nstage:\n  Background: \"#ffffff\"\n  GridNormal: \"rgba(51, 51, 51, 0.4)\"\n  GridHeavy: \"rgba(20, 20, 20, 0.4)\"\n  DetailsDebugText: \"rgba(125, 125, 125, 0.8)\"\n  SelectRectangleBorder: \"rgba(0, 0, 0, 1)\"\n  SelectRectangleFill: \"rgba(0, 50, 100, 0.1)\"\n  StageObjectBorder: \"#000000\"\n  NodeDetailsText: \"#000000\"\n  CollideBoxSelected: \"rgba(23, 114, 246, 0.8)\"\n  CollideBoxPreSelected: \"rgba(23, 114, 246, 0.5)\"\n  CollideBoxPreDelete: \"rgba(255, 255, 0, 0.2)\"\n\neffects:\n  flash: \"#000\"\n  dash: \"#000\"\n  windowFlash: \"#fff\"\n  warningShadow: \"#c00\"\n  successShadow: \"rgb(23, 114, 246)\"\n"
  },
  {
    "path": "app/src/themes/macaron.pg-theme",
    "content": "---\nid: macaron\ntype: light\nauthor:\n  en: Official\n  zh_CN: 官方\ndescription:\n  en: A dark and colorful theme with a hint of warmth.\n  zh_CN: 源于法国的小甜点，用于光线明朗的情况下使用。\nname:\n  en: Macaron\n  zh_CN: 马卡龙\n---\nbackground: oklch(0.985 0.02 340)\nforeground: oklch(0.269 0.05 340)\ncard: oklch(0.985 0.03 340)\ncard-foreground: oklch(0.269 0.05 340)\npopover: oklch(0.985 0.03 340)\npopover-foreground: oklch(0.269 0.05 340)\nprimary: oklch(0.75 0.15 340)\nprimary-foreground: oklch(0.985 0 0)\nsecondary: oklch(0.95 0.05 340)\nsecondary-foreground: oklch(0.269 0.05 340)\nmuted: oklch(0.95 0.05 340)\nmuted-foreground: oklch(0.708 0.03 340)\naccent: oklch(0.95 0.05 340)\naccent-foreground: oklch(0.269 0.05 340)\ndestructive: oklch(0.704 0.191 22.216)\nborder: oklch(0.269 0 0 / 10%)\ninput: oklch(0.269 0 0 / 15%)\nring: oklch(0.75 0.15 340)\nchart-1: oklch(0.75 0.15 340)\nchart-2: oklch(0.696 0.17 162.48)\nchart-3: oklch(0.769 0.188 70.08)\nchart-4: oklch(0.627 0.265 303.9)\nchart-5: oklch(0.645 0.246 16.439)\nsidebar: oklch(0.985 0.03 340)\nsidebar-foreground: oklch(0.269 0.05 340)\nsidebar-primary: oklch(0.75 0.15 340)\nsidebar-primary-foreground: oklch(0.985 0 0)\nsidebar-accent: oklch(0.95 0.05 340)\nsidebar-accent-foreground: oklch(0.269 0.05 340)\nsidebar-border: oklch(0.269 0 0 / 10%)\nsidebar-ring: oklch(0.75 0.15 340)\n\nstage:\n  Background: \"#f4e5d3\"\n  GridNormal: \"rgba(111, 98, 98, 0.3)\"\n  GridHeavy: \"rgba(111, 98, 98, 0.5)\"\n  DetailsDebugText: \"#d2adb4\"\n  SelectRectangleBorder: \"rgba(77, 16, 24, 0.5)\"\n  SelectRectangleFill: \"rgba(227, 180, 184, 0.3)\"\n  StageObjectBorder: \"#72244e\"\n  NodeDetailsText: \"#6b596c\"\n  CollideBoxPreSelected: \"rgba(238, 63, 77, 0.3)\"\n  CollideBoxSelected: \"rgba(238, 63, 77, 0.7)\"\n  CollideBoxPreDelete: \"rgba(255, 255, 0, 0.2)\"\n\neffects:\n  flash: \"#8b5891\"\n  dash: \"#000000\"\n  windowFlash: \"#f9e9ea\"\n  warningShadow: \"rgba(226, 167, 31, 0.2)\"\n  successShadow: \"#7893af\"\n"
  },
  {
    "path": "app/src/themes/morandi.pg-theme",
    "content": "---\nid: morandi\ntype: light\nauthor:\n  en: Official\n  zh_CN: 官方\ndescription:\n  en: Low-saturation theme with bright light in bright environments.\n  zh_CN: 低饱和度主题，光线明朗的环境下使用\nname:\n  en: Morandi\n  zh_CN: 莫兰迪\n---\nbackground: oklch(0.97 0.01 230)\nforeground: oklch(0.35 0.02 230)\ncard: oklch(0.95 0.01 230)\ncard-foreground: oklch(0.35 0.02 230)\npopover: oklch(0.95 0.01 230)\npopover-foreground: oklch(0.35 0.02 230)\nprimary: oklch(0.75 0.06 230)\nprimary-foreground: oklch(0.985 0 0)\nsecondary: oklch(0.92 0.01 230)\nsecondary-foreground: oklch(0.35 0.02 230)\nmuted: oklch(0.92 0.01 230)\nmuted-foreground: oklch(0.65 0.02 230)\naccent: oklch(0.92 0.01 230)\naccent-foreground: oklch(0.35 0.02 230)\ndestructive: oklch(0.65 0.08 22)\nborder: oklch(0.35 0 0 / 10%)\ninput: oklch(0.35 0 0 / 15%)\nring: oklch(0.75 0.06 230)\nchart-1: oklch(0.75 0.06 230)\nchart-2: oklch(0.696 0.17 162.48)\nchart-3: oklch(0.769 0.188 70.08)\nchart-4: oklch(0.627 0.265 303.9)\nchart-5: oklch(0.645 0.246 16.439)\nsidebar: oklch(0.95 0.01 230)\nsidebar-foreground: oklch(0.35 0.02 230)\nsidebar-primary: oklch(0.75 0.06 230)\nsidebar-primary-foreground: oklch(0.985 0 0)\nsidebar-accent: oklch(0.92 0.01 230)\nsidebar-accent-foreground: oklch(0.35 0.02 230)\nsidebar-border: oklch(0.35 0 0 / 10%)\nsidebar-ring: oklch(0.75 0.06 230)\n\nstage:\n  Background: \"#e8e6e7\"\n  GridNormal: \"rgba(98, 98, 111, 0.3)\"\n  GridHeavy: \"rgba(98, 98, 111, 0.5)\"\n  DetailsDebugText: \"#6b7280\"\n  SelectRectangleBorder: \"rgba(71, 108, 117, 0.5)\"\n  SelectRectangleFill: \"rgba(169, 221, 208, 0.3)\"\n  StageObjectBorder: \"#45484f\"\n  NodeDetailsText: \"#414a54\"\n  CollideBoxPreSelected: \"rgba(169, 221, 208, 0.3)\"\n  CollideBoxSelected: \"rgba(62, 125, 83, 0.4)\"\n  CollideBoxPreDelete: \"rgba(255, 255, 0, 0.2)\"\n\neffects:\n  flash: \"#8b949d\"\n  dash: \"#6b7280\"\n  windowFlash: \"#eef1f0\"\n  warningShadow: \"rgba(248, 128, 112, 0.4)\"\n  successShadow: \"#7893af\"\n"
  },
  {
    "path": "app/src/themes/park.pg-theme",
    "content": "---\nid: park\ntype: light\nauthor:\n  en: Official\n  zh_CN: 官方\nname:\n  en: Park\n  zh_CN: 公园\ndescription:\n  en: A dark and colorful theme with a black and white feel.\n  zh_CN: 兼具夜晚防刺眼的特性与一定的色彩\n---\nbackground: oklch(0.4749 0.0476 149.91)\nforeground: oklch(0.9 0.02 150)\ncard: oklch(0.252 0.0165 144)\ncard-foreground: oklch(0.9 0.02 150)\npopover: oklch(0.4125 0.0476 149.91)\npopover-foreground: oklch(0.9 0.02 150)\nprimary: oklch(0.45 0.15 150)\nprimary-foreground: oklch(0.985 0 0)\nsecondary: oklch(0.25 0.05 150)\nsecondary-foreground: oklch(0.9 0.02 150)\nmuted: oklch(0.25 0.05 150)\nmuted-foreground: oklch(0.65 0.03 150)\naccent: oklch(0.5312 0.0593 149.91)\naccent-foreground: oklch(0.9 0.02 150)\ndestructive: oklch(0.65 0.15 30)\nborder: oklch(0.9 0 0 / 10%)\ninput: oklch(0.9 0 0 / 15%)\nring: oklch(0.45 0.15 150)\nchart-1: oklch(0.45 0.15 150)\nchart-2: oklch(0.696 0.17 162.48)\nchart-3: oklch(0.769 0.188 70.08)\nchart-4: oklch(0.627 0.265 303.9)\nchart-5: oklch(0.645 0.246 16.439)\nsidebar: oklch(0.252 0.0165 144)\nsidebar-foreground: oklch(0.9 0.02 150)\nsidebar-primary: oklch(0.45 0.15 150)\nsidebar-primary-foreground: oklch(0.985 0 0)\nsidebar-accent: oklch(0.3205 0.0165 144)\nsidebar-accent-foreground: oklch(0.9 0.02 150)\nsidebar-border: oklch(0.9 0 0 / 10%)\nsidebar-ring: oklch(0.45 0.15 150)\n\nstage:\n  Background: \"rgba(34, 34, 34)\"\n  GridNormal: \"rgba(94, 101, 108, 0.5)\" # 雾霭灰网格\n  GridHeavy: \"rgb(94, 101, 108)\" # 夜霭加深\n  DetailsDebugText: \"rgba(118, 129, 143, 0.7)\" # 暮霭灰\n  SelectRectangleBorder: \"#fff\"\n  SelectRectangleFill: \"rgba(169, 221, 208, 0.15)\" # 月下竹影\n  StageObjectBorder: \"rgb(229, 231, 235)\"\n  NodeDetailsText: \"#c0c8d0\" # 霜月白\n  CollideBoxPreSelected: \"rgba(106, 153, 85, 0.5)\"\n  CollideBoxSelected: \"rgba(106, 153, 85, 0.85)\" # 深林选中\n  CollideBoxPreDelete: \"rgba(255, 210, 0, 0.2)\" # 落叶黄\n\neffects:\n  flash: \"#fff\"\n  dash: \"#6b7280\" # 夜雾拖影\n  windowFlash: \"#eef1f0\" # 月光涟漪\n  warningShadow: \"rgba(223, 195, 134, 0.4)\" # 夜鸮警示\n  successShadow: \"rgba(122, 154, 129, 0.8)\"\n"
  },
  {
    "path": "app/src/types/cursors.tsx",
    "content": "/**\n * 所有鼠标光标的名称枚举\n */\nexport enum CursorNameEnum {\n  Default = \"cursor-default\",\n  Pointer = \"cursor-pointer\",\n  Crosshair = \"cursor-crosshair\",\n  Move = \"cursor-move\",\n  Grab = \"cursor-grab\",\n  Grabbing = \"cursor-grabbing\",\n  Text = \"cursor-text\",\n  NotAllowed = \"cursor-not-allowed\",\n  EResize = \"cursor-e-resize\",\n  NResize = \"cursor-n-resize\",\n  NeResize = \"cursor-ne-resize\",\n  NwResize = \"cursor-nw-resize\",\n  SResize = \"cursor-s-resize\",\n  SeResize = \"cursor-se-resize\",\n  SwResize = \"cursor-sw-resize\",\n  WResize = \"cursor-w-resize\",\n  NsResize = \"cursor-ns-resize\",\n  NeswResize = \"cursor-nesw-resize\",\n  NwseResize = \"cursor-nwse-resize\",\n  ColResize = \"cursor-col-resize\",\n  RowResize = \"cursor-row-resize\",\n  AllScroll = \"cursor-all-scroll\",\n  ZoomIn = \"cursor-zoom-in\",\n  ZoomOut = \"cursor-zoom-out\",\n  GrabHand = \"cursor-grab-hand\",\n  NotAllowedHand = \"cursor-not-allowed-hand\",\n  Pen = \"cursor-pen\",\n  Eraser = \"cursor-eraser\",\n  Handwriting = \"cursor-handwriting\",\n  ZoomInHand = \"cursor-zoom-in-hand\",\n  ZoomOutHand = \"cursor-zoom-out-hand\",\n}\n"
  },
  {
    "path": "app/src/types/directions.tsx",
    "content": "/**\n * 经常会有方向键控制的场景，比如上下左右移动，这时可以用这个枚举来表示方向。\n */\nexport enum Direction {\n  Up,\n  Down,\n  Left,\n  Right,\n}\n"
  },
  {
    "path": "app/src/types/metadata.tsx",
    "content": "/**\n * 项目文件的元数据\n * 存储在 .prg 文件的 metadata.msgpack 中\n * 用于版本管理、数据升级、文件信息记录等\n */\nexport interface ProjectMetadata {\n  /**\n   * 数据文件版本号（语义化版本格式，如 \"2.0.0\", \"2.1.0\"）\n   * 用于判断是否需要数据升级\n   * @required\n   */\n  version: string;\n\n  // /**\n  //  * 创建时使用的软件版本\n  //  * 用于追踪文件是由哪个版本的软件创建的\n  //  */\n  // createdByVersion?: string;\n\n  // /**\n  //  * 最后修改时使用的软件版本\n  //  * 用于追踪文件最后是由哪个版本的软件修改的\n  //  */\n  // lastModifiedByVersion?: string;\n\n  // /**\n  //  * 文件创建时间（ISO 8601 格式）\n  //  * 用于记录文件的创建时间\n  //  */\n  // createdAt?: string;\n\n  // /**\n  //  * 最后修改时间（ISO 8601 格式）\n  //  * 用于记录文件的最后修改时间\n  //  */\n  // lastModified?: string;\n\n  // /**\n  //  * 文件格式标识\n  //  * 如果未来文件格式发生变化，可以用此字段标识\n  //  * 例如: \"prg-v1\", \"prg-v2\"\n  //  */\n  // fileFormat?: string;\n\n  // /**\n  //  * 压缩算法\n  //  * 如果未来支持多种压缩算法，可以用此字段标识\n  //  * 例如: \"none\", \"gzip\", \"brotli\"\n  //  */\n  // compression?: string;\n\n  // /**\n  //  * 编码方式\n  //  * 用于标识数据的编码方式\n  //  * 例如: \"utf-8\", \"utf-16\"\n  //  */\n  // encoding?: string;\n\n  // /**\n  //  * 数据校验和\n  //  * 用于验证数据完整性，防止文件损坏\n  //  * 例如: MD5, SHA256 等\n  //  */\n  // checksum?: string;\n\n  // /**\n  //  * 使用的功能特性列表\n  //  * 用于兼容性检查，标识文件使用了哪些特性\n  //  * 例如: [\"lineType\", \"customShapes\", \"plugins\"]\n  //  */\n  // features?: string[];\n\n  // /**\n  //  * 最低要求的软件版本\n  //  * 如果文件使用了某些新特性，可能需要特定版本的软件才能打开\n  //  * 例如: \"2.1.0\"\n  //  */\n  // requiredVersion?: string;\n\n  // /**\n  //  * 作者信息\n  //  * 文件的创建者或主要维护者\n  //  */\n  // author?: string;\n\n  // /**\n  //  * 文件描述\n  //  * 对文件的简短描述\n  //  */\n  // description?: string;\n\n  // /**\n  //  * 文件标签（元数据层面）\n  //  * 不同于项目内的 tags，这是文件本身的标签\n  //  * 例如: [\"template\", \"example\", \"archived\"]\n  //  */\n  // fileTags?: string[];\n\n  // /**\n  //  * 许可证信息\n  //  * 文件的许可证类型\n  //  * 例如: \"MIT\", \"CC-BY-4.0\", \"proprietary\"\n  //  */\n  // license?: string;\n\n  // /**\n  //  * 迁移历史记录\n  //  * 记录文件从哪些版本升级过，用于调试和追踪\n  //  * 例如: [\"2.0.0\", \"2.1.0\"]\n  //  */\n  // migrationHistory?: string[];\n\n  // /**\n  //  * 自定义字段\n  //  * 用于扩展，允许添加任意自定义字段\n  //  * 键名建议使用命名空间，例如: \"custom:myField\"\n  //  */\n  // [key: `custom:${string}`]: any;\n}\n\n/**\n * 创建默认的 metadata 对象\n * @param version 版本号，默认为最新版本\n */\nexport function createDefaultMetadata(version: string = \"2.0.0\"): ProjectMetadata {\n  return {\n    version,\n  };\n}\n\n/**\n * 验证 metadata 对象是否有效\n * @param metadata 待验证的 metadata\n * @returns 是否有效\n */\nexport function isValidMetadata(metadata: any): metadata is ProjectMetadata {\n  return metadata && typeof metadata === \"object\" && typeof metadata.version === \"string\";\n}\n\n/**\n * 合并 metadata，保留所有字段\n * @param base 基础 metadata\n * @param updates 更新的字段\n * @returns 合并后的 metadata\n */\nexport function mergeMetadata(base: ProjectMetadata, updates: Partial<ProjectMetadata>): ProjectMetadata {\n  return {\n    ...base,\n    ...updates,\n  };\n}\n"
  },
  {
    "path": "app/src/types/node.tsx",
    "content": "import { Project } from \"@/core/Project\";\n\n// version 在 StageDumper里\nexport namespace Serialized {\n  export type Vector = [number, number];\n  export type Color = [number, number, number, number];\n\n  export type StageObject = {\n    uuid: string;\n    type: string;\n  };\n\n  export type Entity = StageObject & {\n    location: Vector;\n    details: string;\n  };\n  /**\n   * 调整大小的模式\n   * auto：自动缩紧\n   * manual：手动调整宽度，高度自动撑开。\n   */\n  export type TextNodeSizeAdjust = \"auto\" | \"manual\";\n  export type TextNode = Entity & {\n    type: \"core:text_node\";\n    size: Vector;\n    text: string;\n    color: Color;\n    sizeAdjust: TextNodeSizeAdjust;\n  };\n\n  export function isTextNode(obj: StageObject): obj is TextNode {\n    return obj.type === \"core:text_node\";\n  }\n\n  export type Section = Entity & {\n    type: \"core:section\";\n    size: Vector;\n    text: string;\n    color: Color;\n\n    children: string[]; // uuid[]\n    isHidden: boolean;\n    isCollapsed: boolean;\n  };\n\n  export function isSection(obj: StageObject): obj is Section {\n    return obj.type === \"core:section\";\n  }\n\n  export type ConnectPoint = Entity & {\n    type: \"core:connect_point\";\n  };\n  export function isConnectPoint(obj: StageObject): obj is ConnectPoint {\n    return obj.type === \"core:connect_point\";\n  }\n  export type ImageNode = Entity & {\n    path: string;\n    size: Vector;\n    scale: number;\n    type: \"core:image_node\";\n  };\n  export function isImageNode(obj: StageObject): obj is ImageNode {\n    return obj.type === \"core:image_node\";\n  }\n  export type UrlNode = Entity & {\n    url: string;\n    title: string;\n    size: Vector;\n    color: Color;\n    type: \"core:url_node\";\n  };\n  export function isUrlNode(obj: StageObject): obj is UrlNode {\n    return obj.type === \"core:url_node\";\n  }\n  export type PortalNode = Entity & {\n    // 连接的文件\n    portalFilePath: string;\n    targetLocation: Vector;\n    cameraScale: number;\n    // 显示的可更改标题\n    title: string;\n    // 传送门的大小\n    size: Vector;\n    // 颜色\n    color: Color;\n    type: \"core:portal_node\";\n  };\n  export function isPortalNode(obj: StageObject): obj is PortalNode {\n    return obj.type === \"core:portal_node\";\n  }\n  export type PenStroke = Entity & {\n    type: \"core:pen_stroke\";\n    content: string;\n    color: Color;\n  };\n  export function isPenStroke(obj: StageObject): obj is PenStroke {\n    return obj.type === \"core:pen_stroke\";\n  }\n  export type SvgNode = Entity & {\n    type: \"core:svg_node\";\n    content: string;\n    size: Vector;\n    color: Color;\n    scale: number;\n  };\n  export function isSvgNode(obj: StageObject): obj is SvgNode {\n    return obj.type === \"core:svg_node\";\n  }\n  // export type Edge = StageObject & {\n  //   type: \"core:edge\";\n  //   source: string;\n  //   target: string;\n  //   text: string;\n  // };\n  export type Association = StageObject & {\n    text: string;\n    color: Color;\n  };\n\n  /**\n   * 无向边的箭头类型\n   * inner：--> xxx <--\n   * outer：<-- xxx -->\n   * none： --- xxx ---\n   */\n  export type UndirectedEdgeArrowType = \"inner\" | \"outer\" | \"none\";\n  /**\n   * 无向边的渲染方式\n   * line：内部连线式渲染\n   * convex：凸包连线式渲染\n   */\n  export type MultiTargetUndirectedEdgeRenderType = \"line\" | \"convex\";\n  export type MultiTargetUndirectedEdge = Association & {\n    type: \"core:multi_target_undirected_edge\";\n    targets: string[];\n    arrow: UndirectedEdgeArrowType;\n    rectRates: [number, number][]; // 默认中心 0.5, 0.5\n    centerRate: [number, number]; // 默认中心 0.5, 0.5\n    padding: number;\n    renderType: MultiTargetUndirectedEdgeRenderType;\n  };\n  export function isMultiTargetUndirectedEdge(obj: StageObject): obj is MultiTargetUndirectedEdge {\n    return obj.type === \"core:multi_target_undirected_edge\";\n  }\n  export type Edge = Association & {\n    source: string;\n    target: string;\n    sourceRectRate: [number, number]; // 默认中心 0.5, 0.5\n    targetRectRate: [number, number]; // 默认中心 0.5, 0.5\n  };\n  export function isEdge(obj: StageObject): obj is Edge {\n    return \"source\" in obj && \"target\" in obj;\n  }\n  export type LineEdge = Edge & {\n    type: \"core:line_edge\";\n    color: Color;\n    text: string;\n  };\n  export function isLineEdge(obj: StageObject): obj is LineEdge {\n    return obj.type === \"core:line_edge\";\n  }\n  export function isCubicCatmullRomSplineEdge(obj: StageObject): obj is CubicCatmullRomSplineEdge {\n    return obj.type === \"core:cublic_catmull_rom_spline_edge\";\n  }\n  export type CubicCatmullRomSplineEdge = Edge & {\n    type: \"core:cublic_catmull_rom_spline_edge\";\n    text: string;\n    controlPoints: Vector[];\n    alpha: number;\n    tension: number;\n  };\n\n  export type CoreEntity = TextNode | Section | ConnectPoint | ImageNode | UrlNode | PenStroke | PortalNode | SvgNode;\n  export function isCoreEntity(obj: StageObject): obj is CoreEntity {\n    return obj.type.startsWith(\"core:\");\n  }\n  export type CoreAssociation = LineEdge | CubicCatmullRomSplineEdge | MultiTargetUndirectedEdge;\n\n  export type File = {\n    version: typeof Project.latestVersion;\n    entities: CoreEntity[];\n    associations: CoreAssociation[];\n    tags: string[];\n  };\n}\n"
  },
  {
    "path": "app/src/utils/base64.tsx",
    "content": "export namespace Base64 {\n  export function encode(str: string) {\n    return btoa(Array.from(new TextEncoder().encode(str), (byte) => String.fromCodePoint(byte)).join(\"\"));\n  }\n  export function decode(b64: string) {\n    return new TextDecoder().decode(Uint8Array.from(atob(b64), (m) => m.codePointAt(0)!));\n  }\n}\n"
  },
  {
    "path": "app/src/utils/cn.tsx",
    "content": "import { ClassValue, clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\n/**\n * 将多个 tailwindcss 的 class 名合并为一个字符串\n * @param inputs\n * @returns\n */\nexport const cn = (...inputs: ClassValue[]) => {\n  return twMerge(clsx(inputs));\n};\n"
  },
  {
    "path": "app/src/utils/dateChecker.tsx",
    "content": "/**\n * 日期检查和时间转换工具\n */\nexport namespace DateChecker {\n  /**\n   * 判断当前是否是某月某日\n   * 判断当前是否是3月15日就直接传入3和15即可\n   */\n  export function isCurrentEqualDate(month: number, day: number): boolean {\n    const now = new Date();\n    return now.getMonth() + 1 === month && now.getDate() === day;\n  }\n\n  /**\n   * 将时间戳转换为相对时间格式\n   * 例如：1天前，3小时前，5分钟前\n   */\n  export function formatRelativeTime(timestamp: number): string {\n    const now = Date.now();\n    const diff = now - timestamp;\n\n    // 计算时间差的各个单位\n    const seconds = Math.floor(diff / 1000);\n    const minutes = Math.floor(seconds / 60);\n    const hours = Math.floor(minutes / 60);\n    const days = Math.floor(hours / 24);\n    const months = Math.floor(days / 30);\n    const years = Math.floor(days / 365);\n\n    // 根据时间差选择合适的单位\n    if (years > 0) {\n      return `${years}${years === 1 ? \"年\" : \"年\"}前`;\n    }\n    if (months > 0) {\n      return `${months}${months === 1 ? \"月\" : \"月\"}前`;\n    }\n    if (days > 0) {\n      return `${days}${days === 1 ? \"天\" : \"天\"}前`;\n    }\n    if (hours > 0) {\n      return `${hours}${hours === 1 ? \"小时\" : \"小时\"}前`;\n    }\n    if (minutes > 0) {\n      return `${minutes}${minutes === 1 ? \"分钟\" : \"分钟\"}前`;\n    }\n    if (seconds > 0) {\n      return `${seconds}${seconds === 1 ? \"秒\" : \"秒\"}前`;\n    }\n    return \"刚刚\";\n  }\n}\n"
  },
  {
    "path": "app/src/utils/emacs.tsx",
    "content": "/**\n * <0>: 鼠标按键0\n * <1>: 鼠标按键1\n * <x>: 鼠标按键x\n * <MWU>: MouseWheelUp\n * <MWD>: MouseWheelDown\n * key: 其他按键\n * @example\n * C-home\n * C-S-end\n * C-s\n * C-<MWU>\n */\n\nexport function parseEmacsKey(key: string): {\n  key: string;\n  alt: boolean;\n  control: boolean;\n  shift: boolean;\n  meta: boolean;\n}[] {\n  return key.split(\" \").map((it) => parseSingleEmacsKey(it));\n}\n\n/**\n * 解析按键字符串\n */\nexport function parseSingleEmacsKey(key: string): {\n  key: string;\n  alt: boolean;\n  control: boolean;\n  shift: boolean;\n  meta: boolean;\n} {\n  let alt = false;\n  let control = false;\n  let shift = false;\n  let meta = false;\n\n  if (key === \"-\") {\n    // 真就只是一个 减号，不是组合键\n    return {\n      key: \"-\",\n      alt,\n      control,\n      shift,\n      meta,\n    };\n  }\n\n  if (key.endsWith(\"--\")) {\n    // 说明是含有一个减号的组合键\n    key = key.replace(\"--\", \"-[SUB]\");\n    return parseSingleEmacsKey(key);\n  }\n\n  const parts = key.split(\"-\");\n  if (parts.length === 0) return { key: \"\", alt, control, shift, meta };\n\n  const keyPart = parts[parts.length - 1];\n  const modifiers = parts.slice(0, -1);\n\n  modifiers.forEach((mod) => {\n    switch (mod.toUpperCase()) {\n      case \"C\":\n        control = true;\n        break;\n      case \"S\":\n        shift = true;\n        break;\n      case \"M\":\n        meta = true;\n        break;\n      case \"A\":\n        alt = true;\n        break;\n    }\n  });\n\n  if (keyPart === \"[SUB]\") {\n    return {\n      key: \"-\",\n      alt,\n      control,\n      shift,\n      meta,\n    };\n  }\n\n  const specialKeyMatch = /^<(.+?)>$/.exec(keyPart);\n  const parsedKey = specialKeyMatch\n    ? keyPart // 保持特殊按键原样（MWU/MWD/数字等）\n    : keyPart.toLowerCase(); // 普通按键转为小写\n\n  return {\n    key: parsedKey,\n    alt,\n    control,\n    shift,\n    meta,\n  };\n}\n\n/**\n * 解决macbook特殊中文按键符号问题\n * 左边是中文符号，右边是对应的英文符号\n */\nconst transformedKeys = {\n  \"【\": \"[\",\n  \"】\": \"]\",\n  \"；\": \";\",\n  \"‘\": \"'\",\n  \"’\": \"'\",\n  \"“\": '\"',\n  \"”\": '\"',\n  \"，\": \",\",\n  \"。\": \".\",\n  \"、\": \"\\\\\",\n  \"《\": \"<\",\n  \"》\": \">\",\n  \"？\": \"?\",\n  \"！\": \"!\",\n  \"：\": \":\",\n  \"·\": \"`\",\n  \"¥\": \"$\",\n  \"～\": \"~\",\n  \"……\": \"^\",\n  \"｜\": \"|\",\n};\n\n/**\n * 检测一个emacs格式的快捷键是否匹配一个事件\n */\nexport function matchSingleEmacsKey(key: string, event: KeyboardEvent | MouseEvent | WheelEvent): boolean {\n  const parsedKey = parseSingleEmacsKey(key);\n\n  const matchModifiers =\n    parsedKey.control === event.ctrlKey &&\n    parsedKey.alt === event.altKey &&\n    parsedKey.shift === event.shiftKey &&\n    parsedKey.meta === event.metaKey;\n\n  let matchKey = false;\n  if (event instanceof KeyboardEvent) {\n    const eventKey = event.key.toLowerCase();\n    if (eventKey in transformedKeys) {\n      matchKey = transformedKeys[eventKey as keyof typeof transformedKeys] === parsedKey.key;\n    } else {\n      matchKey = eventKey === parsedKey.key;\n    }\n  }\n  if (event instanceof MouseEvent) {\n    matchKey = event.button === parseInt(parsedKey.key.slice(1, -1));\n  }\n  if (event instanceof WheelEvent) {\n    matchKey = (event.deltaY < 0 && parsedKey.key === \"<MWU>\") || (event.deltaY > 0 && parsedKey.key === \"<MWD>\");\n  }\n\n  return matchModifiers && matchKey;\n}\n\n/**\n * 把windows/linux格式的快捷键转换为Mac格式\n * @param key\n * @returns\n */\nexport function transEmacsKeyWinToMac(key: string): string {\n  key = key.replace(\"C-\", \"Control-\");\n  key = key.replace(\"M-\", \"C-\");\n  key = key.replace(\"Control-\", \"M-\");\n  return key;\n}\n\n/**\n * 匹配序列快捷键\n * @param key \"C-k C-t\"\n * @param events [KeyboardEvent, KeyboardEvent], 最大长度20，刚才触发的一系列事件\n * @returns 是否匹配上了\n */\nexport function matchEmacsKeyPress(key: string, events: (KeyboardEvent | MouseEvent | WheelEvent)[]): boolean {\n  const seq = key.trim().split(/\\s+/);\n  if (seq.length === 0 || events.length < seq.length) return false;\n\n  // 从后往前比对\n  for (let i = 0; i < seq.length; i++) {\n    const seqIdx = seq.length - 1 - i; // 从 seq 尾部开始\n    const eventIdx = events.length - 1 - i; // 从 events 尾部开始\n    if (!matchSingleEmacsKey(seq[seqIdx], events[eventIdx])) {\n      return false;\n    }\n  }\n  return true;\n}\n\n/**\n * 将事件转换为emacs格式的快捷键\n * @param event KeyboardEvent | MouseEvent | WheelEvent\n * @returns \"C-s\", \"C-<MWU>\" 等格式\n */\nexport function formatEmacsKey(event: KeyboardEvent | MouseEvent | WheelEvent): string {\n  let key = \"\";\n  if (event instanceof KeyboardEvent) {\n    const eventKey = event.key.toLowerCase();\n    if (eventKey in transformedKeys) {\n      key = transformedKeys[eventKey as keyof typeof transformedKeys];\n    } else {\n      key = event.key.toLowerCase();\n    }\n  }\n  if (event instanceof MouseEvent) {\n    key = `<${event.button}>`;\n  }\n  if (event instanceof WheelEvent) {\n    key = event.deltaY < 0 ? \"<MWU>\" : \"<MWD>\";\n  }\n\n  const modifiers = [];\n  if (event.ctrlKey) modifiers.push(\"C\");\n  if (event.altKey) modifiers.push(\"A\");\n  if (event.shiftKey) modifiers.push(\"S\");\n  if (event.metaKey) modifiers.push(\"M\");\n\n  return modifiers.map((it) => it + \"-\").join(\"\") + key;\n}\n"
  },
  {
    "path": "app/src/utils/externalOpen.tsx",
    "content": "import { Project } from \"@/core/Project\";\nimport { Entity } from \"@/core/stage/stageObject/abstract/StageEntity\";\nimport { TextNode } from \"@/core/stage/stageObject/entity/TextNode\";\nimport { open } from \"@tauri-apps/plugin-shell\";\nimport { toast } from \"sonner\";\nimport { PathString } from \"./pathString\";\nimport { URI } from \"vscode-uri\";\nimport { onOpenFile } from \"@/core/service/GlobalMenu\";\n\nexport async function openBrowserOrFile(project: Project) {\n  for (const node of project.stageManager.getSelectedEntities()) {\n    openBrowserOrFileByEntity(node, project);\n  }\n}\n\nexport function openBrowserOrFileByEntity(entity: Entity, project: Project) {\n  if (entity instanceof TextNode) {\n    openOneTextNode(entity, project);\n  } else {\n    openOneEntity(entity, project);\n  }\n}\n\nfunction openOneEntity(node: Entity, project: Project) {\n  let targetUrl = \"\";\n  if (node.details.length > 0) {\n    targetUrl = getEntityDetailsFirstLine(node);\n  }\n  targetUrl = splitDoubleQuote(targetUrl);\n  myOpen(targetUrl, project);\n}\n\n/**\n * 打开一个文本节点url\n * 先看看详细信息的第一行是不是内容，如果符合，就根据它打开\n * 如果不符合，就根据内容打开\n * @param node\n */\nfunction openOneTextNode(node: TextNode, project: Project) {\n  let targetUrl = node.text;\n  targetUrl = splitDoubleQuote(targetUrl);\n  if (node.details.length > 0) {\n    targetUrl = getEntityDetailsFirstLine(node);\n  }\n  myOpen(targetUrl, project);\n  // 2025年1月4日——有自动备份功能了，好像不需要再加验证了\n}\n\nfunction getEntityDetailsFirstLine(node: Entity): string {\n  let res = \"\";\n  if (node.details.length > 0) {\n    // 说明详细信息里面有内容，看看第一个内容是不是p标签\n    const firstLine = node.details[0];\n    if (firstLine.type === \"p\") {\n      for (const child of firstLine.children) {\n        if (typeof child.text === \"string\") {\n          res = child.text;\n        }\n        break;\n      }\n    }\n  }\n  return res;\n}\n\n/**\n * 去除字符串两端的引号\n * @param str\n */\nfunction splitDoubleQuote(str: string) {\n  if (str.startsWith('\"') && str.endsWith('\"')) {\n    return str.slice(1, -1);\n  }\n  return str;\n}\n\n/**\n * 调用tauri框架的open方法\n * @param url\n * @param project 之所以需要project参数，是因为需要根据project的uri来转换相对路径\n */\nfunction myOpen(url: string, project: Project) {\n  const isValidURL = PathString.isValidURL(url);\n  if (isValidURL) {\n    // 是网址\n    toast.info(`正在打开网址：【${url}】`);\n  } else {\n    toast.info(`正在打开本地文件路径：【${url}】`);\n  }\n  if (!isValidURL) {\n    // 是文件路径\n    if (url.startsWith(\"./\") || url.startsWith(\"../\")) {\n      // 是相对路径！转成绝对路径\n      let currentProjectPath = PathString.uppercaseAbsolutePathDiskChar(project.uri.fsPath);\n      currentProjectPath = currentProjectPath.replaceAll(\"\\\\\", \"/\");\n      url = PathString.relativePathToAbsolutePath(currentProjectPath, url);\n      console.log(\"转换后的url\", url);\n    }\n    if (url.endsWith(\".prg\")) {\n      // 打开绝对路径 prg文件\n      const uri = URI.file(url);\n      onOpenFile(uri, \"externalOpen-myOpen-prg文件\");\n      return;\n    }\n  }\n  open(url)\n    // eslint-disable-next-line @typescript-eslint/no-unused-vars\n    .then((_) => {\n      console.log(\"打开成功\");\n    })\n    .catch((e) => {\n      // 依然会导致程序崩溃，具体原因未知\n      // 2025年2月17日，好像不会再崩溃了，只是可能会弹窗说找不到文件\n      console.error(e);\n    });\n}\n"
  },
  {
    "path": "app/src/utils/font.tsx",
    "content": "import { isMac } from \"@/utils/platform\";\nimport { MaxSizeCache, Vector } from \"@graphif/data-structures\";\nimport { Settings } from \"@/core/service/Settings\";\n\nconst _canvas = document.createElement(\"canvas\");\nconst _context = _canvas.getContext(\"2d\");\n\nconst _cache = new MaxSizeCache<string, number>(10000);\n\n/** canvas中使用的字体 */\nexport let FONT = \"-apple-system, BlinkMacSystemFont, MiSans, system-ui, sans-serif\";\nif (isMac) {\n  // 只有 PingFang TC 在mac中，中英文混合文字 宽度才能计算正确，离谱\n  FONT = \"PingFang SC, PingFang TC, -apple-system\";\n}\n\n// eslint-disable-next-line prefer-const\nlet useCache = true;\n/**\n * 测量文本的宽度（高度不测量）\n * 不要在循环中调用，会影响性能\n * @param text\n * @param size\n * @returns\n */\nexport function getTextSize(text: string, size: number): Vector {\n  // const t1 = performance.now();\n  if (useCache) {\n    const value = _cache.get(`${text}-${size}`);\n    if (value) {\n      return new Vector(value, size);\n    }\n  }\n\n  if (!_context) {\n    throw new Error(\"Failed to get canvas context\");\n  }\n\n  _context.font = `${size}px normal ${FONT}`;\n  const metrics = _context.measureText(text);\n  // const t2 = performance.now();\n  if (useCache) {\n    _cache.set(`${text}-${size}`, metrics.width);\n  }\n\n  return new Vector(metrics.width, size);\n}\n\n/**\n * 获取多行文本的宽度和高度\n * @param text\n * @param fontSize\n * @param lineHeight 行高，是一个比率\n * @returns\n */\nexport function getMultiLineTextSize(text: string, fontSize: number, lineHeight: number): Vector {\n  const lines = text.split(\"\\n\");\n  let width = 0;\n  let height = 0;\n  for (const line of lines) {\n    const size = getTextSize(line, fontSize);\n    width = Math.max(width, size.x);\n    height += size.y * lineHeight;\n  }\n  return new Vector(width, height);\n}\n\n/**\n * 隐私保护文本替换\n * 根据设置的保护模式进行不同的替换\n * @param text\n */\nexport function replaceTextWhenProtect(text: string) {\n  // 检查是否设置了保护模式，如果没有，默认为secretWord\n  const mode = Settings.protectingPrivacyMode || \"secretWord\";\n\n  if (mode === \"caesar\") {\n    // 凯撒移位加密：所有字符往后移动一位\n    return text\n      .split(\"\")\n      .map((char) => {\n        const code = char.charCodeAt(0);\n\n        // 对于可打印ASCII字符进行移位\n        if (code >= 32 && code <= 126) {\n          // 特殊处理：'z' 移到 'a'，'Z' 移到 'A'，'9' 移到 '0'\n          if (char === \"z\") return \"a\";\n          if (char === \"Z\") return \"A\";\n          if (char === \"9\") return \"0\";\n          // 其他字符直接 +1\n          return String.fromCharCode(code + 1);\n        }\n\n        // 对于中文字符，进行移位加密\n        if (code >= 0x4e00 && code <= 0x9fa5) {\n          // 中文字符在Unicode范围内循环移位\n          // 0x4e00是汉字起始，0x9fa5是汉字结束，总共约20902个汉字\n          const shiftedCode = code + 1;\n          // 如果超过汉字范围，则回到起始位置\n          return String.fromCharCode(shiftedCode <= 0x9fa5 ? shiftedCode : 0x4e00);\n        }\n\n        // 其他字符保持不变\n        return char;\n      })\n      .join(\"\");\n  }\n\n  // 默认的secretWord模式\n  return text\n    .replace(/[\\u4e00-\\u9fa5]/g, \"㊙\")\n    .replace(/[a-z]/g, \"a\")\n    .replace(/[A-Z]/g, \"A\")\n    .replace(/\\d/g, \"6\");\n}\n\nexport function camelCaseToDashCase(text: string) {\n  return text.replace(/([a-z])([A-Z])/g, \"$1-$2\").toLowerCase();\n}\n"
  },
  {
    "path": "app/src/utils/imageExport.tsx",
    "content": "import { ImageNode } from \"@/core/stage/stageObject/entity/ImageNode\";\nimport { exists, writeFile } from \"@tauri-apps/plugin-fs\";\nimport mime from \"mime\";\n\n/**\n * 将选中的图片节点导出到项目目录\n * @param imageNodes 要导出的图片节点数组\n * @param projectPath 项目文件路径（.prg 文件的完整路径）\n * @param attachments 附件存储（用于获取图片 Blob）\n * @param fileName 用户输入的文件名（不含扩展名）\n * @returns 导出结果（成功数量、失败数量）\n */\nexport async function exportImagesToProjectDirectory(\n  imageNodes: ImageNode[],\n  projectPath: string,\n  attachments: Map<string, Blob>,\n  fileName: string,\n): Promise<{ successCount: number; failedCount: number }> {\n  const isBatch = imageNodes.length > 1;\n  let successCount = 0;\n  let failedCount = 0;\n\n  // 获取当前 prg 文件所在目录\n  const projectDir = projectPath.substring(0, projectPath.lastIndexOf(\"/\"));\n\n  for (let i = 0; i < imageNodes.length; i++) {\n    const imageNode = imageNodes[i];\n\n    try {\n      // 获取图片 Blob\n      const blob = attachments.get(imageNode.attachmentId);\n      if (!blob) {\n        console.warn(`跳过：无法找到图片 ${i + 1} 的数据`);\n        failedCount++;\n        continue;\n      }\n\n      // 从 Blob type 推断扩展名\n      const ext = mime.getExtension(blob.type) || \"png\";\n\n      // 构建文件名：如果是批量导出，添加数字后缀\n      const finalFileName = isBatch ? `${fileName}_${i + 1}` : fileName;\n      const saveFilePath = `${projectDir}/${finalFileName}.${ext}`;\n\n      // 检查文件是否已存在\n      const fileExists = await exists(saveFilePath);\n      if (fileExists) {\n        console.warn(`跳过：文件已存在 ${finalFileName}.${ext}`);\n        failedCount++;\n        continue;\n      }\n\n      // 将 Blob 转换为 Uint8Array\n      const arrayBuffer = await blob.arrayBuffer();\n      const uint8Array = new Uint8Array(arrayBuffer);\n\n      // 保存图片\n      await writeFile(saveFilePath, uint8Array);\n      successCount++;\n    } catch (error) {\n      console.error(`保存图片 ${i + 1} 失败:`, error);\n      failedCount++;\n    }\n  }\n\n  return { successCount, failedCount };\n}\n"
  },
  {
    "path": "app/src/utils/keyboardFunctions.tsx",
    "content": "export function getEnterKey(event: KeyboardEvent): \"enter\" | \"ctrlEnter\" | \"shiftEnter\" | \"altEnter\" | \"other\" {\n  if (event.key === \"Enter\") {\n    if (event.ctrlKey) {\n      return \"ctrlEnter\";\n    } else if (event.altKey) {\n      return \"altEnter\";\n    } else if (event.shiftKey) {\n      return \"shiftEnter\";\n    } else {\n      return \"enter\";\n    }\n  } else {\n    return \"other\";\n  }\n}\n"
  },
  {
    "path": "app/src/utils/markdownParse.tsx",
    "content": "export interface MarkdownNode {\n  title: string;\n  content: string;\n  children: MarkdownNode[];\n}\n\n/**\n * 将markdonwn文本解析为JSON对象\n * @param markdown\n * @returns\n */\nexport function parseMarkdownToJSON(markdown: string): MarkdownNode[] {\n  const lines = markdown.split(\"\\n\");\n  const root: MarkdownNode[] = [];\n  const stack: { node: MarkdownNode; level: number }[] = [];\n\n  for (const line of lines) {\n    // 匹配标题（如 #, ##, ### 等）\n    const titleMatch = line.match(/^(#+)\\s*(.*)/);\n    if (titleMatch) {\n      const level = titleMatch[1].length; // 标题层级\n      const title = titleMatch[2].trim(); // 标题内容\n\n      const newNode: MarkdownNode = {\n        title,\n        content: \"\",\n        children: [],\n      };\n\n      // 根据层级找到父节点\n      while (stack.length > 0 && stack[stack.length - 1].level >= level) {\n        stack.pop();\n      }\n\n      if (stack.length === 0) {\n        // 根节点\n        root.push(newNode);\n      } else {\n        // 添加到父节点的 children 中\n        stack[stack.length - 1].node.children.push(newNode);\n      }\n\n      // 将当前节点推入栈中\n      stack.push({ node: newNode, level });\n    } else if (line.trim()) {\n      // 非标题行，作为内容处理\n      if (stack.length > 0) {\n        stack[stack.length - 1].node.content += line + \"\\n\";\n        // 再去除一下最终的换行符\n        stack[stack.length - 1].node.content = stack[stack.length - 1].node.content.trim();\n      }\n    }\n  }\n\n  return root;\n}\n"
  },
  {
    "path": "app/src/utils/otherApi.tsx",
    "content": "import { getVersion } from \"@tauri-apps/api/app\";\nimport { invoke } from \"@tauri-apps/api/core\";\nimport { isWeb } from \"@/utils/platform\";\n\nexport async function writeStdout(content: string): Promise<void> {\n  if (!isWeb) {\n    return invoke(\"write_stdout\", { content });\n  }\n}\n\nexport async function writeStderr(content: string): Promise<void> {\n  if (isWeb) {\n    console.error(\"STDERR:\", content);\n  } else {\n    return invoke(\"write_stderr\", { content });\n  }\n}\n\nexport async function openDevtools(): Promise<void> {\n  if (!isWeb) {\n    return invoke(\"open_devtools\");\n  }\n}\n\nexport async function exit(code: number = 0): Promise<void> {\n  if (isWeb) {\n    window.close();\n  } else {\n    return invoke(\"exit\", { code });\n  }\n}\n\nexport async function getAppVersion(): Promise<string> {\n  if (isWeb) {\n    return \"0.0.0-web\";\n  } else {\n    return getVersion();\n  }\n}\n\nexport async function getDeviceId(): Promise<string> {\n  if (isWeb) {\n    return \"web\";\n  } else {\n    return invoke(\"get_device_id\");\n  }\n}\n"
  },
  {
    "path": "app/src/utils/path.tsx",
    "content": "import { isTauri } from \"@tauri-apps/api/core\";\nimport { sep } from \"@tauri-apps/api/path\";\nimport { URI } from \"vscode-uri\";\n\nexport class Path {\n  private readonly path: string;\n  static sep = isTauri() ? sep() : \"/\";\n\n  constructor(path: string);\n  constructor(uri: URI);\n  constructor(pathOrUri: string | URI) {\n    if (typeof pathOrUri === \"string\") {\n      this.path = pathOrUri;\n    } else {\n      this.path = pathOrUri.fsPath;\n    }\n  }\n\n  get parent() {\n    const parts = this.path.split(Path.sep);\n    parts.pop();\n    return new Path(parts.join(Path.sep));\n  }\n  get name() {\n    const parts = this.path.split(Path.sep);\n    return parts[parts.length - 1];\n  }\n  get ext() {\n    const parts = this.path.split(\".\");\n    if (parts.length > 1) {\n      return parts[parts.length - 1];\n    } else {\n      return \"\";\n    }\n  }\n  get nameWithoutExt() {\n    const parts = this.name.split(\".\");\n    if (parts.length > 1) {\n      parts.pop();\n    }\n    return parts.join(\".\");\n  }\n  join(path: string) {\n    return new Path(this.path + Path.sep + path);\n  }\n  toUri() {\n    return URI.file(this.path);\n  }\n  toString() {\n    return this.path;\n  }\n}\n"
  },
  {
    "path": "app/src/utils/pathString.tsx",
    "content": "import { family } from \"@/utils/platform\";\n\nexport namespace PathString {\n  /**\n   * 获取当前平台的路径分隔符\n   * @returns\n   */\n  export function getSep(): string {\n    const fam = family();\n    if (fam === \"windows\") {\n      return \"\\\\\";\n    } else {\n      return \"/\";\n    }\n  }\n\n  /**\n   * 将绝对路径转换为文件名\n   * @param path\n   * @returns\n   */\n  export function absolute2file(path: string): string {\n    const fam = family();\n    // const fam = \"windows\"; // vitest 测试时打开此行注释\n\n    if (fam === \"windows\") {\n      path = path.replace(/\\\\/g, \"/\");\n    }\n    const file = path.split(\"/\").pop();\n    if (!file) {\n      throw new Error(\"Invalid path\");\n    }\n    const parts = file.split(\".\");\n    if (parts.length > 1) {\n      return parts.slice(0, -1).join(\".\");\n    } else {\n      return file;\n    }\n  }\n\n  /**\n   * 根据文件的绝对路径，获取当前文件所在目录的路径\n   * 结尾不带 /\n   * @param path 必须是一个文件的路径，不能是文件夹的路径\n   * @returns\n   */\n  export function dirPath(path: string): string {\n    const fam = family();\n    // const fam = \"windows\"; // vitest 测试时打开此行注释\n\n    if (fam === \"windows\") {\n      path = path.replace(/\\\\/g, \"/\"); // 将反斜杠替换为正斜杠\n    }\n\n    const file = path.split(\"/\").pop(); // 获取文件名\n    if (!file) {\n      throw new Error(\"Invalid path\");\n    }\n\n    let directory = path.substring(0, path.length - file.length); // 获取目录路径\n    if (directory.endsWith(\"/\")) {\n      directory = directory.slice(0, -1); // 如果目录路径以斜杠结尾，去掉最后的斜杠\n    }\n\n    if (fam === \"windows\") {\n      // 再换回反斜杠\n      return directory.replace(/\\//g, \"\\\\\");\n    }\n\n    return directory; // 返回目录路径\n  }\n\n  /**\n   * 通过路径字符串中，提取出文件名\n   * 例如：\n   * path = \"C:/Users/admin/Desktop/test.txt\"\n   * 则返回 \"test\"\n   * @param path\n   */\n  export function getFileNameFromPath(path: string): string {\n    path = path.replace(/\\\\/g, \"/\");\n    const parts = path.split(\"/\");\n    const fileName = parts[parts.length - 1];\n    const parts2 = fileName.split(\".\");\n    if (parts2.length > 1) {\n      return parts2.slice(0, -1).join(\".\");\n    } else {\n      return fileName;\n    }\n  }\n\n  /**\n   * 获取符合路径文件名规则的时间字符串\n   */\n  export function getTime(): string {\n    const dateTime = new Date().toLocaleString().replaceAll(/\\//g, \"-\").replaceAll(\" \", \"_\").replaceAll(\":\", \"-\");\n    return dateTime;\n  }\n\n  /**\n   * 获取简短压缩后的文件名，会省略中间部分\n   * 用于显示在文件列表中\n   * @param fileName 原始文件名\n   * @param limitLength 文件名长度限制\n   * @param splitRate 分割比例，默认0.66，表示省略掉一部分内容后，\n   * 最后呈现的部分前半部分占比0.66，后半部分占比0.34\n   */\n  export function getShortedFileName(fileName: string, limitLength = 30, splitRate = 0.66): string {\n    let result = fileName;\n    if (fileName.length > limitLength) {\n      // 只截取前20+后10个字符\n      const frontEnd = Math.floor(limitLength * splitRate);\n      const endLength = limitLength - frontEnd;\n      result = `${fileName.slice(0, frontEnd)}…${fileName.slice(-endLength)}`;\n    }\n    return result;\n  }\n\n  /**\n   * 将盘符转大写（先检测是否有盘符开头，如果有则转，没有则返回原来的字符串）\n   * @param absolutePath\n   * 例如：\n   * 输入：\"d:/desktop/a.txt\"\n   * 输出：\"D:/desktop/a.txt\"\n   */\n  export function uppercaseAbsolutePathDiskChar(absolutePath: string) {\n    if (!absolutePath) return absolutePath;\n\n    // 匹配 Windows 盘符格式，如 \"c:\", \"D:\\\", \"e:/\"\n    const windowsDiskPattern = /^[a-zA-Z]:[\\\\/]/;\n\n    if (windowsDiskPattern.test(absolutePath)) {\n      // 将盘符转为大写\n      return absolutePath[0].toUpperCase() + absolutePath.slice(1);\n    }\n\n    // 非 Windows 路径或没有盘符，直接返回\n    return absolutePath;\n  }\n\n  /**\n   * 获取一个相对路径，从一个绝对路径到另一个绝对路径的跳转\n   * 如果无法获取，或者路径不合法，则返回空字符串\n   * @param from\n   * @param to\n   * @returns 相对路径\n   * 例如：\n   * from = \"C:/Users/admin/Desktop/test.txt\"\n   * to = \"C:/Users/admin/Desktop/test2.txt\"\n   * 则返回 \"./test2.txt\"\n   * from = \"C:/Users/admin/Desktop/test.txt\"\n   * to = \"C:/Users/admin/test2.txt\"\n   * 则返回 \"../test2.txt\"\n   */\n  export function getRelativePath(from: string, to: string): string {\n    // 统一替换反斜杠为正斜杠\n    const fromNormalized = from.replace(/\\\\/g, \"/\");\n    const toNormalized = to.replace(/\\\\/g, \"/\");\n\n    console.log(fromNormalized, toNormalized);\n\n    // 分割路径为数组\n    const fromParts = fromNormalized.split(\"/\").filter((part) => part && part !== \".\");\n    const toParts = toNormalized.split(\"/\").filter((part) => part && part !== \".\");\n\n    // 检查是否在同一根目录下\n    if (fromParts[0] !== toParts[0]) {\n      return \"\";\n    }\n\n    // 找到共同路径的深度\n    let commonDepth = 0;\n    const maxCommonDepth = Math.min(fromParts.length, toParts.length);\n    while (commonDepth < maxCommonDepth && fromParts[commonDepth] === toParts[commonDepth]) {\n      commonDepth++;\n    }\n\n    // 计算需要向上退出的层数\n    const upLevel = fromParts.length - commonDepth - 1; // -1 因为最后一部分是文件名\n\n    // 构建向上部分\n    const upPart = upLevel > 0 ? Array(upLevel).fill(\"..\").join(\"/\") : \"\";\n\n    // 构建向下部分\n    const downPart = toParts.slice(commonDepth).join(\"/\");\n\n    // 组合相对路径\n    let relativePath = \"\";\n    if (upPart && downPart) {\n      relativePath = upPart + \"/\" + downPart;\n    } else if (upPart) {\n      relativePath = upPart;\n    } else if (downPart) {\n      relativePath = \"./\" + downPart;\n    } else {\n      relativePath = \"./\" + toParts[toParts.length - 1];\n    }\n\n    return relativePath;\n  }\n\n  // 这个函数用AI生成，DeepSeek整整思考了四分钟，252秒，一次性全部通过测试，而其他大模型都无法通过测试。\n  /**\n   * 根据一个绝对路径和一个相对路径，获取新文件的绝对路径\n   * @param currentPath 绝对路径\n   * @param relativePath 相对路径\n   * 例如：\n   * currentPath = \"C:/Users/admin/Desktop/test.txt\"\n   * relativePath = \"./test2.txt\"\n   * 则返回 \"C:/Users/admin/Desktop/test2.txt\"\n   *\n   * currentPath = \"C:/Users/admin/Desktop/test.txt\"\n   * relativePath = \"../test2.txt\"\n   * 则返回 \"C:/Users/admin/test2.txt\"\n   * @returns\n   */\n  export function relativePathToAbsolutePath(currentPath: string, relativePath: string): string {\n    const { drive, parts: currentParts } = splitCurrentPath(currentPath);\n    const relativeParts = splitRelativePath(relativePath);\n\n    // 如果当前路径是文件（有扩展名），则去掉文件名部分，只保留目录\n    const isFile = hasFileExtension(currentParts[currentParts.length - 1]);\n    const directoryParts = isFile ? currentParts.slice(0, -1) : [...currentParts];\n\n    const mergedParts = [...directoryParts];\n    for (const part of relativeParts) {\n      if (part === \"..\") {\n        if (mergedParts.length > 0) {\n          mergedParts.pop();\n        }\n      } else if (part !== \".\" && part !== \"\") {\n        mergedParts.push(part);\n      }\n    }\n\n    let absolutePath;\n    if (drive) {\n      absolutePath = `${drive}/`;\n    } else {\n      absolutePath = \"/\";\n    }\n    absolutePath += mergedParts.join(\"/\");\n\n    // 处理根目录情况\n    if (mergedParts.length === 0) {\n      absolutePath = drive ? `${drive}/` : \"/\";\n    }\n\n    // 替换多个连续的斜杠为单个斜杠\n    absolutePath = absolutePath.replace(/\\/+/g, \"/\");\n\n    return absolutePath;\n  }\n\n  function splitCurrentPath(path: string) {\n    path = path.replace(/\\\\/g, \"/\");\n    let drive = \"\";\n    const driveMatch = path.match(/^([a-zA-Z]:)(\\/|$)/);\n    if (driveMatch) {\n      drive = driveMatch[1];\n      path = path.substring(drive.length);\n    }\n    const parts = path.split(\"/\").filter((p) => p !== \"\");\n    return { drive, parts };\n  }\n\n  function splitRelativePath(relativePath: string) {\n    relativePath = relativePath.replace(/\\\\/g, \"/\");\n    return relativePath.split(\"/\").filter((p) => p !== \"\");\n  }\n\n  // 辅助函数：判断字符串是否有文件扩展名\n  function hasFileExtension(filename: string): boolean {\n    if (!filename) return false;\n    // 简单的扩展名检测：包含点号且点号不在开头\n    return filename.includes(\".\") && filename.indexOf(\".\") > 0;\n  }\n\n  /**\n   * 检测一个字符串是否是一个有效的url网址\n   * 用于判断是否可以打开浏览器\n   * @param url\n   * @returns\n   */\n  export function isValidURL(url: string): boolean {\n    const trimmed = url.trim();\n    if (!trimmed) return false;\n\n    // 包含协议的正则（支持任意合法协议）\n    const protocolPattern = /^[a-z][a-z0-9+.-]*:\\/\\//i;\n\n    if (protocolPattern.test(trimmed)) {\n      // 完整URL校验（包含协议）\n      return /^[a-z][a-z0-9+.-]*:\\/\\/[^\\s/?#].[^\\s]*$/i.test(trimmed);\n    } else {\n      // 无协议时校验域名格式\n      return /^(?:(localhost|(\\d{1,3}\\.){3}\\d{1,3}|([a-z0-9-]+\\.)+[a-z]{2,})|xn--[a-z0-9]+|[\\p{L}\\p{N}-]+(\\.[\\p{L}\\p{N}-]+)+)(?::\\d+)?(?:[/?#][^\\s]*)?$/iu.test(\n        trimmed,\n      );\n    }\n  }\n\n  /**\n   * 识别一个url是否是一个markdown格式的url，并提取出内容\n   * [text](url)\n   * @param url\n   */\n  export function isMarkdownUrl(str: string): { valid: boolean; text: string; url: string } {\n    const result = { valid: false, text: \"\", url: \"\" };\n    if (typeof str !== \"string\") return result;\n    str = str.trim();\n\n    if (str.startsWith(\"[\") && str.endsWith(\")\") && str.includes(\"](\")) {\n      const parts = str.split(\"](\");\n      if (parts.length === 2) {\n        let [text, url] = parts;\n        // text 去除左侧第一个 [\n        text = text.substring(1);\n        // url 去除右侧第一个 )\n        url = url.substring(0, url.length - 1);\n        // url可能是 `http://xxx \"title\"` 的格式\n        if (url.includes(\" \")) {\n          // eslint-disable-next-line @typescript-eslint/no-unused-vars\n          const [url2, _] = url.split(\" \");\n          url = url2;\n          // title就丢掉不要了\n        }\n        if (isValidURL(url)) {\n          result.valid = true;\n          result.text = text;\n          result.url = url;\n        }\n      }\n    }\n    return result;\n  }\n\n  /**\n   * 对文件名进行安全处理，防止文件名中包含特殊字符\n   * @param fileName\n   */\n  export function fileNameSafity(fileName: string) {\n    const dangerousChars = \"\\n .<>{}[]*&^%$#@?+=`'\\\"|!~/\\\\\";\n    for (const char of dangerousChars) {\n      fileName = fileName.replaceAll(char, \"_\");\n    }\n    fileName = getShortedFileName(fileName, 20);\n    return fileName;\n  }\n}\n"
  },
  {
    "path": "app/src/utils/platform.tsx",
    "content": "import { family as osFamily, platform } from \"@tauri-apps/plugin-os\";\n\nexport const isWeb = !(\"__TAURI_OS_PLUGIN_INTERNALS__\" in window);\nexport const isMobile = isWeb ? navigator.userAgent.toLowerCase().includes(\"mobile\") : platform() === \"android\";\nexport const isDesktop = !isMobile;\n\nexport const isIpad = isWeb && navigator.userAgent.toLowerCase().includes(\"mac os\");\n\nexport const isFrame =\n  isWeb && (new URLSearchParams(window.location.search).get(\"frame\") === \"true\" || import.meta.env.LR_FRAME === \"true\");\n\nexport const isMac = !isWeb && platform() === \"macos\";\nexport const isWindows = !isWeb && platform() === \"windows\";\nexport const isLinux = !isWeb && platform() === \"linux\";\n\nexport const isTest = import.meta.env.LR_VITEST === \"true\";\n\nexport function family() {\n  if (isWeb) {\n    // 从userAgent判断unix|windows\n    const ua = navigator.userAgent.toLowerCase();\n    if (ua.includes(\"windows\")) {\n      return \"windows\";\n    } else {\n      return \"unix\";\n    }\n  } else {\n    return osFamily();\n  }\n}\n"
  },
  {
    "path": "app/src/utils/sleep.tsx",
    "content": "export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));\n"
  },
  {
    "path": "app/src/utils/store.tsx",
    "content": "import { load, Store } from \"@tauri-apps/plugin-store\";\nimport { isWeb } from \"@/utils/platform\";\n\nexport async function createStore(name: string): Promise<Store> {\n  if (isWeb) {\n    return new WebStore(name) as unknown as Store;\n  } else {\n    return load(name);\n  }\n}\n\nclass WebStore {\n  rid = 114514;\n  constructor(private name: string) {}\n\n  async clear() {\n    for (let i = 0; i < localStorage.length; i++) {\n      const key = localStorage.key(i);\n      if (key?.startsWith(`${this.name}_`)) {\n        localStorage.removeItem(key);\n      }\n    }\n  }\n  async close() {}\n  async delete(key: string) {\n    localStorage.removeItem(`${this.name}_${key}`);\n    return true;\n  }\n  async entries<T>() {\n    const result: [string, T][] = [];\n    for (let i = 0; i < localStorage.length; i++) {\n      const key = localStorage.key(i);\n      if (key?.startsWith(`${this.name}_`)) {\n        const value = localStorage.getItem(key);\n        if (value) {\n          result.push([key.slice(this.name.length + 1), JSON.parse(value)]);\n        }\n      }\n    }\n    return result;\n  }\n  async get<T>(key: string) {\n    const value = localStorage.getItem(`${this.name}_${key}`);\n    if (value) {\n      return JSON.parse(value) as T;\n    }\n    return undefined;\n  }\n  async has(key: string) {\n    return localStorage.getItem(`${this.name}_${key}`) !== null;\n  }\n  async keys() {\n    const result: string[] = [];\n    for (let i = 0; i < localStorage.length; i++) {\n      const key = localStorage.key(i);\n      if (key?.startsWith(`${this.name}_`)) {\n        result.push(key.slice(this.name.length + 1));\n      }\n    }\n    return result;\n  }\n  async length() {\n    let count = 0;\n    for (let i = 0; i < localStorage.length; i++) {\n      const key = localStorage.key(i);\n      if (key?.startsWith(`${this.name}_`)) {\n        count++;\n      }\n    }\n    return count;\n  }\n  async onChange() {\n    return () => {};\n  }\n  async onKeyChange() {\n    return () => {};\n  }\n  async reload() {}\n  async reset() {\n    for (let i = 0; i < localStorage.length; i++) {\n      const key = localStorage.key(i);\n      if (key?.startsWith(`${this.name}_`)) {\n        localStorage.removeItem(key);\n      }\n    }\n  }\n  async save() {}\n  async set(key: string, value: any) {\n    localStorage.setItem(`${this.name}_${key}`, JSON.stringify(value));\n  }\n  async values<T>() {\n    const result: T[] = [];\n    for (let i = 0; i < localStorage.length; i++) {\n      const key = localStorage.key(i);\n      if (key?.startsWith(`${this.name}_`)) {\n        const value = localStorage.getItem(key);\n        if (value) {\n          result.push(JSON.parse(value));\n        }\n      }\n    }\n    return result;\n  }\n}\n"
  },
  {
    "path": "app/src/utils/updater.tsx",
    "content": "import { check } from \"@tauri-apps/plugin-updater\";\nimport { isMobile } from \"@/utils/platform\";\n\nexport async function checkUpdate() {\n  if (isMobile) return null;\n  const update = await check();\n  return update;\n}\n"
  },
  {
    "path": "app/src/utils/xml.tsx",
    "content": "/**\n * XML类用于创建和操作XML文档。\n */\nexport class XML {\n  private readonly document: XMLDocument;\n  private readonly root: Element;\n  private current: Element;\n\n  /**\n   * 构造函数，初始化XML文档和根元素。\n   * @param namespace - XML文档的命名空间\n   */\n  constructor(namespace: string) {\n    this.document = document.implementation.createDocument(null, namespace);\n    const pi = this.document.createProcessingInstruction(\"xml\", 'version=\"1.0\" encoding=\"UTF-8\"');\n    this.document.insertBefore(pi, this.document.documentElement);\n    this.root = this.document.lastChild as Element;\n    this.current = this.root;\n  }\n\n  /**\n   * 切换到指定选择器的当前元素。\n   * @param selector - 选择器字符串\n   * @returns 当前XML对象\n   */\n  cd(selector: string) {\n    this.current = this.root.querySelector(selector) as Element;\n    return this;\n  }\n\n  /**\n   * 返回到当前元素的父节点。\n   * @returns 当前XML对象\n   */\n  up() {\n    this.current = this.current.parentNode as Element;\n    return this;\n  }\n\n  /**\n   * 为当前元素添加一个属性。\n   * @param name - 属性名称\n   * @param value - 属性值\n   * @returns 当前XML对象\n   */\n  attr(name: string, value: string) {\n    this.current.setAttribute(name, value);\n    return this;\n  }\n\n  /**\n   * 为当前元素添加多个属性。\n   * @param attributes - 属性键值对\n   * @returns 当前XML对象\n   */\n  attrs(attributes: Record<string, string>) {\n    for (const [key, value] of Object.entries(attributes)) {\n      this.current.setAttribute(key, value);\n    }\n    return this;\n  }\n\n  /**\n   * 移除当前元素的指定属性。\n   * @param name - 属性名称\n   * @returns 当前XML对象\n   */\n  rmattr(name: string) {\n    this.current.removeAttribute(name);\n    return this;\n  }\n\n  /**\n   * 添加一个新的子元素，并切换到该子元素。\n   * @param tag - 子元素的标签名\n   * @returns 当前XML对象\n   */\n  add(tag: string) {\n    const element = this.document.createElement(tag);\n    this.current.appendChild(element);\n    this.current = element;\n    return this;\n  }\n\n  /**\n   * 添加一个文本节点。\n   * @param text - 文本内容\n   * @returns 当前XML对象\n   */\n  text(text: string) {\n    this.current.textContent = text;\n    return this;\n  }\n\n  /**\n   * 将XML文档转换为字符串。\n   * @returns XML字符串\n   */\n  toString() {\n    return new XMLSerializer().serializeToString(this.document);\n  }\n\n  /**\n   * 获取当前XML文档对象。\n   * @returns XMLDocument对象\n   */\n  get xmlDocument() {\n    return this.document;\n  }\n}\n"
  },
  {
    "path": "app/src/utils/yaml.tsx",
    "content": "import YAML from \"yaml\";\n\n/**\n * 将一个yaml文件里的内容根据三横杠分割线，解析成两个部分，前者为frontmatter，后者为content。\n * 原格式：\n * ---\n * aaa\n * ---\n * bbb\n *\n * 将aaa放入frontmatter，bbb放入content。\n *\n * @param yaml\n * @returns\n */\nexport function parseYamlWithFrontmatter<F, C>(\n  yaml: string,\n): {\n  frontmatter: F;\n  content: C;\n} {\n  // const frontmatterMatch = yaml.match(/---\\n([\\s\\S]*?)\\n---/);\n  // const frontmatter = frontmatterMatch ? frontmatterMatch[1] : \"\";\n  // const content = frontmatter ? yaml.replace(/---\\n([\\s\\S]*?)\\n---/, \"\") : yaml;\n\n  yaml = yaml.trim();\n  let contentList = yaml.split(\"---\");\n  // 过滤掉空字符串\n  contentList = contentList.filter(Boolean);\n  const frontmatter = contentList[0];\n  const content = contentList[1];\n\n  return { frontmatter: YAML.parse(frontmatter), content: YAML.parse(content) };\n}\n"
  },
  {
    "path": "app/src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n/// <reference types=\"vite-plugin-svgr/client\" />\n/// <reference types=\"@modyfi/vite-plugin-yaml/modules\" />\n/// <reference types=\"unplugin-original-class-name/client\" />\n\ninterface ImportMetaEnv {\n  LR_GITHUB_CLIENT_SECRET?: string;\n  LR_API_BASE_URL?: string;\n  LR_FRAME?: string;\n  LR_VITEST?: \"true\";\n  LR_TURNSTILE_SITE_KEY?: string;\n}\n"
  },
  {
    "path": "app/src-tauri/.gitignore",
    "content": "# Generated by Cargo\n# will have compiled files and executables\n/target/\n\n# Generated by Tauri\n# will have schema files for capabilities auto-completion\n/gen/schemas\n"
  },
  {
    "path": "app/src-tauri/Cargo.toml",
    "content": "[package]\nname = \"project-graph\"\nversion = \"0.1.0\"\ndescription = \"A Tauri App\"\nauthors = [\"you\"]\nedition = \"2021\"\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[lib]\nname = \"project_graph_lib\"\ncrate-type = [\"lib\", \"cdylib\", \"staticlib\"]\n\n[build-dependencies]\ntauri-build = { version = \"2.3.0\", features = [] }\n\n[dependencies]\ntauri = { version = \"2.6.2\", features = [\"macos-private-api\", \"image-png\"] }\ntauri-plugin-shell = \"2.3.0\"\nserde = { version = \"1\", features = [\"derive\"] }\nserde_json = \"1\"\ntauri-plugin-os = \"2.3.0\"\ntauri-plugin-dialog = \"2.3.0\"\ntauri-plugin-store = \"2.3.0\"\ntauri-plugin-http = \"2\"\ntauri-plugin-clipboard-manager = \"2.3.0\"\nbase64 = \"0.22.1\"\ntauri-plugin-process = \"2\"\ntauri-plugin-fs = \"2.4.0\"\ntauri-plugin-devtools = \"2.0.0\"\ntauri-plugin-global-shortcut = \"2.3.0\"\ntauri-plugin-system-info = \"2.0.9\"\n\n[target.'cfg(not(any(target_os = \"android\", target_os = \"ios\")))'.dependencies]\ntauri-plugin-cli = \"2\"\ntauri-plugin-updater = \"2\"\ntauri-plugin-window-state = \"2\"\n\n[profile.release]\ncodegen-units = 1 # Allows LLVM to perform better optimization.\nlto = true        # Enables link-time-optimizations.\nopt-level = \"s\"   # Prioritizes small binary size. Use `3` if you prefer speed.\npanic = \"abort\"   # Higher performance by disabling panic handlers.\nstrip = true      # Ensures debug symbols are removed.\n"
  },
  {
    "path": "app/src-tauri/build.rs",
    "content": "fn main() {\n    tauri_build::build()\n}\n"
  },
  {
    "path": "app/src-tauri/capabilities/default.json",
    "content": "{\n  \"$schema\": \"../gen/schemas/desktop-schema.json\",\n  \"identifier\": \"default\",\n  \"description\": \"Capability for the main window\",\n  \"windows\": [\"main\"],\n  \"permissions\": [\n    \"core:default\",\n    \"shell:allow-open\",\n    \"core:window:allow-close\",\n    \"core:window:allow-minimize\",\n    \"core:window:allow-is-maximized\",\n    \"core:window:allow-set-focus\",\n    \"core:window:allow-unmaximize\",\n    \"core:window:allow-maximize\",\n    \"core:window:allow-start-dragging\",\n    \"core:window:allow-is-fullscreen\",\n    \"core:window:allow-set-fullscreen\",\n    \"core:window:allow-set-title\",\n    \"core:window:allow-set-decorations\",\n    \"core:window:allow-destroy\",\n    \"core:window:allow-hide\",\n    \"core:window:allow-show\",\n    \"core:window:allow-set-skip-taskbar\",\n    \"core:window:allow-set-size\",\n    \"core:window:allow-set-always-on-top\",\n    \"core:window:allow-set-ignore-cursor-events\",\n    \"core:webview:deny-set-webview-zoom\",\n    \"clipboard-manager:allow-clear\",\n    \"clipboard-manager:allow-read-image\",\n    \"clipboard-manager:allow-write-text\",\n    \"clipboard-manager:allow-write-image\",\n    \"clipboard-manager:allow-read-text\",\n    \"clipboard-manager:allow-write-html\",\n    \"os:default\",\n    \"dialog:default\",\n    \"shell:default\",\n    \"store:default\",\n    \"global-shortcut:allow-is-registered\",\n    \"global-shortcut:allow-register\",\n    \"global-shortcut:allow-unregister\",\n    \"global-shortcut:allow-unregister-all\",\n    \"system-info:allow-all\",\n    {\n      \"identifier\": \"http:default\",\n      \"allow\": [\n        {\n          \"url\": \"*://**:*/**\"\n        }\n      ]\n    },\n    \"fs:default\",\n    \"fs:read-all\",\n    \"fs:write-all\",\n    \"fs:allow-rename\",\n    \"fs:allow-mkdir\",\n    \"fs:allow-exists\",\n    \"fs:allow-watch\",\n    \"fs:read-dirs\",\n    {\n      \"identifier\": \"fs:scope\",\n      \"allow\": [\"**/*\"]\n    }\n  ]\n}\n"
  },
  {
    "path": "app/src-tauri/capabilities/desktop.json",
    "content": "{\n  \"identifier\": \"desktop-capability\",\n  \"platforms\": [\"macOS\", \"windows\", \"linux\"],\n  \"windows\": [\"main\"],\n  \"permissions\": [\"cli:default\", \"updater:default\", \"window-state:default\"]\n}\n"
  },
  {
    "path": "app/src-tauri/gen/android/.editorconfig",
    "content": "# EditorConfig is awesome: https://EditorConfig.org\n\n# top-most EditorConfig file\nroot = true\n\n[*]\nindent_style = space\nindent_size = 2\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = false\ninsert_final_newline = false"
  },
  {
    "path": "app/src-tauri/gen/android/.gitignore",
    "content": "*.iml\n.gradle\n/local.properties\n/.idea/caches\n/.idea/libraries\n/.idea/modules.xml\n/.idea/workspace.xml\n/.idea/navEditor.xml\n/.idea/assetWizardSettings.xml\n.DS_Store\nbuild\n/captures\n.externalNativeBuild\n.cxx\nlocal.properties\nkey.properties\n\n/.tauri\n/tauri.settings.gradle"
  },
  {
    "path": "app/src-tauri/gen/android/app/.gitignore",
    "content": "/src/main/java/liren/project_graph/generated\n/src/main/jniLibs/**/*.so\n/src/main/assets/tauri.conf.json\n/tauri.build.gradle.kts\n/proguard-tauri.pro\n/tauri.properties"
  },
  {
    "path": "app/src-tauri/gen/android/app/build.gradle.kts",
    "content": "import java.io.FileInputStream\nimport java.util.Properties\n\nplugins {\n    id(\"com.android.application\")\n    id(\"org.jetbrains.kotlin.android\")\n    id(\"rust\")\n}\n\nval tauriProperties =\n        Properties().apply {\n            val propFile = file(\"tauri.properties\")\n            if (propFile.exists()) {\n                propFile.inputStream().use { load(it) }\n            }\n        }\n\nandroid {\n    compileSdk = 34\n    namespace = \"liren.project_graph\"\n    defaultConfig {\n        manifestPlaceholders[\"usesCleartextTraffic\"] = \"false\"\n        applicationId = \"liren.project_graph\"\n        minSdk = 24\n        targetSdk = 34\n        versionCode = tauriProperties.getProperty(\"tauri.android.versionCode\", \"1\").toInt()\n        versionName = tauriProperties.getProperty(\"tauri.android.versionName\", \"1.0\")\n    }\n    signingConfigs {\n        create(\"release\") {\n            val keystorePropertiesFile = rootProject.file(\"keystore.properties\")\n            val keystoreProperties = Properties()\n            if (keystorePropertiesFile.exists()) {\n                keystoreProperties.load(FileInputStream(keystorePropertiesFile))\n            }\n\n            keyAlias = keystoreProperties[\"keyAlias\"] as String\n            keyPassword = keystoreProperties[\"password\"] as String\n            storeFile = file(keystoreProperties[\"storeFile\"] as String)\n            storePassword = keystoreProperties[\"password\"] as String\n        }\n    }\n    buildTypes {\n        getByName(\"debug\") {\n            manifestPlaceholders[\"usesCleartextTraffic\"] = \"true\"\n            isDebuggable = true\n            isJniDebuggable = true\n            isMinifyEnabled = false\n            packaging {\n                jniLibs.keepDebugSymbols.add(\"*/arm64-v8a/*.so\")\n                jniLibs.keepDebugSymbols.add(\"*/armeabi-v7a/*.so\")\n                jniLibs.keepDebugSymbols.add(\"*/x86/*.so\")\n                jniLibs.keepDebugSymbols.add(\"*/x86_64/*.so\")\n            }\n        }\n        getByName(\"release\") {\n            signingConfig = signingConfigs.getByName(\"release\")\n            isMinifyEnabled = true\n            proguardFiles(\n                    *fileTree(\".\") { include(\"**/*.pro\") }\n                            .plus(getDefaultProguardFile(\"proguard-android-optimize.txt\"))\n                            .toList()\n                            .toTypedArray()\n            )\n        }\n    }\n    kotlinOptions { jvmTarget = \"1.8\" }\n    buildFeatures { buildConfig = true }\n}\n\nrust { rootDirRel = \"../../../\" }\n\ndependencies {\n    implementation(\"androidx.webkit:webkit:1.6.1\")\n    implementation(\"androidx.appcompat:appcompat:1.6.1\")\n    implementation(\"com.google.android.material:material:1.8.0\")\n    testImplementation(\"junit:junit:4.13.2\")\n    androidTestImplementation(\"androidx.test.ext:junit:1.1.4\")\n    androidTestImplementation(\"androidx.test.espresso:espresso-core:3.5.0\")\n}\n\napply(from = \"tauri.build.gradle.kts\")\n"
  },
  {
    "path": "app/src-tauri/gen/android/app/proguard-rules.pro",
    "content": "# Add project specific ProGuard rules here.\n# You can control the set of applied configuration files using the\n# proguardFiles setting in build.gradle.\n#\n# For more details, see\n#   http://developer.android.com/guide/developing/tools/proguard.html\n\n# If your project uses WebView with JS, uncomment the following\n# and specify the fully qualified class name to the JavaScript interface\n# class:\n#-keepclassmembers class fqcn.of.javascript.interface.for.webview {\n#   public *;\n#}\n\n# Uncomment this to preserve the line number information for\n# debugging stack traces.\n#-keepattributes SourceFile,LineNumberTable\n\n# If you keep the line number information, uncomment this to\n# hide the original source file name.\n#-renamesourcefileattribute SourceFile"
  },
  {
    "path": "app/src-tauri/gen/android/app/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <uses-permission android:name=\"android.permission.INTERNET\" />\n\n    <!-- AndroidTV support -->\n    <uses-feature android:name=\"android.software.leanback\" android:required=\"false\" />\n\n    <application\n        android:icon=\"@mipmap/ic_launcher\"\n        android:label=\"@string/app_name\"\n        android:theme=\"@style/AppTheme.FullScreen\"\n        android:usesCleartextTraffic=\"${usesCleartextTraffic}\">\n        <activity\n            android:screenOrientation=\"landscape\"\n            android:configChanges=\"orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode\"\n            android:launchMode=\"singleTask\"\n            android:label=\"@string/main_activity_title\"\n            android:name=\".MainActivity\"\n            android:exported=\"true\">\n            <intent-filter>\n                <action android:name=\"android.intent.action.MAIN\" />\n                <category android:name=\"android.intent.category.LAUNCHER\" />\n                <!-- AndroidTV support -->\n                <category android:name=\"android.intent.category.LEANBACK_LAUNCHER\" />\n            </intent-filter>\n        </activity>\n\n        <provider\n          android:name=\"androidx.core.content.FileProvider\"\n          android:authorities=\"${applicationId}.fileprovider\"\n          android:exported=\"false\"\n          android:grantUriPermissions=\"true\">\n          <meta-data\n            android:name=\"android.support.FILE_PROVIDER_PATHS\"\n            android:resource=\"@xml/file_paths\" />\n        </provider>\n    </application>\n</manifest>\n"
  },
  {
    "path": "app/src-tauri/gen/android/app/src/main/java/liren/project_graph/MainActivity.kt",
    "content": "package liren.project_graph\n\nimport android.os.Build\nimport android.os.Bundle\nimport android.view.WindowInsets\nimport android.view.WindowInsetsController\nimport android.view.WindowManager\nimport androidx.core.view.WindowCompat\n\nclass MainActivity : TauriActivity() {\n  override fun onCreate(savedInstanceState: Bundle?) {\n    super.onCreate(savedInstanceState)\n\n    // 设置布局内容（确保在调用窗口相关设置之前）\n    setContentView(R.layout.activity_main)\n\n    // 全屏显示应用内容\n    WindowCompat.setDecorFitsSystemWindows(window, false)\n\n    // 隐藏系统栏 (状态栏和导航栏)\n    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {\n      window.decorView.windowInsetsController?.let { controller ->\n        controller.hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars())\n        controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE\n      }\n    }\n\n    // 防止在沉浸模式下屏幕变暗\n    window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)\n  }\n}\n"
  },
  {
    "path": "app/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:width=\"108dp\"\n    android:height=\"108dp\"\n    android:viewportWidth=\"108\"\n    android:viewportHeight=\"108\">\n    <path\n        android:fillColor=\"#3DDC84\"\n        android:pathData=\"M0,0h108v108h-108z\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M9,0L9,108\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M19,0L19,108\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M29,0L29,108\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M39,0L39,108\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M49,0L49,108\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M59,0L59,108\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M69,0L69,108\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M79,0L79,108\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M89,0L89,108\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M99,0L99,108\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M0,9L108,9\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M0,19L108,19\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M0,29L108,29\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M0,39L108,39\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M0,49L108,49\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M0,59L108,59\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M0,69L108,69\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M0,79L108,79\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M0,89L108,89\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M0,99L108,99\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M19,29L89,29\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M19,39L89,39\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M19,49L89,49\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M19,59L89,59\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M19,69L89,69\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M19,79L89,79\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M29,19L29,89\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M39,19L39,89\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M49,19L49,89\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M59,19L59,89\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M69,19L69,89\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n    <path\n        android:fillColor=\"#00000000\"\n        android:pathData=\"M79,19L79,89\"\n        android:strokeWidth=\"0.8\"\n        android:strokeColor=\"#33FFFFFF\" />\n</vector>\n"
  },
  {
    "path": "app/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml",
    "content": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    xmlns:aapt=\"http://schemas.android.com/aapt\"\n    android:width=\"108dp\"\n    android:height=\"108dp\"\n    android:viewportWidth=\"108\"\n    android:viewportHeight=\"108\">\n    <path android:pathData=\"M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z\">\n        <aapt:attr name=\"android:fillColor\">\n            <gradient\n                android:endX=\"85.84757\"\n                android:endY=\"92.4963\"\n                android:startX=\"42.9492\"\n                android:startY=\"49.59793\"\n                android:type=\"linear\">\n                <item\n                    android:color=\"#44000000\"\n                    android:offset=\"0.0\" />\n                <item\n                    android:color=\"#00000000\"\n                    android:offset=\"1.0\" />\n            </gradient>\n        </aapt:attr>\n    </path>\n    <path\n        android:fillColor=\"#FFFFFF\"\n        android:fillType=\"nonZero\"\n        android:pathData=\"M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z\"\n        android:strokeWidth=\"1\"\n        android:strokeColor=\"#00000000\" />\n</vector>"
  },
  {
    "path": "app/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<androidx.constraintlayout.widget.ConstraintLayout xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    xmlns:app=\"http://schemas.android.com/apk/res-auto\"\n    xmlns:tools=\"http://schemas.android.com/tools\"\n    android:layout_width=\"match_parent\"\n    android:layout_height=\"match_parent\"\n    tools:context=\".MainActivity\">\n\n    <TextView\n        android:layout_width=\"wrap_content\"\n        android:layout_height=\"wrap_content\"\n        android:text=\"Loading\"\n        app:layout_constraintBottom_toBottomOf=\"parent\"\n        app:layout_constraintLeft_toLeftOf=\"parent\"\n        app:layout_constraintRight_toRightOf=\"parent\"\n        app:layout_constraintTop_toTopOf=\"parent\" />\n\n</androidx.constraintlayout.widget.ConstraintLayout>"
  },
  {
    "path": "app/src-tauri/gen/android/app/src/main/res/value-night/styles.xml",
    "content": "<resources>\n    <style name=\"AppTheme.FullScreen\" parent=\"Theme.AppCompat.DayNight.NoActionBar\">\n        <item name=\"android:windowFullscreen\">true</item>\n        <item name=\"android:windowLayoutInDisplayCutoutMode\">shortEdges</item>\n        <item name=\"android:windowActionBar\">false</item>\n        <item name=\"android:windowNoTitle\">true</item>\n        <item name=\"android:windowTranslucentNavigation\">true</item>\n        <item name=\"android:navigationBarColor\">@android:color/transparent</item>\n    </style>\n</resources>\n"
  },
  {
    "path": "app/src-tauri/gen/android/app/src/main/res/values/colors.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n    <color name=\"purple_200\">#FFBB86FC</color>\n    <color name=\"purple_500\">#FF6200EE</color>\n    <color name=\"purple_700\">#FF3700B3</color>\n    <color name=\"teal_200\">#FF03DAC5</color>\n    <color name=\"teal_700\">#FF018786</color>\n    <color name=\"black\">#FF000000</color>\n    <color name=\"white\">#FFFFFFFF</color>\n</resources>"
  },
  {
    "path": "app/src-tauri/gen/android/app/src/main/res/values/strings.xml",
    "content": "<resources>\n    <string name=\"app_name\">Project Graph</string>\n    <string name=\"main_activity_title\">Project Graph</string>\n</resources>"
  },
  {
    "path": "app/src-tauri/gen/android/app/src/main/res/values/styles.xml",
    "content": "<resources>\n    <style name=\"AppTheme.FullScreen\" parent=\"Theme.AppCompat.DayNight.NoActionBar\">\n        <item name=\"android:windowFullscreen\">true</item>\n        <item name=\"android:windowLayoutInDisplayCutoutMode\">shortEdges</item>\n        <item name=\"android:windowActionBar\">false</item>\n        <item name=\"android:windowNoTitle\">true</item>\n        <item name=\"android:windowTranslucentNavigation\">true</item>\n        <item name=\"android:navigationBarColor\">@android:color/transparent</item>\n    </style>\n</resources>\n"
  },
  {
    "path": "app/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<paths xmlns:android=\"http://schemas.android.com/apk/res/android\">\n  <external-path name=\"my_images\" path=\".\" />\n  <cache-path name=\"my_cache_images\" path=\".\" />\n</paths>\n"
  },
  {
    "path": "app/src-tauri/gen/android/build.gradle.kts",
    "content": "buildscript {\n    repositories {\n        google()\n        mavenCentral()\n    }\n    dependencies {\n        classpath(\"com.android.tools.build:gradle:8.5.1\")\n        classpath(\"org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25\")\n    }\n}\n\nallprojects {\n    repositories {\n        google()\n        mavenCentral()\n    }\n}\n\ntasks.register(\"clean\").configure {\n    delete(\"build\")\n}\n\n"
  },
  {
    "path": "app/src-tauri/gen/android/buildSrc/build.gradle.kts",
    "content": "plugins {\n    `kotlin-dsl`\n}\n\ngradlePlugin {\n    plugins {\n        create(\"pluginsForCoolKids\") {\n            id = \"rust\"\n            implementationClass = \"RustPlugin\"\n        }\n    }\n}\n\nrepositories {\n    google()\n    mavenCentral()\n}\n\ndependencies {\n    compileOnly(gradleApi())\n    implementation(\"com.android.tools.build:gradle:8.5.1\")\n}\n\n"
  },
  {
    "path": "app/src-tauri/gen/android/buildSrc/src/main/java/liren/project_graph/kotlin/BuildTask.kt",
    "content": "import java.io.File\nimport org.apache.tools.ant.taskdefs.condition.Os\nimport org.gradle.api.DefaultTask\nimport org.gradle.api.GradleException\nimport org.gradle.api.logging.LogLevel\nimport org.gradle.api.tasks.Input\nimport org.gradle.api.tasks.TaskAction\n\nopen class BuildTask : DefaultTask() {\n    @Input\n    var rootDirRel: String? = null\n    @Input\n    var target: String? = null\n    @Input\n    var release: Boolean? = null\n\n    @TaskAction\n    fun assemble() {\n        val executable = \"\"\"pnpm\"\"\";\n        try {\n            runTauriCli(executable)\n        } catch (e: Exception) {\n            if (Os.isFamily(Os.FAMILY_WINDOWS)) {\n                runTauriCli(\"$executable.cmd\")\n            } else {\n                throw e;\n            }\n        }\n    }\n\n    fun runTauriCli(executable: String) {\n        val rootDirRel = rootDirRel ?: throw GradleException(\"rootDirRel cannot be null\")\n        val target = target ?: throw GradleException(\"target cannot be null\")\n        val release = release ?: throw GradleException(\"release cannot be null\")\n        val args = listOf(\"tauri\", \"android\", \"android-studio-script\");\n\n        project.exec {\n            workingDir(File(project.projectDir, rootDirRel))\n            executable(executable)\n            args(args)\n            if (project.logger.isEnabled(LogLevel.DEBUG)) {\n                args(\"-vv\")\n            } else if (project.logger.isEnabled(LogLevel.INFO)) {\n                args(\"-v\")\n            }\n            if (release) {\n                args(\"--release\")\n            }\n            args(listOf(\"--target\", target))\n        }.assertNormalExitValue()\n    }\n}"
  },
  {
    "path": "app/src-tauri/gen/android/buildSrc/src/main/java/liren/project_graph/kotlin/RustPlugin.kt",
    "content": "import com.android.build.api.dsl.ApplicationExtension\nimport org.gradle.api.DefaultTask\nimport org.gradle.api.Plugin\nimport org.gradle.api.Project\nimport org.gradle.kotlin.dsl.configure\nimport org.gradle.kotlin.dsl.get\n\nconst val TASK_GROUP = \"rust\"\n\nopen class Config {\n    lateinit var rootDirRel: String\n}\n\nopen class RustPlugin : Plugin<Project> {\n    private lateinit var config: Config\n\n    override fun apply(project: Project) = with(project) {\n        config = extensions.create(\"rust\", Config::class.java)\n\n        val defaultAbiList = listOf(\"arm64-v8a\", \"armeabi-v7a\", \"x86\", \"x86_64\");\n        val abiList = (findProperty(\"abiList\") as? String)?.split(',') ?: defaultAbiList\n\n        val defaultArchList = listOf(\"arm64\", \"arm\", \"x86\", \"x86_64\");\n        val archList = (findProperty(\"archList\") as? String)?.split(',') ?: defaultArchList\n\n        val targetsList = (findProperty(\"targetList\") as? String)?.split(',') ?: listOf(\"aarch64\", \"armv7\", \"i686\", \"x86_64\")\n\n        extensions.configure<ApplicationExtension> {\n            @Suppress(\"UnstableApiUsage\")\n            flavorDimensions.add(\"abi\")\n            productFlavors {\n                create(\"universal\") {\n                    dimension = \"abi\"\n                    ndk {\n                        abiFilters += abiList\n                    }\n                }\n                defaultArchList.forEachIndexed { index, arch ->\n                    create(arch) {\n                        dimension = \"abi\"\n                        ndk {\n                            abiFilters.add(defaultAbiList[index])\n                        }\n                    }\n                }\n            }\n        }\n\n        afterEvaluate {\n            for (profile in listOf(\"debug\", \"release\")) {\n                val profileCapitalized = profile.replaceFirstChar { it.uppercase() }\n                val buildTask = tasks.maybeCreate(\n                    \"rustBuildUniversal$profileCapitalized\",\n                    DefaultTask::class.java\n                ).apply {\n                    group = TASK_GROUP\n                    description = \"Build dynamic library in $profile mode for all targets\"\n                }\n\n                tasks[\"mergeUniversal${profileCapitalized}JniLibFolders\"].dependsOn(buildTask)\n\n                for (targetPair in targetsList.withIndex()) {\n                    val targetName = targetPair.value\n                    val targetArch = archList[targetPair.index]\n                    val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() }\n                    val targetBuildTask = project.tasks.maybeCreate(\n                        \"rustBuild$targetArchCapitalized$profileCapitalized\",\n                        BuildTask::class.java\n                    ).apply {\n                        group = TASK_GROUP\n                        description = \"Build dynamic library in $profile mode for $targetArch\"\n                        rootDirRel = config.rootDirRel\n                        target = targetName\n                        release = profile == \"release\"\n                    }\n\n                    buildTask.dependsOn(targetBuildTask)\n                    tasks[\"merge$targetArchCapitalized${profileCapitalized}JniLibFolders\"].dependsOn(\n                        targetBuildTask\n                    )\n                }\n            }\n        }\n    }\n}"
  },
  {
    "path": "app/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties",
    "content": "#Tue May 10 19:22:52 CST 2022\ndistributionBase=GRADLE_USER_HOME\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-8.9-bin.zip\ndistributionPath=wrapper/dists\nzipStorePath=wrapper/dists\nzipStoreBase=GRADLE_USER_HOME\n"
  },
  {
    "path": "app/src-tauri/gen/android/gradle.properties",
    "content": "# Project-wide Gradle settings.\n# IDE (e.g. Android Studio) users:\n# Gradle settings configured through the IDE *will override*\n# any settings specified in this file.\n# For more details on how to configure your build environment visit\n# http://www.gradle.org/docs/current/userguide/build_environment.html\n# Specifies the JVM arguments used for the daemon process.\n# The setting is particularly useful for tweaking memory settings.\norg.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8\n# When configured, Gradle will run in incubating parallel mode.\n# This option should only be used with decoupled projects. More details, visit\n# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects\n# org.gradle.parallel=true\n# AndroidX package structure to make it clearer which packages are bundled with the\n# Android operating system, and which are packaged with your app\"s APK\n# https://developer.android.com/topic/libraries/support-library/androidx-rn\nandroid.useAndroidX=true\n# Kotlin code style for this project: \"official\" or \"obsolete\":\nkotlin.code.style=official\n# Enables namespacing of each library's R class so that its R class includes only the\n# resources declared in the library itself and none from the library's dependencies,\n# thereby reducing the size of the R class for that library\nandroid.nonTransitiveRClass=true\nandroid.nonFinalResIds=false"
  },
  {
    "path": "app/src-tauri/gen/android/gradlew",
    "content": "#!/usr/bin/env sh\n\n#\n# Copyright 2015 the original author or authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      https://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n##############################################################################\n##\n##  Gradle start up script for UN*X\n##\n##############################################################################\n\n# Attempt to set APP_HOME\n# Resolve links: $0 may be a link\nPRG=\"$0\"\n# Need this for relative symlinks.\nwhile [ -h \"$PRG\" ] ; do\n    ls=`ls -ld \"$PRG\"`\n    link=`expr \"$ls\" : '.*-> \\(.*\\)$'`\n    if expr \"$link\" : '/.*' > /dev/null; then\n        PRG=\"$link\"\n    else\n        PRG=`dirname \"$PRG\"`\"/$link\"\n    fi\ndone\nSAVED=\"`pwd`\"\ncd \"`dirname \\\"$PRG\\\"`/\" >/dev/null\nAPP_HOME=\"`pwd -P`\"\ncd \"$SAVED\" >/dev/null\n\nAPP_NAME=\"Gradle\"\nAPP_BASE_NAME=`basename \"$0\"`\n\n# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\nDEFAULT_JVM_OPTS='\"-Xmx64m\" \"-Xms64m\"'\n\n# Use the maximum available, or set MAX_FD != -1 to use that value.\nMAX_FD=\"maximum\"\n\nwarn () {\n    echo \"$*\"\n}\n\ndie () {\n    echo\n    echo \"$*\"\n    echo\n    exit 1\n}\n\n# OS specific support (must be 'true' or 'false').\ncygwin=false\nmsys=false\ndarwin=false\nnonstop=false\ncase \"`uname`\" in\n  CYGWIN* )\n    cygwin=true\n    ;;\n  Darwin* )\n    darwin=true\n    ;;\n  MINGW* )\n    msys=true\n    ;;\n  NONSTOP* )\n    nonstop=true\n    ;;\nesac\n\nCLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar\n\n\n# Determine the Java command to use to start the JVM.\nif [ -n \"$JAVA_HOME\" ] ; then\n    if [ -x \"$JAVA_HOME/jre/sh/java\" ] ; then\n        # IBM's JDK on AIX uses strange locations for the executables\n        JAVACMD=\"$JAVA_HOME/jre/sh/java\"\n    else\n        JAVACMD=\"$JAVA_HOME/bin/java\"\n    fi\n    if [ ! -x \"$JAVACMD\" ] ; then\n        die \"ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\n    fi\nelse\n    JAVACMD=\"java\"\n    which java >/dev/null 2>&1 || die \"ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\nfi\n\n# Increase the maximum file descriptors if we can.\nif [ \"$cygwin\" = \"false\" -a \"$darwin\" = \"false\" -a \"$nonstop\" = \"false\" ] ; then\n    MAX_FD_LIMIT=`ulimit -H -n`\n    if [ $? -eq 0 ] ; then\n        if [ \"$MAX_FD\" = \"maximum\" -o \"$MAX_FD\" = \"max\" ] ; then\n            MAX_FD=\"$MAX_FD_LIMIT\"\n        fi\n        ulimit -n $MAX_FD\n        if [ $? -ne 0 ] ; then\n            warn \"Could not set maximum file descriptor limit: $MAX_FD\"\n        fi\n    else\n        warn \"Could not query maximum file descriptor limit: $MAX_FD_LIMIT\"\n    fi\nfi\n\n# For Darwin, add options to specify how the application appears in the dock\nif $darwin; then\n    GRADLE_OPTS=\"$GRADLE_OPTS \\\"-Xdock:name=$APP_NAME\\\" \\\"-Xdock:icon=$APP_HOME/media/gradle.icns\\\"\"\nfi\n\n# For Cygwin or MSYS, switch paths to Windows format before running java\nif [ \"$cygwin\" = \"true\" -o \"$msys\" = \"true\" ] ; then\n    APP_HOME=`cygpath --path --mixed \"$APP_HOME\"`\n    CLASSPATH=`cygpath --path --mixed \"$CLASSPATH\"`\n\n    JAVACMD=`cygpath --unix \"$JAVACMD\"`\n\n    # We build the pattern for arguments to be converted via cygpath\n    ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`\n    SEP=\"\"\n    for dir in $ROOTDIRSRAW ; do\n        ROOTDIRS=\"$ROOTDIRS$SEP$dir\"\n        SEP=\"|\"\n    done\n    OURCYGPATTERN=\"(^($ROOTDIRS))\"\n    # Add a user-defined pattern to the cygpath arguments\n    if [ \"$GRADLE_CYGPATTERN\" != \"\" ] ; then\n        OURCYGPATTERN=\"$OURCYGPATTERN|($GRADLE_CYGPATTERN)\"\n    fi\n    # Now convert the arguments - kludge to limit ourselves to /bin/sh\n    i=0\n    for arg in \"$@\" ; do\n        CHECK=`echo \"$arg\"|egrep -c \"$OURCYGPATTERN\" -`\n        CHECK2=`echo \"$arg\"|egrep -c \"^-\"`                                 ### Determine if an option\n\n        if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then                    ### Added a condition\n            eval `echo args$i`=`cygpath --path --ignore --mixed \"$arg\"`\n        else\n            eval `echo args$i`=\"\\\"$arg\\\"\"\n        fi\n        i=`expr $i + 1`\n    done\n    case $i in\n        0) set -- ;;\n        1) set -- \"$args0\" ;;\n        2) set -- \"$args0\" \"$args1\" ;;\n        3) set -- \"$args0\" \"$args1\" \"$args2\" ;;\n        4) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" ;;\n        5) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" \"$args4\" ;;\n        6) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" \"$args4\" \"$args5\" ;;\n        7) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" \"$args4\" \"$args5\" \"$args6\" ;;\n        8) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" \"$args4\" \"$args5\" \"$args6\" \"$args7\" ;;\n        9) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" \"$args4\" \"$args5\" \"$args6\" \"$args7\" \"$args8\" ;;\n    esac\nfi\n\n# Escape application args\nsave () {\n    for i do printf %s\\\\n \"$i\" | sed \"s/'/'\\\\\\\\''/g;1s/^/'/;\\$s/\\$/' \\\\\\\\/\" ; done\n    echo \" \"\n}\nAPP_ARGS=`save \"$@\"`\n\n# Collect all arguments for the java command, following the shell quoting and substitution rules\neval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS \"\\\"-Dorg.gradle.appname=$APP_BASE_NAME\\\"\" -classpath \"\\\"$CLASSPATH\\\"\" org.gradle.wrapper.GradleWrapperMain \"$APP_ARGS\"\n\nexec \"$JAVACMD\" \"$@\"\n"
  },
  {
    "path": "app/src-tauri/gen/android/gradlew.bat",
    "content": "@rem\n@rem Copyright 2015 the original author or authors.\n@rem\n@rem Licensed under the Apache License, Version 2.0 (the \"License\");\n@rem you may not use this file except in compliance with the License.\n@rem You may obtain a copy of the License at\n@rem\n@rem      https://www.apache.org/licenses/LICENSE-2.0\n@rem\n@rem Unless required by applicable law or agreed to in writing, software\n@rem distributed under the License is distributed on an \"AS IS\" BASIS,\n@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n@rem See the License for the specific language governing permissions and\n@rem limitations under the License.\n@rem\n\n@if \"%DEBUG%\" == \"\" @echo off\n@rem ##########################################################################\n@rem\n@rem  Gradle startup script for Windows\n@rem\n@rem ##########################################################################\n\n@rem Set local scope for the variables with windows NT shell\nif \"%OS%\"==\"Windows_NT\" setlocal\n\nset DIRNAME=%~dp0\nif \"%DIRNAME%\" == \"\" set DIRNAME=.\nset APP_BASE_NAME=%~n0\nset APP_HOME=%DIRNAME%\n\n@rem Resolve any \".\" and \"..\" in APP_HOME to make it shorter.\nfor %%i in (\"%APP_HOME%\") do set APP_HOME=%%~fi\n\n@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\nset DEFAULT_JVM_OPTS=\"-Xmx64m\" \"-Xms64m\"\n\n@rem Find java.exe\nif defined JAVA_HOME goto findJavaFromJavaHome\n\nset JAVA_EXE=java.exe\n%JAVA_EXE% -version >NUL 2>&1\nif \"%ERRORLEVEL%\" == \"0\" goto execute\n\necho.\necho ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.\necho.\necho Please set the JAVA_HOME variable in your environment to match the\necho location of your Java installation.\n\ngoto fail\n\n:findJavaFromJavaHome\nset JAVA_HOME=%JAVA_HOME:\"=%\nset JAVA_EXE=%JAVA_HOME%/bin/java.exe\n\nif exist \"%JAVA_EXE%\" goto execute\n\necho.\necho ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%\necho.\necho Please set the JAVA_HOME variable in your environment to match the\necho location of your Java installation.\n\ngoto fail\n\n:execute\n@rem Setup the command line\n\nset CLASSPATH=%APP_HOME%\\gradle\\wrapper\\gradle-wrapper.jar\n\n\n@rem Execute Gradle\n\"%JAVA_EXE%\" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% \"-Dorg.gradle.appname=%APP_BASE_NAME%\" -classpath \"%CLASSPATH%\" org.gradle.wrapper.GradleWrapperMain %*\n\n:end\n@rem End local scope for the variables with windows NT shell\nif \"%ERRORLEVEL%\"==\"0\" goto mainEnd\n\n:fail\nrem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of\nrem the _cmd.exe /c_ return code!\nif  not \"\" == \"%GRADLE_EXIT_CONSOLE%\" exit 1\nexit /b 1\n\n:mainEnd\nif \"%OS%\"==\"Windows_NT\" endlocal\n\n:omega\n"
  },
  {
    "path": "app/src-tauri/gen/android/keystore.properties",
    "content": "password=LiRenAndroid\nkeyAlias=upload\nstoreFile=/home/zty/upload.jks"
  },
  {
    "path": "app/src-tauri/gen/android/settings.gradle",
    "content": "include ':app'\n\napply from: 'tauri.settings.gradle'\n"
  },
  {
    "path": "app/src-tauri/nsis/installer.nsh",
    "content": "!macro NSIS_HOOK_POSTINSTALL\n    nsExec::ExecToLog 'powershell -Command \"$p=[Environment]::GetEnvironmentVariable(\\\"PATH\\\",\\\"User\\\"); if($p -notlike \\\"*$INSTDIR*\\\") { setx PATH \\\"$p;$INSTDIR\\\" }\"'\n!macroend\n\n!macro NSIS_HOOK_POSTUNINSTALL\n    nsExec::ExecToLog 'powershell -Command \"$p=[Environment]::GetEnvironmentVariable(\\\"PATH\\\",\\\"User\\\"); setx PATH ($p -replace [regex]::Escape(\\\"$INSTDIR\\\"),\\\"\\\")\"'\n!macroend"
  },
  {
    "path": "app/src-tauri/src/cmd/device.rs",
    "content": "use std::{io::Read, process::Command};\n\n#[cfg(target_os = \"windows\")]\nuse std::os::windows::process::CommandExt;\n\n#[tauri::command]\n#[cfg(target_os = \"windows\")]\npub fn get_device_id() -> Result<String, String> {\n    let output = Command::new(\"wmic\")\n        .arg(\"csproduct\")\n        .arg(\"get\")\n        .arg(\"uuid\")\n        .creation_flags(0x08000000) // CREATE_NO_WINDOW\n        .output()\n        .map_err(|e| format!(\"Failed to execute wmic: {e}\"))?;\n    let stdout = String::from_utf8_lossy(&output.stdout);\n    let uuid = stdout.trim().lines().last().unwrap_or(\"\").to_string();\n    Ok(uuid)\n}\n\n#[tauri::command]\n#[cfg(target_os = \"macos\")]\npub fn get_device_id() -> Result<String, String> {\n    let output = Command::new(\"system_profiler\")\n        .arg(\"SPHardwareDataType\")\n        .output()\n        .map_err(|e| format!(\"Failed to execute system_profiler: {e}\"))?;\n    if !output.status.success() {\n        return Err(\"Failed to get device id\".to_string());\n    }\n    let stdout = String::from_utf8_lossy(&output.stdout);\n    for line in stdout.lines() {\n        if line.trim().starts_with(\"Hardware UUID\") {\n            let parts: Vec<&str> = line.split(':').collect();\n            if parts.len() > 1 {\n                let uuid = parts[1].trim();\n                return Ok(uuid.to_string());\n            }\n        }\n    }\n    Err(\"Failed to get device id\".to_string())\n}\n\n#[tauri::command]\n#[cfg(target_os = \"linux\")]\npub fn get_device_id() -> Result<String, String> {\n    let mut file = std::fs::File::open(\"/etc/machine-id\")\n        .map_err(|e| format!(\"Failed to open /etc/machine-id: {e}\"))?;\n    let mut contents = String::new();\n    file.read_to_string(&mut contents)\n        .map_err(|e| format!(\"Failed to read /etc/machine-id: {e}\"))?;\n    Ok(contents.trim().to_string())\n}\n\n#[tauri::command]\n#[cfg(not(any(target_os = \"windows\", target_os = \"macos\", target_os = \"linux\")))]\npub fn get_device_id() -> Result<String, String> {\n    Err(\"Unsupported platform\".to_string())\n}\n"
  },
  {
    "path": "app/src-tauri/src/cmd/fs.rs",
    "content": "use serde::{Deserialize, Serialize};\nuse std::io::Read;\n\nuse base64::engine::general_purpose;\nuse base64::Engine;\n\n#[derive(Debug, Serialize, Deserialize)]\npub struct FolderEntry {\n    name: String,\n    is_file: bool,\n    path: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    children: Option<Vec<FolderEntry>>,\n}\n\n/// 递归读取文件夹结构，返回嵌套的文件夹结构\n#[tauri::command]\npub fn read_folder_structure(path: String) -> FolderEntry {\n    let path_buf = std::path::PathBuf::from(&path);\n    let name = path_buf\n        .file_name()\n        .and_then(|n| n.to_str())\n        .unwrap_or(\"\")\n        .to_string();\n\n    let mut children = Vec::new();\n    if let Ok(entries) = std::fs::read_dir(&path) {\n        for entry in entries {\n            if let Ok(entry) = entry {\n                let path = entry.path();\n                let child_name = entry.file_name().to_string_lossy().to_string();\n                let path_str = path.to_string_lossy().to_string();\n\n                if path.is_file() {\n                    children.push(FolderEntry {\n                        name: child_name,\n                        is_file: true,\n                        path: path_str,\n                        children: None,\n                    });\n                } else if path.is_dir() {\n                    children.push(read_folder_structure(path_str));\n                }\n            }\n        }\n    }\n\n    FolderEntry {\n        name,\n        is_file: false,\n        path,\n        children: Some(children),\n    }\n}\n\n/// 判断文件是否存在\n#[tauri::command]\npub fn exists(path: String) -> bool {\n    std::path::Path::new(&path).exists()\n}\n\n/// 读取文件夹中的文件列表\n/// 如果文件夹不存在，返回空列表\n#[tauri::command]\npub fn read_folder(path: String) -> Vec<String> {\n    let mut files = Vec::new();\n    if let Ok(entries) = std::fs::read_dir(path) {\n        for entry in entries {\n            if let Ok(entry) = entry {\n                if entry.path().is_file() {\n                    if let Some(file_name) = entry.file_name().to_str() {\n                        files.push(file_name.to_string());\n                    }\n                }\n            }\n        }\n    }\n    files\n}\n\n/// 读取一个文件夹中的全部文件，递归的读取\n/// 如果文件夹不存在，返回空列表\n/// fileExts: 要读取的文件扩展名列表，例如：[\".txt\", \".md\"]\n#[tauri::command]\npub fn read_folder_recursive(path: String, file_exts: Vec<String>) -> Vec<String> {\n    let mut files = Vec::new();\n    if let Ok(entries) = std::fs::read_dir(path) {\n        for entry in entries {\n            if let Ok(entry) = entry {\n                let path = entry.path();\n                if path.is_file() {\n                    if let Some(file_name) = path.to_str() {\n                        if file_exts.iter().any(|ext| file_name.ends_with(ext)) {\n                            files.push(file_name.to_string());\n                        }\n                    }\n                } else if path.is_dir() {\n                    let mut sub_files = read_folder_recursive(\n                        path.to_str().unwrap().to_string(),\n                        file_exts.clone(),\n                    );\n                    files.append(&mut sub_files);\n                }\n            }\n        }\n    }\n    files\n}\n\n/// 删除文件\n#[tauri::command]\npub fn delete_file(path: String) -> Result<(), String> {\n    std::fs::remove_file(path).map_err(|e| e.to_string())?;\n    Ok(())\n}\n\n/// 读取文件，返回字符串\n#[tauri::command]\npub fn read_text_file(path: String) -> String {\n    let mut file = std::fs::File::open(path).unwrap();\n    let mut contents = String::new();\n    file.read_to_string(&mut contents).unwrap();\n    contents\n}\n\n/// 读取文件，返回base64\n#[tauri::command]\npub fn read_file_base64(path: String) -> Result<String, String> {\n    Ok(general_purpose::STANDARD\n        .encode(&std::fs::read(path).map_err(|e| format!(\"无法读取文件: {}\", e))?))\n}\n\n/// 写入文件\n#[tauri::command]\npub fn write_text_file(path: String, content: String) -> Result<(), String> {\n    std::fs::write(path, content).map_err(|e| e.to_string())?;\n    Ok(())\n}\n\n/// 写入文件，base64字符串\n#[tauri::command]\npub fn write_file_base64(content: String, path: String) -> Result<(), String> {\n    // 解码 Base64 内容\n    let decoded_content = general_purpose::STANDARD\n        .decode(content)\n        .map_err(|e| format!(\"解码失败: {}\", e))?;\n\n    // 写入文件\n    std::fs::write(&path, decoded_content).map_err(|e| {\n        eprintln!(\"写入文件失败: {}\", e);\n        e.to_string()\n    })?;\n\n    Ok(())\n}\n\n/// 创建文件夹\n/// 如果创建成功，则返回true，如果创建失败则返回false\n#[tauri::command]\npub fn create_folder(path: String) -> bool {\n    std::fs::create_dir_all(&path).is_ok()\n}\n"
  },
  {
    "path": "app/src-tauri/src/cmd/mod.rs",
    "content": "pub mod device;\npub mod fs;"
  },
  {
    "path": "app/src-tauri/src/lib.rs",
    "content": "mod cmd;\n\n// 这两行可能不能去掉，否则会导致linux打包软件报错\nuse std::path::Path;\nuse tauri::Manager;\n\n#[tauri::command]\nfn write_stdout(content: String) {\n    println!(\"{}\", content);\n}\n\n#[tauri::command]\nfn write_stderr(content: String) {\n    eprintln!(\"{}\", content);\n}\n\n#[tauri::command]\nfn exit(code: i32) {\n    std::process::exit(code);\n}\n\n#[cfg_attr(mobile, tauri::mobile_entry_point)]\npub fn run() {\n    // 在 Linux 上禁用 DMA-BUF 渲染器\n    // 否则无法在 Linux 上运行\n    // 相同的bug: https://github.com/tauri-apps/tauri/issues/10702\n    #[cfg(target_os = \"linux\")]\n    {\n        if Path::new(\"/proc/driver/nvidia/gpus\").exists() {\n            std::env::set_var(\"__GL_THREADED_OPTIMIZATIONS\", \"0\");\n            std::env::set_var(\"__NV_DISABLE_EXPLICIT_SYNC\", \"1\");\n        }\n    }\n\n    tauri::Builder::default()\n        .plugin(tauri_plugin_fs::init())\n        .plugin(tauri_plugin_store::Builder::new().build())\n        .plugin(tauri_plugin_http::init())\n        .plugin(tauri_plugin_dialog::init())\n        .plugin(tauri_plugin_os::init())\n        .plugin(tauri_plugin_shell::init())\n        .plugin(tauri_plugin_clipboard_manager::init())\n        .plugin(tauri_plugin_system_info::init())\n        .setup(|app| {\n            #[cfg(debug_assertions)] // only include this code on debug builds\n            {\n                // let window = app.get_webview_window(\"main\").unwrap();\n                // window.open_devtools();\n                app.handle().plugin(tauri_plugin_devtools::init())?;\n            }\n            #[cfg(desktop)]\n            {\n                app.handle().plugin(tauri_plugin_cli::init())?;\n                app.handle().plugin(tauri_plugin_process::init())?;\n                app.handle()\n                    .plugin(tauri_plugin_window_state::Builder::new().build())?;\n                app.handle()\n                    .plugin(tauri_plugin_updater::Builder::new().build())?;\n                app.handle()\n                    .plugin(tauri_plugin_global_shortcut::Builder::new().build())?;\n            }\n            Ok(())\n        })\n        .invoke_handler(tauri::generate_handler![\n            cmd::device::get_device_id,\n            cmd::fs::read_folder_structure,\n            cmd::fs::read_folder_recursive,\n            write_stdout,\n            write_stderr,\n            exit,\n        ])\n        .run(tauri::generate_context!())\n        .expect(\"error while running tauri application\");\n}\n"
  },
  {
    "path": "app/src-tauri/src/main.rs",
    "content": "// Prevents additional console window on Windows in release, DO NOT REMOVE!!\n#![cfg_attr(not(debug_assertions), windows_subsystem = \"windows\")]\n\nfn main() {\n    // 测试发现可以给run函数增加一些自定义的参数。\n    // 当时是想传一个时间戳来测试时间，但想想好像没必要 ——littlefean\n    project_graph_lib::run()\n}\n"
  },
  {
    "path": "app/src-tauri/tauri.conf.json",
    "content": "{\n  \"$schema\": \"../node_modules/@tauri-apps/cli/config.schema.json\",\n  \"productName\": \"Project Graph\",\n  \"version\": \"0.0.0-dev\",\n  \"identifier\": \"liren.project-graph\",\n  \"build\": {\n    \"beforeDevCommand\": \"\",\n    \"devUrl\": \"http://localhost:1420\",\n    \"beforeBuildCommand\": \"\",\n    \"frontendDist\": \"../dist\",\n    \"removeUnusedCommands\": true\n  },\n  \"app\": {\n    \"windows\": [\n      {\n        \"label\": \"main\",\n        \"title\": \"Project Graph\",\n        \"width\": 1200,\n        \"height\": 800,\n        \"decorations\": false,\n        \"transparent\": true,\n        \"visible\": false\n      },\n      {\n        \"label\": \"splash\",\n        \"title\": \"Project Graph Startup\",\n        \"url\": \"/splash\",\n        \"width\": 640,\n        \"height\": 375,\n        \"minWidth\": 640,\n        \"minHeight\": 375,\n        \"maxWidth\": 640,\n        \"maxHeight\": 375,\n        \"decorations\": false,\n        \"transparent\": true,\n        \"resizable\": false,\n        \"visible\": false\n      }\n    ],\n    \"macOSPrivateApi\": true,\n    \"security\": {\n      \"csp\": null,\n      \"capabilities\": []\n    }\n  },\n  \"bundle\": {\n    \"active\": true,\n    \"targets\": [\"nsis\", \"deb\", \"dmg\", \"appimage\", \"rpm\", \"app\"],\n    \"createUpdaterArtifacts\": true,\n    \"category\": \"Productivity\",\n    \"longDescription\": \"A simple tool to create topology diagrams.\",\n    \"shortDescription\": \"Diagram creator\",\n    \"icon\": [\"icons/32x32.png\", \"icons/128x128.png\", \"icons/128x128@2x.png\", \"icons/icon.icns\", \"icons/icon.ico\"],\n    \"windows\": {\n      \"nsis\": {\n        \"displayLanguageSelector\": true,\n        \"languages\": [\"SimpChinese\", \"TradChinese\", \"English\", \"Indonesian\"],\n        \"sidebarImage\": \"nsis/left.bmp\",\n        \"headerImage\": \"nsis/project-graph-install.bmp\",\n        \"installerHooks\": \"nsis/installer.nsh\"\n      },\n      \"webviewInstallMode\": {\n        \"type\": \"downloadBootstrapper\"\n      }\n    },\n    \"linux\": {\n      \"appimage\": {\n        \"bundleMediaFramework\": true\n      }\n    },\n    \"macOS\": {\n      \"minimumSystemVersion\": \"11.0\"\n    },\n    \"fileAssociations\": [\n      {\n        \"name\": \"Project Graph Document\",\n        \"description\": \"Project Graph Document\",\n        \"ext\": [\"prg\"],\n        \"mimeType\": \"application/vnd.project-graph\",\n        \"role\": \"Editor\"\n      }\n    ]\n  },\n  \"plugins\": {\n    \"cli\": {\n      \"description\": \"A simple tool to create topology diagrams.\",\n      \"args\": [\n        {\n          \"name\": \"path\",\n          \"index\": 1,\n          \"takesValue\": true\n        },\n        {\n          \"name\": \"output\",\n          \"short\": \"o\",\n          \"takesValue\": true,\n          \"multiple\": false\n        }\n      ]\n    },\n    \"updater\": {\n      \"pubkey\": \"dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEIyQzM3OTE1QjgwRTRCNzEKUldSeFN3NjRGWG5Ec2hVb05sWGZhVmpJdEVCR2cwNmM4UXlISEN5eHJBeFlCQkF3VjV6Z0tEY08K\",\n      \"endpoints\": [\"https://release.project-graph.top/update/{{target}}/{{arch}}/{{current_version}}\"],\n      \"windows\": {\n        \"installMode\": \"passive\"\n      }\n    },\n    \"fs\": {\n      \"requireLiteralLeadingDot\": false\n    },\n    \"shell\": {\n      \"open\": \".*\"\n    }\n  }\n}\n"
  },
  {
    "path": "app/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"ESNext\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"skipLibCheck\": true, // 不检查.d.ts文件\n\n    // 打包配置\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true, // 因为vite使用esbuild打包，tsc只负责类型检查，所以不需要生成js文件\n    \"jsx\": \"react-jsx\",\n\n    // 类型检查\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true,\n\n    // 其他\n    \"experimentalDecorators\": true, // 开启装饰器\n    \"emitDecoratorMetadata\": true,\n    \"preserveSymlinks\": true,\n\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  },\n  \"include\": [\"src\", \"tests\"],\n  \"references\": [{ \"path\": \"./tsconfig.node.json\" }]\n}\n"
  },
  {
    "path": "app/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"skipLibCheck\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"allowSyntheticDefaultImports\": true\n  },\n  \"include\": [\"vite.config.ts\", \"scripts\", \"vite-plugins\"]\n}\n"
  },
  {
    "path": "app/vite-plugins/i18n-auto-tw.ts",
    "content": "import * as OpenCC from \"opencc-js\";\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport type { Plugin } from \"vite\";\n\nexport function i18nAutoTW(): Plugin {\n  const zhCNPath = path.resolve(__dirname, \"../src/locales/zh_CN.yml\");\n  const zhTWPath = path.resolve(__dirname, \"../src/locales/zh_TW.yml\");\n\n  const converter = OpenCC.Converter({ from: \"cn\", to: \"tw\" });\n\n  function convert() {\n    try {\n      if (!fs.existsSync(zhCNPath)) return;\n\n      const content = fs.readFileSync(zhCNPath, \"utf-8\");\n      const converted = converter(content);\n\n      // Only write if changed to avoid infinite loops or unnecessary writes\n      if (fs.existsSync(zhTWPath)) {\n        const currentTW = fs.readFileSync(zhTWPath, \"utf-8\");\n        if (currentTW === converted) return;\n      }\n\n      fs.writeFileSync(zhTWPath, converted, \"utf-8\");\n      console.log(`[i18n-auto-tw] Updated zh_TW.yml from zh_CN.yml`);\n    } catch (error) {\n      console.error(`[i18n-auto-tw] Error converting zh_CN to zh_TW:`, error);\n    }\n  }\n\n  return {\n    name: \"vite-plugin-i18n-auto-tw\",\n    buildStart() {\n      convert();\n    },\n    handleHotUpdate({ file }) {\n      if (file === zhCNPath) {\n        convert();\n      }\n    },\n  };\n}\n"
  },
  {
    "path": "app/vite.config.ts",
    "content": "/// <reference types=\"vitest/config\" />\n\nimport operatorOverload from \"unplugin-operator-overload/vite\";\nimport originalClassName from \"unplugin-original-class-name/vite\";\nimport ViteYaml from \"@modyfi/vite-plugin-yaml\";\nimport tailwindcss from \"@tailwindcss/vite\";\nimport react from \"@vitejs/plugin-react-oxc\";\nimport path from \"node:path\";\nimport { createLogger, defineConfig } from \"vite\";\nimport svgr from \"vite-plugin-svgr\";\nimport { i18nAutoTW } from \"./vite-plugins/i18n-auto-tw\";\n\nexport const viteLogger = createLogger(\"info\", { prefix: \"[project-graph]\" });\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n  plugins: [\n    tailwindcss(),\n    originalClassName({\n      staticMethodName: \"className\",\n    }),\n    operatorOverload(),\n    // 将svg文件作为react组件导入\n    // import Icon from \"./icon.svg?react\"\n    svgr(),\n    // 解析yaml文件，作为js对象导入\n    // import config from \"./config.yaml\"\n    ViteYaml(),\n    // zh_CN 自动转 zh_TW\n    i18nAutoTW(),\n    // react插件\n    react(),\n    // 分析组件性能\n    // reactScan(),\n  ],\n\n  // 不清屏，方便看rust报错\n  clearScreen: false,\n  // tauri需要固定的端口\n  server: {\n    port: 1420,\n    // 端口冲突时直接报错，不尝试下一个可用端口\n    strictPort: true,\n    watch: {\n      ignored: [\"**/src-tauri/**\"],\n    },\n  },\n\n  // 2024年10月3日发现 pnpm build 会报错，\n  // Top-level await is not available in the configured target environment\n  // 添加下面的配置解决了\n  // 2024/10/05 main.tsx去掉了顶层await，所以不需要这个配置\n  // build: {\n  //   target: \"esnext\",\n  // },\n\n  // 环境变量前缀\n  // 只有名字以LR_开头的环境变量才会被注入到前端\n  // import.meta.env.LR_xxx\n  envPrefix: \"LR_\",\n\n  resolve: {\n    alias: {\n      \"@\": path.resolve(__dirname, \"./src\"),\n    },\n  },\n\n  build: {\n    sourcemap: false,\n  },\n});\n"
  },
  {
    "path": "config.xlings",
    "content": "-- step1: install xlings tools: https://github.com/d2learn/xlings\n-- step2: run [xlings install], auto config project dev-environment\n-- support: windows, ubuntu, ...\n\nxname = \"Project Graph\"\nxdeps = {\n    vs = \"2022\",\n    rust = \"1.81.0\",\n    nodejs = \"20.19.0\",\n    pnpm = \"9.8.0\",\n    xppcmds = {\n        -- https://v1.tauri.app/v1/guides/getting-started/prerequisites\n        -- ubuntu 22.04+\n        {\"ubuntu\", \"sudo apt update\"},\n        {\"ubuntu\", \"sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev -y\"},\n        {\"arch\", \"sudo pacman -Syu\"},\n        {\"arch\", \"sudo pacman -S --needed webkit2gtk base-devel curl wget file openssl appmenu-gtk-module gtk3 libappindicator-gtk3 librsvg libvips --noconfirm\"},\n        --\"pnpm install --registry=https://registry.npmmirror.com\",\n        \"pnpm install\",\n        \"pnpm dev:app\",\n    }\n}"
  },
  {
    "path": "docs-pg/Project Graph v2.json",
    "content": "{\n  \"version\": 17,\n  \"entities\": [\n    {\n      \"location\": [-256, -203],\n      \"size\": [182.65594482421875, 76],\n      \"text\": \"class Core\",\n      \"uuid\": \"4b9be933-ac94-49e0-8ad1-5271baf147b9\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [32, -500],\n      \"size\": [226.59190368652344, 76],\n      \"text\": \"disposables[]\",\n      \"uuid\": \"9f4aaa0d-e726-42f1-b61a-ce883cf020f9\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [84, -332],\n      \"size\": [183.67991638183594, 76],\n      \"text\": \"tickables[]\",\n      \"uuid\": \"8a084019-71c9-4da4-8070-02f35bbc5294\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [315, -503],\n      \"size\": [335.8078918457031, 76],\n      \"text\": \"interface Disposable\",\n      \"uuid\": \"41855f81-5d1b-4ad9-a7f3-df704ede99f9\",\n      \"details\": \"任何需要销毁函数的东西\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [352, -333],\n      \"size\": [295.9678955078125, 76],\n      \"text\": \"interface Tickable\",\n      \"uuid\": \"2b824f3a-6419-4c26-9dbb-3b941622f7f3\",\n      \"details\": \"任何能迭代循环的东西\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [722, -495],\n      \"size\": [244.09591674804688, 76],\n      \"text\": \"dispose(): void\",\n      \"uuid\": \"e10b10bf-8dbc-4f4a-a586-6a2ab2a891bc\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [720, -333],\n      \"size\": [183.32794189453125, 76],\n      \"text\": \"tick(): void\",\n      \"uuid\": \"22876b8f-5359-4eff-87d6-7aa043a0bc61\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [52, -123],\n      \"size\": [159.4559326171875, 76],\n      \"text\": \"plugins[]\",\n      \"uuid\": \"094be541-0518-431a-a753-bfb48267865c\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [347, -115],\n      \"size\": [205.72792053222656, 76],\n      \"text\": \"class Plugin\",\n      \"uuid\": \"4b3e7525-3723-463f-96b7-1e8a58fc55f2\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [660, -196],\n      \"size\": [159.7439422607422, 76],\n      \"text\": \"id: string\",\n      \"uuid\": \"1ca9874c-a475-4b95-907a-d817b0d9ae66\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [653, -120],\n      \"size\": [215.13592529296875, 76],\n      \"text\": \"name: string\",\n      \"uuid\": \"b68ded35-556b-4fb3-8506-cbe6dd6c143c\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [653, -36],\n      \"size\": [257.1519470214844, 76],\n      \"text\": \"worker: Worker\",\n      \"uuid\": \"e99098a8-975b-447e-9069-d5e45dc79d6b\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-288, -687],\n      \"size\": [110.62396240234375, 76],\n      \"text\": \"stage\",\n      \"uuid\": \"928d667b-c765-45a4-a992-eba81325a192\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-419, -567],\n      \"size\": [157.53594970703125, 76],\n      \"text\": \"renderer\",\n      \"uuid\": \"b5dbfe83-1e5a-406f-a0e7-1498c6de989a\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-521, -289],\n      \"size\": [172.4479522705078, 76],\n      \"text\": \"controller\",\n      \"uuid\": \"8bcefcbf-5b85-4a30-bf88-3c3f9777f535\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-534, -138],\n      \"size\": [138.6239471435547, 76],\n      \"text\": \"camera\",\n      \"uuid\": \"7f4f5e8b-8e15-43dc-ac98-893a1c6c72a2\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-516, 17],\n      \"size\": [147.55194091796875, 76],\n      \"text\": \"settings\",\n      \"uuid\": \"34a3273e-1453-4fb4-b97d-bbe8264e3d9b\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-710, -566],\n      \"size\": [248.99191284179688, 76],\n      \"text\": \"class Renderer\",\n      \"uuid\": \"b70ea90d-24f1-4bd6-8d9b-8490adb1ee2b\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1271, -567],\n      \"size\": [512.31982421875, 76],\n      \"text\": \"ctx: CanvasRenderingContext2D\",\n      \"uuid\": \"434ba9ee-8b4b-4039-90d6-e85646de0529\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-844, -290],\n      \"size\": [261.1839141845703, 76],\n      \"text\": \"class Controller\",\n      \"uuid\": \"27f5bed0-ee2e-4516-8e9a-8a3f43e151b4\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1254, -450],\n      \"size\": [269.5679016113281, 76],\n      \"text\": \"register(...): void\",\n      \"uuid\": \"3072d354-fa4b-407d-a433-d60471a2886e\",\n      \"details\": \"id: string\\nbind: Bind\\nonDown: () => void\\nonUp: () => void\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1308, -288],\n      \"size\": [372.09588623046875, 76],\n      \"text\": \"mouseLocation: Vector\",\n      \"uuid\": \"19165ae4-26ea-42bb-b78a-71edd301a330\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-880, -134],\n      \"size\": [227.55191040039062, 76],\n      \"text\": \"class Camera\",\n      \"uuid\": \"62e75f2c-7b66-465a-b5f9-464baf094744\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1039, -135],\n      \"size\": [52.6719970703125, 76],\n      \"text\": \"...\",\n      \"uuid\": \"6bc76cbb-ac87-4853-b216-16b5d822efda\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-578, -690],\n      \"size\": [198.367919921875, 76],\n      \"text\": \"class Stage\",\n      \"uuid\": \"31434a0c-d551-4510-8009-36bd697aac96\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-702, -689],\n      \"size\": [52.6719970703125, 76],\n      \"text\": \"...\",\n      \"uuid\": \"337c8260-21b7-4124-ab7d-b963e6f7bf25\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-930, 24],\n      \"size\": [314.78387451171875, 76],\n      \"text\": \"async get<T>(...): T\",\n      \"uuid\": \"0e0640ea-02f7-49df-af22-f294f310fce4\",\n      \"details\": \"namespace: string\\nkey: string\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-874, 141],\n      \"size\": [294.9118957519531, 76],\n      \"text\": \"async set(...): void\",\n      \"uuid\": \"a1265b8a-b1dc-4e2f-8c90-8637ecbf813c\",\n      \"details\": \"namespace: string\\nkey: string\\nvalue: any\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-542, 498],\n      \"size\": [181.0559539794922, 76],\n      \"text\": \"project.pg\",\n      \"uuid\": \"81157c3e-4628-42ee-8577-7cf48e8ce9aa\",\n      \"details\": \"application/zip\",\n      \"color\": [22, 163, 74, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-226, 283],\n      \"size\": [136.19195556640625, 76],\n      \"text\": \"version\",\n      \"uuid\": \"811083e8-ab11-4f78-b4fc-0f6cad695750\",\n      \"details\": \"text/plain\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-243, 472],\n      \"size\": [208.60792541503906, 76],\n      \"text\": \"entities.json\",\n      \"uuid\": \"91b58bcc-7995-4937-825c-d33807e12cfd\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-477, 816],\n      \"size\": [135.1359405517578, 76],\n      \"text\": \"assets/\",\n      \"uuid\": \"5a2bcdef-8c9e-4c95-8bae-423332b54e8b\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-279, 760],\n      \"size\": [116.06394958496094, 76],\n      \"text\": \"[uuid]\",\n      \"uuid\": \"e6b0d7d5-63b2-4564-8130-ec199cbb9416\",\n      \"details\": \"application/octet-stream\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-278, 887],\n      \"size\": [116.06394958496094, 76],\n      \"text\": \"[uuid]\",\n      \"uuid\": \"053c3015-8d33-47d2-8bfa-41d45d857af4\",\n      \"details\": \"application/octet-stream\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [26, 307],\n      \"size\": [220, 76],\n      \"text\": \"文档的版本号\",\n      \"uuid\": \"d62398ef-ad7d-4603-b10d-d0db9823002b\",\n      \"details\": \"\",\n      \"color\": [168, 85, 247, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-187, 611],\n      \"size\": [400.7359619140625, 76],\n      \"text\": \"JSON数组，存储所有节点\",\n      \"uuid\": \"8a28ed07-efa4-4067-9a4a-6b7d0129a3fc\",\n      \"details\": \"\",\n      \"color\": [168, 85, 247, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [352, 347],\n      \"size\": [60.03196716308594, 76],\n      \"text\": \"[ ]\",\n      \"uuid\": \"0a1fcafd-3260-4c36-8743-29065322702e\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [426, 469],\n      \"size\": [60.35197448730469, 76],\n      \"text\": \"{ }\",\n      \"uuid\": \"4d8e9416-9fb4-4882-9451-e06c66579912\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [567, 593],\n      \"size\": [94.719970703125, 76],\n      \"text\": \"type\",\n      \"uuid\": \"695ab1d8-4b46-4934-8804-7d3092802eb9\",\n      \"details\": \"string\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [720, 617],\n      \"size\": [249.72793579101562, 76],\n      \"text\": \"core:text_node\",\n      \"uuid\": \"dcf31d1c-9459-4a30-87e7-11d7d9f20923\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [417, 645],\n      \"size\": [112.60797119140625, 76],\n      \"text\": \"width\",\n      \"uuid\": \"a25b8ea6-601e-4aa6-afe4-5c3841675af5\",\n      \"details\": \"number\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [420, 743],\n      \"size\": [123.42396545410156, 76],\n      \"text\": \"height\",\n      \"uuid\": \"879601c0-f7c3-4994-a291-72b9730ca9d3\",\n      \"details\": \"number\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [344, 653],\n      \"size\": [44.511993408203125, 76],\n      \"text\": \"x\",\n      \"uuid\": \"0e58f1f1-f279-4c79-8bea-696e2ac91f9e\",\n      \"details\": \"number\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [348, 738],\n      \"size\": [45.21598815917969, 76],\n      \"text\": \"y\",\n      \"uuid\": \"8fcdc80f-1735-42f0-ad1f-734cd63a8f1b\",\n      \"details\": \"number\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [556, 392],\n      \"size\": [136.15994262695312, 76],\n      \"text\": \"data { }\",\n      \"uuid\": \"e6dc021d-fd5f-4846-96a6-3df38b4f16de\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [756, 403],\n      \"size\": [85.63197326660156, 76],\n      \"text\": \"text\",\n      \"uuid\": \"c690aa9e-dba6-46dc-ac66-51c844cf01de\",\n      \"details\": \"string\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [700, 502],\n      \"size\": [52.6719970703125, 76],\n      \"text\": \"...\",\n      \"uuid\": \"265ae47a-ab84-420c-93f1-2aee86142eeb\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-44, 882],\n      \"size\": [403.07196044921875, 76],\n      \"text\": \"文档中的图片（raw格式）\",\n      \"uuid\": \"580cd42e-a1a3-432e-8ce1-9e3080307d82\",\n      \"details\": \"\",\n      \"color\": [168, 85, 247, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [314, 267],\n      \"size\": [685.7279357910156, 582],\n      \"uuid\": \"16a7755e-12c0-4db4-bd13-d25e7c40cb99\",\n      \"text\": \"JSON\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\n        \"0a1fcafd-3260-4c36-8743-29065322702e\",\n        \"4d8e9416-9fb4-4882-9451-e06c66579912\",\n        \"695ab1d8-4b46-4934-8804-7d3092802eb9\",\n        \"dcf31d1c-9459-4a30-87e7-11d7d9f20923\",\n        \"a25b8ea6-601e-4aa6-afe4-5c3841675af5\",\n        \"879601c0-f7c3-4994-a291-72b9730ca9d3\",\n        \"0e58f1f1-f279-4c79-8bea-696e2ac91f9e\",\n        \"8fcdc80f-1735-42f0-ad1f-734cd63a8f1b\",\n        \"e6dc021d-fd5f-4846-96a6-3df38b4f16de\",\n        \"c690aa9e-dba6-46dc-ac66-51c844cf01de\",\n        \"265ae47a-ab84-420c-93f1-2aee86142eeb\"\n      ],\n      \"details\": \"\"\n    }\n  ],\n  \"associations\": [\n    {\n      \"source\": \"4b9be933-ac94-49e0-8ad1-5271baf147b9\",\n      \"target\": \"9f4aaa0d-e726-42f1-b61a-ce883cf020f9\",\n      \"text\": \"\",\n      \"uuid\": \"e6e0172c-e7d9-492a-9067-704e7a7d7200\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"4b9be933-ac94-49e0-8ad1-5271baf147b9\",\n      \"target\": \"8a084019-71c9-4da4-8070-02f35bbc5294\",\n      \"text\": \"\",\n      \"uuid\": \"3622a2ec-6df3-47e4-98d9-8e990679894b\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"41855f81-5d1b-4ad9-a7f3-df704ede99f9\",\n      \"target\": \"e10b10bf-8dbc-4f4a-a586-6a2ab2a891bc\",\n      \"text\": \"\",\n      \"uuid\": \"37bbc304-3130-460c-969a-d9db351abe6c\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"2b824f3a-6419-4c26-9dbb-3b941622f7f3\",\n      \"target\": \"22876b8f-5359-4eff-87d6-7aa043a0bc61\",\n      \"text\": \"\",\n      \"uuid\": \"9dff0ca7-0cb0-43bb-80b2-83f0fbe52d94\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"9f4aaa0d-e726-42f1-b61a-ce883cf020f9\",\n      \"target\": \"41855f81-5d1b-4ad9-a7f3-df704ede99f9\",\n      \"text\": \"\",\n      \"uuid\": \"31ace868-2f81-4b22-897c-bc89f89a9ac2\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"8a084019-71c9-4da4-8070-02f35bbc5294\",\n      \"target\": \"2b824f3a-6419-4c26-9dbb-3b941622f7f3\",\n      \"text\": \"\",\n      \"uuid\": \"fdd3c63d-519e-4c56-aff5-95c1f6e0c5c5\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"094be541-0518-431a-a753-bfb48267865c\",\n      \"target\": \"4b3e7525-3723-463f-96b7-1e8a58fc55f2\",\n      \"text\": \"\",\n      \"uuid\": \"efd7e4cd-35e2-4f65-ae38-d52398afc7e6\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"4b3e7525-3723-463f-96b7-1e8a58fc55f2\",\n      \"target\": \"1ca9874c-a475-4b95-907a-d817b0d9ae66\",\n      \"text\": \"\",\n      \"uuid\": \"4b800f10-fbd5-4db3-b6b4-cfad539dbdcc\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"4b3e7525-3723-463f-96b7-1e8a58fc55f2\",\n      \"target\": \"b68ded35-556b-4fb3-8506-cbe6dd6c143c\",\n      \"text\": \"\",\n      \"uuid\": \"9483a522-9c2b-41a5-9b45-2f5496cad19e\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"4b3e7525-3723-463f-96b7-1e8a58fc55f2\",\n      \"target\": \"e99098a8-975b-447e-9069-d5e45dc79d6b\",\n      \"text\": \"\",\n      \"uuid\": \"6abb5c81-f3e7-4d26-a230-1e2835afc07e\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"4b9be933-ac94-49e0-8ad1-5271baf147b9\",\n      \"target\": \"928d667b-c765-45a4-a992-eba81325a192\",\n      \"text\": \"\",\n      \"uuid\": \"12a31811-ce1b-482c-9d79-3613285afff0\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"4b9be933-ac94-49e0-8ad1-5271baf147b9\",\n      \"target\": \"b5dbfe83-1e5a-406f-a0e7-1498c6de989a\",\n      \"text\": \"\",\n      \"uuid\": \"fcb25620-9c5b-4e15-9e39-d4ee941c4e6b\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"b70ea90d-24f1-4bd6-8d9b-8490adb1ee2b\",\n      \"target\": \"434ba9ee-8b4b-4039-90d6-e85646de0529\",\n      \"text\": \"\",\n      \"uuid\": \"2643fae7-4608-47d2-a34a-4803d0231b8b\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"4b9be933-ac94-49e0-8ad1-5271baf147b9\",\n      \"target\": \"8bcefcbf-5b85-4a30-bf88-3c3f9777f535\",\n      \"text\": \"\",\n      \"uuid\": \"80dd5067-1f48-4caf-aa6f-e6b0920c224c\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"4b9be933-ac94-49e0-8ad1-5271baf147b9\",\n      \"target\": \"7f4f5e8b-8e15-43dc-ac98-893a1c6c72a2\",\n      \"text\": \"\",\n      \"uuid\": \"e8acda24-a59c-4be1-a11b-810af6138c0d\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"4b9be933-ac94-49e0-8ad1-5271baf147b9\",\n      \"target\": \"34a3273e-1453-4fb4-b97d-bbe8264e3d9b\",\n      \"text\": \"\",\n      \"uuid\": \"f9df8f25-5a4d-4eb6-be17-d3d91f34ef5a\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"8bcefcbf-5b85-4a30-bf88-3c3f9777f535\",\n      \"target\": \"27f5bed0-ee2e-4516-8e9a-8a3f43e151b4\",\n      \"text\": \"\",\n      \"uuid\": \"05b2a00e-8861-4cfa-979d-876cf1622395\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"27f5bed0-ee2e-4516-8e9a-8a3f43e151b4\",\n      \"target\": \"19165ae4-26ea-42bb-b78a-71edd301a330\",\n      \"text\": \"\",\n      \"uuid\": \"8f21c987-4e14-4e81-96c9-56d0de3e12f8\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"7f4f5e8b-8e15-43dc-ac98-893a1c6c72a2\",\n      \"target\": \"62e75f2c-7b66-465a-b5f9-464baf094744\",\n      \"text\": \"\",\n      \"uuid\": \"95c01e67-5556-4dd4-8c0f-502feac82b9d\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"62e75f2c-7b66-465a-b5f9-464baf094744\",\n      \"target\": \"6bc76cbb-ac87-4853-b216-16b5d822efda\",\n      \"text\": \"\",\n      \"uuid\": \"ad94aa20-952d-4e82-abaf-158370ee904d\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"31434a0c-d551-4510-8009-36bd697aac96\",\n      \"target\": \"337c8260-21b7-4124-ab7d-b963e6f7bf25\",\n      \"text\": \"\",\n      \"uuid\": \"4ddee042-bfb5-42f8-8461-7884e9eb1517\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"34a3273e-1453-4fb4-b97d-bbe8264e3d9b\",\n      \"target\": \"0e0640ea-02f7-49df-af22-f294f310fce4\",\n      \"text\": \"\",\n      \"uuid\": \"c563e687-23ce-48d1-894b-30d00dad758e\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"34a3273e-1453-4fb4-b97d-bbe8264e3d9b\",\n      \"target\": \"a1265b8a-b1dc-4e2f-8c90-8637ecbf813c\",\n      \"text\": \"\",\n      \"uuid\": \"143ffee2-bf5a-47db-a540-0f50419d3f4a\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"81157c3e-4628-42ee-8577-7cf48e8ce9aa\",\n      \"target\": \"811083e8-ab11-4f78-b4fc-0f6cad695750\",\n      \"text\": \"\",\n      \"uuid\": \"4241bf83-e4b7-487c-8e8c-4e3571868646\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"81157c3e-4628-42ee-8577-7cf48e8ce9aa\",\n      \"target\": \"91b58bcc-7995-4937-825c-d33807e12cfd\",\n      \"text\": \"\",\n      \"uuid\": \"3beb6f01-f9b8-40cf-bb86-370bf65b1b32\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"81157c3e-4628-42ee-8577-7cf48e8ce9aa\",\n      \"target\": \"5a2bcdef-8c9e-4c95-8bae-423332b54e8b\",\n      \"text\": \"\",\n      \"uuid\": \"cac5ee05-ed66-49c4-8b20-11a8f6b52a10\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"5a2bcdef-8c9e-4c95-8bae-423332b54e8b\",\n      \"target\": \"e6b0d7d5-63b2-4564-8130-ec199cbb9416\",\n      \"text\": \"\",\n      \"uuid\": \"6c24c0aa-7f09-4957-8fc1-fa5e8c8e4c8b\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"5a2bcdef-8c9e-4c95-8bae-423332b54e8b\",\n      \"target\": \"053c3015-8d33-47d2-8bfa-41d45d857af4\",\n      \"text\": \"\",\n      \"uuid\": \"ee7b6ed7-f032-47fb-a0bd-ac9446b3065e\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"b5dbfe83-1e5a-406f-a0e7-1498c6de989a\",\n      \"target\": \"b70ea90d-24f1-4bd6-8d9b-8490adb1ee2b\",\n      \"text\": \"\",\n      \"uuid\": \"2e8cfabe-3997-482c-be56-9220e9ce44bb\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"928d667b-c765-45a4-a992-eba81325a192\",\n      \"target\": \"31434a0c-d551-4510-8009-36bd697aac96\",\n      \"text\": \"\",\n      \"uuid\": \"0c793f5f-b1aa-49bd-85a5-260c0a63e640\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"4b9be933-ac94-49e0-8ad1-5271baf147b9\",\n      \"target\": \"094be541-0518-431a-a753-bfb48267865c\",\n      \"text\": \"\",\n      \"uuid\": \"cfc2fa96-6402-4410-bc78-39cf619a281d\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"d62398ef-ad7d-4603-b10d-d0db9823002b\",\n      \"target\": \"811083e8-ab11-4f78-b4fc-0f6cad695750\",\n      \"text\": \"\",\n      \"uuid\": \"5c79648c-5882-4cf8-8c1d-d6fc27065cd4\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"8a28ed07-efa4-4067-9a4a-6b7d0129a3fc\",\n      \"target\": \"91b58bcc-7995-4937-825c-d33807e12cfd\",\n      \"text\": \"\",\n      \"uuid\": \"7c8f7123-9d92-4789-8fd6-3b9326d5f5f8\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"91b58bcc-7995-4937-825c-d33807e12cfd\",\n      \"target\": \"0a1fcafd-3260-4c36-8743-29065322702e\",\n      \"text\": \"\",\n      \"uuid\": \"acca432b-b452-4efa-a627-855ad005293d\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"0a1fcafd-3260-4c36-8743-29065322702e\",\n      \"target\": \"4d8e9416-9fb4-4882-9451-e06c66579912\",\n      \"text\": \"\",\n      \"uuid\": \"f005dc4b-1a1a-44e2-88db-a349ac690988\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"4d8e9416-9fb4-4882-9451-e06c66579912\",\n      \"target\": \"695ab1d8-4b46-4934-8804-7d3092802eb9\",\n      \"text\": \"\",\n      \"uuid\": \"4184232a-8f96-4e92-a433-a5f04dd04f1c\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"695ab1d8-4b46-4934-8804-7d3092802eb9\",\n      \"target\": \"dcf31d1c-9459-4a30-87e7-11d7d9f20923\",\n      \"text\": \"\",\n      \"uuid\": \"f03ce828-3164-4197-9d5b-70309aec1f81\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"4d8e9416-9fb4-4882-9451-e06c66579912\",\n      \"target\": \"0e58f1f1-f279-4c79-8bea-696e2ac91f9e\",\n      \"text\": \"\",\n      \"uuid\": \"c053deda-3be2-4867-97df-1faaef0a7309\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"4d8e9416-9fb4-4882-9451-e06c66579912\",\n      \"target\": \"a25b8ea6-601e-4aa6-afe4-5c3841675af5\",\n      \"text\": \"\",\n      \"uuid\": \"00e55895-1e30-4ffb-a951-0307ef8927d1\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"4d8e9416-9fb4-4882-9451-e06c66579912\",\n      \"target\": \"8fcdc80f-1735-42f0-ad1f-734cd63a8f1b\",\n      \"text\": \"\",\n      \"uuid\": \"456431e7-b241-4a03-8244-83d023d6fce7\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"4d8e9416-9fb4-4882-9451-e06c66579912\",\n      \"target\": \"879601c0-f7c3-4994-a291-72b9730ca9d3\",\n      \"text\": \"\",\n      \"uuid\": \"3dfa87c6-f6fc-4f37-b903-3db6dd128d24\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"4d8e9416-9fb4-4882-9451-e06c66579912\",\n      \"target\": \"e6dc021d-fd5f-4846-96a6-3df38b4f16de\",\n      \"text\": \"\",\n      \"uuid\": \"0203990f-8000-43e2-991a-7cbebae91617\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"e6dc021d-fd5f-4846-96a6-3df38b4f16de\",\n      \"target\": \"c690aa9e-dba6-46dc-ac66-51c844cf01de\",\n      \"text\": \"\",\n      \"uuid\": \"ea3eb996-440d-4ead-ac6c-7df6bd4a5249\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"e6dc021d-fd5f-4846-96a6-3df38b4f16de\",\n      \"target\": \"265ae47a-ab84-420c-93f1-2aee86142eeb\",\n      \"text\": \"\",\n      \"uuid\": \"2663d81f-2b67-42c7-897d-96de5da82df4\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"580cd42e-a1a3-432e-8ce1-9e3080307d82\",\n      \"target\": \"053c3015-8d33-47d2-8bfa-41d45d857af4\",\n      \"text\": \"\",\n      \"uuid\": \"41e68161-f743-4710-bd13-51651c48cf47\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"580cd42e-a1a3-432e-8ce1-9e3080307d82\",\n      \"target\": \"e6b0d7d5-63b2-4564-8130-ec199cbb9416\",\n      \"text\": \"\",\n      \"uuid\": \"5ffd4c3e-5428-4b74-8130-bc167f4a7475\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    }\n  ],\n  \"tags\": []\n}\n"
  },
  {
    "path": "docs-pg/ProjectGraph决策日志.json",
    "content": "{\n  \"version\": 17,\n  \"entities\": [\n    {\n      \"location\": [-873, -1900],\n      \"size\": [552.3839721679688, 124],\n      \"text\": \"在开发CannonWar2\\n一起在FigJam上绘制开发流程拓扑图\",\n      \"uuid\": \"4f0321af-a8c7-406f-b440-b2f5ceaf3346\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-927, -1637],\n      \"size\": [658.9119873046875, 76],\n      \"text\": \"Rutubet提出可以开发一个绘制节点图的工具\",\n      \"uuid\": \"359ee3c0-853c-42d5-a394-e76ab170ab94\",\n      \"details\": \"\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-156, -1729],\n      \"size\": [535.5839233398438, 76],\n      \"text\": \"Rutubet用JavaFx简单写了一个框架\",\n      \"uuid\": \"0c8606ff-17ab-4054-a6cd-5167d9bb8f2b\",\n      \"details\": \"\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-167, -1575],\n      \"size\": [558.367919921875, 172],\n      \"text\": \"Littlefean感觉不熟悉\\n希望早点用\\n快速拷贝visual-file项目代码做了出来\",\n      \"uuid\": \"4de776a3-0d8d-4491-9770-8fe2ea119748\",\n      \"details\": \"2024.08.21\",\n      \"color\": [22, 163, 74, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-44, -1359],\n      \"size\": [311.8079833984375, 124],\n      \"text\": \"ZTY优化了项目结构\\n使用pdm管理\",\n      \"uuid\": \"f84f72e5-c43b-4088-b158-0c5e39435aee\",\n      \"details\": \"2024.08.27\",\n      \"color\": [234, 179, 8, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-167, -1160],\n      \"size\": [559.0399169921875, 124],\n      \"text\": \"Littlefean不知道如何打包windows版\\n先发布第一期视频\",\n      \"uuid\": \"31bf362a-3845-4cff-8751-c810ade383eb\",\n      \"details\": \"\",\n      \"color\": [22, 163, 74, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-62, -994],\n      \"size\": [348, 124],\n      \"text\": \"发布视频后看观众留言\\n解决了pdm打包问题\",\n      \"uuid\": \"e0510bf5-8c9a-471e-b85d-8cedcbe3010d\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-824, -1160],\n      \"size\": [529.0238647460938, 124],\n      \"text\": \"Rutubet开发liren-side\\n打算统一projectGraph和VisualFile\",\n      \"uuid\": \"372b3ac3-3928-47fe-9c89-8747f0dd29ba\",\n      \"details\": \"\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-369, -829],\n      \"size\": [311.0079345703125, 76],\n      \"text\": \"ZTY提出PyQt5过时\",\n      \"uuid\": \"e98da3ed-00bb-4dcd-baa3-dcf85e687a50\",\n      \"details\": \"\",\n      \"color\": [234, 179, 8, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-208, -545],\n      \"size\": [380.89593505859375, 76],\n      \"text\": \"ZTY将项目改为tauri框架\",\n      \"uuid\": \"01745060-bceb-48e2-8b35-8a85e043d6cf\",\n      \"details\": \"\",\n      \"color\": [234, 179, 8, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-143, -145],\n      \"size\": [252, 124],\n      \"text\": \"Littlefean\\n发布第二期视频\",\n      \"uuid\": \"c942b7a4-c968-4aa1-869e-9ae260402b4d\",\n      \"details\": \"\",\n      \"color\": [22, 163, 74, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-158, -685],\n      \"size\": [281.98399353027344, 76],\n      \"text\": \"项目中添加AI功能\",\n      \"uuid\": \"9737cb11-280a-4f32-8e5c-ce43228d7310\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-176, -310],\n      \"size\": [317.34393310546875, 124],\n      \"text\": \"Littlefan继续在tauri\\n上重写原有功能\",\n      \"uuid\": \"9456105a-3a13-4634-8ea2-eee404383bc2\",\n      \"details\": \"\",\n      \"color\": [22, 163, 74, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [309.12676665962647, -685.3256757509188],\n      \"size\": [382.30389404296875, 124],\n      \"text\": \"ZTY把github的README\\n移动到de域名的网站上\",\n      \"uuid\": \"02798310-1809-4883-9243-c3c15419c7a8\",\n      \"details\": \"\",\n      \"color\": [234, 179, 8, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-194, 229],\n      \"size\": [353.08795166015625, 124],\n      \"text\": \"tauri功能重写基本完毕\\n发布1.0.0宣传片\",\n      \"uuid\": \"655564da-c94d-41bc-8127-39bedb6e48d8\",\n      \"details\": \"\",\n      \"color\": [22, 163, 74, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1117, 45],\n      \"size\": [220, 76],\n      \"text\": \"有运营成本了\",\n      \"uuid\": \"8f3947d9-c37f-4389-a713-6e3b5cc8f51e\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [679, -3],\n      \"size\": [348, 172],\n      \"text\": \"有很多人进群\\n后来发现人数不够用了\\n开了个QQ会员\",\n      \"uuid\": \"f1bd3f0d-34e1-46df-8354-d8b79d6d1736\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [237, -145],\n      \"size\": [188, 76],\n      \"text\": \"观看量较多\",\n      \"uuid\": \"d70f1280-ffa6-49ba-a385-0c24377308c0\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [679, -193],\n      \"size\": [439.8079833984375, 124],\n      \"text\": \"ZTY专门为此项目买了个域名\\n并移动了文档到新域名\",\n      \"uuid\": \"50e81a06-7e00-4f0f-8011-cdbc671e36b1\",\n      \"details\": \"\",\n      \"color\": [234, 179, 8, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [395, 255],\n      \"size\": [348.6719665527344, 124],\n      \"text\": \"有观众特意进群发红包\\n并提醒在github开赞助\",\n      \"uuid\": \"18a8a15b-7630-43c7-b0fe-fc6c84104ede\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1101, 255],\n      \"size\": [252, 124],\n      \"text\": \"决定在官网开启\\n赞助\",\n      \"uuid\": \"bf88e463-1dc7-490f-90ed-339191c7cab4\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1037, 552],\n      \"size\": [316, 76],\n      \"text\": \"有群友希望有微信群\",\n      \"uuid\": \"9d441349-fe73-4c4c-974d-888ab3610fb9\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [647, 504],\n      \"size\": [338.9119873046875, 124],\n      \"text\": \"Rutubet说不用开会员\\n搞分群也行\",\n      \"uuid\": \"902269b4-fc9e-4bb7-ad94-6ace53342755\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-927, -829],\n      \"size\": [233.72793579101562, 76],\n      \"text\": \"放弃liren-side\",\n      \"uuid\": \"b336d8b4-f8f8-4778-a3ed-2060386130fc\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1277, -1160],\n      \"size\": [299.64794921875, 124],\n      \"text\": \"VisualFile逐渐发现\\n实用性没有特别高\",\n      \"uuid\": \"82e75cc6-cc10-4e2e-aa24-96ce418e8f47\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-749, 231],\n      \"size\": [380, 172],\n      \"text\": \"Rutubet修\\n贝塞尔曲线样子\\n但反复感觉不如手动调整\",\n      \"uuid\": \"34b2b64d-acf0-4475-9bd0-9d5f37f6d2f3\",\n      \"details\": \"\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-723, 463],\n      \"size\": [327.42401123046875, 76],\n      \"text\": \"决定设计一种CR曲线\",\n      \"uuid\": \"1976fab6-9aca-4424-b7ec-d901118e0580\",\n      \"details\": \"\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-772, -145],\n      \"size\": [426.4959411621094, 124],\n      \"text\": \"Littlefean希望增强管理功能\\n设计并增加了自动计算节点\",\n      \"uuid\": \"3486b3eb-e9ac-4c91-a5cd-38ff7e868fe1\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1242, -145],\n      \"size\": [380, 124],\n      \"text\": \"逐渐发现这里似乎能\\n做成基于图论的编程语言\",\n      \"uuid\": \"3fb77a00-31ba-4032-984d-ca47efea42ad\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-157, 504],\n      \"size\": [279.8079833984375, 76],\n      \"text\": \"ZTY提出插件系统\",\n      \"uuid\": \"e8844af9-3be5-49fc-98f5-6906116c9575\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-643, -569],\n      \"size\": [348, 124],\n      \"text\": \"很早之前有尝试体验过\\n这个框架\",\n      \"uuid\": \"3f025405-3ac7-42ec-b252-a36583ee0c33\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1057, -593],\n      \"size\": [336.9599609375, 172],\n      \"text\": \"ZTY和Littlefean都\\n熟悉前端开发\\n比特山就是用的React\",\n      \"uuid\": \"a3bb3183-70f4-45e2-944f-d795f6b32456\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-195, -434],\n      \"size\": [355.67987060546875, 76],\n      \"text\": \"选定react+tailwindcss\",\n      \"uuid\": \"32c6c052-4bc2-42c6-9d7d-23c8e4f90a0a\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-651, -434],\n      \"size\": [356.86395263671875, 76],\n      \"text\": \"ZTY提到react用的人多\",\n      \"uuid\": \"e1505328-0b60-4d15-9994-3bf91142dbb4\",\n      \"details\": \"\",\n      \"color\": [234, 179, 8, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [173, 610],\n      \"size\": [316, 76],\n      \"text\": \"收到了很多用户反馈\",\n      \"uuid\": \"ea51d7fd-c6d9-4377-a32e-4c030cea33f9\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [158, 737],\n      \"size\": [347.4239807128906, 124],\n      \"text\": \"太多mac用户反馈问题\\n但mac问题很难修复\",\n      \"uuid\": \"83f241f7-2b38-42f7-a719-42d2fc4898ba\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [237, 913],\n      \"size\": [188, 76],\n      \"text\": \"开发网页版\",\n      \"uuid\": \"6e2f248c-7a9c-4078-b6e4-e7ceb27f2d64\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [3009, -1284],\n      \"size\": [348, 124],\n      \"text\": \"2024.9\\n最开始，节点无法换行\",\n      \"uuid\": \"fbf81bb2-6398-4e3d-8ed8-8d433efdbec9\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [2797, -1088],\n      \"size\": [348, 76],\n      \"text\": \"为什么当时没做换行？\",\n      \"uuid\": \"6fe33d76-ca04-4e9d-b3f5-26d15ed25dcf\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [2103, -1136],\n      \"size\": [572, 220],\n      \"text\": \"因为用的pyqt的canvas渲染\\n要换行还要手动计算每行的宽度和高度\\n然后更新节点矩形的碰撞箱\\n非常麻烦，所以一直没顾得做换行\",\n      \"uuid\": \"534182c2-60d6-4555-b7b2-07b7d0e28416\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [3167, -1042],\n      \"size\": [444, 268],\n      \"text\": \"有观众在第一期视频提到：\\n能不能在节点下面写详细注释\\n详细注释可以换行\\n且不用计算碰撞箱，很好做\\n就直接加上了。\",\n      \"uuid\": \"2bb5b822-3634-4f95-9892-20a0b2192b65\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [3386, -709],\n      \"size\": [540, 124],\n      \"text\": \"所以当时的节点注释\\n是为了解决节点内容无法换行的问题\",\n      \"uuid\": \"6d99e44e-7ec9-454e-9d6a-3c310fbb09f2\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [2647, -873],\n      \"size\": [293.1199645996094, 76],\n      \"text\": \"后来用Tauri重写了\",\n      \"uuid\": \"cd21f186-32bd-4275-9e31-3f8dfd981a9d\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [2838, -661],\n      \"size\": [519.5199584960938, 172],\n      \"text\": \"节点换行在前端\\nCanvas测量字体宽度工具的支持下\\n硬是给手搓做出来了\",\n      \"uuid\": \"47ce4db8-81d4-4e2d-a032-53b56be5132f\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [3418, -355],\n      \"size\": [476, 124],\n      \"text\": \"此时节点详细信息的存在不再是\\n成为解决“节点无法换行”的理由\",\n      \"uuid\": \"8a8bdc6b-a78b-4d5a-8d58-51512ddaa153\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [2041, -458],\n      \"size\": [316, 124],\n      \"text\": \"有用户希望节点注释\\n支持markdown语法\",\n      \"uuid\": \"9d9a8805-d538-4e98-a6aa-9a7b22ddfd27\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1991, -662],\n      \"size\": [365.69598388671875, 124],\n      \"text\": \"用户希望支持Latex公式\\nmarkdown节点\",\n      \"uuid\": \"fa83a554-56ef-4854-a876-9081c09689e9\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [2529, -706],\n      \"size\": [261.1519775390625, 76],\n      \"text\": \"发布了1.0宣传片\",\n      \"uuid\": \"9189fd92-04b5-4f51-a207-a8387994f541\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [2444, -521],\n      \"size\": [156, 76],\n      \"text\": \"收到反馈\",\n      \"uuid\": \"9b43879a-23e0-4cef-b5e0-f8c5def66a9f\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [2445, -410],\n      \"size\": [388.9919128417969, 124],\n      \"text\": \"用Canvas渲染markdown\\n稍微繁杂\",\n      \"uuid\": \"a86e7968-c44e-4eac-8cda-4258192f4d9a\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [2939, -311],\n      \"size\": [377.4719543457031, 172],\n      \"text\": \"打算将节点详细信息\\n（节点注释）\\n承载这个markdown功能\",\n      \"uuid\": \"1f9b45c4-0413-4cc0-91f1-e544ba3fd50b\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [2585, -239],\n      \"size\": [203.9359588623047, 76],\n      \"text\": \"增加VEditor\",\n      \"uuid\": \"382bf4dc-73b5-4afe-a8fb-484d75eb33f3\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [2441, -99],\n      \"size\": [348, 220],\n      \"text\": \"最开始有两种输入方式\\n一种是纯文本输入\\n另一种是VEditor的\\nmarkdown输入\",\n      \"uuid\": \"8b95cbe3-2812-473f-aaa2-0a2dbe28f374\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [2345, 209],\n      \"size\": [444, 76],\n      \"text\": \"后来纯文本输入的方式关闭了\",\n      \"uuid\": \"3929a2b5-c59d-43b4-b94d-c571115ea2ff\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [2907, 137],\n      \"size\": [409.4719543457031, 220],\n      \"text\": \"导致节点注释内容\\n强制变成了markdown语法\\n在画布上渲染出来是\\n有很多空白行间隔的样子\",\n      \"uuid\": \"b9783df0-a736-44e8-be4e-67ad260c37a8\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [3418, 233],\n      \"size\": [252, 124],\n      \"text\": \"出现矛盾\\n两种习惯的矛盾\",\n      \"uuid\": \"5ee606e0-f77a-411f-9dc5-f00f212119a7\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [3007, 441],\n      \"size\": [764, 220],\n      \"text\": \"在节点注释中输入少量内容并直观显示在画布上\\n与\\n在节点注释中输入大量笔记文本并网状组织各种笔记\\n的两种使用策略和习惯的矛盾\",\n      \"uuid\": \"3b930433-f146-42a5-984d-68802e05c51a\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [3489, -1384],\n      \"size\": [437.47198486328125, 220],\n      \"text\": \"观众可能认为：\\n既然你的节点不能换行\\n那么这个节点的用途是“标题”\\n所以需要增添“详细内容”\",\n      \"uuid\": \"8e706cc5-b6be-4994-b453-597af51e517a\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [2799, 796],\n      \"size\": [362.81591796875, 76],\n      \"text\": \"思路1：（群友Partist）\",\n      \"uuid\": \"1dde98b7-bb86-4de2-a926-0dbb30d11e2a\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [3387, 820],\n      \"size\": [334.4320068359375, 172],\n      \"text\": \"思路2：（第一反应）\\n出一种自定义选项\\n让用户切换两种形态\",\n      \"uuid\": \"c2d445e7-995b-42da-98d6-35293846b280\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [2761, 1087],\n      \"size\": [263.00794982910156, 124],\n      \"text\": \"学习heptabse的\\n卡片节点\",\n      \"uuid\": \"9cafe9c3-7631-4d5e-8b85-7f6a57aeaad8\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [2214, 796],\n      \"size\": [418.783935546875, 220],\n      \"text\": \"直接出一个markdown节点\\r\\n画布上渲染输入的标题\\r\\n选中后自动弹出markdown\\r\\n编辑器\",\n      \"uuid\": \"153bd3c5-c7bc-420e-add6-4f1662434d13\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [2506, 1326],\n      \"size\": [316, 172],\n      \"text\": \"需要做一个文本节点\\n转换成卡片节点的\\n操作方法\",\n      \"uuid\": \"81a319c4-013f-4955-8a84-3b9791fab519\",\n      \"details\": \"\",\n      \"color\": [92, 47, 96, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [2266, 1087],\n      \"size\": [316, 124],\n      \"text\": \"为了兼容一些用户的\\n已有的文件\",\n      \"uuid\": \"8b71bbc0-3d0d-47f1-a46f-52b8d3714ad3\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [3229, 1176],\n      \"size\": [492, 268],\n      \"text\": \"问题：\\n如果用了markdown语法\\n写详细信息\\n它在画布上直接在文本底部渲染\\n会以md原格式渲染，非常不美观\",\n      \"uuid\": \"ac2d50a1-54b3-4445-9cb0-6b5e9d2d22a5\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [2004, 1326],\n      \"size\": [444, 124],\n      \"text\": \"需要让markdown节点\\n样子看起来和文本节点不一样\",\n      \"uuid\": \"2fea8696-53be-480f-987d-a85ec55d6481\",\n      \"details\": \"\",\n      \"color\": [92, 47, 96, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [2506, 1538],\n      \"size\": [380, 172],\n      \"text\": \"真的要再加个快捷键吗？\\n快捷键数量好像在以一种\\n很快的速度增长\",\n      \"uuid\": \"a6ef23cb-e6b3-4c8c-b2fd-92888a8da175\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [2087, 1504],\n      \"size\": [220, 124],\n      \"text\": \"增加阴影\\n或者边框变粗\",\n      \"uuid\": \"07867c7a-f4c1-41bc-8414-85070d8c5bba\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [2355, 1769],\n      \"size\": [680.3839721679688, 220],\n      \"text\": \"似乎底部可以出个工具箱了\\n可以选择性添加：\\n文本节点、Section、质点、markdown节点\\n以拖拽的形式添加到舞台上，学习FigJam白板\",\n      \"uuid\": \"8666e25c-86e6-4a21-b8ca-552353436679\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1961, -1464],\n      \"size\": [1995.4719848632812, 3483],\n      \"uuid\": \"879af6d9-8cbd-4a37-800d-55b8e6a1c77c\",\n      \"text\": \"节点问题\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\n        \"fbf81bb2-6398-4e3d-8ed8-8d433efdbec9\",\n        \"6fe33d76-ca04-4e9d-b3f5-26d15ed25dcf\",\n        \"534182c2-60d6-4555-b7b2-07b7d0e28416\",\n        \"2bb5b822-3634-4f95-9892-20a0b2192b65\",\n        \"6d99e44e-7ec9-454e-9d6a-3c310fbb09f2\",\n        \"cd21f186-32bd-4275-9e31-3f8dfd981a9d\",\n        \"47ce4db8-81d4-4e2d-a032-53b56be5132f\",\n        \"8a8bdc6b-a78b-4d5a-8d58-51512ddaa153\",\n        \"9d9a8805-d538-4e98-a6aa-9a7b22ddfd27\",\n        \"fa83a554-56ef-4854-a876-9081c09689e9\",\n        \"9189fd92-04b5-4f51-a207-a8387994f541\",\n        \"9b43879a-23e0-4cef-b5e0-f8c5def66a9f\",\n        \"a86e7968-c44e-4eac-8cda-4258192f4d9a\",\n        \"1f9b45c4-0413-4cc0-91f1-e544ba3fd50b\",\n        \"382bf4dc-73b5-4afe-a8fb-484d75eb33f3\",\n        \"8b95cbe3-2812-473f-aaa2-0a2dbe28f374\",\n        \"3929a2b5-c59d-43b4-b94d-c571115ea2ff\",\n        \"b9783df0-a736-44e8-be4e-67ad260c37a8\",\n        \"5ee606e0-f77a-411f-9dc5-f00f212119a7\",\n        \"3b930433-f146-42a5-984d-68802e05c51a\",\n        \"8e706cc5-b6be-4994-b453-597af51e517a\",\n        \"1dde98b7-bb86-4de2-a926-0dbb30d11e2a\",\n        \"c2d445e7-995b-42da-98d6-35293846b280\",\n        \"9cafe9c3-7631-4d5e-8b85-7f6a57aeaad8\",\n        \"153bd3c5-c7bc-420e-add6-4f1662434d13\",\n        \"81a319c4-013f-4955-8a84-3b9791fab519\",\n        \"8b71bbc0-3d0d-47f1-a46f-52b8d3714ad3\",\n        \"ac2d50a1-54b3-4445-9cb0-6b5e9d2d22a5\",\n        \"2fea8696-53be-480f-987d-a85ec55d6481\",\n        \"a6ef23cb-e6b3-4c8c-b2fd-92888a8da175\",\n        \"07867c7a-f4c1-41bc-8414-85070d8c5bba\",\n        \"8666e25c-86e6-4a21-b8ca-552353436679\"\n      ],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [464, -2532],\n      \"size\": [252, 76],\n      \"text\": \"其他节点图记录\",\n      \"uuid\": \"05f7bcfb-2712-4b8f-a8e9-4bf13a85aca6\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [939, -2532],\n      \"size\": [130.49595642089844, 76],\n      \"text\": \"noteey\",\n      \"uuid\": \"94ef8959-7b46-4ab2-a763-67446f689d49\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [985, -2334],\n      \"size\": [300, 150],\n      \"url\": \"https://mefo.cc/\",\n      \"title\": \"链接\",\n      \"uuid\": \"374d658d-0dc9-4c34-8d98-19c8bb1a3272\",\n      \"type\": \"core:url_node\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0]\n    },\n    {\n      \"location\": [759, -2532],\n      \"size\": [136.3839569091797, 76],\n      \"text\": \"FigJam\",\n      \"uuid\": \"f569f493-5f4b-413c-bd42-e85d22ae96e5\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1122, -2532],\n      \"size\": [348, 76],\n      \"text\": \"飞书文档的节点图功能\",\n      \"uuid\": \"6da7e1bd-f552-449d-81ac-c43d5c946de5\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [759, -2261],\n      \"size\": [189.18394470214844, 76],\n      \"text\": \"Heptabase\",\n      \"uuid\": \"e14adfc2-4201-475a-8c6d-12d368f3f0a2\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [830, -2102],\n      \"size\": [348, 76],\n      \"text\": \"这些收费软件都太强了\",\n      \"uuid\": \"1613eda8-58dd-47f4-ae9e-53cf65c61935\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [782, -1967],\n      \"size\": [444, 172],\n      \"text\": \"但功能强悍意味着通用性降低\\n可能会在和其他软件联动方面\\n有不足\",\n      \"uuid\": \"a424f562-462c-4fdc-bf04-999b88c9cdc2\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5454, -1136],\n      \"size\": [270.5599365234375, 124],\n      \"text\": \".pg\\npostgreSQL用了\",\n      \"uuid\": \"6df586a4-1aa6-4658-8219-5b63ea4d8f64\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5458, -970],\n      \"size\": [280.7359619140625, 124],\n      \"text\": \".gp\\n是gnu plot的格式\",\n      \"uuid\": \"721f0055-1cbb-44e1-af0b-47d310f43928\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5454, -709],\n      \"size\": [292.9279479980469, 124],\n      \"text\": \".prog\\n太常见了 program\",\n      \"uuid\": \"85b347b1-6aa2-45e4-8227-4e6f5414cda5\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5454, -850],\n      \"size\": [174.68797302246094, 76],\n      \"text\": \".pjh   别扭\",\n      \"uuid\": \"77aca5dd-0fe8-4a03-8e53-784e8772c57c\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5454, -458],\n      \"size\": [87.19998168945312, 76],\n      \"text\": \".prg\",\n      \"uuid\": \"0247e567-5fd2-4b09-aee3-bd46d32350a0\",\n      \"details\": \"\",\n      \"color\": [22, 163, 74, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5454, -145],\n      \"size\": [243.9359130859375, 76],\n      \"text\": \".project-graph\",\n      \"uuid\": \"bc9b0fba-e747-4d1f-bbbb-d6c4bdab9f98\",\n      \"details\": \"\",\n      \"color\": [22, 163, 74, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5454, -355],\n      \"size\": [95.93597412109375, 76],\n      \"text\": \".prjg\",\n      \"uuid\": \"7e5ceb8c-ed35-4324-bd02-fb6f7733f94a\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5454, -263],\n      \"size\": [113.34397888183594, 76],\n      \"text\": \".pgph\",\n      \"uuid\": \"21a1e677-4923-43ae-b483-4d2b5c18d576\",\n      \"details\": \"\",\n      \"color\": [22, 163, 74, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5696, -287],\n      \"size\": [496.73583984375, 124],\n      \"text\": \"dos系统上会截断\\n变成application/pgp-encrypted\",\n      \"uuid\": \"a3d1aa4a-1226-40c0-8a77-370f29c4a5e0\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5454, -565],\n      \"size\": [87.64797973632812, 76],\n      \"text\": \".pgr\",\n      \"uuid\": \"ee3be6f3-9e07-4baa-b833-fa71d428fce1\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [4949, -709],\n      \"size\": [230.30392456054688, 124],\n      \"text\": \"project graph\\n后缀名决策\",\n      \"uuid\": \"f9ba6b35-9c3e-48f5-ab00-8d55494c6bd6\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5352, -1136],\n      \"size\": [48.511993408203125, 76],\n      \"text\": \"0\",\n      \"uuid\": \"90ba22b2-8af5-4673-8584-4e64cb354196\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5360, -946],\n      \"size\": [41.43998718261719, 76],\n      \"text\": \"1\",\n      \"uuid\": \"958bfbe9-45dc-48bd-8582-bdc499bd6f0a\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5362, -850],\n      \"size\": [46.43199157714844, 76],\n      \"text\": \"2\",\n      \"uuid\": \"d2de60e7-655a-423f-9ffa-983f2e298b72\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5362, -709],\n      \"size\": [47.167999267578125, 76],\n      \"text\": \"3\",\n      \"uuid\": \"c789603d-636a-4a46-b751-a82a2570516d\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5370, -565],\n      \"size\": [47.23199462890625, 76],\n      \"text\": \"4\",\n      \"uuid\": \"2a66a301-c37d-4376-8557-cb990847cd0a\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5371, -457],\n      \"size\": [47.67999267578125, 76],\n      \"text\": \"6\",\n      \"uuid\": \"09927b3a-3181-44c0-8f65-72a4d2b00777\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5369, -355],\n      \"size\": [45.15199279785156, 76],\n      \"text\": \"7\",\n      \"uuid\": \"e96b53f6-00b1-4a14-95db-9b6c2f9b846c\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5369, -263],\n      \"size\": [48.41600036621094, 76],\n      \"text\": \"8\",\n      \"uuid\": \"106ad03b-bd13-420b-9daa-54c701186f3c\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5369, -145],\n      \"size\": [47.519989013671875, 76],\n      \"text\": \"9\",\n      \"uuid\": \"b08ff688-2f7f-4522-9e81-6ba30b55ad0b\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5884, -461],\n      \"size\": [308.4799499511719, 124],\n      \"text\": \"但tauri打包出的exe\\n已经win10起步了\",\n      \"uuid\": \"240bb2d9-be7c-4c9a-8c17-e6d3def93496\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5454, -27],\n      \"size\": [92.7039794921875, 76],\n      \"text\": \".pjg \",\n      \"uuid\": \"ef0fc2fa-2500-4381-bd26-46a223daae62\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5353, -27],\n      \"size\": [61.95198059082031, 76],\n      \"text\": \"10\",\n      \"uuid\": \"e5bb8e64-d442-47aa-8519-84b77dfdcd11\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5753, -145],\n      \"size\": [494.27191162109375, 76],\n      \"text\": \"xmind的后缀是.xmind，5个字符\",\n      \"uuid\": \"d36dd5c5-e42a-40ca-8a2a-327e9dc94558\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5360, 92],\n      \"size\": [54.879974365234375, 76],\n      \"text\": \"11\",\n      \"uuid\": \"f4eedb35-0c1b-4766-bd11-c7b4fa8be69d\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5454, 174],\n      \"size\": [142.68795776367188, 76],\n      \"text\": \".pgraph\",\n      \"uuid\": \"a5cee97a-9a91-4ea2-bab9-66759fa03524\",\n      \"details\": \"\",\n      \"color\": [22, 163, 74, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5354, 175],\n      \"size\": [59.871978759765625, 76],\n      \"text\": \"12\",\n      \"uuid\": \"26ea2d87-0f1a-49e5-ae04-c3d0128afc8d\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5454, 93],\n      \"size\": [122.62396240234375, 76],\n      \"text\": \".graph\",\n      \"uuid\": \"6e7ce1b6-c554-41d0-b4b8-a703ac89e393\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5670, 93],\n      \"size\": [220, 76],\n      \"text\": \"可能太通用了\",\n      \"uuid\": \"5aaaed47-6bbb-4385-9faf-c18c9577b9d6\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5354, 282],\n      \"size\": [60.60798645019531, 76],\n      \"text\": \"13\",\n      \"uuid\": \"0ff3a4ca-fc9f-437d-be0c-fc1bf2cfaaad\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5453, 283],\n      \"size\": [93.91998291015625, 76],\n      \"text\": \".pgh\",\n      \"uuid\": \"32afca98-81bf-4733-8d31-29fd6d68bd50\",\n      \"details\": \"\",\n      \"color\": [22, 163, 74, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5454, 377],\n      \"size\": [106.01597595214844, 76],\n      \"text\": \".prgh\",\n      \"uuid\": \"bae09559-119f-4281-8641-962438805b72\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5354, 377],\n      \"size\": [60.67198181152344, 76],\n      \"text\": \"14\",\n      \"uuid\": \"c22e4fd0-9e4f-44a1-bbf2-a3f85b3ed791\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [6097, -709],\n      \"size\": [325.02398681640625, 124],\n      \"text\": \".prproj\\nPR剪辑软件的后缀名\",\n      \"uuid\": \"ae655701-ce23-4ef8-8ec4-e7623c2cd5de\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5454, 464],\n      \"size\": [103.64797973632812, 76],\n      \"text\": \".pgm\",\n      \"uuid\": \"9f7bd25a-b0bd-4d74-b6b6-28faac99e089\",\n      \"details\": \"\",\n      \"color\": [22, 163, 74, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5649, 463],\n      \"size\": [261.1519470214844, 76],\n      \"text\": \"m代表mindmap\",\n      \"uuid\": \"5611367f-f2cb-4dac-8cd3-7cac03c3c2d7\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5347, 463],\n      \"size\": [60.95997619628906, 76],\n      \"text\": \"15\",\n      \"uuid\": \"31bb2e20-ac99-4717-b5eb-f78b5db0bee4\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [4507, -254],\n      \"size\": [592.671875, 76],\n      \"text\": \"#COLLECT_NODE_NAME_BY_RGBA#\",\n      \"uuid\": \"f2321f29-8355-47e8-bcda-c252d43b8ac8\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [4559.53596496582, -457],\n      \"size\": [64.86398315429688, 76],\n      \"text\": \"22\",\n      \"uuid\": \"8123c9ba-0ca3-46be-a415-486c35228745\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [4633.647933959961, -457],\n      \"size\": [80.28797912597656, 76],\n      \"text\": \"163\",\n      \"uuid\": \"eccfc8b1-c1cb-4f4b-8f57-e366f53ef4b1\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [4772.295944213867, -457],\n      \"size\": [62.079986572265625, 76],\n      \"text\": \"74\",\n      \"uuid\": \"fe577cf9-0071-4fea-875c-61cc275f6107\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [4903, -457],\n      \"size\": [41.43998718261719, 76],\n      \"text\": \"1\",\n      \"uuid\": \"8e9ae469-f7ca-4e8c-a51e-a7693a866226\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [4436, -62],\n      \"size\": [87.19998168945312, 76],\n      \"text\": \".prg\",\n      \"uuid\": \"1f97c8b9-e936-40e9-8bb9-087ab2953f74\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [4470, 47],\n      \"size\": [243.9359130859375, 76],\n      \"text\": \".project-graph\",\n      \"uuid\": \"a06f227e-4111-47b0-815b-8ece1ae77b69\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [4651, 169],\n      \"size\": [113.34397888183594, 76],\n      \"text\": \".pgph\",\n      \"uuid\": \"6dd1df1e-9f9c-4041-9cb2-16ebf55f932a\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [4750, 260],\n      \"size\": [142.68795776367188, 76],\n      \"text\": \".pgraph\",\n      \"uuid\": \"0e840fae-f5f3-4104-87c0-4720942aeea3\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [4903, 392],\n      \"size\": [93.91998291015625, 76],\n      \"text\": \".pgh\",\n      \"uuid\": \"84885cfa-b60d-4373-9bb9-bb36084ca129\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5054, 502],\n      \"size\": [103.64797973632812, 76],\n      \"text\": \".pgm\",\n      \"uuid\": \"949bd876-f9b4-43dc-9d08-822cf52e8b7f\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [5649, 611],\n      \"size\": [791.0718994140625, 172],\n      \"text\": \"DeepSeek:\\n与知名图像格式Portable Graymap（.pgm）完全冲突\\n用户可能误以为软件输出的是灰度图像\",\n      \"uuid\": \"46c8e6ae-f8c6-470e-a3de-74b4016e43f7\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [4406, -1216],\n      \"size\": [3518.471923828125, 2029],\n      \"uuid\": \"0988ad38-53c4-403a-b56d-c123157951ea\",\n      \"text\": \"后缀名决策\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\n        \"6df586a4-1aa6-4658-8219-5b63ea4d8f64\",\n        \"721f0055-1cbb-44e1-af0b-47d310f43928\",\n        \"85b347b1-6aa2-45e4-8227-4e6f5414cda5\",\n        \"77aca5dd-0fe8-4a03-8e53-784e8772c57c\",\n        \"0247e567-5fd2-4b09-aee3-bd46d32350a0\",\n        \"bc9b0fba-e747-4d1f-bbbb-d6c4bdab9f98\",\n        \"7e5ceb8c-ed35-4324-bd02-fb6f7733f94a\",\n        \"21a1e677-4923-43ae-b483-4d2b5c18d576\",\n        \"a3d1aa4a-1226-40c0-8a77-370f29c4a5e0\",\n        \"ee3be6f3-9e07-4baa-b833-fa71d428fce1\",\n        \"f9ba6b35-9c3e-48f5-ab00-8d55494c6bd6\",\n        \"90ba22b2-8af5-4673-8584-4e64cb354196\",\n        \"958bfbe9-45dc-48bd-8582-bdc499bd6f0a\",\n        \"d2de60e7-655a-423f-9ffa-983f2e298b72\",\n        \"c789603d-636a-4a46-b751-a82a2570516d\",\n        \"2a66a301-c37d-4376-8557-cb990847cd0a\",\n        \"09927b3a-3181-44c0-8f65-72a4d2b00777\",\n        \"e96b53f6-00b1-4a14-95db-9b6c2f9b846c\",\n        \"106ad03b-bd13-420b-9daa-54c701186f3c\",\n        \"b08ff688-2f7f-4522-9e81-6ba30b55ad0b\",\n        \"240bb2d9-be7c-4c9a-8c17-e6d3def93496\",\n        \"ef0fc2fa-2500-4381-bd26-46a223daae62\",\n        \"e5bb8e64-d442-47aa-8519-84b77dfdcd11\",\n        \"d36dd5c5-e42a-40ca-8a2a-327e9dc94558\",\n        \"f4eedb35-0c1b-4766-bd11-c7b4fa8be69d\",\n        \"a5cee97a-9a91-4ea2-bab9-66759fa03524\",\n        \"26ea2d87-0f1a-49e5-ae04-c3d0128afc8d\",\n        \"6e7ce1b6-c554-41d0-b4b8-a703ac89e393\",\n        \"5aaaed47-6bbb-4385-9faf-c18c9577b9d6\",\n        \"0ff3a4ca-fc9f-437d-be0c-fc1bf2cfaaad\",\n        \"32afca98-81bf-4733-8d31-29fd6d68bd50\",\n        \"bae09559-119f-4281-8641-962438805b72\",\n        \"c22e4fd0-9e4f-44a1-bbf2-a3f85b3ed791\",\n        \"ae655701-ce23-4ef8-8ec4-e7623c2cd5de\",\n        \"9f7bd25a-b0bd-4d74-b6b6-28faac99e089\",\n        \"5611367f-f2cb-4dac-8cd3-7cac03c3c2d7\",\n        \"31bb2e20-ac99-4717-b5eb-f78b5db0bee4\",\n        \"f2321f29-8355-47e8-bcda-c252d43b8ac8\",\n        \"8123c9ba-0ca3-46be-a415-486c35228745\",\n        \"eccfc8b1-c1cb-4f4b-8f57-e366f53ef4b1\",\n        \"fe577cf9-0071-4fea-875c-61cc275f6107\",\n        \"8e9ae469-f7ca-4e8c-a51e-a7693a866226\",\n        \"1f97c8b9-e936-40e9-8bb9-087ab2953f74\",\n        \"a06f227e-4111-47b0-815b-8ece1ae77b69\",\n        \"6dd1df1e-9f9c-4041-9cb2-16ebf55f932a\",\n        \"0e840fae-f5f3-4104-87c0-4720942aeea3\",\n        \"84885cfa-b60d-4373-9bb9-bb36084ca129\",\n        \"949bd876-f9b4-43dc-9d08-822cf52e8b7f\",\n        \"46c8e6ae-f8c6-470e-a3de-74b4016e43f7\",\n        \"09e0c318-2bfc-4c74-8513-f094002f8a06\",\n        \"fb963815-f9bc-4c75-8cf8-1daceb0ce098\",\n        \"1ce9920b-364a-4dfa-a4f8-732029b296bc\",\n        \"6a596594-5bd3-498f-ac4c-1324673d38d6\"\n      ],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [6569, -313],\n      \"size\": [1325.471923828125, 412],\n      \"text\": \"我投pgh（权重3），pgph（权重2），pgraph（权重1）\\n\\n我的理由是：\\n\\n虽然pgh和pgph都没有太大辨识度，因为没有r，但要有r就要有a，但是辨识度不是必要的，\\n因为用户都是通过学习认识后缀对应的文件（谁出生就知道exe是干什么的）\\n\\n后缀简短+好输入是大优势。pgh单手就能输入，pgph手要跳两次，pgraph嗯就不说了\",\n      \"uuid\": \"09e0c318-2bfc-4c74-8513-f094002f8a06\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [6569, -413],\n      \"size\": [124, 76],\n      \"text\": \"行止：\",\n      \"uuid\": \"fb963815-f9bc-4c75-8cf8-1daceb0ce098\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [6569, 260],\n      \"size\": [118.91195678710938, 76],\n      \"text\": \"pgh+1\",\n      \"uuid\": \"1ce9920b-364a-4dfa-a4f8-732029b296bc\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [6569, 135],\n      \"size\": [92, 76],\n      \"text\": \"欸嘿\",\n      \"uuid\": \"6a596594-5bd3-498f-ac4c-1324673d38d6\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    }\n  ],\n  \"associations\": [\n    {\n      \"source\": \"4f0321af-a8c7-406f-b440-b2f5ceaf3346\",\n      \"target\": \"359ee3c0-853c-42d5-a394-e76ab170ab94\",\n      \"text\": \"\",\n      \"uuid\": \"e5e43326-1b6f-4125-9944-620133cf5473\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"359ee3c0-853c-42d5-a394-e76ab170ab94\",\n      \"target\": \"0c8606ff-17ab-4054-a6cd-5167d9bb8f2b\",\n      \"text\": \"\",\n      \"uuid\": \"00298925-8910-4a2e-a9ce-778d8e782907\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.99, 0.5],\n      \"targetRectRate\": [0.01, 0.5]\n    },\n    {\n      \"source\": \"0c8606ff-17ab-4054-a6cd-5167d9bb8f2b\",\n      \"target\": \"4de776a3-0d8d-4491-9770-8fe2ea119748\",\n      \"text\": \"\",\n      \"uuid\": \"c2c28c3e-1d04-4d51-8096-88c9c51a6575\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"4de776a3-0d8d-4491-9770-8fe2ea119748\",\n      \"target\": \"f84f72e5-c43b-4088-b158-0c5e39435aee\",\n      \"text\": \"\",\n      \"uuid\": \"6d35f410-bb8d-4c52-aa77-f92ec3b484bc\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"f84f72e5-c43b-4088-b158-0c5e39435aee\",\n      \"target\": \"31bf362a-3845-4cff-8751-c810ade383eb\",\n      \"text\": \"\",\n      \"uuid\": \"bc81b98c-15e9-4f48-8bb6-f97a06dd792c\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"31bf362a-3845-4cff-8751-c810ade383eb\",\n      \"target\": \"e0510bf5-8c9a-471e-b85d-8cedcbe3010d\",\n      \"text\": \"\",\n      \"uuid\": \"df7ad70b-41e5-43d0-bb15-3ea35d3f6d7f\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"e0510bf5-8c9a-471e-b85d-8cedcbe3010d\",\n      \"target\": \"e98da3ed-00bb-4dcd-baa3-dcf85e687a50\",\n      \"text\": \"\",\n      \"uuid\": \"bfcc70ac-1f1f-42d6-845b-cfe483b32e73\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"e98da3ed-00bb-4dcd-baa3-dcf85e687a50\",\n      \"target\": \"9737cb11-280a-4f32-8e5c-ce43228d7310\",\n      \"text\": \"\",\n      \"uuid\": \"8d8e57c0-7ec5-4afe-be85-64b4e41bc6c6\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"9737cb11-280a-4f32-8e5c-ce43228d7310\",\n      \"target\": \"01745060-bceb-48e2-8b35-8a85e043d6cf\",\n      \"text\": \"\",\n      \"uuid\": \"bbbd1526-cbde-4403-a8c3-7e6825c70ea4\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"e0510bf5-8c9a-471e-b85d-8cedcbe3010d\",\n      \"target\": \"02798310-1809-4883-9243-c3c15419c7a8\",\n      \"text\": \"\",\n      \"uuid\": \"1ca7ccdc-4696-4953-abc5-7dfb5783010b\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"c942b7a4-c968-4aa1-869e-9ae260402b4d\",\n      \"target\": \"655564da-c94d-41bc-8127-39bedb6e48d8\",\n      \"text\": \"\",\n      \"uuid\": \"8b2a91cd-2a71-452c-89d3-eb633138be6e\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"655564da-c94d-41bc-8127-39bedb6e48d8\",\n      \"target\": \"d70f1280-ffa6-49ba-a385-0c24377308c0\",\n      \"text\": \"\",\n      \"uuid\": \"caf87935-b798-4149-bafa-2ef645129b2a\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"d70f1280-ffa6-49ba-a385-0c24377308c0\",\n      \"target\": \"f1bd3f0d-34e1-46df-8354-d8b79d6d1736\",\n      \"text\": \"\",\n      \"uuid\": \"c4c980d6-aa2a-4389-bad9-5b3f78e8d8dd\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"f1bd3f0d-34e1-46df-8354-d8b79d6d1736\",\n      \"target\": \"8f3947d9-c37f-4389-a713-6e3b5cc8f51e\",\n      \"text\": \"\",\n      \"uuid\": \"b3724934-9f07-4d62-9a87-e12a1bbc7289\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"d70f1280-ffa6-49ba-a385-0c24377308c0\",\n      \"target\": \"50e81a06-7e00-4f0f-8011-cdbc671e36b1\",\n      \"text\": \"\",\n      \"uuid\": \"64368ba5-0cc7-4ae4-9346-d8cd74044a61\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"02798310-1809-4883-9243-c3c15419c7a8\",\n      \"target\": \"50e81a06-7e00-4f0f-8011-cdbc671e36b1\",\n      \"text\": \"\",\n      \"uuid\": \"7b8907f3-b46a-46ad-8450-c0ac9b9ceb83\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"50e81a06-7e00-4f0f-8011-cdbc671e36b1\",\n      \"target\": \"8f3947d9-c37f-4389-a713-6e3b5cc8f51e\",\n      \"text\": \"\",\n      \"uuid\": \"b7c00f0a-b749-4015-a241-09897644dbf6\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"8f3947d9-c37f-4389-a713-6e3b5cc8f51e\",\n      \"target\": \"bf88e463-1dc7-490f-90ed-339191c7cab4\",\n      \"text\": \"\",\n      \"uuid\": \"ce2cf90a-fe59-4329-a423-44390d8158d0\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"18a8a15b-7630-43c7-b0fe-fc6c84104ede\",\n      \"target\": \"bf88e463-1dc7-490f-90ed-339191c7cab4\",\n      \"text\": \"\",\n      \"uuid\": \"de288401-0535-4f0a-b13d-5e290536555f\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"d70f1280-ffa6-49ba-a385-0c24377308c0\",\n      \"target\": \"18a8a15b-7630-43c7-b0fe-fc6c84104ede\",\n      \"text\": \"\",\n      \"uuid\": \"45b6a38f-38c1-43f8-bbe1-67a4cb1c50ef\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"f1bd3f0d-34e1-46df-8354-d8b79d6d1736\",\n      \"target\": \"9d441349-fe73-4c4c-974d-888ab3610fb9\",\n      \"text\": \"\",\n      \"uuid\": \"fc728cc0-fb88-4874-be94-e15bf73592a7\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"f1bd3f0d-34e1-46df-8354-d8b79d6d1736\",\n      \"target\": \"902269b4-fc9e-4bb7-ad94-6ace53342755\",\n      \"text\": \"\",\n      \"uuid\": \"8d5d7234-7ff8-4957-ad4c-f4b5b65d71f6\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"f84f72e5-c43b-4088-b158-0c5e39435aee\",\n      \"target\": \"372b3ac3-3928-47fe-9c89-8747f0dd29ba\",\n      \"text\": \"\",\n      \"uuid\": \"f94f5dc9-0824-407d-acfb-9a767e97cb78\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"e98da3ed-00bb-4dcd-baa3-dcf85e687a50\",\n      \"target\": \"b336d8b4-f8f8-4778-a3ed-2060386130fc\",\n      \"text\": \"\",\n      \"uuid\": \"5dd10b04-e57c-4c23-bdc9-a2e7b055fdad\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"372b3ac3-3928-47fe-9c89-8747f0dd29ba\",\n      \"target\": \"82e75cc6-cc10-4e2e-aa24-96ce418e8f47\",\n      \"text\": \"\",\n      \"uuid\": \"85135ff8-116d-4dfd-823c-8490c9e43bdb\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"372b3ac3-3928-47fe-9c89-8747f0dd29ba\",\n      \"target\": \"b336d8b4-f8f8-4778-a3ed-2060386130fc\",\n      \"text\": \"\",\n      \"uuid\": \"0a6edd31-10d7-4d32-afdd-a590085a4f2b\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"82e75cc6-cc10-4e2e-aa24-96ce418e8f47\",\n      \"target\": \"b336d8b4-f8f8-4778-a3ed-2060386130fc\",\n      \"text\": \"\",\n      \"uuid\": \"81b84458-47e7-4bb1-91c1-4c8f4c9578c7\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"9456105a-3a13-4634-8ea2-eee404383bc2\",\n      \"target\": \"c942b7a4-c968-4aa1-869e-9ae260402b4d\",\n      \"text\": \"\",\n      \"uuid\": \"3a133037-59f5-4e58-b3b6-21aebd1544a6\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"655564da-c94d-41bc-8127-39bedb6e48d8\",\n      \"target\": \"34b2b64d-acf0-4475-9bd0-9d5f37f6d2f3\",\n      \"text\": \"\",\n      \"uuid\": \"e1e6a714-ce34-43ef-ab00-f41c4bd8f468\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"34b2b64d-acf0-4475-9bd0-9d5f37f6d2f3\",\n      \"target\": \"1976fab6-9aca-4424-b7ec-d901118e0580\",\n      \"text\": \"\",\n      \"uuid\": \"bbe35a67-0dd5-4da3-a9ca-1cb0a908d55d\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"c942b7a4-c968-4aa1-869e-9ae260402b4d\",\n      \"target\": \"3486b3eb-e9ac-4c91-a5cd-38ff7e868fe1\",\n      \"text\": \"\",\n      \"uuid\": \"fb39d2c7-9e62-43b8-b773-c369a51a2346\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"3486b3eb-e9ac-4c91-a5cd-38ff7e868fe1\",\n      \"target\": \"655564da-c94d-41bc-8127-39bedb6e48d8\",\n      \"text\": \"\",\n      \"uuid\": \"02624983-d51a-41a4-95c2-a48ae6888bf1\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"3486b3eb-e9ac-4c91-a5cd-38ff7e868fe1\",\n      \"target\": \"3fb77a00-31ba-4032-984d-ca47efea42ad\",\n      \"text\": \"\",\n      \"uuid\": \"664a58df-5fc6-4601-a7a5-06a0b39292a8\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"655564da-c94d-41bc-8127-39bedb6e48d8\",\n      \"target\": \"e8844af9-3be5-49fc-98f5-6906116c9575\",\n      \"text\": \"\",\n      \"uuid\": \"9cd45f73-e864-4412-9bfe-2c6ca67b0ae6\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"3f025405-3ac7-42ec-b252-a36583ee0c33\",\n      \"target\": \"01745060-bceb-48e2-8b35-8a85e043d6cf\",\n      \"text\": \"\",\n      \"uuid\": \"0ff39b17-20d5-4a9c-bfb6-341112dfafa9\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"a3bb3183-70f4-45e2-944f-d795f6b32456\",\n      \"target\": \"3f025405-3ac7-42ec-b252-a36583ee0c33\",\n      \"text\": \"\",\n      \"uuid\": \"79788155-cf9d-421b-845d-5cfde8f7798a\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"01745060-bceb-48e2-8b35-8a85e043d6cf\",\n      \"target\": \"32c6c052-4bc2-42c6-9d7d-23c8e4f90a0a\",\n      \"text\": \"\",\n      \"uuid\": \"5d10f991-c0a8-48b8-8941-77a943048c29\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"32c6c052-4bc2-42c6-9d7d-23c8e4f90a0a\",\n      \"target\": \"9456105a-3a13-4634-8ea2-eee404383bc2\",\n      \"text\": \"\",\n      \"uuid\": \"5fa36e63-14c2-4917-a34d-16fa4d4383c3\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"e1505328-0b60-4d15-9994-3bf91142dbb4\",\n      \"target\": \"32c6c052-4bc2-42c6-9d7d-23c8e4f90a0a\",\n      \"text\": \"\",\n      \"uuid\": \"096d3720-c39b-4e36-9d5b-4d1dad3db0d3\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"d70f1280-ffa6-49ba-a385-0c24377308c0\",\n      \"target\": \"ea51d7fd-c6d9-4377-a32e-4c030cea33f9\",\n      \"text\": \"\",\n      \"uuid\": \"3c54701c-efb5-47af-936a-955133ce051e\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"ea51d7fd-c6d9-4377-a32e-4c030cea33f9\",\n      \"target\": \"83f241f7-2b38-42f7-a719-42d2fc4898ba\",\n      \"text\": \"\",\n      \"uuid\": \"49674aaa-1a68-4d88-84ba-45e1a92b90fc\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"83f241f7-2b38-42f7-a719-42d2fc4898ba\",\n      \"target\": \"6e2f248c-7a9c-4078-b6e4-e7ceb27f2d64\",\n      \"text\": \"\",\n      \"uuid\": \"fea46037-8392-4f44-aed9-89832d139f92\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"fbf81bb2-6398-4e3d-8ed8-8d433efdbec9\",\n      \"target\": \"6fe33d76-ca04-4e9d-b3f5-26d15ed25dcf\",\n      \"text\": \"\",\n      \"uuid\": \"27d544d2-cda9-483b-a636-6f3f16d70175\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"6fe33d76-ca04-4e9d-b3f5-26d15ed25dcf\",\n      \"target\": \"534182c2-60d6-4555-b7b2-07b7d0e28416\",\n      \"text\": \"\",\n      \"uuid\": \"8e19a6ea-30be-4986-8f8b-7bbcae93b8e1\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"fbf81bb2-6398-4e3d-8ed8-8d433efdbec9\",\n      \"target\": \"2bb5b822-3634-4f95-9892-20a0b2192b65\",\n      \"text\": \"\",\n      \"uuid\": \"e918c675-c37c-4f93-a6bd-8b9e43087a39\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"2bb5b822-3634-4f95-9892-20a0b2192b65\",\n      \"target\": \"6d99e44e-7ec9-454e-9d6a-3c310fbb09f2\",\n      \"text\": \"\",\n      \"uuid\": \"3568dfc9-280a-424a-a848-0feec8c09203\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"cd21f186-32bd-4275-9e31-3f8dfd981a9d\",\n      \"target\": \"47ce4db8-81d4-4e2d-a032-53b56be5132f\",\n      \"text\": \"\",\n      \"uuid\": \"53f3dfc2-ebb9-48f2-9037-78be69e49540\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"47ce4db8-81d4-4e2d-a032-53b56be5132f\",\n      \"target\": \"8a8bdc6b-a78b-4d5a-8d58-51512ddaa153\",\n      \"text\": \"\",\n      \"uuid\": \"d9378375-d8a2-4e35-89b4-87e58e593137\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"6d99e44e-7ec9-454e-9d6a-3c310fbb09f2\",\n      \"target\": \"8a8bdc6b-a78b-4d5a-8d58-51512ddaa153\",\n      \"text\": \"\",\n      \"uuid\": \"5581dfbb-de62-4fd4-b124-52b096679c12\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"6fe33d76-ca04-4e9d-b3f5-26d15ed25dcf\",\n      \"target\": \"cd21f186-32bd-4275-9e31-3f8dfd981a9d\",\n      \"text\": \"\",\n      \"uuid\": \"af4c906c-0e4a-4807-bfd0-fa74652e6aa4\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"cd21f186-32bd-4275-9e31-3f8dfd981a9d\",\n      \"target\": \"9189fd92-04b5-4f51-a207-a8387994f541\",\n      \"text\": \"\",\n      \"uuid\": \"3cc81a1c-3723-47a8-a17a-e88864c41674\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"9189fd92-04b5-4f51-a207-a8387994f541\",\n      \"target\": \"9b43879a-23e0-4cef-b5e0-f8c5def66a9f\",\n      \"text\": \"\",\n      \"uuid\": \"e717b15f-d26b-4e8d-934f-3b300331176b\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"9b43879a-23e0-4cef-b5e0-f8c5def66a9f\",\n      \"target\": \"fa83a554-56ef-4854-a876-9081c09689e9\",\n      \"text\": \"\",\n      \"uuid\": \"caeb25f5-dd2f-4032-91b2-bf46943c8a0b\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"9b43879a-23e0-4cef-b5e0-f8c5def66a9f\",\n      \"target\": \"9d9a8805-d538-4e98-a6aa-9a7b22ddfd27\",\n      \"text\": \"\",\n      \"uuid\": \"64977324-bd4f-4d6a-81a8-8f6a74dc29bf\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"9b43879a-23e0-4cef-b5e0-f8c5def66a9f\",\n      \"target\": \"a86e7968-c44e-4eac-8cda-4258192f4d9a\",\n      \"text\": \"\",\n      \"uuid\": \"f2ab58f0-4165-4176-b9f9-c0c3d78a277e\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"a86e7968-c44e-4eac-8cda-4258192f4d9a\",\n      \"target\": \"1f9b45c4-0413-4cc0-91f1-e544ba3fd50b\",\n      \"text\": \"\",\n      \"uuid\": \"fb367881-8ae5-4070-80c6-b43de51f14bb\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"8a8bdc6b-a78b-4d5a-8d58-51512ddaa153\",\n      \"target\": \"1f9b45c4-0413-4cc0-91f1-e544ba3fd50b\",\n      \"text\": \"\",\n      \"uuid\": \"0ddda36d-663e-4759-9f5c-36bd7255d4e9\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"1f9b45c4-0413-4cc0-91f1-e544ba3fd50b\",\n      \"target\": \"382bf4dc-73b5-4afe-a8fb-484d75eb33f3\",\n      \"text\": \"\",\n      \"uuid\": \"67552527-642a-467a-b81f-5703df350196\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"382bf4dc-73b5-4afe-a8fb-484d75eb33f3\",\n      \"target\": \"8b95cbe3-2812-473f-aaa2-0a2dbe28f374\",\n      \"text\": \"\",\n      \"uuid\": \"99b37023-1523-454f-bb0d-4d7b3340a0a3\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"8b95cbe3-2812-473f-aaa2-0a2dbe28f374\",\n      \"target\": \"3929a2b5-c59d-43b4-b94d-c571115ea2ff\",\n      \"text\": \"\",\n      \"uuid\": \"534357e4-510c-48d9-8d29-1f00a0f5425e\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"3929a2b5-c59d-43b4-b94d-c571115ea2ff\",\n      \"target\": \"b9783df0-a736-44e8-be4e-67ad260c37a8\",\n      \"text\": \"\",\n      \"uuid\": \"c288587b-db21-431e-81dd-da393c0b7b1a\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"b9783df0-a736-44e8-be4e-67ad260c37a8\",\n      \"target\": \"5ee606e0-f77a-411f-9dc5-f00f212119a7\",\n      \"text\": \"\",\n      \"uuid\": \"dea001c1-adb0-496a-b879-9c1f6faf4ae3\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"5ee606e0-f77a-411f-9dc5-f00f212119a7\",\n      \"target\": \"3b930433-f146-42a5-984d-68802e05c51a\",\n      \"text\": \"\",\n      \"uuid\": \"2d25ceb7-f785-4e6c-af6a-088c04001703\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"2bb5b822-3634-4f95-9892-20a0b2192b65\",\n      \"target\": \"8e706cc5-b6be-4994-b453-597af51e517a\",\n      \"text\": \"\",\n      \"uuid\": \"38b98c47-0808-451d-a520-d2f5798336c4\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"3b930433-f146-42a5-984d-68802e05c51a\",\n      \"target\": \"1dde98b7-bb86-4de2-a926-0dbb30d11e2a\",\n      \"text\": \"\",\n      \"uuid\": \"c4e9dbfa-6e1a-4045-9db9-5e112a532ee0\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"3b930433-f146-42a5-984d-68802e05c51a\",\n      \"target\": \"c2d445e7-995b-42da-98d6-35293846b280\",\n      \"text\": \"\",\n      \"uuid\": \"c5dbb3d5-4394-44ba-9c62-c7863ac8cbc2\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"1dde98b7-bb86-4de2-a926-0dbb30d11e2a\",\n      \"target\": \"9cafe9c3-7631-4d5e-8b85-7f6a57aeaad8\",\n      \"text\": \"\",\n      \"uuid\": \"fdf7eb6d-50b3-461a-858d-59196f34cf55\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"1dde98b7-bb86-4de2-a926-0dbb30d11e2a\",\n      \"target\": \"153bd3c5-c7bc-420e-add6-4f1662434d13\",\n      \"text\": \"\",\n      \"uuid\": \"bcb283af-7f09-4f28-9305-24e052880c54\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"153bd3c5-c7bc-420e-add6-4f1662434d13\",\n      \"target\": \"8b71bbc0-3d0d-47f1-a46f-52b8d3714ad3\",\n      \"text\": \"\",\n      \"uuid\": \"01b74a84-cd93-4395-926f-a0399b1a951a\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"8b71bbc0-3d0d-47f1-a46f-52b8d3714ad3\",\n      \"target\": \"81a319c4-013f-4955-8a84-3b9791fab519\",\n      \"text\": \"\",\n      \"uuid\": \"ab78d310-a190-4b1f-a1e7-7fa8dd7a1ac7\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"c2d445e7-995b-42da-98d6-35293846b280\",\n      \"target\": \"ac2d50a1-54b3-4445-9cb0-6b5e9d2d22a5\",\n      \"text\": \"\",\n      \"uuid\": \"9f7c31de-7bff-4e69-bde6-4e2c091f27b9\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"8b71bbc0-3d0d-47f1-a46f-52b8d3714ad3\",\n      \"target\": \"2fea8696-53be-480f-987d-a85ec55d6481\",\n      \"text\": \"\",\n      \"uuid\": \"f6fd77b6-090d-4ddd-9055-e97bf0b97222\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"81a319c4-013f-4955-8a84-3b9791fab519\",\n      \"target\": \"a6ef23cb-e6b3-4c8c-b2fd-92888a8da175\",\n      \"text\": \"\",\n      \"uuid\": \"59d9bcbb-f4cc-4063-9d9a-488ae16d7407\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"2fea8696-53be-480f-987d-a85ec55d6481\",\n      \"target\": \"07867c7a-f4c1-41bc-8414-85070d8c5bba\",\n      \"text\": \"\",\n      \"uuid\": \"30957965-0fff-4a8d-aa49-f993b557e961\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"a6ef23cb-e6b3-4c8c-b2fd-92888a8da175\",\n      \"target\": \"8666e25c-86e6-4a21-b8ca-552353436679\",\n      \"text\": \"\",\n      \"uuid\": \"bcc9b609-778b-4cf2-a83f-c466d51b1945\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"e14adfc2-4201-475a-8c6d-12d368f3f0a2\",\n      \"target\": \"1613eda8-58dd-47f4-ae9e-53cf65c61935\",\n      \"text\": \"\",\n      \"uuid\": \"8234c31a-c058-4f8f-9a6a-138662e90d9b\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"374d658d-0dc9-4c34-8d98-19c8bb1a3272\",\n      \"target\": \"1613eda8-58dd-47f4-ae9e-53cf65c61935\",\n      \"text\": \"\",\n      \"uuid\": \"205435d0-f025-4752-9e87-ada8be438f82\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"1613eda8-58dd-47f4-ae9e-53cf65c61935\",\n      \"target\": \"a424f562-462c-4fdc-bf04-999b88c9cdc2\",\n      \"text\": \"\",\n      \"uuid\": \"edfd96c8-eacf-4a48-9669-7b17d298f140\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"21a1e677-4923-43ae-b483-4d2b5c18d576\",\n      \"target\": \"a3d1aa4a-1226-40c0-8a77-370f29c4a5e0\",\n      \"text\": \"\",\n      \"uuid\": \"577c667f-71a8-49c6-ba2c-e23626ce7437\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"a3d1aa4a-1226-40c0-8a77-370f29c4a5e0\",\n      \"target\": \"240bb2d9-be7c-4c9a-8c17-e6d3def93496\",\n      \"text\": \"\",\n      \"uuid\": \"503ba4aa-9313-46b1-84b0-58d880b042bb\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"bc9b0fba-e747-4d1f-bbbb-d6c4bdab9f98\",\n      \"target\": \"d36dd5c5-e42a-40ca-8a2a-327e9dc94558\",\n      \"text\": \"\",\n      \"uuid\": \"d9de453c-04ee-42c3-bb25-50d2b7d81f1a\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"6e7ce1b6-c554-41d0-b4b8-a703ac89e393\",\n      \"target\": \"5aaaed47-6bbb-4385-9faf-c18c9577b9d6\",\n      \"text\": \"\",\n      \"uuid\": \"a640f502-f0fa-4b3a-9a4f-c72d73449771\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"9f7bd25a-b0bd-4d74-b6b6-28faac99e089\",\n      \"target\": \"5611367f-f2cb-4dac-8cd3-7cac03c3c2d7\",\n      \"text\": \"\",\n      \"uuid\": \"2e8cfbc3-592e-4f40-80c2-c22081311ec6\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"8123c9ba-0ca3-46be-a415-486c35228745\",\n      \"target\": \"f2321f29-8355-47e8-bcda-c252d43b8ac8\",\n      \"text\": \"\",\n      \"uuid\": \"797c453f-2dcf-48d7-ab40-aa3685593ddb\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.99],\n      \"targetRectRate\": [0.5, 0.01]\n    },\n    {\n      \"source\": \"eccfc8b1-c1cb-4f4b-8f57-e366f53ef4b1\",\n      \"target\": \"f2321f29-8355-47e8-bcda-c252d43b8ac8\",\n      \"text\": \"\",\n      \"uuid\": \"35e680e4-fa80-41c9-8909-0ae6b6a00e6f\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.99],\n      \"targetRectRate\": [0.5, 0.01]\n    },\n    {\n      \"source\": \"fe577cf9-0071-4fea-875c-61cc275f6107\",\n      \"target\": \"f2321f29-8355-47e8-bcda-c252d43b8ac8\",\n      \"text\": \"\",\n      \"uuid\": \"ba7422f0-4966-42dc-8426-fbe7cf6d65e8\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.99],\n      \"targetRectRate\": [0.5, 0.01]\n    },\n    {\n      \"source\": \"8e9ae469-f7ca-4e8c-a51e-a7693a866226\",\n      \"target\": \"f2321f29-8355-47e8-bcda-c252d43b8ac8\",\n      \"text\": \"\",\n      \"uuid\": \"e47c8b00-289e-4b3d-9577-6c5ec0f4a78a\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.99],\n      \"targetRectRate\": [0.5, 0.01]\n    },\n    {\n      \"source\": \"f2321f29-8355-47e8-bcda-c252d43b8ac8\",\n      \"target\": \"1f97c8b9-e936-40e9-8bb9-087ab2953f74\",\n      \"text\": \"\",\n      \"uuid\": \"3425dd84-ec2b-4c76-a361-a0834f5830b4\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"f2321f29-8355-47e8-bcda-c252d43b8ac8\",\n      \"target\": \"a06f227e-4111-47b0-815b-8ece1ae77b69\",\n      \"text\": \"\",\n      \"uuid\": \"c4e82e07-d077-42fb-a825-151b85f46d4d\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"f2321f29-8355-47e8-bcda-c252d43b8ac8\",\n      \"target\": \"6dd1df1e-9f9c-4041-9cb2-16ebf55f932a\",\n      \"text\": \"\",\n      \"uuid\": \"1bce1353-f618-4954-a4a0-6e94acec0610\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"f2321f29-8355-47e8-bcda-c252d43b8ac8\",\n      \"target\": \"0e840fae-f5f3-4104-87c0-4720942aeea3\",\n      \"text\": \"\",\n      \"uuid\": \"ab39aab2-11a9-4730-9a18-177112c9b9f6\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"f2321f29-8355-47e8-bcda-c252d43b8ac8\",\n      \"target\": \"84885cfa-b60d-4373-9bb9-bb36084ca129\",\n      \"text\": \"\",\n      \"uuid\": \"29073c2c-4d7b-40ec-846e-9ec73327d59f\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"f2321f29-8355-47e8-bcda-c252d43b8ac8\",\n      \"target\": \"949bd876-f9b4-43dc-9d08-822cf52e8b7f\",\n      \"text\": \"\",\n      \"uuid\": \"21ecb34e-e505-48cc-a0b3-7787a7712a33\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"9f7bd25a-b0bd-4d74-b6b6-28faac99e089\",\n      \"target\": \"46c8e6ae-f8c6-470e-a3de-74b4016e43f7\",\n      \"text\": \"\",\n      \"uuid\": \"6d659fe7-265b-460e-bb9c-b40fc6fa6de6\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"fb963815-f9bc-4c75-8cf8-1daceb0ce098\",\n      \"target\": \"09e0c318-2bfc-4c74-8513-f094002f8a06\",\n      \"text\": \"\",\n      \"uuid\": \"93c2eeea-4734-4ced-a71d-68fd9068bc43\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"6a596594-5bd3-498f-ac4c-1324673d38d6\",\n      \"target\": \"1ce9920b-364a-4dfa-a4f8-732029b296bc\",\n      \"text\": \"\",\n      \"uuid\": \"b69a5526-30c3-400a-933b-ce190e969770\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    }\n  ],\n  \"tags\": []\n}\n"
  },
  {
    "path": "docs-pg/ProjectGraph开发进程图.json",
    "content": "{\n  \"version\": 17,\n  \"entities\": [\n    {\n      \"location\": [-2559, -1927],\n      \"size\": [357.407958984375, 76],\n      \"text\": \"TODOlist时间计算扩展\",\n      \"uuid\": \"0862210f-3dbb-43c8-a6ce-bc49837f6311\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1849, -1026],\n      \"size\": [188, 76],\n      \"text\": \"第二次重构\",\n      \"uuid\": \"63591c61-d454-4720-9619-ac85b90fd637\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-2666, -1670],\n      \"size\": [571.7438354492188, 76],\n      \"text\": \"githubIssuee导入projectgraph的能力\",\n      \"uuid\": \"2e31b90e-1388-4670-b7a4-280a95186c45\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1701, -1492],\n      \"size\": [183.26394653320312, 76],\n      \"text\": \"发布v2.0.0\",\n      \"uuid\": \"0a8fe11c-0af7-4cd9-8365-4b8c3fc4f88a\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1402, -2335],\n      \"size\": [156, 76],\n      \"text\": \"多人联机\",\n      \"uuid\": \"0bfa1bb4-c518-478c-aca1-ea4112ae9b5b\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-2523, -1801],\n      \"size\": [284.6719665527344, 76],\n      \"text\": \"增加github等功能\",\n      \"uuid\": \"277b551a-2b74-4d54-80bb-e8ca5478d054\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1849, -738],\n      \"size\": [170.30393981933594, 76],\n      \"text\": \"发布v1.1.0\",\n      \"uuid\": \"199a0ec8-61de-4566-af09-4cdc65c56e96\",\n      \"details\": \"\",\n      \"color\": [22, 163, 74, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-742, -578],\n      \"size\": [188, 76],\n      \"text\": \"富文本功能\",\n      \"uuid\": \"aa4c1e02-8cf6-431e-b5bd-dee0509b9b97\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [506, -275],\n      \"size\": [316, 76],\n      \"text\": \"贝塞尔曲线手动调整\",\n      \"uuid\": \"8de14ca9-9717-4aa0-9274-38f34251ce37\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-641, -1826],\n      \"size\": [316, 76],\n      \"text\": \"自动生成统计图功能\",\n      \"uuid\": \"66c3d7f1-56f5-455d-a3b3-e3f22f1b1224\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-773, -728],\n      \"size\": [249.47195434570312, 76],\n      \"text\": \"渲染markdown\",\n      \"uuid\": \"2923abe6-b4b9-469a-9a79-f93e5b275d41\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1056, 283],\n      \"size\": [297.85589599609375, 76],\n      \"text\": \"优化ctrl+c、ctrl+v\",\n      \"uuid\": \"45613fa3-8436-4818-bcf5-3edf029b69a6\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1056, 406],\n      \"size\": [261.8559875488281, 76],\n      \"text\": \"支持Alt复制功能\",\n      \"uuid\": \"16ec1596-df63-46b5-be9d-9773fc5aa6bd\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1701, -1250],\n      \"size\": [156, 76],\n      \"text\": \"抽取内核\",\n      \"uuid\": \"0977daf0-bd15-4bc2-aa03-9171c7825bbe\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-310, 375],\n      \"size\": [584.7359619140625, 76],\n      \"text\": \"可以考虑把section框的大小改成自定义\",\n      \"uuid\": \"150c64b0-8639-43b2-8a3b-25fde6856ee3\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-641, -1600],\n      \"size\": [636, 76],\n      \"text\": \"设计一种按连线顺序执行逻辑节点的方式？\",\n      \"uuid\": \"e931a7bf-c105-4104-91db-0044cf55713a\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-848, -2239],\n      \"size\": [220, 76],\n      \"text\": \"增加指令系统\",\n      \"uuid\": \"c989c4bf-c028-454c-a501-73e521c12a84\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-2034, -23],\n      \"size\": [382.3359680175781, 76],\n      \"text\": \"增加shift键移动节点功能\",\n      \"uuid\": \"55d5a658-d6a7-4bce-8b51-50b2bf67b119\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-446, -578],\n      \"size\": [156, 76],\n      \"text\": \"公式渲染\",\n      \"uuid\": \"8ae18bb2-0725-461d-a08f-9cbefe1d6b22\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [490, 38],\n      \"size\": [348, 76],\n      \"text\": \"图片下面增加一个标题\",\n      \"uuid\": \"087da532-0840-4c12-8d82-f177a9883702\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-71, -245],\n      \"size\": [316, 76],\n      \"text\": \"专属的文件路径节点\",\n      \"uuid\": \"90d63a73-db33-4758-a715-89c04591a7f0\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-877, -2335],\n      \"size\": [185.98399353027344, 76],\n      \"text\": \"AI功能优化\",\n      \"uuid\": \"1d7486ee-3c8e-49c5-bcbb-b102e12a8aff\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1100, -1026],\n      \"size\": [361.1199951171875, 76],\n      \"text\": \"学习笔记/思维导图方向\",\n      \"uuid\": \"f07cd6e4-981a-41fe-8202-7ea6bf601e46\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1538, -2150],\n      \"size\": [220, 76],\n      \"text\": \"头脑风暴方向\",\n      \"uuid\": \"e9054cbb-9356-43c5-8f9a-e9adb25eb72b\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1244, -1678],\n      \"size\": [284, 76],\n      \"text\": \"项目工程管理方向\",\n      \"uuid\": \"9da829e6-dfb8-414d-900f-694608040d6d\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1223, -104],\n      \"size\": [156, 76],\n      \"text\": \"基础问题\",\n      \"uuid\": \"4938e887-858e-4bfc-b94a-55309009276c\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-310, 630],\n      \"size\": [712.7359619140625, 76],\n      \"text\": \"多层嵌套时，外层折叠，内层section碰撞箱还在\",\n      \"uuid\": \"0af5b406-7888-4d15-b5f1-ba18ea96398d\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-931, -79],\n      \"size\": [671.2639770507812, 76],\n      \"text\": \"把节点的渲染逻辑提取到节点的render方法中\",\n      \"uuid\": \"ea4e42b0-7775-481e-8de7-2d7cba1cdf67\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-71, 13],\n      \"size\": [284, 76],\n      \"text\": \"为插件系统做准备\",\n      \"uuid\": \"fb40ca58-8a2c-4e77-8a0d-2c1aefebe06d\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1985, -275],\n      \"size\": [284, 76],\n      \"text\": \"更改工程文件格式\",\n      \"uuid\": \"56c82faf-7ed0-428a-94a0-5917a6d11843\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1701, -1353],\n      \"size\": [142.36798095703125, 76],\n      \"text\": \"插件API\",\n      \"uuid\": \"7bf3996d-e1e1-4d4b-ba13-a8f00ef1fe13\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-570, -1262],\n      \"size\": [245.47198486328125, 76],\n      \"text\": \"“知识库”的概念\",\n      \"uuid\": \"e299c930-6e0c-4986-8152-2e9d361f06c1\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-773, -1432],\n      \"size\": [156, 76],\n      \"text\": \"双向链接\",\n      \"uuid\": \"23e9b42d-d28b-4e14-8990-7bdd9d8976e3\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1917, 283],\n      \"size\": [639.5198974609375, 76],\n      \"text\": \"自定义工具栏（类似kde plasma的“面板”）\",\n      \"uuid\": \"f7a586ef-e6db-4c7f-8887-95f26679bb76\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-528, -1093],\n      \"size\": [156, 76],\n      \"text\": \"单向链接\",\n      \"uuid\": \"78be1241-8d96-4b21-99e7-03a1fb40fd00\",\n      \"details\": \"\",\n      \"color\": [22, 163, 74, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1665, -2335],\n      \"size\": [183.4239501953125, 76],\n      \"text\": \"发布v3.0.0\",\n      \"uuid\": \"61735b75-480c-4867-96fd-0a5fb300f0d6\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-128, 919],\n      \"size\": [92, 76],\n      \"text\": \"……\",\n      \"uuid\": \"cb88dfda-8e5e-4b85-8fa9-0b3196501de7\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [128, 919],\n      \"size\": [92, 76],\n      \"text\": \"……\",\n      \"uuid\": \"721fb8c9-912d-453d-b70e-4c7a9bbfd227\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [476, -355],\n      \"size\": [376, 186],\n      \"uuid\": \"80f1e32c-1c9d-49a7-b6b9-d64321bc867b\",\n      \"text\": \"连线\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\"8de14ca9-9717-4aa0-9274-38f34251ce37\"],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [460, -42],\n      \"size\": [408, 186],\n      \"uuid\": \"14f720f1-0169-408e-bb05-759cd9b81aec\",\n      \"text\": \"图片\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\"087da532-0840-4c12-8d82-f177a9883702\"],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [-803, -808],\n      \"size\": [543, 336],\n      \"uuid\": \"882d5ab7-1e39-4eb1-a1af-3af662782a29\",\n      \"text\": \"更丰富的节点渲染\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\n        \"aa4c1e02-8cf6-431e-b5bd-dee0509b9b97\",\n        \"2923abe6-b4b9-469a-9a79-f93e5b275d41\",\n        \"8ae18bb2-0725-461d-a08f-9cbefe1d6b22\"\n      ],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [-340, 295],\n      \"size\": [644.7359619140625, 186],\n      \"uuid\": \"bbfedb68-6a3c-435c-b468-2185f5aa48d5\",\n      \"text\": \"Section功能完善\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\"150c64b0-8639-43b2-8a3b-25fde6856ee3\"],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [-1947, 203],\n      \"size\": [699.5198974609375, 186],\n      \"uuid\": \"eb6c8d0f-2c59-4a85-9d74-ff3c38aa3c3c\",\n      \"text\": \"工具栏优化\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\"f7a586ef-e6db-4c7f-8887-95f26679bb76\"],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [-671, -1906],\n      \"size\": [696, 412],\n      \"uuid\": \"43704845-0f9a-4fc1-a32a-9cf86088ab67\",\n      \"text\": \"自动计算引擎\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\"66c3d7f1-56f5-455d-a3b3-e3f22f1b1224\", \"e931a7bf-c105-4104-91db-0044cf55713a\"],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [-2064, -103],\n      \"size\": [442.3359680175781, 186],\n      \"uuid\": \"f8a64ce4-a3cc-423f-8e26-9738de4d63e2\",\n      \"text\": \"布局功能\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\"55d5a658-d6a7-4bce-8b51-50b2bf67b119\"],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [-2696, -2007],\n      \"size\": [631.7438354492188, 443],\n      \"uuid\": \"3c5baaa9-3c97-4212-9065-4a90ad948317\",\n      \"text\": \"插件\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\n        \"0862210f-3dbb-43c8-a6ce-bc49837f6311\",\n        \"277b551a-2b74-4d54-80bb-e8ca5478d054\",\n        \"2e31b90e-1388-4670-b7a4-280a95186c45\"\n      ],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [-101, -325],\n      \"size\": [376, 444],\n      \"uuid\": \"52014138-86c1-4fa5-a072-21d3d2ecfc6b\",\n      \"text\": \"节点优化\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\"90d63a73-db33-4758-a715-89c04591a7f0\", \"fb40ca58-8a2c-4e77-8a0d-2c1aefebe06d\"],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [-907, -2415],\n      \"size\": [309, 282],\n      \"uuid\": \"d13c9d0a-4921-43f5-828e-4fa79faf8485\",\n      \"text\": \"AI专项计划\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\"c989c4bf-c028-454c-a501-73e521c12a84\", \"1d7486ee-3c8e-49c5-bcbb-b102e12a8aff\"],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [-1086, 203],\n      \"size\": [357.85589599609375, 309],\n      \"uuid\": \"38512161-5d70-4688-8d41-4baf972a27cf\",\n      \"text\": \"优化键盘操作\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\"45613fa3-8436-4818-bcf5-3edf029b69a6\", \"16ec1596-df63-46b5-be9d-9773fc5aa6bd\"],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [-340, 550],\n      \"size\": [772.7359619140625, 186],\n      \"uuid\": \"c9085182-3fa3-4773-a90b-985bb57002f2\",\n      \"text\": \"多层嵌套Section完善\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\"0af5b406-7888-4d15-b5f1-ba18ea96398d\"],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [-961, -159],\n      \"size\": [731.2639770507812, 186],\n      \"uuid\": \"0b3e55b4-a884-4e1e-8116-15b74a56ad35\",\n      \"text\": \"性能优化\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\"ea4e42b0-7775-481e-8de7-2d7cba1cdf67\"],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [-158, 839],\n      \"size\": [408, 186],\n      \"uuid\": \"09362bef-d992-4819-a4a1-850b0aaedbea\",\n      \"text\": \"增加表格元素\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\"cb88dfda-8e5e-4b85-8fa9-0b3196501de7\", \"721fb8c9-912d-453d-b70e-4c7a9bbfd227\"],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [-392, -1456],\n      \"size\": [316, 124],\n      \"text\": \"加强搜索功能\\n支持跨工程文件搜索\",\n      \"uuid\": \"f2a7b75a-560b-44e0-b4b8-03e34426f3b8\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [198, -1942],\n      \"size\": [380, 76],\n      \"text\": \"基于图论的编程语言计划\",\n      \"uuid\": \"c3a79790-1cdf-41e7-8090-3df3feca7324\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [310, -1826],\n      \"size\": [156, 76],\n      \"text\": \"变量系统\",\n      \"uuid\": \"1f9f415c-7422-44ef-ba79-93370c9d06d2\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [310, -1717],\n      \"size\": [156, 76],\n      \"text\": \"条件语句\",\n      \"uuid\": \"013b0e2d-cda1-4f13-99ab-971c2e08b99e\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [310, -1600],\n      \"size\": [156, 76],\n      \"text\": \"循环语句\",\n      \"uuid\": \"02e8e0b7-60a5-4dd3-adcf-75e5cae9e1d2\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    }\n  ],\n  \"associations\": [\n    {\n      \"source\": \"45613fa3-8436-4818-bcf5-3edf029b69a6\",\n      \"target\": \"16ec1596-df63-46b5-be9d-9773fc5aa6bd\",\n      \"text\": \"\",\n      \"uuid\": \"20ce82ad-bfce-4e0f-8cc7-089c80e15208\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"277b551a-2b74-4d54-80bb-e8ca5478d054\",\n      \"target\": \"2e31b90e-1388-4670-b7a4-280a95186c45\",\n      \"text\": \"\",\n      \"uuid\": \"690cd19d-afbe-4eef-a8ca-bdb186b90e57\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"63591c61-d454-4720-9619-ac85b90fd637\",\n      \"target\": \"0977daf0-bd15-4bc2-aa03-9171c7825bbe\",\n      \"text\": \"\",\n      \"uuid\": \"b9826a89-7fd7-4075-877c-2d6f307871b4\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"199a0ec8-61de-4566-af09-4cdc65c56e96\",\n      \"target\": \"4938e887-858e-4bfc-b94a-55309009276c\",\n      \"text\": \"\",\n      \"uuid\": \"1cc12bb8-7b0a-4c1e-a069-caa642aace9f\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.99],\n      \"targetRectRate\": [0.5, 0.01]\n    },\n    {\n      \"source\": \"52014138-86c1-4fa5-a072-21d3d2ecfc6b\",\n      \"target\": \"80f1e32c-1c9d-49a7-b6b9-d64321bc867b\",\n      \"text\": \"\",\n      \"uuid\": \"dbb2d24b-fe27-4fb5-a058-a54e5cc8c302\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"199a0ec8-61de-4566-af09-4cdc65c56e96\",\n      \"target\": \"f07cd6e4-981a-41fe-8202-7ea6bf601e46\",\n      \"text\": \"\",\n      \"uuid\": \"284c6d5f-83c6-4bf5-b305-6b76d1f71b83\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.99, 0.5],\n      \"targetRectRate\": [0.01, 0.5]\n    },\n    {\n      \"source\": \"9da829e6-dfb8-414d-900f-694608040d6d\",\n      \"target\": \"43704845-0f9a-4fc1-a32a-9cf86088ab67\",\n      \"text\": \"\",\n      \"uuid\": \"614ad2e7-f2dd-48e7-aa32-f96b468a05d9\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"199a0ec8-61de-4566-af09-4cdc65c56e96\",\n      \"target\": \"9da829e6-dfb8-414d-900f-694608040d6d\",\n      \"text\": \"\",\n      \"uuid\": \"80ec5ef5-4a0c-4652-a8f4-e8aec3ea752e\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"9da829e6-dfb8-414d-900f-694608040d6d\",\n      \"target\": \"e9054cbb-9356-43c5-8f9a-e9adb25eb72b\",\n      \"text\": \"\",\n      \"uuid\": \"170b79b6-d5e1-423f-926c-f42218754d9b\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"bbfedb68-6a3c-435c-b468-2185f5aa48d5\",\n      \"target\": \"c9085182-3fa3-4773-a90b-985bb57002f2\",\n      \"text\": \"\",\n      \"uuid\": \"1b2a0e54-cb6c-491c-9293-f5e9da3b8e07\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"4938e887-858e-4bfc-b94a-55309009276c\",\n      \"target\": \"0b3e55b4-a884-4e1e-8116-15b74a56ad35\",\n      \"text\": \"\",\n      \"uuid\": \"b40d6bc4-d019-4fe0-aa8b-c10ad4c109fa\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"0b3e55b4-a884-4e1e-8116-15b74a56ad35\",\n      \"target\": \"52014138-86c1-4fa5-a072-21d3d2ecfc6b\",\n      \"text\": \"\",\n      \"uuid\": \"a2d89309-14f7-4a14-a326-b7f34fbe003d\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"0b3e55b4-a884-4e1e-8116-15b74a56ad35\",\n      \"target\": \"882d5ab7-1e39-4eb1-a1af-3af662782a29\",\n      \"text\": \"\",\n      \"uuid\": \"7295dd4b-1255-4b29-b449-7d094b9ddfab\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"9da829e6-dfb8-414d-900f-694608040d6d\",\n      \"target\": \"d13c9d0a-4921-43f5-828e-4fa79faf8485\",\n      \"text\": \"\",\n      \"uuid\": \"e055c780-b0e4-482f-80db-ebeff2ff509e\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"4938e887-858e-4bfc-b94a-55309009276c\",\n      \"target\": \"38512161-5d70-4688-8d41-4baf972a27cf\",\n      \"text\": \"\",\n      \"uuid\": \"054b3162-ba3c-4235-b657-a3c5264bd32e\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"4938e887-858e-4bfc-b94a-55309009276c\",\n      \"target\": \"f8a64ce4-a3cc-423f-8e26-9738de4d63e2\",\n      \"text\": \"\",\n      \"uuid\": \"69970843-c02b-4cbe-9ba6-413672ee1590\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"52014138-86c1-4fa5-a072-21d3d2ecfc6b\",\n      \"target\": \"14f720f1-0169-408e-bb05-759cd9b81aec\",\n      \"text\": \"\",\n      \"uuid\": \"c0798190-43d8-4ee0-b042-8d7a55764a5c\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"52014138-86c1-4fa5-a072-21d3d2ecfc6b\",\n      \"target\": \"bbfedb68-6a3c-435c-b468-2185f5aa48d5\",\n      \"text\": \"\",\n      \"uuid\": \"c45e1169-b7ed-44c2-82aa-a5e5616480d9\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"4938e887-858e-4bfc-b94a-55309009276c\",\n      \"target\": \"eb6c8d0f-2c59-4a85-9d74-ff3c38aa3c3c\",\n      \"text\": \"\",\n      \"uuid\": \"b77da430-5fe9-4973-92ab-785e30b5dc5a\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"f07cd6e4-981a-41fe-8202-7ea6bf601e46\",\n      \"target\": \"882d5ab7-1e39-4eb1-a1af-3af662782a29\",\n      \"text\": \"\",\n      \"uuid\": \"f894e1c5-f3e3-49f5-84d7-002d89bcd4ff\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"4938e887-858e-4bfc-b94a-55309009276c\",\n      \"target\": \"56c82faf-7ed0-428a-94a0-5917a6d11843\",\n      \"text\": \"\",\n      \"uuid\": \"127e51a2-6bee-421f-b306-76ab374fec14\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.01, 0.5],\n      \"targetRectRate\": [0.99, 0.5]\n    },\n    {\n      \"source\": \"199a0ec8-61de-4566-af09-4cdc65c56e96\",\n      \"target\": \"63591c61-d454-4720-9619-ac85b90fd637\",\n      \"text\": \"\",\n      \"uuid\": \"bb1e7f78-9021-46dc-a09d-cca83a51bc74\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"0977daf0-bd15-4bc2-aa03-9171c7825bbe\",\n      \"target\": \"7bf3996d-e1e1-4d4b-ba13-a8f00ef1fe13\",\n      \"text\": \"\",\n      \"uuid\": \"5ed81fed-3715-49b1-9828-43e8252e32e5\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"7bf3996d-e1e1-4d4b-ba13-a8f00ef1fe13\",\n      \"target\": \"3c5baaa9-3c97-4212-9065-4a90ad948317\",\n      \"text\": \"\",\n      \"uuid\": \"a5b5b99a-50db-4cba-b650-02703894d1dd\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.01, 0.5],\n      \"targetRectRate\": [0.99, 0.5]\n    },\n    {\n      \"source\": \"7bf3996d-e1e1-4d4b-ba13-a8f00ef1fe13\",\n      \"target\": \"0a8fe11c-0af7-4cd9-8365-4b8c3fc4f88a\",\n      \"text\": \"\",\n      \"uuid\": \"18e32370-27c1-47ad-87c2-3e43f5d40f41\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"e299c930-6e0c-4986-8152-2e9d361f06c1\",\n      \"target\": \"23e9b42d-d28b-4e14-8990-7bdd9d8976e3\",\n      \"text\": \"\",\n      \"uuid\": \"8fa8263d-1779-4661-be00-3aef0824db1c\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"78be1241-8d96-4b21-99e7-03a1fb40fd00\",\n      \"target\": \"e299c930-6e0c-4986-8152-2e9d361f06c1\",\n      \"text\": \"\",\n      \"uuid\": \"48ce46b9-7c86-4d31-900f-ca6ae297957d\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"f07cd6e4-981a-41fe-8202-7ea6bf601e46\",\n      \"target\": \"78be1241-8d96-4b21-99e7-03a1fb40fd00\",\n      \"text\": \"\",\n      \"uuid\": \"db0ab1c2-0aa8-4dca-92c3-9280760af78d\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"e9054cbb-9356-43c5-8f9a-e9adb25eb72b\",\n      \"target\": \"61735b75-480c-4867-96fd-0a5fb300f0d6\",\n      \"text\": \"\",\n      \"uuid\": \"1240a4de-2abb-43d0-ab17-2b84db418000\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"e9054cbb-9356-43c5-8f9a-e9adb25eb72b\",\n      \"target\": \"0bfa1bb4-c518-478c-aca1-ea4112ae9b5b\",\n      \"text\": \"\",\n      \"uuid\": \"a6638465-e6a4-4282-be0d-c12fedfbf985\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"c9085182-3fa3-4773-a90b-985bb57002f2\",\n      \"target\": \"09362bef-d992-4819-a4a1-850b0aaedbea\",\n      \"text\": \"\",\n      \"uuid\": \"3dd74016-93c0-4d58-a805-3a560492351a\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"e299c930-6e0c-4986-8152-2e9d361f06c1\",\n      \"target\": \"f2a7b75a-560b-44e0-b4b8-03e34426f3b8\",\n      \"text\": \"\",\n      \"uuid\": \"5795c6bc-43ac-4d1b-a81b-fd6968e74769\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"43704845-0f9a-4fc1-a32a-9cf86088ab67\",\n      \"target\": \"c3a79790-1cdf-41e7-8090-3df3feca7324\",\n      \"text\": \"\",\n      \"uuid\": \"8c0c85d4-b342-4ce1-a57e-238dc1958b33\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.99, 0.5],\n      \"targetRectRate\": [0.01, 0.5]\n    },\n    {\n      \"source\": \"1f9f415c-7422-44ef-ba79-93370c9d06d2\",\n      \"target\": \"013b0e2d-cda1-4f13-99ab-971c2e08b99e\",\n      \"text\": \"\",\n      \"uuid\": \"ee91a0b5-af44-4034-adb3-d3f41320b989\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"013b0e2d-cda1-4f13-99ab-971c2e08b99e\",\n      \"target\": \"02e8e0b7-60a5-4dd3-adcf-75e5cae9e1d2\",\n      \"text\": \"\",\n      \"uuid\": \"f2a0f197-f290-4df7-bcea-4655326bfcfe\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"c3a79790-1cdf-41e7-8090-3df3feca7324\",\n      \"target\": \"1f9f415c-7422-44ef-ba79-93370c9d06d2\",\n      \"text\": \"\",\n      \"uuid\": \"0ae3e2c8-ba9a-473c-9db3-5c27e552d5b8\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    }\n  ],\n  \"tags\": []\n}\n"
  },
  {
    "path": "docs-pg/ProjectGraph继承体系.json",
    "content": "{\n  \"version\": 17,\n  \"entities\": [\n    {\n      \"location\": [-463, 1105],\n      \"size\": [104.41596984863281, 76],\n      \"text\": \"Edge\",\n      \"uuid\": \"b96b41c5-d5b9-4c37-a16b-519218e63ef6\",\n      \"details\": \"长度2，数组顺序表示有向边\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [853, 1518],\n      \"size\": [140.92796325683594, 76],\n      \"text\": \"Section\",\n      \"uuid\": \"3e215928-c1d9-4e0c-8b4d-937d93918e24\",\n      \"details\": \"一个矩形容器框，当他移动的时候能附带里面所有的东西进行移动\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1063, 1714],\n      \"size\": [108.12797546386719, 76],\n      \"text\": \"Node\",\n      \"uuid\": \"b2974a32-d46b-40a7-97f3-e6021001e8f7\",\n      \"details\": \"\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-297, 12],\n      \"size\": [215.26393127441406, 76],\n      \"text\": \"StageObject\",\n      \"uuid\": \"fa49c7c9-ce2a-4b6c-806d-b32681f98e6d\",\n      \"details\": \"舞台东西\",\n      \"color\": [168, 85, 247, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1233, 1717],\n      \"size\": [213.9519500732422, 76],\n      \"text\": \"BubbleNode\",\n      \"uuid\": \"0cd2a022-265b-488d-8fea-498bd8551212\",\n      \"details\": \"圆形的节点，身体始终呈现圆形，可以用于好看的状态机绘制\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1498, 1721],\n      \"size\": [154.39996337890625, 76],\n      \"text\": \"fileNode\",\n      \"uuid\": \"13a10114-0490-44e5-bffd-7891e3565e7b\",\n      \"details\": \"表示文件，可点击打开文件的节点\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1724, 1716],\n      \"size\": [291.5199279785156, 76],\n      \"text\": \"WebsiteLinkNode\",\n      \"uuid\": \"4cf355e0-074c-4670-98cc-40f2c5796af8\",\n      \"details\": \"表示网页，可点击直接进入网页，创建时能自动爬网站内的图片来渲染\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [2066, 1714],\n      \"size\": [199.9679412841797, 76],\n      \"text\": \"ImageNode\",\n      \"uuid\": \"4012bfde-8e13-489f-b5bc-db32b1dec0c8\",\n      \"details\": \"纯图片节点\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1267, 798],\n      \"size\": [304.70391845703125, 76],\n      \"text\": \"ConnectableEntity\",\n      \"uuid\": \"b588b252-1a12-404a-9045-806d299ba0e0\",\n      \"details\": \"\",\n      \"color\": [168, 85, 247, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [162, 57],\n      \"size\": [348, 76],\n      \"text\": \"一切具有碰撞箱的东西\",\n      \"uuid\": \"cde9d4e5-fda2-4578-b711-46cc88a8e279\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1678, 798],\n      \"size\": [808.4159545898438, 76],\n      \"text\": \"一切可被Edge连接的东西，且会算入图分析算法的东西\",\n      \"uuid\": \"d724bf18-5737-4e97-ad46-414a28702fa8\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [512, 1517],\n      \"size\": [297.4078674316406, 76],\n      \"text\": \"container: Entity[]\",\n      \"uuid\": \"cc7c9d29-ca48-42e8-a802-96e5c7195588\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [395, 350],\n      \"size\": [115.4239501953125, 76],\n      \"text\": \"Entity\",\n      \"uuid\": \"6073bc30-0c2f-45c6-af8c-4f371fffa573\",\n      \"details\": \"实体\",\n      \"color\": [168, 85, 247, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [176, -70],\n      \"size\": [125.34397888183594, 76],\n      \"text\": \"有uuid\",\n      \"uuid\": \"e6c6836d-a05e-489a-9c1b-36e95fd291fc\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [386, 1158],\n      \"size\": [124, 76],\n      \"text\": \"分割线\",\n      \"uuid\": \"6a2cfa0e-4776-4c10-9e15-6da621edb0b2\",\n      \"details\": \"用于在视觉上划分区域\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1063, 798],\n      \"size\": [92, 76],\n      \"text\": \"贴纸\",\n      \"uuid\": \"67b12ccd-ab07-47af-9f3d-5ab201fb7ec6\",\n      \"details\": \"\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [784, 798],\n      \"size\": [210.01593017578125, 76],\n      \"text\": \"EffectOrigin\",\n      \"uuid\": \"d0e281eb-f3dd-4392-a953-a92abbf140be\",\n      \"details\": \"特效源，持续在某一位置播放粒子效果\",\n      \"color\": [168, 85, 247, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [652, -195],\n      \"size\": [537.3759765625, 76],\n      \"text\": \"json化时，表示各个物体之间的关系\",\n      \"uuid\": \"cc7a7572-5272-4411-80e9-6fd3553154cd\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [652, -119],\n      \"size\": [220, 76],\n      \"text\": \"图论分析算法\",\n      \"uuid\": \"df300c7c-5a80-422b-8e80-80ff67008e45\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [652, -43],\n      \"size\": [303.03997802734375, 76],\n      \"text\": \"优化，作为key使用\",\n      \"uuid\": \"a458fb52-45c8-42f4-98e6-5b14e5ec1bec\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1681, 284],\n      \"size\": [473.27996826171875, 76],\n      \"text\": \"与Connectable实时关联的东西\",\n      \"uuid\": \"bbacd288-c843-4ab2-b178-50afc52042f0\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1664, 1038],\n      \"size\": [220, 76],\n      \"text\": \"图论分析结果\",\n      \"uuid\": \"9f5a7b32-a04a-4749-9d2d-e2479791f7ad\",\n      \"details\": \"关联一个Section，只分析Section里面的Connectable形成的图\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-697, 1104],\n      \"size\": [200.4159698486328, 76],\n      \"text\": \"全连接Edge\",\n      \"uuid\": \"a91311cd-4090-40aa-af07-98e6e06d64a7\",\n      \"details\": \"长度N\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1096, 348],\n      \"size\": [200.22393798828125, 76],\n      \"text\": \"Association\",\n      \"uuid\": \"ec983004-0785-47b3-9f2c-4cba9322144d\",\n      \"details\": \"关系\",\n      \"color\": [168, 85, 247, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1932, 408],\n      \"size\": [724.095947265625, 76],\n      \"text\": \"有一个非空数组，表示关联的connectable的数组\",\n      \"uuid\": \"08f41018-f75a-40c3-9924-056810a51fcd\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1521, 797],\n      \"size\": [313.15191650390625, 76],\n      \"text\": \"SectionAssociation\",\n      \"uuid\": \"d227089d-386b-40e3-8556-5cbc0c9ec49f\",\n      \"details\": \"\",\n      \"color\": [168, 85, 247, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-748, 799],\n      \"size\": [389.50390625, 76],\n      \"text\": \"ConnectableAssociation\",\n      \"uuid\": \"3e55226c-22f9-4462-8f36-5e76fd22cd50\",\n      \"details\": \"专注于处理图论的关系\",\n      \"color\": [168, 85, 247, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-2320, 803],\n      \"size\": [279.00791931152344, 76],\n      \"text\": \"Xxx...Association\",\n      \"uuid\": \"7fa8aad7-828c-4c99-a3f7-51089ae49952\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [743, 296],\n      \"size\": [476, 76],\n      \"text\": \"一切独立存在，独立移动的东西\",\n      \"uuid\": \"2ec6115f-af92-4074-9b10-79a4f2d0486d\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [743, 375],\n      \"size\": [556.927978515625, 76],\n      \"text\": \"一切能放入Section里附带移动的东西\",\n      \"uuid\": \"ba96dfe7-b9ea-448c-b719-a458f6664792\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [487, 798],\n      \"size\": [252, 76],\n      \"text\": \"独立线存在的线\",\n      \"uuid\": \"9f471776-6d77-4460-80a8-86dae3344514\",\n      \"details\": \"\",\n      \"color\": [168, 85, 247, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1200, 799],\n      \"size\": [407.48785400390625, 76],\n      \"text\": \"AssociationOfAssociation\",\n      \"uuid\": \"9a0d5c8b-3f7e-4bbc-a8fe-03dc20ddfce3\",\n      \"details\": \"关系的关系\",\n      \"color\": [168, 85, 247, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-2608, -803],\n      \"size\": [92, 76],\n      \"text\": \"接口\",\n      \"uuid\": \"ca957d6b-5cde-4843-aaa5-f7307f95b8a6\",\n      \"details\": \"\",\n      \"color\": [22, 163, 74, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-2608, -725],\n      \"size\": [124, 76],\n      \"text\": \"抽象类\",\n      \"uuid\": \"fd258d11-f421-45c2-af6a-93c88c5b7165\",\n      \"details\": \"\",\n      \"color\": [168, 85, 247, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-2608, -647],\n      \"size\": [60, 76],\n      \"text\": \"类\",\n      \"uuid\": \"85bda354-02a7-4a3f-902b-5f32c529098a\",\n      \"details\": \"\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [583, 1155],\n      \"size\": [156, 76],\n      \"text\": \"双箭头线\",\n      \"uuid\": \"934adc68-e7d8-4523-9360-8e7fa61f1dfd\",\n      \"details\": \"用于绘制小坐标轴比较图\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [652, 33],\n      \"size\": [888.1599731445312, 76],\n      \"text\": \"由于有“关系的关系”，所以关系也有uuid，所以一切都有uuid\",\n      \"uuid\": \"3db06015-6775-42a4-ae7a-5a0d7b6a3d65\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1417, 1037],\n      \"size\": [252, 76],\n      \"text\": \"树形图分析结果\",\n      \"uuid\": \"350dd1e1-c8b3-40a3-b9c0-6d7862850250\",\n      \"details\": \"关联一个Section，只分析里面的树形嵌套\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-792, -36],\n      \"size\": [124, 76],\n      \"text\": \"碰撞箱\",\n      \"uuid\": \"0aba43ea-9c9d-4431-af38-12f36a8c66d1\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-2842, 98],\n      \"size\": [284, 76],\n      \"text\": \"可参与碰撞的图形\",\n      \"uuid\": \"95251ba5-ed15-4023-965f-817dde2d0b02\",\n      \"details\": \"\",\n      \"color\": [168, 85, 247, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-3147, 226],\n      \"size\": [188, 76],\n      \"text\": \"碰撞管理器\",\n      \"uuid\": \"468d18f2-6fb7-4f98-88a5-1e291c75345e\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-2608, -569],\n      \"size\": [271.8079376220703, 76],\n      \"text\": \"NameSpace单例\",\n      \"uuid\": \"67571306-d9a4-4cdb-b69b-fe52067e3b57\",\n      \"details\": \"\",\n      \"color\": [239, 68, 68, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1123, 1037],\n      \"size\": [156, 76],\n      \"text\": \"双向关系\",\n      \"uuid\": \"ab4ff8a3-2e03-4b13-ab55-00f2ed13df48\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-892, 1041],\n      \"size\": [156, 76],\n      \"text\": \"单向关系\",\n      \"uuid\": \"77ca4c7a-d984-40ed-8bba-facb4dc12e3d\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [784, 1156],\n      \"size\": [188, 76],\n      \"text\": \"烟雾弹遮罩\",\n      \"uuid\": \"c5c7db9d-9bb1-4631-9fbb-311d5a175cb5\",\n      \"details\": \"\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [976, 1154],\n      \"size\": [92, 76],\n      \"text\": \"烟花\",\n      \"uuid\": \"61061fa1-a049-4b9a-9e1d-c2b6cf138e88\",\n      \"details\": \"\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1873, 796],\n      \"size\": [289.5679016113281, 76],\n      \"text\": \"AssociationEffect\",\n      \"uuid\": \"356f9da5-2a56-4669-a682-81e5ca9b89d9\",\n      \"details\": \"\",\n      \"color\": [168, 85, 247, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1985, 1042],\n      \"size\": [188, 76],\n      \"text\": \"引力波特效\",\n      \"uuid\": \"b1cdb14c-5ada-432a-94bb-88ff00b0d4cd\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-2608, -492],\n      \"size\": [92, 76],\n      \"text\": \"对象\",\n      \"uuid\": \"3c82b102-9c7a-4cf7-a0e7-a22b874b9818\",\n      \"details\": \"\",\n      \"color\": [234, 179, 8, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-3121, 619],\n      \"size\": [92, 76],\n      \"text\": \"矩形\",\n      \"uuid\": \"dc52bfdd-5900-4c5c-aae3-0266a93747bd\",\n      \"details\": \"\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-2997, 619],\n      \"size\": [92, 76],\n      \"text\": \"圆形\",\n      \"uuid\": \"292c601f-191f-4931-87f6-7316f3054b27\",\n      \"details\": \"\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-2873, 619],\n      \"size\": [124, 76],\n      \"text\": \"线段形\",\n      \"uuid\": \"f5e9a4ff-9f81-4683-8808-b8a48d556eb7\",\n      \"details\": \"\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-2718, 619],\n      \"size\": [60, 76],\n      \"text\": \"点\",\n      \"uuid\": \"b41ae71c-b2fc-40ae-8d1d-072dbac60a7f\",\n      \"details\": \"\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-2626, 619],\n      \"size\": [220, 76],\n      \"text\": \"贝塞尔线段形\",\n      \"uuid\": \"ae52803b-022e-43ff-a8b5-14ad1d25ffaf\",\n      \"details\": \"\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-527, -674],\n      \"size\": [193.02394104003906, 76],\n      \"text\": \"Disposable\",\n      \"uuid\": \"bcba5d42-c24f-4206-b5ec-523add9234d0\",\n      \"details\": \"具有销毁函数的\",\n      \"color\": [22, 163, 74, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-552, 12],\n      \"size\": [92, 76],\n      \"text\": \"字段\",\n      \"uuid\": \"6bf5d93f-03e3-4702-bfd4-3bb64fd5f207\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-864, 57],\n      \"size\": [188, 76],\n      \"text\": \"渲染函数？\",\n      \"uuid\": \"415c5c49-ae15-421d-8922-20b499802470\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-780, -293],\n      \"size\": [173.85594177246094, 76],\n      \"text\": \"Extension\",\n      \"uuid\": \"b29ebefb-11e5-4170-92ab-427ac709f950\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-995, -289],\n      \"size\": [92, 76],\n      \"text\": \"字段\",\n      \"uuid\": \"a8cc5794-9dda-4b44-bde4-21ee28720267\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1255, -287],\n      \"size\": [161.69593811035156, 76],\n      \"text\": \"init(core)\",\n      \"uuid\": \"da5acb36-d129-4e2f-81a3-de2b4762beca\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1806, 102],\n      \"size\": [156, 76],\n      \"text\": \"碰撞箱类\",\n      \"uuid\": \"f4677fb0-ffbb-4dd3-a926-cb2eb7d72116\",\n      \"details\": \"\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1985, 103],\n      \"size\": [92, 76],\n      \"text\": \"字段\",\n      \"uuid\": \"5c777ce0-57dd-46de-b0ac-19d750318c95\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-2405, 98],\n      \"size\": [156, 76],\n      \"text\": \"图形数组\",\n      \"uuid\": \"4db8074b-e96b-4e68-b686-2bee543711b1\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-2376, -211],\n      \"size\": [220, 76],\n      \"text\": \"鼠标是否在内\",\n      \"uuid\": \"64106e17-442a-4590-9a7c-6c2eef8abbef\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-2376, -135],\n      \"size\": [220, 76],\n      \"text\": \"鼠标是否框选\",\n      \"uuid\": \"d73a9508-f1de-4998-a2ee-76b6545bd5b7\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-2376, -59],\n      \"size\": [220, 76],\n      \"text\": \"划线是否切中\",\n      \"uuid\": \"cdb1e3e8-39cf-4d59-be70-8d9a0d95c176\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-2376, -287],\n      \"size\": [220, 76],\n      \"text\": \"更新形状数组\",\n      \"uuid\": \"bfcece66-7970-478a-bdbd-a07617f0523b\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [112, 798],\n      \"size\": [189.31195068359375, 76],\n      \"text\": \"VeenCircle\",\n      \"uuid\": \"6e95c80e-150d-4c95-b2e0-2b11739c9e15\",\n      \"details\": \"用来画交集分析图\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-297, 798],\n      \"size\": [361.535888671875, 76],\n      \"text\": \"VeenCircleAssociation\",\n      \"uuid\": \"fd794c28-9193-4107-833f-e2a41059ca62\",\n      \"details\": \"uuid数组>1，当两个圆形重叠时自动显示重叠区域关系文字\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-214, -669],\n      \"size\": [153.1839599609375, 76],\n      \"text\": \"Tickable\",\n      \"uuid\": \"501e6db5-3c87-4f81-8fc4-6415fb8bedc5\",\n      \"details\": \"\",\n      \"color\": [22, 163, 74, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [544, -741],\n      \"size\": [124, 76],\n      \"text\": \"新设计\",\n      \"uuid\": \"a791f550-b886-4f60-9992-621536a61227\",\n      \"details\": \"考虑像HTMLElement那样有各种方法\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [807, -1280],\n      \"size\": [461.5358581542969, 76],\n      \"text\": \"onObjectClick(mouseButton)\",\n      \"uuid\": \"4e8b8c0c-82ba-4736-8fac-b88480576c0d\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1009, -1199],\n      \"size\": [569.0878295898438, 76],\n      \"text\": \"onObjectDoubleClick(mouseButton)\",\n      \"uuid\": \"b180f2ee-013b-4d27-a1c2-938c229568e4\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [43, -968],\n      \"size\": [327.263916015625, 76],\n      \"text\": \"onMouseMoveEnter\",\n      \"uuid\": \"3e80b28f-3b64-4a12-8cc9-7cc1ec73887f\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [43, -892],\n      \"size\": [334.43194580078125, 76],\n      \"text\": \"onMouseMoveLeave\",\n      \"uuid\": \"29d1a804-3758-4648-86cc-d2be723dc3af\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1193, -853],\n      \"size\": [369.055908203125, 76],\n      \"text\": \"onRectangleSelectAdd\",\n      \"uuid\": \"d19d0fd6-a693-46d2-ba6a-0b450ee06f59\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1195, -774],\n      \"size\": [428.3518981933594, 76],\n      \"text\": \"onRectangleSelectRemove\",\n      \"uuid\": \"c29a3f55-d0c1-4464-bbb6-38cf28167eb1\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1145, -1018],\n      \"size\": [441.91986083984375, 76],\n      \"text\": \"onDragMove(mouseButton)\",\n      \"uuid\": \"5c7abad1-3249-46b5-bc37-e3b6a64c5978\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1198, -696],\n      \"size\": [490.43182373046875, 76],\n      \"text\": \"_rectangleSelectLevel: number\",\n      \"uuid\": \"c2b871f8-ca83-43ff-add1-7ec588b9d179\",\n      \"details\": \"框选优先级逐渐升高：\\n线1，节点2，Section3\\n\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [908, -741],\n      \"size\": [156, 76],\n      \"text\": \"框选相关\",\n      \"uuid\": \"2587f315-433b-4fe4-836d-f5f8a33570bd\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1181, -527],\n      \"size\": [283.03993225097656, 76],\n      \"text\": \"onLineSelectAdd\",\n      \"uuid\": \"e5e3d3fd-fa22-4ac7-ba0f-ec83875df58c\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1190, -439],\n      \"size\": [342.3359375, 76],\n      \"text\": \"onLineSelectRemove\",\n      \"uuid\": \"198a47ca-507c-4811-8a39-ac03dbd514bd\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [908, -512],\n      \"size\": [188, 76],\n      \"text\": \"删除线相关\",\n      \"uuid\": \"05d3ec5c-0f55-45f1-a308-fa1c3fc0e1b6\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1182, -354],\n      \"size\": [164.1599578857422, 76],\n      \"text\": \"onDelete\",\n      \"uuid\": \"e2b352cf-26c8-4e0d-9263-525432e97835\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [43, -816],\n      \"size\": [328.4479064941406, 76],\n      \"text\": \"onControlWheel(dy)\",\n      \"uuid\": \"84f77e1d-d557-4a34-91ac-101064838c53\",\n      \"details\": \"对着这个东西使用Ctrl+滚轮\\n\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-2638, -883],\n      \"size\": [331.8079376220703, 497],\n      \"uuid\": \"b605fe5f-6909-4ff9-964d-81352a6b4b32\",\n      \"text\": \"颜色表示\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\n        \"ca957d6b-5cde-4843-aaa5-f7307f95b8a6\",\n        \"fd258d11-f421-45c2-af6a-93c88c5b7165\",\n        \"85bda354-02a7-4a3f-902b-5f32c529098a\",\n        \"67571306-d9a4-4cdb-b69b-fe52067e3b57\",\n        \"3c82b102-9c7a-4cf7-a0e7-a22b874b9818\"\n      ],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [622, -275],\n      \"size\": [948.1599731445312, 414],\n      \"uuid\": \"65e464eb-54bd-4cbb-984e-ce25e63cf290\",\n      \"text\": \"uuid的意义\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\n        \"cc7a7572-5272-4411-80e9-6fd3553154cd\",\n        \"df300c7c-5a80-422b-8e80-80ff67008e45\",\n        \"a458fb52-45c8-42f4-98e6-5b14e5ec1bec\",\n        \"3db06015-6775-42a4-ae7a-5a0d7b6a3d65\"\n      ],\n      \"details\": \"\"\n    }\n  ],\n  \"associations\": [\n    {\n      \"source\": \"cde9d4e5-fda2-4578-b711-46cc88a8e279\",\n      \"target\": \"fa49c7c9-ce2a-4b6c-806d-b32681f98e6d\",\n      \"text\": \"\",\n      \"uuid\": \"7ec03573-2c6b-456e-8116-0b36c4d15fd0\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.01, 0.5],\n      \"targetRectRate\": [0.99, 0.5]\n    },\n    {\n      \"source\": \"b2974a32-d46b-40a7-97f3-e6021001e8f7\",\n      \"target\": \"b588b252-1a12-404a-9045-806d299ba0e0\",\n      \"text\": \"\",\n      \"uuid\": \"9e78f13a-654c-4377-9a74-0de9791eaea0\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"0cd2a022-265b-488d-8fea-498bd8551212\",\n      \"target\": \"b588b252-1a12-404a-9045-806d299ba0e0\",\n      \"text\": \"\",\n      \"uuid\": \"365b6b42-e267-4a84-8556-6dae398218e5\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"13a10114-0490-44e5-bffd-7891e3565e7b\",\n      \"target\": \"b588b252-1a12-404a-9045-806d299ba0e0\",\n      \"text\": \"\",\n      \"uuid\": \"1c59b25c-2548-4e0c-bedb-0406fe5fd154\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"4cf355e0-074c-4670-98cc-40f2c5796af8\",\n      \"target\": \"b588b252-1a12-404a-9045-806d299ba0e0\",\n      \"text\": \"\",\n      \"uuid\": \"603a05bb-fcb5-4c9f-8c8f-7ee9c491937a\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"4012bfde-8e13-489f-b5bc-db32b1dec0c8\",\n      \"target\": \"b588b252-1a12-404a-9045-806d299ba0e0\",\n      \"text\": \"\",\n      \"uuid\": \"90f638e5-4015-4374-ac6e-50e03b317111\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"d724bf18-5737-4e97-ad46-414a28702fa8\",\n      \"target\": \"b588b252-1a12-404a-9045-806d299ba0e0\",\n      \"text\": \"\",\n      \"uuid\": \"e9c49e0c-8fa6-4e06-8212-e5b2713729ee\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.01, 0.5],\n      \"targetRectRate\": [0.99, 0.5]\n    },\n    {\n      \"source\": \"3e215928-c1d9-4e0c-8b4d-937d93918e24\",\n      \"target\": \"b588b252-1a12-404a-9045-806d299ba0e0\",\n      \"text\": \"\",\n      \"uuid\": \"e5971533-33c0-48e7-bd7c-27c240a680ee\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"3e215928-c1d9-4e0c-8b4d-937d93918e24\",\n      \"target\": \"cc7c9d29-ca48-42e8-a802-96e5c7195588\",\n      \"text\": \"\",\n      \"uuid\": \"02dc9c6d-3b42-448a-a9a7-2cfd52487f2c\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"6073bc30-0c2f-45c6-af8c-4f371fffa573\",\n      \"target\": \"fa49c7c9-ce2a-4b6c-806d-b32681f98e6d\",\n      \"text\": \"\",\n      \"uuid\": \"6bc9b5ab-a851-4f75-8d9f-220bfc83de40\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"b588b252-1a12-404a-9045-806d299ba0e0\",\n      \"target\": \"6073bc30-0c2f-45c6-af8c-4f371fffa573\",\n      \"text\": \"\",\n      \"uuid\": \"fe5e2e3b-62be-4bc8-856a-45d2d19f7be5\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"67b12ccd-ab07-47af-9f3d-5ab201fb7ec6\",\n      \"target\": \"6073bc30-0c2f-45c6-af8c-4f371fffa573\",\n      \"text\": \"\",\n      \"uuid\": \"a2255b94-aad0-4423-b715-cc5ad89ea647\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"d0e281eb-f3dd-4392-a953-a92abbf140be\",\n      \"target\": \"6073bc30-0c2f-45c6-af8c-4f371fffa573\",\n      \"text\": \"\",\n      \"uuid\": \"76d61fea-150f-4c53-9a1a-313d4ad59e3c\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"ec983004-0785-47b3-9f2c-4cba9322144d\",\n      \"target\": \"fa49c7c9-ce2a-4b6c-806d-b32681f98e6d\",\n      \"text\": \"\",\n      \"uuid\": \"9f0f95f9-40b5-4f7a-ad0c-d9e762baa166\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"bbacd288-c843-4ab2-b178-50afc52042f0\",\n      \"target\": \"ec983004-0785-47b3-9f2c-4cba9322144d\",\n      \"text\": \"\",\n      \"uuid\": \"3883310c-695b-4c72-9eb8-579a1e3afcf0\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.99, 0.5],\n      \"targetRectRate\": [0.01, 0.5]\n    },\n    {\n      \"source\": \"08f41018-f75a-40c3-9924-056810a51fcd\",\n      \"target\": \"ec983004-0785-47b3-9f2c-4cba9322144d\",\n      \"text\": \"\",\n      \"uuid\": \"71c2c0b2-e4eb-4677-a8a2-21a4507fc108\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.99, 0.5],\n      \"targetRectRate\": [0.01, 0.5]\n    },\n    {\n      \"source\": \"9f5a7b32-a04a-4749-9d2d-e2479791f7ad\",\n      \"target\": \"d227089d-386b-40e3-8556-5cbc0c9ec49f\",\n      \"text\": \"\",\n      \"uuid\": \"320a46be-b070-4c71-a3c2-f637748d7c6c\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"3e55226c-22f9-4462-8f36-5e76fd22cd50\",\n      \"target\": \"ec983004-0785-47b3-9f2c-4cba9322144d\",\n      \"text\": \"\",\n      \"uuid\": \"945076bf-4cf6-4fdf-a737-5c54b5fc01e8\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"7fa8aad7-828c-4c99-a3f7-51089ae49952\",\n      \"target\": \"ec983004-0785-47b3-9f2c-4cba9322144d\",\n      \"text\": \"\",\n      \"uuid\": \"49267d7c-40b6-4daf-989b-5d2c28668544\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"b96b41c5-d5b9-4c37-a16b-519218e63ef6\",\n      \"target\": \"3e55226c-22f9-4462-8f36-5e76fd22cd50\",\n      \"text\": \"\",\n      \"uuid\": \"800c1c93-8ffa-4365-aa23-58923a0f38fc\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"a91311cd-4090-40aa-af07-98e6e06d64a7\",\n      \"target\": \"3e55226c-22f9-4462-8f36-5e76fd22cd50\",\n      \"text\": \"\",\n      \"uuid\": \"ad16e5d9-1057-44a7-b12d-3f99019f34b0\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"2ec6115f-af92-4074-9b10-79a4f2d0486d\",\n      \"target\": \"6073bc30-0c2f-45c6-af8c-4f371fffa573\",\n      \"text\": \"\",\n      \"uuid\": \"592301c0-0ab1-4a77-94d2-1cb79daa7271\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"ba96dfe7-b9ea-448c-b719-a458f6664792\",\n      \"target\": \"6073bc30-0c2f-45c6-af8c-4f371fffa573\",\n      \"text\": \"\",\n      \"uuid\": \"8118f334-16ca-4189-a8b2-0cf19984afb1\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"9f471776-6d77-4460-80a8-86dae3344514\",\n      \"target\": \"6073bc30-0c2f-45c6-af8c-4f371fffa573\",\n      \"text\": \"\",\n      \"uuid\": \"f1853b92-dcbe-4588-b1c4-269753a78e85\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"9a0d5c8b-3f7e-4bbc-a8fe-03dc20ddfce3\",\n      \"target\": \"ec983004-0785-47b3-9f2c-4cba9322144d\",\n      \"text\": \"\",\n      \"uuid\": \"cdc1f1cc-f938-409d-a5cc-ef01bfd6c93e\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"934adc68-e7d8-4523-9360-8e7fa61f1dfd\",\n      \"target\": \"9f471776-6d77-4460-80a8-86dae3344514\",\n      \"text\": \"\",\n      \"uuid\": \"45a1cc41-859a-4a33-9e65-a8096d7a5ce2\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"6a2cfa0e-4776-4c10-9e15-6da621edb0b2\",\n      \"target\": \"9f471776-6d77-4460-80a8-86dae3344514\",\n      \"text\": \"\",\n      \"uuid\": \"1a7b4e94-fd38-4aa5-8eb2-0eb4d4d9f4a3\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"e6c6836d-a05e-489a-9c1b-36e95fd291fc\",\n      \"target\": \"fa49c7c9-ce2a-4b6c-806d-b32681f98e6d\",\n      \"text\": \"\",\n      \"uuid\": \"b39e7bf5-4f23-4c1f-ab9b-d625abaea142\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.01, 0.5],\n      \"targetRectRate\": [0.99, 0.5]\n    },\n    {\n      \"source\": \"d227089d-386b-40e3-8556-5cbc0c9ec49f\",\n      \"target\": \"ec983004-0785-47b3-9f2c-4cba9322144d\",\n      \"text\": \"\",\n      \"uuid\": \"ab52450b-8776-42af-a06d-75d9f396c57d\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"350dd1e1-c8b3-40a3-b9c0-6d7862850250\",\n      \"target\": \"d227089d-386b-40e3-8556-5cbc0c9ec49f\",\n      \"text\": \"\",\n      \"uuid\": \"f255a289-6cb5-432a-ae15-b39bd430b0f8\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"ab4ff8a3-2e03-4b13-ab55-00f2ed13df48\",\n      \"target\": \"9a0d5c8b-3f7e-4bbc-a8fe-03dc20ddfce3\",\n      \"text\": \"\",\n      \"uuid\": \"61ab1a95-f3d1-44ec-9d0d-a92480364ec4\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"77ca4c7a-d984-40ed-8bba-facb4dc12e3d\",\n      \"target\": \"9a0d5c8b-3f7e-4bbc-a8fe-03dc20ddfce3\",\n      \"text\": \"\",\n      \"uuid\": \"4213f646-f294-4173-8841-5e8de1d8db9c\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"c5c7db9d-9bb1-4631-9fbb-311d5a175cb5\",\n      \"target\": \"d0e281eb-f3dd-4392-a953-a92abbf140be\",\n      \"text\": \"\",\n      \"uuid\": \"78c8482e-5e07-4905-bf96-e80659c94ddd\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"61061fa1-a049-4b9a-9e1d-c2b6cf138e88\",\n      \"target\": \"d0e281eb-f3dd-4392-a953-a92abbf140be\",\n      \"text\": \"\",\n      \"uuid\": \"b3518bed-5290-4d24-976e-9f342c5c4887\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"356f9da5-2a56-4669-a682-81e5ca9b89d9\",\n      \"target\": \"ec983004-0785-47b3-9f2c-4cba9322144d\",\n      \"text\": \"\",\n      \"uuid\": \"961e6cc7-4bcc-4c1b-a40b-8186834d0b85\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"b1cdb14c-5ada-432a-94bb-88ff00b0d4cd\",\n      \"target\": \"356f9da5-2a56-4669-a682-81e5ca9b89d9\",\n      \"text\": \"\",\n      \"uuid\": \"7b044297-fa28-4417-b838-2f8daf838199\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"dc52bfdd-5900-4c5c-aae3-0266a93747bd\",\n      \"target\": \"95251ba5-ed15-4023-965f-817dde2d0b02\",\n      \"text\": \"\",\n      \"uuid\": \"451aa24c-5d8d-4db8-87a5-f81dfe8334ff\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"292c601f-191f-4931-87f6-7316f3054b27\",\n      \"target\": \"95251ba5-ed15-4023-965f-817dde2d0b02\",\n      \"text\": \"\",\n      \"uuid\": \"4390ab71-5af8-4aa5-85ba-f1e64d5f92e8\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"f5e9a4ff-9f81-4683-8808-b8a48d556eb7\",\n      \"target\": \"95251ba5-ed15-4023-965f-817dde2d0b02\",\n      \"text\": \"\",\n      \"uuid\": \"05bbee93-da8c-4b71-b139-c0d11fdc83dd\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"b41ae71c-b2fc-40ae-8d1d-072dbac60a7f\",\n      \"target\": \"95251ba5-ed15-4023-965f-817dde2d0b02\",\n      \"text\": \"\",\n      \"uuid\": \"6ecfb5e4-e5f9-44c5-b80b-5209b7819ce4\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"ae52803b-022e-43ff-a8b5-14ad1d25ffaf\",\n      \"target\": \"95251ba5-ed15-4023-965f-817dde2d0b02\",\n      \"text\": \"\",\n      \"uuid\": \"4e82a084-b83f-4d55-a8ce-c6dbea6843f1\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"fa49c7c9-ce2a-4b6c-806d-b32681f98e6d\",\n      \"target\": \"bcba5d42-c24f-4206-b5ec-523add9234d0\",\n      \"text\": \"\",\n      \"uuid\": \"817e0ba5-c544-470f-b276-706b2abc8d4c\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"fa49c7c9-ce2a-4b6c-806d-b32681f98e6d\",\n      \"target\": \"6bf5d93f-03e3-4702-bfd4-3bb64fd5f207\",\n      \"text\": \"\",\n      \"uuid\": \"3375d355-52ab-437f-8004-55546437bcb3\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"6bf5d93f-03e3-4702-bfd4-3bb64fd5f207\",\n      \"target\": \"0aba43ea-9c9d-4431-af38-12f36a8c66d1\",\n      \"text\": \"\",\n      \"uuid\": \"3a1ad115-eb25-4203-932f-142e6e5dca5c\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"6bf5d93f-03e3-4702-bfd4-3bb64fd5f207\",\n      \"target\": \"415c5c49-ae15-421d-8922-20b499802470\",\n      \"text\": \"\",\n      \"uuid\": \"8b689ff4-f322-4b6a-ae69-e410e4965c9d\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"bcba5d42-c24f-4206-b5ec-523add9234d0\",\n      \"target\": \"b29ebefb-11e5-4170-92ab-427ac709f950\",\n      \"text\": \"\",\n      \"uuid\": \"53b6a38b-fcfc-442f-9985-5340a8f34ba9\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"b29ebefb-11e5-4170-92ab-427ac709f950\",\n      \"target\": \"a8cc5794-9dda-4b44-bde4-21ee28720267\",\n      \"text\": \"\",\n      \"uuid\": \"f4e26eac-6317-4ec8-8e65-d60cda8007d7\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"a8cc5794-9dda-4b44-bde4-21ee28720267\",\n      \"target\": \"da5acb36-d129-4e2f-81a3-de2b4762beca\",\n      \"text\": \"\",\n      \"uuid\": \"76e8f67c-0fff-4fa7-902d-fb1cd61b0d40\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"f4677fb0-ffbb-4dd3-a926-cb2eb7d72116\",\n      \"target\": \"5c777ce0-57dd-46de-b0ac-19d750318c95\",\n      \"text\": \"\",\n      \"uuid\": \"6347496b-cb25-4cb6-9638-c204e360a7c6\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"5c777ce0-57dd-46de-b0ac-19d750318c95\",\n      \"target\": \"4db8074b-e96b-4e68-b686-2bee543711b1\",\n      \"text\": \"\",\n      \"uuid\": \"b917e657-740f-4c33-8c51-619f248374f7\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"4db8074b-e96b-4e68-b686-2bee543711b1\",\n      \"target\": \"95251ba5-ed15-4023-965f-817dde2d0b02\",\n      \"text\": \"\",\n      \"uuid\": \"7633ae04-a6ae-49d8-96de-c6a8366158db\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"5c777ce0-57dd-46de-b0ac-19d750318c95\",\n      \"target\": \"64106e17-442a-4590-9a7c-6c2eef8abbef\",\n      \"text\": \"\",\n      \"uuid\": \"9b9c05c9-fe3e-42fb-a05d-c8da9a5ad047\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.01, 0.5],\n      \"targetRectRate\": [0.99, 0.5]\n    },\n    {\n      \"source\": \"5c777ce0-57dd-46de-b0ac-19d750318c95\",\n      \"target\": \"d73a9508-f1de-4998-a2ee-76b6545bd5b7\",\n      \"text\": \"\",\n      \"uuid\": \"184a3ff8-268f-4ac7-877f-2926161d2fdd\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.01, 0.5],\n      \"targetRectRate\": [0.99, 0.5]\n    },\n    {\n      \"source\": \"5c777ce0-57dd-46de-b0ac-19d750318c95\",\n      \"target\": \"cdb1e3e8-39cf-4d59-be70-8d9a0d95c176\",\n      \"text\": \"\",\n      \"uuid\": \"7e5db569-7ab4-4fe4-8c7f-ac495cab9021\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.01, 0.5],\n      \"targetRectRate\": [0.99, 0.5]\n    },\n    {\n      \"source\": \"5c777ce0-57dd-46de-b0ac-19d750318c95\",\n      \"target\": \"bfcece66-7970-478a-bdbd-a07617f0523b\",\n      \"text\": \"\",\n      \"uuid\": \"1a167883-eeca-45dd-9c28-b81a3324679e\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.01, 0.5],\n      \"targetRectRate\": [0.99, 0.5]\n    },\n    {\n      \"source\": \"6e95c80e-150d-4c95-b2e0-2b11739c9e15\",\n      \"target\": \"6073bc30-0c2f-45c6-af8c-4f371fffa573\",\n      \"text\": \"\",\n      \"uuid\": \"cfcd238b-eb1b-4f89-94bb-abdec90a95ac\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"fd794c28-9193-4107-833f-e2a41059ca62\",\n      \"target\": \"ec983004-0785-47b3-9f2c-4cba9322144d\",\n      \"text\": \"\",\n      \"uuid\": \"31f148f7-9329-4bfa-88ae-fa511eaf32a2\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"fa49c7c9-ce2a-4b6c-806d-b32681f98e6d\",\n      \"target\": \"a791f550-b886-4f60-9992-621536a61227\",\n      \"text\": \"\",\n      \"uuid\": \"eea3f426-6418-4550-8525-a18489d01bd1\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"a791f550-b886-4f60-9992-621536a61227\",\n      \"target\": \"4e8b8c0c-82ba-4736-8fac-b88480576c0d\",\n      \"text\": \"\",\n      \"uuid\": \"9c3430ef-2ba9-4470-84ea-9b7403cbd6eb\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.99, 0.5],\n      \"targetRectRate\": [0.01, 0.5]\n    },\n    {\n      \"source\": \"a791f550-b886-4f60-9992-621536a61227\",\n      \"target\": \"b180f2ee-013b-4d27-a1c2-938c229568e4\",\n      \"text\": \"\",\n      \"uuid\": \"3c12e67f-92b3-4e3a-9729-5b1b50e8f442\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.99, 0.5],\n      \"targetRectRate\": [0.01, 0.5]\n    },\n    {\n      \"source\": \"a791f550-b886-4f60-9992-621536a61227\",\n      \"target\": \"3e80b28f-3b64-4a12-8cc9-7cc1ec73887f\",\n      \"text\": \"\",\n      \"uuid\": \"63fc2a0e-55f4-41e8-b856-2a99d47fac14\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.01, 0.5],\n      \"targetRectRate\": [0.99, 0.5]\n    },\n    {\n      \"source\": \"a791f550-b886-4f60-9992-621536a61227\",\n      \"target\": \"29d1a804-3758-4648-86cc-d2be723dc3af\",\n      \"text\": \"\",\n      \"uuid\": \"af31b269-5610-4d71-aa4d-8280aef8b5e5\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.01, 0.5],\n      \"targetRectRate\": [0.99, 0.5]\n    },\n    {\n      \"source\": \"a791f550-b886-4f60-9992-621536a61227\",\n      \"target\": \"5c7abad1-3249-46b5-bc37-e3b6a64c5978\",\n      \"text\": \"\",\n      \"uuid\": \"836b23ca-81d2-45cc-8924-1f3575f404d1\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.99, 0.5],\n      \"targetRectRate\": [0.01, 0.5]\n    },\n    {\n      \"source\": \"2587f315-433b-4fe4-836d-f5f8a33570bd\",\n      \"target\": \"d19d0fd6-a693-46d2-ba6a-0b450ee06f59\",\n      \"text\": \"\",\n      \"uuid\": \"fbcb40be-f6bc-4732-a95b-bcfff59452d6\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.99, 0.5],\n      \"targetRectRate\": [0.01, 0.5]\n    },\n    {\n      \"source\": \"2587f315-433b-4fe4-836d-f5f8a33570bd\",\n      \"target\": \"c29a3f55-d0c1-4464-bbb6-38cf28167eb1\",\n      \"text\": \"\",\n      \"uuid\": \"071c0b32-cdd5-4778-a321-5c48aed90e30\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.99, 0.5],\n      \"targetRectRate\": [0.01, 0.5]\n    },\n    {\n      \"source\": \"2587f315-433b-4fe4-836d-f5f8a33570bd\",\n      \"target\": \"c2b871f8-ca83-43ff-add1-7ec588b9d179\",\n      \"text\": \"\",\n      \"uuid\": \"238d81eb-e7ec-4632-b422-959f0ad597c4\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.99, 0.5],\n      \"targetRectRate\": [0.01, 0.5]\n    },\n    {\n      \"source\": \"a791f550-b886-4f60-9992-621536a61227\",\n      \"target\": \"2587f315-433b-4fe4-836d-f5f8a33570bd\",\n      \"text\": \"\",\n      \"uuid\": \"fe6c1255-e557-48e7-811a-f2d85d932b9f\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.99, 0.5],\n      \"targetRectRate\": [0.01, 0.5]\n    },\n    {\n      \"source\": \"05d3ec5c-0f55-45f1-a308-fa1c3fc0e1b6\",\n      \"target\": \"e5e3d3fd-fa22-4ac7-ba0f-ec83875df58c\",\n      \"text\": \"\",\n      \"uuid\": \"54c689cc-d7c0-4124-a931-753ebba1a00f\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.99, 0.5],\n      \"targetRectRate\": [0.01, 0.5]\n    },\n    {\n      \"source\": \"05d3ec5c-0f55-45f1-a308-fa1c3fc0e1b6\",\n      \"target\": \"198a47ca-507c-4811-8a39-ac03dbd514bd\",\n      \"text\": \"\",\n      \"uuid\": \"9e55591f-e0b9-426d-a98d-f60fec200acf\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.99, 0.5],\n      \"targetRectRate\": [0.01, 0.5]\n    },\n    {\n      \"source\": \"a791f550-b886-4f60-9992-621536a61227\",\n      \"target\": \"05d3ec5c-0f55-45f1-a308-fa1c3fc0e1b6\",\n      \"text\": \"\",\n      \"uuid\": \"18822b26-afad-4bec-9d04-232a73a8d120\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.99, 0.5],\n      \"targetRectRate\": [0.01, 0.5]\n    },\n    {\n      \"source\": \"05d3ec5c-0f55-45f1-a308-fa1c3fc0e1b6\",\n      \"target\": \"e2b352cf-26c8-4e0d-9263-525432e97835\",\n      \"text\": \"\",\n      \"uuid\": \"43484207-aebf-4d2d-b59c-828d227cea4d\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.99, 0.5],\n      \"targetRectRate\": [0.01, 0.5]\n    },\n    {\n      \"source\": \"a791f550-b886-4f60-9992-621536a61227\",\n      \"target\": \"84f77e1d-d557-4a34-91ac-101064838c53\",\n      \"text\": \"\",\n      \"uuid\": \"959360b5-63ae-48cd-9980-5b1af1919887\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.01, 0.5],\n      \"targetRectRate\": [0.99, 0.5]\n    },\n    {\n      \"source\": \"e6c6836d-a05e-489a-9c1b-36e95fd291fc\",\n      \"target\": \"65e464eb-54bd-4cbb-984e-ce25e63cf290\",\n      \"text\": \"\",\n      \"uuid\": \"6853e819-b31f-4021-8269-31823525712e\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    }\n  ],\n  \"tags\": []\n}\n"
  },
  {
    "path": "docs-pg/ProjectGraph项目架构.json",
    "content": "{\n  \"version\": 17,\n  \"entities\": [\n    {\n      \"location\": [-3145, 13],\n      \"size\": [249.3759765625, 76],\n      \"text\": \"序列化json数据\",\n      \"uuid\": \"5eb6ac6f-9843-4dbe-bd28-f2647179bb5e\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-3094, -212],\n      \"size\": [407.263916015625, 76],\n      \"text\": \"各种StageObject类的对象\",\n      \"uuid\": \"d3893b79-32aa-4be3-9ef4-2fb5cd8f0b95\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1152, -335],\n      \"size\": [201.98397827148438, 76],\n      \"text\": \"DOM渲染器\",\n      \"uuid\": \"f566f755-707d-414a-a0d7-6ac83778b620\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [807, 148],\n      \"size\": [274.07994079589844, 76],\n      \"text\": \"Canvas2D渲染器\",\n      \"uuid\": \"43a140c9-f720-406e-814a-c4cd86928837\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1605, 331],\n      \"size\": [178.97598266601562, 76],\n      \"text\": \"Svg生成器\",\n      \"uuid\": \"c52beb93-3cfe-419f-8892-b0830094f1f5\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-196, -1976],\n      \"size\": [220, 76],\n      \"text\": \"自动计算引擎\",\n      \"uuid\": \"ce5a3281-5490-4dad-8764-1d6f8b1f7685\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [777, -1571],\n      \"size\": [156, 76],\n      \"text\": \"特效引擎\",\n      \"uuid\": \"c051dd44-9769-4947-a7f9-1b449996b9d8\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1926, -1717],\n      \"size\": [220, 76],\n      \"text\": \"自动布局引擎\",\n      \"uuid\": \"ab36d50a-91b4-4c13-8a20-ec2dd446659d\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1926, -1594],\n      \"size\": [252, 76],\n      \"text\": \"纯键盘操作引擎\",\n      \"uuid\": \"1bd4eedd-8e53-4b28-9ee9-07740cfecd88\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [921, -1443],\n      \"size\": [156, 76],\n      \"text\": \"音效引擎\",\n      \"uuid\": \"466cddac-4089-47b9-bf1a-d766a629b46c\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [777, -1306],\n      \"size\": [252, 76],\n      \"text\": \"舞台样式控制器\",\n      \"uuid\": \"ec046b65-8012-4941-879c-5e245a5507cd\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [204, -1291],\n      \"size\": [252, 76],\n      \"text\": \"节点颜色管理器\",\n      \"uuid\": \"2bd8a76a-e6c4-4c78-8d37-5a7bf8dfe473\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1403, -1717],\n      \"size\": [316, 76],\n      \"text\": \"快捷键注册监听系统\",\n      \"uuid\": \"8cc30dad-e619-4c9f-b9cf-331fd7da3ef1\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [523, -1805],\n      \"size\": [316, 76],\n      \"text\": \"最近打开文件管理器\",\n      \"uuid\": \"c5eac5da-2ee8-41ba-a172-dda47af284a8\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [281, -309],\n      \"size\": [200.86392211914062, 124],\n      \"text\": \"设置管理器\\nSettings.tsx\",\n      \"uuid\": \"09197827-0908-451a-ac0c-844b51a7f60c\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [521, -1918],\n      \"size\": [348, 76],\n      \"text\": \"初始化启动文件管理器\",\n      \"uuid\": \"fc8944ff-3bfa-4710-b772-aa64a9d6e479\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [71, -1976],\n      \"size\": [121.98399353027344, 76],\n      \"text\": \"AI服务\",\n      \"uuid\": \"340d73a5-d7ee-45bb-a044-6afdc6c81396\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-983, -2451],\n      \"size\": [316, 76],\n      \"text\": \"上一次启动记录服务\",\n      \"uuid\": \"54011656-0b7e-4ab0-b323-6def3c64c09f\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [872, -2451],\n      \"size\": [141.2799835205078, 76],\n      \"text\": \"CLI系统\",\n      \"uuid\": \"42e7ab69-54cf-4562-b765-2e0e9d931668\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1896, -1217],\n      \"size\": [156, 76],\n      \"text\": \"连线模块\",\n      \"uuid\": \"89c36263-3122-4db4-9411-65ef8e0a038c\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1694, -1217],\n      \"size\": [156, 76],\n      \"text\": \"框选模块\",\n      \"uuid\": \"a2f10c4b-fca7-460f-99b4-cf5fe140b3e1\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-196, -1860],\n      \"size\": [348, 76],\n      \"text\": \"根据文本生成节点系统\",\n      \"uuid\": \"92665a81-d1b9-4f8e-a23a-5a9b9f8f5332\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-179, -1752],\n      \"size\": [313.4719543457031, 76],\n      \"text\": \"导出markdown系统\",\n      \"uuid\": \"c1e1a39d-c18b-4674-8762-65967391a152\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1417, 1257],\n      \"size\": [220, 76],\n      \"text\": \"数组扩展算法\",\n      \"uuid\": \"d7355bd2-e913-4ef0-b38d-31bda424fd40\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1417, 1378],\n      \"size\": [220, 76],\n      \"text\": \"实数扩展算法\",\n      \"uuid\": \"8870ab29-90c5-4e93-8aea-c1c36eff9ada\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [688, 1826],\n      \"size\": [156, 76],\n      \"text\": \"二维向量\",\n      \"uuid\": \"693d24b2-8a1f-4c79-9679-a9b26d3a8247\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1690, 1378],\n      \"size\": [252, 76],\n      \"text\": \"数值积分的算法\",\n      \"uuid\": \"853621fe-f0f5-42dd-b0b1-d8d957bd0beb\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1754, 1257],\n      \"size\": [124, 76],\n      \"text\": \"概率论\",\n      \"uuid\": \"335409a6-93fb-41be-83be-7511ecd5effa\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [466, 1826],\n      \"size\": [92, 76],\n      \"text\": \"图形\",\n      \"uuid\": \"64f933fd-04a4-4708-a355-c89d85f76e15\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1055, 1285],\n      \"size\": [220, 76],\n      \"text\": \"图形碰撞检测\",\n      \"uuid\": \"a1940efe-d067-4659-a226-e3b8ba690a25\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [472, 1958],\n      \"size\": [220, 76],\n      \"text\": \"缓存数据结构\",\n      \"uuid\": \"679b8c53-667d-49f4-a94e-4e453890951a\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [746, 1958],\n      \"size\": [124, 76],\n      \"text\": \"单调栈\",\n      \"uuid\": \"ac5f2fa2-08bb-4ccd-8470-4dd166382fa3\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [898, 1958],\n      \"size\": [124, 76],\n      \"text\": \"颜色类\",\n      \"uuid\": \"f1fd52b4-ce5a-4b09-9015-32ca0f5b00b5\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [898, 1826],\n      \"size\": [156, 76],\n      \"text\": \"进度条类\",\n      \"uuid\": \"b410476e-a215-4363-ba69-6ed3803bc9aa\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [898, 1720],\n      \"size\": [189.34397888183594, 76],\n      \"text\": \"uuid字典类\",\n      \"uuid\": \"2f6b326c-2d84-4c9a-a412-583fb30d3b50\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-415, -2939],\n      \"size\": [252, 124],\n      \"text\": \"插件系统\\n更多的业务服务\",\n      \"uuid\": \"e2d6a89e-bc9f-41d4-af8f-4d3e825244ef\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [132, -1422],\n      \"size\": [220, 76],\n      \"text\": \"节点搜索功能\",\n      \"uuid\": \"30322678-71f1-4656-8e1e-c026dc81cc1a\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [330, -1918],\n      \"size\": [156, 76],\n      \"text\": \"自动保存\",\n      \"uuid\": \"ccf19be1-65ff-4de2-a314-658dd03ef7ce\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [330, -1805],\n      \"size\": [156, 76],\n      \"text\": \"自动备份\",\n      \"uuid\": \"09f91fbd-d9ec-4f2f-9757-5d66f38f9d99\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1403, -1217],\n      \"size\": [220, 76],\n      \"text\": \"复制粘贴功能\",\n      \"uuid\": \"7eb277ce-244e-4159-be0c-e33308129afa\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1106, -1326],\n      \"size\": [220, 76],\n      \"text\": \"拖拽文件控制\",\n      \"uuid\": \"48906681-e4f0-473e-8044-648b2578295f\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1929, 484],\n      \"size\": [219.1039276123047, 76],\n      \"text\": \"StageLoader\",\n      \"uuid\": \"bf28c283-84e6-4b15-80b9-d84ede8e120c\",\n      \"details\": \"读取json文件\\n并一级一级转换版本\\nV1->V2->....->V12\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1929, 327],\n      \"size\": [236.4479217529297, 76],\n      \"text\": \"StageDumper\",\n      \"uuid\": \"586c1c04-aa80-4fed-8d5d-0a9e1e403d08\",\n      \"details\": \"将各种StageObject转换成json格式\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1563, 469],\n      \"size\": [300.31988525390625, 76],\n      \"text\": \"StageDumperSVG\",\n      \"uuid\": \"260d6b15-eeb4-41e8-9672-3cf1a9086d01\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-3175, -292],\n      \"size\": [518.263916015625, 411],\n      \"uuid\": \"573569cc-7ce5-421a-af67-e42e7ac0970b\",\n      \"text\": \"概念关系\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\"5eb6ac6f-9843-4dbe-bd28-f2647179bb5e\", \"d3893b79-32aa-4be3-9ef4-2fb5cd8f0b95\"],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [777, -415],\n      \"size\": [1036.9759826660156, 852],\n      \"uuid\": \"109fa2e2-bdcc-420e-9cd4-f465811fb2c6\",\n      \"text\": \"渲染器\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\n        \"f566f755-707d-414a-a0d7-6ac83778b620\",\n        \"43a140c9-f720-406e-814a-c4cd86928837\",\n        \"c52beb93-3cfe-419f-8892-b0830094f1f5\"\n      ],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [-1986, -2627],\n      \"size\": [3123, 1576],\n      \"uuid\": \"7b113823-e220-420c-95e8-1e91192e7994\",\n      \"text\": \"Service层\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\n        \"2253d9ff-3e55-4284-8fb2-9c869778cd0f\",\n        \"7f2856f0-0491-4300-aec2-64bdedb4c4e4\",\n        \"3357834e-8e17-4bbe-86dc-812c40bcf9ca\",\n        \"ee390b32-2139-4a0f-b670-e35edd32e974\",\n        \"aa153f8c-b9ac-4c2b-a933-2459042bda90\",\n        \"c66c85de-0b69-43e3-8666-6cb1e58773b7\",\n        \"144572f0-7355-4583-8893-3588a38ab4c9\",\n        \"42e7ab69-54cf-4562-b765-2e0e9d931668\",\n        \"15fb2043-ea83-4e08-8858-87538b2a6a42\"\n      ],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [747, -1651],\n      \"size\": [360, 451],\n      \"uuid\": \"aa153f8c-b9ac-4c2b-a933-2459042bda90\",\n      \"text\": \"感官反馈 Feedback\",\n      \"color\": [236, 198, 60, 1],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\n        \"c051dd44-9769-4947-a7f9-1b449996b9d8\",\n        \"466cddac-4089-47b9-bf1a-d766a629b46c\",\n        \"ec046b65-8012-4941-879c-5e245a5507cd\"\n      ],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [-226, -2056],\n      \"size\": [448.98399353027344, 410],\n      \"uuid\": \"7f2856f0-0491-4300-aec2-64bdedb4c4e4\",\n      \"text\": \"信息生成 DataGenerate\",\n      \"color\": [92, 47, 96, 1],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\n        \"ce5a3281-5490-4dad-8764-1d6f8b1f7685\",\n        \"340d73a5-d7ee-45bb-a044-6afdc6c81396\",\n        \"92665a81-d1b9-4f8e-a23a-5a9b9f8f5332\",\n        \"c1e1a39d-c18b-4674-8762-65967391a152\"\n      ],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [-1956, -1998],\n      \"size\": [1626, 917],\n      \"uuid\": \"c66c85de-0b69-43e3-8666-6cb1e58773b7\",\n      \"text\": \"交互控制类服务\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\n        \"6b16b339-69e3-4489-a282-f628194d83f7\",\n        \"57299029-e5a5-460b-9588-12b3d63962c9\",\n        \"33504ab5-424f-42a7-b036-e1781bb4bbdf\",\n        \"ab36d50a-91b4-4c13-8a20-ec2dd446659d\",\n        \"1bd4eedd-8e53-4b28-9ee9-07740cfecd88\",\n        \"945caf04-a716-44e8-9d96-592c06020f9d\",\n        \"8cc30dad-e619-4c9f-b9cf-331fd7da3ef1\",\n        \"a6219774-81f5-471b-8659-1d9e0b15d1a6\"\n      ],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [1025, 1177],\n      \"size\": [947, 307],\n      \"uuid\": \"28b571f8-7941-4eb5-bca0-0ea0d68e2630\",\n      \"text\": \"通用算法\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\n        \"d7355bd2-e913-4ef0-b38d-31bda424fd40\",\n        \"8870ab29-90c5-4e93-8aea-c1c36eff9ada\",\n        \"853621fe-f0f5-42dd-b0b1-d8d957bd0beb\",\n        \"335409a6-93fb-41be-83be-7511ecd5effa\",\n        \"a1940efe-d067-4659-a226-e3b8ba690a25\"\n      ],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [436, 1640],\n      \"size\": [681.3439788818359, 424],\n      \"uuid\": \"43be170a-872f-490c-b9ac-a2b97630e421\",\n      \"text\": \"数据结构\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\n        \"693d24b2-8a1f-4c79-9679-a9b26d3a8247\",\n        \"64f933fd-04a4-4708-a355-c89d85f76e15\",\n        \"679b8c53-667d-49f4-a94e-4e453890951a\",\n        \"ac5f2fa2-08bb-4ccd-8470-4dd166382fa3\",\n        \"f1fd52b4-ce5a-4b09-9015-32ca0f5b00b5\",\n        \"b410476e-a215-4363-ba69-6ed3803bc9aa\",\n        \"2f6b326c-2d84-4c9a-a412-583fb30d3b50\"\n      ],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [300, -1998],\n      \"size\": [599, 299],\n      \"uuid\": \"2253d9ff-3e55-4284-8fb2-9c869778cd0f\",\n      \"text\": \"文件管理 DataFile\",\n      \"color\": [73, 159, 202, 1],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\n        \"c5eac5da-2ee8-41ba-a172-dda47af284a8\",\n        \"fc8944ff-3bfa-4710-b772-aa64a9d6e479\",\n        \"ccf19be1-65ff-4de2-a314-658dd03ef7ce\",\n        \"09f91fbd-d9ec-4f2f-9757-5d66f38f9d99\"\n      ],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [102, -1502],\n      \"size\": [384, 317],\n      \"uuid\": \"3357834e-8e17-4bbe-86dc-812c40bcf9ca\",\n      \"text\": \"信息管理 DataManage\",\n      \"color\": [73, 159, 202, 1],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\"2bd8a76a-e6c4-4c78-8d37-5a7bf8dfe473\", \"30322678-71f1-4656-8e1e-c026dc81cc1a\"],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [-1926, -1805],\n      \"size\": [156, 76],\n      \"text\": \"吸附对齐\",\n      \"uuid\": \"945caf04-a716-44e8-9d96-592c06020f9d\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1013, -2531],\n      \"size\": [376, 186],\n      \"uuid\": \"ee390b32-2139-4a0f-b670-e35edd32e974\",\n      \"text\": \"其他未分类的服务\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\"54011656-0b7e-4ab0-b323-6def3c64c09f\"],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [-1926, -2547],\n      \"size\": [274.8159637451172, 172],\n      \"text\": \"注：\\n通常各种服务\\n会绑定到Stage上\",\n      \"uuid\": \"15fb2043-ea83-4e08-8858-87538b2a6a42\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-984, 1354],\n      \"size\": [215.26393127441406, 124],\n      \"text\": \"舞台对象\\nStageObject\",\n      \"uuid\": \"13179626-e481-4300-a546-c3dafbe2d149\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1915, 1097],\n      \"size\": [1890, 1464],\n      \"uuid\": \"bb087df2-7762-40bd-aa18-d574e5c1fa05\",\n      \"text\": \"舞台基本元素 StageObject\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\n        \"13179626-e481-4300-a546-c3dafbe2d149\",\n        \"25f5cf59-858c-4b4b-8181-7501c203f2cd\",\n        \"6faaa091-fd40-4d6b-9a93-2b3ffac24f35\",\n        \"264981a2-8caf-4327-ad1e-bf46904a0326\",\n        \"50d7e98c-876b-479b-854f-55cd93cad7c9\",\n        \"92f1a49a-483c-4077-b9fa-5e602b8c277e\",\n        \"91e5819b-6901-497b-b268-a7ff0830db90\",\n        \"ba0e5b95-72ad-4148-b661-eddefc9c6221\",\n        \"172bc90e-326d-45e5-b69a-f530b979f67a\",\n        \"7213f25b-f0e9-4948-9aa3-d5eeb1f3ecf8\",\n        \"f985a884-08da-4002-8a75-b725cc7d0aab\",\n        \"a6b713e7-f15b-42d5-a665-3e73b67b8e02\",\n        \"a8a93d4e-39e9-4805-86a6-edaff279cda3\",\n        \"b7cf0cbf-0be7-43ee-8433-271ce2a3df7f\",\n        \"a06176cf-d520-40d8-bd95-7b8884e5c3e2\",\n        \"7314a8ac-b5a7-4626-8c46-e04847fc57ff\",\n        \"c39b6cec-768d-48e6-bad2-576c0c025197\",\n        \"76ceb2b4-68a4-4d6f-9fa6-7b9ef1199044\",\n        \"0b35df90-e38b-4123-bab4-0672332c1ec8\"\n      ],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [-1535, 1545],\n      \"size\": [348, 172],\n      \"text\": \"“关系”阵营\\nAssociation\\n必须依靠其他内容存在\",\n      \"uuid\": \"25f5cf59-858c-4b4b-8181-7501c203f2cd\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-459, 1545],\n      \"size\": [220, 172],\n      \"text\": \"“实体”阵营\\nEntity\\n能够独立存在\",\n      \"uuid\": \"6faaa091-fd40-4d6b-9a93-2b3ffac24f35\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-687, 1849],\n      \"size\": [304.70391845703125, 124],\n      \"text\": \"可连接实体\\nConnectableEntity\",\n      \"uuid\": \"264981a2-8caf-4327-ad1e-bf46904a0326\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-147, 1849],\n      \"size\": [92, 76],\n      \"text\": \"涂鸦\",\n      \"uuid\": \"50d7e98c-876b-479b-854f-55cd93cad7c9\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [406, 1097],\n      \"size\": [1596, 997],\n      \"uuid\": \"2a26a90c-29cd-45ee-8fbf-f13dc58e4c54\",\n      \"text\": \"通用数据结构与算法\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\"28b571f8-7941-4eb5-bca0-0ea0d68e2630\", \"43be170a-872f-490c-b9ac-a2b97630e421\"],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [-829, 2318],\n      \"size\": [60, 76],\n      \"text\": \"框\",\n      \"uuid\": \"92f1a49a-483c-4077-b9fa-5e602b8c277e\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-763, 2318],\n      \"size\": [156, 76],\n      \"text\": \"文本节点\",\n      \"uuid\": \"91e5819b-6901-497b-b268-a7ff0830db90\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-597, 2318],\n      \"size\": [124, 76],\n      \"text\": \"传送门\",\n      \"uuid\": \"ba0e5b95-72ad-4148-b661-eddefc9c6221\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1359, 1849],\n      \"size\": [220, 76],\n      \"text\": \"图论连接关系\",\n      \"uuid\": \"172bc90e-326d-45e5-b69a-f530b979f67a\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1149, 2226],\n      \"size\": [124, 76],\n      \"text\": \"有向边\",\n      \"uuid\": \"7213f25b-f0e9-4948-9aa3-d5eeb1f3ecf8\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1467, 2226],\n      \"size\": [156, 76],\n      \"text\": \"全联通边\",\n      \"uuid\": \"f985a884-08da-4002-8a75-b725cc7d0aab\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1057, 2455],\n      \"size\": [188, 76],\n      \"text\": \"直线有向边\",\n      \"uuid\": \"a6b713e7-f15b-42d5-a665-3e73b67b8e02\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1359, 2455],\n      \"size\": [231.4239959716797, 76],\n      \"text\": \"CR曲线有向边\",\n      \"uuid\": \"a8a93d4e-39e9-4805-86a6-edaff279cda3\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1057, 1177],\n      \"size\": [124, 76],\n      \"text\": \"继承图\",\n      \"uuid\": \"b7cf0cbf-0be7-43ee-8433-271ce2a3df7f\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-459, 2318],\n      \"size\": [92, 76],\n      \"text\": \"质点\",\n      \"uuid\": \"a06176cf-d520-40d8-bd95-7b8884e5c3e2\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-355, 2318],\n      \"size\": [156, 76],\n      \"text\": \"链接节点\",\n      \"uuid\": \"7314a8ac-b5a7-4626-8c46-e04847fc57ff\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1885, 1355],\n      \"size\": [284, 124],\n      \"text\": \"碰撞箱\\n本质是图形的集合\",\n      \"uuid\": \"c39b6cec-768d-48e6-bad2-576c0c025197\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1782, 1849],\n      \"size\": [188, 76],\n      \"text\": \"关系的关系\",\n      \"uuid\": \"76ceb2b4-68a4-4d6f-9fa6-7b9ef1199044\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1830, 2226],\n      \"size\": [284, 76],\n      \"text\": \"有向的关系的关系\",\n      \"uuid\": \"0b35df90-e38b-4123-bab4-0672332c1ec8\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-371, 645],\n      \"size\": [188, 76],\n      \"text\": \"集合论算法\",\n      \"uuid\": \"42328117-8a34-4feb-956a-936eecbc8246\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-221, 450],\n      \"size\": [156, 76],\n      \"text\": \"图论算法\",\n      \"uuid\": \"e41866cf-0713-4475-b646-1631069824d2\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-401, 370],\n      \"size\": [366, 381],\n      \"uuid\": \"d6d3ce04-b3ae-486f-a60f-b6d3f312eab6\",\n      \"text\": \"基础算法类模块\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\"42328117-8a34-4feb-956a-936eecbc8246\", \"e41866cf-0713-4475-b646-1631069824d2\"],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [-1959, -575],\n      \"size\": [1954, 1356],\n      \"uuid\": \"aaff9a9f-5f96-4cf5-a13d-793b2b5f34fb\",\n      \"text\": \"舞台管理器 StageManager\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\n        \"d6d3ce04-b3ae-486f-a60f-b6d3f312eab6\",\n        \"70bb3616-9bf3-475f-be5f-e86207569690\",\n        \"d69f9781-4694-419d-bdb4-d693c1ccf43b\",\n        \"bf28c283-84e6-4b15-80b9-d84ede8e120c\",\n        \"586c1c04-aa80-4fed-8d5d-0a9e1e403d08\",\n        \"05bc2592-ddc9-4fbe-a43e-ec724fa37b57\"\n      ],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [-1121.1171579743009, 645],\n      \"size\": [220, 76],\n      \"text\": \"舞台内容容器\",\n      \"uuid\": \"3547931c-b425-4e94-b103-aecead33d619\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1929, 107],\n      \"size\": [284, 76],\n      \"text\": \"舞台历史记录管理\",\n      \"uuid\": \"70bb3616-9bf3-475f-be5f-e86207569690\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1745, -415],\n      \"size\": [220, 76],\n      \"text\": \"内容删除模块\",\n      \"uuid\": \"968eb1c4-6f99-4578-8a8d-5cd94e669847\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1409, -415],\n      \"size\": [220, 76],\n      \"text\": \"节点旋转模块\",\n      \"uuid\": \"91023d59-245f-4bb6-a291-e0dbcd0df8ef\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1073, -415],\n      \"size\": [220, 76],\n      \"text\": \"自动对齐模块\",\n      \"uuid\": \"1084df08-bb14-4f02-9c23-c2e41412b55d\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-705, -415],\n      \"size\": [156, 76],\n      \"text\": \"移动模块\",\n      \"uuid\": \"bed33e5e-1ea1-4c9e-bc60-74077e1b658c\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1761, -257],\n      \"size\": [252, 76],\n      \"text\": \"跳入跳出框模块\",\n      \"uuid\": \"70ec4e1d-afe1-490d-8eb0-8f36968dc2c6\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1377, -257],\n      \"size\": [156, 76],\n      \"text\": \"连线模块\",\n      \"uuid\": \"66d14b47-be59-4a3e-9bf1-2250c97843fa\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1057, -257],\n      \"size\": [188, 76],\n      \"text\": \"打包框模块\",\n      \"uuid\": \"84afceea-3207-49a5-8048-3f207bd57e69\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-737, -257],\n      \"size\": [220, 76],\n      \"text\": \"标签管理模块\",\n      \"uuid\": \"423a2cd0-0f45-4e3f-81e8-e386a2384348\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1779, -105],\n      \"size\": [316, 76],\n      \"text\": \"通过序列化增加内容\",\n      \"uuid\": \"9c9aeb75-4c03-4672-99f4-a2729cb4bc84\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1378, -105],\n      \"size\": [156, 76],\n      \"text\": \"颜色管理\",\n      \"uuid\": \"54ad87f7-b55a-4c0e-9694-f47c71fe3a8e\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1809, -495],\n      \"size\": [1322, 496],\n      \"uuid\": \"d69f9781-4694-419d-bdb4-d693c1ccf43b\",\n      \"text\": \"具体舞台内容管理模块\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\n        \"968eb1c4-6f99-4578-8a8d-5cd94e669847\",\n        \"91023d59-245f-4bb6-a291-e0dbcd0df8ef\",\n        \"1084df08-bb14-4f02-9c23-c2e41412b55d\",\n        \"bed33e5e-1ea1-4c9e-bc60-74077e1b658c\",\n        \"70ec4e1d-afe1-490d-8eb0-8f36968dc2c6\",\n        \"66d14b47-be59-4a3e-9bf1-2250c97843fa\",\n        \"84afceea-3207-49a5-8048-3f207bd57e69\",\n        \"423a2cd0-0f45-4e3f-81e8-e386a2384348\",\n        \"9c9aeb75-4c03-4672-99f4-a2729cb4bc84\",\n        \"54ad87f7-b55a-4c0e-9694-f47c71fe3a8e\"\n      ],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [-441, -2476],\n      \"size\": [220, 76],\n      \"text\": \"帮助教程服务\",\n      \"uuid\": \"144572f0-7355-4583-8893-3588a38ab4c9\",\n      \"details\": \"\",\n      \"color\": [193, 240, 168, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1503, -1918],\n      \"size\": [284, 124],\n      \"text\": \"MouseLocation\\n全局检测鼠标位置\",\n      \"uuid\": \"6b16b339-69e3-4489-a282-f628194d83f7\",\n      \"details\": \"\",\n      \"color\": [168, 216, 240, 1],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1281.1171579743009, 645],\n      \"size\": [124, 76],\n      \"text\": \"摄像机\",\n      \"uuid\": \"2bcb54f6-d25d-47e2-9bd7-82bf26299713\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1213.1171579743009, 217],\n      \"size\": [114.81596374511719, 124],\n      \"text\": \"舞台\\nStage\",\n      \"uuid\": \"2280fcad-b69f-4fbd-aacf-39e3d1e8adf8\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1457, 137],\n      \"size\": [585.8828420256991, 614],\n      \"uuid\": \"05bc2592-ddc9-4fbe-a43e-ec724fa37b57\",\n      \"text\": \"舞台基础\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\n        \"3547931c-b425-4e94-b103-aecead33d619\",\n        \"2bcb54f6-d25d-47e2-9bd7-82bf26299713\",\n        \"2280fcad-b69f-4fbd-aacf-39e3d1e8adf8\",\n        \"c1c4a086-cd6e-4c0e-a436-a1bef678473c\"\n      ],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [-1159, 460],\n      \"uuid\": \"0a36e598-7e3f-4180-977e-94791ebbd1d7\",\n      \"type\": \"core:connect_point\",\n      \"details\": \"\"\n    },\n    {\n      \"location\": [-1427, 645],\n      \"size\": [124, 76],\n      \"text\": \"框选框\",\n      \"uuid\": \"c1c4a086-cd6e-4c0e-a436-a1bef678473c\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-612, -1241],\n      \"size\": [252, 124],\n      \"text\": \"秘籍键模块\\n存放实验性功能\",\n      \"uuid\": \"57299029-e5a5-460b-9588-12b3d63962c9\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1047, -1594],\n      \"size\": [284, 76],\n      \"text\": \"鼠标悬浮交互模块\",\n      \"uuid\": \"33504ab5-424f-42a7-b036-e1781bb4bbdf\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-1926, -1406],\n      \"size\": [1070, 295],\n      \"uuid\": \"a6219774-81f5-471b-8659-1d9e0b15d1a6\",\n      \"text\": \"控制器Controller\",\n      \"color\": [59, 130, 246, 1],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\n        \"89c36263-3122-4db4-9411-65ef8e0a038c\",\n        \"a2f10c4b-fca7-460f-99b4-cf5fe140b3e1\",\n        \"7eb277ce-244e-4159-be0c-e33308129afa\",\n        \"48906681-e4f0-473e-8044-648b2578295f\"\n      ],\n      \"details\": \"\"\n    }\n  ],\n  \"associations\": [\n    {\n      \"source\": \"5eb6ac6f-9843-4dbe-bd28-f2647179bb5e\",\n      \"target\": \"d3893b79-32aa-4be3-9ef4-2fb5cd8f0b95\",\n      \"text\": \"StageLoader\",\n      \"uuid\": \"9c284bb4-7257-47ec-8c53-9e66e81b14dd\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"d3893b79-32aa-4be3-9ef4-2fb5cd8f0b95\",\n      \"target\": \"5eb6ac6f-9843-4dbe-bd28-f2647179bb5e\",\n      \"text\": \"StageDumper\",\n      \"uuid\": \"a830a392-2f60-40d0-91de-769dba26cebd\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"09197827-0908-451a-ac0c-844b51a7f60c\",\n      \"target\": \"7b113823-e220-420c-95e8-1e91192e7994\",\n      \"text\": \"控制各种服务的开关和配置\",\n      \"uuid\": \"53368b70-1695-4dd4-812f-c01c409c83f6\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"109fa2e2-bdcc-420e-9cd4-f465811fb2c6\",\n      \"target\": \"09197827-0908-451a-ac0c-844b51a7f60c\",\n      \"text\": \"\",\n      \"uuid\": \"498bb262-861e-4489-beb5-1ff77c9b81b5\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"109fa2e2-bdcc-420e-9cd4-f465811fb2c6\",\n      \"target\": \"7b113823-e220-420c-95e8-1e91192e7994\",\n      \"text\": \"\",\n      \"uuid\": \"31e1e15d-e706-4c2c-89d4-b3de82f2be08\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"64f933fd-04a4-4708-a355-c89d85f76e15\",\n      \"target\": \"a1940efe-d067-4659-a226-e3b8ba690a25\",\n      \"text\": \"\",\n      \"uuid\": \"4ad44e02-954b-408d-9c23-a529b1debc87\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"693d24b2-8a1f-4c79-9679-a9b26d3a8247\",\n      \"target\": \"64f933fd-04a4-4708-a355-c89d85f76e15\",\n      \"text\": \"\",\n      \"uuid\": \"a9903d71-7c9b-4dd1-89c8-74368ca57d8a\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"7b113823-e220-420c-95e8-1e91192e7994\",\n      \"target\": \"e2d6a89e-bc9f-41d4-af8f-4d3e825244ef\",\n      \"text\": \"\",\n      \"uuid\": \"65cf455c-2ed2-42d7-af8d-a16c757a0727\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"3357834e-8e17-4bbe-86dc-812c40bcf9ca\",\n      \"target\": \"7f2856f0-0491-4300-aec2-64bdedb4c4e4\",\n      \"text\": \"\",\n      \"uuid\": \"7c813b81-ac56-4ed6-9b9d-1be177307a8a\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"3357834e-8e17-4bbe-86dc-812c40bcf9ca\",\n      \"target\": \"2253d9ff-3e55-4284-8fb2-9c869778cd0f\",\n      \"text\": \"\",\n      \"uuid\": \"5414c647-9f0f-4c6b-b366-bff2e66295ae\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"25f5cf59-858c-4b4b-8181-7501c203f2cd\",\n      \"target\": \"13179626-e481-4300-a546-c3dafbe2d149\",\n      \"text\": \"\",\n      \"uuid\": \"47fcbac4-6009-4875-82a7-2ddea129f34c\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"6faaa091-fd40-4d6b-9a93-2b3ffac24f35\",\n      \"target\": \"13179626-e481-4300-a546-c3dafbe2d149\",\n      \"text\": \"\",\n      \"uuid\": \"b8cb014e-6785-483f-b26c-9a10f2d5ba37\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"264981a2-8caf-4327-ad1e-bf46904a0326\",\n      \"target\": \"6faaa091-fd40-4d6b-9a93-2b3ffac24f35\",\n      \"text\": \"\",\n      \"uuid\": \"93d6150d-fd80-45ed-90e9-bac286483368\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"50d7e98c-876b-479b-854f-55cd93cad7c9\",\n      \"target\": \"6faaa091-fd40-4d6b-9a93-2b3ffac24f35\",\n      \"text\": \"\",\n      \"uuid\": \"172177d5-4355-4e9a-8c21-3e6aee397f5b\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"92f1a49a-483c-4077-b9fa-5e602b8c277e\",\n      \"target\": \"264981a2-8caf-4327-ad1e-bf46904a0326\",\n      \"text\": \"\",\n      \"uuid\": \"e8e147a0-4a2b-4dd8-9cc4-1ce20e82ce9d\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"91e5819b-6901-497b-b268-a7ff0830db90\",\n      \"target\": \"264981a2-8caf-4327-ad1e-bf46904a0326\",\n      \"text\": \"\",\n      \"uuid\": \"05ea35f7-b19b-488e-8bda-1f79ee3db422\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"ba0e5b95-72ad-4148-b661-eddefc9c6221\",\n      \"target\": \"264981a2-8caf-4327-ad1e-bf46904a0326\",\n      \"text\": \"\",\n      \"uuid\": \"bbd20d99-3ca0-4b60-bbc9-7c8c1310276b\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"172bc90e-326d-45e5-b69a-f530b979f67a\",\n      \"target\": \"25f5cf59-858c-4b4b-8181-7501c203f2cd\",\n      \"text\": \"\",\n      \"uuid\": \"a7421472-1b0a-40ae-a82f-369c17e3eaa3\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"7213f25b-f0e9-4948-9aa3-d5eeb1f3ecf8\",\n      \"target\": \"172bc90e-326d-45e5-b69a-f530b979f67a\",\n      \"text\": \"\",\n      \"uuid\": \"4fe5ee54-78df-4c0b-9019-09fabeffe2f5\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"f985a884-08da-4002-8a75-b725cc7d0aab\",\n      \"target\": \"172bc90e-326d-45e5-b69a-f530b979f67a\",\n      \"text\": \"\",\n      \"uuid\": \"109ed873-ec11-43e7-9f73-22c98f592083\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"a6b713e7-f15b-42d5-a665-3e73b67b8e02\",\n      \"target\": \"7213f25b-f0e9-4948-9aa3-d5eeb1f3ecf8\",\n      \"text\": \"\",\n      \"uuid\": \"7b996e68-ba75-4e66-8e8e-521d4a08ecea\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"a8a93d4e-39e9-4805-86a6-edaff279cda3\",\n      \"target\": \"7213f25b-f0e9-4948-9aa3-d5eeb1f3ecf8\",\n      \"text\": \"\",\n      \"uuid\": \"98c5b883-61f3-4a35-ae0b-81f78affafb2\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"a06176cf-d520-40d8-bd95-7b8884e5c3e2\",\n      \"target\": \"264981a2-8caf-4327-ad1e-bf46904a0326\",\n      \"text\": \"\",\n      \"uuid\": \"ed85a646-25f8-497f-9c46-16531a43e2eb\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"7314a8ac-b5a7-4626-8c46-e04847fc57ff\",\n      \"target\": \"264981a2-8caf-4327-ad1e-bf46904a0326\",\n      \"text\": \"\",\n      \"uuid\": \"42e656f0-256e-4c3c-8358-b9fc9e1548d9\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"13179626-e481-4300-a546-c3dafbe2d149\",\n      \"target\": \"c39b6cec-768d-48e6-bad2-576c0c025197\",\n      \"text\": \"含有\",\n      \"uuid\": \"8dcd069c-8c35-475f-8baa-7fe936c49d1b\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 255, 0, 1],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"76ceb2b4-68a4-4d6f-9fa6-7b9ef1199044\",\n      \"target\": \"25f5cf59-858c-4b4b-8181-7501c203f2cd\",\n      \"text\": \"\",\n      \"uuid\": \"68724c60-38b6-4e07-9c5d-6635748b8548\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"0b35df90-e38b-4123-bab4-0672332c1ec8\",\n      \"target\": \"76ceb2b4-68a4-4d6f-9fa6-7b9ef1199044\",\n      \"text\": \"\",\n      \"uuid\": \"08578921-6efe-4041-89d9-457670e94baf\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"bb087df2-7762-40bd-aa18-d574e5c1fa05\",\n      \"target\": \"aaff9a9f-5f96-4cf5-a13d-793b2b5f34fb\",\n      \"text\": \"\",\n      \"uuid\": \"ecda329a-8668-48a1-bef6-a27549772c8e\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"aaff9a9f-5f96-4cf5-a13d-793b2b5f34fb\",\n      \"target\": \"09197827-0908-451a-ac0c-844b51a7f60c\",\n      \"text\": \"\",\n      \"uuid\": \"cb84cfc6-1164-4a08-acad-9331d20fbdec\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"aaff9a9f-5f96-4cf5-a13d-793b2b5f34fb\",\n      \"target\": \"7b113823-e220-420c-95e8-1e91192e7994\",\n      \"text\": \"\",\n      \"uuid\": \"6be6fd98-2f19-41fc-b66f-23410ef12ba2\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"aaff9a9f-5f96-4cf5-a13d-793b2b5f34fb\",\n      \"target\": \"109fa2e2-bdcc-420e-9cd4-f465811fb2c6\",\n      \"text\": \"\",\n      \"uuid\": \"443ebf8d-9de5-49c7-9437-f2d315695523\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"3547931c-b425-4e94-b103-aecead33d619\",\n      \"target\": \"d6d3ce04-b3ae-486f-a60f-b6d3f312eab6\",\n      \"text\": \"仅对内容基础操作\",\n      \"uuid\": \"5d44ad66-808b-4ad8-8aa5-a99b47fd34a7\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"05bc2592-ddc9-4fbe-a43e-ec724fa37b57\",\n      \"target\": \"d69f9781-4694-419d-bdb4-d693c1ccf43b\",\n      \"text\": \"衍生功能\",\n      \"uuid\": \"08c25dd6-5051-46a0-a52d-916eef3687cc\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"3547931c-b425-4e94-b103-aecead33d619\",\n      \"target\": \"0a36e598-7e3f-4180-977e-94791ebbd1d7\",\n      \"text\": \"\",\n      \"uuid\": \"7149c45d-1e04-4315-8302-72a680a3d635\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"2bcb54f6-d25d-47e2-9bd7-82bf26299713\",\n      \"target\": \"0a36e598-7e3f-4180-977e-94791ebbd1d7\",\n      \"text\": \"\",\n      \"uuid\": \"24877899-610b-49e8-8dab-6b818605daff\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"0a36e598-7e3f-4180-977e-94791ebbd1d7\",\n      \"target\": \"2280fcad-b69f-4fbd-aacf-39e3d1e8adf8\",\n      \"text\": \"构成\",\n      \"uuid\": \"fea23fa1-83ff-43e4-bb87-6afbf84adf39\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"c1c4a086-cd6e-4c0e-a436-a1bef678473c\",\n      \"target\": \"0a36e598-7e3f-4180-977e-94791ebbd1d7\",\n      \"text\": \"\",\n      \"uuid\": \"8b57a408-bd8d-480d-a7e4-3073f6f8c0c2\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.01],\n      \"targetRectRate\": [0.5, 0.99]\n    },\n    {\n      \"source\": \"d69f9781-4694-419d-bdb4-d693c1ccf43b\",\n      \"target\": \"a6219774-81f5-471b-8659-1d9e0b15d1a6\",\n      \"text\": \"紧密联系\",\n      \"uuid\": \"20e37914-a347-467d-89d8-ce21de61e9d7\",\n      \"type\": \"core:line_edge\",\n      \"color\": [59, 130, 246, 1],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"57299029-e5a5-460b-9588-12b3d63962c9\",\n      \"target\": \"a6219774-81f5-471b-8659-1d9e0b15d1a6\",\n      \"text\": \"成熟后加入\",\n      \"uuid\": \"658d5b0c-dc03-4ad4-96e9-dc6e08b16bb0\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    },\n    {\n      \"source\": \"c52beb93-3cfe-419f-8892-b0830094f1f5\",\n      \"target\": \"260d6b15-eeb4-41e8-9672-3cf1a9086d01\",\n      \"text\": \"\",\n      \"uuid\": \"91e61733-5c76-4626-b2da-7f8aea27ab76\",\n      \"type\": \"core:line_edge\",\n      \"color\": [0, 0, 0, 0],\n      \"sourceRectRate\": [0.5, 0.5],\n      \"targetRectRate\": [0.5, 0.5]\n    }\n  ],\n  \"tags\": []\n}\n"
  },
  {
    "path": "docs-pg/README_FOR_AI.md",
    "content": "# 项目介绍\n\n这是一个绘制网状节点图的工具。目前采用json存储数据工程文件。\n\n## 技术栈\n\n基于 Tauri 框架，TypeScript + React + Rust。其中Rust只负责基础的本地文件处理部分，绝大部份功能由前端完成。\n\n前端UI采用 tailwindcss 完成。\n\n使用 monorepo 管理项目，主要分为 app（应用程序本体） 和 docs（软件官网） 两个部分。\n\n使用 pnpm 作为包管理工具。\n\n## 代码要求\n\nrust中的函数提供给前端调用，函数在运行中绝对不能出现报错，必须要保证函数内部捕获所有可能出现的错误，函数的健壮性。否则会导致程序直接闪退\n"
  },
  {
    "path": "docs-pg/issue分类.json",
    "content": "{\n  \"version\": 17,\n  \"entities\": [\n    {\n      \"location\": [1334, 1397],\n      \"size\": [497.24951171875, 76],\n      \"text\": \"✨ 希望增加更多节点类型及设置\",\n      \"uuid\": \"38ee526f-a301-4cba-a514-be2b960d3ef4\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1323, 954],\n      \"size\": [785.24951171875, 76],\n      \"text\": \"✨ 希望使用逻辑节点时可以在旁添加可使用函数列表\",\n      \"uuid\": \"d21ee3f6-08a8-410b-bb85-a6e15e87d7fe\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-488, 795],\n      \"size\": [871.9375, 76],\n      \"text\": \"✨希望连接线可以分组，并提供基于连接线分组的管理功能\",\n      \"uuid\": \"eacf86ec-916c-481e-a4a4-1fbc47af7896\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1323, 309],\n      \"size\": [241.24949645996094, 76],\n      \"text\": \"✨ 多文件联动\",\n      \"uuid\": \"2915cbb3-f663-43ec-973e-0acee5af2192\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-488, 683],\n      \"size\": [657.24951171875, 76],\n      \"text\": \"✨ 如果引入间隔重复系统，可能会很有价值\",\n      \"uuid\": \"f07854d7-df7e-4539-9a48-a70ea4bb4ff5\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1323, 625],\n      \"size\": [310.33746337890625, 76],\n      \"text\": \"✨ 导出为mermaid\",\n      \"uuid\": \"afa300ad-5307-4a30-8463-b8e32dc6c4d6\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [514, 874],\n      \"size\": [305.24951171875, 76],\n      \"text\": \"✨ 支持分支与合并\",\n      \"uuid\": \"315e93a1-a46e-4f68-a4d8-6bb99d8b7c3e\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1323, 1064],\n      \"size\": [450.0494689941406, 76],\n      \"text\": \"✨ 针对Linux系统的性能优化\",\n      \"uuid\": \"c9b3c62b-91c8-448e-bde9-038760e7181f\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1334, 1285],\n      \"size\": [694.4639892578125, 76],\n      \"text\": \"Feature:希望文件路径能够作为节点的属性设置\",\n      \"uuid\": \"1528e680-f5a0-4b2e-9813-9c888537f054\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [514, 1094],\n      \"size\": [655.2335205078125, 76],\n      \"text\": \"✨ 新增指令系统，以便在未来更好的训练AI\",\n      \"uuid\": \"6cfc677f-337c-4b70-80da-b94abd06ced3\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-488, 1030],\n      \"size\": [721.24951171875, 76],\n      \"text\": \"✨ 增加快速窗口内容隐藏，只显示顶部窗口功能\",\n      \"uuid\": \"71e3c81b-5821-4164-9ebb-a50a313ca1e5\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1364, 1756],\n      \"size\": [272.16148376464844, 76],\n      \"text\": \"✨ UI面板的优化\",\n      \"uuid\": \"0388946d-ee3e-466e-a05d-37f3736cc361\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-488, 926],\n      \"size\": [548.1934814453125, 76],\n      \"text\": \"✨ 支持“组合”与“隐藏”以及大纲面板\",\n      \"uuid\": \"fc121b9a-f77c-4dd6-91e2-068027d3bfba\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [544, 1516],\n      \"size\": [273.24949645996094, 76],\n      \"text\": \"✨ 图论高亮功能\",\n      \"uuid\": \"a7e9ee15-ba9e-4e3b-bfef-8ecac16712c9\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [544, 1416],\n      \"size\": [369.24951171875, 76],\n      \"text\": \"✨ 更好的节点颜色管理\",\n      \"uuid\": \"4b17ac0f-82f2-436d-b43f-b5eff824d570\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [514, 1205],\n      \"size\": [689.24951171875, 76],\n      \"text\": \"✨ 支持拖入其他类型文件进入程序窗口的功能\",\n      \"uuid\": \"2ac21894-fcfe-4276-87b1-5452569c9f16\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [544, 1619],\n      \"size\": [591.8734741210938, 76],\n      \"text\": \"✨ 导入 Git 仓库，可视化观看项目进展\",\n      \"uuid\": \"33705eb3-b94d-4ddb-b42c-092761476e85\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [66, 1921],\n      \"size\": [273.24949645996094, 76],\n      \"text\": \"✨ 窗口状态记忆\",\n      \"uuid\": \"d0f66b6e-d40f-494b-9e87-01bca8f4b8c0\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [-488, 579],\n      \"size\": [433.24951171875, 76],\n      \"text\": \"✨ 加入貼上任意檔案的能力\",\n      \"uuid\": \"334516dd-9234-4c78-9cd7-9ae89931bb19\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1323, 838],\n      \"size\": [563.58544921875, 76],\n      \"text\": \"✨ 增加shift锁定移动推移节点的功能\",\n      \"uuid\": \"87603fee-84db-429c-a1ac-8b92e03321d3\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1334, 1527],\n      \"size\": [1049.5374755859375, 76],\n      \"text\": \"✨ 增加一个节点的截止日期设置，变成一个高级的拓扑型的TODO LIST\",\n      \"uuid\": \"2c929664-7701-4428-b6f5-e971de8445a9\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1364, 1881],\n      \"size\": [1137.24951171875, 76],\n      \"text\": \"✨ 对于上方菜单栏中一些常用功能提供快捷键，以及设置中快捷键自定义绑定\",\n      \"uuid\": \"0e09b1e2-9fb1-4aa6-b54b-df21c8c58aab\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1364, 1984],\n      \"size\": [433.24951171875, 76],\n      \"text\": \"✨ 快速创建节点的快捷提升\",\n      \"uuid\": \"bd1dc6cf-5fc3-4a0f-a819-a475ee2d57a7\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [514, 980],\n      \"size\": [497.24951171875, 76],\n      \"text\": \"✨ 增加节点框文本自动换行功能\",\n      \"uuid\": \"f0694709-c70e-4011-95c8-d9475febf91b\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1323, 412],\n      \"size\": [474.27349853515625, 76],\n      \"text\": \"✨ 文字节点内渲染 LaTeX 公式\",\n      \"uuid\": \"0bdb7934-533c-423a-913d-4a370a4b713b\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1323, 513],\n      \"size\": [401.24951171875, 76],\n      \"text\": \"✨ 希望添加多标签页功能\",\n      \"uuid\": \"47f33f33-7456-4db5-885e-c32aacc9fdb4\",\n      \"details\": \"\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:text_node\",\n      \"sizeAdjust\": \"auto\"\n    },\n    {\n      \"location\": [1304, 1205],\n      \"size\": [1109.5374755859375, 428],\n      \"uuid\": \"c709d320-b795-4d95-9952-7372fca045ef\",\n      \"text\": \"添加新的节点/修改已有节点的字段\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\n        \"38ee526f-a301-4cba-a514-be2b960d3ef4\",\n        \"1528e680-f5a0-4b2e-9813-9c888537f054\",\n        \"2c929664-7701-4428-b6f5-e971de8445a9\"\n      ],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [1334, 1676],\n      \"size\": [1197.24951171875, 414],\n      \"uuid\": \"0004d9be-81a1-4336-b0f7-1f67bc792db4\",\n      \"text\": \"操作相关\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\n        \"0388946d-ee3e-466e-a05d-37f3736cc361\",\n        \"0e09b1e2-9fb1-4aa6-b54b-df21c8c58aab\",\n        \"bd1dc6cf-5fc3-4a0f-a819-a475ee2d57a7\"\n      ],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [1293, 229],\n      \"size\": [534.2734985351562, 502],\n      \"uuid\": \"fb4f9cfd-2e85-4b9f-a2a3-233ecc7845b2\",\n      \"text\": \"双链笔记\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\n        \"2915cbb3-f663-43ec-973e-0acee5af2192\",\n        \"afa300ad-5307-4a30-8463-b8e32dc6c4d6\",\n        \"0bdb7934-533c-423a-913d-4a370a4b713b\",\n        \"47f33f33-7456-4db5-885e-c32aacc9fdb4\"\n      ],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [1293, 758],\n      \"size\": [845.24951171875, 412],\n      \"uuid\": \"d5f4700d-a827-41bd-99c2-24594361324c\",\n      \"text\": \"用户体验\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\n        \"d21ee3f6-08a8-410b-bb85-a6e15e87d7fe\",\n        \"c9b3c62b-91c8-448e-bde9-038760e7181f\",\n        \"87603fee-84db-429c-a1ac-8b92e03321d3\"\n      ],\n      \"details\": \"\"\n    },\n    {\n      \"location\": [514, 1336],\n      \"size\": [651.8734741210938, 389],\n      \"uuid\": \"1a130d86-37fb-45a3-8d92-287b3de7fc43\",\n      \"text\": \"项目工程管理方向\",\n      \"color\": [0, 0, 0, 0],\n      \"type\": \"core:section\",\n      \"isCollapsed\": false,\n      \"isHidden\": false,\n      \"children\": [\n        \"a7e9ee15-ba9e-4e3b-bfef-8ecac16712c9\",\n        \"4b17ac0f-82f2-436d-b43f-b5eff824d570\",\n        \"33705eb3-b94d-4ddb-b42c-092761476e85\"\n      ],\n      \"details\": \"\"\n    }\n  ],\n  \"associations\": [],\n  \"tags\": []\n}\n"
  },
  {
    "path": "eslint.config.js",
    "content": "import pluginJs from \"@eslint/js\";\nimport eslintConfigPrettier from \"eslint-config-prettier\";\nimport eslintPluginPrettierRecommended from \"eslint-plugin-prettier/recommended\";\nimport pluginReact from \"eslint-plugin-react\";\nimport storybook from \"eslint-plugin-storybook\";\nimport globals from \"globals\";\nimport tseslint from \"typescript-eslint\";\n\nexport default [\n  {\n    files: [\"**/*.{js,mjs,cjs,ts,jsx,tsx}\"],\n    settings: { react: { version: \"19\" } },\n    languageOptions: { globals: globals.browser },\n  },\n  // https://github.com/eslint/eslint/discussions/18304\n  {\n    ignores: [\"app/dist/**/*\", \"app/src-tauri/**/*\", \"app/src/components/ui/**/*\", \"!.storybook\"],\n  },\n  pluginJs.configs.recommended,\n  ...tseslint.configs.recommended,\n  pluginReact.configs.flat.recommended,\n  pluginReact.configs.flat[\"jsx-runtime\"],\n  eslintConfigPrettier,\n  eslintPluginPrettierRecommended,\n  ...storybook.configs[\"flat/recommended\"],\n  // 2024/10/23 这里的rules不能写在上面，否则会被覆盖\n  {\n    rules: {\n      \"@typescript-eslint/no-namespace\": \"off\",\n      \"@typescript-eslint/no-explicit-any\": \"off\",\n    },\n  },\n];\n"
  },
  {
    "path": "nx.json",
    "content": "{\n  \"$schema\": \"./node_modules/nx/schemas/nx-schema.json\",\n  \"targetDefaults\": {\n    \"build\": {\n      \"dependsOn\": [\"^build\"],\n      \"outputs\": [\"{projectRoot}/dist/**\"],\n      \"cache\": true\n    },\n    \"dev\": {\n      \"dependsOn\": [\"^build\"],\n      \"cache\": false,\n      \"continuous\": true\n    },\n    \"tauri:dev\": {\n      \"dependsOn\": [\"^build\"],\n      \"cache\": false,\n      \"continuous\": true\n    },\n    \"tauri:build\": {\n      \"dependsOn\": [\"build\", \"^build\"],\n      \"outputs\": [\"{projectRoot}/src-tauri/target/release/bundle/**\"],\n      \"cache\": true\n    },\n    \"type-check\": {\n      \"inputs\": [\"{projectRoot}/src/**/*.{ts,tsx}\"],\n      \"cache\": true\n    }\n  },\n  \"defaultBase\": \"next\"\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"project-graph\",\n  \"version\": \"1.0.0\",\n  \"description\": \"An open-source project that aims to provide a next-generation node diagram tool for visual thinking.\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"nx run-many -t dev tauri:dev\",\n    \"build\": \"nx run-many -t build tauri:build\",\n    \"build:ci\": \"nx run-many --skipNxCache -t build tauri:build\",\n    \"prepare\": \"husky\",\n    \"lint\": \"eslint\",\n    \"lint:fix\": \"eslint --fix\",\n    \"format\": \"prettier --write .\",\n    \"test\": \"vitest\",\n    \"tauri\": \"pnpm --filter @graphif/project-graph tauri\",\n    \"prepublish\": \"pnpm run build\"\n  },\n  \"devDependencies\": {\n    \"@eslint/js\": \"^9.31.0\",\n    \"@types/node\": \"^24.1.0\",\n    \"cross-env\": \"^10.0.0\",\n    \"eslint\": \"^9.31.0\",\n    \"eslint-config-prettier\": \"^10.1.8\",\n    \"eslint-plugin-prettier\": \"^5.5.3\",\n    \"eslint-plugin-react\": \"^7.37.5\",\n    \"eslint-plugin-storybook\": \"^9.0.18\",\n    \"globals\": \"^16.3.0\",\n    \"husky\": \"^9.1.7\",\n    \"lint-staged\": \"^16.1.2\",\n    \"prettier\": \"^3.6.2\",\n    \"prettier-plugin-tailwindcss\": \"^0.6.14\",\n    \"turbo\": \"^2.5.5-canary.1\",\n    \"typescript-eslint\": \"^8.38.0\",\n    \"vitest\": \"3.2.4\",\n    \"nx\": \"21.6.3\"\n  },\n  \"pnpm\": {\n    \"peerDependencyRules\": {\n      \"ignoreMissing\": [\n        \"@algolia/client-search\",\n        \"search-insights\"\n      ]\n    },\n    \"onlyBuiltDependencies\": [\n      \"@swc/core\",\n      \"bcrypt\",\n      \"esbuild\",\n      \"msw\",\n      \"sharp\"\n    ],\n    \"patchedDependencies\": {\n      \"typescript\": \"patches/typescript.patch\"\n    }\n  },\n  \"packageManager\": \"pnpm@10.6.5\"\n}\n"
  },
  {
    "path": "packages/api/.npmignore",
    "content": "src\n"
  },
  {
    "path": "packages/api/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 Graphif\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "packages/api/package.json",
    "content": "{\n  \"name\": \"project-graph-api\",\n  \"description\": \"Provide API and types for Project Graph plugin development\",\n  \"private\": false,\n  \"version\": \"1.0.1\",\n  \"type\": \"module\",\n  \"main\": \"dist/index.js\",\n  \"types\": \"dist/index.d.ts\",\n  \"scripts\": {\n    \"build\": \"tsc && esbuild src/index.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/index.js --minify\"\n  },\n  \"keywords\": [],\n  \"author\": \"Project Graph <support@project-graph.top>\",\n  \"license\": \"MIT\",\n  \"packageManager\": \"pnpm@10.6.5\",\n  \"devDependencies\": {\n    \"esbuild\": \"^0.25.2\",\n    \"typescript\": \"catalog:\"\n  },\n  \"dependencies\": {\n    \"zod\": \"^3.24.2\"\n  }\n}\n"
  },
  {
    "path": "packages/api/src/apis/camera.ts",
    "content": "import { api } from \"..\";\n\nexport const Camera = {\n  getLocation: api(\"getCameraLocation\"),\n  setLocation: api(\"setCameraLocation\"),\n};\n"
  },
  {
    "path": "packages/api/src/apis/controller.ts",
    "content": "import { api } from \"..\";\n\nexport const Controller = {\n  getPressingKeys: api(\"getPressingKey\"),\n};\n"
  },
  {
    "path": "packages/api/src/apis/dialog.ts",
    "content": "import { api } from \"..\";\n\nexport const Dialog = {\n  show: api(\"openDialog\"),\n};\n"
  },
  {
    "path": "packages/api/src/index.ts",
    "content": "import { Asyncize, PluginAPI } from \"./types\";\n\nexport function api<T extends keyof PluginAPI>(method: T): Asyncize<PluginAPI[T]> {\n  return (...args: Parameters<PluginAPI[T]>) => {\n    return new Promise<ReturnType<PluginAPI[T]>>((resolve, reject) => {\n      const reqId = crypto.randomUUID();\n      window.postMessage(\n        {\n          type: \"callAPIMethod\",\n          payload: { reqId, method, args },\n        },\n        \"*\",\n      );\n      const handler = (event: MessageEvent) => {\n        const { type, payload } = event.data;\n        if (type === \"apiResponse\") {\n          if (payload.reqId === reqId) {\n            if (payload.success) {\n              resolve(payload.result);\n            } else {\n              reject(payload.result);\n            }\n            window.removeEventListener(\"message\", handler);\n          }\n        }\n      };\n      window.addEventListener(\"message\", handler);\n    });\n  };\n}\n\nexport { Camera } from \"./apis/camera\";\nexport { Controller } from \"./apis/controller\";\nexport { Dialog } from \"./apis/dialog\";\n"
  },
  {
    "path": "packages/api/src/types.ts",
    "content": "import { z } from \"zod\";\n\n// 定义允许插件调用的 API 方法类型\nexport const apiTypes = {\n  hello: [[z.string()], z.void()],\n  getCameraLocation: [[], z.tuple([z.number(), z.number()])],\n  setCameraLocation: [[z.number(), z.number()], z.void()],\n  getPressingKey: [[], z.array(z.string())],\n  openDialog: [[z.string(), z.string()], z.void()],\n} as const;\n\ntype Zod2Interface<T> = {\n  [K in keyof T]: T[K] extends readonly [\n    // 第一个元素：参数列表\n    infer Args extends readonly z.ZodTypeAny[],\n    // 第二个元素：返回值类型\n    infer Return extends z.ZodTypeAny,\n  ]\n    ? (\n        ...args: {\n          // 对每个参数使用z.infer\n          [L in keyof Args]: Args[L] extends z.ZodTypeAny ? z.infer<Args[L]> : never;\n        }\n      ) => z.infer<Return>\n    : never;\n};\n\nexport type Asyncize<T extends (...args: any[]) => any> = (...args: Parameters<T>) => Promise<ReturnType<T>>;\nexport type AsyncizeInterface<T> = {\n  [K in keyof T]: T[K] extends (...args: any[]) => any ? Asyncize<T[K]> : never;\n};\nexport type SyncOrAsyncizeInterface<T> = {\n  [K in keyof T]: T[K] extends (...args: any[]) => any ? Asyncize<T[K]> | T[K] : never;\n};\n\nexport type PluginAPI = Zod2Interface<typeof apiTypes>;\nexport type PluginAPIMayAsync = SyncOrAsyncizeInterface<PluginAPI>;\n\n// 消息通信协议类型\n\n/**\n * 插件发送给主进程的消息类型\n */\nexport type CallAPIMessage = {\n  type: \"callAPIMethod\";\n  payload: {\n    method: keyof typeof apiTypes;\n    args: any[];\n    reqId: string;\n  };\n};\n\n/**\n * 主进程响应给插件的消息类型\n */\nexport type APIResponseMessage = {\n  type: \"apiResponse\";\n  payload: {\n    reqId: string;\n    result?: any;\n    error?: string;\n  };\n};\n"
  },
  {
    "path": "packages/api/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"esModuleInterop\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"strict\": true,\n    \"skipLibCheck\": true,\n    \"preserveSymlinks\": true,\n    \"emitDeclarationOnly\": true,\n    \"declaration\": true,\n    \"declarationDir\": \"dist\"\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "packages/data-structures/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "packages/data-structures/.npmignore",
    "content": "src\n"
  },
  {
    "path": "packages/data-structures/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 Graphif\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "packages/data-structures/package.json",
    "content": "{\n  \"name\": \"@graphif/data-structures\",\n  \"description\": \"Serializable data structures\",\n  \"version\": \"1.0.0\",\n  \"type\": \"module\",\n  \"license\": \"MIT\",\n  \"files\": [\n    \"dist\"\n  ],\n  \"module\": \"dist/index.js\",\n  \"types\": \"dist/index.d.ts\",\n  \"exports\": {\n    \".\": {\n      \"import\": \"./dist/index.js\",\n      \"types\": \"./dist/index.d.ts\"\n    }\n  },\n  \"scripts\": {\n    \"build\": \"tsdown\",\n    \"type-check\": \"tsc\"\n  },\n  \"devDependencies\": {\n    \"tsdown\": \"catalog:\",\n    \"typescript\": \"catalog:\"\n  },\n  \"dependencies\": {\n    \"@graphif/serializer\": \"workspace:*\",\n    \"unplugin-original-class-name\": \"^1.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/data-structures/src/Cache.ts",
    "content": "/**\n * 最近最少使用缓存\n * 原理：当缓存满时，删除最早添加的缓存\n */\nexport class LruCache<K, V> extends Map<K, V> {\n  private readonly capacity: number;\n\n  constructor(capacity: number) {\n    super();\n    this.capacity = capacity;\n  }\n\n  set(key: K, value: V): this {\n    if (this.capacity === 0) {\n      return this;\n    }\n    if (super.has(key)) {\n      super.delete(key);\n    } else if (super.size >= this.capacity) {\n      const firstKey = super.keys().next().value;\n      if (firstKey !== undefined) {\n        super.delete(firstKey);\n      }\n    }\n    super.set(key, value);\n    return this;\n  }\n\n  // 重写 get：访问后把该 key 提到“最近使用”位置\n  get(key: K): V | undefined {\n    const value = super.get(key);\n    if (value !== undefined) {\n      super.delete(key);\n      super.set(key, value);\n    }\n    return value;\n  }\n}\n\n/**\n * 一旦缓存达到最大容量，则自动删除全部\n */\nexport class MaxSizeCache<K, V> {\n  private cache: Map<K, V> = new Map();\n  private readonly maxSize: number;\n\n  /**\n   * 获取当前缓存的容量状态\n   * @returns\n   */\n  getCapacityStatus(): [number, number] {\n    return [this.cache.size, this.maxSize];\n  }\n  constructor(maxSize: number) {\n    this.maxSize = maxSize;\n  }\n\n  get(key: K): V | undefined {\n    return this.cache.get(key);\n  }\n\n  set(key: K, value: V): void {\n    if (this.cache.size >= this.maxSize) {\n      this.cache.clear();\n    }\n    this.cache.set(key, value);\n  }\n\n  has(key: K): boolean {\n    return this.cache.has(key);\n  }\n\n  clear(): void {\n    this.cache.clear();\n  }\n}\n"
  },
  {
    "path": "packages/data-structures/src/Color.ts",
    "content": "import { serializable } from \"@graphif/serializer\";\n\n/**\n * 颜色对象\n * 不透明度最大值为1，最小值为0\n */\nexport class Color {\n  static White = new Color(255, 255, 255);\n  static Black = new Color(0, 0, 0);\n  static Gray = new Color(128, 128, 128);\n  static Red = new Color(255, 0, 0);\n  static Green = new Color(0, 255, 0);\n  static Blue = new Color(0, 0, 255);\n  static Yellow = new Color(255, 255, 0);\n  static Cyan = new Color(0, 255, 255);\n  static Magenta = new Color(255, 0, 255);\n  static Transparent = new Color(0, 0, 0, 0);\n\n  @serializable\n  r: number;\n  @serializable\n  g: number;\n  @serializable\n  b: number;\n  @serializable\n  a: number;\n\n  constructor(r: number, g: number, b: number, a: number = 1) {\n    this.r = r;\n    this.g = g;\n    this.b = b;\n    this.a = a;\n  }\n\n  toString() {\n    return `rgba(${this.r}, ${this.g}, ${this.b}, ${this.a})`;\n  }\n  toHexString(): string {\n    return `#${this.r.toString(16).padStart(2, \"0\")}${this.g.toString(16).padStart(2, \"0\")}${this.b.toString(16).padStart(2, \"0\")}${this.a.toString(16).padStart(2, \"0\")}`;\n  }\n  toHexStringWithoutAlpha(): string {\n    return `#${this.r.toString(16).padStart(2, \"0\")}${this.g.toString(16).padStart(2, \"0\")}${this.b.toString(16).padStart(2, \"0\")}`;\n  }\n  clone() {\n    return new Color(this.r, this.g, this.b, this.a);\n  }\n  /**\n   * 将字符串十六进制转成颜色对象，注意带井号\n   * @param hex\n   * @returns\n   */\n  static fromHex(hex: string) {\n    hex = hex.replace(\"#\", \"\");\n    hex = hex.toUpperCase();\n\n    if (hex.length === 6) {\n      // hex = \"FF\" + hex;  // 这一行代码是AI补出来的，实际上这是错的，被坑了\n      const r = parseInt(hex.slice(0, 2), 16);\n      const g = parseInt(hex.slice(2, 4), 16);\n      const b = parseInt(hex.slice(4, 6), 16);\n      return new Color(r, g, b);\n    } else {\n      const r = parseInt(hex.slice(0, 2), 16);\n      const g = parseInt(hex.slice(2, 4), 16);\n      const b = parseInt(hex.slice(4, 6), 16);\n      const a = parseInt(hex.slice(6, 8), 16);\n      return new Color(r, g, b, a);\n    }\n  }\n  static fromCss(color: string): Color {\n    if (color === \"transparent\") {\n      return this.Transparent;\n    }\n\n    // 处理十六进制格式\n    if (/^#([A-Fa-f0-9]{3,4}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$/.test(color)) {\n      let hex = color.slice(1);\n      // 扩展简写格式\n      if (hex.length <= 4) {\n        hex = hex.replace(/(.)/g, \"$1$1\");\n      }\n      const value = parseInt(hex, 16);\n      switch (hex.length) {\n        case 3: // #rgb\n          return new Color(((value >> 8) & 0xf) * 17, ((value >> 4) & 0xf) * 17, (value & 0xf) * 17, 1);\n        case 4: // #rgba\n          return new Color(\n            ((value >> 12) & 0xf) * 17,\n            ((value >> 8) & 0xf) * 17,\n            ((value >> 4) & 0xf) * 17,\n            (value & 0xf) / 15,\n          );\n        case 6: // #rrggbb\n          return new Color((value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff, 1);\n        case 8: // #rrggbbaa\n          return new Color((value >> 24) & 0xff, (value >> 16) & 0xff, (value >> 8) & 0xff, (value & 0xff) / 255);\n      }\n    }\n\n    // 处理 rgb/rgba 格式\n    const rgbMatch = color.match(/^rgba?\\((.*)\\)$/i);\n    if (rgbMatch) {\n      const parts = rgbMatch[1].split(/[,/]\\s*/).map((p) => p.trim());\n      if (parts.length >= 3) {\n        const parseValue = (v: string, max: number) => {\n          if (v.endsWith(\"%\")) {\n            return Math.round((parseFloat(v) * max) / 100);\n          }\n          return parseFloat(v);\n        };\n\n        return new Color(\n          parseValue(parts[0], 255),\n          parseValue(parts[1], 255),\n          parseValue(parts[2], 255),\n          parts[3] ? parseFloat(parts[3]) : 1,\n        );\n      }\n    }\n\n    // 处理oklch格式\n    const oklchMatch = color.match(/^oklch\\((.*)\\)$/i);\n    if (oklchMatch) {\n      const parts = oklchMatch[1]\n        .split(/[,/]\\s*|\\s+/)\n        .map((p) => p.trim())\n        .filter((p) => p !== \"\");\n      if (parts.length >= 3) {\n        const parseHue = (hStr: string) => {\n          const hueMatch = hStr.match(/^(-?\\d*\\.?\\d+)(deg|rad|turn)?$/i);\n          if (!hueMatch) return 0;\n          const value = parseFloat(hueMatch[1]);\n          const unit = hueMatch[2] ? hueMatch[2].toLowerCase() : \"deg\";\n          switch (unit) {\n            case \"deg\":\n              return value;\n            case \"rad\":\n              return (value * 180) / Math.PI;\n            case \"turn\":\n              return value * 360;\n            default:\n              return value;\n          }\n        };\n\n        const parsePercentOrNumber = (v: string, max: number = 1) => {\n          if (v.endsWith(\"%\")) {\n            return Math.min(max, Math.max(0, parseFloat(v) / 100));\n          }\n          return Math.min(max, Math.max(0, parseFloat(v)));\n        };\n\n        const l = parsePercentOrNumber(parts[0]); // L 范围 0-1\n        const c = parsePercentOrNumber(parts[1], Infinity); // C 允许非负\n        const h = parseHue(parts[2]);\n        const alpha = parts.length >= 4 ? parsePercentOrNumber(parts[3]) : 1;\n\n        // 转换到Oklab的a、b\n        const hRad = ((((h % 360) + 360) % 360) * Math.PI) / 180;\n        const aVal = c * Math.cos(hRad);\n        const bVal = c * Math.sin(hRad);\n\n        // 转换到LMS线性值\n        const lLMS = l + 0.3963377774 * aVal + 0.2158037573 * bVal;\n        const mLMS = l - 0.1055613458 * aVal - 0.0638541728 * bVal;\n        const sLMS = l - 0.0894841775 * aVal - 1.291485548 * bVal;\n\n        // 立方转换得到非线性LMS\n        const lNonlinear = Math.pow(lLMS, 3);\n        const mNonlinear = Math.pow(mLMS, 3);\n        const sNonlinear = Math.pow(sLMS, 3);\n\n        // 转换到 XYZ\n        const x = 1.2270138511 * lNonlinear - 0.5577999807 * mNonlinear + 0.281256149 * sNonlinear;\n        const y = -0.0405801784 * lNonlinear + 1.1122568696 * mNonlinear - 0.0716766787 * sNonlinear;\n        const z = -0.0763812845 * lNonlinear - 0.4214819784 * mNonlinear + 1.5861632204 * sNonlinear;\n\n        // 转换到线性RGB\n        const rLinear = x * 3.2404542 + y * -1.5371385 + z * -0.4985314;\n        const gLinear = x * -0.969266 + y * 1.8760108 + z * 0.041556;\n        const bLinear = x * 0.0556434 + y * -0.2040259 + z * 1.0572252;\n\n        // Gamma校正\n        const gammaCorrect = (v: number) => {\n          v = Math.max(0, Math.min(1, v));\n          return v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;\n        };\n\n        const sr = gammaCorrect(rLinear);\n        const sg = gammaCorrect(gLinear);\n        const sb = gammaCorrect(bLinear);\n\n        // 转换为0-255整数\n        const toByte = (v: number) => Math.round(Math.min(255, Math.max(0, v * 255)));\n        return new Color(toByte(sr), toByte(sg), toByte(sb), alpha);\n      }\n    }\n\n    return this.Black;\n  }\n\n  /**\n   * 将此颜色转换为透明色，\n   * 和(0, 0, 0, 0)不一样\n   * 因为(0, 0, 0, 0)是黑色的透明色，在颜色线性混合的时候会偏黑\n   * @returns\n   */\n  toTransparent() {\n    return new Color(this.r, this.g, this.b, 0);\n  }\n\n  /**\n   * 和toTransparent完全相反\n   * @returns\n   */\n  toSolid() {\n    return new Color(this.r, this.g, this.b, 1);\n  }\n\n  toNewAlpha(a: number) {\n    return new Color(this.r, this.g, this.b, a);\n  }\n\n  /**\n   * 判断自己是否和另一个颜色相等\n   */\n\n  equals(color: Color) {\n    return this.r === color.r && this.g === color.g && this.b === color.b && this.a === color.a;\n  }\n\n  toArray(): [number, number, number, number] {\n    return [this.r, this.g, this.b, this.a];\n  }\n\n  static getRandom(): Color {\n    const r = Math.floor(Math.random() * 256);\n    const g = Math.floor(Math.random() * 256);\n    const b = Math.floor(Math.random() * 256);\n    return new Color(r, g, b);\n  }\n\n  /**\n   * 降低颜色的饱和度\n   * @param amount 0 到 1 之间的值，表示去饱和的程度\n   */\n  desaturate(amount: number): Color {\n    const grayScale = Math.round(this.r * 0.299 + this.g * 0.587 + this.b * 0.114);\n    const newR = Math.round(grayScale + (this.r - grayScale) * (1 - amount));\n    const newG = Math.round(grayScale + (this.g - grayScale) * (1 - amount));\n    const newB = Math.round(grayScale + (this.b - grayScale) * (1 - amount));\n    return new Color(newR, newG, newB, this.a);\n  }\n\n  /**\n   * 将颜色转换为冷色调且低饱和度的版本\n   * 注意：此方法是基于简单假设实现的，并不能精确地转换颜色空间。\n   */\n  toColdLowSaturation(): Color {\n    // 转换至更冷的色调，这里简化处理，主要针对红色系向蓝色系偏移\n    const hsl = this.rgbToHsl();\n    hsl.h = Math.max(180, hsl.h); // 强制色调向冷色调偏移\n    hsl.s = Math.min(hsl.s * 0.5, 1); // 减少饱和度\n\n    const rgb = this.hslToRgb(hsl);\n    return new Color(rgb.r, rgb.g, rgb.b, this.a).desaturate(0.3); // 进一步降低饱和度\n  }\n\n  // 辅助方法：RGB转HSL\n  rgbToHsl(): { h: number; s: number; l: number } {\n    const r = this.r / 255;\n    const g = this.g / 255;\n    const b = this.b / 255;\n    const max = Math.max(r, g, b);\n    const min = Math.min(r, g, b);\n    let h = 0;\n    let s = 0;\n    const l = (max + min) / 2;\n\n    if (max !== min) {\n      const d = max - min;\n      s = l > 0.5 ? d / (2 - max - min) : d / (max + min);\n      switch (max) {\n        case r:\n          h = (g - b) / d + (g < b ? 6 : 0);\n          break;\n        case g:\n          h = (b - r) / d + 2;\n          break;\n        case b:\n          h = (r - g) / d + 4;\n          break;\n      }\n      h /= 6;\n    }\n\n    return { h: h * 360, s, l };\n  }\n\n  /**\n   * 计算颜色的色相\n   * @param color\n   * @returns 色相值（0-360）\n   */\n  public static getHue(color: Color): number {\n    const r = color.r / 255;\n    const g = color.g / 255;\n    const b = color.b / 255;\n\n    const max = Math.max(r, g, b);\n    const min = Math.min(r, g, b);\n    let hue = 0;\n\n    if (max === min) {\n      hue = 0; // achromatic\n    } else {\n      const diff = max - min;\n      if (max === r) {\n        hue = ((g - b) / diff) % 6;\n      } else if (max === g) {\n        hue = (b - r) / diff + 2;\n      } else if (max === b) {\n        hue = (r - g) / diff + 4;\n      }\n      hue = Math.round(hue * 60);\n      if (hue < 0) {\n        hue += 360;\n      }\n    }\n    return hue;\n  }\n\n  // 辅助方法：HSL转RGB\n  private hslToRgb(hsl: { h: number; s: number; l: number }): { r: number; g: number; b: number } {\n    let r, g, b;\n    const h = hsl.h / 360;\n    const s = hsl.s;\n    const l = hsl.l;\n\n    if (s === 0) {\n      r = g = b = l; // achromatic\n    } else {\n      const q = l < 0.5 ? l * (1 + s) : l + s - l * s;\n      const p = 2 * l - q;\n      r = this.hueToRgb(p, q, h + 1 / 3);\n      g = this.hueToRgb(p, q, h);\n      b = this.hueToRgb(p, q, h - 1 / 3);\n    }\n\n    return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) };\n  }\n\n  private hueToRgb(p: number, q: number, t: number): number {\n    if (t < 0) t += 1;\n    if (t > 1) t -= 1;\n    if (t < 1 / 6) return p + (q - p) * 6 * t;\n    if (t < 1 / 2) return q;\n    if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;\n    return p;\n  }\n\n  /**\n   * 改变色相\n   * @param deHue 色相差值(角度)，正数表示顺时针，负数表示逆时针\n   */\n  public changeHue(deHue: number): Color {\n    // 修复处理负值色相差的问题\n    const hsl = this.rgbToHsl();\n    // 确保色相值始终在0-360范围内\n    hsl.h = (((hsl.h + deHue) % 360) + 360) % 360;\n    const { r, g, b } = this.hslToRgb(hsl);\n    return new Color(r, g, b, this.a);\n  }\n}\nexport function colorInvert(color: Color): Color {\n  /**\n   * 计算背景色的亮度 更精确的人眼感知亮度公式\n   * 0.2126 * R + 0.7152 * G + 0.0722 * B，\n   * 如果亮度较高，则使用黑色文字，\n   * 如果亮度较低，则使用白色文字。\n   * 这种方法能够确保无论背景色如何变化，文字都能保持足够的对比度。\n   */\n\n  const r = color.r;\n  const g = color.g;\n  const b = color.b;\n  const brightness = 0.2126 * r + 0.7152 * g + 0.0722 * b;\n\n  if (brightness > 128) {\n    return Color.Black; // 返回黑色\n  } else {\n    return Color.White; // 返回白色\n  }\n}\n/**\n * 获取两个颜色的中间过渡色（线性混合）\n * 根据两个颜色，以及一个 0~1 的权重，返回一个新的颜色\n * 0 权重返回 color1，1 权重返回 color2\n * @param color1 颜色1\n * @param color2 颜色2\n */\nexport function mixColors(color1: Color, color2: Color, weight: number): Color {\n  const r = Math.round(color1.r * (1 - weight) + color2.r * weight);\n  const g = Math.round(color1.g * (1 - weight) + color2.g * weight);\n  const b = Math.round(color1.b * (1 - weight) + color2.b * weight);\n  const a = color1.a * (1 - weight) + color2.a * weight;\n  return new Color(r, g, b, Math.round(a * 100) / 100); // 颜色混合很容易搞出科学计数法的小数，这里取两位小数精度\n}\n\n/**\n * 获取一个颜色列表的平均颜色\n */\nexport function averageColors(colors: Color[]): Color {\n  const r = Math.round(colors.reduce((acc, cur) => acc + cur.r, 0) / colors.length);\n  const g = Math.round(colors.reduce((acc, cur) => acc + cur.g, 0) / colors.length);\n  const b = Math.round(colors.reduce((acc, cur) => acc + cur.b, 0) / colors.length);\n\n  // const a = Math.round(colors.reduce((acc, cur) => acc + cur.a, 0.0) / colors.length);\n  // 上面这种写法有bug，透明度平均不了\n\n  let a = 0.0;\n  for (const color of colors) {\n    a += color.a;\n  }\n  a /= colors.length;\n  console.log(a);\n  return new Color(r, g, b, a);\n}\n"
  },
  {
    "path": "packages/data-structures/src/LimitLengthQueue.ts",
    "content": "import { Queue } from \"./Queue\";\n\nexport class LimitLengthQueue<T> extends Queue<T> {\n  constructor(private limitLength: number) {\n    if (limitLength <= 0) {\n      throw new Error(\"限制长度必须是正整数\");\n    }\n    super();\n  }\n  // 入队操作\n  enqueue(element: T): void {\n    if (this.items.length === this.limitLength) {\n      this.dequeue();\n    }\n    this.items.push(element);\n  }\n\n  /**\n   * 获取多个队尾元素，如果长度不足则返回数组长度不足\n   * @param multi\n   */\n  multiGetTail(multi: number): T[] {\n    if (multi >= this.items.length) {\n      // 长度不够了！全部返回\n      return [...this.items];\n    } else {\n      // 长度足够\n      const result: T[] = [];\n      for (let i = this.items.length - multi; i < this.items.length; i++) {\n        result.push(this.items[i]);\n      }\n      return result;\n    }\n  }\n}\n"
  },
  {
    "path": "packages/data-structures/src/MonoStack.ts",
    "content": "type StackItem<T> = {\n  item: T;\n  level: number;\n};\n/**\n * 单调栈\n * 单调递增\n */\nexport class MonoStack<T> {\n  private stack: StackItem<T>[] = [];\n\n  constructor() {\n    this.stack = [];\n  }\n\n  // 长度属性\n  get length(): number {\n    return this.stack.length;\n  }\n\n  // 入栈操作\n  push(item: T, level: number): void {\n    const stackItem: StackItem<T> = { item, level };\n    while (this.stack.length > 0 && this.stack[this.stack.length - 1].level >= level) {\n      this.stack.pop(); // 弹出不满足单调性的元素\n    }\n    this.stack.push(stackItem);\n  }\n\n  // 出栈操作\n  pop(): T | undefined {\n    const stackItem = this.stack.pop();\n    return stackItem ? stackItem.item : undefined;\n  }\n\n  // 获取栈顶元素\n  peek(): T | undefined {\n    const stackItem = this.stack[this.stack.length - 1];\n    return stackItem ? stackItem.item : undefined;\n  }\n\n  /**\n   * 不安全的获取栈顶元素\n   * 如果栈为空，则会抛出异常\n   */\n  unsafePeek(): T {\n    return this.stack[this.stack.length - 1].item;\n  }\n\n  // 获取从栈底到栈顶的元素数，第几个元素\n  get(index: number): T | undefined {\n    const stackItem = this.stack[index];\n    return stackItem ? stackItem.item : undefined;\n  }\n\n  unsafeGet(index: number): T {\n    return this.stack[index].item;\n  }\n\n  // 判断栈是否为空\n  isEmpty(): boolean {\n    return this.stack.length === 0;\n  }\n}\n"
  },
  {
    "path": "packages/data-structures/src/ProgressNumber.ts",
    "content": "/**\n * 进度条数字类\n * 可用于 血量、等等的进度条使用场景\n */\nexport class ProgressNumber {\n  /**\n   *\n   * @param curValue 当前的值\n   * @param maxValue 进度条的最大值\n   */\n  constructor(\n    public curValue: number,\n    public maxValue: number,\n  ) {}\n\n  /**\n   * 返回百分比，0-100\n   */\n  get percentage(): number {\n    return (this.curValue / this.maxValue) * 100;\n  }\n  /**\n   * 返回比率，0-1\n   */\n  get rate(): number {\n    return this.curValue / this.maxValue;\n  }\n\n  get isFull(): boolean {\n    return this.curValue == this.maxValue;\n  }\n\n  get isEmpty(): boolean {\n    return this.curValue <= 0;\n  }\n\n  setEmpty() {\n    this.curValue = 0;\n  }\n\n  setFull() {\n    this.curValue = this.maxValue;\n  }\n\n  add(value: number) {\n    this.curValue += value;\n    if (this.curValue > this.maxValue) {\n      this.curValue = this.maxValue;\n    }\n  }\n\n  clone(): ProgressNumber {\n    return new ProgressNumber(this.curValue, this.maxValue);\n  }\n\n  subtract(value: number) {\n    this.curValue -= value;\n    if (this.curValue < 0) {\n      this.curValue = 0;\n    }\n  }\n}\n"
  },
  {
    "path": "packages/data-structures/src/Queue.ts",
    "content": "export class Queue<T> {\n  protected items: T[] = [];\n\n  // 入队操作\n  enqueue(element: T): void {\n    this.items.push(element);\n  }\n\n  // 出队操作\n  dequeue(): T | undefined {\n    if (this.isEmpty()) {\n      return undefined;\n    }\n    return this.items.shift();\n  }\n\n  public get arrayList(): T[] {\n    return this.items;\n  }\n\n  // 查看队首元素\n  peek(): T | undefined {\n    if (this.isEmpty()) {\n      return undefined;\n    }\n    return this.items[0];\n  }\n\n  tail(): T | undefined {\n    if (this.isEmpty()) {\n      return undefined;\n    }\n    return this.items[this.items.length - 1];\n  }\n\n  clear(): void {\n    this.items = [];\n  }\n\n  // 检查队列是否为空\n  isEmpty(): boolean {\n    return this.items.length === 0;\n  }\n\n  get length(): number {\n    return this.items.length;\n  }\n\n  // 获取队列的长度\n  size(): number {\n    return this.items.length;\n  }\n\n  toString(): string {\n    return this.items.toString();\n  }\n}\n"
  },
  {
    "path": "packages/data-structures/src/Stack.ts",
    "content": "/**\n * 栈数据结构\n */\nexport class Stack<T> {\n  private stack: T[] = [];\n\n  push(item: T): void {\n    this.stack.push(item);\n  }\n\n  pop(): T | undefined {\n    return this.stack.pop();\n  }\n\n  /**\n   * 获取栈顶元素，不弹出\n   * @returns\n   */\n  peek(): T | undefined {\n    return this.stack[this.stack.length - 1];\n  }\n\n  isEmpty(): boolean {\n    return this.stack.length === 0;\n  }\n\n  size(): number {\n    return this.stack.length;\n  }\n}\n"
  },
  {
    "path": "packages/data-structures/src/Vector.ts",
    "content": "import { serializable } from \"@graphif/serializer\";\n\nexport class Vector {\n  @serializable\n  x: number;\n  @serializable\n  y: number;\n\n  constructor(x: number, y: number) {\n    this.x = x;\n    this.y = y;\n  }\n\n  static getZero(): Vector {\n    return new Vector(0, 0);\n  }\n\n  isZero(): boolean {\n    return this.x === 0 && this.y === 0;\n  }\n\n  add(vector: Vector): Vector {\n    return new Vector(this.x + vector.x, this.y + vector.y);\n  }\n\n  subtract(vector: Vector): Vector {\n    return new Vector(this.x - vector.x, this.y - vector.y);\n  }\n\n  multiply(scalar: number): Vector {\n    return new Vector(this.x * scalar, this.y * scalar);\n  }\n\n  divide(scalar: number): Vector {\n    if (scalar === 0) {\n      return Vector.getZero();\n    }\n    return new Vector(this.x / scalar, this.y / scalar);\n  }\n\n  /**\n   * 获得向量的模长\n   * @returns\n   */\n  magnitude(): number {\n    return Math.sqrt(this.x ** 2 + this.y ** 2);\n  }\n\n  /**\n   * 获得向量的单位向量\n   * 如果向量的模长为0，则返回(0,0)\n   * @returns\n   */\n  normalize(): Vector {\n    const mag = this.magnitude();\n    const x = this.x / mag;\n    const y = this.y / mag;\n\n    if (Number.isNaN(x) || Number.isNaN(y)) {\n      return Vector.getZero();\n    }\n\n    return new Vector(x, y);\n  }\n\n  dot(vector: Vector): number {\n    return this.x * vector.x + this.y * vector.y;\n  }\n\n  /**\n   * 获得一个与该向量垂直的单位向量\n   */\n  getPerpendicular(): Vector {\n    return new Vector(-this.y, this.x).normalize();\n  }\n\n  /**\n   * 将自身向量按顺时针旋转一定角度，获得一个新的向量\n   * @param angle 单位：弧度\n   */\n  rotate(angle: number): Vector {\n    const x = this.x * Math.cos(angle) - this.y * Math.sin(angle);\n    const y = this.x * Math.sin(angle) + this.y * Math.cos(angle);\n    return new Vector(x, y);\n  }\n  /**\n   * 将自身向量按逆时针旋转一定角度，获得一个新的向量\n   * @param degrees 单位：度\n   */\n  rotateDegrees(degrees: number): Vector {\n    return this.rotate(degrees * (Math.PI / 180));\n  }\n\n  /**\n   * 计算自己向量与另一个向量之间的角度\n   * @param vector\n   * @returns 单位：弧度\n   */\n  angle(vector: Vector): number {\n    const dot = this.dot(vector);\n    const mag1 = this.magnitude();\n    const mag2 = vector.magnitude();\n\n    return Math.acos(dot / (mag1 * mag2));\n  }\n  /**\n   * 计算自己向量与另一个向量之间的夹角\n   * @param vector\n   * @returns 单位：度\n   */\n  angleTo(vector: Vector): number {\n    return (this.angle(vector) * 180) / Math.PI;\n  }\n  /**\n   * 计算自己向量与另一个向量之间的夹角，但带正负号\n   * 如果另一个向量相对自己是顺时针，则返回正值，否则返回负值\n   * @param vector\n   * @returns 单位：度\n   */\n  angleToSigned(vector: Vector): number {\n    const angle = this.angleTo(vector);\n    const cross = this.cross(vector);\n    if (cross > 0) {\n      return angle;\n    } else {\n      return -angle;\n    }\n  }\n\n  /**\n   * 从自己这个向量所指向的点到另一个向量所指向的点的距离\n   * @param vector\n   * @returns\n   */\n  distance(vector: Vector): number {\n    if (vector === null || vector === undefined) {\n      throw new Error(\"vector is null or undefined\");\n    }\n    const dx = this.x - vector.x;\n    const dy = this.y - vector.y;\n    return Math.sqrt(dx ** 2 + dy ** 2);\n  }\n\n  // 计算两个向量的叉积\n  cross(other: Vector): number {\n    return this.x * other.y - this.y * other.x;\n  }\n\n  /**\n   * 向量之间的分量分别相乘\n   * @param other\n   */\n  componentMultiply(other: Vector): Vector {\n    return new Vector(this.x * other.x, this.y * other.y);\n  }\n\n  /**\n   * 根据角度构造一个单位向量\n   * @param angle 单位：弧度\n   */\n  static fromAngle(angle: number): Vector {\n    const x = Math.cos(angle);\n    const y = Math.sin(angle);\n    return new Vector(x, y);\n  }\n\n  /**\n   * 根据角度构造一个单位向量\n   * @param degrees 单位：度\n   */\n  static fromDegrees(degrees: number): Vector {\n    return Vector.fromAngle(degrees * (Math.PI / 180));\n  }\n\n  /**\n   * 计算两个点之间的向量，让两个点构成一个向量\n   * @param p1 起始点\n   * @param p2 终止点\n   */\n  static fromTwoPoints(p1: Vector, p2: Vector): Vector {\n    const x = p2.x - p1.x;\n    const y = p2.y - p1.y;\n    return new Vector(x, y);\n  }\n\n  /**\n   * 将自己方向的单位向量分解成一堆向量，就像散弹分裂子弹一样\n   * 返回的都是单位向量\n   *\n   * 根据散弹数量和间隔角度，计算出每个散弹的方向单位向量\n   * 做法是先依次生成 bulletCount 个向量，每个间隔角度为 bulletIntervalDegrees，顺时针旋转\n   * 第一个生成的向量恰好就是攻击方向。\n   * 最后再整体 逆时针旋转总角度的一半，得到每个向量最终的方向向量\n   */\n  splitVector(splitCount: number, splitDegrees: number): Vector[] {\n    let vectors: Vector[] = [];\n    const selfNormalized = this.normalize();\n    for (let i = 0; i < splitCount; i++) {\n      vectors.push(selfNormalized.rotateDegrees(i * splitDegrees));\n    }\n    // 计算最终需要的总偏移角度\n    const totalOffsetDegrees = ((splitCount - 1) * splitDegrees) / 2;\n    vectors = vectors.map((d) => d.rotateDegrees(-totalOffsetDegrees));\n    return vectors;\n  }\n\n  /**\n   * 计算两个点的中心点\n   * @param p1\n   * @param p2\n   * @returns\n   */\n  static fromTwoPointsCenter(p1: Vector, p2: Vector): Vector {\n    const x = (p2.x + p1.x) / 2;\n    const y = (p2.y + p1.y) / 2;\n    return new Vector(x, y);\n  }\n\n  /**\n   * 获得两个点的中间连线一点，当rate为0时，返回p1，当rate为1时，返回p2\n   * @param p1\n   * @param p2\n   * @param rate\n   */\n  static fromTwoPointsRate(p1: Vector, p2: Vector, rate: number): Vector {\n    const x = p1.x + (p2.x - p1.x) * rate;\n    const y = p1.y + (p2.y - p1.y) * rate;\n    return new Vector(x, y);\n  }\n  /**\n   * 计算两个向量所代表位置的中点\n   * @param p1\n   * @param p2\n   */\n  static average(p1: Vector, p2: Vector): Vector {\n    return new Vector((p1.x + p2.x) / 2, (p1.y + p2.y) / 2);\n  }\n\n  /**\n   * 计算多个向量所代表位置的平均点\n   * @param vectors 向量数组\n   * @returns 平均位置向量\n   */\n  static averageMultiple(vectors: Vector[]): Vector {\n    if (vectors.length === 0) {\n      return Vector.getZero();\n    }\n\n    const sumX = vectors.reduce((sum, vec) => sum + vec.x, 0);\n    const sumY = vectors.reduce((sum, vec) => sum + vec.y, 0);\n\n    return new Vector(sumX / vectors.length, sumY / vectors.length);\n  }\n  /**\n   * 将自己这个向量转换成角度数字\n   * 例如当自己 x=1 y=1 时，返回 45\n   */\n  toDegrees(): number {\n    let result = (Math.atan2(this.y, this.x) * 180) / Math.PI;\n    if (result < 0) {\n      result += 360;\n    }\n    if (result >= 360) {\n      result -= 360;\n    }\n    return result;\n  }\n  clone(): Vector {\n    return new Vector(this.x, this.y);\n  }\n  equals(vector: Vector): boolean {\n    return this.x === vector.x && this.y === vector.y;\n  }\n  nearlyEqual(vector: Vector, radius: number): boolean {\n    return Math.abs(this.x - vector.x) <= radius && Math.abs(this.y - vector.y) <= radius;\n  }\n\n  toString(): string {\n    return `(${this.x.toFixed(2)}, ${this.y.toFixed(2)})`;\n  }\n\n  // /**\n  //  * 获取一个随机方向上的单位向量\n  //  */\n  // static randomUnit(): Vector {\n  //   const angle = uniform(0, 2 * Math.PI);\n  //   return Vector.fromAngle(angle);\n  // }\n\n  limitX(min: number, max: number): Vector {\n    return new Vector(Math.min(Math.max(this.x, min), max), this.y);\n  }\n\n  limitY(min: number, max: number): Vector {\n    return new Vector(this.x, Math.min(Math.max(this.y, min), max));\n  }\n\n  /**\n   * 创建x和y相同的向量 (其实就是正方形，从左上到右下)\n   */\n  static same(value: number): Vector {\n    return new Vector(value, value);\n  }\n  static fromTouch(touch: Touch): Vector {\n    return new Vector(touch.clientX, touch.clientY);\n  }\n\n  toInteger(): Vector {\n    return new Vector(Math.round(this.x), Math.round(this.y));\n  }\n\n  toArray(): [number, number] {\n    return [this.x, this.y];\n  }\n\n  __add__(other: Vector): Vector {\n    return this.add(other);\n  }\n}\n"
  },
  {
    "path": "packages/data-structures/src/index.ts",
    "content": "export * from \"./Cache\";\nexport * from \"./Color\";\nexport * from \"./LimitLengthQueue\";\nexport * from \"./MonoStack\";\nexport * from \"./ProgressNumber\";\nexport * from \"./Queue\";\nexport * from \"./Stack\";\nexport * from \"./Vector\";\n"
  },
  {
    "path": "packages/data-structures/tests/Cache.test.ts",
    "content": "import { describe, expect, it } from \"vitest\";\nimport { LruCache, MaxSizeCache } from \"../src/Cache\";\n\ndescribe(\"LruCache 缓存\", () => {\n  it(\"基本 set 和 get\", () => {\n    const cache = new LruCache<string, number>(2);\n    cache.set(\"a\", 1);\n    cache.set(\"b\", 2);\n    expect(cache.get(\"a\")).toBe(1);\n    expect(cache.get(\"b\")).toBe(2);\n  });\n\n  it(\"get 操作会更新项目为最近使用\", () => {\n    const cache = new LruCache<string, number>(2);\n    cache.set(\"a\", 1);\n    cache.set(\"b\", 2);\n    cache.get(\"a\"); // a 变为最近使用的\n    cache.set(\"c\", 3); // b 应该被淘汰\n    expect(cache.has(\"b\")).toBe(false);\n    expect(cache.has(\"a\")).toBe(true);\n    expect(cache.has(\"c\")).toBe(true);\n  });\n\n  it(\"set 操作会更新项目为最近使用\", () => {\n    const cache = new LruCache<string, number>(2);\n    cache.set(\"a\", 1);\n    cache.set(\"b\", 2);\n    cache.set(\"a\", 11); // a 变为最近使用的\n    cache.set(\"c\", 3); // b 应该被淘汰\n    expect(cache.has(\"b\")).toBe(false);\n    expect(cache.get(\"a\")).toBe(11);\n    expect(cache.has(\"c\")).toBe(true);\n  });\n\n  it(\"容量为0时，无法添加\", () => {\n    const cache = new LruCache<string, number>(0);\n    cache.set(\"a\", 1);\n    expect(cache.has(\"a\")).toBe(false);\n  });\n\n  it(\"超出容量时，淘汰最久未使用的\", () => {\n    const cache = new LruCache<string, number>(2);\n    cache.set(\"a\", 1);\n    cache.set(\"b\", 2);\n    cache.set(\"c\", 3); // a 应该被淘汰\n    expect(cache.has(\"a\")).toBe(false);\n    expect(cache.get(\"b\")).toBe(2);\n    expect(cache.get(\"c\")).toBe(3);\n  });\n});\n\ndescribe(\"MaxSizeCache 最大容量缓存\", () => {\n  it(\"达到最大容量时自动清空\", () => {\n    const cache = new MaxSizeCache<string, number>(3);\n    cache.set(\"a\", 1);\n    cache.set(\"b\", 2);\n    cache.set(\"c\", 3);\n    expect(cache.getCapacityStatus()).toEqual([3, 3]);\n    cache.set(\"d\", 4); // 缓存已满，再次添加将清空\n    expect(cache.getCapacityStatus()).toEqual([1, 3]);\n    expect(cache.get(\"a\")).toBeUndefined();\n    expect(cache.get(\"d\")).toBe(4);\n  });\n\n  it(\"get, has, clear 方法\", () => {\n    const cache = new MaxSizeCache<string, number>(2);\n    cache.set(\"a\", 1);\n    expect(cache.get(\"a\")).toBe(1);\n    expect(cache.has(\"a\")).toBe(true);\n    expect(cache.has(\"b\")).toBe(false);\n    cache.clear();\n    expect(cache.has(\"a\")).toBe(false);\n    expect(cache.getCapacityStatus()).toEqual([0, 2]);\n  });\n});\n"
  },
  {
    "path": "packages/data-structures/tests/Color.test.ts",
    "content": "import { describe, expect, it } from \"vitest\";\nimport { averageColors, Color, colorInvert, mixColors } from \"../src/Color\";\n\ndescribe(\"Color 颜色\", () => {\n  it(\"构造\", () => {\n    const color = new Color(255, 128, 0, 0.5);\n    expect(color.r).toBe(255);\n    expect(color.g).toBe(128);\n    expect(color.b).toBe(0);\n    expect(color.a).toBe(0.5);\n  });\n\n  it(\"转字符串\", () => {\n    const color = new Color(255, 0, 0);\n    expect(color.toString()).toBe(\"rgba(255, 0, 0, 1)\");\n  });\n\n  it(\"转十六进制字符串\", () => {\n    expect(new Color(255, 255, 255, 1).toHexString()).toBe(\"#ffffff01\");\n    expect(new Color(255, 0, 0, 1).toHexString()).toBe(\"#ff000001\");\n  });\n\n  it(\"转十六进制字符串（无透明度）\", () => {\n    const color = new Color(255, 0, 0, 0.5);\n    expect(color.toHexStringWithoutAlpha()).toBe(\"#ff0000\");\n  });\n\n  it(\"克隆\", () => {\n    const color1 = new Color(1, 2, 3, 0.5);\n    const color2 = color1.clone();\n    expect(color1).not.toBe(color2);\n    expect(color1.equals(color2)).toBe(true);\n  });\n\n  it(\"从十六进制创建\", () => {\n    const color1 = Color.fromHex(\"#ff0000\");\n    expect(color1.equals(new Color(255, 0, 0, 1))).toBe(true);\n    const color2 = Color.fromHex(\"#00ff0080\");\n    expect(color2.equals(new Color(0, 255, 0, 128))).toBe(true);\n  });\n\n  it(\"从CSS创建\", () => {\n    expect(Color.fromCss(\"#f00\").equals(Color.Red)).toBe(true);\n    expect(Color.fromCss(\"#ff0000\").equals(Color.Red)).toBe(true);\n    expect(Color.fromCss(\"rgb(0, 255, 0)\").equals(Color.Green)).toBe(true);\n    expect(Color.fromCss(\"rgba(0, 0, 255, 0.5)\").equals(new Color(0, 0, 255, 0.5))).toBe(true);\n    expect(Color.fromCss(\"transparent\").equals(Color.Transparent)).toBe(true);\n  });\n\n  it(\"相等判断\", () => {\n    const color1 = new Color(1, 2, 3, 0.5);\n    const color2 = new Color(1, 2, 3, 0.5);\n    const color3 = new Color(4, 5, 6, 1);\n    expect(color1.equals(color2)).toBe(true);\n    expect(color1.equals(color3)).toBe(false);\n  });\n\n  it(\"降低饱和度\", () => {\n    const color = new Color(255, 0, 0); // Red\n    const desaturated = color.desaturate(0.5);\n    expect(desaturated.r).toBeGreaterThan(160);\n    expect(desaturated.g).toBeCloseTo(desaturated.b);\n  });\n\n  it(\"改变色相\", () => {\n    const red = new Color(255, 0, 0);\n    const green = red.changeHue(120);\n    expect(green.r).toBeLessThan(10);\n    expect(green.g).toBeGreaterThan(245);\n    expect(green.b).toBeLessThan(10);\n  });\n});\n\ndescribe(\"颜色辅助函数\", () => {\n  it(\"颜色反转\", () => {\n    expect(colorInvert(Color.White).equals(Color.Black)).toBe(true);\n    expect(colorInvert(Color.Black).equals(Color.White)).toBe(true);\n  });\n\n  it(\"颜色混合\", () => {\n    const red = Color.Red;\n    const blue = Color.Blue;\n    const purple = mixColors(red, blue, 0.5);\n    expect(purple.r).toBe(128);\n    expect(purple.g).toBe(0);\n    expect(purple.b).toBe(128);\n  });\n\n  it(\"计算平均色\", () => {\n    const colors = [Color.Red, Color.Green, Color.Blue];\n    const avg = averageColors(colors);\n    expect(avg.r).toBe(85);\n    expect(avg.g).toBe(85);\n    expect(avg.b).toBe(85);\n  });\n});\n"
  },
  {
    "path": "packages/data-structures/tests/LimitLengthQueue.test.ts",
    "content": "import { describe, expect, it } from \"vitest\";\nimport { LimitLengthQueue } from \"../src/LimitLengthQueue\";\n\ndescribe(\"LimitLengthQueue 受限队列\", () => {\n  it(\"构造函数\", () => {\n    expect(() => new LimitLengthQueue<number>(0)).toThrow(\"限制长度必须是正整数\");\n    expect(() => new LimitLengthQueue<number>(-1)).toThrow(\"限制长度必须是正整数\");\n  });\n\n  it(\"入队 - 不超过限制\", () => {\n    const queue = new LimitLengthQueue<number>(3);\n    queue.enqueue(1);\n    queue.enqueue(2);\n    expect(queue.size()).toBe(2);\n    expect(queue.arrayList).toEqual([1, 2]);\n  });\n\n  it(\"入队 - 超过限制\", () => {\n    const queue = new LimitLengthQueue<number>(3);\n    queue.enqueue(1);\n    queue.enqueue(2);\n    queue.enqueue(3);\n    expect(queue.arrayList).toEqual([1, 2, 3]);\n    queue.enqueue(4); // 队首1出队，4入队\n    expect(queue.size()).toBe(3);\n    expect(queue.arrayList).toEqual([2, 3, 4]);\n    queue.enqueue(5);\n    expect(queue.arrayList).toEqual([3, 4, 5]);\n  });\n\n  it(\"获取多个队尾元素\", () => {\n    const queue = new LimitLengthQueue<number>(5);\n    queue.enqueue(1);\n    queue.enqueue(2);\n    queue.enqueue(3);\n    queue.enqueue(4);\n\n    expect(queue.multiGetTail(2)).toEqual([3, 4]);\n    expect(queue.multiGetTail(3)).toEqual([2, 3, 4]);\n    expect(queue.multiGetTail(4)).toEqual([1, 2, 3, 4]);\n    // 请求的数量超过队列现有元素，返回所有元素\n    expect(queue.multiGetTail(5)).toEqual([1, 2, 3, 4]);\n  });\n\n  it(\"在空队列上获取多个队尾元素\", () => {\n    const queue = new LimitLengthQueue<number>(5);\n    expect(queue.multiGetTail(3)).toEqual([]);\n  });\n});\n"
  },
  {
    "path": "packages/data-structures/tests/MonoStack.test.ts",
    "content": "import { describe, expect, it } from \"vitest\";\nimport { MonoStack } from \"../src/MonoStack\";\n\ndescribe(\"MonoStack 单调栈\", () => {\n  it(\"入栈 - 维护单调递增\", () => {\n    const stack = new MonoStack<string>();\n    stack.push(\"a\", 1);\n    stack.push(\"b\", 2);\n    expect(stack.length).toBe(2);\n    expect(stack.peek()).toBe(\"b\");\n\n    // 'c'的level比'b'小，所以'b'会出栈\n    stack.push(\"c\", 1);\n    expect(stack.length).toBe(1);\n    expect(stack.peek()).toBe(\"c\");\n    expect(stack.get(0)).toBe(\"c\");\n\n    // 'd'的level等于'c'，所以'c'会出栈\n    stack.push(\"d\", 1);\n    expect(stack.length).toBe(1);\n    expect(stack.peek()).toBe(\"d\");\n\n    // 'e'的level更高，直接入栈\n    stack.push(\"e\", 5);\n    expect(stack.length).toBe(2);\n    expect(stack.peek()).toBe(\"e\");\n  });\n\n  it(\"出栈\", () => {\n    const stack = new MonoStack<string>();\n    stack.push(\"a\", 1);\n    stack.push(\"b\", 2);\n    expect(stack.pop()).toBe(\"b\");\n    expect(stack.pop()).toBe(\"a\");\n    expect(stack.pop()).toBeUndefined();\n  });\n\n  it(\"查看栈顶\", () => {\n    const stack = new MonoStack<number>();\n    expect(stack.peek()).toBeUndefined();\n    stack.push(10, 1);\n    expect(stack.peek()).toBe(10);\n    stack.push(20, 2);\n    expect(stack.peek()).toBe(20);\n    expect(stack.length).toBe(2);\n  });\n\n  it(\"不安全的查看栈顶\", () => {\n    const stack = new MonoStack<number>();\n    stack.push(10, 1);\n    expect(stack.unsafePeek()).toBe(10);\n    expect(() => {\n      const emptyStack = new MonoStack();\n      emptyStack.unsafePeek();\n    }).toThrow();\n  });\n\n  it(\"通过索引获取元素\", () => {\n    const stack = new MonoStack<string>();\n    stack.push(\"a\", 1);\n    stack.push(\"b\", 2);\n    expect(stack.get(0)).toBe(\"a\");\n    expect(stack.get(1)).toBe(\"b\");\n    expect(stack.get(2)).toBeUndefined();\n  });\n\n  it(\"不安全地通过索引获取元素\", () => {\n    const stack = new MonoStack<string>();\n    stack.push(\"a\", 1);\n    expect(stack.unsafeGet(0)).toBe(\"a\");\n    expect(() => {\n      const emptyStack = new MonoStack();\n      emptyStack.unsafeGet(0);\n    }).toThrow();\n  });\n\n  it(\"检查是否为空和长度\", () => {\n    const stack = new MonoStack<number>();\n    expect(stack.isEmpty()).toBe(true);\n    expect(stack.length).toBe(0);\n    stack.push(1, 1);\n    expect(stack.isEmpty()).toBe(false);\n    expect(stack.length).toBe(1);\n  });\n});\n"
  },
  {
    "path": "packages/data-structures/tests/ProgressNumber.test.ts",
    "content": "import { describe, expect, it } from \"vitest\";\nimport { ProgressNumber } from \"../src/ProgressNumber\";\n\ndescribe(\"ProgressNumber 进度\", () => {\n  it(\"构造和基本属性\", () => {\n    const progress = new ProgressNumber(50, 100);\n    expect(progress.curValue).toBe(50);\n    expect(progress.maxValue).toBe(100);\n  });\n\n  it(\"计算百分比和比率\", () => {\n    const progress = new ProgressNumber(25, 100);\n    expect(progress.percentage).toBe(25);\n    expect(progress.rate).toBe(0.25);\n  });\n\n  it(\"判断是否已满或已空\", () => {\n    const progress1 = new ProgressNumber(100, 100);\n    expect(progress1.isFull).toBe(true);\n    const progress2 = new ProgressNumber(0, 100);\n    expect(progress2.isEmpty).toBe(true);\n    const progress3 = new ProgressNumber(50, 100);\n    expect(progress3.isFull).toBe(false);\n    expect(progress3.isEmpty).toBe(false);\n  });\n\n  it(\"设为已满或已空\", () => {\n    const progress = new ProgressNumber(50, 100);\n    progress.setFull();\n    expect(progress.curValue).toBe(100);\n    progress.setEmpty();\n    expect(progress.curValue).toBe(0);\n  });\n\n  it(\"增加值\", () => {\n    const progress = new ProgressNumber(50, 100);\n    progress.add(20);\n    expect(progress.curValue).toBe(70);\n    progress.add(40); // 超出最大值\n    expect(progress.curValue).toBe(100);\n  });\n\n  it(\"减少值\", () => {\n    const progress = new ProgressNumber(50, 100);\n    progress.subtract(20);\n    expect(progress.curValue).toBe(30);\n    progress.subtract(40); // 低于0\n    expect(progress.curValue).toBe(0);\n  });\n\n  it(\"克隆\", () => {\n    const progress1 = new ProgressNumber(50, 100);\n    const progress2 = progress1.clone();\n    expect(progress1).not.toBe(progress2);\n    expect(progress1.curValue).toBe(progress2.curValue);\n    expect(progress1.maxValue).toBe(progress2.maxValue);\n  });\n});\n"
  },
  {
    "path": "packages/data-structures/tests/Queue.test.ts",
    "content": "import { describe, expect, it } from \"vitest\";\nimport { Queue } from \"../src/Queue\";\n\ndescribe(\"Queue 队列\", () => {\n  it(\"入队\", () => {\n    const queue = new Queue<number>();\n    queue.enqueue(1);\n    queue.enqueue(2);\n    expect(queue.size()).toBe(2);\n  });\n\n  it(\"出队\", () => {\n    const queue = new Queue<number>();\n    queue.enqueue(1);\n    queue.enqueue(2);\n    expect(queue.dequeue()).toBe(1);\n    expect(queue.dequeue()).toBe(2);\n  });\n\n  it(\"查看队首\", () => {\n    const queue = new Queue<number>();\n    queue.enqueue(1);\n    queue.enqueue(2);\n    expect(queue.peek()).toBe(1);\n    expect(queue.size()).toBe(2);\n  });\n\n  it(\"查看队尾\", () => {\n    const queue = new Queue<number>();\n    queue.enqueue(1);\n    queue.enqueue(2);\n    expect(queue.tail()).toBe(2);\n    expect(queue.size()).toBe(2);\n  });\n\n  it(\"检查是否为空\", () => {\n    const queue = new Queue<number>();\n    expect(queue.isEmpty()).toBe(true);\n    queue.enqueue(1);\n    expect(queue.isEmpty()).toBe(false);\n  });\n\n  it(\"获取队列大小\", () => {\n    const queue = new Queue<number>();\n    queue.enqueue(1);\n    queue.enqueue(2);\n    queue.enqueue(3);\n    expect(queue.size()).toBe(3);\n    expect(queue.length).toBe(3);\n  });\n\n  it(\"从空队列中出队\", () => {\n    const queue = new Queue<number>();\n    expect(queue.dequeue()).toBeUndefined();\n  });\n\n  it(\"查看空队列的队首\", () => {\n    const queue = new Queue<number>();\n    expect(queue.peek()).toBeUndefined();\n  });\n\n  it(\"查看空队列的队尾\", () => {\n    const queue = new Queue<number>();\n    expect(queue.tail()).toBeUndefined();\n  });\n\n  it(\"清空队列\", () => {\n    const queue = new Queue<number>();\n    queue.enqueue(1);\n    queue.enqueue(2);\n    queue.clear();\n    expect(queue.isEmpty()).toBe(true);\n    expect(queue.size()).toBe(0);\n  });\n});\n"
  },
  {
    "path": "packages/data-structures/tests/Stack.test.ts",
    "content": "import { describe, expect, it } from \"vitest\";\nimport { Stack } from \"../src/Stack\";\n\ndescribe(\"Stack 栈\", () => {\n  it(\"入栈\", () => {\n    const stack = new Stack<number>();\n    stack.push(1);\n    stack.push(2);\n    expect(stack.size()).toBe(2);\n  });\n\n  it(\"出栈\", () => {\n    const stack = new Stack<number>();\n    stack.push(1);\n    stack.push(2);\n    expect(stack.pop()).toBe(2);\n    expect(stack.pop()).toBe(1);\n  });\n\n  it(\"查看栈顶\", () => {\n    const stack = new Stack<number>();\n    stack.push(1);\n    stack.push(2);\n    expect(stack.peek()).toBe(2);\n    expect(stack.size()).toBe(2);\n  });\n\n  it(\"检查是否为空\", () => {\n    const stack = new Stack<number>();\n    expect(stack.isEmpty()).toBe(true);\n    stack.push(1);\n    expect(stack.isEmpty()).toBe(false);\n  });\n\n  it(\"获取栈大小\", () => {\n    const stack = new Stack<number>();\n    stack.push(1);\n    stack.push(2);\n    stack.push(3);\n    expect(stack.size()).toBe(3);\n  });\n\n  it(\"从空栈中出栈\", () => {\n    const stack = new Stack<number>();\n    expect(stack.pop()).toBeUndefined();\n  });\n\n  it(\"查看空栈栈顶\", () => {\n    const stack = new Stack<number>();\n    expect(stack.peek()).toBeUndefined();\n  });\n});\n"
  },
  {
    "path": "packages/data-structures/tests/Vector.test.ts",
    "content": "import { Vector } from \"../src/Vector\";\nimport { describe, it, expect } from \"vitest\";\n\ndescribe(\"Vector 向量\", () => {\n  it(\"构造和静态创建\", () => {\n    const v = new Vector(3, 4);\n    expect(v.x).toBe(3);\n    expect(v.y).toBe(4);\n\n    const zero = Vector.getZero();\n    expect(zero.x).toBe(0);\n    expect(zero.y).toBe(0);\n\n    const fromAngle = Vector.fromAngle(Math.PI / 2); // 90 degrees\n    expect(fromAngle.x).toBeCloseTo(0);\n    expect(fromAngle.y).toBeCloseTo(1);\n\n    const fromDeg = Vector.fromDegrees(180);\n    expect(fromDeg.x).toBeCloseTo(-1);\n    expect(fromDeg.y).toBeCloseTo(0);\n\n    const p1 = new Vector(1, 1);\n    const p2 = new Vector(4, 5);\n    const fromPoints = Vector.fromTwoPoints(p1, p2);\n    expect(fromPoints.x).toBe(3);\n    expect(fromPoints.y).toBe(4);\n  });\n\n  it(\"基本运算\", () => {\n    const v1 = new Vector(1, 2);\n    const v2 = new Vector(3, 4);\n\n    const add = v1.add(v2);\n    expect(add.x).toBe(4);\n    expect(add.y).toBe(6);\n\n    const sub = v1.subtract(v2);\n    expect(sub.x).toBe(-2);\n    expect(sub.y).toBe(-2);\n\n    const mul = v1.multiply(3);\n    expect(mul.x).toBe(3);\n    expect(mul.y).toBe(6);\n\n    const div = v2.divide(2);\n    expect(div.x).toBe(1.5);\n    expect(div.y).toBe(2);\n\n    const divByZero = v2.divide(0);\n    expect(divByZero.isZero()).toBe(true);\n  });\n\n  it(\"模长和归一化\", () => {\n    const v = new Vector(3, 4);\n    expect(v.magnitude()).toBe(5);\n\n    const normalized = v.normalize();\n    expect(normalized.x).toBe(0.6);\n    expect(normalized.y).toBe(0.8);\n    expect(normalized.magnitude()).toBeCloseTo(1);\n\n    const zero = Vector.getZero();\n    expect(zero.normalize().isZero()).toBe(true);\n  });\n\n  it(\"点积和叉积\", () => {\n    const v1 = new Vector(1, 2);\n    const v2 = new Vector(3, 4);\n    expect(v1.dot(v2)).toBe(11); // 1*3 + 2*4\n    expect(v1.cross(v2)).toBe(-2); // 1*4 - 2*3\n  });\n\n  it(\"旋转\", () => {\n    const v = new Vector(1, 0);\n    const rotated = v.rotate(Math.PI / 2); // 90 degrees\n    expect(rotated.x).toBeCloseTo(0);\n    expect(rotated.y).toBeCloseTo(1);\n\n    const rotatedDeg = v.rotateDegrees(-90);\n    expect(rotatedDeg.x).toBeCloseTo(0);\n    expect(rotatedDeg.y).toBeCloseTo(-1);\n  });\n\n  it(\"角度计算\", () => {\n    const v1 = new Vector(1, 0);\n    const v2 = new Vector(0, 1);\n    expect(v1.angle(v2)).toBeCloseTo(Math.PI / 2);\n    expect(v1.angleTo(v2)).toBeCloseTo(90);\n    expect(v1.angleToSigned(v2)).toBeCloseTo(90);\n    expect(v2.angleToSigned(v1)).toBeCloseTo(-90);\n    expect(new Vector(1, 1).toDegrees()).toBeCloseTo(45);\n  });\n\n  it(\"距离计算\", () => {\n    const v1 = new Vector(1, 1);\n    const v2 = new Vector(4, 5);\n    expect(v1.distance(v2)).toBe(5);\n  });\n\n  it(\"比较\", () => {\n    const v1 = new Vector(1, 2);\n    const v2 = new Vector(1, 2);\n    const v3 = new Vector(2, 3);\n    expect(v1.equals(v2)).toBe(true);\n    expect(v1.equals(v3)).toBe(false);\n    expect(v1.nearlyEqual(new Vector(1.01, 1.99), 0.02)).toBe(true);\n    expect(Vector.getZero().isZero()).toBe(true);\n  });\n\n  it(\"克隆\", () => {\n    const v1 = new Vector(1, 2);\n    const v2 = v1.clone();\n    expect(v1).not.toBe(v2);\n    expect(v1.equals(v2)).toBe(true);\n  });\n\n  it(\"多个向量的平均值\", () => {\n    const v1 = new Vector(1, 2);\n    const v2 = new Vector(3, 4);\n    const v3 = new Vector(5, 6);\n\n    // 测试多个向量的平均值\n    const avg = Vector.averageMultiple([v1, v2, v3]);\n    expect(avg.x).toBe(3);\n    expect(avg.y).toBe(4);\n\n    // 测试空数组的情况（应该返回零向量）\n    const avgEmpty = Vector.averageMultiple([]);\n    expect(avgEmpty.isZero()).toBe(true);\n\n    // 测试只有一个向量的情况\n    const avgSingle = Vector.averageMultiple([v1]);\n    expect(avgSingle.equals(v1)).toBe(true);\n  });\n});\n"
  },
  {
    "path": "packages/data-structures/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"useDefineForClassFields\": true,\n    \"module\": \"ESNext\",\n    \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true,\n    \"experimentalDecorators\": true,\n    \"emitDecoratorMetadata\": true\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "packages/data-structures/tsdown.config.ts",
    "content": "import originalClassName from \"unplugin-original-class-name/rollup\";\nimport { defineConfig } from \"tsdown\";\n\nexport default defineConfig({\n  plugins: [originalClassName()],\n});\n"
  },
  {
    "path": "packages/serializer/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "packages/serializer/.npmignore",
    "content": "src\n"
  },
  {
    "path": "packages/serializer/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 Graphif\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "packages/serializer/README.md",
    "content": "# @graphif/serializer\n\n[Documentation](https://project-graph.top/docs/serializer)\n"
  },
  {
    "path": "packages/serializer/package.json",
    "content": "{\n  \"name\": \"@graphif/serializer\",\n  \"description\": \"Serialize instances to objects\",\n  \"version\": \"1.0.0\",\n  \"type\": \"module\",\n  \"license\": \"MIT\",\n  \"files\": [\n    \"dist\"\n  ],\n  \"module\": \"dist/index.js\",\n  \"types\": \"dist/index.d.ts\",\n  \"exports\": {\n    \".\": {\n      \"import\": \"./dist/index.js\",\n      \"types\": \"./dist/index.d.ts\"\n    }\n  },\n  \"scripts\": {\n    \"build\": \"tsdown\",\n    \"type-check\": \"tsc\"\n  },\n  \"devDependencies\": {\n    \"@types/js-md5\": \"^0.8.0\",\n    \"tsdown\": \"catalog:\",\n    \"typescript\": \"catalog:\"\n  },\n  \"dependencies\": {\n    \"@msgpack/msgpack\": \"^3.1.2\",\n    \"js-md5\": \"^0.8.3\",\n    \"reflect-metadata\": \"^0.2.2\"\n  }\n}\n"
  },
  {
    "path": "packages/serializer/src/index.ts",
    "content": "import \"reflect-metadata\";\n\nlet getOriginalNameOf: (class_: { [x: string | number | symbol]: any; new (...args: any[]): any }) => string = (\n  class_,\n) => class_.name;\nexport function configureSerializer(\n  getOriginalNameOfFn: (class_: { [x: string | number | symbol]: any; new (...args: any[]): any }) => string,\n) {\n  getOriginalNameOf = getOriginalNameOfFn;\n}\n\n/**\n * 序列化装饰器\n */\nconst serializableSymbol = Symbol(\"serializable\");\nconst lastSerializableIndexSymbol = Symbol(\"lastSerializableIndex\");\nexport const serializable = (target: any, key: string) => {\n  if (!Reflect.hasMetadata(lastSerializableIndexSymbol, target)) {\n    Reflect.defineMetadata(lastSerializableIndexSymbol, 0, target);\n  }\n  Reflect.defineMetadata(serializableSymbol, Reflect.getMetadata(lastSerializableIndexSymbol, target), target, key);\n  Reflect.defineMetadata(\n    lastSerializableIndexSymbol,\n    Reflect.getMetadata(lastSerializableIndexSymbol, target) + 1,\n    target,\n  );\n  classes.set(getOriginalNameOf(target.constructor), target.constructor);\n};\n\nconst passExtraAtArg1Symbol = Symbol(\"passExtraAtArg1\");\nexport const passExtraAtArg1 = Reflect.metadata(passExtraAtArg1Symbol, true);\n\nconst passExtraAtLastArgSymbol = Symbol(\"passExtraAtLastArg\");\nexport const passExtraAtLastArg = Reflect.metadata(passExtraAtLastArgSymbol, true);\n\nconst passObjectSymbol = Symbol(\"passObject\");\nexport const passObject = Reflect.metadata(passObjectSymbol, true);\n\nconst idSymbol = Symbol(\"id\");\nexport const id = Reflect.metadata(idSymbol, true);\n\nconst classes: Map<string, any> = new Map();\n\n/**\n * 将任意类型对象转换为 序列化形式，不包含函数\n */\nexport function serialize(originalObj: any): any {\n  const id2path = new Map<string, string>();\n  function _serialize(obj: any, path: string): any {\n    if (obj instanceof Array) {\n      return obj.map((v, i) => _serialize(v, `${path}/${i}`));\n    } else if (typeof obj === \"string\") {\n      return obj;\n    } else if (typeof obj === \"number\") {\n      // 判断是否是整数\n      if (obj % 1 === 0) {\n        return obj;\n      } else {\n        // 保留2位小数\n        return parseFloat(obj.toFixed(2));\n      }\n    } else if (typeof obj === \"boolean\") {\n      return obj;\n    } else if (obj === null) {\n      return null;\n    } else if (typeof obj === \"object\") {\n      const className = getOriginalNameOf(obj.constructor);\n      if (!className) {\n        throw TypeError(\"[Serializer] Cannot find class name of\", obj);\n      }\n      if (className === \"Object\") {\n        return obj;\n      }\n      const result: any = {\n        _: className,\n      };\n      let id: any;\n      for (const key in obj) {\n        if (!Reflect.hasMetadata(serializableSymbol, obj, key)) continue;\n        if (Reflect.hasMetadata(idSymbol, obj, key)) {\n          id = obj[key];\n        }\n        result[key] = _serialize(obj[key], `${path}/${key}`);\n      }\n      if (id) {\n        if (id2path.has(id)) {\n          // 如果已经有了id，直接使用之前的路径\n          return { $: id2path.get(id) };\n        } else {\n          // 如果没有id，记录路径\n          id2path.set(id, path);\n        }\n      }\n      return result;\n    } else if (typeof obj === \"undefined\") {\n      return undefined;\n    } else {\n      throw TypeError(`[Serializer] Unsupported value type ${obj}`);\n    }\n  }\n  return _serialize(originalObj, \"\");\n}\n\nexport function deserialize(originalJson: any, extra?: any): any {\n  const cache = new WeakMap<object, any>();\n  function _deserialize(json: any, extra?: any): any {\n    if (json instanceof Array) {\n      if (cache.has(json)) {\n        return cache.get(json);\n      }\n      // 就地转换并缓存，保证同一数组节点被反序列化为同一实例\n      cache.set(json, json);\n      for (let i = 0; i < json.length; i++) {\n        json[i] = _deserialize(json[i], extra);\n      }\n      return json;\n    }\n    if (!isSerializedObject(json)) {\n      return json;\n    }\n    if (cache.has(json)) {\n      return cache.get(json);\n    }\n    const className = json._;\n    const class_ = classes.get(className);\n    if (!class_) {\n      throw TypeError(`[Serializer] Cannot find class ${class_} of ${JSON.stringify(json)}`);\n    }\n    // 先把json中有_的值反序列化\n    for (const key in json) {\n      if (key === \"_\") continue;\n      const value = json[key];\n      if (isSerializedObject(value) || value instanceof Array) {\n        json[key] = _deserialize(value, extra);\n      }\n    }\n    const args = [];\n    if (Reflect.hasMetadata(passExtraAtArg1Symbol, class_)) {\n      args.push(extra);\n    }\n    if (Reflect.hasMetadata(passObjectSymbol, class_)) {\n      args.push(json);\n    } else {\n      for (const key in json) {\n        if (key === \"_\") continue;\n        args.push(json[key]);\n      }\n    }\n    if (Reflect.hasMetadata(passExtraAtLastArgSymbol, class_)) {\n      args.push(extra);\n    }\n    const instance = new class_(...args);\n    cache.set(json, instance);\n    return instance;\n  }\n  return _deserialize(replaceRef(originalJson), extra);\n}\n\nfunction isSerializedObject(obj: any): boolean {\n  return typeof obj === \"object\" && obj !== null && \"_\" in obj;\n}\nfunction getByPath(obj: any, path: string): any {\n  const segments = path.split(\"/\").filter((s) => s !== \"\");\n  let result = obj;\n  for (const segment of segments) {\n    if (typeof result !== \"object\" || result === null) {\n      throw TypeError(`[Serializer] Cannot find object at path ${path}`);\n    }\n    result = result[segment];\n  }\n  return result;\n}\n/**\n * 将$替换为实际值\n * @param obj 要替换的对象\n * @param refPathObj obj参数中$的路径所在的对象\n */\nfunction replaceRef(obj: any, refPathObj: any = obj): any {\n  if (obj instanceof Array) {\n    return obj.map((v) => replaceRef(v, refPathObj));\n  }\n  if (typeof obj === \"object\" && obj !== null) {\n    if (\"$\" in obj) {\n      const path = obj.$ as string;\n      return getByPath(refPathObj, path);\n    }\n    for (const key in obj) {\n      obj[key] = replaceRef(obj[key], refPathObj);\n    }\n  }\n  return obj;\n}\n"
  },
  {
    "path": "packages/serializer/tests/index.test.ts",
    "content": "import { describe, expect, test } from \"vitest\";\nimport { deserialize, serializable, serialize } from \"../src\";\n\ndescribe(\"对象序列化\", () => {\n  class A {\n    @serializable\n    public propString: string;\n    @serializable\n    public propNumber: number;\n    @serializable\n    public propBoolean: boolean;\n    @serializable\n    public propArray: string[];\n    @serializable\n    public nestedObject: { name: string; age: number };\n    @serializable\n    public nestedInstance?: A;\n\n    constructor(\n      propString: string,\n      propNumber: number,\n      propBoolean: boolean,\n      propArray: string[],\n      nestedObject: { name: string; age: number },\n      nestedInstance?: A,\n    ) {\n      this.propString = propString;\n      this.propNumber = propNumber;\n      this.propBoolean = propBoolean;\n      this.propArray = propArray;\n      this.nestedObject = nestedObject;\n      this.nestedInstance = nestedInstance;\n    }\n  }\n  const instance = new A(\n    \"@graphif/serializer\",\n    114514,\n    false,\n    [\"hello\", \"world\"],\n    { name: \"Traveller\", age: 1000 },\n    new A(\"nested\", 123, true, [\"nested\"], { name: \"Trailblazer\", age: 2000 }),\n  );\n  const serialized = {\n    _: \"A\",\n    propString: \"@graphif/serializer\",\n    propNumber: 114514,\n    propBoolean: false,\n    propArray: [\"hello\", \"world\"],\n    nestedObject: { name: \"Traveller\", age: 1000 },\n    nestedInstance: {\n      _: \"A\",\n      propString: \"nested\",\n      propNumber: 123,\n      propBoolean: true,\n      propArray: [\"nested\"],\n      nestedObject: { name: \"Trailblazer\", age: 2000 },\n    },\n  };\n\n  test(\"序列化对象\", () => {\n    const serialized = serialize(instance);\n    expect(serialized._).toBe(\"A\");\n    expect(serialized.nestedInstance._).toBe(\"A\");\n    expect(serialized.propString).toBe(\"@graphif/serializer\");\n  });\n  test(\"反序列化对象\", () => {\n    const deserialized = deserialize(serialized);\n    expect(deserialized).toBeInstanceOf(A);\n    expect(deserialized.nestedInstance).toBeInstanceOf(A);\n    expect(deserialized.propString).toBe(\"@graphif/serializer\");\n  });\n  // 指针引用同一性的反序列化测试（数组与对象容器）\n  class B {\n    @serializable\n    public items: number[];\n    @serializable\n    public meta: { tag: string; count: number };\n    @serializable\n    public mirrorItems?: number[];\n    @serializable\n    public mirrorMeta?: { tag: string; count: number };\n\n    constructor(\n      items: number[],\n      meta: { tag: string; count: number },\n      mirrorItems?: number[],\n      mirrorMeta?: { tag: string; count: number },\n    ) {\n      this.items = items;\n      this.meta = meta;\n      this.mirrorItems = mirrorItems;\n      this.mirrorMeta = mirrorMeta;\n    }\n  }\n\n  test(\"反序列化时，数组与对象容器通过指针保持同一实例\", () => {\n    const json = {\n      _: \"B\",\n      items: [1, 2, 3],\n      meta: { tag: \"t\", count: 2 },\n      mirrorItems: { $: \"/items\" },\n      mirrorMeta: { $: \"/meta\" },\n    };\n    const b = deserialize(json) as B;\n\n    expect(b).toBeInstanceOf(B);\n    expect(Array.isArray(b.items)).toBe(true);\n    expect(Array.isArray(b.mirrorItems)).toBe(true);\n    expect(b.items).toBe(b.mirrorItems);\n\n    expect(typeof b.meta).toBe(\"object\");\n    expect(b.meta).toBe(b.mirrorMeta);\n  });\n});\n\ndescribe(\"数组序列化\", () => {\n  const arr = [1, \"2\", true, { name: \"Traveller\", age: 1000 }, [1, 2, 3]];\n  const serialized = [1, \"2\", true, { name: \"Traveller\", age: 1000 }, [1, 2, 3]];\n\n  test(\"序列化数组\", () => {\n    const serialized = serialize(arr);\n    expect(serialized).toEqual(serialized);\n  });\n  test(\"反序列化数组\", () => {\n    const deserialized = deserialize(serialized);\n    expect(deserialized).toEqual(arr);\n  });\n});\n"
  },
  {
    "path": "packages/serializer/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"useDefineForClassFields\": true,\n    \"module\": \"ESNext\",\n    \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"erasableSyntaxOnly\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true,\n\n    \"experimentalDecorators\": true,\n    \"emitDecoratorMetadata\": true\n  },\n  \"include\": [\"src\", \"tests\"]\n}\n"
  },
  {
    "path": "packages/shapes/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "packages/shapes/.npmignore",
    "content": "src\n"
  },
  {
    "path": "packages/shapes/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 Graphif\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "packages/shapes/package.json",
    "content": "{\n  \"name\": \"@graphif/shapes\",\n  \"description\": \"Serializable shapes\",\n  \"version\": \"1.0.0\",\n  \"type\": \"module\",\n  \"license\": \"MIT\",\n  \"files\": [\n    \"dist\"\n  ],\n  \"module\": \"dist/index.js\",\n  \"types\": \"dist/index.d.ts\",\n  \"exports\": {\n    \".\": {\n      \"import\": \"./dist/index.js\",\n      \"types\": \"./dist/index.d.ts\"\n    }\n  },\n  \"scripts\": {\n    \"build\": \"tsdown\",\n    \"type-check\": \"tsc\"\n  },\n  \"devDependencies\": {\n    \"tsdown\": \"catalog:\",\n    \"typescript\": \"catalog:\"\n  },\n  \"dependencies\": {\n    \"@graphif/data-structures\": \"workspace:*\",\n    \"@graphif/serializer\": \"workspace:*\",\n    \"unplugin-original-class-name\": \"^1.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/shapes/src/Circle.ts",
    "content": "import { Vector } from \"@graphif/data-structures\";\nimport { Line } from \"./Line\";\nimport { Rectangle } from \"./Rectangle\";\nimport { Shape } from \"./Shape\";\n\n/**\n * 圆形，\n * 注意：坐标点location属性是圆心属性\n */\nexport class Circle extends Shape {\n  constructor(\n    public location: Vector,\n    public radius: number,\n  ) {\n    super();\n  }\n\n  isPointIn(point: Vector) {\n    const distance = this.location.distance(point);\n    return distance <= this.radius;\n  }\n\n  isCollideWithRectangle(rectangle: Rectangle): boolean {\n    return rectangle.isPointIn(this.location);\n  }\n\n  isCollideWithLine(line: Line): boolean {\n    return line.isIntersectingWithCircle(this);\n  }\n\n  getRectangle(): Rectangle {\n    const left = this.location.x - this.radius;\n    const top = this.location.y - this.radius;\n    return new Rectangle(new Vector(left, top), Vector.same(this.radius * 2));\n  }\n\n  toString(): string {\n    return `Circle(${this.location.toString()}, ${this.radius})`;\n  }\n}\n"
  },
  {
    "path": "packages/shapes/src/CubicCatmullRomSpline.ts",
    "content": "import { Vector } from \"@graphif/data-structures\";\nimport { Line } from \"./Line\";\nimport { Rectangle } from \"./Rectangle\";\nimport { Shape } from \"./Shape\";\n\n/**\n * CR曲线形状\n */\nexport class CubicCatmullRomSpline extends Shape {\n  public controlPoints: Vector[];\n  public alpha: number;\n  public tension: number;\n\n  constructor(controlPoints: Vector[], alpha: number = 0.5, tension: number = 0) {\n    super();\n    if (controlPoints.length < 4) {\n      throw new Error(\"There must be at least 4 control points\");\n    }\n    this.controlPoints = controlPoints;\n    this.alpha = alpha;\n    this.tension = tension;\n  }\n\n  computePath(): Vector[] {\n    const result = [this.controlPoints[1]];\n    for (const funcs of this.computeFunction()) {\n      const s = romberg((t) => funcs.derivative(t).magnitude(), 0.5);\n      const maxLength = 5; //每一小段的最大长度\n      let num = 1;\n      for (; s / num > maxLength; num++);\n      // console.log(\"Curve segments: \" + num);\n      for (let i = 0, t0 = 0; i < num - 1; i++) {\n        for (let left = t0, right = 1; ; ) {\n          const t = left + (right - left) / 2;\n          const point = funcs.equation(t);\n          const requiredError = 0.25;\n          const dist = point.distance(result[result.length - 1]);\n          // const dist =\n          //   (t - t0) * NumericalIntegration.romberg((x) => funcs.derivative(x * (t - t0) + t0).magnitude(), 0.1);\n          const diff = dist - s / num;\n          // console.log(\"segment \" + (i + 1) + \"/\" + num + \" diff: \" + diff);\n          if (Math.abs(diff) < requiredError) {\n            result.push(point);\n            t0 = t;\n            break;\n          } else if (diff < 0) {\n            left = t;\n          } else {\n            right = t;\n          }\n          // console.log(\"segment \" + (i + 1) + \"/\" + num + \" t: \" + t);\n        }\n        // console.log(\"segment \" + (i + 1) + \" compelete\");\n      }\n      result.push(funcs.equation(1));\n    }\n    return result;\n  }\n\n  private computeLines(): Line[] {\n    const points = this.computePath();\n    const result = [];\n    for (let i = 1; i < points.length; i++) {\n      result.push(new Line(points[i - 1], points[i]));\n    }\n    return result;\n  }\n\n  isPointIn(point: Vector): boolean {\n    for (const line of this.computeLines()) {\n      if (line.isPointIn(point)) {\n        return true;\n      }\n    }\n    return false;\n  }\n  isCollideWithRectangle(rectangle: Rectangle): boolean {\n    for (const line of this.computeLines()) {\n      if (line.isCollideWithRectangle(rectangle)) {\n        return true;\n      }\n    }\n    return false;\n  }\n  isCollideWithLine(line: Line): boolean {\n    for (const l of this.computeLines()) {\n      if (l.isCollideWithLine(line)) {\n        return true;\n      }\n    }\n    return false;\n  }\n  getRectangle(): Rectangle {\n    const min = this.controlPoints[1].clone();\n    const max = min.clone();\n    for (const p of this.computePath()) {\n      min.x = Math.min(min.x, p.x);\n      min.y = Math.min(min.y, p.y);\n      max.x = Math.max(max.x, p.x);\n      max.y = Math.max(max.y, p.y);\n    }\n    return new Rectangle(min, max.subtract(min));\n  }\n\n  /**\n   * 计算控制点所构成的曲线的参数方程和导数\n   */\n  public computeFunction(): Array<{\n    equation: (t: number) => Vector;\n    derivative: (t: number) => Vector;\n  }> {\n    const result = [];\n    for (let i = 0; i + 4 <= this.controlPoints.length; i++) {\n      const p0 = this.controlPoints[i];\n      const p1 = this.controlPoints[i + 1];\n      const p2 = this.controlPoints[i + 2];\n      const p3 = this.controlPoints[i + 3];\n\n      const t01 = Math.pow(p0.distance(p1), this.alpha);\n      const t12 = Math.pow(p1.distance(p2), this.alpha);\n      const t23 = Math.pow(p2.distance(p3), this.alpha);\n\n      const m1 = p2\n        .subtract(p1)\n        .add(\n          p1\n            .subtract(p0)\n            .divide(t01)\n            .subtract(p2.subtract(p0).divide(t01 + t12))\n            .multiply(t12),\n        )\n        .multiply(1 - this.tension);\n      const m2 = p2\n        .subtract(p1)\n        .add(\n          p3\n            .subtract(p2)\n            .divide(t23)\n            .subtract(p3.subtract(p1).divide(t12 + t23))\n            .multiply(t12),\n        )\n        .multiply(1 - this.tension);\n\n      const a = p1.subtract(p2).multiply(2).add(m1).add(m2);\n      const b = p1.subtract(p2).multiply(-3).subtract(m1).subtract(m1).subtract(m2);\n      const c = m1;\n      const d = p1;\n      result.push({\n        equation: (t: number) =>\n          a\n            .multiply(t * t * t)\n            .add(b.multiply(t * t))\n            .add(c.multiply(t))\n            .add(d),\n        derivative: (t: number) =>\n          a\n            .multiply(3 * t * t)\n            .add(b.multiply(2 * t))\n            .add(c),\n      });\n    }\n    return result;\n  }\n}\n\n/**\n * 使用romberg算法对函数func在[0, 1]区间上进行数值积分，确保绝对误差小于error\n * 参考网址 https://math.fandom.com/zh/wiki/Romberg_%E7%AE%97%E6%B3%95?variant=zh-sg\n * @param func 被积函数\n * @param error 误差\n */\nexport function romberg(func: (x: number) => number, error: number): number {\n  const t: number[][] = [[(func(0) + func(1)) / 2]];\n  function tJK(t: number[][], j: number, k: number): number {\n    return (Math.pow(4, j) * t[j - 1][k + 1] - t[j - 1][k]) / (Math.pow(4, j) - 1);\n  }\n  function extendsTj(t: number[][], j: number): number {\n    if (j == 0) {\n      const k = t[0].length;\n      const twoPowK = Math.pow(2, k);\n      let sum = 0;\n      for (let j = 1; j <= Math.pow(2, k - 1); j++) {\n        sum += func((2 * j - 1) / twoPowK);\n      }\n      sum = sum / twoPowK + t[0][k - 1] / 2;\n      t[0].push(sum);\n      return sum;\n    } else {\n      const val = tJK(t, j, t[j].length);\n      t[j].push(val);\n      return val;\n    }\n  }\n  extendsTj(t, 0);\n  extendsTj(t, 0);\n  for (let j = 1; ; j++) {\n    t.push([]);\n    for (let i = 0; i < j; i++) {\n      extendsTj(t, i);\n    }\n    extendsTj(t, j);\n    const tj1 = extendsTj(t, j);\n    const tj2 = extendsTj(t, j);\n    if (Math.abs(tj2 - tj1) < error) {\n      return tj1;\n    }\n  }\n}\n"
  },
  {
    "path": "packages/shapes/src/Curve.ts",
    "content": "import { Vector } from \"@graphif/data-structures\";\nimport { Line } from \"./Line\";\nimport { Rectangle } from \"./Rectangle\";\nimport { Shape } from \"./Shape\";\n\n/**\n * 贝塞尔曲线\n */\nexport class CubicBezierCurve extends Shape {\n  constructor(\n    public start: Vector,\n    public ctrlPt1: Vector,\n    public ctrlPt2: Vector,\n    public end: Vector,\n  ) {\n    super();\n  }\n\n  toString(): string {\n    return `CubicBezierCurve(start:${this.start}, ctrlPt1:${this.ctrlPt1}, ctrlPt2:${this.ctrlPt2}, end:${this.end})`;\n  }\n\n  /**\n   * 根据参数t（范围[0, 1]）获取贝塞尔曲线上的点\n   * @param t\n   * @returns\n   */\n  getPointByT(t: number): Vector {\n    return this.start\n      .multiply(Math.pow(1 - t, 3))\n      .add(this.ctrlPt1.multiply(3 * t * Math.pow(1 - t, 2)))\n      .add(this.ctrlPt2.multiply(3 * Math.pow(t, 2) * (1 - t)).add(this.end.multiply(Math.pow(t, 3))));\n  }\n\n  // /**\n  //  * 根据参数t（范围[0, 1]）获取贝塞尔曲线上的导数\n  //  * @param t\n  //  * @returns\n  //  */\n  // private derivative(t: number): Vector {\n  //   return this.start.multiply(-3 * Math.pow(1 - t, 2)).add(\n  //     this.ctrlPt1.multiply(3 * (3 * Math.pow(t, 2) - 4 * t + 1)).add(\n  //       this.ctrlPt2.multiply(3 * (2 * t - 3 * Math.pow(t, 2))).add(\n  //         this.end.multiply(3 * Math.pow(t, 2))\n  //       )\n  //     )\n  //   );\n  // }\n\n  // /**\n  //  * 根据参数t（范围[0, 1]）获取贝塞尔曲线上的二阶导数\n  //  * @param t\n  //  * @returns\n  //  */\n  // private secondDerivative(t: number): Vector {\n  //   return this.start.multiply(6 * (1 - t)).add(\n  //     this.ctrlPt1.multiply(3 * (6 * t - 4)).add(\n  //       this.ctrlPt2.multiply(3 * (2 - 6 * t)).add(\n  //         this.end.multiply(6 * t)\n  //       )\n  //     )\n  //   );\n  // }\n\n  // private newtonIteration(last: number) {\n\n  // }\n\n  // private findMaxMinValue() {\n  //   const b = this.start.multiply(6).subtract(\n  //     this.ctrlPt1.multiply(12)).add(this.ctrlPt2.multiply(6));\n  //   const delta = b.componentMultiply(b).subtract(\n  //     this.ctrlPt1.multiply(3).subtract(this.start.multiply(3)).multiply(4).componentMultiply(\n  //       this.start.multiply(-3).add(this.ctrlPt1.multiply(9)).subtract(\n  //         this.ctrlPt2.multiply(9)).add(this.end.multiply(3))\n  //     ));\n  //   let minX, maxX;\n  //   if (delta.x < 0) {\n  //     minX = Math.min(this.start.x, this.end.x);\n  //     maxX = Math.max(this.start.x, this.end.x);\n  //   } else {\n\n  //   }\n  // }\n\n  // computeAabb(start: number, end: number): Rectangle {\n\n  // }\n\n  private static segment = 40;\n\n  isPointIn(point: Vector): boolean {\n    let lastPoint = this.start;\n    for (let i = 1; i <= CubicBezierCurve.segment; i++) {\n      const line = new Line(lastPoint, this.getPointByT(i / CubicBezierCurve.segment));\n      if (line.isPointIn(point)) {\n        return true;\n      }\n      lastPoint = line.end;\n    }\n    return false;\n  }\n  isCollideWithRectangle(rectangle: Rectangle): boolean {\n    let lastPoint = this.start;\n    for (let i = 1; i <= CubicBezierCurve.segment; i++) {\n      const line = new Line(lastPoint, this.getPointByT(i / CubicBezierCurve.segment));\n      if (line.isCollideWithRectangle(rectangle)) {\n        return true;\n      }\n      lastPoint = line.end;\n    }\n    return false;\n  }\n  isCollideWithLine(l: Line): boolean {\n    let lastPoint = this.start;\n    for (let i = 1; i <= CubicBezierCurve.segment; i++) {\n      const line = new Line(lastPoint, this.getPointByT(i / CubicBezierCurve.segment));\n      if (line.isCollideWithLine(l)) {\n        return true;\n      }\n      lastPoint = line.end;\n    }\n    return false;\n  }\n  getRectangle(): Rectangle {\n    const minX = Math.min(this.start.x, this.ctrlPt1.x, this.ctrlPt2.x, this.end.x);\n    const maxX = Math.max(this.start.x, this.ctrlPt1.x, this.ctrlPt2.x, this.end.x);\n    const minY = Math.min(this.start.y, this.ctrlPt1.y, this.ctrlPt2.y, this.end.y);\n    const maxY = Math.max(this.start.y, this.ctrlPt1.y, this.ctrlPt2.y, this.end.y);\n    const leftTop = new Vector(minX, minY);\n    const size = new Vector(maxX - minX, maxY - minY);\n    return new Rectangle(leftTop, size);\n  }\n}\n\n/**\n * 对称曲线\n */\nexport class SymmetryCurve extends Shape {\n  constructor(\n    public start: Vector,\n    public startDirection: Vector,\n    public end: Vector,\n    public endDirection: Vector,\n    public bending: number,\n  ) {\n    super();\n  }\n\n  get bezier(): CubicBezierCurve {\n    return new CubicBezierCurve(\n      this.start,\n      this.startDirection.normalize().multiply(this.bending).add(this.start),\n      this.endDirection.normalize().multiply(this.bending).add(this.end),\n      this.end,\n    );\n  }\n\n  isPointIn(point: Vector): boolean {\n    return this.bezier.isPointIn(point);\n  }\n  isCollideWithRectangle(rectangle: Rectangle): boolean {\n    return this.bezier.isCollideWithRectangle(rectangle);\n  }\n  isCollideWithLine(line: Line): boolean {\n    return this.bezier.isCollideWithLine(line);\n  }\n\n  toString(): string {\n    return `SymmetryCurve(start:${this.start}, startDirection:${this.startDirection}, end:${this.end}, endDirection:${this.endDirection}, bending:${this.bending})`;\n  }\n  getRectangle(): Rectangle {\n    const minX = Math.min(this.start.x, this.end.x);\n    const maxX = Math.max(this.start.x, this.end.x);\n    const minY = Math.min(this.start.y, this.end.y);\n    const maxY = Math.max(this.start.y, this.end.y);\n    const leftTop = new Vector(minX, minY);\n    const size = new Vector(maxX - minX, maxY - minY);\n    return new Rectangle(leftTop, size);\n  }\n}\n"
  },
  {
    "path": "packages/shapes/src/Line.ts",
    "content": "import { Vector } from \"@graphif/data-structures\";\nimport { Circle } from \"./Circle\";\nimport { Rectangle } from \"./Rectangle\";\nimport { Shape } from \"./Shape\";\n\nexport interface IntersectionResult {\n  intersects: boolean;\n  point?: Vector; // 使用可选属性来表示当没有交点时的情况\n}\n\n/**\n * 线段类\n */\nexport class Line extends Shape {\n  start: Vector;\n  end: Vector;\n\n  constructor(start: Vector, end: Vector) {\n    super();\n    this.start = start;\n    this.end = end;\n  }\n\n  toString(): string {\n    return `Line(${this.start}, ${this.end})`;\n  }\n\n  length(): number {\n    return this.end.subtract(this.start).magnitude();\n  }\n\n  midPoint(): Vector {\n    return new Vector((this.start.x + this.end.x) / 2, (this.start.y + this.end.y) / 2);\n  }\n\n  direction(): Vector {\n    return this.end.subtract(this.start);\n  }\n\n  /**\n   * 判断点是否在线段附近\n   * @param point\n   * @param tolerance 附近容错度\n   */\n  isPointNearLine(point: Vector, tolerance: number = 5): boolean {\n    const lineVector = this.direction();\n    const pointVector = point.subtract(this.start);\n\n    const lineLengthSquared = lineVector.dot(lineVector);\n\n    if (lineLengthSquared === 0) {\n      // 线段的起点和终点重合\n      return this.start.subtract(point).magnitude() <= tolerance;\n    }\n\n    // 计算投影点在线段上的位置\n    const t = pointVector.dot(lineVector) / lineLengthSquared;\n\n    // 限制投影点在0到1的范围内\n    const nearestPoint =\n      t < 0\n        ? this.start\n        : t > 1\n          ? this.end\n          : new Vector(this.start.x + t * lineVector.x, this.start.y + t * lineVector.y);\n\n    // 检查该点到line的距离是否在容差范围内\n    return nearestPoint.subtract(point).magnitude() <= tolerance;\n  }\n\n  isPointIn(point: Vector): boolean {\n    return this.isPointNearLine(point, 12);\n  }\n\n  isCollideWithRectangle(rectangle: Rectangle): boolean {\n    return rectangle.isCollideWithLine(this);\n  }\n\n  isCollideWithLine(line: Line): boolean {\n    return this.isIntersecting(line);\n  }\n\n  isParallel(other: Line): boolean {\n    /** 判断两条线段是否平行 */\n    return this.direction().cross(other.direction()) === 0;\n  }\n\n  isCollinear(other: Line): boolean {\n    /** 判断两条线段是否共线 */\n    return this.isParallel(other) && this.start.subtract(other.start).cross(this.direction()) === 0;\n  }\n\n  /**\n   * 判断该线段是否和一个水平的线段相交\n   * @param y 水平线段的y坐标\n   * @param xLeft 水平线段的左端点\n   * @param xRight 水平线段的右端点\n   */\n  isIntersectingWithHorizontalLine(y: number, xLeft: number, xRight: number): boolean {\n    // 如果两端点都在水平线段上下两侧区域，则不可能相交\n    if ((this.start.y < y && this.end.y < y) || (this.start.y > y && this.end.y > y)) {\n      return false;\n    }\n    // 如果两端点都在水平线段左右两侧区域，则不可能相交\n    if ((this.start.x < xLeft && this.end.x < xLeft) || (this.start.x > xRight && this.end.x > xRight)) {\n      return false;\n    }\n\n    // 如果线段的一个端点恰好位于水平线上，则不视为相交 # 253\n    if (this.start.y === y || this.end.y === y) {\n      return false;\n    }\n\n    // 计算线段在y轴方向上的变化率（斜率）\n    const slope = (this.end.x - this.start.x) / (this.end.y - this.start.y);\n\n    // 计算线段与水平线的交点的x坐标\n    const intersectionX = this.start.x + slope * (y - this.start.y);\n\n    // 检查交点的x坐标是否在水平线段的范围内\n    return intersectionX >= Math.min(xLeft, xRight) && intersectionX <= Math.max(xLeft, xRight);\n  }\n\n  getRectangle(): Rectangle {\n    const minX = Math.min(this.start.x, this.end.x);\n    const maxX = Math.max(this.start.x, this.end.x);\n    const minY = Math.min(this.start.y, this.end.y);\n    const maxY = Math.max(this.start.y, this.end.y);\n    const location = new Vector(minX, minY);\n    const size = new Vector(maxX - minX, maxY - minY);\n    return new Rectangle(location, size);\n  }\n\n  /**\n   * 判断该线段是否和一个垂直的线段相交\n   * @param x 垂直线段的x坐标\n   * @param yBottom 垂直线段的下端点\n   * @param yTop 垂直线段的上端点\n   */\n  isIntersectingWithVerticalLine(x: number, yBottom: number, yTop: number): boolean {\n    // 如果线段两端点的x坐标都在垂直线的同一侧，则不可能相交\n    if ((this.start.x < x && this.end.x < x) || (this.start.x > x && this.end.x > x)) {\n      return false;\n    }\n    // 如果线段两端都在顶部或底部，则不可能相交\n    // 这里注意y坐标的顺序，yTop在yBottom的上方\n    if ((this.start.y > yBottom && this.end.y > yBottom) || (this.start.y < yTop && this.end.y < yTop)) {\n      return false;\n    }\n\n    // 如果线段的一个端点恰好位于垂直线上，则不视为相交 # 253\n    if (this.start.x === x || this.end.x === x) {\n      return false;\n    }\n\n    // 计算线段在x轴方向上的变化率（倒数斜率）\n    const inverseSlope = (this.end.y - this.start.y) / (this.end.x - this.start.x);\n\n    // 计算线段与垂直线的交点的y坐标\n    const intersectionY = this.start.y + inverseSlope * (x - this.start.x);\n\n    // 检查交点的y坐标是否在垂直线段的范围内\n    return intersectionY >= Math.min(yBottom, yTop) && intersectionY <= Math.max(yBottom, yTop);\n  }\n  /**\n   * 一个线段是否和一个水平线段相交\n   *  this line\n   *    xx\n   *      x\n   *  ├────xxx─────────┤\n   *         xxx\n   *            xxx\n   *  xLeft       xxx  xRight\n   *\n   * @param y\n   * @param xLeft\n   * @param xRight\n   * @returns\n   */\n  getIntersectingWithHorizontalLine(y: number, xLeft: number, xRight: number): IntersectionResult {\n    // 如果两端点都在水平线段上下两侧区域，则不可能相交\n    if ((this.start.y < y && this.end.y < y) || (this.start.y > y && this.end.y > y)) {\n      return { intersects: false };\n    }\n    // 如果两端点都在水平线段左右两侧区域，则不可能相交\n    if ((this.start.x < xLeft && this.end.x < xLeft) || (this.start.x > xRight && this.end.x > xRight)) {\n      return { intersects: false };\n    }\n\n    // 如果线段的一个端点恰好位于水平线上，则不视为相交 # 253\n    if (this.start.y === y || this.end.y === y) {\n      return { intersects: false };\n    }\n\n    // 计算线段在y轴方向上的变化率（斜率）\n    const slope = (this.end.x - this.start.x) / (this.end.y - this.start.y);\n\n    // 计算线段与水平线的交点的x坐标\n    const intersectionX = this.start.x + slope * (y - this.start.y);\n\n    // 检查交点的x坐标是否在水平线段的范围内\n    if (intersectionX >= Math.min(xLeft, xRight) && intersectionX <= Math.max(xLeft, xRight)) {\n      return { intersects: true, point: new Vector(intersectionX, y) };\n    }\n\n    return { intersects: false };\n  }\n\n  /**\n   * 当前线段和垂直线段相交算法\n   * start\n   * x   │yTop\n   *  x  │\n   *   x │\n   *    x│\n   *     x   end\n   *     │x\n   *     │\n   *     │yBottom\n   * @param x\n   * @param yBottom\n   * @param yTop\n   * @returns\n   */\n  getIntersectingWithVerticalLine(x: number, yBottom: number, yTop: number): IntersectionResult {\n    // 如果线段两端点的x坐标都在垂直线的同一侧，则不可能相交\n    if ((this.start.x < x && this.end.x < x) || (this.start.x > x && this.end.x > x)) {\n      return { intersects: false };\n    }\n    // 如果线段两端都在顶部或底部，则不可能相交\n    // 这里注意y坐标的顺序，yTop在yBottom的上方\n    if ((this.start.y > yBottom && this.end.y > yBottom) || (this.start.y < yTop && this.end.y < yTop)) {\n      return { intersects: false };\n    }\n\n    // 如果线段的一个端点恰好位于垂直线上，则不视为相交 # 253\n    if (this.start.x === x || this.end.x === x) {\n      return { intersects: false };\n    }\n\n    // 计算线段在x轴方向上的变化率（倒数斜率）\n    const inverseSlope = (this.end.y - this.start.y) / (this.end.x - this.start.x);\n\n    // 计算线段与垂直线的交点的y坐标\n    const intersectionY = this.start.y + inverseSlope * (x - this.start.x);\n\n    // 检查交点的y坐标是否在垂直线段的范围内\n    if (intersectionY >= Math.min(yBottom, yTop) && intersectionY <= Math.max(yBottom, yTop)) {\n      return { intersects: true, point: new Vector(x, intersectionY) };\n    }\n\n    return { intersects: false };\n  }\n\n  isIntersectingWithCircle(circle: Circle): boolean {\n    // 先判断线段的两个端点是否在圆内\n    if (circle.isPointIn(this.start) || circle.isPointIn(this.end)) {\n      return true;\n    }\n    const A = this.start.y - this.end.y;\n    const B = this.end.x - this.start.x;\n    const C = this.start.x * this.end.y - this.end.x * this.start.y;\n    // 使用距离公式判断圆心到直线ax+by+c=0的距离是否大于半径\n    let dist1 = A * circle.location.x + B * circle.location.y + C;\n    dist1 *= dist1;\n    const dist2 = (A * A + B * B) * circle.radius * circle.radius;\n    if (dist1 > dist2) {\n      // 圆心到直线p1p2的距离大于半径，不相交\n      return false;\n    }\n    const angle1 =\n      (circle.location.x - this.start.x) * (this.end.x - this.start.x) +\n      (circle.location.y - this.start.y) * (this.end.y - this.start.y);\n    const angle2 =\n      (circle.location.x - this.end.x) * (this.start.x - this.end.x) +\n      (circle.location.y - this.end.y) * (this.start.y - this.end.y);\n    // 余弦为正，则是锐角，一定相交\n    return angle1 > 0 && angle2 > 0;\n  }\n  /**\n   * 判断两条线段是否相交\n   */\n  isIntersecting(other: Line): boolean {\n    if (this.isCollinear(other)) {\n      return false;\n    }\n\n    const onSegment = (p: Vector, q: Vector, r: Vector): boolean => {\n      return (\n        Math.max(p.x, r.x) >= q.x && q.x >= Math.min(p.x, r.x) && Math.max(p.y, r.y) >= q.y && q.y >= Math.min(p.y, r.y)\n      );\n    };\n\n    const orientation = (p: Vector, q: Vector, r: Vector): number => {\n      const val = (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y);\n      if (val === 0) return 0;\n      return val > 0 ? 1 : 2;\n    };\n\n    const o1 = orientation(this.start, this.end, other.start);\n    const o2 = orientation(this.start, this.end, other.end);\n    const o3 = orientation(other.start, other.end, this.start);\n    const o4 = orientation(other.start, other.end, this.end);\n\n    if (o1 !== o2 && o3 !== o4) {\n      return true;\n    }\n\n    if (o1 === 0 && onSegment(this.start, other.start, this.end)) {\n      return true;\n    }\n\n    if (o2 === 0 && onSegment(this.start, other.end, this.end)) {\n      return true;\n    }\n\n    if (o3 === 0 && onSegment(other.start, this.start, other.end)) {\n      return true;\n    }\n\n    if (o4 === 0 && onSegment(other.start, this.end, other.end)) {\n      return true;\n    }\n\n    return false;\n  }\n\n  cross(other: Line): number {\n    /** 计算两条线段方向向量的叉积 */\n    return this.direction().cross(other.direction());\n  }\n\n  getIntersection(other: Line): Vector | null {\n    /**\n     * 计算两条线段的交点\n     */\n    if (!this.isIntersecting(other)) {\n      return null;\n    }\n    try {\n      const x1 = this.start.x,\n        y1 = this.start.y;\n      const x2 = this.end.x,\n        y2 = this.end.y;\n      const x3 = other.start.x,\n        y3 = other.start.y;\n      const x4 = other.end.x,\n        y4 = other.end.y;\n\n      const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);\n      if (denom === 0) {\n        return null;\n      }\n\n      const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom;\n      const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom;\n\n      if (t >= 0 && t <= 1 && u >= 0 && u <= 1) {\n        const intersectionX = x1 + t * (x2 - x1);\n        const intersectionY = y1 + t * (y2 - y1);\n        return new Vector(intersectionX, intersectionY);\n      } else {\n        return null;\n      }\n    } catch (e) {\n      console.error(e);\n      return null;\n    }\n  }\n}\n"
  },
  {
    "path": "packages/shapes/src/Rectangle.ts",
    "content": "import { Vector } from \"@graphif/data-structures\";\nimport { serializable } from \"@graphif/serializer\";\nimport { Line } from \"./Line\";\nimport { Shape } from \"./Shape\";\n\nexport class Rectangle extends Shape {\n  @serializable\n  location: Vector;\n  @serializable\n  size: Vector;\n\n  constructor(location: Vector, size: Vector) {\n    super();\n    this.location = location;\n    this.size = size;\n  }\n\n  /**\n   * 构造一个相对于屏幕来说内容居中的矩形\n   * 用于UI初始化窗口\n   */\n  public static inCenter(size: Vector): Rectangle {\n    const screenCenter = new Vector(window.innerWidth, window.innerHeight).divide(2);\n    const location = screenCenter.subtract(size.divide(2));\n    return new Rectangle(location, size);\n  }\n\n  public get left(): number {\n    return this.location.x;\n  }\n\n  public get right(): number {\n    return this.location.x + this.size.x;\n  }\n\n  public get top(): number {\n    return this.location.y;\n  }\n\n  public get bottom(): number {\n    return this.location.y + this.size.y;\n  }\n\n  public get center(): Vector {\n    return this.location.add(this.size.divide(2));\n  }\n\n  public getInnerLocationByRateVector(rateVector: Vector) {\n    return this.location.add(new Vector(this.size.x * rateVector.x, this.size.y * rateVector.y));\n  }\n\n  public get leftCenter(): Vector {\n    return new Vector(this.left, this.center.y);\n  }\n\n  public get rightCenter(): Vector {\n    return new Vector(this.right, this.center.y);\n  }\n\n  public get topCenter(): Vector {\n    return new Vector(this.center.x, this.top);\n  }\n\n  public get bottomCenter(): Vector {\n    return new Vector(this.center.x, this.bottom);\n  }\n  public get leftTop(): Vector {\n    return new Vector(this.left, this.top);\n  }\n  public get rightTop(): Vector {\n    return new Vector(this.right, this.top);\n  }\n  public get leftBottom(): Vector {\n    return new Vector(this.left, this.bottom);\n  }\n  public get rightBottom(): Vector {\n    return new Vector(this.right, this.bottom);\n  }\n\n  public get width(): number {\n    return this.size.x;\n  }\n  public get height(): number {\n    return this.size.y;\n  }\n  getRectangle(): Rectangle {\n    return this.clone();\n  }\n\n  /**\n   * 以中心点为中心，扩展矩形\n   * @param amount\n   * @returns\n   */\n  public expandFromCenter(amount: number): Rectangle {\n    // const halfAmount = amount / 2;\n    // const newSize = this.size.add(new Vector(amount, amount));\n    // const newLocation = this.center\n    //   .subtract(newSize.divide(2))\n    //   .subtract(new Vector(halfAmount, halfAmount));\n    // return new Rectangle(newLocation, newSize);\n    return Rectangle.fromEdges(this.left - amount, this.top - amount, this.right + amount, this.bottom + amount);\n  }\n\n  public clone(): Rectangle {\n    return new Rectangle(this.location.clone(), this.size.clone());\n  }\n\n  /**\n   * 通过四条边来创建矩形\n   * @param left\n   * @param top\n   * @param right\n   * @param bottom\n   * @returns\n   */\n  public static fromEdges(left: number, top: number, right: number, bottom: number): Rectangle {\n    const location = new Vector(left, top);\n    const size = new Vector(right - left, bottom - top);\n    return new Rectangle(location, size);\n  }\n\n  /**\n   * 通过两个点来创建矩形，可以用于框选生成矩形\n   * @param p1\n   * @param p2\n   * @returns\n   */\n  public static fromTwoPoints(p1: Vector, p2: Vector): Rectangle {\n    const left = Math.min(p1.x, p2.x);\n    const top = Math.min(p1.y, p2.y);\n    const right = Math.max(p1.x, p2.x);\n    const bottom = Math.max(p1.y, p2.y);\n    return Rectangle.fromEdges(left, top, right, bottom);\n  }\n\n  /**\n   * 获取多个矩形的最小外接矩形\n   * @param rectangles\n   * @returns\n   */\n  public static getBoundingRectangle(rectangles: Rectangle[], padding: number = 0): Rectangle {\n    if (rectangles.length === 0) {\n      // 抛出异常\n      throw new Error(\"rectangles is empty\");\n    }\n\n    let left = Infinity;\n    let top = Infinity;\n    let right = -Infinity;\n    let bottom = -Infinity;\n    for (const rect of rectangles) {\n      left = Math.min(left, rect.left - padding);\n      top = Math.min(top, rect.top - padding);\n      right = Math.max(right, rect.right + padding);\n      bottom = Math.max(bottom, rect.bottom + padding);\n    }\n    return Rectangle.fromEdges(left, top, right, bottom);\n  }\n\n  /**\n   * 按照 上右下左 的顺序返回四条边\n   * @returns\n   */\n  public getBoundingLines(): Line[] {\n    const lines: Line[] = [\n      // top line\n      new Line(new Vector(this.left, this.top), new Vector(this.right, this.top)),\n      // right line\n      new Line(new Vector(this.right, this.top), new Vector(this.right, this.bottom)),\n      // bottom line\n      new Line(new Vector(this.right, this.bottom), new Vector(this.left, this.bottom)),\n      // left line\n      new Line(new Vector(this.left, this.bottom), new Vector(this.left, this.top)),\n    ];\n\n    return lines;\n  }\n\n  getFroePoints(): Vector[] {\n    const points = [\n      new Vector(this.left, this.top),\n      new Vector(this.right, this.top),\n      new Vector(this.right, this.bottom),\n      new Vector(this.left, this.bottom),\n    ];\n    return points;\n  }\n\n  /**\n   * 和另一个矩形有部分相交（碰到一点点就算）\n   */\n  public isCollideWith(other: Rectangle): boolean {\n    const collision_x = this.right > other.left && this.left < other.right;\n    const collision_y = this.bottom > other.top && this.top < other.bottom;\n    return collision_x && collision_y;\n  }\n\n  /**\n   * 判断一个矩形是否完全在某个矩形内部\n   * @param otherBig\n   */\n  public isAbsoluteIn(otherBig: Rectangle): boolean {\n    return (\n      this.left >= otherBig.left &&\n      this.right <= otherBig.right &&\n      this.top >= otherBig.top &&\n      this.bottom <= otherBig.bottom\n    );\n  }\n  isCollideWithRectangle(rectangle: Rectangle): boolean {\n    return this.isCollideWith(rectangle);\n  }\n\n  /**\n   * 已知两个矩形必定相交，返回重叠部分的矩形区域\n   */\n  static getIntersectionRectangle(rect1: Rectangle, rect2: Rectangle) {\n    // 计算重叠部分的左上角和右下角坐标\n    const left = Math.max(rect1.left, rect2.left);\n    const top = Math.max(rect1.top, rect2.top);\n    const right = Math.min(rect1.right, rect2.right);\n    const bottom = Math.min(rect1.bottom, rect2.bottom);\n\n    // 返回新的矩形对象表示重叠区域\n    return Rectangle.fromEdges(left, top, right, bottom);\n  }\n\n  /**\n   * 自己这个矩形是否和线段有交点\n   * 用于节点切割检测\n   *\n   * @param line\n   */\n  public isCollideWithLine(line: Line): boolean {\n    if (this.isPointIn(line.start) || this.isPointIn(line.end)) {\n      // 当用于切割线的时候，两个端点必定都在矩形外\n      // 这个实际上是不可能的，但是为了保险起见，还是加上判断\n      return true;\n    }\n\n    if (line.isIntersectingWithHorizontalLine(this.location.y, this.left, this.right)) {\n      return true;\n    }\n\n    if (line.isIntersectingWithHorizontalLine(this.location.y + this.size.y, this.left, this.right)) {\n      return true;\n    }\n\n    if (line.isIntersectingWithVerticalLine(this.location.x, this.bottom, this.top)) {\n      return true;\n    }\n\n    if (line.isIntersectingWithVerticalLine(this.location.x + this.size.x, this.bottom, this.top)) {\n      return true;\n    }\n    return false;\n  }\n\n  /**\n   * 获取线段和矩形的交点\n   * @param line\n   */\n  public getCollidePointsWithLine(line: Line): Vector[] {\n    const result: Vector[] = [];\n    if (this.isPointIn(line.start)) {\n      result.push(line.start);\n    }\n    if (this.isPointIn(line.end)) {\n      result.push(line.end);\n    }\n    const topResult = line.getIntersectingWithHorizontalLine(this.location.y, this.left, this.right);\n    if (topResult.intersects) {\n      result.push(topResult.point!);\n    }\n    const bottomResult = line.getIntersectingWithHorizontalLine(this.location.y + this.size.y, this.left, this.right);\n    if (bottomResult.intersects) {\n      result.push(bottomResult.point!);\n    }\n    const leftResult = line.getIntersectingWithVerticalLine(this.location.x, this.bottom, this.top);\n    if (leftResult.intersects) {\n      result.push(leftResult.point!);\n    }\n    const rightResult = line.getIntersectingWithVerticalLine(this.location.x + this.size.x, this.bottom, this.top);\n    if (rightResult.intersects) {\n      result.push(rightResult.point!);\n    }\n\n    return result;\n  }\n  /**\n   * 是否完全在另一个矩形内\n   * AI写的，有待测试\n   * @param other\n   * @returns\n   */\n  public isInOther(other: Rectangle): boolean {\n    const collision_x = this.left > other.left && this.right < other.right;\n    const collision_y = this.top > other.top && this.bottom < other.bottom;\n    return collision_x && collision_y;\n  }\n  /**\n   * 获取两个矩形的重叠区域的矩形的宽度和高度\n   * 如果没有重叠区域，则宽度和高度都是0\n   * 返回的x,y 都大于零\n   */\n  public getOverlapSize(other: Rectangle): Vector {\n    if (!this.isCollideWith(other)) {\n      return new Vector(0, 0);\n    }\n    const left = Math.max(this.left, other.left);\n    const top = Math.max(this.top, other.top);\n    const right = Math.min(this.right, other.right);\n    const bottom = Math.min(this.bottom, other.bottom);\n    const width = right - left;\n    const height = bottom - top;\n    return new Vector(width, height);\n  }\n\n  /**\n   * 判断点是否在矩形内/边上也算\n   * 为什么边上也算，因为节点的位置在左上角上，可以用于判断节点是否存在于某位置\n   */\n  public isPointIn(point: Vector): boolean {\n    const collision_x = this.left <= point.x && point.x <= this.right;\n    const collision_y = this.top <= point.y && point.y <= this.bottom;\n    return collision_x && collision_y;\n  }\n\n  /**\n   *\n   * @param scale\n   * @returns\n   */\n\n  public multiply(scale: number): Rectangle {\n    return new Rectangle(this.location.multiply(scale), this.size.multiply(scale));\n  }\n\n  public toString(): string {\n    return `[${this.location.toString()}, ${this.size.toString()}]`;\n  }\n\n  public getCenter(): Vector {\n    return this.location.add(this.size.divide(2));\n  }\n\n  static fromPoints(p1: Vector, p2: Vector): Rectangle {\n    const location = p1.clone();\n    const size = p2.clone().subtract(p1);\n    return new Rectangle(location, size);\n  }\n\n  /**\n   * 返回一个线段和这个矩形的交点，如果没有交点，就返回这个矩形的中心点\n   * 请确保线段和矩形只有一个交点，出现两个交点的情况还未测试\n   */\n  public getLineIntersectionPoint(line: Line) {\n    const topLine = new Line(this.location, this.location.add(new Vector(this.size.x, 0)));\n    const topIntersection = topLine.getIntersection(line);\n    if (topIntersection) {\n      return topIntersection;\n    }\n    const bottomLine = new Line(this.location.add(new Vector(0, this.size.y)), this.location.add(this.size));\n    const bottomIntersection = bottomLine.getIntersection(line);\n    if (bottomIntersection) {\n      return bottomIntersection;\n    }\n    const leftLine = new Line(this.location, this.location.add(new Vector(0, this.size.y)));\n    const leftIntersection = leftLine.getIntersection(line);\n    if (leftIntersection) {\n      return leftIntersection;\n    }\n    const rightLine = new Line(this.location.add(new Vector(this.size.x, 0)), this.location.add(this.size));\n    const rightIntersection = rightLine.getIntersection(line);\n    if (rightIntersection) {\n      return rightIntersection;\n    }\n    return this.getCenter();\n  }\n\n  /**\n   * 获取在this矩形边上的point的单位法向量,若point不在this矩形边上，则该函数可能返回任意向量。\n   * @param point\n   */\n  public getNormalVectorAt(point: Vector): Vector {\n    if (point.x === this.left) {\n      return new Vector(-1, 0);\n    } else if (point.x === this.right) {\n      return new Vector(1, 0);\n    } else if (point.y === this.top) {\n      return new Vector(0, -1);\n    } else {\n      return new Vector(0, 1);\n    }\n  }\n\n  public translate(offset: Vector): Rectangle {\n    return new Rectangle(this.location.add(offset), this.size);\n  }\n\n  public limit(limit: Rectangle): Rectangle {\n    const left = Math.max(limit.left, this.left);\n    const top = Math.max(limit.top, this.top);\n    const right = Math.min(limit.right, this.right);\n    const bottom = Math.min(limit.bottom, this.bottom);\n    return Rectangle.fromEdges(left, top, right, bottom);\n  }\n}\n"
  },
  {
    "path": "packages/shapes/src/Shape.ts",
    "content": "import { Vector } from \"@graphif/data-structures\";\nimport { Line } from \"./Line\";\nimport { Rectangle } from \"./Rectangle\";\n\n/**\n * 可交互的 图形抽象类\n */\nexport abstract class Shape {\n  abstract isPointIn(point: Vector): boolean;\n\n  abstract isCollideWithRectangle(rectangle: Rectangle): boolean;\n\n  abstract isCollideWithLine(line: Line): boolean;\n\n  /**\n   * 获取图形的最小外接矩形，用于对齐操作\n   */\n  abstract getRectangle(): Rectangle;\n}\n"
  },
  {
    "path": "packages/shapes/src/index.ts",
    "content": "export * from \"./Circle\";\nexport * from \"./CubicCatmullRomSpline\";\nexport * from \"./Curve\";\nexport * from \"./Line\";\nexport * from \"./Rectangle\";\nexport * from \"./Shape\";\n"
  },
  {
    "path": "packages/shapes/tests/Circle.test.ts",
    "content": "import { describe, it, expect } from \"vitest\";\nimport { Circle } from \"../src/Circle\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Rectangle } from \"../src/Rectangle\";\nimport { Line } from \"../src/Line\";\n\ndescribe(\"Circle 圆形\", () => {\n  it(\"创建圆形\", () => {\n    const circle = new Circle(new Vector(10, 20), 5);\n    expect(circle.location.x).toBe(10);\n    expect(circle.location.y).toBe(20);\n    expect(circle.radius).toBe(5);\n  });\n\n  it(\"判断点是否在圆形内\", () => {\n    const circle = new Circle(new Vector(0, 0), 10);\n    expect(circle.isPointIn(new Vector(5, 5))).toBe(true);\n    expect(circle.isPointIn(new Vector(10, 0))).toBe(true);\n    expect(circle.isPointIn(new Vector(11, 0))).toBe(false);\n  });\n\n  it(\"检测与矩形的碰撞\", () => {\n    const circle = new Circle(new Vector(5, 5), 3);\n    const rectangle1 = new Rectangle(new Vector(0, 0), new Vector(10, 10));\n    const rectangle2 = new Rectangle(new Vector(10, 10), new Vector(5, 5));\n    expect(circle.isCollideWithRectangle(rectangle1)).toBe(true);\n    expect(circle.isCollideWithRectangle(rectangle2)).toBe(false);\n  });\n\n  it(\"检测与线段的碰撞\", () => {\n    const circle = new Circle(new Vector(0, 0), 5);\n    const line1 = new Line(new Vector(-10, 0), new Vector(10, 0));\n    const line2 = new Line(new Vector(6, 0), new Vector(10, 0));\n    expect(circle.isCollideWithLine(line1)).toBe(true);\n    expect(circle.isCollideWithLine(line2)).toBe(false);\n  });\n\n  it(\"获取外接矩形\", () => {\n    const circle = new Circle(new Vector(10, 10), 5);\n    const rectangle = circle.getRectangle();\n    expect(rectangle.location.x).toBe(5);\n    expect(rectangle.location.y).toBe(5);\n    expect(rectangle.size.x).toBe(10);\n    expect(rectangle.size.y).toBe(10);\n  });\n});\n"
  },
  {
    "path": "packages/shapes/tests/CubicCatmullRomSpline.test.ts",
    "content": "import { describe, it, expect } from \"vitest\";\nimport { CubicCatmullRomSpline, romberg } from \"../src/CubicCatmullRomSpline\";\nimport { Vector } from \"@graphif/data-structures\";\n\ndescribe(\"CubicCatmullRomSpline CR曲线\", () => {\n  it(\"创建CR曲线\", () => {\n    const points = [new Vector(0, 0), new Vector(10, 20), new Vector(30, 40), new Vector(50, 60)];\n    const spline = new CubicCatmullRomSpline(points);\n    expect(spline.controlPoints.length).toBe(4);\n  });\n\n  it(\"当控制点少于4个时应抛出错误\", () => {\n    const points = [new Vector(0, 0), new Vector(10, 20), new Vector(30, 40)];\n    expect(() => new CubicCatmullRomSpline(points)).toThrow(\"There must be at least 4 control points\");\n  });\n\n  it(\"计算曲线路径\", () => {\n    const points = [new Vector(0, 0), new Vector(10, 0), new Vector(20, 0), new Vector(30, 0)];\n    const spline = new CubicCatmullRomSpline(points);\n    const path = spline.computePath();\n    expect(path.length).toBeGreaterThan(1);\n  });\n});\n\ndescribe(\"romberg 龙贝格积分\", () => {\n  it(\"计算函数在[0, 1]区间的积分\", () => {\n    const func = (x: number) => x * x;\n    const result = romberg(func, 0.001);\n    expect(result).toBeCloseTo(1 / 3, 3);\n  });\n});\n"
  },
  {
    "path": "packages/shapes/tests/Curve.test.ts",
    "content": "import { describe, it, expect } from \"vitest\";\nimport { CubicBezierCurve, SymmetryCurve } from \"../src/Curve\";\nimport { Vector } from \"@graphif/data-structures\";\n\ndescribe(\"CubicBezierCurve 贝塞尔曲线\", () => {\n  it(\"创建贝塞尔曲线\", () => {\n    const curve = new CubicBezierCurve(new Vector(0, 0), new Vector(10, 20), new Vector(30, 40), new Vector(50, 60));\n    expect(curve.start.x).toBe(0);\n    expect(curve.ctrlPt1.y).toBe(20);\n    expect(curve.ctrlPt2.x).toBe(30);\n    expect(curve.end.y).toBe(60);\n  });\n\n  it(\"根据参数t获取曲线上的点\", () => {\n    const curve = new CubicBezierCurve(new Vector(0, 0), new Vector(0, 100), new Vector(100, 100), new Vector(100, 0));\n    const point = curve.getPointByT(0.5);\n    expect(point.x).toBe(50);\n    expect(point.y).toBe(75);\n  });\n});\n\ndescribe(\"SymmetryCurve 对称曲线\", () => {\n  it(\"创建对称曲线\", () => {\n    const curve = new SymmetryCurve(new Vector(0, 0), new Vector(1, 0), new Vector(100, 0), new Vector(-1, 0), 50);\n    expect(curve.start.x).toBe(0);\n    expect(curve.end.x).toBe(100);\n    expect(curve.bending).toBe(50);\n  });\n\n  it(\"获取其对应的贝塞尔曲线\", () => {\n    const curve = new SymmetryCurve(new Vector(0, 0), new Vector(1, 0), new Vector(100, 0), new Vector(-1, 0), 50);\n    const bezier = curve.bezier;\n    expect(bezier.start.x).toBe(0);\n    expect(bezier.ctrlPt1.x).toBe(50);\n    expect(bezier.ctrlPt2.x).toBe(50);\n    expect(bezier.end.x).toBe(100);\n  });\n});\n"
  },
  {
    "path": "packages/shapes/tests/Line.test.ts",
    "content": "import { describe, it, expect } from \"vitest\";\nimport { Line } from \"../src/Line\";\nimport { Vector } from \"@graphif/data-structures\";\nimport { Circle } from \"../src/Circle\";\n\ndescribe(\"Line 线段\", () => {\n  it(\"创建线段\", () => {\n    const line = new Line(new Vector(10, 20), new Vector(30, 40));\n    expect(line.start.x).toBe(10);\n    expect(line.start.y).toBe(20);\n    expect(line.end.x).toBe(30);\n    expect(line.end.y).toBe(40);\n  });\n\n  it(\"计算线段长度\", () => {\n    const line = new Line(new Vector(0, 0), new Vector(3, 4));\n    expect(line.length()).toBe(5);\n  });\n\n  it(\"计算线段中点\", () => {\n    const line = new Line(new Vector(10, 20), new Vector(30, 40));\n    const midPoint = line.midPoint();\n    expect(midPoint.x).toBe(20);\n    expect(midPoint.y).toBe(30);\n  });\n\n  it(\"判断点是否在线段附近\", () => {\n    const line = new Line(new Vector(0, 0), new Vector(10, 10));\n    expect(line.isPointNearLine(new Vector(5, 5))).toBe(true);\n    expect(line.isPointNearLine(new Vector(11, 11), 2)).toBe(true);\n    expect(line.isPointNearLine(new Vector(12, 12), 1)).toBe(false);\n  });\n\n  it(\"检测与另一条线段是否相交\", () => {\n    const line1 = new Line(new Vector(0, 0), new Vector(10, 10));\n    const line2 = new Line(new Vector(0, 10), new Vector(10, 0));\n    const line3 = new Line(new Vector(11, 11), new Vector(20, 20));\n    expect(line1.isIntersecting(line2)).toBe(true);\n    expect(line1.isIntersecting(line3)).toBe(false);\n  });\n\n  it(\"获取与另一条线段的交点\", () => {\n    const line1 = new Line(new Vector(0, 0), new Vector(10, 10));\n    const line2 = new Line(new Vector(0, 10), new Vector(10, 0));\n    const intersection = line1.getIntersection(line2);\n    expect(intersection).not.toBeNull();\n    if (intersection) {\n      expect(intersection.x).toBe(5);\n      expect(intersection.y).toBe(5);\n    }\n  });\n\n  it(\"检测与圆形的碰撞\", () => {\n    const line1 = new Line(new Vector(-10, 0), new Vector(10, 0));\n    const line2 = new Line(new Vector(6, 0), new Vector(10, 0));\n    const circle = new Circle(new Vector(0, 0), 5);\n    expect(line1.isIntersectingWithCircle(circle)).toBe(true);\n    expect(line2.isIntersectingWithCircle(circle)).toBe(false);\n  });\n});\n"
  },
  {
    "path": "packages/shapes/tests/Rectangle.test.ts",
    "content": "import { Vector } from \"@graphif/data-structures\";\nimport { describe, expect, it } from \"vitest\";\nimport { Rectangle } from \"../src/Rectangle\";\n\ndescribe(\"Rectangle 矩形\", () => {\n  it(\"创建矩形\", () => {\n    const rect = new Rectangle(new Vector(10, 20), new Vector(30, 40));\n    expect(rect.location.x).toBe(10);\n    expect(rect.location.y).toBe(20);\n    expect(rect.size.x).toBe(30);\n    expect(rect.size.y).toBe(40);\n  });\n\n  it(\"获取矩形的各种属性\", () => {\n    const rect = new Rectangle(new Vector(10, 20), new Vector(30, 40));\n    expect(rect.left).toBe(10);\n    expect(rect.top).toBe(20);\n    expect(rect.right).toBe(40);\n    expect(rect.bottom).toBe(60);\n    expect(rect.width).toBe(30);\n    expect(rect.height).toBe(40);\n    expect(rect.center.x).toBe(25);\n    expect(rect.center.y).toBe(40);\n  });\n\n  it(\"判断点是否在矩形内\", () => {\n    const rect = new Rectangle(new Vector(0, 0), new Vector(10, 10));\n    expect(rect.isPointIn(new Vector(5, 5))).toBe(true);\n    expect(rect.isPointIn(new Vector(10, 10))).toBe(true);\n    expect(rect.isPointIn(new Vector(11, 5))).toBe(false);\n  });\n\n  it(\"检测与另一个矩形的碰撞\", () => {\n    const rect1 = new Rectangle(new Vector(0, 0), new Vector(10, 10));\n    const rect2 = new Rectangle(new Vector(5, 5), new Vector(10, 10));\n    const rect3 = new Rectangle(new Vector(11, 11), new Vector(10, 10));\n    expect(rect1.isCollideWith(rect2)).toBe(true);\n    expect(rect1.isCollideWith(rect3)).toBe(false);\n  });\n\n  it(\"从两条边创建矩形\", () => {\n    const rect = Rectangle.fromEdges(10, 20, 40, 60);\n    expect(rect.location.x).toBe(10);\n    expect(rect.location.y).toBe(20);\n    expect(rect.size.x).toBe(30);\n    expect(rect.size.y).toBe(40);\n  });\n\n  it(\"从两个点创建矩形\", () => {\n    const rect = Rectangle.fromTwoPoints(new Vector(10, 20), new Vector(40, 60));\n    expect(rect.location.x).toBe(10);\n    expect(rect.location.y).toBe(20);\n    expect(rect.size.x).toBe(30);\n    expect(rect.size.y).toBe(40);\n  });\n\n  it(\"获取多个矩形的最小外接矩形\", () => {\n    const rects = [\n      new Rectangle(new Vector(0, 0), new Vector(10, 10)),\n      new Rectangle(new Vector(20, 20), new Vector(10, 10)),\n    ];\n    const boundingRect = Rectangle.getBoundingRectangle(rects);\n    expect(boundingRect.location.x).toBe(0);\n    expect(boundingRect.location.y).toBe(0);\n    expect(boundingRect.size.x).toBe(30);\n    expect(boundingRect.size.y).toBe(30);\n  });\n\n  it(\"获取矩形的四条边\", () => {\n    const rect = new Rectangle(new Vector(10, 20), new Vector(30, 40));\n    const lines = rect.getBoundingLines();\n    expect(lines.length).toBe(4);\n    // top\n    expect(lines[0].start.x).toBe(10);\n    expect(lines[0].start.y).toBe(20);\n    expect(lines[0].end.x).toBe(40);\n    expect(lines[0].end.y).toBe(20);\n    // right\n    expect(lines[1].start.x).toBe(40);\n    expect(lines[1].start.y).toBe(20);\n    expect(lines[1].end.x).toBe(40);\n    expect(lines[1].end.y).toBe(60);\n    // bottom\n    expect(lines[2].start.x).toBe(40);\n    expect(lines[2].start.y).toBe(60);\n    expect(lines[2].end.x).toBe(10);\n    expect(lines[2].end.y).toBe(60);\n    // left\n    expect(lines[3].start.x).toBe(10);\n    expect(lines[3].start.y).toBe(60);\n    expect(lines[3].end.x).toBe(10);\n    expect(lines[3].end.y).toBe(20);\n  });\n});\n"
  },
  {
    "path": "packages/shapes/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"useDefineForClassFields\": true,\n    \"module\": \"ESNext\",\n    \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"moduleDetection\": \"force\",\n    \"noEmit\": true,\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUncheckedSideEffectImports\": true,\n    \"experimentalDecorators\": true,\n    \"emitDecoratorMetadata\": true\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "packages/shapes/tsdown.config.ts",
    "content": "import { defineConfig } from \"tsdown\";\nimport originalClassName from \"unplugin-original-class-name/rollup\";\n\nexport default defineConfig({\n  plugins: [originalClassName()],\n});\n"
  },
  {
    "path": "patches/typescript.patch",
    "content": "diff --git a/lib/_tsc.js b/lib/_tsc.js\nindex 11ab5ff444ede75f643b3df775438d35d56fb9bd..430031522df8728d0b0a1179b24de969681999b1 100644\n--- a/lib/_tsc.js\n+++ b/lib/_tsc.js\n@@ -80238,16 +80238,6 @@ function createTypeChecker(host) {\n         [effectiveLeft, effectiveRight] = getBaseTypesIfUnrelated(leftType, rightType, isRelated);\n       }\n       const [leftStr, rightStr] = getTypeNamesForErrorDisplay(effectiveLeft, effectiveRight);\n-      if (!tryGiveBetterPrimaryError(errNode, wouldWorkWithAwait, leftStr, rightStr)) {\n-        errorAndMaybeSuggestAwait(\n-          errNode,\n-          wouldWorkWithAwait,\n-          Diagnostics.Operator_0_cannot_be_applied_to_types_1_and_2,\n-          tokenToString(operatorToken.kind),\n-          leftStr,\n-          rightStr\n-        );\n-      }\n     }\n     function tryGiveBetterPrimaryError(errNode, maybeMissingAwait, leftStr, rightStr) {\n       switch (operatorToken.kind) {\ndiff --git a/lib/typescript.js b/lib/typescript.js\nindex 2643aa12aa6497ca0b90ecd99b202f37ea0e6330..7cf10bb62ba73a3dbd5a4009b07a0ba5641d0516 100644\n--- a/lib/typescript.js\n+++ b/lib/typescript.js\n@@ -84849,16 +84849,6 @@ function createTypeChecker(host) {\n         [effectiveLeft, effectiveRight] = getBaseTypesIfUnrelated(leftType, rightType, isRelated);\n       }\n       const [leftStr, rightStr] = getTypeNamesForErrorDisplay(effectiveLeft, effectiveRight);\n-      if (!tryGiveBetterPrimaryError(errNode, wouldWorkWithAwait, leftStr, rightStr)) {\n-        errorAndMaybeSuggestAwait(\n-          errNode,\n-          wouldWorkWithAwait,\n-          Diagnostics.Operator_0_cannot_be_applied_to_types_1_and_2,\n-          tokenToString(operatorToken.kind),\n-          leftStr,\n-          rightStr\n-        );\n-      }\n     }\n     function tryGiveBetterPrimaryError(errNode, maybeMissingAwait, leftStr, rightStr) {\n       switch (operatorToken.kind) {\n"
  },
  {
    "path": "pnpm-workspace.yaml",
    "content": "packages:\n  - \"app\"\n  - \"docs\"\n  - \"packages/*\"\n\noverrides:\n  vite: \"npm:rolldown-vite@latest\"\n\ncatalog:\n  react: ^19.1.0\n  react-dom: ^19.1.0\n  typescript: 5.9.2\n  tsdown: ^0.12.9\n  jotai: ^2.12.5\n"
  },
  {
    "path": "utils/add-ts-nocheck.ts",
    "content": "import fs from \"fs\";\nimport path from \"path\";\n\nconst files: string[] = [\n  \"src/components/ui/ai-toolbar-button.tsx\",\n  \"src/components/ui/block-discussion.tsx\",\n  \"src/components/ui/block-selection.tsx\",\n  \"src/components/ui/blockquote-node-static.tsx\",\n  \"src/components/ui/callout-node-static.tsx\",\n  \"src/components/ui/code-block-node-static.tsx\",\n  \"src/components/ui/code-node-static.tsx\",\n  \"src/components/ui/code-node.tsx\",\n  \"src/components/ui/column-node-static.tsx\",\n  \"src/components/ui/column-node.tsx\",\n  \"src/components/ui/comment-node-static.tsx\",\n  \"src/components/ui/comment-node.tsx\",\n  \"src/components/ui/comment-toolbar-button.tsx\",\n  \"src/components/ui/comment.tsx\",\n  \"src/components/ui/date-node-static.tsx\",\n  \"src/components/ui/editor-static.tsx\",\n  \"src/components/ui/equation-node-static.tsx\",\n  \"src/components/ui/equation-node.tsx\",\n  \"src/components/ui/floating-toolbar.tsx\",\n  \"src/components/ui/font-size-toolbar-button.tsx\",\n  \"src/components/ui/heading-node.tsx\",\n  \"src/components/ui/highlight-node-static.tsx\",\n  \"src/components/ui/highlight-node.tsx\",\n  \"src/components/ui/history-toolbar-button.tsx\",\n  \"src/components/ui/hr-node-static.tsx\",\n  \"src/components/ui/hr-node.tsx\",\n  \"src/components/ui/inline-combobox.tsx\",\n  \"src/components/ui/kbd-node-static.tsx\",\n  \"src/components/ui/kbd-node.tsx\",\n  \"src/components/ui/link-node-static.tsx\",\n  \"src/components/ui/link-node.tsx\",\n  \"src/components/ui/link-toolbar.tsx\",\n  \"src/components/ui/media-audio-node-static.tsx\",\n  \"src/components/ui/media-audio-node.tsx\",\n  \"src/components/ui/media-file-node-static.tsx\",\n  \"src/components/ui/media-file-node.tsx\",\n  \"src/components/ui/media-image-node-static.tsx\",\n  \"src/components/ui/media-image-node.tsx\",\n  \"src/components/ui/media-toolbar-button.tsx\",\n  \"src/components/ui/media-toolbar.tsx\",\n  \"src/components/ui/media-video-node-static.tsx\",\n  \"src/components/ui/media-video-node.tsx\",\n  \"src/components/ui/paragraph-node-static.tsx\",\n  \"src/components/ui/paragraph-node.tsx\",\n  \"src/components/ui/suggestion-node-static.tsx\",\n  \"src/components/ui/suggestion-toolbar-button.tsx\",\n  \"src/components/ui/table-node.tsx\",\n  \"src/components/ui/toc-node-static.tsx\",\n  \"src/components/ui/toc-node.tsx\",\n  \"src/components/ui/toggle-node-static.tsx\",\n  \"src/components/ui/toggle-node.tsx\",\n  \"src/components/ui/turn-into-toolbar-button.tsx\",\n];\n\nfiles.forEach((relPath) => {\n  const filePath = path.join(\"app\", relPath);\n  if (!fs.existsSync(filePath)) {\n    console.warn(`File not found: ${filePath}`);\n    return;\n  }\n  const content = fs.readFileSync(filePath, \"utf8\");\n  if (!content.startsWith(\"// @ts-nocheck\")) {\n    fs.writeFileSync(filePath, `// @ts-nocheck\\n${content}`);\n    console.log(`Added @ts-nocheck to: ${filePath}`);\n  } else {\n    console.log(`Already has @ts-nocheck: ${filePath}`);\n  }\n});\n"
  },
  {
    "path": "utils/class2namespace.ts",
    "content": "import { readFileSync, writeFileSync } from \"fs\";\n\nfunction convertNamespaceToClass(code: string): string {\n  return code\n    .replace(/namespace (\\w+)/gm, \"class $1\")\n    .replace(/^ {2}export (let|const) ([A-Z_]+)/gm, \"static $2\")\n    .replace(/^ {2}export let (\\w+)/gm, \"$1\")\n    .replace(/^ {2}export const (\\w+)/gm, \"readonly $1\")\n    .replace(/^ {2}let (\\w+)/gm, \"private $1\")\n    .replace(/^ {2}const (\\w+)/gm, \"private readonly $1\")\n    .replace(/^ {2}export function init\\(\\)/gm, \"constructor()\")\n    .replace(/^ {2}export function (\\w+)/gm, \"$1\")\n    .replace(/^ {2}export async function (\\w+)/gm, \"async $1\")\n    .replace(/^ {2}function (\\w+)/gm, \"private $1\");\n}\n\nconst path = process.argv[2];\nconst code = readFileSync(path, \"utf-8\");\nconst result = convertNamespaceToClass(code);\nwriteFileSync(path, result, \"utf-8\");\n"
  },
  {
    "path": "utils/generate-service-docs.sh",
    "content": "#!/bin/bash\nset -e\n\nmodel=\"gemini-2.5-flash\"\nout=\"./docs/content/docs/core/services\"\nmkdir -p \"$out\"\n\nfor file in $(grep -rl \"^@service\" app/src/core); do\n  relative=${file#app/src/core/}\n  relative=\"app/src/core/$relative\"\n  service=$(grep -oP '@service\\(\"\\K[^\"]+(?=\"\\))' \"$file\")\n  service=$(echo \"$service\" | sed -E 's/([a-z0-9])([A-Z])/\\1-\\L\\2/g' | tr '[:upper:]' '[:lower:]')\n\n  doc=\"$out/$service.zh-CN.mdx\"\n  [[ -e $doc ]] && { echo \"$(tput setaf 3)文件 $doc 已存在，跳过\"; continue; }\n\n  prompt=$(cat <<EOF\n下面是一段 TypeScript 源码。请完成三件事，用三行“---”作为分隔符输出：\n\n1. 用中文概述这个服务的用途，要求同以前（不要称“类”，分段，善用标题，不要一级标题，API 方法也要标题）。\n2. 给这个服务起一个 lucide 的大驼峰图标名。\n3. 把服务原始大驼峰名翻译成中文，格式：“[原文的大驼峰形式][空格][译文]”。\n\n不要输出多余的解释或空白。\n\nexample:\n用于管理用户会话，跟踪登录状态与权限……\n\n## API\n\n### \\`login(userId: string): Promise<void>\\`\n\n……\n\n### \\`logout(): Promise<void>\\`\n\n……\n---\nUserCog\n---\nUserService 用户服务\nend example.\n\n---\n$(cat \"$file\")\nEOF\n  )\n\n  tput setaf 4\n  echo \"正在生成 $service.zh-CN.mdx, $relative\"\n  tput setaf 8\n\n  # 一次请求拿到三段\n  raw=$(gemini -p \"$prompt\" -m \"$model\")\n\n  # 用 awk 按 \"---\" 拆分\n  doc_body=$(echo \"$raw\" | awk -v RS='---' 'NR==1{print; exit}')\n  icon=$(echo      \"$raw\" | awk -v RS='---' 'NR==2{gsub(/[[:space:]]/, \"\"); print}')\n  title=$(echo     \"$raw\" | awk -v RS='---' 'NR==3{gsub(/^[[:space:]]+|[[:space:]]+$/, \"\"); print}')\n\n  tput setaf 2\n  echo \"标题: $title\"\n  echo \"图标: $icon\"\n\n  cat > \"$doc\" <<EOF\n---\ntitle: $title\nicon: $icon\nrelatedFile: $relative\n---\n\n$doc_body\nEOF\ndone"
  },
  {
    "path": "utils/lines.sh",
    "content": "#!/usr/bin/env bash\n# 统计当前目录下所有 .tsx 文件行数的统计信息（sum / avg / min / max）\n# 用法:\n#   ./count_tsx_lines.sh\n#   ./count_tsx_lines.sh -x node_modules -x dist\n#\n# 设计说明（WHY 而非 WHAT）：\n# - 使用 -print0 + read -d '' 防止空格/特殊字符文件名破坏循环\n# - 不直接用 wc 汇总的 total 行，避免解析风险\n# - 提供 -x 排除目录以避免 node_modules 等巨量无关文件\n# - 使用 bc 保留平均值两位小数；bc 不存在时报 fallback（整数除法）\n\nset -euo pipefail\n\ndeclare -a EXCLUDES=()\n\nprint_help() {\n  cat <<'EOF'\n统计当前目录（递归）所有 .tsx 文件的行数 (sum / avg / min / max)\n\n选项:\n  -x <dir>   排除目录 (可多次使用)，匹配相对路径前缀。例如: -x node_modules -x dist\n  -h         显示本帮助\n\n示例:\n  ./count_tsx_lines.sh\n  ./count_tsx_lines.sh -x node_modules -x dist -x build\nEOF\n}\n\n# 解析参数\nwhile getopts \":x:h\" opt; do\n  case \"$opt\" in\n    x)\n      # 去掉可能的末尾斜杠\n      ex=\"${OPTARG%/}\"\n      EXCLUDES+=(\"$ex\")\n      ;;\n    h)\n      print_help\n      exit 0\n      ;;\n    \\?)\n      echo \"未知选项: -$OPTARG\" >&2\n      exit 2\n      ;;\n    :)\n      echo \"选项 -$OPTARG 需要一个参数\" >&2\n      exit 2\n      ;;\n  esac\ndone\nshift $((OPTIND - 1))\n\n# 构造排除参数（使用 -not -path './dir/*'）\n# 注意：如果目录不存在也不会报错\nexclude_args=()\nfor d in \"${EXCLUDES[@]}\"; do\n  # 统一相对路径前缀\n  exclude_args+=( -not -path \"./${d}\" -not -path \"./${d}/*\" )\ndone\n\n# 收集文件列表\ndeclare -a FILES=()\nwhile IFS= read -r -d '' f; do\n  FILES+=(\"$f\")\ndone < <(find . -type f -name '*.tsx' \"${exclude_args[@]}\" -print0)\n\nfile_count=${#FILES[@]}\nif [[ $file_count -eq 0 ]]; then\n  echo \"No .tsx files found (after exclusions).\"\n  exit 0\nfi\n\nsum=0\nmin=\nmax=\n# 也可输出每个文件行数，按需开启\n# printf \"%s\\t%s\\n\" \"Lines\" \"File\"\n\nfor f in \"${FILES[@]}\"; do\n  # 使用重定向避免 wc 输出文件名，性能更好\n  if ! lines=$(wc -l < \"$f\"); then\n    echo \"读取文件失败: $f\" >&2\n    exit 1\n  fi\n  # printf \"%s\\t%s\\n\" \"$lines\" \"$f\"\n  sum=$((sum + lines))\n  if [[ -z \"${min}\" || lines -lt min ]]; then\n    min=$lines\n  fi\n  if [[ -z \"${max}\" || lines -gt max ]]; then\n    max=$lines\n  fi\ndone\n\n# 平均值，优先使用 bc 保留两位\nif command -v bc >/dev/null 2>&1; then\n  avg=$(echo \"scale=2; $sum / $file_count\" | bc)\nelse\n  # fallback：整数除法\n  avg=$((sum / file_count))\nfi\n\necho \"Files: $file_count\"\necho \"Sum  : $sum\"\necho \"Min  : $min\"\necho \"Max  : $max\"\necho \"Avg  : $avg\"\n\n# 额外：如需按行数排序列出前/后若干文件，可追加：\n# for f in \"${FILES[@]}\"; do echo \"$(wc -l < \"$f\")\"$'\\t'\"$f\"; done | sort -n | head\n"
  },
  {
    "path": "utils/relative2alias.ts",
    "content": "import { readdirSync, readFileSync, statSync, writeFileSync } from \"node:fs\";\nimport { extname, join, relative, resolve } from \"node:path\";\n\nconst SRC_DIR = resolve(process.cwd(), \"src\"); // @ 指向目录\nconst EXTS = [\".ts\", \".tsx\", \".js\", \".jsx\"];\n\n/**\n * 判断是否为相对路径\n */\n// const isRelative = (p: string) => p.startsWith(\"./\") || p.startsWith(\"../\");\n\n/**\n * 将相对路径 import 转换成 @/xxx\n */\nfunction toAlias(currentFile: string, rawImport: string) {\n  const absolute = resolve(currentFile, \"..\", rawImport);\n  const rel = relative(SRC_DIR, absolute).replace(/\\\\/g, \"/\");\n  if (rel.startsWith(\"..\")) return null; // 超出 src 范围，保持原样\n  return `@/${rel}`;\n}\n\n/**\n * 替换单个文件\n */\nfunction fixFile(file: string) {\n  const code = readFileSync(file, \"utf8\");\n  const newCode = code.replace(/from\\s+['\"]([^'\"]+)['\"]/g, (_, quote: string) => {\n    if (!quote.includes(\"./\")) return _;\n    const alias = toAlias(file, quote);\n    return alias ? `from \"@/${relative(SRC_DIR, resolve(file, \"..\", quote)).replace(/\\\\/g, \"/\")}\"` : _;\n  });\n  if (newCode !== code) {\n    writeFileSync(file, newCode);\n    console.log(`✅ ${file}`);\n  }\n}\n\n/**\n * 递归目录\n */\nfunction walk(dir: string) {\n  for (const name of readdirSync(dir)) {\n    const full = join(dir, name);\n    if (statSync(full).isDirectory()) {\n      walk(full);\n    } else if (EXTS.includes(extname(name))) {\n      fixFile(full);\n    }\n  }\n}\n\nwalk(SRC_DIR);\n"
  },
  {
    "path": "vitest.config.ts",
    "content": "import { defineProject } from \"vitest/config\";\n\nexport default defineProject({\n  test: {\n    projects: [\"packages/*\"],\n  },\n});\n"
  },
  {
    "path": "文本节点功能分析.md",
    "content": "# 文本节点功能实现难度分析\n\n## 1. 当前实现情况\n\n- **形状**：所有文本节点均为矩形，使用 `Rectangle` 类定义\n- **字体大小**：固定为 `Renderer.FONT_SIZE = 32`\n- **节点大小**：支持自动调整或手动调整\n- **渲染流程**：统一使用矩形渲染逻辑\n\n## 2. 不同字体大小的文本节点\n\n### 实现要点\n\n1. **属性扩展**：在 `TextNode` 类中添加 `fontSize` 属性\n2. **尺寸计算**：修改 `adjustSizeByText` 方法，使用节点自身字体大小\n3. **渲染逻辑**：修改 `renderTextNodeTextLayer` 方法，使用节点自身字体大小\n4. **向后兼容**：为现有节点设置默认字体大小\n5. **工具函数适配**：确保 `getMultiLineTextSize` 等函数使用正确的字体大小\n\n### 影响范围\n\n- `TextNode` 类：添加属性和修改尺寸计算\n- `TextNodeRenderer` 类：修改文本渲染逻辑\n- `Renderer` 类：可能需要调整默认值\n- 相关工具函数：确保参数传递正确\n\n### 实现难度\n\n**低**：主要是属性添加和参数传递，改动相对集中，风险较小\n\n## 3. 不同形状的文本节点\n\n### 实现要点\n\n1. **数据结构修改**：将 `collisionBox.shapes[0]` 从固定 `Rectangle` 改为支持多种形状\n2. **渲染逻辑扩展**：根据不同形状调用不同的渲染方法\n3. **碰撞检测适配**：确保不同形状的碰撞检测正确\n4. **调整大小逻辑**：不同形状的调整方式差异很大\n5. **兼容性处理**：处理依赖矩形属性的现有代码\n\n### 影响范围\n\n- `TextNode` 类：核心数据结构修改\n- `TextNodeRenderer` 类：大幅修改渲染逻辑\n- `CollisionBox` 相关：碰撞检测逻辑调整\n- 调整大小控制器：需要支持不同形状\n- 多处依赖矩形属性的代码：需要适配\n\n### 实现难度\n\n**高**：涉及核心数据结构和多个模块，改动范围广，风险大，需要处理复杂的边缘情况\n\n## 4. 建议实现顺序\n\n### 先实现不同字体大小的文本节点\n\n**理由**：\n\n1. 实现难度低，风险小\n2. 用户需求可能更迫切（字体大小调整是常见需求）\n3. 改动集中，易于测试和维护\n4. 为后续实现不同形状奠定基础\n5. 可以快速看到效果，提升用户体验\n\n### 后实现不同形状的文本节点\n\n**理由**：\n\n1. 实现难度高，需要更多的设计和测试\n2. 可以基于字体大小调整的经验进行设计\n3. 可以先收集用户对形状的具体需求\n4. 避免一次性引入过多复杂性\n\n## 5. 关键修改点\n\n### 字体大小调整关键修改\n\n1. **TextNode.tsx**：添加 `fontSize` 属性，修改 `adjustSizeByText` 方法\n2. **TextNodeRenderer.tsx**：修改 `renderTextNodeTextLayer` 方法，使用节点的字体大小\n3. **Renderer.tsx**：保留 `FONT_SIZE` 作为默认值\n4. **font.tsx**：确保文本测量函数正确使用传入的字体大小\n\n### 不同形状关键修改（未来）\n\n1. **TextNode.tsx**：修改 `collisionBox` 处理逻辑，支持多种形状\n2. **TextNodeRenderer.tsx**：添加不同形状的渲染分支\n3. **CollisionBox** 相关：增强碰撞检测支持\n4. **调整大小控制器**：支持不同形状的调整方式\n\n## 结论\n\n**建议先实现不同字体大小的文本节点**，因为其实现难度低、风险小、用户需求迫切，可以快速提升用户体验。不同形状的文本节点可以在后续版本中实现，需要更多的设计和开发工作。\n"
  }
]