mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-06-06 23:50:15 +00:00
feat(stage): 非点击画布选中组件时高亮闪烁选中区域
从图层树、面包屑等外部选中组件时,在画布上对选中区域做一次紫色高亮闪烁, 帮助用户快速定位组件;选中页面不触发。支持通过 editor 的 disabledFlashTip 关闭。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a9e9e65f9c
commit
444d4223a9
@ -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
|
||||
|
||||
- **详情:**
|
||||
|
||||
@ -221,6 +221,7 @@ const stageOptions: StageOptions = {
|
||||
guidesOptions: props.guidesOptions,
|
||||
disabledMultiSelect: props.disabledMultiSelect,
|
||||
alwaysMultiSelect: props.alwaysMultiSelect,
|
||||
disabledFlashTip: props.disabledFlashTip,
|
||||
beforeDblclick: props.beforeDblclick,
|
||||
};
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -48,6 +48,7 @@ export const useStage = (stageOptions: StageOptions) => {
|
||||
disabledMultiSelect: stageOptions.disabledMultiSelect,
|
||||
alwaysMultiSelect: stageOptions.alwaysMultiSelect,
|
||||
disabledRule: stageOptions.disabledRule,
|
||||
disabledFlashTip: stageOptions.disabledFlashTip,
|
||||
});
|
||||
|
||||
watch(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
142
packages/stage/src/StageFlashHighlight.ts
Normal file
142
packages/stage/src/StageFlashHighlight.ts
Normal 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;
|
||||
}
|
||||
`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -90,6 +90,11 @@ export interface StageCoreConfig {
|
||||
*/
|
||||
alwaysMultiSelect?: boolean;
|
||||
disabledRule?: boolean;
|
||||
/**
|
||||
* 是否禁用「非点击画布选中组件时,对选中区域做高亮闪烁提示」,默认 false(即默认开启闪烁提示)。
|
||||
* 用于从图层树、面包屑等外部选中组件时,帮助用户快速定位组件在画布中的位置。
|
||||
*/
|
||||
disabledFlashTip?: boolean;
|
||||
}
|
||||
|
||||
export interface ActionManagerConfig {
|
||||
|
||||
146
packages/stage/tests/unit/StageFlashHighlight.spec.ts
Normal file
146
packages/stage/tests/unit/StageFlashHighlight.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user