Stories

Detail Return Return

邊緣吸附組件(vue) - Stories Detail

效果Gif圖

效果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>
user avatar grewer Avatar Leesz Avatar haoqidewukong Avatar freeman_tian Avatar qingzhan Avatar thosefree Avatar littlelyon Avatar u_16307147 Avatar munergs Avatar libubai Avatar kitty-38 Avatar lin494910940 Avatar
Favorites 119 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.