This commit is contained in:
2025-12-26 22:33:30 +08:00
parent 869201d08a
commit 93a8eb9ffb
10 changed files with 581 additions and 2 deletions

1
.serena/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/cache

View File

@@ -0,0 +1,27 @@
# 项目概览yys-editor
目标与定位
- yys-editor 是一个基于浏览器的可视化编辑器,用于拖拽排布素材并生成展示效果(围绕式神/御魂等要素)。
- 适合快速制作、调整与导出展示图(如阵容/排行等)。
技术要点
- Vue 3 + Vite + Element Plus + Pinia + vue-i18n
- 入口:`index.html` -> `/src/main.js` -> `App.vue`
- 路径别名:`@` -> `src/`
- 资源:大量静态图片位于 `public/assets/`
- 语言中文、日文从浏览器语言推断fallback 为 `zh`
当前现状
- 包管理器npm存在 `package-lock.json`
- 脚本:`dev`/`build`/`preview`/`lint`/`format`
- Lint/FormatESLint + Prettier无项目级 Prettier 配置文件,使用默认)
- 测试:未配置自动化测试
典型编辑流程
1) `npm install`
2) `npm run dev` 在 http://localhost:5173/ 进行开发与手动验收
3) `npm run lint` + `npm run format` 保持风格统一
4) `npm run build` 产出 `dist/``npm run preview` 进行生产包预览
其它
- 状态存储使用 Pinia并通过 `localStorage` 自动保存;应用启动时支持恢复上次未保存内容。

View File

@@ -0,0 +1,30 @@
# yys-editor 项目结构(概要)
根目录
- `index.html`Vite 入口 HTML挂载点 `#app`,引入 `/src/main.js`
- `package.json` / `package-lock.json`npm 包与脚本;包管理器为 npm。
- `vite.config.js`Vite 配置,`@` -> `src/`
- `jsconfig.json`:编辑器路径提示(`@/*` -> `./src/*`)。
- `.gitignore`:忽略 `node_modules/``dist/` 等。
- `.vscode/`推荐扩展Volar
- `README.md`:项目说明(中文)。
- `public/`:静态资源目录(大量图片素材:`assets/Shikigami`, `assets/Yuhun` 等)。
`src/`
- `main.js`:应用入口,注册 Element Plus、Icons、vue-i18n、Pinia、vue3-draggable-resizable挂载 `App.vue`
- `App.vue`:主布局(工具栏、侧边栏、工作区 Tab根据文件类型切换主要编辑视图。
- `components/`
- 核心:`Yys.vue`, `YysRank.vue`, `Toolbar.vue`, `ProjectExplorer.vue`
- 基础:`ShikigamiSelect.vue`, `YuhunSelect.vue`, `Watermark.vue`, `HelloWorld.vue`
- `components/icons/`:若干图标组件
- `assets/`:基础样式 `base.css`, `main.css` 与 logo 等
- `data/`:若干 JSON 数据(如 `Shikigami.json`, `Yuhun.json`, `property.json`, `updateLog.json`
- `locales/`:多语言资源 `zh.json``ja.json`
- `ts/`:脚本与 store
- `files.ts`Pinia store文件页签、可见性、删除/重命名;含 `localStorage` 自动保存与启动恢复提示)
- `useGlobalMessage.ts`全局消息Element Plus
- `types/`:类型定义(如后续扩展)
说明
- 未见 `router` 相关文件;当前为单页多区域布局。
- 构建产物输出到 `dist/``npm run build`)。

View File

@@ -0,0 +1,33 @@
# yys-editor 技术栈与风格约定
技术栈
- 前端框架Vue 3Composition API部分 SFC 使用 `<script setup lang="ts">`
- 构建与开发Vite
- UI 组件Element Plus`@element-plus/icons-vue`
- 状态管理Pinia示例`src/ts/files.ts` 存储文件页签等,含本地存储自动保存)
- 国际化vue-i18n`src/locales/zh.json` + `src/locales/ja.json`默认从浏览器语言推断fallback 为 `zh`
- 拖拽缩放vue3-draggable-resizable
- 其它依赖:`@vueup/vue-quill`(如后续使用富文本)/ `html2canvas` / `vuedraggable`
工程与路径
- 别名:`@` 指向 `src/`(见 `vite.config.js``jsconfig.json`)。
- 入口:`index.html` -> `/src/main.js` -> `App.vue`
- 静态资源:`public/`(大量图片素材位于 `public/assets/...`)。
代码风格
- 代码格式Prettier未见项目级配置使用默认规则脚本仅格式化 `src/`)。
- 语法检查ESLint`eslint-plugin-vue` + `@vue/eslint-config-prettier`;脚本已启用 `--fix`)。
- 组件命名PascalCase 单文件组件(`.vue`),按功能归档于 `src/components/`
- 类型:以 JS 为主SFC 中可使用 TS经 Vite/ESBuild 处理,无专门 `tsconfig.json`,有 `jsconfig.json` 路径提示)。
- 国际化:新增/修改文案时,请同步维护 `zh.json``ja.json`,避免硬编码 UI 文案。
- 状态与持久化Pinia store 通过 `localStorage` 自动保存,注意 key 兼容;涉及 schema 变更时考虑迁移逻辑。
样式
- 常规样式位于 `src/assets/*.css`,组件中常用 `<style scoped>` 以局部隔离。
- UI 主题由 Element Plus 提供,如需自定义主题请统一约定变量来源。
提交与约定
- 未发现提交规范配置(如 commitlint/husky建议信息清晰、原子化提交。必要时中英文均可但保持一致性。
测试
- 未配置自动化测试框架;变更请重点进行手动验收(参见 `task_completion_checklist.md`)。

View File

@@ -0,0 +1,26 @@
# yys-editor 常用命令Windows/PowerShell
- 安装依赖:
- `npm install`
- 本地开发Vite 开发服务器,默认 http://localhost:5173/
- `npm run dev`
- 生产构建(输出到 `dist/`
- `npm run build`
- 本地预览生产包:
- `npm run preview`
- 代码检查ESLint自动修复
- `npm run lint`
- 代码格式化Prettier`src/` 目录):
- `npm run format`
辅助命令PowerShell 常用):
- 进入目录/查看/读取文件:`cd`, `ls` (Get-ChildItem), `cat file` (Get-Content -Raw)
- 删除/复制/移动:`rm -r -Force path` (Remove-Item), `cp src dst` (Copy-Item), `mv src dst` (Move-Item)
- Git`git status`, `git add -p`, `git commit -m "msg"`, `git switch -c <branch>`, `git push`
- 进程占用端口排查(可选):`Get-Process``netstat -ano | findstr 5173`
- 快速全文搜索(若已安装):`rg "pattern"`
说明:
- 包管理器使用 npm`package-lock.json`)。
- Vite 默认端口 5173未在 `vite.config.js` 中覆写。
- 项目未配置自动化测试命令。

View File

@@ -0,0 +1,22 @@
# 任务完成前检查清单yys-editor
功能与兼容
- 本地自测通过:`npm run dev` 无报错、关键路径可用(切换 Tab、新增/删除文件、编辑视图渲染、国际化切换等)。
- UI 走查常用分辨率下布局正常Toolbar、侧边栏、工作区滚动
- 资源校验:新增素材放入 `public/assets/...`,路径正确;体积适中(尽量压缩)。
- i18n新增/修改的 UI 文案同步维护 `src/locales/zh.json``src/locales/ja.json`
代码质量
- 语法检查:`npm run lint`(包含 `--fix` 自动修复)。
- 代码格式:`npm run format`(当前仅格式化 `src/` 目录)。
- 引用规范:优先使用 `@/...` 别名;避免相对路径层级过深。
- 状态与持久化:如更改 `files` store 结构,评估 `localStorage` 兼容/迁移策略。
构建与预览
- 生产构建:`npm run build` 成功,产物在 `dist/`
- 生产预览:`npm run preview` 本地检查基础路由与功能。
提交
- 更新文档(如需):`README.md` 或注释。
- Git 提交清晰、原子:`git add -p``git commit -m "feat/fix: ..."`
- 如涉及较大静态资源,建议说明来源与 License。

84
.serena/project.yml Normal file
View File

@@ -0,0 +1,84 @@
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp csharp_omnisharp
# dart elixir elm erlang fortran go
# haskell java julia kotlin lua markdown
# nix perl php python python_jedi r
# rego ruby ruby_solargraph rust scala swift
# terraform typescript typescript_vts yaml zig
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# Special requirements:
# - csharp: Requires the presence of a .sln file in the project folder.
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- vue
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed) on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
project_name: "yys-editor"
included_optional_tools: []

10
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
"files.exclude": {
"**/.git": true,
"**/.svn": true,
"**/.hg": true,
"**/.DS_Store": true,
"**/Thumbs.db": true,
".mule": true
}
}

26
AGENTS.md Normal file
View File

@@ -0,0 +1,26 @@
## Documentation Lookup
When documentation lookup is required:
- The agent should use Context7 as the primary source of truth.
- Do not rely on assumptions if documentation can be retrieved via Context7.
## Progress Update Rules
The project progress is tracked in:
- `/docs/1management/plan.md`
When the user explicitly states that a task or step is completed (e.g. “当前任务完成”, “这一步做完了”, “可以更新进度了”):
The agent MUST:
1. Identify the corresponding step or module in `plan.md`.
2. Update the completion status or percentage of that specific step.
3. Recalculate and update the overall progress to reflect this change.
4. Keep all other progress entries unchanged.
The agent MUST NOT:
- Update progress proactively without explicit confirmation from the user.
- Guess completion status or infer progress implicitly.
- Modify unrelated sections in `plan.md`.
If the mapping between the completed task and `plan.md` is ambiguous:
- Ask for clarification before making any changes.

View File

@@ -56,12 +56,13 @@
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
import LogicFlow, { EventType } from '@logicflow/core';
import type { Position, NodeData, EdgeData, BaseNodeModel, GraphModel } from '@logicflow/core';
import type { Position, NodeData, EdgeData, BaseNodeModel, GraphModel, GraphData } from '@logicflow/core';
import '@logicflow/core/lib/style/index.css';
import { Menu, Label, Snapshot, SelectionSelect } from '@logicflow/extension';
import '@logicflow/extension/lib/style/index.css';
import '@logicflow/core/es/index.css';
import '@logicflow/extension/es/index.css';
import { translateEdgeData, translateNodeData } from '@logicflow/core/es/keyboard/shortcut';
import { register } from '@logicflow/vue-node-registry';
import ShikigamiSelectNode from './nodes/yys/ShikigamiSelectNode.vue';
@@ -76,6 +77,10 @@ import { setLogicFlowInstance, destroyLogicFlowInstance } from '@/ts/useLogicFlo
type AlignType = 'left' | 'right' | 'top' | 'bottom' | 'hcenter' | 'vcenter';
type DistributeType = 'horizontal' | 'vertical';
const MOVE_STEP = 2;
const MOVE_STEP_LARGE = 10;
const COPY_TRANSLATION = 40;
const props = defineProps<{
height?: string;
}>();
@@ -101,6 +106,280 @@ const { showMessage } = useGlobalMessage();
// 当前选中节点
const selectedNode = ref<any>(null);
const copyBuffer = ref<GraphData | null>(null);
let nextPasteDistance = COPY_TRANSLATION;
function isInputLike(event?: KeyboardEvent) {
const target = event?.target as HTMLElement | null;
if (!target) return false;
const tag = target.tagName?.toLowerCase();
return ['input', 'textarea', 'select', 'option'].includes(tag) || target.isContentEditable;
}
function shouldSkipShortcut(event?: KeyboardEvent) {
const lfInstance = lf.value as any;
if (!lfInstance) return true;
if (lfInstance.keyboard?.disabled) return true;
if (lfInstance.graphModel?.textEditElement) return true;
if (isInputLike(event)) return true;
return false;
}
function ensureMeta(meta?: Record<string, any>) {
const next: Record<string, any> = meta ? { ...meta } : {};
if (next.visible == null) next.visible = true;
if (next.locked == null) next.locked = false;
return next;
}
function applyMetaToModel(model: BaseNodeModel, metaInput?: Record<string, any>) {
const lfInstance = lf.value;
const meta = ensureMeta(metaInput ?? (model.getProperties?.() as any)?.meta ?? (model as any)?.properties?.meta);
model.visible = meta.visible !== false;
model.draggable = !meta.locked;
model.setHittable?.(!meta.locked);
model.setHitable?.(!meta.locked);
model.setIsShowAnchor?.(!meta.locked);
model.setRotatable?.(!meta.locked);
model.setResizable?.(!meta.locked);
if (lfInstance) {
const connectedEdges = lfInstance.getNodeEdges(model.id);
connectedEdges.forEach((edgeModel) => {
edgeModel.visible = meta.visible !== false;
});
}
}
function normalizeNodeModel(model: BaseNodeModel) {
const lfInstance = lf.value;
if (!lfInstance) return;
const props = (model.getProperties?.() as any) ?? (model as any)?.properties ?? {};
const incomingMeta = ensureMeta(props.meta);
const currentMeta = ensureMeta((model as any)?.properties?.meta);
const needPersist =
currentMeta.visible !== incomingMeta.visible ||
currentMeta.locked !== incomingMeta.locked ||
currentMeta.groupId !== incomingMeta.groupId;
if (needPersist) {
lfInstance.setProperties(model.id, { ...props, meta: incomingMeta });
}
applyMetaToModel(model, incomingMeta);
}
function normalizeAllNodes() {
const lfInstance = lf.value;
if (!lfInstance) return;
lfInstance.graphModel?.nodes.forEach((model: BaseNodeModel) => normalizeNodeModel(model));
}
function updateNodeMeta(model: BaseNodeModel, updater: (meta: Record<string, any>) => Record<string, any>) {
const lfInstance = lf.value;
if (!lfInstance) return;
const props = (model.getProperties?.() as any) ?? (model as any)?.properties ?? {};
const nextMeta = updater(ensureMeta(props.meta));
lfInstance.setProperties(model.id, { ...props, meta: nextMeta });
applyMetaToModel(model, nextMeta);
}
function getSelectedNodeModels() {
const graphModel = lf.value?.graphModel;
if (!graphModel) return [];
return [...graphModel.selectNodes];
}
function collectGroupNodeIds(models: BaseNodeModel[]) {
const graphModel = lf.value?.graphModel;
if (!graphModel) return [];
const ids = new Set<string>();
models.forEach((model) => {
const meta = ensureMeta((model.getProperties?.() as any)?.meta ?? (model as any)?.properties?.meta);
if (meta.locked) return;
if (meta.groupId) {
graphModel.nodes.forEach((node) => {
const peerMeta = ensureMeta((node.getProperties?.() as any)?.meta ?? (node as any)?.properties?.meta);
if (peerMeta.groupId === meta.groupId && !peerMeta.locked) {
ids.add(node.id);
}
});
} else {
ids.add(model.id);
}
});
return Array.from(ids);
}
function moveSelectedNodes(deltaX: number, deltaY: number) {
const graphModel = lf.value?.graphModel;
if (!graphModel) return;
const targets = collectGroupNodeIds(getSelectedNodeModels());
if (!targets.length) return;
graphModel.moveNodes(targets, deltaX, deltaY);
}
function deleteSelectedElements(event?: KeyboardEvent) {
if (shouldSkipShortcut(event)) return true;
const lfInstance = lf.value;
if (!lfInstance) return true;
const { nodes, edges } = lfInstance.getSelectElements(true);
const lockedNodes = nodes.filter((node) => ensureMeta((node as any).properties?.meta).locked);
edges.forEach((edge) => edge.id && lfInstance.deleteEdge(edge.id));
nodes
.filter((node) => {
const meta = ensureMeta((node as any).properties?.meta);
return !meta.locked && meta.visible !== false;
})
.forEach((node) => node.id && lfInstance.deleteNode(node.id));
if (lockedNodes.length) {
showMessage('warning', '部分节点已锁定,未删除');
}
updateSelectedCount();
selectedNode.value = null;
return false;
}
function toggleLockSelected(event?: KeyboardEvent) {
if (shouldSkipShortcut(event)) return true;
const models = getSelectedNodeModels();
if (!models.length) {
showMessage('info', '请选择节点后再执行锁定/解锁');
return true;
}
const hasUnlocked = models.some((model) => !ensureMeta((model.getProperties?.() as any)?.meta).locked);
models.forEach((model) => {
updateNodeMeta(model, (meta) => ({ ...meta, locked: hasUnlocked ? true : false }));
});
return false;
}
function toggleVisibilitySelected(event?: KeyboardEvent) {
if (shouldSkipShortcut(event)) return true;
const models = getSelectedNodeModels();
if (!models.length) {
showMessage('info', '请选择节点后再执行显示/隐藏');
return true;
}
const hasVisible = models.some((model) => ensureMeta((model.getProperties?.() as any)?.meta).visible !== false);
models.forEach((model) => {
updateNodeMeta(model, (meta) => ({ ...meta, visible: !hasVisible ? true : false }));
});
if (hasVisible) {
selectedNode.value = null;
}
updateSelectedCount();
return false;
}
function groupSelectedNodes(event?: KeyboardEvent) {
if (shouldSkipShortcut(event)) return true;
const models = getSelectedNodeModels().filter((model) => !ensureMeta((model.getProperties?.() as any)?.meta).locked);
if (models.length < 2) {
showMessage('warning', '请选择至少两个未锁定的节点进行分组');
return true;
}
const groupId = `group_${Date.now().toString(36)}`;
models.forEach((model) => {
updateNodeMeta(model, (meta) => ({ ...meta, groupId }));
});
return false;
}
function ungroupSelectedNodes(event?: KeyboardEvent) {
if (shouldSkipShortcut(event)) return true;
const models = getSelectedNodeModels();
if (!models.length) {
showMessage('info', '请选择节点后再执行解组');
return true;
}
models.forEach((model) => {
updateNodeMeta(model, (meta) => {
const nextMeta = { ...meta };
delete nextMeta.groupId;
return nextMeta;
});
});
return false;
}
function handleArrowMove(direction: 'left' | 'right' | 'up' | 'down', event?: KeyboardEvent) {
if (shouldSkipShortcut(event)) return true;
const step = (event?.shiftKey ? MOVE_STEP_LARGE : MOVE_STEP) * (direction === 'left' || direction === 'up' ? -1 : 1);
if (direction === 'left' || direction === 'right') {
moveSelectedNodes(step, 0);
} else {
moveSelectedNodes(0, step);
}
return false;
}
function remapGroupIds(nodes: GraphData['nodes']) {
const map = new Map<string, string>();
const seed = Date.now().toString(36);
nodes.forEach((node, index) => {
const meta = ensureMeta((node as any).properties?.meta);
if (meta.groupId) {
if (!map.has(meta.groupId)) {
map.set(meta.groupId, `group_${seed}_${index}`);
}
meta.groupId = map.get(meta.groupId);
}
(node as any).properties = { ...(node as any).properties, meta };
});
}
function handleCopy(event?: KeyboardEvent) {
if (shouldSkipShortcut(event)) return true;
const lfInstance = lf.value;
if (!lfInstance) return true;
const elements = lfInstance.getSelectElements(false);
if (!elements.nodes.length && !elements.edges.length) {
copyBuffer.value = null;
return true;
}
const nodes = elements.nodes.map((node) => translateNodeData(JSON.parse(JSON.stringify(node)), COPY_TRANSLATION));
const edges = elements.edges.map((edge) => translateEdgeData(JSON.parse(JSON.stringify(edge)), COPY_TRANSLATION));
remapGroupIds(nodes);
copyBuffer.value = { nodes, edges };
nextPasteDistance = COPY_TRANSLATION;
return false;
}
function handlePaste(event?: KeyboardEvent) {
if (shouldSkipShortcut(event)) return true;
const lfInstance = lf.value;
if (!lfInstance || !copyBuffer.value) return true;
lfInstance.clearSelectElements();
const added = lfInstance.addElements(copyBuffer.value, nextPasteDistance);
if (added) {
added.nodes.forEach((model) => {
normalizeNodeModel(model);
lfInstance.selectElementById(model.id, true);
});
added.edges.forEach((edge) => lfInstance.selectElementById(edge.id, true));
copyBuffer.value.nodes.forEach((node) => translateNodeData(node, COPY_TRANSLATION));
copyBuffer.value.edges.forEach((edge) => translateEdgeData(edge, COPY_TRANSLATION));
nextPasteDistance += COPY_TRANSLATION;
updateSelectedCount(lfInstance.graphModel);
}
return false;
}
function handleNodeDrag(args: { data: NodeData; deltaX: number; deltaY: number }) {
const { data, deltaX, deltaY } = args;
if (!deltaX && !deltaY) return;
const graphModel = lf.value?.graphModel;
if (!graphModel) return;
const model = graphModel.getNodeModelById(data.id);
if (!model) return;
const targets = collectGroupNodeIds([model]).filter((id) => id !== model.id);
if (!targets.length) return;
graphModel.moveNodes(targets, deltaX, deltaY);
}
function updateSelectedCount(model?: GraphModel) {
const lfInstance = lf.value;
@@ -127,7 +406,11 @@ function applySnapGrid(enabled: boolean) {
function getSelectedRects() {
const lfInstance = lf.value;
if (!lfInstance) return [];
return lfInstance.graphModel.selectNodes.map((model: BaseNodeModel) => {
const unlocked = lfInstance.graphModel.selectNodes.filter((model: BaseNodeModel) => {
const meta = ensureMeta((model.getProperties?.() as any)?.meta ?? (model as any)?.properties?.meta);
return !meta.locked && meta.visible !== false;
});
return unlocked.map((model: BaseNodeModel) => {
const bounds = model.getBounds();
const width = bounds.maxX - bounds.minX;
const height = bounds.maxY - bounds.minY;
@@ -242,6 +525,9 @@ onMounted(() => {
allowRotate: true,
overlapMode: -1,
snapline: true,
keyboard: {
enabled: true
},
plugins: [Menu, Label, Snapshot, SelectionSelect],
pluginsOptions: {
label: {
@@ -257,6 +543,26 @@ onMounted(() => {
const lfInstance = lf.value;
if (!lfInstance) return;
lfInstance.keyboard.off(['cmd + c', 'ctrl + c']);
lfInstance.keyboard.off(['cmd + v', 'ctrl + v']);
lfInstance.keyboard.off(['backspace']);
const bindShortcut = (keys: string | string[], handler: (event?: KeyboardEvent) => boolean | void) => {
lfInstance.keyboard.on(keys, (event: KeyboardEvent) => handler(event));
};
bindShortcut(['del', 'backspace'], deleteSelectedElements);
bindShortcut(['left'], (event) => handleArrowMove('left', event));
bindShortcut(['right'], (event) => handleArrowMove('right', event));
bindShortcut(['up'], (event) => handleArrowMove('up', event));
bindShortcut(['down'], (event) => handleArrowMove('down', event));
bindShortcut(['cmd + c', 'ctrl + c'], handleCopy);
bindShortcut(['cmd + v', 'ctrl + v'], handlePaste);
bindShortcut(['cmd + g', 'ctrl + g'], groupSelectedNodes);
bindShortcut(['cmd + u', 'ctrl + u'], ungroupSelectedNodes);
bindShortcut(['cmd + l', 'ctrl + l'], toggleLockSelected);
bindShortcut(['cmd + shift + h', 'ctrl + shift + h'], toggleVisibilitySelected);
lfInstance.extension.menu.addMenuConfig({
nodeMenu: [
{
@@ -307,6 +613,7 @@ onMounted(() => {
lfInstance.render({
// 渲染的数据
});
normalizeAllNodes();
lfInstance.updateEditConfig({
multipleSelectKey: 'shift',
snapGrid: snapGridEnabled.value
@@ -320,6 +627,15 @@ onMounted(() => {
updateSelectedCount();
});
lfInstance.on(EventType.NODE_DRAG, (args) => handleNodeDrag(args as any));
lfInstance.on(EventType.NODE_ADD, ({ data }) => {
const model = lfInstance.getNodeModelById(data.id);
if (model) normalizeNodeModel(model);
});
lfInstance.on(EventType.GRAPH_RENDERED, () => normalizeAllNodes());
// 监听空白点击事件,取消选中
lfInstance.on(EventType.BLANK_CLICK, () => {
selectedNode.value = null;
@@ -337,6 +653,10 @@ onMounted(() => {
};
}
}
const model = lfInstance.getNodeModelById(nodeId);
if (model && data.properties?.meta) {
applyMetaToModel(model, data.properties.meta);
}
});
lfInstance.on('selection:selected', () => updateSelectedCount());