@tmagic/editor 1.7.13 → 1.7.14-beta.1

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 (47) hide show
  1. package/dist/es/Editor.vue_vue_type_script_setup_true_lang.js +21 -3
  2. package/dist/es/components/ScrollViewer.vue_vue_type_script_setup_true_lang.js +1 -1
  3. package/dist/es/components/Tree.vue_vue_type_script_setup_true_lang.js +8 -4
  4. package/dist/es/components/TreeNode.vue_vue_type_script_setup_true_lang.js +19 -9
  5. package/dist/es/editorProps.js +1 -0
  6. package/dist/es/fields/StyleSetter/Index.vue_vue_type_script_setup_true_lang.js +2 -1
  7. package/dist/es/hooks/use-stage.js +5 -0
  8. package/dist/es/index.js +2 -2
  9. package/dist/es/initService.js +3 -0
  10. package/dist/es/layouts/props-panel/PropsPanel.vue_vue_type_script_setup_true_lang.js +1 -1
  11. package/dist/es/layouts/sidebar/Sidebar.vue_vue_type_script_setup_true_lang.js +12 -3
  12. package/dist/es/layouts/sidebar/layer/LayerPanel.vue_vue_type_script_setup_true_lang.js +21 -5
  13. package/dist/es/layouts/sidebar/layer/use-click.js +22 -3
  14. package/dist/es/layouts/sidebar/layer/use-drag.js +28 -6
  15. package/dist/es/layouts/workspace/Breadcrumb.vue_vue_type_script_setup_true_lang.js +87 -12
  16. package/dist/es/layouts/workspace/Workspace.vue_vue_type_script_setup_true_lang.js +5 -2
  17. package/dist/es/layouts/workspace/viewer/Stage.vue_vue_type_script_setup_true_lang.js +16 -4
  18. package/dist/es/services/editor.js +2 -1
  19. package/dist/es/services/props.js +14 -2
  20. package/dist/es/style.css +20 -0
  21. package/dist/es/utils/dep/worker.js +1 -1
  22. package/dist/es/utils/tree.js +3 -1
  23. package/dist/style.css +20 -0
  24. package/dist/tmagic-editor.umd.cjs +266 -52
  25. package/package.json +7 -7
  26. package/src/Editor.vue +17 -0
  27. package/src/components/ScrollViewer.vue +4 -1
  28. package/src/components/Tree.vue +5 -1
  29. package/src/components/TreeNode.vue +14 -9
  30. package/src/editorProps.ts +23 -0
  31. package/src/fields/StyleSetter/Index.vue +5 -1
  32. package/src/hooks/use-stage.ts +9 -0
  33. package/src/initService.ts +10 -0
  34. package/src/layouts/props-panel/PropsPanel.vue +1 -1
  35. package/src/layouts/sidebar/Sidebar.vue +19 -0
  36. package/src/layouts/sidebar/layer/LayerPanel.vue +37 -3
  37. package/src/layouts/sidebar/layer/use-click.ts +36 -3
  38. package/src/layouts/sidebar/layer/use-drag.ts +62 -6
  39. package/src/layouts/workspace/Breadcrumb.vue +87 -6
  40. package/src/layouts/workspace/Workspace.vue +3 -1
  41. package/src/layouts/workspace/viewer/Stage.vue +31 -3
  42. package/src/services/editor.ts +1 -0
  43. package/src/services/props.ts +18 -2
  44. package/src/theme/breadcrumb.scss +24 -0
  45. package/src/type.ts +58 -1
  46. package/src/utils/tree.ts +5 -1
  47. package/types/index.d.ts +97 -8
@@ -11,6 +11,8 @@ const dragState: {
11
11
  dropType: NodeDropType | '';
12
12
  container: HTMLElement | null;
13
13
  nodeId?: Id;
14
+ /** canDropIn 返回 Id 时记录的重定向目标 id,handleDragEnd 阶段会改用该 id 对应的节点作为 inner 拖入的父节点 */
15
+ redirectedTargetId?: Id;
14
16
  } = {
15
17
  dragOverNodeId: '',
16
18
  dropType: '',
@@ -37,12 +39,22 @@ const removeStatusClass = (el: HTMLElement | null) => {
37
39
  });
38
40
  };
39
41
 
42
+ export interface UseDragOptions {
43
+ /**
44
+ * 用于判断某个节点是否能被拖动到另一个节点内部
45
+ * - 返回 `false`:阻止 inner 拖入(before/after 仍然可用)
46
+ * - 返回 `Id`:将 inner 拖入的目标重定向到该 id 对应的节点
47
+ * - 其他:按原 targetId 正常拖入
48
+ */
49
+ canDropIn?: (sourceIds: Id[], targetId: Id) => Id | boolean | void;
50
+ }
51
+
40
52
  /**
41
53
  * dragstart/dragleave/dragend 属于源节点
42
54
  * dragover 属于目标节点
43
55
  * 这些方法并不是同一个dom事件触发的
44
56
  */
45
- export const useDrag = ({ editorService }: Services) => {
57
+ export const useDrag = ({ editorService }: Services, options: UseDragOptions = {}) => {
46
58
  const handleDragStart = (event: DragEvent) => {
47
59
  if (!event.dataTransfer || !event.target || !event.currentTarget) return;
48
60
 
@@ -102,13 +114,43 @@ export const useDrag = ({ editorService }: Services) => {
102
114
  }
103
115
  }
104
116
 
105
- if (distance < targetHeight / 3) {
117
+ // 通过用户配置的钩子判断当前拖动节点是否允许拖入目标节点内部
118
+ // - false:禁止 inner 拖入
119
+ // - Id :将 inner 拖入的父节点重定向为该 id 对应的节点
120
+ // - 其他:按原 targetNodeId 正常拖入
121
+ let canDropInTarget = isContainer;
122
+ let redirectedTargetId: Id | undefined;
123
+ if (canDropInTarget && options.canDropIn && nodeId && targetNodeId !== nodeId) {
124
+ const result = options.canDropIn([nodeId], targetNodeId);
125
+ if (result === false) {
126
+ canDropInTarget = false;
127
+ } else if (typeof result === 'string' || typeof result === 'number') {
128
+ redirectedTargetId = result;
129
+ }
130
+ }
131
+
132
+ // before/after 模式下新节点会成为 target 的兄弟(即 target 的直接父节点的子节点),
133
+ // 所以应该用 target 的直接父节点 id 再次调用 canDropIn 校验。
134
+ // 若该父节点禁止拖入(false)或要求重定向(Id),都视为"不应放入此父节点",
135
+ // 故对应的 before/after 也禁用——避免绕过 inner 限制。
136
+ let canDropAsSibling = true;
137
+ const directParentId = parentsId?.[parentsId.length - 1];
138
+ if (options.canDropIn && nodeId && directParentId && directParentId !== nodeId) {
139
+ const siblingResult = options.canDropIn([nodeId], directParentId);
140
+ if (siblingResult === false || typeof siblingResult === 'string' || typeof siblingResult === 'number') {
141
+ canDropAsSibling = false;
142
+ }
143
+ }
144
+
145
+ // 显式重置 dropType,避免上一次 dragover 的残留值影响本次判断
146
+ dragState.dropType = '';
147
+ if (distance < targetHeight / 3 && canDropAsSibling) {
106
148
  dragState.dropType = 'before';
107
149
  addClassName(labelEl, globalThis.document, 'drag-before');
108
- } else if (distance > (targetHeight * 2) / 3) {
150
+ } else if (distance > (targetHeight * 2) / 3 && canDropAsSibling) {
109
151
  dragState.dropType = 'after';
110
152
  addClassName(labelEl, globalThis.document, 'drag-after');
111
- } else if (isContainer) {
153
+ } else if (canDropInTarget) {
112
154
  dragState.dropType = 'inner';
113
155
  addClassName(labelEl, globalThis.document, 'drag-inner');
114
156
  }
@@ -119,6 +161,8 @@ export const useDrag = ({ editorService }: Services) => {
119
161
 
120
162
  dragState.dragOverNodeId = targetNodeId;
121
163
  dragState.container = event.currentTarget as HTMLElement;
164
+ // 仅 inner 时才使用重定向,before/after 是相对于 dragOverNodeId 的兄弟插入,重定向无意义
165
+ dragState.redirectedTargetId = dragState.dropType === 'inner' ? redirectedTargetId : undefined;
122
166
 
123
167
  event.preventDefault();
124
168
  };
@@ -158,8 +202,19 @@ export const useDrag = ({ editorService }: Services) => {
158
202
  let targetIndex = -1;
159
203
 
160
204
  if (Array.isArray(targetNode.items) && dragState.dropType === 'inner') {
161
- targetIndex = targetNode.items.length;
162
- targetParent = targetNode as MContainer;
205
+ // 优先使用 canDropIn 返回的重定向 id 对应的节点作为父节点
206
+ if (dragState.redirectedTargetId !== undefined) {
207
+ const redirectedNode = editorService.getNodeInfo(dragState.redirectedTargetId, false).node;
208
+ if (!redirectedNode || !Array.isArray((redirectedNode as MContainer).items)) {
209
+ // 重定向目标无效或不是容器,放弃此次拖入
210
+ return;
211
+ }
212
+ targetParent = redirectedNode as MContainer;
213
+ targetIndex = (redirectedNode as MContainer).items!.length;
214
+ } else {
215
+ targetIndex = targetNode.items.length;
216
+ targetParent = targetNode as MContainer;
217
+ }
163
218
  } else {
164
219
  targetIndex = getNodeIndex(dragState.dragOverNodeId, targetParent);
165
220
  }
@@ -180,6 +235,7 @@ export const useDrag = ({ editorService }: Services) => {
180
235
  dragState.dragOverNodeId = '';
181
236
  dragState.dropType = '';
182
237
  dragState.container = null;
238
+ dragState.redirectedTargetId = undefined;
183
239
  };
184
240
 
185
241
  return {
@@ -1,17 +1,22 @@
1
1
  <template>
2
- <div v-if="nodes.length === 1" class="m-editor-breadcrumb">
3
- <template v-for="(item, index) in path" :key="item.id">
4
- <TMagicButton link :disabled="item.id === node?.id" @click="select(item)">{{ item.name }}</TMagicButton
5
- ><span v-if="index < path.length - 1">/</span>
2
+ <div v-if="nodes.length === 1" ref="containerRef" class="m-editor-breadcrumb">
3
+ <template v-for="(item, index) in displayPath" :key="item.isEllipsis ? `ellipsis-${index}` : item.id">
4
+ <span v-if="item.isEllipsis" class="m-editor-breadcrumb-ellipsis">...</span>
5
+ <TMagicTooltip v-else :content="item.name" placement="top" :show-after="500">
6
+ <TMagicButton class="m-editor-breadcrumb-item" link :disabled="item.id === node?.id" @click="select(item)">{{
7
+ item.name
8
+ }}</TMagicButton>
9
+ </TMagicTooltip>
10
+ <span v-if="index < displayPath.length - 1" class="m-editor-breadcrumb-separator">/</span>
6
11
  </template>
7
12
  </div>
8
13
  </template>
9
14
 
10
15
  <script setup lang="ts">
11
- import { computed } from 'vue';
16
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
12
17
 
13
18
  import type { MNode } from '@tmagic/core';
14
- import { TMagicButton } from '@tmagic/design';
19
+ import { TMagicButton, TMagicTooltip } from '@tmagic/design';
15
20
  import { getNodePath } from '@tmagic/utils';
16
21
 
17
22
  import { useServices } from '@editor/hooks/use-services';
@@ -20,6 +25,8 @@ defineOptions({
20
25
  name: 'MEditorBreadcrumb',
21
26
  });
22
27
 
28
+ type DisplayItem = (MNode & { isEllipsis?: false }) | { isEllipsis: true; id: string; name: string };
29
+
23
30
  const { editorService } = useServices();
24
31
 
25
32
  const node = computed(() => editorService.get('node'));
@@ -27,6 +34,80 @@ const nodes = computed(() => editorService.get('nodes'));
27
34
  const root = computed(() => editorService.get('root'));
28
35
  const path = computed(() => getNodePath(node.value?.id || '', root.value?.items || []));
29
36
 
37
+ const containerRef = ref<HTMLElement | null>(null);
38
+ // 当面包屑宽度超过父元素宽度的阈值时折叠
39
+ const COLLAPSE_RATIO = 0.8;
40
+ const collapsed = ref(false);
41
+
42
+ const displayPath = computed<DisplayItem[]>(() => {
43
+ const list = path.value;
44
+ // 折叠后视觉元素数(first + ... + last2 = 4 个),所以只有路径 > 3 时折叠才能减少占位
45
+ if (!collapsed.value || list.length <= 3) {
46
+ return list as DisplayItem[];
47
+ }
48
+ return [
49
+ list[0],
50
+ { isEllipsis: true, id: '__ellipsis__', name: '...' },
51
+ list[list.length - 2],
52
+ list[list.length - 1],
53
+ ] as DisplayItem[];
54
+ });
55
+
56
+ const measureOverflow = async () => {
57
+ // 先恢复完整渲染再测量,避免折叠后误判
58
+ if (collapsed.value) {
59
+ collapsed.value = false;
60
+ await nextTick();
61
+ }
62
+ const el = containerRef.value;
63
+ const parent = el?.parentElement;
64
+ if (!el || !parent) return;
65
+ // scrollWidth 取内容自然宽度(不受自身 max-width 影响),与父容器宽度做比例判断
66
+ const contentWidth = el.scrollWidth;
67
+ const parentWidth = parent.clientWidth;
68
+ if (parentWidth <= 0) return;
69
+ collapsed.value = contentWidth > parentWidth * COLLAPSE_RATIO;
70
+ };
71
+
72
+ let resizeObserver: ResizeObserver | null = null;
73
+
74
+ const observe = () => {
75
+ resizeObserver?.disconnect();
76
+ const el = containerRef.value;
77
+ if (!el || typeof ResizeObserver === 'undefined') return;
78
+ resizeObserver = new ResizeObserver(() => {
79
+ measureOverflow();
80
+ });
81
+ resizeObserver.observe(el);
82
+ if (el.parentElement) {
83
+ resizeObserver.observe(el.parentElement);
84
+ }
85
+ };
86
+
87
+ onMounted(() => {
88
+ observe();
89
+ measureOverflow();
90
+ });
91
+
92
+ onBeforeUnmount(() => {
93
+ resizeObserver?.disconnect();
94
+ resizeObserver = null;
95
+ });
96
+
97
+ watch(
98
+ () => nodes.value.length,
99
+ async () => {
100
+ await nextTick();
101
+ observe();
102
+ measureOverflow();
103
+ },
104
+ );
105
+
106
+ watch(path, async () => {
107
+ await nextTick();
108
+ measureOverflow();
109
+ });
110
+
30
111
  const select = async (node: MNode) => {
31
112
  await editorService.select(node);
32
113
  editorService.get('stage')?.select(node.id);
@@ -9,7 +9,9 @@
9
9
  :disabled-stage-overlay="disabledStageOverlay"
10
10
  :stage-content-menu="stageContentMenu"
11
11
  :custom-content-menu="customContentMenu"
12
- ></MagicStage>
12
+ >
13
+ <template #stage-top><slot name="stage-top"></slot></template>
14
+ </MagicStage>
13
15
  </slot>
14
16
 
15
17
  <slot name="workspace-content"></slot>
@@ -27,6 +27,10 @@
27
27
 
28
28
  <NodeListMenu></NodeListMenu>
29
29
 
30
+ <template #before>
31
+ <slot name="stage-top"></slot>
32
+ </template>
33
+
30
34
  <template #content>
31
35
  <StageOverlay v-if="!disabledStageOverlay"></StageOverlay>
32
36
 
@@ -52,7 +56,7 @@ import { calcValueByFontsize, getIdFromEl } from '@tmagic/utils';
52
56
  import ScrollViewer from '@editor/components/ScrollViewer.vue';
53
57
  import { useServices } from '@editor/hooks';
54
58
  import { useStage } from '@editor/hooks/use-stage';
55
- import type { CustomContentMenuFunction, MenuButton, MenuComponent, StageOptions } from '@editor/type';
59
+ import type { CustomContentMenuFunction, MenuButton, MenuComponent, StageOptions, StageSlots } from '@editor/type';
56
60
  import { DragType, Layout } from '@editor/type';
57
61
  import { getEditorConfig } from '@editor/utils/config';
58
62
  import { KeyBindingContainerKey } from '@editor/utils/keybinding-config';
@@ -65,6 +69,8 @@ defineOptions({
65
69
  name: 'MEditorStage',
66
70
  });
67
71
 
72
+ defineSlots<StageSlots>();
73
+
68
74
  const props = withDefaults(
69
75
  defineProps<{
70
76
  stageOptions: StageOptions;
@@ -311,12 +317,34 @@ const dropHandler = async (e: DragEvent) => {
311
317
  );
312
318
 
313
319
  let parent: MContainer | undefined | null = page.value;
320
+ let resolvedParentEl: HTMLElement | null | undefined = parentEl;
314
321
  const parentId = getIdFromEl()(parentEl);
315
322
  if (parentId) {
316
323
  parent = editorService.getNodeById(parentId, false) as MContainer;
317
324
  }
318
325
 
319
326
  if (parent && stageContainerEl.value && stage) {
327
+ // 通过用户配置的钩子再次确认当前拖入的新组件是否允许放入命中的高亮容器,
328
+ // 防止 delayedMarkContainer 的延迟/异步未生效或残留高亮导致命中错误容器
329
+ // - 返回 false:取消此次拖入
330
+ // - 返回 Id :将父节点重定向到该 id 对应的节点(layout 坐标也基于其 DOM 重新计算)
331
+ // - 其他 :使用原命中节点
332
+ // 从组件列表拖入新组件时 sourceIds 为空数组(尚无 id)
333
+ if (props.stageOptions.canDropIn) {
334
+ const result = props.stageOptions.canDropIn([], parent.id);
335
+ if (result === false) {
336
+ return;
337
+ }
338
+ if (typeof result === 'string' || typeof result === 'number') {
339
+ const redirectedNode = editorService.getNodeById(result, false) as MContainer | undefined;
340
+ if (!redirectedNode) {
341
+ return;
342
+ }
343
+ parent = redirectedNode;
344
+ resolvedParentEl = stage.renderer?.getTargetElement(result) ?? null;
345
+ }
346
+ }
347
+
320
348
  const layout = await editorService.getLayout(parent);
321
349
 
322
350
  const containerRect = stageContainerEl.value.getBoundingClientRect();
@@ -336,8 +364,8 @@ const dropHandler = async (e: DragEvent) => {
336
364
  top = e.clientY - containerRect.top + scrollTop;
337
365
  left = e.clientX - containerRect.left + scrollLeft;
338
366
 
339
- if (parentEl) {
340
- const { left: parentLeft, top: parentTop } = getOffset(parentEl);
367
+ if (resolvedParentEl) {
368
+ const { left: parentLeft, top: parentTop } = getOffset(resolvedParentEl);
341
369
  left = left - parentLeft * zoom.value;
342
370
  top = top - parentTop * zoom.value;
343
371
  }
@@ -79,6 +79,7 @@ class Editor extends BaseService {
79
79
  pageLength: 0,
80
80
  pageFragmentLength: 0,
81
81
  disabledMultiSelect: false,
82
+ alwaysMultiSelect: false,
82
83
  });
83
84
  private isHistoryStateChange = false;
84
85
  private selectionBeforeOp: Id[] | null = null;
@@ -93,6 +93,10 @@ class Props extends BaseService {
93
93
  this.emit('props-configs-change');
94
94
  }
95
95
 
96
+ public getPropsConfigs(): Record<string, FormConfig> {
97
+ return this.state.propsConfigMap;
98
+ }
99
+
96
100
  public async fillConfig(config: FormConfig, labelWidth?: string) {
97
101
  return fillConfig(config, {
98
102
  labelWidth: typeof labelWidth !== 'function' ? labelWidth : '80px',
@@ -117,20 +121,28 @@ class Props extends BaseService {
117
121
  * @param type 组件类型
118
122
  * @returns 组件属性表单配置
119
123
  */
120
- public async getPropsConfig(type: string): Promise<FormConfig> {
124
+ public async getPropsConfig(type: string, data?: { node?: MNode | null }): Promise<FormConfig> {
121
125
  if (type === 'area') {
122
- return await this.getPropsConfig('button');
126
+ return await this.getPropsConfig('button', data);
123
127
  }
124
128
 
125
129
  return cloneDeep(this.state.propsConfigMap[toLine(type)] || (await this.fillConfig([])));
126
130
  }
127
131
 
132
+ public hasPropsConfig(type: string): boolean {
133
+ return !!this.state.propsConfigMap[toLine(type)];
134
+ }
135
+
128
136
  public setPropsValues(values: Record<string, Partial<MNode> | PropsFormValueFunction>) {
129
137
  Object.keys(values).forEach((type: string) => {
130
138
  this.setPropsValue(toLine(type), values[type]);
131
139
  });
132
140
  }
133
141
 
142
+ public getPropsValues(): Record<string, Partial<MNode>> {
143
+ return this.state.propsValueMap;
144
+ }
145
+
134
146
  /**
135
147
  * 为指点类型组件设置组件初始值
136
148
  * @param type 组件类型
@@ -178,6 +190,10 @@ class Props extends BaseService {
178
190
  };
179
191
  }
180
192
 
193
+ public hasPropsValue(type: string): boolean {
194
+ return !!this.state.propsValueMap[toLine(type)];
195
+ }
196
+
181
197
  public createId(type: string | number): string {
182
198
  return `${type}_${guid()}`;
183
199
  }
@@ -3,4 +3,28 @@
3
3
  left: 5px;
4
4
  top: 5px;
5
5
  z-index: 10;
6
+ display: flex;
7
+ align-items: center;
8
+ white-space: nowrap;
9
+
10
+ .m-editor-breadcrumb-item {
11
+ max-width: 100px;
12
+ min-width: 0;
13
+ overflow: hidden;
14
+
15
+ // Element Plus button 内部文本节点
16
+ > span {
17
+ display: block;
18
+ max-width: 100%;
19
+ overflow: hidden;
20
+ text-overflow: ellipsis;
21
+ white-space: nowrap;
22
+ }
23
+ }
24
+
25
+ .m-editor-breadcrumb-separator,
26
+ .m-editor-breadcrumb-ellipsis {
27
+ margin: 0 4px;
28
+ flex-shrink: 0;
29
+ }
6
30
  }
package/src/type.ts CHANGED
@@ -26,6 +26,7 @@ import type { CodeBlockContent, CodeBlockDSL, Id, MApp, MContainer, MNode, MPage
26
26
  import type { ChangeRecord, FormConfig, TableColumnConfig, TypeFunction } from '@tmagic/form';
27
27
  import type StageCore from '@tmagic/stage';
28
28
  import type {
29
+ CanDropIn,
29
30
  ContainerHighlightType,
30
31
  CustomizeMoveableOptions,
31
32
  GuidesOptions,
@@ -73,7 +74,17 @@ export interface FrameworkSlots {
73
74
  'page-list-popover'(props: { list: (MPage | MPageFragment)[] }): any;
74
75
  }
75
76
 
76
- export interface WorkspaceSlots {
77
+ export interface ScrollViewerSlots {
78
+ before(props: {}): any;
79
+ content(props: {}): any;
80
+ default(props: {}): any;
81
+ }
82
+
83
+ export interface StageSlots extends ScrollViewerSlots {
84
+ 'stage-top'(props: {}): any;
85
+ }
86
+
87
+ export interface WorkspaceSlots extends StageSlots {
77
88
  stage(props: {}): any;
78
89
  'workspace-content'(props: {}): any;
79
90
  }
@@ -158,10 +169,23 @@ export interface StageOptions {
158
169
  moveableOptions?: CustomizeMoveableOptions;
159
170
  canSelect?: (el: HTMLElement) => boolean | Promise<boolean>;
160
171
  isContainer?: (el: HTMLElement) => boolean | Promise<boolean>;
172
+ /**
173
+ * 画布上拖入组件(包括从组件列表拖入新组件、画布上拖动已有组件)时,
174
+ * 对已通过 isContainer 命中的候选容器进行二次过滤;返回 false 时阻止该容器被高亮命中
175
+ * - 在画布上拖动已有组件时:sourceIds 为被拖动组件的 id 列表
176
+ * - 从组件列表拖入新组件时:sourceIds 为空数组(尚无 id,仅可依据 targetId 判断)
177
+ * 该选项会被透传给 StageCore 的 canDropIn
178
+ */
179
+ canDropIn?: CanDropIn;
161
180
  updateDragEl?: UpdateDragEl;
162
181
  renderType?: RenderType;
163
182
  guidesOptions?: Partial<GuidesOptions>;
164
183
  disabledMultiSelect?: boolean;
184
+ /**
185
+ * 始终启用多选模式(无需按住 Ctrl/Meta),默认 false。
186
+ * 当 `disabledMultiSelect` 为 true 时本配置失效。
187
+ */
188
+ alwaysMultiSelect?: boolean;
165
189
  disabledRule?: boolean;
166
190
  zoom?: number;
167
191
  /** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
@@ -181,6 +205,8 @@ export interface StoreState {
181
205
  pageLength: number;
182
206
  pageFragmentLength: number;
183
207
  disabledMultiSelect: boolean;
208
+ /** 是否始终启用多选模式(无需按住 Ctrl/Meta) */
209
+ alwaysMultiSelect: boolean;
184
210
  }
185
211
 
186
212
  export type StoreStateKey = keyof StoreState;
@@ -669,6 +695,37 @@ export interface TreeNodeData {
669
695
  [key: string]: any;
670
696
  }
671
697
 
698
+ /** 判断组件树节点是否可展开(即是否要展示为拥有子节点的形态)的函数 */
699
+ export type IsExpandableFunction = (_data: TreeNodeData, _nodeStatusMap: Map<Id, LayerNodeStatus>) => boolean;
700
+
701
+ /** canDropIn 的调用场景 */
702
+ export type CanDropInScene =
703
+ /** 在"已选组件"面板的组件树中拖动节点 */
704
+ | 'layer'
705
+ /** 在画布上拖动已有组件(被拖动组件本身已经存在于画布中,sourceIds 包含其 id) */
706
+ | 'stage-drag'
707
+ /** 从组件列表拖入新组件到画布(被拖入的组件尚不存在,sourceIds 为空数组) */
708
+ | 'stage-add';
709
+
710
+ /**
711
+ * 判断当前正在拖动的源节点是否可以拖入目标节点内部的函数
712
+ * @param _sourceIds 当前正在拖动的源节点 id 列表
713
+ * - `layer`:被拖动的组件树节点 id(单选时长度为 1)
714
+ * - `stage-drag`:被拖动组件的 id 列表(多选拖动时为多个)
715
+ * - `stage-add`:始终为空数组(从组件列表新增的组件尚无 id)
716
+ * @param _targetId 目标容器的节点 id
717
+ * @param _scene 调用场景:见 {@link CanDropInScene}
718
+ * @returns
719
+ * - `false`:阻止该容器被视为合法拖入目标
720
+ * - `layer`:禁用 inner 高亮(before/after 仍然可用)
721
+ * - `stage-drag`:阻止该容器被高亮命中
722
+ * - `stage-add`:阻止该容器被高亮命中并退化为放入当前页面
723
+ * - `Id`(string | number):将拖入目标重定向到该 id 对应的节点
724
+ * (例如把命中的"卡片外壳"节点重定向到其内层"卡片内容"容器节点)
725
+ * - 其他(`true` / `void` / `undefined`):按原 targetId 正常拖入
726
+ */
727
+ export type CanDropInFunction = (_sourceIds: Id[], _targetId: Id, _scene: CanDropInScene) => Id | boolean | void;
728
+
672
729
  export type AsyncBeforeHook<Value extends Array<string>, C extends Record<Value[number], (...args: any) => any>> = {
673
730
  [K in Value[number]]?: (...args: Parameters<C[K]>) => Promise<Parameters<C[K]>> | Parameters<C[K]>;
674
731
  };
package/src/utils/tree.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Id } from '@tmagic/core';
2
2
  import { getKeys } from '@tmagic/utils';
3
3
 
4
- import type { LayerNodeStatus } from '@editor/type';
4
+ import type { IsExpandableFunction, LayerNodeStatus } from '@editor/type';
5
5
 
6
6
  export const updateStatus = (nodeStatusMap: Map<Id, LayerNodeStatus>, id: Id, status: Partial<LayerNodeStatus>) => {
7
7
  const nodeStatus = nodeStatusMap.get(id);
@@ -13,3 +13,7 @@ export const updateStatus = (nodeStatusMap: Map<Id, LayerNodeStatus>, id: Id, st
13
13
  }
14
14
  });
15
15
  };
16
+
17
+ /** 默认的组件树节点是否可展开的判断函数:当节点的子项中至少存在一个可见节点时认为可展开 */
18
+ export const defaultIsExpandable: IsExpandableFunction = (data, nodeStatusMap) =>
19
+ Array.isArray(data.items) && data.items.some((item) => nodeStatusMap.get(item.id)?.visible);