feat(editor,stage): 支持双击穿透选中鼠标下方的下一个可选中元素

将 dblclick 处理统一到 Stage.vue,新增 ActionManager.getNextElementFromPoint
方法跳过最上层元素返回下方第二个可选中元素,双击时若无特殊处理则穿透选中下方组件。

Made-with: Cursor
This commit is contained in:
roymondchen 2026-04-07 15:38:27 +08:00
parent 73c676931f
commit 172a7a1c92
3 changed files with 108 additions and 75 deletions

View File

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

View File

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

View File

@ -236,6 +236,30 @@ export default class ActionManager extends EventEmitter {
return null;
}
/**
* 穿
* @param event
* @returns null
*/
public async getNextElementFromPoint(event: MouseEvent): Promise<HTMLElement | null> {
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