@mx-sose-front/mx-sose-graph 1.1.3 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/index.d.ts +177 -9
  2. package/dist/index.esm.js +4569 -63119
  3. package/dist/index.esm.js.map +1 -1
  4. package/dist/index.umd.js +1 -39
  5. package/dist/index.umd.js.map +1 -1
  6. package/dist/style.css +1 -1
  7. package/package.json +10 -1
  8. package/src/components/ContextMenu/ContextMenu.vue +10 -10
  9. package/src/components/DiagramListTooltip/DiagramListTooltip.vue +7 -12
  10. package/src/components/InteractionLayer.vue +323 -157
  11. package/src/components/LineStyle/LineStyleMarker.vue +1 -1
  12. package/src/components/{NameEditor.vue → NameEditor/NameEditor.vue} +4 -4
  13. package/src/components/{SelectionBox.vue → SelectionBox/SelectionBox.vue} +5 -5
  14. package/src/components/Shape/Block.vue +1 -1
  15. package/src/constants/edgeShapeKeys.ts +43 -3
  16. package/src/constants/index.ts +19 -4
  17. package/src/hooks/index.ts +3 -0
  18. package/src/hooks/useHighlight.ts +223 -0
  19. package/src/hooks/useNameEdit.ts +234 -0
  20. package/src/{utils/resizeUtils.ts → hooks/useResize.ts} +55 -155
  21. package/src/index.ts +4 -1
  22. package/src/render/shape-renderer.ts +59 -46
  23. package/src/statics/icons/createMenu/show.png +0 -0
  24. package/src/statics/icons/createMenu/tree.png +0 -0
  25. package/src/statics/icons/createMenu//345/261/225/347/244/272/347/253/257/345/217/243/345/261/236/346/200/247@3x.png +0 -0
  26. package/src/statics/icons/createMenu//345/261/225/347/244/272/350/277/236/347/272/277@3x.png +0 -0
  27. package/src/statics/icons/createMenu//346/211/200/345/234/250/345/233/276/350/241/250@3x.png +0 -0
  28. package/src/store/graphStore.ts +185 -65
  29. package/src/types/index.ts +4 -2
  30. package/src/types/interactionLayer.ts +1 -0
  31. package/src/utils/batchAutoExpand.ts +65 -0
  32. package/src/utils/compartment.ts +78 -4
  33. package/src/utils/containers.ts +24 -10
  34. package/src/utils/contextMenuUtils.ts +106 -147
  35. package/src/utils/drag.ts +10 -5
  36. package/src/utils/edgeUtils.ts +3 -4
  37. package/src/utils/graphDragService.ts +27 -23
  38. package/src/utils/iconLoader.ts +7 -7
  39. package/src/utils/keyboardUtils.ts +195 -32
  40. package/src/utils/pinUtils.ts +1 -2
  41. package/src/utils/shapeOps/shapeOps.ts +168 -0
  42. package/src/utils/viewportCulling.ts +193 -0
  43. package/src/view/graph.vue +115 -60
  44. package/src/utils/highlightUtils.ts +0 -162
  45. package/src/utils/nameEditUtils.ts +0 -137
  46. /package/src/statics/icons/createMenu/{scissors.png → cut.png} +0 -0
@@ -25,7 +25,7 @@
25
25
 
26
26
  <!-- 泛化 - 空心三角形箭头 -->
27
27
  <template v-else-if="shapeKey === EDGE_TYPE.GENERALIZATION">
28
- <marker :id="`diamond-${shapeKey}`" markerWidth="10" markerHeight="7" refX="" refY="3.5" orient="auto">
28
+ <marker :id="`diamond-${shapeKey}`" markerWidth="10" markerHeight="7" refX="0" refY="3.5" orient="auto">
29
29
  <path d="M0 0 L10 3.5 L0 7 Z" fill="none" stroke="#5E5E5E" stroke-width="1" />
30
30
  </marker>
31
31
  </template>
@@ -42,9 +42,9 @@
42
42
 
43
43
  <script setup lang="ts">
44
44
  import { ref, watch, computed, nextTick, type CSSProperties } from 'vue';
45
- import type { Shape } from '../types';
46
- import { nameTextBoxContainerStyle, nameTextBoxStyle, nameEditorContainerStyle, nameInputStyle } from '../utils/diagram';
47
- import type { NameEditManager } from '../utils/nameEditUtils';
45
+ import type { Shape } from '../../types';
46
+ import { nameTextBoxContainerStyle, nameTextBoxStyle, nameEditorContainerStyle, nameInputStyle } from '../../utils/diagram';
47
+ import type { INameEditManager } from '../../hooks/useNameEdit';
48
48
 
49
49
  // 定义组件的props
50
50
  interface NameEditorProps {
@@ -52,7 +52,7 @@ interface NameEditorProps {
52
52
  canEdit: boolean;
53
53
  isEditingName: boolean;
54
54
  editingName: string;
55
- nameEditManager: NameEditManager;
55
+ nameEditManager: INameEditManager;
56
56
  }
57
57
 
58
58
  // 定义组件的事件
@@ -22,7 +22,7 @@
22
22
  @mousedown.stop.prevent="onModelTypePropertyIdClick"
23
23
  title="设置类型"
24
24
  >
25
- <img src="../statics/icons/childIcons/设置类型.png" alt="设置类型" />
25
+ <img src="../../statics/icons/childIcons/设置类型.png" alt="设置类型" />
26
26
  </button>
27
27
  </div>
28
28
  <button
@@ -41,15 +41,15 @@
41
41
 
42
42
  <script setup lang="ts">
43
43
  import { computed } from "vue";
44
- import type { Shape } from "../types";
45
- import { resizeHandles } from "../constants/index";
44
+ import type { Shape } from "../../types";
45
+ import { resizeHandles } from "../../constants/index";
46
46
  import {
47
47
  selectionBoxStyle,
48
48
  handleStyle,
49
49
  actionButtonsStyle,
50
50
  ShapeConfig,
51
- } from "../utils/diagram";
52
- import { getIcon } from "../utils/iconLoader";
51
+ } from "../../utils/diagram";
52
+ import { getIcon } from "../../utils/iconLoader";
53
53
 
54
54
  // Props
55
55
  const props = defineProps<{
@@ -29,7 +29,7 @@
29
29
  :font-weight="nameStyle.fontWeight || 'bold'" :fill="nameStyle.color || '#000000'" style="cursor: pointer;">
30
30
  {{ shape.name }}
31
31
  </text>
32
- <line v-if="shape.comparents?.length && shape.comparents[0]?.comparentShapes?.length" :x1="strokeWidth"
32
+ <line v-if="shape.showComparents" :x1="strokeWidth"
33
33
  :x2="vbW - strokeWidth" :y1="(nameBounds.y || 45) + 20" :y2="(nameBounds.y || 45) + 20" stroke="#767a7d"
34
34
  stroke-width="1" />
35
35
  <!-- 图标图片 -->
@@ -35,20 +35,60 @@ export const DASHED_EDGE_SHAPES = [
35
35
  'Desires',
36
36
  'Achieves',
37
37
  'Implements',
38
- 'IsCapableToperform',
39
38
  'ArbitraryConnector',
40
39
  'OperationalControlFlow',
41
40
  'FunctionControlFlow',
42
41
  'ServiceControlFlow',
43
42
  'IsCapableToPerform',
44
- 'Exhibits'
43
+ 'ProvidesCompetence',
44
+ 'OwnsProcesses',
45
+ 'FillsPost',
46
+ 'Exhibits',
47
+ 'CompetenceToConduct',
48
+ 'control',
49
+ 'command',
50
+ 'RequiresCompetence',
51
+ 'OwnsRisk',
52
+ 'Affects',
53
+ 'Mitigates',
54
+ 'Protects',
55
+ 'MapsToCapability',
56
+ 'ActualResourceRelationship'
45
57
  ];
46
58
 
47
59
  /**
48
60
  * 需要显示keywords和lineName的边类型集合
49
61
  * 与DASHED_EDGE_SHAPES保持一致,便于统一管理
50
62
  */
51
- export const EDGES_WITH_KEYWORDS = DASHED_EDGE_SHAPES;
63
+ export const EDGES_WITH_KEYWORDS = [
64
+ 'Phases',
65
+ 'Sequence',
66
+ 'Dependency',
67
+ 'Creates',
68
+ 'ComparesTo',
69
+ 'ImpactedBy',
70
+ 'Desires',
71
+ 'Achieves',
72
+ 'Implements',
73
+ 'ArbitraryConnector',
74
+ 'OperationalControlFlow',
75
+ 'FunctionControlFlow',
76
+ 'ServiceControlFlow',
77
+ 'IsCapableToPerform',
78
+ 'ProvidesCompetence',
79
+ 'OwnsProcesses',
80
+ 'FillsPost',
81
+ 'CompetenceToConduct',
82
+ 'control',
83
+ 'command',
84
+ 'RequiresCompetence',
85
+ 'OwnsRisk',
86
+ 'Affects',
87
+ 'Mitigates',
88
+ 'Protects',
89
+ 'MapsToCapability',
90
+ 'Exhibits'
91
+ ];
52
92
 
53
93
  /**
54
94
  * 带箭头的边类型集合
@@ -6,6 +6,7 @@ export const ShapeType = {
6
6
  Label: 'Label', // 标签
7
7
  Shape: 'Shape', // 图元
8
8
  ConceptualRole: 'ConceptualRole', //概念角色
9
+ Gantt: 'Gantt', //甘特图
9
10
  } as const
10
11
 
11
12
  // 导出类型
@@ -121,7 +122,7 @@ export const BlockKeyMap = {
121
122
  'SecurityConnectivityDiagram': "Diagram", //安全连通图
122
123
  'SecurityConnectivityTable': "Diagram", //安全连通表
123
124
  'SecurityProcessesDiagram': "Diagram", //安全流程图
124
- 'SecurityProcessesFlowDiagram': "Diagram", //安全内部流程图
125
+ 'SecurityProcessFlowDiagram': "Diagram", //安全内部流程图
125
126
  'SecurityConstraintsDiagram': "Diagram", //安全约束图
126
127
  'SecurityConstraintsDefinitionDiagram': "Diagram", //安全约束定义图
127
128
  'RisksToAssetsMappingMatrix': "Diagram", //资产风险映射矩阵
@@ -204,6 +205,7 @@ export const BlockKeyMap = {
204
205
  'Person': 'Block',//人员
205
206
  'SecurityProcess': 'Block',//安全流程
206
207
  'ResourceMitigation': 'Block', // 资源缓解措施
208
+ 'OperationalMitigation': 'Block', // 业务缓解措施
207
209
 
208
210
  // 线
209
211
  'Association': 'Edge', //双向关联
@@ -223,7 +225,6 @@ export const BlockKeyMap = {
223
225
  'ImpactedBy':'Edge',//受到影响
224
226
  'Desires':'Edge',//要求
225
227
  'Achieves':'Edge',//达到
226
- 'IsCapableToperform':'Edge',//能够胜任
227
228
  'ArbitraryConnector':'Edge',//任意连接器
228
229
  'ArbitraryRelationship':'Edge',//任意关系
229
230
  'DirectedRelationship':'Edge',//定向关系
@@ -237,6 +238,18 @@ export const BlockKeyMap = {
237
238
  'Connector':'Edge',//连接器
238
239
  'OperationalConnector':'Edge',//业务连接器
239
240
  'IsCapableToPerform':'Edge',//能够胜任
241
+ 'OwnsProcesses':'Edge',//拥有流程
242
+ 'ActualResourceRelationship':'Edge',//实际资源关系
243
+ 'FillsPost':'Edge',//填写职位申请
244
+ 'control':'Edge',//控制
245
+ 'command':'Edge',//命令
246
+ 'RequiresCompetence':'Edge',//需求权限
247
+ 'ProvidesCompetence':'Edge',//提供权限
248
+ 'OwnsRisk':'Edge',//承担风险
249
+ 'Affects':'Edge',//影响
250
+ 'Mitigates':'Edge',//缓解
251
+ 'Protects':'Edge',//保护
252
+ 'MapsToCapability':'Edge',//适用能力
240
253
  'ProjectSequence':'Edge',//项目顺序
241
254
  'MilestoneDependency':'Edge',//里程碑依赖
242
255
  'DirectedAssociation':'Edge',//定向关联
@@ -417,7 +430,7 @@ export const DiagramKeyMap = {
417
430
  'SecurityConnectivityDiagram': 'StrategicTaxonomyDiagram', //安全连通图
418
431
  'SecurityConnectivityTable': 'StrategicTaxonomyDiagram', //安全连通表
419
432
  'SecurityProcessesDiagram': 'StrategicTaxonomyDiagram', //安全流程图
420
- 'SecurityProcessesFlowDiagram': 'StrategicTaxonomyDiagram', //安全内部流程图
433
+ 'SecurityProcessFlowDiagram': 'StrategicTaxonomyDiagram', //安全内部流程图
421
434
  'SecurityConstraintsDiagram': 'StrategicTaxonomyDiagram', //安全约束图
422
435
  'SecurityConstraintsDefinitionDiagram': 'StrategicTaxonomyDiagram', //安全约束定义图
423
436
  'RisksToAssetsMappingMatrix': 'StrategicTaxonomyDiagram', //风险与资产映射矩阵
@@ -446,7 +459,9 @@ export const DiagramKeyMap = {
446
459
  export const PinKeyMap = {
447
460
  'OutputPin': 'Pin', //输出引脚
448
461
  'InputPin': 'Pin', //输入引脚
449
- "OperationalPort": "Port"
462
+ "OperationalPort": "Port", //业务端口
463
+ "ServicePort": "Port", //服务端口
464
+ "ResourcePort": "Port", //资源端口
450
465
  } as const
451
466
 
452
467
  // 导出DiagramKeyMap类型
@@ -0,0 +1,3 @@
1
+ export * from './useHighlight';
2
+ export * from './useNameEdit';
3
+ export * from './useResize';
@@ -0,0 +1,223 @@
1
+ import { ref, onMounted, onUnmounted } from 'vue';
2
+ import type { Ref } from 'vue';
3
+ import type { Shape } from '../types';
4
+ import { eventBus } from '../store';
5
+
6
+ /**
7
+ * 高亮覆盖层的边界信息
8
+ */
9
+ export interface HighlightOverlayBounds {
10
+ x: number;
11
+ y: number;
12
+ width: number;
13
+ height: number;
14
+ }
15
+
16
+ /**
17
+ * 高亮颜色类型
18
+ */
19
+ export type HighlightColor = 'blue' | 'red';
20
+
21
+ /**
22
+ * 高亮工具接口 - 用于 EdgeUtils.cancelConnection 等方法
23
+ */
24
+ export interface IHighlightUtils {
25
+ highlightShape: (shape: Shape | null, isHighlight: boolean, isValidSource?: boolean) => void;
26
+ clearHighlightTimeout: () => void;
27
+ }
28
+
29
+ /**
30
+ * 外部高亮控制事件的 payload 类型
31
+ */
32
+ export interface HighlightShapePayload {
33
+ shape: Shape;
34
+ isHighlight: boolean;
35
+ isValidSource?: boolean;
36
+ }
37
+
38
+ /**
39
+ * useHighlight 配置选项
40
+ */
41
+ export interface UseHighlightOptions {
42
+ /** 是否监听 eventBus 事件,默认 true */
43
+ listenEvents?: boolean;
44
+ }
45
+
46
+ /**
47
+ * useHighlight 返回类型
48
+ */
49
+ export interface UseHighlightReturn extends IHighlightUtils {
50
+ // 状态(用于模板绑定)
51
+ overlayBounds: Ref<HighlightOverlayBounds | null>;
52
+ overlayColor: Ref<HighlightColor>;
53
+ highlightedShapeId: Ref<string | null>;
54
+ // 方法
55
+ setHighlightTimeout: (callback: () => void, delay?: number) => void;
56
+ getHighlightedShapeId: () => string | null;
57
+ // 清理
58
+ dispose: () => void;
59
+ }
60
+
61
+ /**
62
+ * 图元高亮 Composable
63
+ * 使用覆盖层方式实现高亮(性能优化:不修改 graphStore)
64
+ *
65
+ * @param options 配置选项
66
+ * @returns 高亮相关的状态和方法
67
+ *
68
+ * @example
69
+ * ```vue
70
+ * <script setup>
71
+ * const { overlayBounds, overlayColor, highlightShape } = useHighlight();
72
+ * </script>
73
+ *
74
+ * <template>
75
+ * <div v-if="overlayBounds" class="highlight-overlay" :class="overlayColor" :style="{
76
+ * left: overlayBounds.x + 'px',
77
+ * top: overlayBounds.y + 'px',
78
+ * width: overlayBounds.width + 'px',
79
+ * height: overlayBounds.height + 'px',
80
+ * }" />
81
+ * </template>
82
+ * ```
83
+ *
84
+ * @example 外部控制高亮
85
+ * ```ts
86
+ * // 触发高亮
87
+ * eventBus.emit('highlight-shape-overlay', { shape, isHighlight: true, isValidSource: true });
88
+ * // 清除高亮
89
+ * eventBus.emit('clear-highlight-overlay');
90
+ * ```
91
+ */
92
+ export function useHighlight(options: UseHighlightOptions = {}): UseHighlightReturn {
93
+ const { listenEvents = true } = options;
94
+
95
+ // 内部状态
96
+ const overlayBounds = ref<HighlightOverlayBounds | null>(null);
97
+ const overlayColor = ref<HighlightColor>('blue');
98
+ const highlightedShapeId = ref<string | null>(null);
99
+ const highlightTimeout = ref<ReturnType<typeof setTimeout> | null>(null);
100
+
101
+ /**
102
+ * 清除高亮定时器
103
+ */
104
+ const clearHighlightTimeout = () => {
105
+ if (highlightTimeout.value) {
106
+ clearTimeout(highlightTimeout.value);
107
+ highlightTimeout.value = null;
108
+ }
109
+ };
110
+
111
+ /**
112
+ * 设置高亮定时器
113
+ * @param callback 回调函数
114
+ * @param delay 延迟时间(毫秒),默认 60ms
115
+ */
116
+ const setHighlightTimeout = (callback: () => void, delay: number = 60) => {
117
+ clearHighlightTimeout();
118
+ highlightTimeout.value = setTimeout(callback, delay);
119
+ };
120
+
121
+ /**
122
+ * 高亮或取消高亮图元(使用覆盖层方式)
123
+ * @param shape 要高亮的图元,null 表示取消高亮
124
+ * @param isHighlight 是否高亮
125
+ * @param isValidSource 是否是有效的连接源(影响高亮颜色:蓝色=有效,红色=无效)
126
+ */
127
+ const highlightShape = (
128
+ shape: Shape | null,
129
+ isHighlight: boolean,
130
+ isValidSource: boolean = true
131
+ ) => {
132
+ // 取消高亮
133
+ if (!shape || !isHighlight) {
134
+ overlayBounds.value = null;
135
+ highlightedShapeId.value = null;
136
+ return;
137
+ }
138
+
139
+ // 不高亮 diagram 和 edge 类型
140
+ if (shape.shapeType === 'diagram' || shape.shapeType === 'edge') {
141
+ return;
142
+ }
143
+
144
+ // 不高亮没有有效 bounds 的图元
145
+ if (!shape.bounds || (shape.bounds.width ?? 0) <= 0 || (shape.bounds.height ?? 0) <= 0) {
146
+ return;
147
+ }
148
+
149
+ // 不高亮没有 shapeKey 的图元
150
+ if (!shape.shapeKey) {
151
+ return;
152
+ }
153
+
154
+ // 设置高亮覆盖层(向外扩展 3px,完全覆盖图元边框)
155
+ overlayBounds.value = {
156
+ x: shape.bounds.x ?? 0,
157
+ y: shape.bounds.y ?? 0,
158
+ width: shape.bounds.width ?? 0,
159
+ height: shape.bounds.height ?? 0,
160
+ };
161
+ overlayColor.value = isValidSource ? 'blue' : 'red';
162
+ highlightedShapeId.value = shape.id;
163
+ };
164
+
165
+ /**
166
+ * 获取当前高亮的图元 ID
167
+ */
168
+ const getHighlightedShapeId = () => highlightedShapeId.value;
169
+
170
+ /**
171
+ * 清理所有状态
172
+ */
173
+ const dispose = () => {
174
+ clearHighlightTimeout();
175
+ overlayBounds.value = null;
176
+ highlightedShapeId.value = null;
177
+ };
178
+
179
+ // eventBus 事件处理
180
+ const handleHighlightShapeOverlay = (payload: HighlightShapePayload | null) => {
181
+ if (!payload) {
182
+ highlightShape(null, false);
183
+ } else {
184
+ highlightShape(payload.shape, payload.isHighlight, payload.isValidSource ?? true);
185
+ }
186
+ };
187
+
188
+ const handleClearHighlightOverlay = () => {
189
+ highlightShape(null, false);
190
+ clearHighlightTimeout();
191
+ };
192
+
193
+ // 生命周期
194
+ if (listenEvents) {
195
+ onMounted(() => {
196
+ eventBus.on('highlight-shape-overlay', handleHighlightShapeOverlay);
197
+ eventBus.on('clear-highlight-overlay', handleClearHighlightOverlay);
198
+ });
199
+
200
+ onUnmounted(() => {
201
+ eventBus.off('highlight-shape-overlay', handleHighlightShapeOverlay);
202
+ eventBus.off('clear-highlight-overlay', handleClearHighlightOverlay);
203
+ dispose();
204
+ });
205
+ } else {
206
+ onUnmounted(() => {
207
+ dispose();
208
+ });
209
+ }
210
+
211
+ return {
212
+ // 状态
213
+ overlayBounds,
214
+ overlayColor,
215
+ highlightedShapeId,
216
+ // 方法
217
+ highlightShape,
218
+ setHighlightTimeout,
219
+ clearHighlightTimeout,
220
+ getHighlightedShapeId,
221
+ dispose,
222
+ };
223
+ }
@@ -0,0 +1,234 @@
1
+ import { ref, nextTick, onUnmounted, type Ref } from 'vue';
2
+ import type { Shape } from '../types';
3
+
4
+ /**
5
+ * 名称编辑配置选项
6
+ */
7
+ export interface UseNameEditOptions {
8
+ /** 获取当前选中的 shape(用于 onNameChange 回调) */
9
+ getSelectedShape?: () => Shape | null;
10
+ /** 名称变更时的回调,包含 shape 信息 */
11
+ onNameChange?: (shape: Shape, oldName: string, newName: string) => void;
12
+ /** 名称验证函数,返回错误信息,null 表示验证通过 */
13
+ validateName?: (name: string) => string | null;
14
+ /** 开始编辑时的回调 */
15
+ onEditStart?: () => void;
16
+ /** 结束编辑时的回调 */
17
+ onEditEnd?: () => void;
18
+ }
19
+
20
+ /**
21
+ * 名称编辑管理器接口
22
+ * 用于 NameEditor 组件的 props 类型定义
23
+ */
24
+ export interface INameEditManager {
25
+ // 响应式状态(通过 editingState getter 访问)
26
+ editingState: {
27
+ isEditingName: Ref<boolean>;
28
+ editingName: Ref<string>;
29
+ nameInput: Ref<HTMLInputElement | null>;
30
+ };
31
+ // 方法
32
+ setNameInput: (input: HTMLInputElement | null) => void;
33
+ startEdit: (shape: Shape | null) => Promise<void>;
34
+ finishEdit: (shape: Shape | null) => void;
35
+ cancelEdit: () => void;
36
+ handleKeyUp: (event: KeyboardEvent, shape: Shape | null) => void;
37
+ handleBlur: (shape: Shape | null) => void;
38
+ canEdit: (shape: Shape | null) => boolean;
39
+ getDisplayName: (shape: Shape | null) => string;
40
+ reset: () => void;
41
+ }
42
+
43
+ /**
44
+ * useNameEdit 返回类型
45
+ */
46
+ export interface UseNameEditReturn extends INameEditManager {
47
+ // 直接暴露的响应式状态(方便解构使用)
48
+ isEditingName: Ref<boolean>;
49
+ editingName: Ref<string>;
50
+ nameInput: Ref<HTMLInputElement | null>;
51
+ }
52
+
53
+ /**
54
+ * 名称编辑 Composable
55
+ *
56
+ * 用于管理图元名称的编辑状态和行为
57
+ *
58
+ * @param options 配置选项
59
+ * @returns 名称编辑相关的状态和方法
60
+ *
61
+ * @example
62
+ * ```vue
63
+ * <script setup>
64
+ * // 方式1:解构使用
65
+ * const { isEditingName, editingName, startEdit, handleBlur } = useNameEdit({
66
+ * onNameChange: (oldName, newName) => {
67
+ * emit('editName', selectedShape, newName, oldName);
68
+ * }
69
+ * });
70
+ *
71
+ * // 方式2:作为对象传递给子组件(兼容 NameEditor 组件)
72
+ * const nameEditManager = useNameEdit({ ... });
73
+ * // <NameEditor :name-edit-manager="nameEditManager" />
74
+ * </script>
75
+ * ```
76
+ */
77
+ export function useNameEdit(options: UseNameEditOptions = {}): UseNameEditReturn {
78
+ // 响应式状态
79
+ const isEditingName = ref(false);
80
+ const editingName = ref('');
81
+ const nameInput = ref<HTMLInputElement | null>(null);
82
+
83
+ /**
84
+ * 设置名称输入框引用
85
+ */
86
+ const setNameInput = (input: HTMLInputElement | null) => {
87
+ nameInput.value = input;
88
+ };
89
+
90
+ /**
91
+ * 开始编辑名称
92
+ */
93
+ const startEdit = async (shape: Shape | null): Promise<void> => {
94
+ if (!shape || isEditingName.value) return;
95
+
96
+ isEditingName.value = true;
97
+ editingName.value = shape.name || '';
98
+
99
+ // 触发开始编辑回调
100
+ options.onEditStart?.();
101
+
102
+ await nextTick();
103
+
104
+ // 聚焦并选中文本
105
+ nameInput.value?.focus();
106
+ nameInput.value?.select();
107
+ };
108
+
109
+ /**
110
+ * 完成编辑
111
+ */
112
+ const finishEdit = (shape: Shape | null): void => {
113
+ if (!shape || !isEditingName.value) return;
114
+
115
+ const newName = editingName.value.trim();
116
+ const oldName = shape.name || '';
117
+
118
+ if (newName && newName !== oldName) {
119
+ // 验证名称
120
+ const validationError = options.validateName?.(newName);
121
+ if (validationError) {
122
+ console.warn('名称验证失败:', validationError);
123
+ cancelEdit();
124
+ return;
125
+ }
126
+
127
+ // 调用名称变更回调(优先使用 getSelectedShape 获取最新的 shape)
128
+ const targetShape = options.getSelectedShape?.() ?? shape;
129
+ if (targetShape) {
130
+ options.onNameChange?.(targetShape, oldName, newName);
131
+ }
132
+ }
133
+
134
+ isEditingName.value = false;
135
+ editingName.value = '';
136
+ options.onEditEnd?.();
137
+ };
138
+
139
+ /**
140
+ * 取消编辑
141
+ */
142
+ const cancelEdit = (): void => {
143
+ isEditingName.value = false;
144
+ editingName.value = '';
145
+ options.onEditEnd?.();
146
+ };
147
+
148
+ /**
149
+ * 处理键盘事件
150
+ */
151
+ const handleKeyUp = (event: KeyboardEvent, shape: Shape | null): void => {
152
+ if (event.key === 'Enter') {
153
+ finishEdit(shape);
154
+ } else if (event.key === 'Escape') {
155
+ cancelEdit();
156
+ }
157
+ };
158
+
159
+ /**
160
+ * 处理失焦事件
161
+ */
162
+ const handleBlur = (shape: Shape | null): void => {
163
+ finishEdit(shape);
164
+ };
165
+
166
+ /**
167
+ * 判断是否可以编辑
168
+ */
169
+ const canEdit = (shape: Shape | null): boolean => {
170
+ return !!shape &&
171
+ shape.shapeKey !== 'ConceptRole' &&
172
+ !isEditingName.value;
173
+ };
174
+
175
+ /**
176
+ * 获取名称显示文本
177
+ */
178
+ const getDisplayName = (shape: Shape | null): string => {
179
+ return shape?.name || '';
180
+ };
181
+
182
+ /**
183
+ * 重置状态
184
+ */
185
+ const reset = (): void => {
186
+ isEditingName.value = false;
187
+ editingName.value = '';
188
+ };
189
+
190
+ // 组件卸载时自动重置
191
+ onUnmounted(() => {
192
+ reset();
193
+ });
194
+
195
+ return {
196
+ // 直接暴露的状态(方便解构)
197
+ isEditingName,
198
+ editingName,
199
+ nameInput,
200
+ // editingState getter(兼容 NameEditor 组件)
201
+ get editingState() {
202
+ return {
203
+ isEditingName,
204
+ editingName,
205
+ nameInput,
206
+ };
207
+ },
208
+ // 方法
209
+ setNameInput,
210
+ startEdit,
211
+ finishEdit,
212
+ cancelEdit,
213
+ handleKeyUp,
214
+ handleBlur,
215
+ canEdit,
216
+ getDisplayName,
217
+ reset,
218
+ };
219
+ }
220
+
221
+ /**
222
+ * 默认名称验证函数
223
+ */
224
+ export function defaultNameValidator(name: string): string | null {
225
+ if (!name.trim()) {
226
+ return '名称不能为空';
227
+ }
228
+
229
+ if (name.length > 100) {
230
+ return '名称长度不能超过100个字符';
231
+ }
232
+
233
+ return null;
234
+ }