feat(editor): 新增 alwaysMultiSelect 配置开启常驻多选模式

新增编辑器配置项 alwaysMultiSelect(默认 false),开启后无需按住 Ctrl/Meta
键,组件树与画布点击即多选;当 disabledMultiSelect=true 时本配置失效。同步
在 stage 层 ActionManager 暴露 setAlwaysMultiSelect 方法用于运行时切换,并
补充组件树/服务/画布的状态联动、文档与单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
roymondchen 2026-05-11 16:50:40 +08:00
parent 2475a4f901
commit aab73249d1
15 changed files with 275 additions and 8 deletions

View File

@ -3,7 +3,7 @@
## get
- **参数:**
- `{'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'modifiedNodeIds' | 'pageLength' | 'pageFragmentLength' | 'stage' | 'stageLoading' | 'disabledMultiSelect'} name`
- `{'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'modifiedNodeIds' | 'pageLength' | 'pageFragmentLength' | 'stage' | 'stageLoading' | 'disabledMultiSelect' | 'alwaysMultiSelect'} name`
- **返回:**
- `{any} value`
@ -36,6 +36,8 @@
'disabledMultiSelect': 是否禁用多选
'alwaysMultiSelect': 是否始终启用多选模式(无需按住 Ctrl/Meta
- **示例:**
```js
@ -46,7 +48,7 @@ const node = editorService.get("node");
## set
- `{'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'modifiedNodeIds' | 'pageLength' | 'pageFragmentLength' | 'stage' | 'stageLoading' | 'disabledMultiSelect'} name`
- `{'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'modifiedNodeIds' | 'pageLength' | 'pageFragmentLength' | 'stage' | 'stageLoading' | 'disabledMultiSelect' | 'alwaysMultiSelect'} name`
- `{any} value`
- **详情:**

View File

@ -1043,7 +1043,26 @@ const updateDragEl = (el, target) => {
<m-editor :disabled-multi-select="true"></m-editor>
</template>
```
## alwaysMultiSelect
- **详情:**
始终启用多选模式:开启后无需按住 `Ctrl/Meta` 键,组件树和画布上点击即多选。
当 [`disabledMultiSelect`](#disabledmultiselect) 为 `true` 时本配置失效。
- **类型:** `boolean`
- **默认值:** `false`
- **示例:**
```html
<template>
<m-editor :always-multi-select="true"></m-editor>
</template>
```
## guidesOptions
- **详情:**

View File

@ -113,6 +113,13 @@
- **类型**`() => void`
- **详情**:启用多选能力
## setAlwaysMultiSelect
- **类型**`(value: boolean) => void`
- **参数**
- `value`:是否始终启用多选模式(无需按住 `Ctrl/Meta` 键)
- **详情**:设置是否始终启用多选模式。当多选被 `disableMultiSelect` 禁用时,本方法不会启用多选
## reloadIframe
- **类型**`(url: string) => void`

View File

@ -219,6 +219,7 @@ const stageOptions: StageOptions = {
renderType: props.renderType,
guidesOptions: props.guidesOptions,
disabledMultiSelect: props.disabledMultiSelect,
alwaysMultiSelect: props.alwaysMultiSelect,
beforeDblclick: props.beforeDblclick,
};

View File

@ -77,6 +77,11 @@ export interface EditorProps {
guidesOptions?: Partial<GuidesOptions>;
/** 禁止多选 */
disabledMultiSelect?: boolean;
/**
* Ctrl/Meta
* false `disabledMultiSelect` true
*/
alwaysMultiSelect?: boolean;
/** 禁用页面片 */
disabledPageFragment?: boolean;
/** 禁用双击在浮层中单独编辑选中组件 */
@ -127,6 +132,7 @@ export interface EditorProps {
export const defaultEditorProps = {
renderType: RenderType.IFRAME,
disabledMultiSelect: false,
alwaysMultiSelect: false,
disabledPageFragment: false,
disabledStageOverlay: false,
containerHighlightClassName: CONTAINER_HIGHLIGHT_CLASS_NAME,

View File

@ -46,6 +46,7 @@ export const useStage = (stageOptions: StageOptions) => {
updateDragEl: stageOptions.updateDragEl,
guidesOptions: stageOptions.guidesOptions,
disabledMultiSelect: stageOptions.disabledMultiSelect,
alwaysMultiSelect: stageOptions.alwaysMultiSelect,
disabledRule: stageOptions.disabledRule,
});
@ -60,6 +61,13 @@ export const useStage = (stageOptions: StageOptions) => {
},
);
watch(
() => editorService.get('alwaysMultiSelect'),
(alwaysMultiSelect) => {
stage.setAlwaysMultiSelect(Boolean(alwaysMultiSelect));
},
);
const hGuidesCache = getGuideLineFromCache(getGuideLineKey(H_GUIDE_LINE_STORAGE_KEY));
const vGuidesCache = getGuideLineFromCache(getGuideLineKey(V_GUIDE_LINE_STORAGE_KEY));

View File

@ -72,6 +72,16 @@ export const initServiceState = (
},
);
watch(
() => props.alwaysMultiSelect,
(alwaysMultiSelect) => {
editorService.set('alwaysMultiSelect', alwaysMultiSelect || false);
},
{
immediate: true,
},
);
watch(
() => props.componentGroupList,
(componentGroupList) => componentGroupList && componentListService.setList(componentGroupList),

View File

@ -2,7 +2,7 @@ import { computed, type ComputedRef, nextTick, type Ref, type ShallowRef } from
import { throttle } from 'lodash-es';
import { Id, MNode } from '@tmagic/core';
import { isPage, isPageFragment } from '@tmagic/utils';
import { getElById, isPage, isPageFragment } from '@tmagic/utils';
import type { LayerNodeStatus, Services, TreeNodeData } from '@editor/type';
import { UI_SELECT_MODE_EVENT_NAME } from '@editor/utils/const';
@ -16,7 +16,9 @@ export const useClick = (
nodeStatusMap: ComputedRef<Map<Id, LayerNodeStatus> | undefined>,
menuRef: ShallowRef<InstanceType<typeof LayerMenu> | null>,
) => {
const isMultiSelect = computed(() => isCtrlKeyDown.value && !editorService.get('disabledMultiSelect'));
const isMultiSelect = computed(
() => !editorService.get('disabledMultiSelect') && (isCtrlKeyDown.value || editorService.get('alwaysMultiSelect')),
);
// 触发画布选中
const select = async (data: MNode) => {
@ -81,6 +83,19 @@ export const useClick = (
stageOverlayService.get('stage')?.highlight(data.id);
};
const isNodeCanSelect = async (data: TreeNodeData) => {
const canSelect = stageOverlayService.get('stageOptions')?.canSelect;
if (!canSelect) return true;
const doc = editorService.get('stage')?.renderer?.contentWindow?.document;
if (!doc) return true;
const el = getElById()(doc, data.id);
if (!el) return true;
return Boolean(await canSelect(el));
};
const nodeClickHandler = (event: MouseEvent, data: TreeNodeData): void => {
if (!nodeStatusMap?.value) return;
@ -95,7 +110,9 @@ export const useClick = (
});
}
nextTick(() => {
nextTick(async () => {
if (!(await isNodeCanSelect(data))) return;
select(data);
});
};

View File

@ -79,6 +79,7 @@ class Editor extends BaseService {
pageLength: 0,
pageFragmentLength: 0,
disabledMultiSelect: false,
alwaysMultiSelect: false,
});
private isHistoryStateChange = false;
private selectionBeforeOp: Id[] | null = null;

View File

@ -181,6 +181,11 @@ export interface StageOptions {
renderType?: RenderType;
guidesOptions?: Partial<GuidesOptions>;
disabledMultiSelect?: boolean;
/**
* Ctrl/Meta false
* `disabledMultiSelect` true
*/
alwaysMultiSelect?: boolean;
disabledRule?: boolean;
zoom?: number;
/** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
@ -200,6 +205,8 @@ export interface StoreState {
pageLength: number;
pageFragmentLength: number;
disabledMultiSelect: boolean;
/** 是否始终启用多选模式(无需按住 Ctrl/Meta */
alwaysMultiSelect: boolean;
}
export type StoreStateKey = keyof StoreState;

View File

@ -127,6 +127,23 @@ describe('get', () => {
});
});
describe('multiSelect 标志位', () => {
test('disabledMultiSelect 默认值为 false', () => {
expect(editorService.get('disabledMultiSelect')).toBe(false);
});
test('alwaysMultiSelect 默认值为 false', () => {
expect(editorService.get('alwaysMultiSelect')).toBe(false);
});
test('alwaysMultiSelect 可被 set 修改并通过 get 读取', () => {
editorService.set('alwaysMultiSelect', true);
expect(editorService.get('alwaysMultiSelect')).toBe(true);
editorService.set('alwaysMultiSelect', false);
expect(editorService.get('alwaysMultiSelect')).toBe(false);
});
});
describe('getNodeInfo', () => {
beforeAll(() => editorService.set('root', cloneDeep(root)));

View File

@ -93,6 +93,8 @@ export default class ActionManager extends EventEmitter {
private canDropIn?: CanDropIn;
private getRenderDocument: GetRenderDocument;
private disabledMultiSelect = false;
/** 始终启用多选模式(无需按住 Ctrl/Meta优先级低于 disabledMultiSelect */
private alwaysMultiSelect = false;
private config: ActionManagerConfig;
private mouseMoveHandler = throttle((event: MouseEvent): void => {
@ -123,6 +125,7 @@ export default class ActionManager extends EventEmitter {
this.containerHighlightDuration = config.containerHighlightDuration || defaultContainerHighlightDuration;
this.containerHighlightType = config.containerHighlightType;
this.disabledMultiSelect = config.disabledMultiSelect ?? false;
this.alwaysMultiSelect = config.alwaysMultiSelect ?? false;
this.getTargetElement = config.getTargetElement;
this.getElementsFromPoint = config.getElementsFromPoint;
this.canSelect = config.canSelect || ((el: HTMLElement) => Boolean(getIdFromEl()(el)));
@ -134,6 +137,9 @@ export default class ActionManager extends EventEmitter {
if (!this.disabledMultiSelect) {
this.multiDr = this.createMultiDr(config);
if (this.alwaysMultiSelect) {
this.isMultiSelectStatus = true;
}
}
this.highlightLayer = new StageHighlight({
@ -148,6 +154,7 @@ export default class ActionManager extends EventEmitter {
public disableMultiSelect() {
this.disabledMultiSelect = true;
this.isMultiSelectStatus = false;
if (this.multiDr) {
this.multiDr.destroy();
this.multiDr = null;
@ -160,6 +167,20 @@ export default class ActionManager extends EventEmitter {
if (!this.multiDr) {
this.multiDr = this.createMultiDr(this.config);
}
if (this.alwaysMultiSelect) {
this.isMultiSelectStatus = true;
}
}
/**
* Ctrl/Meta
* `disabledMultiSelect` true
*/
public setAlwaysMultiSelect(value: boolean) {
this.alwaysMultiSelect = value;
if (this.disabledMultiSelect) return;
this.isMultiSelectStatus = value;
}
/**
@ -633,14 +654,14 @@ export default class ActionManager extends EventEmitter {
});
// ctrl+tab切到其他窗口需要将多选状态置为false
KeyController.global.on('blur', () => {
if (!this.disabledMultiSelect) {
if (!this.disabledMultiSelect && !this.alwaysMultiSelect) {
this.isMultiSelectStatus = false;
}
this.isAltKeydown = false;
});
KeyController.global.keyup(ctrl, (e) => {
e.inputEvent.preventDefault();
if (!this.disabledMultiSelect) {
if (!this.disabledMultiSelect && !this.alwaysMultiSelect) {
this.isMultiSelectStatus = false;
}
});

View File

@ -269,6 +269,14 @@ export default class StageCore extends EventEmitter {
this.actionManager?.enableMultiSelect();
}
/**
* Ctrl/Meta
* `disabledMultiSelect`
*/
public setAlwaysMultiSelect(value: boolean) {
this.actionManager?.setAlwaysMultiSelect(value);
}
public reloadIframe(url: string) {
this.renderer?.reloadIframe(url);
}
@ -346,6 +354,7 @@ export default class StageCore extends EventEmitter {
container: this.mask!.content,
disabledDragStart: config.disabledDragStart,
disabledMultiSelect: config.disabledMultiSelect,
alwaysMultiSelect: config.alwaysMultiSelect,
canSelect: config.canSelect,
isContainer: config.isContainer,
canDropIn: config.canDropIn,

View File

@ -84,6 +84,11 @@ export interface StageCoreConfig {
renderType?: RenderType;
guidesOptions?: Partial<GuidesOptions>;
disabledMultiSelect?: boolean;
/**
* Ctrl/Meta false
* `disabledMultiSelect` true
*/
alwaysMultiSelect?: boolean;
disabledRule?: boolean;
}
@ -95,6 +100,11 @@ export interface ActionManagerConfig {
moveableOptions?: CustomizeMoveableOptions;
disabledDragStart?: boolean;
disabledMultiSelect?: boolean;
/**
* Ctrl/Meta false
* `disabledMultiSelect` true
*/
alwaysMultiSelect?: boolean;
canSelect?: CanSelect;
isContainer?: IsContainer;
/** 见 StageCoreConfig.canDropIn */

View File

@ -0,0 +1,132 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import ActionManager from '../../src/ActionManager';
import type { ActionManagerConfig } from '../../src/types';
// 在 jsdom 环境下 `moveable-helper`/`moveable` 的 ESM 默认导出无法直接被
// require/import 调用,这里仅测试 ActionManager 自身的多选状态管理,将其桩掉。
// vi.mock 调用会被 vitest 自动提升到模块顶部,因此放在 import 之后无影响。
vi.mock('moveable-helper', () => ({
default: {
create: () => ({ clear: vi.fn() }),
},
}));
vi.mock('moveable', () => ({
default: class MockMoveable {
on() {
return this;
}
off() {
return this;
}
destroy() {}
request() {}
updateRect() {}
updateTarget() {}
},
}));
const createConfig = (overrides: Partial<ActionManagerConfig> = {}): ActionManagerConfig => {
const container = globalThis.document.createElement('div');
globalThis.document.body.appendChild(container);
return {
container,
getTargetElement: () => null,
getElementsFromPoint: () => [],
getRenderDocument: () => globalThis.document,
getRootContainer: () => undefined,
...overrides,
};
};
describe('ActionManager - alwaysMultiSelect', () => {
let am: ActionManager | null = null;
beforeEach(() => {
globalThis.document.body.innerHTML = '';
});
afterEach(() => {
am?.destroy();
am = null;
});
test('默认配置下不会自动开启多选状态', () => {
am = new ActionManager(createConfig());
expect((am as any).isMultiSelectStatus).toBe(false);
expect((am as any).alwaysMultiSelect).toBe(false);
});
test('alwaysMultiSelect=true 构造后即处于多选状态', () => {
am = new ActionManager(createConfig({ alwaysMultiSelect: true }));
expect((am as any).isMultiSelectStatus).toBe(true);
expect((am as any).alwaysMultiSelect).toBe(true);
});
test('disabledMultiSelect=true 时 alwaysMultiSelect=true 也不会开启多选', () => {
am = new ActionManager(
createConfig({
disabledMultiSelect: true,
alwaysMultiSelect: true,
}),
);
expect((am as any).isMultiSelectStatus).toBe(false);
expect((am as any).disabledMultiSelect).toBe(true);
});
test('setAlwaysMultiSelect(true) 切换为多选状态', () => {
am = new ActionManager(createConfig());
expect((am as any).isMultiSelectStatus).toBe(false);
am.setAlwaysMultiSelect(true);
expect((am as any).alwaysMultiSelect).toBe(true);
expect((am as any).isMultiSelectStatus).toBe(true);
am.setAlwaysMultiSelect(false);
expect((am as any).alwaysMultiSelect).toBe(false);
expect((am as any).isMultiSelectStatus).toBe(false);
});
test('setAlwaysMultiSelect 不能突破 disabledMultiSelect 限制', () => {
am = new ActionManager(createConfig({ disabledMultiSelect: true }));
am.setAlwaysMultiSelect(true);
expect((am as any).alwaysMultiSelect).toBe(true);
expect((am as any).isMultiSelectStatus).toBe(false);
});
test('disableMultiSelect() 会重置多选状态', () => {
am = new ActionManager(createConfig({ alwaysMultiSelect: true }));
expect((am as any).isMultiSelectStatus).toBe(true);
am.disableMultiSelect();
expect((am as any).disabledMultiSelect).toBe(true);
expect((am as any).isMultiSelectStatus).toBe(false);
});
test('enableMultiSelect() 后若 alwaysMultiSelect=true 恢复多选状态', () => {
am = new ActionManager(createConfig({ alwaysMultiSelect: true }));
am.disableMultiSelect();
expect((am as any).isMultiSelectStatus).toBe(false);
am.enableMultiSelect();
expect((am as any).disabledMultiSelect).toBe(false);
expect((am as any).isMultiSelectStatus).toBe(true);
});
});