From 172a7a1c92e85266114c628c0fc0534b6b76aca3 Mon Sep 17 00:00:00 2001 From: roymondchen Date: Tue, 7 Apr 2026 15:38:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor,stage):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=8F=8C=E5=87=BB=E7=A9=BF=E9=80=8F=E9=80=89=E4=B8=AD=E9=BC=A0?= =?UTF-8?q?=E6=A0=87=E4=B8=8B=E6=96=B9=E7=9A=84=E4=B8=8B=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E5=8F=AF=E9=80=89=E4=B8=AD=E5=85=83=E7=B4=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 dblclick 处理统一到 Stage.vue,新增 ActionManager.getNextElementFromPoint 方法跳过最上层元素返回下方第二个可选中元素,双击时若无特殊处理则穿透选中下方组件。 Made-with: Cursor --- .../src/layouts/workspace/viewer/Stage.vue | 84 ++++++++++++++++++- .../layouts/workspace/viewer/StageOverlay.vue | 75 +---------------- packages/stage/src/ActionManager.ts | 24 ++++++ 3 files changed, 108 insertions(+), 75 deletions(-) diff --git a/packages/editor/src/layouts/workspace/viewer/Stage.vue b/packages/editor/src/layouts/workspace/viewer/Stage.vue index 145960f6..483643d0 100644 --- a/packages/editor/src/layouts/workspace/viewer/Stage.vue +++ b/packages/editor/src/layouts/workspace/viewer/Stage.vue @@ -80,7 +80,7 @@ const props = withDefaults( let stage: StageCore | null = null; let runtime: Runtime | null = null; -const { editorService, uiService, keybindingService } = useServices(); +const { editorService, uiService, keybindingService, stageOverlayService } = useServices(); const stageLoading = computed(() => editorService.get('stageLoading')); @@ -97,6 +97,60 @@ const page = computed(() => editorService.get('page')); const zoom = computed(() => uiService.get('zoom')); const node = computed(() => editorService.get('node')); +/** + * 判断元素是否被非页面级的滚动容器裁剪(未完整显示) + * + * 从元素向上遍历祖先节点,跳过页面/页面片容器, + * 检查是否存在设置了 overflow 的滚动容器将该元素裁剪, + * 只有元素未被完整显示时才需要打开 overlay 以展示完整内容 + */ +const isClippedByScrollContainer = (el: HTMLElement): boolean => { + const win = el.ownerDocument.defaultView; + if (!win) return false; + + // 收集所有页面和页面片的 id + const root = editorService.get('root'); + const pageIds = new Set(root?.items?.map((item) => `${item.id}`) ?? []); + + // el 本身就是页面或页面片,无需判断 + const elId = getIdFromEl()(el); + if (elId && pageIds.has(elId)) return false; + + let parent = el.parentElement; + + while (parent && parent !== el.ownerDocument.documentElement) { + const parentId = getIdFromEl()(parent); + + // 到达页面或页面片层级,不再继续向上查找 + if (parentId && pageIds.has(parentId)) { + return false; + } + + const { overflowX, overflowY } = win.getComputedStyle(parent); + + if ( + ['auto', 'scroll', 'hidden'].includes(overflowX) || + ['auto', 'scroll', 'hidden'].includes(overflowY) || + parent.scrollWidth > parent.clientWidth || + parent.scrollHeight > parent.clientHeight + ) { + // 比较元素与容器的可视区域,判断元素是否被裁剪 + const elRect = el.getBoundingClientRect(); + const containerRect = parent.getBoundingClientRect(); + if ( + elRect.top < containerRect.top || + elRect.left < containerRect.left || + elRect.bottom > containerRect.bottom || + elRect.right > containerRect.right + ) { + return true; + } + } + parent = parent.parentElement; + } + return false; +}; + watchEffect(() => { if (stage || !page.value) return; @@ -109,6 +163,34 @@ watchEffect(() => { stageWrapRef.value?.container?.focus(); }); + stage.on('dblclick', async (event: MouseEvent) => { + const el = (await stage?.actionManager?.getElementFromPoint(event)) || null; + if (!el) return; + + const id = getIdFromEl()(el); + if (id) { + const node = editorService.getNodeById(id); + if (node?.type === 'page-fragment-container' && node.pageFragmentId) { + await editorService.select(node.pageFragmentId); + return; + } + } + + if (!props.disabledStageOverlay && isClippedByScrollContainer(el)) { + stageOverlayService.openOverlay(el); + return; + } + + const nextEl = (await stage?.actionManager?.getNextElementFromPoint(event)) || null; + if (nextEl) { + const nextId = getIdFromEl()(nextEl); + if (nextId) { + await editorService.select(nextId); + editorService.get('stage')?.select(nextId); + } + } + }); + editorService.set('stage', markRaw(stage)); stage.mount(stageContainerEl.value); diff --git a/packages/editor/src/layouts/workspace/viewer/StageOverlay.vue b/packages/editor/src/layouts/workspace/viewer/StageOverlay.vue index 33447253..c00d5f6d 100644 --- a/packages/editor/src/layouts/workspace/viewer/StageOverlay.vue +++ b/packages/editor/src/layouts/workspace/viewer/StageOverlay.vue @@ -22,7 +22,6 @@ import { computed, inject, onBeforeUnmount, useTemplateRef, watch } from 'vue'; import { CloseBold } from '@element-plus/icons-vue'; import { TMagicIcon } from '@tmagic/design'; -import { getIdFromEl } from '@tmagic/utils'; import ScrollViewer from '@editor/components/ScrollViewer.vue'; import { useServices } from '@editor/hooks/use-services'; @@ -47,25 +46,7 @@ const style = computed(() => ({ })); watch(stage, (stage) => { - if (stage) { - stage.on('dblclick', async (event: MouseEvent) => { - const el = (await stage.actionManager?.getElementFromPoint(event)) || null; - if (!el) return; - - const id = getIdFromEl()(el); - if (id) { - const node = editorService.getNodeById(id); - if (node?.type === 'page-fragment-container' && node.pageFragmentId) { - await editorService.select(node.pageFragmentId); - return; - } - } - - if (isClippedByScrollContainer(el)) { - stageOverlayService.openOverlay(el); - } - }); - } else { + if (!stage) { stageOverlayService.closeOverlay(); } }); @@ -99,60 +80,6 @@ onBeforeUnmount(() => { stageOverlayService.set('stage', null); }); -/** - * 判断元素是否被非页面级的滚动容器裁剪(未完整显示) - * - * 从元素向上遍历祖先节点,跳过页面/页面片容器, - * 检查是否存在设置了 overflow 的滚动容器将该元素裁剪, - * 只有元素未被完整显示时才需要打开 overlay 以展示完整内容 - */ -const isClippedByScrollContainer = (el: HTMLElement): boolean => { - const win = el.ownerDocument.defaultView; - if (!win) return false; - - // 收集所有页面和页面片的 id - const root = editorService.get('root'); - const pageIds = new Set(root?.items?.map((item) => `${item.id}`) ?? []); - - // el 本身就是页面或页面片,无需判断 - const elId = getIdFromEl()(el); - if (elId && pageIds.has(elId)) return false; - - let parent = el.parentElement; - - while (parent && parent !== el.ownerDocument.documentElement) { - const parentId = getIdFromEl()(parent); - - // 到达页面或页面片层级,不再继续向上查找 - if (parentId && pageIds.has(parentId)) { - return false; - } - - const { overflowX, overflowY } = win.getComputedStyle(parent); - - if ( - ['auto', 'scroll', 'hidden'].includes(overflowX) || - ['auto', 'scroll', 'hidden'].includes(overflowY) || - parent.scrollWidth > parent.clientWidth || - parent.scrollHeight > parent.clientHeight - ) { - // 比较元素与容器的可视区域,判断元素是否被裁剪 - const elRect = el.getBoundingClientRect(); - const containerRect = parent.getBoundingClientRect(); - if ( - elRect.top < containerRect.top || - elRect.left < containerRect.left || - elRect.bottom > containerRect.bottom || - elRect.right > containerRect.right - ) { - return true; - } - } - parent = parent.parentElement; - } - return false; -}; - const closeOverlayHandler = () => { stageOverlayService.closeOverlay(); }; diff --git a/packages/stage/src/ActionManager.ts b/packages/stage/src/ActionManager.ts index 8ec6cd77..1508293f 100644 --- a/packages/stage/src/ActionManager.ts +++ b/packages/stage/src/ActionManager.ts @@ -236,6 +236,30 @@ export default class ActionManager extends EventEmitter { return null; } + /** + * 获取鼠标下方第二个可选中元素(跳过最上层),用于双击穿透选中下方元素 + * @param event 鼠标事件 + * @returns 鼠标下方第二个可选中元素,不存在时返回 null + */ + public async getNextElementFromPoint(event: MouseEvent): Promise { + const els = this.getElementsFromPoint(event as Point); + + let stopped = false; + const stop = () => (stopped = true); + let skippedFirst = false; + for (const el of els) { + if (!getIdFromEl()(el)?.startsWith(GHOST_EL_ID_PREFIX) && (await this.isElCanSelect(el, event, stop))) { + if (stopped) break; + if (!skippedFirst) { + skippedFirst = true; + continue; + } + return el; + } + } + return null; + } + /** * 判断一个元素能否在当前场景被选中 * @param el 被判断的元素