254 lines
7.3 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<Teleport to="body" v-if="visible">
<div
ref="target"
:class="['m-editor-float-box', themeClass]"
v-bind="$attrs"
:style="{ ...style, zIndex: curZIndex }"
@mousedown="nextZIndex"
>
<div ref="title" class="m-editor-float-box-title">
<slot name="title">
<span>{{ title }}</span>
</slot>
<div>
<TMagicButton link size="small" @click="closeHandler"><MIcon :icon="Close"></MIcon></TMagicButton>
</div>
</div>
<div class="m-editor-float-box-body" :style="{ height: `${bodyHeight}px`, ...bodyStyle }">
<slot name="body"></slot>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, type CSSProperties, nextTick, onBeforeUnmount, provide, ref, useTemplateRef, watch } from 'vue';
import { Close } from '@element-plus/icons-vue';
import VanillaMoveable from 'moveable';
import { TMagicButton, useThemeClass, useZIndex } from '@tmagic/design';
import MIcon from '@editor/components/Icon.vue';
interface Position {
left: number;
top: number;
}
const width = defineModel<number>('width', { default: 0 });
const height = defineModel<number>('height', { default: 0 });
const visible = defineModel<boolean>('visible', { default: false });
const props = withDefaults(
defineProps<{
position?: Position;
title?: string;
bodyStyle?: CSSProperties;
/** 浮窗初始样式,会与内部计算样式合并,外部传入优先 */
initialStyle?: CSSProperties;
/** 用于约束浮窗 left 的容器宽度,传入时按宽度收敛 left避免超出右边界默认取视窗宽度 */
frameworkWidth?: number;
beforeClose?: (_done: (_cancel?: boolean) => void) => void;
}>(),
{
title: '',
position: () => ({ left: 0, top: 0 }),
initialStyle: () => ({}),
frameworkWidth: 0,
},
);
const targetEl = useTemplateRef<HTMLDivElement>('target');
const titleEl = useTemplateRef<HTMLDivElement>('title');
/**
* 主题修饰类(来自最近的 `<MEditor>` / `<MForm>` 祖先 provide
* 挂在 `Teleport` 出去的浮动面板根节点上,让主题级 CSS 变量在 portal 节点上也能命中。
*/
const themeClass = useThemeClass();
const zIndex = useZIndex();
const curZIndex = ref<number>(0);
const titleHeight = ref(0);
const bodyHeight = computed(() => {
if (height.value) {
return height.value - titleHeight.value;
}
if (targetEl.value) {
return targetEl.value.clientHeight - titleHeight.value;
}
return 'auto';
});
const style = computed(() => {
let { left } = props.position;
const frameworkWidth = props.frameworkWidth || globalThis.window?.innerWidth || 0;
if (width.value && frameworkWidth) {
left = left + width.value > frameworkWidth ? frameworkWidth - width.value : left;
}
return {
left: `${left}px`,
top: `${props.position.top}px`,
width: width.value ? `${width.value}px` : 'auto',
height: height.value ? `${height.value}px` : 'auto',
...props.initialStyle,
};
});
let moveable: VanillaMoveable | null = null;
// 拖拽/缩放时用于覆盖 iframe 的遮罩,防止鼠标进入 iframe 区域后事件被 iframe 吞掉导致拖拽丢失
let dragMask: HTMLDivElement | null = null;
let dragMaskVisible = false;
// 兜底:正常情况下遮罩由 moveable 的 dragEnd/resizeEnd 移除;但某些极端场景
// 拖拽中鼠标移出窗口后松开、alt-tab 切走窗口失焦、标签页被隐藏等)结束事件不会触发,
// 会导致全屏透明遮罩残留、整个编辑器无法点击。这里挂全局 pointerup/blur/visibilitychange 强制收尾。
const bindDragMaskSafety = () => {
globalThis.window?.addEventListener('pointerup', hideDragMask, true);
globalThis.window?.addEventListener('blur', hideDragMask);
globalThis.document?.addEventListener('visibilitychange', hideDragMask);
};
const unbindDragMaskSafety = () => {
globalThis.window?.removeEventListener('pointerup', hideDragMask, true);
globalThis.window?.removeEventListener('blur', hideDragMask);
globalThis.document?.removeEventListener('visibilitychange', hideDragMask);
};
const showDragMask = () => {
if (dragMaskVisible) {
return;
}
if (!dragMask) {
dragMask = globalThis.document.createElement('div');
dragMask.className = 'm-editor-float-box-drag-mask';
}
globalThis.document.body.appendChild(dragMask);
dragMaskVisible = true;
bindDragMaskSafety();
// 拖拽标题时root 上的 @mousedown="nextZIndex" 会把浮窗 z-index 抬高,
// 若此时才读取会拿到旧值导致遮罩被浮窗(及其内部 iframe盖住故用 nextTick 在 z-index 稳定后再设置到浮窗之上
const setMaskZIndex = () => {
if (dragMask) {
dragMask.style.zIndex = `${curZIndex.value + 1}`;
}
};
setMaskZIndex();
nextTick(setMaskZIndex);
};
const hideDragMask = () => {
unbindDragMaskSafety();
dragMask?.parentNode?.removeChild(dragMask);
dragMaskVisible = false;
};
const initMoveable = () => {
moveable = new VanillaMoveable(globalThis.document.body, {
className: 'm-editor-floating-box-moveable',
target: targetEl.value,
draggable: true,
resizable: true,
edge: true,
keepRatio: false,
origin: false,
snappable: true,
dragTarget: titleEl.value,
dragTargetSelf: false,
linePadding: 10,
controlPadding: 10,
bounds: { left: 0, top: 0, right: 0, bottom: 0, position: 'css' },
});
// 仅在真正发生拖拽/缩放位移时插入遮罩moveable 的 dragStart/resizeStart 在 mousedown 时即触发,
// 若此时就盖遮罩,会盖住浮窗本身导致 mouseup 落在遮罩上、关闭按钮的 click 无法触发(点击关闭不了)。
// 改为在 drag/resize实际位移时才显示纯点击不再触发遮罩。
moveable.on('drag', showDragMask);
moveable.on('resize', showDragMask);
moveable.on('dragEnd', hideDragMask);
moveable.on('resizeEnd', hideDragMask);
moveable.on('drag', (e) => {
e.target.style.transform = e.transform;
});
moveable.on('resize', (e) => {
width.value = e.width;
height.value = e.height;
e.target.style.width = `${e.width}px`;
e.target.style.height = `${e.height}px`;
e.target.style.transform = e.drag.transform;
});
};
const destroyMoveable = () => {
hideDragMask();
moveable?.destroy();
moveable = null;
};
watch(
visible,
async (visible) => {
if (visible) {
await nextTick();
curZIndex.value = zIndex.nextZIndex();
const targetRect = targetEl.value?.getBoundingClientRect();
if (targetRect) {
width.value = targetRect.width;
height.value = targetRect.height;
initMoveable();
}
if (titleEl.value) {
const titleRect = titleEl.value.getBoundingClientRect();
titleHeight.value = titleRect.height;
}
} else {
destroyMoveable();
}
},
{
immediate: true,
},
);
onBeforeUnmount(() => {
destroyMoveable();
});
const hide = (cancel?: boolean) => {
if (cancel !== false) {
visible.value = false;
}
};
const closeHandler = () => {
if (typeof props.beforeClose === 'function') {
props.beforeClose(hide);
} else {
hide();
}
};
const nextZIndex = () => {
curZIndex.value = zIndex.nextZIndex();
};
provide('parentFloating', targetEl);
defineExpose({
bodyHeight,
target: targetEl,
titleEl,
});
</script>