feat(stage): 非点击画布选中组件时高亮闪烁选中区域

从图层树、面包屑等外部选中组件时,在画布上对选中区域做一次紫色高亮闪烁,
帮助用户快速定位组件;选中页面不触发。支持通过 editor 的 disabledFlashTip 关闭。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
roymondchen 2026-06-04 14:02:57 +08:00
parent a9e9e65f9c
commit 444d4223a9
10 changed files with 355 additions and 3 deletions

View File

@ -1220,6 +1220,28 @@ const guidesOptions = {
</template>
```
## disabledFlashTip
- **详情:**
禁用「非点击画布选中组件时的高亮闪烁提示」。
当组件不是通过点击画布选中(如从组件树、面包屑等外部方式选中)时,编辑器会在画布上对选中区域做一次高亮闪烁,帮助用户快速定位组件在画布中的位置。设置为 `true` 可关闭该提示。
注:选中页面(`magic-ui-page`)时不会触发闪烁。
- **默认值:** `false`
- **类型:** `boolean`
- **示例:**
```html
<template>
<m-editor :disabled-flash-tip="true"></m-editor>
</template>
```
## disabledStageOverlay
- **详情:**

View File

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

View File

@ -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,

View File

@ -48,6 +48,7 @@ export const useStage = (stageOptions: StageOptions) => {
disabledMultiSelect: stageOptions.disabledMultiSelect,
alwaysMultiSelect: stageOptions.alwaysMultiSelect,
disabledRule: stageOptions.disabledRule,
disabledFlashTip: stageOptions.disabledFlashTip,
});
watch(

View File

@ -199,6 +199,11 @@ export interface StageOptions {
*/
alwaysMultiSelect?: boolean;
disabledRule?: boolean;
/**
*
* false
*/
disabledFlashTip?: boolean;
zoom?: number;
/** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;

View File

@ -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];

View File

@ -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<void> {
public async select(id: Id, event?: MouseEvent, options: { flash?: boolean } = {}): Promise<void> {
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;
}

View File

@ -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;
}
`,
);
}
}

View File

@ -90,6 +90,11 @@ export interface StageCoreConfig {
*/
alwaysMultiSelect?: boolean;
disabledRule?: boolean;
/**
* false
*
*/
disabledFlashTip?: boolean;
}
export interface ActionManagerConfig {

View File

@ -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);
});
});