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