@tmagic/editor 1.7.13-beta.0 → 1.7.14-beta.0

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 (31) hide show
  1. package/dist/es/Editor.vue_vue_type_script_setup_true_lang.js +17 -3
  2. package/dist/es/components/Tree.vue_vue_type_script_setup_true_lang.js +2 -1
  3. package/dist/es/components/TreeNode.vue_vue_type_script_setup_true_lang.js +8 -3
  4. package/dist/es/editorProps.js +1 -0
  5. package/dist/es/hooks/use-stage.js +5 -0
  6. package/dist/es/initService.js +3 -0
  7. package/dist/es/layouts/sidebar/Sidebar.vue_vue_type_script_setup_true_lang.js +10 -3
  8. package/dist/es/layouts/sidebar/layer/LayerPanel.vue_vue_type_script_setup_true_lang.js +18 -5
  9. package/dist/es/layouts/sidebar/layer/use-click.js +22 -3
  10. package/dist/es/layouts/sidebar/layer/use-drag.js +28 -6
  11. package/dist/es/layouts/workspace/viewer/Stage.vue_vue_type_script_setup_true_lang.js +13 -2
  12. package/dist/es/services/editor.js +2 -1
  13. package/dist/es/services/props.js +12 -0
  14. package/dist/es/utils/dep/worker.js +1 -1
  15. package/dist/tmagic-editor.umd.cjs +141 -27
  16. package/package.json +7 -7
  17. package/src/Editor.vue +15 -0
  18. package/src/components/Tree.vue +1 -0
  19. package/src/components/TreeNode.vue +6 -1
  20. package/src/editorProps.ts +20 -0
  21. package/src/hooks/use-stage.ts +9 -0
  22. package/src/initService.ts +10 -0
  23. package/src/layouts/sidebar/Sidebar.vue +15 -0
  24. package/src/layouts/sidebar/layer/LayerPanel.vue +26 -2
  25. package/src/layouts/sidebar/layer/use-click.ts +36 -3
  26. package/src/layouts/sidebar/layer/use-drag.ts +62 -6
  27. package/src/layouts/workspace/viewer/Stage.vue +24 -2
  28. package/src/services/editor.ts +1 -0
  29. package/src/services/props.ts +16 -0
  30. package/src/type.ts +44 -0
  31. package/types/index.d.ts +73 -4
@@ -11,6 +11,7 @@ import StageCore, {
11
11
  import { getIdFromEl } from '@tmagic/utils';
12
12
 
13
13
  import type {
14
+ CanDropInFunction,
14
15
  ComponentGroup,
15
16
  CustomContentMenuFunction,
16
17
  DatasourceTypeOption,
@@ -21,6 +22,7 @@ import type {
21
22
  PageBarSortOptions,
22
23
  SideBarData,
23
24
  StageRect,
25
+ TreeNodeData,
24
26
  } from './type';
25
27
 
26
28
  export interface EditorProps {
@@ -75,6 +77,11 @@ export interface EditorProps {
75
77
  guidesOptions?: Partial<GuidesOptions>;
76
78
  /** 禁止多选 */
77
79
  disabledMultiSelect?: boolean;
80
+ /**
81
+ * 始终启用多选模式:开启后无需按住 Ctrl/Meta,点击即多选;
82
+ * 默认 false。当 `disabledMultiSelect` 为 true 时本配置失效
83
+ */
84
+ alwaysMultiSelect?: boolean;
78
85
  /** 禁用页面片 */
79
86
  disabledPageFragment?: boolean;
80
87
  /** 禁用双击在浮层中单独编辑选中组件 */
@@ -101,8 +108,20 @@ export interface EditorProps {
101
108
  customContentMenu?: CustomContentMenuFunction;
102
109
  /** 用于自定义判断组件树节点是否可展开(即是否要展示为拥有子节点的形态) */
103
110
  layerNodeIsExpandable?: IsExpandableFunction;
111
+ /**
112
+ * 用于自定义判断当前正在拖动的源是否可以拖入目标节点内部
113
+ *
114
+ * 同时覆盖以下两类场景,通过第三个参数 scene 区分:
115
+ * - `'layer'` :"已选组件"面板组件树拖动(返回 false 时仅禁用 inner,不影响 before/after)
116
+ * - `'stage'`:画布拖入组件(返回 false 时阻止该容器被高亮命中;适用于"组件列表拖入新组件"和"画布上拖动已有组件"两种细分情况)
117
+ *
118
+ * 注意:layer 场景目前只识别同步返回值;返回 Promise 时会按 true 处理(即允许)
119
+ */
120
+ canDropIn?: CanDropInFunction;
104
121
  /** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
105
122
  beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
123
+ /** 组件树节点双击前的钩子函数,返回 false 则阻止默认的双击行为 */
124
+ beforeLayerNodeDblclick?: (event: MouseEvent, data: TreeNodeData) => Promise<boolean | void> | boolean | void;
106
125
  extendFormState?: (state: FormState) => Record<string, any> | Promise<Record<string, any>>;
107
126
  /** 页面顺序拖拽配置参数 */
108
127
  pageBarSortOptions?: PageBarSortOptions;
@@ -113,6 +132,7 @@ export interface EditorProps {
113
132
  export const defaultEditorProps = {
114
133
  renderType: RenderType.IFRAME,
115
134
  disabledMultiSelect: false,
135
+ alwaysMultiSelect: false,
116
136
  disabledPageFragment: false,
117
137
  disabledStageOverlay: false,
118
138
  containerHighlightClassName: CONTAINER_HIGHLIGHT_CLASS_NAME,
@@ -24,6 +24,7 @@ export const useStage = (stageOptions: StageOptions) => {
24
24
  zoom: stageOptions.zoom ?? zoom.value,
25
25
  autoScrollIntoView: stageOptions.autoScrollIntoView,
26
26
  isContainer: stageOptions.isContainer,
27
+ canDropIn: stageOptions.canDropIn,
27
28
  containerHighlightClassName: stageOptions.containerHighlightClassName,
28
29
  containerHighlightDuration: stageOptions.containerHighlightDuration,
29
30
  containerHighlightType: stageOptions.containerHighlightType,
@@ -45,6 +46,7 @@ export const useStage = (stageOptions: StageOptions) => {
45
46
  updateDragEl: stageOptions.updateDragEl,
46
47
  guidesOptions: stageOptions.guidesOptions,
47
48
  disabledMultiSelect: stageOptions.disabledMultiSelect,
49
+ alwaysMultiSelect: stageOptions.alwaysMultiSelect,
48
50
  disabledRule: stageOptions.disabledRule,
49
51
  });
50
52
 
@@ -59,6 +61,13 @@ export const useStage = (stageOptions: StageOptions) => {
59
61
  },
60
62
  );
61
63
 
64
+ watch(
65
+ () => editorService.get('alwaysMultiSelect'),
66
+ (alwaysMultiSelect) => {
67
+ stage.setAlwaysMultiSelect(Boolean(alwaysMultiSelect));
68
+ },
69
+ );
70
+
62
71
  const hGuidesCache = getGuideLineFromCache(getGuideLineKey(H_GUIDE_LINE_STORAGE_KEY));
63
72
  const vGuidesCache = getGuideLineFromCache(getGuideLineKey(V_GUIDE_LINE_STORAGE_KEY));
64
73
 
@@ -72,6 +72,16 @@ export const initServiceState = (
72
72
  },
73
73
  );
74
74
 
75
+ watch(
76
+ () => props.alwaysMultiSelect,
77
+ (alwaysMultiSelect) => {
78
+ editorService.set('alwaysMultiSelect', alwaysMultiSelect || false);
79
+ },
80
+ {
81
+ immediate: true,
82
+ },
83
+ );
84
+
75
85
  watch(
76
86
  () => props.componentGroupList,
77
87
  (componentGroupList) => componentGroupList && componentListService.setList(componentGroupList),
@@ -162,6 +162,7 @@ import { useEditorContentHeight } from '@editor/hooks/use-editor-content-height'
162
162
  import { useFloatBox } from '@editor/hooks/use-float-box';
163
163
  import { useServices } from '@editor/hooks/use-services';
164
164
  import {
165
+ type CanDropInFunction,
165
166
  ColumnLayout,
166
167
  CustomContentMenuFunction,
167
168
  type IsExpandableFunction,
@@ -172,6 +173,7 @@ import {
172
173
  type SideComponent,
173
174
  type SideItem,
174
175
  SideItemKey,
176
+ type TreeNodeData,
175
177
  } from '@editor/type';
176
178
 
177
179
  import CodeBlockListPanel from './code-block/CodeBlockListPanel.vue';
@@ -185,6 +187,10 @@ defineOptions({
185
187
  name: 'MEditorSidebar',
186
188
  });
187
189
 
190
+ const emit = defineEmits<{
191
+ 'layer-node-dblclick': [event: MouseEvent, data: TreeNodeData];
192
+ }>();
193
+
188
194
  const props = withDefaults(
189
195
  defineProps<{
190
196
  data?: SideBarData;
@@ -194,6 +200,10 @@ const props = withDefaults(
194
200
  customContentMenu: CustomContentMenuFunction;
195
201
  /** 自定义判断组件树节点是否可展开(即是否要展示为拥有子节点的形态)的函数 */
196
202
  layerNodeIsExpandable?: IsExpandableFunction;
203
+ /** 自定义判断当前正在拖动的源是否可以拖入目标节点内部,详见 EditorProps.canDropIn */
204
+ canDropIn?: CanDropInFunction;
205
+ /** 组件树节点双击前的钩子函数,返回 false 则阻止默认的双击行为 */
206
+ beforeLayerNodeDblclick?: (_event: MouseEvent, _data: TreeNodeData) => Promise<boolean | void> | boolean | void;
197
207
  }>(),
198
208
  {
199
209
  data: () => ({
@@ -252,6 +262,11 @@ const getItemConfig = (data: SideItem): SideComponent => {
252
262
  indent: props.indent,
253
263
  nextLevelIndentIncrement: props.nextLevelIndentIncrement,
254
264
  isExpandable: props.layerNodeIsExpandable,
265
+ canDropIn: props.canDropIn,
266
+ beforeNodeDblclick: props.beforeLayerNodeDblclick,
267
+ },
268
+ listeners: {
269
+ 'node-dblclick': (event: MouseEvent, nodeData: TreeNodeData) => emit('layer-node-dblclick', event, nodeData),
255
270
  },
256
271
  component: LayerPanel,
257
272
  slots: {},
@@ -20,6 +20,7 @@
20
20
  @node-contextmenu="nodeContentMenuHandler"
21
21
  @node-mouseenter="mouseenterHandler"
22
22
  @node-click="nodeClickHandler"
23
+ @node-dblclick="dblclickHandler"
23
24
  >
24
25
  <template #tree-node-content="{ data: nodeData }">
25
26
  <slot name="layer-node-content" :data="nodeData"> </slot>
@@ -58,6 +59,7 @@ import Tree from '@editor/components/Tree.vue';
58
59
  import { useFilter } from '@editor/hooks/use-filter';
59
60
  import { useServices } from '@editor/hooks/use-services';
60
61
  import type {
62
+ CanDropInFunction,
61
63
  CustomContentMenuFunction,
62
64
  IsExpandableFunction,
63
65
  LayerPanelSlots,
@@ -79,13 +81,21 @@ defineOptions({
79
81
  name: 'MEditorLayerPanel',
80
82
  });
81
83
 
82
- defineProps<{
84
+ const props = defineProps<{
83
85
  layerContentMenu: (MenuButton | MenuComponent)[];
84
86
  indent?: number;
85
87
  nextLevelIndentIncrement?: number;
86
88
  customContentMenu: CustomContentMenuFunction;
87
89
  /** 自定义判断组件树节点是否可展开(即是否要展示为拥有子节点的形态)的函数 */
88
90
  isExpandable?: IsExpandableFunction;
91
+ /** 自定义判断当前拖动节点是否可以拖入目标节点内部的函数,返回 false 则禁止拖入 */
92
+ canDropIn?: CanDropInFunction;
93
+ /** 组件树节点双击前的钩子函数,返回 false 则阻止默认的双击行为 */
94
+ beforeNodeDblclick?: (_event: MouseEvent, _data: TreeNodeData) => Promise<boolean | void> | boolean | void;
95
+ }>();
96
+
97
+ const emit = defineEmits<{
98
+ 'node-dblclick': [event: MouseEvent, data: TreeNodeData];
89
99
  }>();
90
100
 
91
101
  const services = useServices();
@@ -123,13 +133,27 @@ const collapseAllHandler = () => {
123
133
  }
124
134
  };
125
135
 
126
- const { handleDragStart, handleDragEnd, handleDragLeave, handleDragOver } = useDrag(services);
136
+ const { handleDragStart, handleDragEnd, handleDragLeave, handleDragOver } = useDrag(services, {
137
+ canDropIn: (sourceIds, targetId) => props.canDropIn?.(sourceIds, targetId, 'layer'),
138
+ });
127
139
 
128
140
  // 右键菜单
129
141
  const menuRef = useTemplateRef<InstanceType<typeof LayerMenu>>('menu');
130
142
  const {
131
143
  nodeClickHandler,
144
+ nodeDblclickHandler,
132
145
  nodeContentMenuHandler,
133
146
  highlightHandler: mouseenterHandler,
134
147
  } = useClick(services, isCtrlKeyDown, nodeStatusMap, menuRef);
148
+
149
+ const dblclickHandler = async (event: MouseEvent, data: TreeNodeData) => {
150
+ if (props.beforeNodeDblclick) {
151
+ const result = await props.beforeNodeDblclick(event, data);
152
+ if (result === false) return;
153
+ }
154
+
155
+ nodeDblclickHandler(event, data);
156
+
157
+ emit('node-dblclick', event, data);
158
+ };
135
159
  </script>
@@ -2,7 +2,7 @@ import { computed, type ComputedRef, nextTick, type Ref, type ShallowRef } from
2
2
  import { throttle } from 'lodash-es';
3
3
 
4
4
  import { Id, MNode } from '@tmagic/core';
5
- import { isPage, isPageFragment } from '@tmagic/utils';
5
+ import { getElById, isPage, isPageFragment } from '@tmagic/utils';
6
6
 
7
7
  import type { LayerNodeStatus, Services, TreeNodeData } from '@editor/type';
8
8
  import { UI_SELECT_MODE_EVENT_NAME } from '@editor/utils/const';
@@ -16,7 +16,9 @@ export const useClick = (
16
16
  nodeStatusMap: ComputedRef<Map<Id, LayerNodeStatus> | undefined>,
17
17
  menuRef: ShallowRef<InstanceType<typeof LayerMenu> | null>,
18
18
  ) => {
19
- const isMultiSelect = computed(() => isCtrlKeyDown.value && !editorService.get('disabledMultiSelect'));
19
+ const isMultiSelect = computed(
20
+ () => !editorService.get('disabledMultiSelect') && (isCtrlKeyDown.value || editorService.get('alwaysMultiSelect')),
21
+ );
20
22
 
21
23
  // 触发画布选中
22
24
  const select = async (data: MNode) => {
@@ -81,6 +83,19 @@ export const useClick = (
81
83
  stageOverlayService.get('stage')?.highlight(data.id);
82
84
  };
83
85
 
86
+ const isNodeCanSelect = async (data: TreeNodeData) => {
87
+ const canSelect = stageOverlayService.get('stageOptions')?.canSelect;
88
+ if (!canSelect) return true;
89
+
90
+ const doc = editorService.get('stage')?.renderer?.contentWindow?.document;
91
+ if (!doc) return true;
92
+
93
+ const el = getElById()(doc, data.id);
94
+ if (!el) return true;
95
+
96
+ return Boolean(await canSelect(el));
97
+ };
98
+
84
99
  const nodeClickHandler = (event: MouseEvent, data: TreeNodeData): void => {
85
100
  if (!nodeStatusMap?.value) return;
86
101
 
@@ -95,16 +110,34 @@ export const useClick = (
95
110
  });
96
111
  }
97
112
 
98
- nextTick(() => {
113
+ nextTick(async () => {
114
+ if (!(await isNodeCanSelect(data))) return;
115
+
99
116
  select(data);
100
117
  });
101
118
  };
102
119
 
120
+ const nodeDblclickHandler = (event: MouseEvent, data: TreeNodeData): void => {
121
+ if (!nodeStatusMap?.value) return;
122
+
123
+ if (uiService.get('uiSelectMode')) return;
124
+
125
+ // 双击切换展开/收起状态
126
+ if (data.items && data.items.length > 0) {
127
+ const status = nodeStatusMap.value.get(data.id);
128
+ updateStatus(nodeStatusMap.value, data.id, {
129
+ expand: !status?.expand,
130
+ });
131
+ }
132
+ };
133
+
103
134
  return {
104
135
  menuRef,
105
136
 
106
137
  nodeClickHandler,
107
138
 
139
+ nodeDblclickHandler,
140
+
108
141
  nodeContentMenuHandler(event: MouseEvent, data: TreeNodeData): void {
109
142
  event.preventDefault();
110
143
 
@@ -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 {
@@ -317,12 +317,34 @@ const dropHandler = async (e: DragEvent) => {
317
317
  );
318
318
 
319
319
  let parent: MContainer | undefined | null = page.value;
320
+ let resolvedParentEl: HTMLElement | null | undefined = parentEl;
320
321
  const parentId = getIdFromEl()(parentEl);
321
322
  if (parentId) {
322
323
  parent = editorService.getNodeById(parentId, false) as MContainer;
323
324
  }
324
325
 
325
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
+
326
348
  const layout = await editorService.getLayout(parent);
327
349
 
328
350
  const containerRect = stageContainerEl.value.getBoundingClientRect();
@@ -342,8 +364,8 @@ const dropHandler = async (e: DragEvent) => {
342
364
  top = e.clientY - containerRect.top + scrollTop;
343
365
  left = e.clientX - containerRect.left + scrollLeft;
344
366
 
345
- if (parentEl) {
346
- const { left: parentLeft, top: parentTop } = getOffset(parentEl);
367
+ if (resolvedParentEl) {
368
+ const { left: parentLeft, top: parentTop } = getOffset(resolvedParentEl);
347
369
  left = left - parentLeft * zoom.value;
348
370
  top = top - parentTop * zoom.value;
349
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',
@@ -125,12 +129,20 @@ class Props extends BaseService {
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
  }
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,
@@ -168,10 +169,23 @@ export interface StageOptions {
168
169
  moveableOptions?: CustomizeMoveableOptions;
169
170
  canSelect?: (el: HTMLElement) => boolean | Promise<boolean>;
170
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;
171
180
  updateDragEl?: UpdateDragEl;
172
181
  renderType?: RenderType;
173
182
  guidesOptions?: Partial<GuidesOptions>;
174
183
  disabledMultiSelect?: boolean;
184
+ /**
185
+ * 始终启用多选模式(无需按住 Ctrl/Meta),默认 false。
186
+ * 当 `disabledMultiSelect` 为 true 时本配置失效。
187
+ */
188
+ alwaysMultiSelect?: boolean;
175
189
  disabledRule?: boolean;
176
190
  zoom?: number;
177
191
  /** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
@@ -191,6 +205,8 @@ export interface StoreState {
191
205
  pageLength: number;
192
206
  pageFragmentLength: number;
193
207
  disabledMultiSelect: boolean;
208
+ /** 是否始终启用多选模式(无需按住 Ctrl/Meta) */
209
+ alwaysMultiSelect: boolean;
194
210
  }
195
211
 
196
212
  export type StoreStateKey = keyof StoreState;
@@ -682,6 +698,34 @@ export interface TreeNodeData {
682
698
  /** 判断组件树节点是否可展开(即是否要展示为拥有子节点的形态)的函数 */
683
699
  export type IsExpandableFunction = (_data: TreeNodeData, _nodeStatusMap: Map<Id, LayerNodeStatus>) => boolean;
684
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
+
685
729
  export type AsyncBeforeHook<Value extends Array<string>, C extends Record<Value[number], (...args: any) => any>> = {
686
730
  [K in Value[number]]?: (...args: Parameters<C[K]>) => Promise<Parameters<C[K]>> | Parameters<C[K]>;
687
731
  };