diff --git a/docs/api/editor/props.md b/docs/api/editor/props.md
index 15ef8116..66dceec9 100644
--- a/docs/api/editor/props.md
+++ b/docs/api/editor/props.md
@@ -1220,6 +1220,28 @@ const guidesOptions = {
```
+## disabledFlashTip
+
+- **详情:**
+
+ 禁用「非点击画布选中组件时的高亮闪烁提示」。
+
+ 当组件不是通过点击画布选中(如从组件树、面包屑等外部方式选中)时,编辑器会在画布上对选中区域做一次高亮闪烁,帮助用户快速定位组件在画布中的位置。设置为 `true` 可关闭该提示。
+
+ 注:选中页面(`magic-ui-page`)时不会触发闪烁。
+
+- **默认值:** `false`
+
+- **类型:** `boolean`
+
+- **示例:**
+
+```html
+
+
+
+```
+
## disabledStageOverlay
- **详情:**
diff --git a/packages/editor/src/Editor.vue b/packages/editor/src/Editor.vue
index 28e4adc4..b328e8f0 100644
--- a/packages/editor/src/Editor.vue
+++ b/packages/editor/src/Editor.vue
@@ -221,6 +221,7 @@ const stageOptions: StageOptions = {
guidesOptions: props.guidesOptions,
disabledMultiSelect: props.disabledMultiSelect,
alwaysMultiSelect: props.alwaysMultiSelect,
+ disabledFlashTip: props.disabledFlashTip,
beforeDblclick: props.beforeDblclick,
};
diff --git a/packages/editor/src/editorProps.ts b/packages/editor/src/editorProps.ts
index 8c16ec3b..da6cc78e 100644
--- a/packages/editor/src/editorProps.ts
+++ b/packages/editor/src/editorProps.ts
@@ -87,6 +87,8 @@ export interface EditorProps {
alwaysMultiSelect?: boolean;
/** 禁用页面片 */
disabledPageFragment?: boolean;
+ /** 禁用「非点击画布选中组件时(如从图层树、面包屑等外部选中),对选中区域做高亮闪烁提示」,默认 false(即默认开启闪烁) */
+ disabledFlashTip?: boolean;
/** 禁用双击在浮层中单独编辑选中组件 */
disabledStageOverlay?: boolean;
/** 禁用属性配置面板右下角显示源码的按钮 */
@@ -139,6 +141,7 @@ export const defaultEditorProps = {
disabledMultiSelect: false,
alwaysMultiSelect: false,
disabledPageFragment: false,
+ disabledFlashTip: false,
disabledStageOverlay: false,
containerHighlightClassName: CONTAINER_HIGHLIGHT_CLASS_NAME,
containerHighlightDuration: 800,
diff --git a/packages/editor/src/hooks/use-stage.ts b/packages/editor/src/hooks/use-stage.ts
index d191aa42..1b6cd268 100644
--- a/packages/editor/src/hooks/use-stage.ts
+++ b/packages/editor/src/hooks/use-stage.ts
@@ -48,6 +48,7 @@ export const useStage = (stageOptions: StageOptions) => {
disabledMultiSelect: stageOptions.disabledMultiSelect,
alwaysMultiSelect: stageOptions.alwaysMultiSelect,
disabledRule: stageOptions.disabledRule,
+ disabledFlashTip: stageOptions.disabledFlashTip,
});
watch(
diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts
index 7a031c21..d02c49f8 100644
--- a/packages/editor/src/type.ts
+++ b/packages/editor/src/type.ts
@@ -199,6 +199,11 @@ export interface StageOptions {
*/
alwaysMultiSelect?: boolean;
disabledRule?: boolean;
+ /**
+ * 禁用「非点击画布选中组件时(如从图层树、面包屑等外部选中),对选中区域做高亮闪烁提示」,
+ * 默认 false(即默认开启闪烁)
+ */
+ disabledFlashTip?: boolean;
zoom?: number;
/** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
beforeDblclick?: (event: MouseEvent) => Promise | boolean | void;
diff --git a/packages/editor/tests/unit/hooks/use-stage.spec.ts b/packages/editor/tests/unit/hooks/use-stage.spec.ts
index b2b1420a..4064304b 100644
--- a/packages/editor/tests/unit/hooks/use-stage.spec.ts
+++ b/packages/editor/tests/unit/hooks/use-stage.spec.ts
@@ -116,6 +116,18 @@ describe('useStage', () => {
expect(stageInstance.mask.setGuides).toHaveBeenCalled();
});
+ test('disabledFlashTip 透传给 StageCore', () => {
+ useStage({ disabledFlashTip: true } as any);
+ const opts = StageCoreCtor.mock.calls[0][0];
+ expect(opts.disabledFlashTip).toBe(true);
+ });
+
+ test('默认不开启 disabledFlashTip(透传 undefined)', () => {
+ useStage({} as any);
+ const opts = StageCoreCtor.mock.calls[0][0];
+ expect(opts.disabledFlashTip).toBeUndefined();
+ });
+
test('canSelect: 无 stageOptions.canSelect 时返回 true', () => {
useStage({} as any);
const opts = StageCoreCtor.mock.calls[0][0];
diff --git a/packages/stage/src/StageCore.ts b/packages/stage/src/StageCore.ts
index e68b6058..e0a3590a 100644
--- a/packages/stage/src/StageCore.ts
+++ b/packages/stage/src/StageCore.ts
@@ -25,7 +25,8 @@ import type { Id } from '@tmagic/core';
import { getIdFromEl } from '@tmagic/core';
import ActionManager from './ActionManager';
-import { DEFAULT_ZOOM } from './const';
+import { DEFAULT_ZOOM, PAGE_CLASS } from './const';
+import StageFlashHighlight from './StageFlashHighlight';
import StageMask from './StageMask';
import StageRender from './StageRender';
import type {
@@ -51,16 +52,20 @@ export default class StageCore extends EventEmitter {
public renderer: StageRender | null = null;
public mask: StageMask | null = null;
public actionManager: ActionManager | null = null;
+ public flashHighlight: StageFlashHighlight | null = null;
private pageResizeObserver: ResizeObserver | null = null;
private autoScrollIntoView: boolean | undefined;
private customizedRender?: CustomizeRender;
+ /** 非点击画布选中组件时,是否对选中区域做高亮闪烁提示,默认开启 */
+ private disabledFlashTip: boolean;
constructor(config: StageCoreConfig) {
super();
this.autoScrollIntoView = config.autoScrollIntoView;
this.customizedRender = config.render;
+ this.disabledFlashTip = config.disabledFlashTip ?? false;
this.renderer = new StageRender({
runtimeUrl: config.runtimeUrl,
@@ -78,6 +83,7 @@ export default class StageCore extends EventEmitter {
disabledRule: config.disabledRule,
});
this.actionManager = new ActionManager(this.getActionManagerConfig(config));
+ this.flashHighlight = new StageFlashHighlight({ container: this.mask.content });
this.initRenderEvent();
this.initActionEvent();
@@ -88,7 +94,7 @@ export default class StageCore extends EventEmitter {
* 单选选中元素
* @param id 选中的id
*/
- public async select(id: Id, event?: MouseEvent): Promise {
+ public async select(id: Id, event?: MouseEvent, options: { flash?: boolean } = {}): Promise {
if (!this.renderer) {
return;
}
@@ -133,6 +139,13 @@ export default class StageCore extends EventEmitter {
if (el && (this.autoScrollIntoView || el.dataset.autoScrollIntoView)) {
this.mask?.observerIntersection(el);
}
+
+ // 非点击画布选中(如从图层树、面包屑等外部选中,此时没有鼠标事件)时,对选中区域做一次高亮闪烁,帮助用户定位组件
+ // 选中页面(mask.page 或带有 magic-ui-page class 的节点)时不做闪烁提示
+ const isPage = el === this.mask?.page || el?.className.includes(PAGE_CLASS);
+ if (el && !event && options.flash !== false && !this.disabledFlashTip && !isPage) {
+ this.flashHighlight?.flash(el);
+ }
}
/**
@@ -300,11 +313,12 @@ export default class StageCore extends EventEmitter {
* 销毁实例
*/
public destroy(): void {
- const { mask, renderer, actionManager, pageResizeObserver } = this;
+ const { mask, renderer, actionManager, flashHighlight, pageResizeObserver } = this;
renderer?.destroy();
mask?.destroy();
actionManager?.destroy();
+ flashHighlight?.destroy();
pageResizeObserver?.disconnect();
this.removeAllListeners();
@@ -313,6 +327,7 @@ export default class StageCore extends EventEmitter {
this.renderer = null;
this.mask = null;
this.actionManager = null;
+ this.flashHighlight = null;
this.pageResizeObserver = null;
}
diff --git a/packages/stage/src/StageFlashHighlight.ts b/packages/stage/src/StageFlashHighlight.ts
new file mode 100644
index 00000000..e7f88b37
--- /dev/null
+++ b/packages/stage/src/StageFlashHighlight.ts
@@ -0,0 +1,142 @@
+/*
+ * 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 { getDocument, injectStyle } from '@tmagic/core';
+
+import { ZIndex } from './const';
+import { getOffset } from './util';
+
+/** 闪烁提示节点的 class name */
+export const FLASH_TIP_CLASS_NAME = 'tmagic-stage-flash-tip';
+/** 闪烁动画名称 */
+const FLASH_ANIMATION_NAME = 'tmagic-stage-flash';
+/** 闪烁动画时长(ms) */
+const FLASH_DURATION = 1500;
+
+/**
+ * 选中区域闪烁提示
+ * @description 当组件不是通过点击画布选中(如从图层树、面包屑等外部选中)时,在画布上对选中区域做一次高亮闪烁,
+ * 帮助用户快速定位组件在画布中的位置。
+ */
+export default class StageFlashHighlight {
+ /** 闪烁节点挂载的容器(蒙层的content) */
+ private container: HTMLElement;
+ private el?: HTMLElement;
+ private timer?: NodeJS.Timeout;
+ private styleEl?: HTMLStyleElement;
+
+ constructor(config: { container: HTMLElement }) {
+ this.container = config.container;
+ }
+
+ /**
+ * 在目标元素所在区域做一次高亮闪烁
+ * @param el 选中组件的Dom节点元素
+ */
+ public flash(el: HTMLElement): void {
+ if (!el) return;
+
+ this.injectStyle();
+ this.clear();
+
+ const offset = getOffset(el);
+ const { transform } = getComputedStyle(el);
+
+ const flashEl = globalThis.document.createElement('div');
+ flashEl.className = FLASH_TIP_CLASS_NAME;
+ flashEl.style.cssText = `
+ position: absolute;
+ box-sizing: border-box;
+ pointer-events: none;
+ transform: ${transform};
+ left: ${offset.left}px;
+ top: ${offset.top}px;
+ width: ${el.clientWidth}px;
+ height: ${el.clientHeight}px;
+ z-index: ${ZIndex.SELECTED_EL};
+ `;
+
+ this.container.appendChild(flashEl);
+ this.el = flashEl;
+
+ this.timer = globalThis.setTimeout(() => {
+ this.clear();
+ }, FLASH_DURATION);
+ }
+
+ /**
+ * 清除闪烁节点
+ */
+ public clear(): void {
+ if (this.timer) {
+ globalThis.clearTimeout(this.timer);
+ this.timer = undefined;
+ }
+ this.el?.remove();
+ this.el = undefined;
+ }
+
+ /**
+ * 销毁实例
+ */
+ public destroy(): void {
+ this.clear();
+ this.styleEl?.remove();
+ this.styleEl = undefined;
+ }
+
+ /**
+ * 注入闪烁动画样式(仅注入一次)
+ */
+ private injectStyle(): void {
+ if (this.styleEl) return;
+
+ this.styleEl = injectStyle(
+ getDocument(),
+ `
+ @keyframes ${FLASH_ANIMATION_NAME} {
+ 0% {
+ opacity: 0;
+ background-color: rgba(146, 84, 222, 0);
+ }
+ 20% {
+ opacity: 1;
+ background-color: rgba(146, 84, 222, 0.7);
+ }
+ 45% {
+ opacity: 0.6;
+ background-color: rgba(146, 84, 222, 0.4);
+ }
+ 70% {
+ opacity: 1;
+ background-color: rgba(146, 84, 222, 0.7);
+ }
+ 100% {
+ opacity: 0;
+ background-color: rgba(146, 84, 222, 0);
+ }
+ }
+ .${FLASH_TIP_CLASS_NAME} {
+ border: 2px solid #9254de;
+ border-radius: 2px;
+ animation: ${FLASH_ANIMATION_NAME} ${FLASH_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1) both;
+ }
+ `,
+ );
+ }
+}
diff --git a/packages/stage/src/types.ts b/packages/stage/src/types.ts
index 0d6c722e..61c7b9da 100644
--- a/packages/stage/src/types.ts
+++ b/packages/stage/src/types.ts
@@ -90,6 +90,11 @@ export interface StageCoreConfig {
*/
alwaysMultiSelect?: boolean;
disabledRule?: boolean;
+ /**
+ * 是否禁用「非点击画布选中组件时,对选中区域做高亮闪烁提示」,默认 false(即默认开启闪烁提示)。
+ * 用于从图层树、面包屑等外部选中组件时,帮助用户快速定位组件在画布中的位置。
+ */
+ disabledFlashTip?: boolean;
}
export interface ActionManagerConfig {
diff --git a/packages/stage/tests/unit/StageFlashHighlight.spec.ts b/packages/stage/tests/unit/StageFlashHighlight.spec.ts
new file mode 100644
index 00000000..bfadfb05
--- /dev/null
+++ b/packages/stage/tests/unit/StageFlashHighlight.spec.ts
@@ -0,0 +1,146 @@
+/*
+ * 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 StageFlashHighlight, { FLASH_TIP_CLASS_NAME } from '../../src/StageFlashHighlight';
+
+const createInstance = () => {
+ const container = globalThis.document.createElement('div');
+ globalThis.document.body.appendChild(container);
+ const flash = new StageFlashHighlight({ container });
+ return { container, flash };
+};
+
+const createTarget = () => {
+ const el = globalThis.document.createElement('div');
+ globalThis.document.body.appendChild(el);
+ return el;
+};
+
+const getFlashEls = (container: HTMLElement) => container.querySelectorAll(`.${FLASH_TIP_CLASS_NAME}`);
+
+const getStyleEls = () => globalThis.document.head.querySelectorAll('style');
+
+describe('StageFlashHighlight', () => {
+ let instance: StageFlashHighlight | null = null;
+
+ beforeEach(() => {
+ globalThis.document.body.innerHTML = '';
+ globalThis.document.head.innerHTML = '';
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ instance?.destroy();
+ instance = null;
+ vi.useRealTimers();
+ });
+
+ test('flash 会在容器内创建一个闪烁提示节点', () => {
+ const { container, flash } = createInstance();
+ instance = flash;
+
+ flash.flash(createTarget());
+
+ const els = getFlashEls(container);
+ expect(els).toHaveLength(1);
+
+ const flashEl = els[0] as HTMLElement;
+ expect(flashEl.style.position).toBe('absolute');
+ expect(flashEl.style.pointerEvents).toBe('none');
+ });
+
+ test('flash 会注入动画样式,且多次调用只注入一次', () => {
+ const { flash } = createInstance();
+ instance = flash;
+
+ expect(getStyleEls()).toHaveLength(0);
+
+ flash.flash(createTarget());
+ expect(getStyleEls()).toHaveLength(1);
+ expect(getStyleEls()[0].innerHTML).toContain('@keyframes');
+
+ flash.flash(createTarget());
+ expect(getStyleEls()).toHaveLength(1);
+ });
+
+ test('重复 flash 时始终只保留一个闪烁节点', () => {
+ const { container, flash } = createInstance();
+ instance = flash;
+
+ flash.flash(createTarget());
+ flash.flash(createTarget());
+ flash.flash(createTarget());
+
+ expect(getFlashEls(container)).toHaveLength(1);
+ });
+
+ test('动画结束后自动移除闪烁节点', () => {
+ const { container, flash } = createInstance();
+ instance = flash;
+
+ flash.flash(createTarget());
+ expect(getFlashEls(container)).toHaveLength(1);
+
+ // 时长内仍然存在
+ vi.advanceTimersByTime(100);
+ expect(getFlashEls(container)).toHaveLength(1);
+
+ // 超过动画时长后被移除
+ vi.advanceTimersByTime(5000);
+ expect(getFlashEls(container)).toHaveLength(0);
+ });
+
+ test('clear 会立即移除闪烁节点并清除定时器', () => {
+ const { container, flash } = createInstance();
+ instance = flash;
+
+ flash.flash(createTarget());
+ flash.clear();
+
+ expect(getFlashEls(container)).toHaveLength(0);
+
+ // clear 后不应再有残留的定时回调影响后续逻辑
+ flash.flash(createTarget());
+ vi.advanceTimersByTime(100);
+ expect(getFlashEls(container)).toHaveLength(1);
+ });
+
+ test('falsy 元素不会创建节点也不会注入样式', () => {
+ const { container, flash } = createInstance();
+ instance = flash;
+
+ flash.flash(null as unknown as HTMLElement);
+
+ expect(getFlashEls(container)).toHaveLength(0);
+ expect(getStyleEls()).toHaveLength(0);
+ });
+
+ test('destroy 会移除闪烁节点与注入的样式', () => {
+ const { container, flash } = createInstance();
+
+ flash.flash(createTarget());
+ expect(getFlashEls(container)).toHaveLength(1);
+ expect(getStyleEls()).toHaveLength(1);
+
+ flash.destroy();
+
+ expect(getFlashEls(container)).toHaveLength(0);
+ expect(getStyleEls()).toHaveLength(0);
+ });
+});