From aab73249d1803219cc4b08b25662760fa0dfc129 Mon Sep 17 00:00:00 2001 From: roymondchen Date: Mon, 11 May 2026 16:50:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E6=96=B0=E5=A2=9E=20alwaysMult?= =?UTF-8?q?iSelect=20=E9=85=8D=E7=BD=AE=E5=BC=80=E5=90=AF=E5=B8=B8?= =?UTF-8?q?=E9=A9=BB=E5=A4=9A=E9=80=89=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增编辑器配置项 alwaysMultiSelect(默认 false),开启后无需按住 Ctrl/Meta 键,组件树与画布点击即多选;当 disabledMultiSelect=true 时本配置失效。同步 在 stage 层 ActionManager 暴露 setAlwaysMultiSelect 方法用于运行时切换,并 补充组件树/服务/画布的状态联动、文档与单元测试。 Co-authored-by: Cursor --- docs/api/editor/editorServiceMethods.md | 6 +- docs/api/editor/props.md | 21 ++- docs/api/stage/coreMethods.md | 7 + packages/editor/src/Editor.vue | 1 + packages/editor/src/editorProps.ts | 6 + packages/editor/src/hooks/use-stage.ts | 8 ++ packages/editor/src/initService.ts | 10 ++ .../src/layouts/sidebar/layer/use-click.ts | 23 ++- packages/editor/src/services/editor.ts | 1 + packages/editor/src/type.ts | 7 + .../editor/tests/unit/services/editor.spec.ts | 17 +++ packages/stage/src/ActionManager.ts | 25 +++- packages/stage/src/StageCore.ts | 9 ++ packages/stage/src/types.ts | 10 ++ .../stage/tests/unit/ActionManager.spec.ts | 132 ++++++++++++++++++ 15 files changed, 275 insertions(+), 8 deletions(-) create mode 100644 packages/stage/tests/unit/ActionManager.spec.ts diff --git a/docs/api/editor/editorServiceMethods.md b/docs/api/editor/editorServiceMethods.md index 924dcf3d..96380af7 100644 --- a/docs/api/editor/editorServiceMethods.md +++ b/docs/api/editor/editorServiceMethods.md @@ -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` - **详情:** diff --git a/docs/api/editor/props.md b/docs/api/editor/props.md index 7e6331a4..955b0f94 100644 --- a/docs/api/editor/props.md +++ b/docs/api/editor/props.md @@ -1043,7 +1043,26 @@ const updateDragEl = (el, target) => { ``` - + +## alwaysMultiSelect + +- **详情:** + + 始终启用多选模式:开启后无需按住 `Ctrl/Meta` 键,组件树和画布上点击即多选。 + 当 [`disabledMultiSelect`](#disabledmultiselect) 为 `true` 时本配置失效。 + +- **类型:** `boolean` + +- **默认值:** `false` + +- **示例:** + +```html + +``` + ## guidesOptions - **详情:** diff --git a/docs/api/stage/coreMethods.md b/docs/api/stage/coreMethods.md index 83469f9e..e2884fc2 100644 --- a/docs/api/stage/coreMethods.md +++ b/docs/api/stage/coreMethods.md @@ -113,6 +113,13 @@ - **类型**:`() => void` - **详情**:启用多选能力 +## setAlwaysMultiSelect + +- **类型**:`(value: boolean) => void` +- **参数**: + - `value`:是否始终启用多选模式(无需按住 `Ctrl/Meta` 键) +- **详情**:设置是否始终启用多选模式。当多选被 `disableMultiSelect` 禁用时,本方法不会启用多选 + ## reloadIframe - **类型**:`(url: string) => void` diff --git a/packages/editor/src/Editor.vue b/packages/editor/src/Editor.vue index e37ff2bf..2252e0ac 100644 --- a/packages/editor/src/Editor.vue +++ b/packages/editor/src/Editor.vue @@ -219,6 +219,7 @@ const stageOptions: StageOptions = { renderType: props.renderType, guidesOptions: props.guidesOptions, disabledMultiSelect: props.disabledMultiSelect, + alwaysMultiSelect: props.alwaysMultiSelect, beforeDblclick: props.beforeDblclick, }; diff --git a/packages/editor/src/editorProps.ts b/packages/editor/src/editorProps.ts index 17a4b0b7..8feec1d0 100644 --- a/packages/editor/src/editorProps.ts +++ b/packages/editor/src/editorProps.ts @@ -77,6 +77,11 @@ export interface EditorProps { guidesOptions?: Partial; /** 禁止多选 */ 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, diff --git a/packages/editor/src/hooks/use-stage.ts b/packages/editor/src/hooks/use-stage.ts index 97366be9..0fa0d2bb 100644 --- a/packages/editor/src/hooks/use-stage.ts +++ b/packages/editor/src/hooks/use-stage.ts @@ -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)); diff --git a/packages/editor/src/initService.ts b/packages/editor/src/initService.ts index e3aaebd3..97059a6a 100644 --- a/packages/editor/src/initService.ts +++ b/packages/editor/src/initService.ts @@ -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), diff --git a/packages/editor/src/layouts/sidebar/layer/use-click.ts b/packages/editor/src/layouts/sidebar/layer/use-click.ts index cc6a3fd4..515352b0 100644 --- a/packages/editor/src/layouts/sidebar/layer/use-click.ts +++ b/packages/editor/src/layouts/sidebar/layer/use-click.ts @@ -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 | undefined>, menuRef: ShallowRef | 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); }); }; diff --git a/packages/editor/src/services/editor.ts b/packages/editor/src/services/editor.ts index 2ed199da..85166281 100644 --- a/packages/editor/src/services/editor.ts +++ b/packages/editor/src/services/editor.ts @@ -79,6 +79,7 @@ class Editor extends BaseService { pageLength: 0, pageFragmentLength: 0, disabledMultiSelect: false, + alwaysMultiSelect: false, }); private isHistoryStateChange = false; private selectionBeforeOp: Id[] | null = null; diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts index bdecf051..99c0e991 100644 --- a/packages/editor/src/type.ts +++ b/packages/editor/src/type.ts @@ -181,6 +181,11 @@ export interface StageOptions { renderType?: RenderType; guidesOptions?: Partial; 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; diff --git a/packages/editor/tests/unit/services/editor.spec.ts b/packages/editor/tests/unit/services/editor.spec.ts index 8fc3f00a..b2e4938c 100644 --- a/packages/editor/tests/unit/services/editor.spec.ts +++ b/packages/editor/tests/unit/services/editor.spec.ts @@ -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))); diff --git a/packages/stage/src/ActionManager.ts b/packages/stage/src/ActionManager.ts index 3e4c307b..f3e93a4d 100644 --- a/packages/stage/src/ActionManager.ts +++ b/packages/stage/src/ActionManager.ts @@ -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; } }); diff --git a/packages/stage/src/StageCore.ts b/packages/stage/src/StageCore.ts index fc26800c..e68b6058 100644 --- a/packages/stage/src/StageCore.ts +++ b/packages/stage/src/StageCore.ts @@ -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, diff --git a/packages/stage/src/types.ts b/packages/stage/src/types.ts index 20882725..d8526af9 100644 --- a/packages/stage/src/types.ts +++ b/packages/stage/src/types.ts @@ -84,6 +84,11 @@ export interface StageCoreConfig { renderType?: RenderType; guidesOptions?: Partial; 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 */ diff --git a/packages/stage/tests/unit/ActionManager.spec.ts b/packages/stage/tests/unit/ActionManager.spec.ts new file mode 100644 index 00000000..aec2cd0f --- /dev/null +++ b/packages/stage/tests/unit/ActionManager.spec.ts @@ -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 => { + 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); + }); +});