效果Gif圖

使用方式
<script lang="ts" setup>
import EdgeDockable from "***/EdgeDockable.vue";
</script>
<template>
<!-- localStorage 存儲位置時所需要的 key="AI" -->
<EdgeDockable localStorageKey="AI">
<template #collapsed>
...
<!-- 拖拽元素需要配置 class="toggle-drag-target" -->
<img src="**" class="toggle-drag-target" />
...
</template>
<template #expanded>
...
<!-- 拖拽元素需要配置 class="toggle-drag-target" -->
<img src="**" class="toggle-drag-target" />
...
</template>
</EdgeDockable>
</template>
EdgeDockable.vue
<script lang="ts" setup>
import { _Utility_GenerateUUID } from "nhanh-pure-function";
import { computed, onMounted, onUnmounted, ref } from "vue";
interface Props {
defaultTop?: number;
defaultLeft?: number;
defaultBottom?: number;
defaultRight?: number;
localStorageKey: string;
}
const props = withDefaults(defineProps<Props>(), {
defaultTop: undefined,
defaultLeft: undefined,
defaultBottom: 0,
defaultRight: 0,
});
// 定義方向類型和列表
type Direction = "top" | "left" | "bottom" | "right";
const directions: Direction[] = ["top", "left", "bottom", "right"];
// 生成localStorage鍵的工具函數
const getStorageKey = (direction: Direction) =>
`edge-dockable-${props.localStorageKey}-default-${direction}`;
// 統一處理默認配置的讀取邏輯
const defaultConfig = directions.reduce(
(config, dir) => {
const storedValue = localStorage.getItem(getStorageKey(dir));
// 優先使用本地存儲值(非空字符串),否則使用props默認值
config[dir] =
storedValue !== null
? storedValue
: /** @ts-ignore */
props[`default${dir.charAt(0).toUpperCase() + dir.slice(1)}`];
return config;
},
{} as Record<Direction, string | number | undefined>
);
const id = _Utility_GenerateUUID("edge-dockable-");
const state = ref<"collapsed" | "expanded">("collapsed");
const styleConfig = ref<Record<string, number | undefined>>(
directions.reduce(
(styles, dir) => {
const value = defaultConfig[dir];
if (value === undefined || value === "") {
styles[dir] = undefined;
return styles;
}
// 確保轉換後是有效數字
const numValue = Number(value);
styles[dir] = !isNaN(numValue) ? numValue : undefined;
return styles;
},
{} as Record<Direction, number | undefined>
)
);
const computedStyle = computed(() => {
const style: Record<string, string> = {};
Object.keys(styleConfig.value).forEach((key) => {
const value = styleConfig.value[key as keyof typeof styleConfig.value];
if (value !== undefined) style[key] = value + "px";
});
return style;
});
let edgeDockableDom: HTMLElement;
const isDown = ref(false);
const isDragging = ref(false);
const isLock = ref(false);
const movement = { x: 0, y: 0 };
function isTarget(target: any) {
if (target instanceof HTMLElement) {
return !!target.closest(`#${id} .toggle-drag-target`);
} else return false;
}
function mousedown(event: MouseEvent) {
if (!isTarget(event.target)) return;
isDown.value = true;
document.body.classList.add("edge-dockable-dragging-lock");
}
function mouseup(event: MouseEvent) {
if (isDragging.value) {
/** 元素邊緣停靠與移動邏輯處理模塊 */ {
// 獲取窗口尺寸與元素位置信息
const { innerWidth: windowWidth, innerHeight: windowHeight } = window;
const elementRect = edgeDockableDom.getBoundingClientRect();
const {
left,
right,
top,
bottom,
height: elementHeight,
width: elementWidth,
} = elementRect;
// 常量定義:觸發快速移動停靠的最小閾值
const MOVE_THRESHOLD = 10;
/**
* 工具函數:限制值在指定範圍內
* @param value 目標值
* @param min 最小值
* @param max 最大值
* @returns 限制後的結果
*/
const clamp = (value: number, min: number, max: number): number => {
return Math.max(min, Math.min(value, max));
};
/**
* 工具函數:計算鼠標移動向量的單位向量(標準化方向)
* @param movementX X軸移動距離
* @param movementY Y軸移動距離
* @returns 標準化後的方向向量
*/
const getUnitVector = (movementX: number, movementY: number) => {
const vectorMagnitude = Math.sqrt(movementX ** 2 + movementY ** 2);
if (vectorMagnitude < 0.1) return { x: 0, y: 0 };
return {
x: movementX / vectorMagnitude,
y: movementY / vectorMagnitude,
};
};
// ------------------------------
// 偏移計算相關函數
// ------------------------------
/**
* 計算水平方向偏移(垂直移動時觸發)
* @param baseDistance 基礎距離
* @param movement 移動向量
* @returns 水平偏移值
*/
const calculateHorizontalOffset = (
baseDistance: number,
movement: { x: number; y: number }
) => {
const { x: unitX } = getUnitVector(movement.x, movement.y);
return baseDistance * unitX;
};
/**
* 計算垂直方向偏移(水平移動時觸發)
* @param baseDistance 基礎距離
* @param movement 移動向量
* @returns 垂直偏移值
*/
const calculateVerticalOffset = (
baseDistance: number,
movement: { x: number; y: number }
) => {
const { y: unitY } = getUnitVector(movement.x, movement.y);
return baseDistance * unitY;
};
// ------------------------------
// 位置更新相關函數
// ------------------------------
/**
* 更新垂直方向定位(top/bottom)
* @param currentValue 當前值
* @param offset 偏移量
* @param isTop 是否為top方向
* @returns 更新後的位置值
*/
const updateVerticalPosition = (
currentValue: number,
offset: number,
isTop: boolean
) => {
const newValue = isTop ? currentValue + offset : currentValue - offset;
return clamp(newValue, 0, windowHeight - elementHeight);
};
/**
* 更新水平方向定位(left/right)
* @param currentValue 當前值
* @param offset 偏移量
* @param isLeft 是否為left方向
* @returns 更新後的位置值
*/
const updateHorizontalPosition = (
currentValue: number,
offset: number,
isLeft: boolean
) => {
const newValue = isLeft ? currentValue + offset : currentValue - offset;
return clamp(newValue, 0, windowWidth - elementWidth);
};
// ------------------------------
// 停靠處理相關函數
// ------------------------------
/**
* 通用停靠處理函數
* @param direction 目標停靠方向
* @param oppositeDirection 相反方向
* @param getInitialValue 獲取初始值的函數
*/
const handleDock = (
direction: keyof typeof styleConfig.value,
oppositeDirection: keyof typeof styleConfig.value,
getInitialValue: () => number
) => {
if (styleConfig.value[direction] === undefined) {
styleConfig.value[direction] = getInitialValue();
// 觸發動畫效果
requestAnimationFrame(() => {
styleConfig.value[direction] = 0;
});
} else {
styleConfig.value[direction] = 0;
}
// 清除相反方向的樣式
styleConfig.value[oppositeDirection] = undefined;
};
/**
* 水平移動時,處理垂直方向偏移
* @param baseDistance 基礎距離
*/
const handleHorizontalVerticalOffset = (baseDistance: number) => {
const offset = calculateVerticalOffset(baseDistance, movement);
if (styleConfig.value.top !== undefined) {
styleConfig.value.top = updateVerticalPosition(
styleConfig.value.top,
offset,
true
);
} else if (styleConfig.value.bottom !== undefined) {
styleConfig.value.bottom = updateVerticalPosition(
styleConfig.value.bottom,
offset,
false
);
}
};
/**
* 垂直移動時,處理水平方向偏移
* @param baseDistance 基礎距離
*/
const handleVerticalHorizontalOffset = (baseDistance: number) => {
const offset = calculateHorizontalOffset(baseDistance, movement);
if (styleConfig.value.left !== undefined) {
styleConfig.value.left = updateHorizontalPosition(
styleConfig.value.left,
offset,
true
);
} else if (styleConfig.value.right !== undefined) {
styleConfig.value.right = updateHorizontalPosition(
styleConfig.value.right,
offset,
false
);
}
};
/**
* 吸附到最近的邊緣(移動幅度未達閾值時使用)
*/
const snapToClosestEdge = () => {
// 計算各方向到窗口邊緣的距離及對應反向
const edgeOffsets = {
top: [top, "bottom"],
left: [left, "right"],
right: [windowWidth - right, "left"],
bottom: [windowHeight - bottom, "top"],
};
// 找到距離最近的邊緣方向
const closestEdge = Object.keys(edgeOffsets).reduce(
(prevEdge, currEdge) => {
const prevDistance =
edgeOffsets[prevEdge as keyof typeof edgeOffsets]?.[0] ||
Infinity;
const currDistance =
edgeOffsets[currEdge as keyof typeof edgeOffsets][0];
/** @ts-ignore */
return Math.abs(prevDistance) < Math.abs(currDistance)
? prevEdge
: currEdge;
}
) as keyof typeof edgeOffsets;
// 處理最近邊緣的停靠邏輯
if (styleConfig.value[closestEdge] === undefined) {
/** @ts-ignore */
styleConfig.value[closestEdge] = edgeOffsets[closestEdge][0];
requestAnimationFrame(() => {
styleConfig.value[closestEdge] = 0;
});
} else {
styleConfig.value[closestEdge] = 0;
}
// 限制交叉方向的位置範圍
if (closestEdge === "bottom" || closestEdge === "top") {
// 垂直方向停靠時,限制水平位置
if (styleConfig.value.left !== undefined) {
styleConfig.value.left = clamp(
styleConfig.value.left,
0,
windowWidth - elementWidth
);
}
if (styleConfig.value.right !== undefined) {
styleConfig.value.right = clamp(
styleConfig.value.right,
0,
windowWidth - elementWidth
);
}
} else {
// 水平方向停靠時,限制垂直位置
if (styleConfig.value.top !== undefined) {
styleConfig.value.top = clamp(
styleConfig.value.top,
0,
windowHeight - elementHeight
);
}
if (styleConfig.value.bottom !== undefined) {
styleConfig.value.bottom = clamp(
styleConfig.value.bottom,
0,
windowHeight - elementHeight
);
}
}
// 清除反向邊緣的樣式
const oppositeEdge = edgeOffsets[
closestEdge
][1] as keyof typeof styleConfig.value;
styleConfig.value[oppositeEdge] = undefined;
};
// ------------------------------
// 核心邏輯:用户移動處理
// ------------------------------
// 計算移動向量的模長(總移動距離)
const movementMagnitude = Math.sqrt(movement.x ** 2 + movement.y ** 2);
if (movementMagnitude > MOVE_THRESHOLD) {
// 移動幅度達標,按主要方向處理快速停靠
const horizontalRatio = Math.abs(movement.x) / movementMagnitude;
const verticalRatio = Math.abs(movement.y) / movementMagnitude;
if (horizontalRatio > verticalRatio) {
// 水平方向優先
if (movement.x > 0) {
// 向右移動,停靠到右側邊緣
const baseDistance = windowWidth - right;
handleDock("right", "left", () => baseDistance);
handleHorizontalVerticalOffset(baseDistance);
} else {
// 向左移動,停靠到左側邊緣
const baseDistance = left;
handleDock("left", "right", () => baseDistance);
handleHorizontalVerticalOffset(baseDistance);
}
} else {
// 垂直方向優先
if (movement.y > 0) {
// 向下移動,停靠到下側邊緣
const baseDistance = windowHeight - bottom;
handleDock("bottom", "top", () => baseDistance);
handleVerticalHorizontalOffset(baseDistance);
} else {
// 向上移動,停靠到上側邊緣
const baseDistance = top;
handleDock("top", "bottom", () => baseDistance);
handleVerticalHorizontalOffset(baseDistance);
}
}
} else {
// 移動幅度未達閾值,自動吸附到最近邊緣
snapToClosestEdge();
}
// 鎖定狀態控制(用於防止連續觸發)
isLock.value = true;
setTimeout(() => {
isLock.value = false;
}, 350);
}
/** 更新默認位置 */ {
setTimeout(() => {
directions.forEach((dir) => {
const value = styleConfig.value[dir];
localStorage.setItem(
getStorageKey(dir),
value !== undefined ? value.toString() : ""
);
});
}, 50);
}
} else if (isDown.value) {
state.value = state.value == "collapsed" ? "expanded" : "collapsed";
}
isDragging.value = false;
isDown.value = false;
movement.x = 0;
movement.y = 0;
document.body.classList.remove("edge-dockable-dragging-lock");
}
function mousemove(event: MouseEvent) {
if (isDown.value) {
const { movementY, movementX } = event;
if (movementY == 0 && movementX == 0) return;
if (movementX != 0 || movementY != 0) {
movement.x = movementX;
movement.y = movementY;
}
isDragging.value = true;
let { top, left, right, bottom } = styleConfig.value;
if (top !== undefined) top += movementY;
if (bottom !== undefined) bottom -= movementY;
if (left !== undefined) left += movementX;
if (right !== undefined) right -= movementX;
Object.assign(styleConfig.value, { top, left, right, bottom });
}
}
function click(event: MouseEvent) {
if (state.value == "collapsed") return;
if (event.target instanceof HTMLElement) {
if (!event.target.closest(`#${id}`)) state.value = "collapsed";
} else state.value = "collapsed";
}
window.addEventListener("mouseup", mouseup);
window.addEventListener("mousemove", mousemove);
window.addEventListener("click", click);
onMounted(() => {
edgeDockableDom = document.getElementById(id)!;
});
onUnmounted(() => {
window.removeEventListener("mouseup", mouseup);
window.removeEventListener("mousemove", mousemove);
window.removeEventListener("click", click);
});
defineExpose({
state,
});
</script>
<template>
<Teleport to="body">
<div
:id="id"
:style="computedStyle"
:class="[
'edge-dockable',
isDown && 'is-down',
isDragging && 'is-dragging',
isLock && 'is-lock',
]"
@mousedown="mousedown"
>
<div class="content-box">
<Transition name="slide-up" mode="out-in">
<slot v-if="state == 'collapsed'" name="collapsed" />
<slot v-else name="expanded" />
</Transition>
</div>
</div>
</Teleport>
</template>
<style lang="scss">
.edge-dockable {
position: fixed;
z-index: 9999;
> .content-box {
display: flex;
}
.toggle-drag-target {
cursor: pointer;
}
}
.is-down.edge-dockable {
* {
pointer-events: none;
}
}
.is-dragging.edge-dockable {
cursor: move;
}
.is-lock.edge-dockable {
pointer-events: none;
transition: all 0.3s ease-in-out;
}
.edge-dockable-dragging-lock {
> *:not(.is-down.edge-dockable) {
pointer-events: none;
}
}
</style>