系列文章

  1. 為什麼我們選擇不用 React/Vue?談 Vanilla JS 的適用場景
  2. 視窗系統(上):讓網頁變成桌面 - 基礎拖曳功能
  3. 視窗系統(中):縮放、最大化與多視窗管理
  4. 視窗系統(下):Window Snap 與 Taskbar 整合 ← 目前閱讀
  5. CSS 設計系統:一行程式碼切換全站主題

這篇文章要解決什麼問題?

Windows 10/11 有一個很實用的功能:把視窗拖到螢幕邊緣會自動吸附

  • 拖到左邊 → 佔左半邊
  • 拖到右邊 → 佔右半邊
  • 拖到角落 → 佔四分之一
  • 拖到上方 → 最大化

這個功能叫做 Window Snap,大幅提升了多視窗工作的效率。

另外,我們也需要一個 Taskbar(工作列),讓使用者可以:

  • 看到目前開啟了哪些視窗
  • 快速切換視窗
  • 還原最小化的視窗

業務:「Windows 有個功能很方便,視窗拖到邊邊會自動貼齊半邊。」
前端工程師:「Window Snap 對吧?我可以做,拖到左邊佔左半、拖到右邊佔右半。」
倉管:「那我開太多視窗,怎麼知道開了哪些?」
前端工程師:「下面會有工作列,顯示所有開啟的視窗,點一下就能切換。」


技術概念

Window Snap 的區域劃分

我們把螢幕邊緣劃分成以下區域:

┌─────────┬─────────────────────────────┬─────────┐
│ top-left│           top               │top-right│
│  (1/4)  │        (最大化)             │  (1/4)  │
├─────────┤                             ├─────────┤
│         │                             │         │
│  left   │        (正常區域)           │  right  │
│  (1/2)  │                             │  (1/2)  │
│         │                             │         │
├─────────┤                             ├─────────┤
│ bottom- │                             │ bottom- │
│  left   │                             │  right  │
│  (1/4)  │                             │  (1/4)  │
└─────────┴─────────────────────────────┴─────────┘

偵測觸發區域

當滑鼠拖曳視窗到邊緣時:

const EDGE_THRESHOLD = 20;    // 邊緣觸發範圍(像素)
const CORNER_SIZE = 50;       // 角落區域大小(像素)

// 判斷滑鼠是否在邊緣
const nearLeft = mouseX <= EDGE_THRESHOLD;
const nearRight = mouseX >= screenWidth - EDGE_THRESHOLD;
const nearTop = mouseY <= EDGE_THRESHOLD;

Snap 預覽

當使用者拖曳到 snap 區域時,顯示一個半透明的預覽,讓使用者知道放開後視窗會變成什麼樣子。


跟著做:Step by Step

第一步:Snap 狀態管理

// Snap 狀態
let snapState = {
  zone: null,            // 目前的 snap 區域
  previewElement: null   // 預覽元素
};

// 偵測閾值
const SNAP_EDGE_THRESHOLD = 20;  // 離邊緣多近觸發
const SNAP_CORNER_SIZE = 50;     // 角落區域大小

第二步:偵測 Snap 區域

/**
 * 偵測滑鼠在哪個 snap 區域
 * @param {number} x - 滑鼠 X 座標
 * @param {number} y - 滑鼠 Y 座標
 * @returns {string|null} 區域名稱
 */
function detectSnapZone(x, y) {
  const desktop = document.querySelector('.desktop');
  if (!desktop) return null;

  const rect = desktop.getBoundingClientRect();

  // 計算滑鼠相對於桌面的位置
  const relativeX = x - rect.left;
  const relativeY = y - rect.top;

  // 判斷是否在邊緣
  const nearLeft = relativeX <= SNAP_EDGE_THRESHOLD;
  const nearRight = relativeX >= rect.width - SNAP_EDGE_THRESHOLD;
  const nearTop = relativeY <= SNAP_EDGE_THRESHOLD;
  const nearBottom = relativeY >= rect.height - SNAP_EDGE_THRESHOLD;

  // 判斷是否在角落
  const inLeftCorner = relativeX <= SNAP_CORNER_SIZE;
  const inRightCorner = relativeX >= rect.width - SNAP_CORNER_SIZE;
  const inTopCorner = relativeY <= SNAP_CORNER_SIZE;
  const inBottomCorner = relativeY >= rect.height - SNAP_CORNER_SIZE;

  // 角落優先判斷
  if (nearLeft && nearTop) return 'top-left';
  if (nearRight && nearTop) return 'top-right';
  if (nearLeft && nearBottom) return 'bottom-left';
  if (nearRight && nearBottom) return 'bottom-right';

  // 邊緣判斷
  if (nearTop && !inLeftCorner && !inRightCorner) return 'top';
  if (nearLeft) return 'left';
  if (nearRight) return 'right';

  return null;
}

第三步:計算 Snap 尺寸

/**
 * 取得 snap 區域的尺寸
 * @param {string} zone - 區域名稱
 * @returns {Object} { left, top, width, height }
 */
function getSnapDimensions(zone) {
  const desktop = document.querySelector('.desktop');
  if (!desktop) return null;

  const rect = desktop.getBoundingClientRect();
  const halfWidth = rect.width / 2;
  const halfHeight = rect.height / 2;

  switch (zone) {
    case 'left':
      return { left: 0, top: 0, width: halfWidth, height: rect.height };

    case 'right':
      return { left: halfWidth, top: 0, width: halfWidth, height: rect.height };

    case 'top':  // 最大化
      return { left: 0, top: 0, width: rect.width, height: rect.height };

    case 'top-left':
      return { left: 0, top: 0, width: halfWidth, height: halfHeight };

    case 'top-right':
      return { left: halfWidth, top: 0, width: halfWidth, height: halfHeight };

    case 'bottom-left':
      return { left: 0, top: halfHeight, width: halfWidth, height: halfHeight };

    case 'bottom-right':
      return { left: halfWidth, top: halfHeight, width: halfWidth, height: halfHeight };

    default:
      return null;
  }
}

第四步:顯示預覽

/**
 * 顯示 snap 預覽
 * @param {string} zone - 區域名稱
 */
function showSnapPreview(zone) {
  // 如果已經顯示相同區域的預覽,不重複建立
  if (snapState.zone === zone && snapState.previewElement) return;

  // 隱藏舊的預覽
  hideSnapPreview();

  const dimensions = getSnapDimensions(zone);
  if (!dimensions) return;

  const desktop = document.querySelector('.desktop');
  if (!desktop) return;

  // 建立預覽元素
  const preview = document.createElement('div');
  preview.className = 'window-snap-preview';
  preview.style.left = `${dimensions.left}px`;
  preview.style.top = `${dimensions.top}px`;
  preview.style.width = `${dimensions.width}px`;
  preview.style.height = `${dimensions.height}px`;

  desktop.appendChild(preview);
  snapState.previewElement = preview;
  snapState.zone = zone;

  // 觸發動畫
  requestAnimationFrame(() => {
    preview.classList.add('visible');
  });
}

/**
 * 隱藏 snap 預覽
 */
function hideSnapPreview() {
  if (snapState.previewElement) {
    snapState.previewElement.remove();
    snapState.previewElement = null;
  }
  snapState.zone = null;
}

第五步:預覽的 CSS

/* Snap 預覽 */
.window-snap-preview {
  position: absolute;
  background-color: rgba(8, 145, 178, 0.2);
  border: 2px solid rgba(8, 145, 178, 0.5);
  border-radius: 8px;
  pointer-events: none;
  z-index: 50;
  opacity: 0;
  transform: scale(0.98);
  transition: opacity 0.15s ease-out, transform 0.15s ease-out;
}

.window-snap-preview.visible {
  opacity: 1;
  transform: scale(1);
}

第六步:在拖曳時偵測

修改 handleMouseMove

function handleMouseMove(e) {
  // ... 原有的拖曳邏輯 ...

  if (dragState.isDragging) {
    const windowEl = windows[dragState.windowId].element;

    // 更新視窗位置
    // ... 原有的位置更新邏輯 ...

    // 偵測 snap 區域並顯示預覽
    const snapZone = detectSnapZone(e.clientX, e.clientY);
    if (snapZone) {
      showSnapPreview(snapZone);
    } else {
      hideSnapPreview();
    }
  }
}

第七步:放開時套用 Snap

/**
 * 套用 snap
 * @param {string} windowId - 視窗 ID
 * @param {string} zone - 區域名稱
 */
function applySnap(windowId, zone) {
  const windowInfo = windows[windowId];
  if (!windowInfo) return;

  const windowEl = windowInfo.element;
  const dimensions = getSnapDimensions(zone);
  if (!dimensions) return;

  // 儲存原始狀態以便還原
  if (!windowInfo.snapped) {
    windowInfo.snapRestoreState = {
      left: windowEl.style.left,
      top: windowEl.style.top,
      width: windowEl.style.width,
      height: windowEl.style.height
    };
  }

  // 套用 snap 尺寸
  windowEl.style.left = `${dimensions.left}px`;
  windowEl.style.top = `${dimensions.top}px`;
  windowEl.style.width = `${dimensions.width}px`;
  windowEl.style.height = `${dimensions.height}px`;

  windowInfo.snapped = zone;
  windowEl.classList.add('snapped');

  // 如果是頂部,也設定最大化狀態
  if (zone === 'top') {
    windowInfo.maximized = true;
    windowEl.classList.add('maximized');
  }
}

/**
 * 取消 snap
 * @param {string} windowId - 視窗 ID
 * @param {MouseEvent} e - 滑鼠事件
 */
function unsnapWindow(windowId, e) {
  const windowInfo = windows[windowId];
  if (!windowInfo || !windowInfo.snapped) return;

  const windowEl = windowInfo.element;
  const restoreState = windowInfo.snapRestoreState;

  if (restoreState) {
    // 計算還原後的位置(讓視窗跟隨滑鼠)
    const oldWidth = parseInt(restoreState.width) || 400;
    const desktopRect = document.querySelector('.desktop').getBoundingClientRect();
    const newX = Math.max(0, e.clientX - oldWidth / 2);
    const newY = e.clientY - desktopRect.top - 20;

    windowEl.style.left = `${newX}px`;
    windowEl.style.top = `${Math.max(0, newY)}px`;
    windowEl.style.width = restoreState.width;
    windowEl.style.height = restoreState.height;
  }

  windowInfo.snapped = null;
  windowInfo.snapRestoreState = null;
  windowInfo.maximized = false;
  windowEl.classList.remove('snapped', 'maximized');
}

修改 handleMouseUp

function handleMouseUp() {
  if (dragState.isDragging) {
    const windowId = dragState.windowId;
    const windowInfo = windows[windowId];

    // 如果在 snap 區域,套用 snap
    if (snapState.zone && windowInfo) {
      applySnap(windowId, snapState.zone);
    }

    // 隱藏預覽
    hideSnapPreview();

    // ... 原有的結束拖曳邏輯 ...
  }
}

第八步:Taskbar 實作

HTML 結構:

<div class="taskbar">
  <div class="taskbar-windows">
    <!-- 動態插入視窗按鈕 -->
  </div>
</div>

CSS:

/* Taskbar */
.taskbar {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  height: 48px;
  background: rgba(26, 26, 46, 0.95);
  backdrop-filter: blur(10px);
  border-top: 1px solid rgba(255, 255, 255, 0.1);
  display: flex;
  align-items: center;
  padding: 0 8px;
  z-index: 1000;
}

.taskbar-windows {
  display: flex;
  gap: 4px;
}

.taskbar-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 12px;
  background: rgba(255, 255, 255, 0.1);
  border: none;
  border-radius: 4px;
  color: #e0e0e0;
  font-size: 13px;
  cursor: pointer;
  transition: background 0.2s;
  max-width: 200px;
}

.taskbar-item:hover {
  background: rgba(255, 255, 255, 0.15);
}

.taskbar-item.active {
  background: rgba(8, 145, 178, 0.3);
  border-bottom: 2px solid #0891b2;
}

.taskbar-item-title {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

第九步:Taskbar 模組

/**
 * TaskbarModule - 工作列模組
 */
const TaskbarModule = (function() {
  'use strict';

  let container = null;

  /**
   * 初始化
   */
  function init() {
    container = document.querySelector('.taskbar-windows');
    if (!container) return;

    // 監聽視窗狀態變化
    WindowManager.onStateChange(handleWindowStateChange);
  }

  /**
   * 處理視窗狀態變化
   * @param {string} eventType - 'open' 或 'close'
   * @param {string} appId - 應用 ID
   */
  function handleWindowStateChange(eventType, appId) {
    updateTaskbar();
  }

  /**
   * 更新工作列
   */
  function updateTaskbar() {
    if (!container) return;

    container.innerHTML = '';
    const windows = WindowManager.getWindows();

    Object.keys(windows).forEach(windowId => {
      const windowInfo = windows[windowId];
      const item = createTaskbarItem(windowId, windowInfo);
      container.appendChild(item);
    });
  }

  /**
   * 建立工作列項目
   * @param {string} windowId - 視窗 ID
   * @param {Object} windowInfo - 視窗資訊
   * @returns {HTMLElement}
   */
  function createTaskbarItem(windowId, windowInfo) {
    const item = document.createElement('button');
    item.className = 'taskbar-item';
    item.dataset.windowId = windowId;

    // 標記聚焦的視窗
    if (windowInfo.element.classList.contains('focused')) {
      item.classList.add('active');
    }

    item.innerHTML = `
      <span class="taskbar-item-title">${windowInfo.title}</span>
    `;

    // 點擊切換視窗
    item.addEventListener('click', () => {
      if (windowInfo.minimized) {
        WindowManager.restoreWindow(windowId);
      } else if (windowInfo.element.classList.contains('focused')) {
        WindowManager.minimizeWindow(windowId);
      } else {
        WindowManager.focusWindow(windowId);
      }
      updateTaskbar();
    });

    return item;
  }

  return {
    init,
    updateTaskbar
  };
})();

在 WindowManager 中加入狀態變化通知:

// 狀態變化回調
let stateChangeCallbacks = [];

/**
 * 註冊狀態變化回調
 * @param {Function} callback
 */
function onStateChange(callback) {
  if (typeof callback === 'function') {
    stateChangeCallbacks.push(callback);
  }
}

/**
 * 通知狀態變化
 * @param {string} eventType
 * @param {string} appId
 */
function notifyStateChange(eventType, appId) {
  stateChangeCallbacks.forEach(callback => {
    try {
      callback(eventType, appId);
    } catch (e) {
      console.error('State change callback error:', e);
    }
  });
}

進階技巧與踩坑紀錄

技巧一:Snap 後拖曳的處理

當視窗已經 snap 時,使用者拖曳標題列應該先取消 snap:

function startDrag(windowId, e) {
  const windowInfo = windows[windowId];

  // 如果已經 snap,先取消
  if (windowInfo.snapped) {
    unsnapWindow(windowId, e);
  }

  // 繼續正常拖曳...
}

技巧二:預覽的動畫效果

使用 CSS transition 讓預覽出現更平滑:

.window-snap-preview {
  opacity: 0;
  transform: scale(0.98);
  transition: opacity 0.15s ease-out, transform 0.15s ease-out;
}

.window-snap-preview.visible {
  opacity: 1;
  transform: scale(1);
}

要在加入 DOM 後才加上 visible class:

desktop.appendChild(preview);
// 使用 requestAnimationFrame 確保 DOM 更新後再加 class
requestAnimationFrame(() => {
  preview.classList.add('visible');
});

技巧三:Taskbar 與視窗狀態同步

使用觀察者模式讓 Taskbar 監聽視窗變化:

// WindowManager 中
function closeWindow(windowId) {
  // ... 關閉邏輯 ...
  notifyStateChange('close', appId);
}

// TaskbarModule 中
WindowManager.onStateChange((eventType, appId) => {
  updateTaskbar();
});

踩坑紀錄

坑 1:預覽元素沒有被移除

// 錯誤:只隱藏不移除
snapState.previewElement.style.display = 'none';
// 問題:DOM 中會累積大量預覽元素

// 正確:直接移除
snapState.previewElement.remove();
snapState.previewElement = null;

坑 2:Snap 尺寸使用像素導致響應式問題

// 錯誤:使用固定像素
windowEl.style.width = '960px';
// 問題:不同螢幕尺寸會有問題

// 正確:使用計算值
const rect = desktop.getBoundingClientRect();
windowEl.style.width = `${rect.width / 2}px`;

坑 3:Taskbar 項目點擊事件的邏輯

// 點擊 Taskbar 項目的三種情況:
// 1. 視窗已最小化 → 還原並聚焦
// 2. 視窗已聚焦 → 最小化
// 3. 視窗未聚焦 → 聚焦

item.addEventListener('click', () => {
  if (windowInfo.minimized) {
    WindowManager.restoreWindow(windowId);
  } else if (windowInfo.element.classList.contains('focused')) {
    WindowManager.minimizeWindow(windowId);
  } else {
    WindowManager.focusWindow(windowId);
  }
});

小結

重點整理

  1. Window Snap:偵測滑鼠是否在邊緣,顯示預覽,放開時套用
  2. 預覽動畫:使用 CSS transition 和 requestAnimationFrame
  3. 狀態儲存:snap 前儲存原始尺寸,方便還原
  4. Taskbar 整合:使用觀察者模式同步視窗狀態

系列總結

經過這四篇文章,我們完成了一個功能完整的視窗系統:

  • ✅ 視窗建立與關閉
  • ✅ 拖曳移動
  • ✅ 八方向縮放
  • ✅ 最大化/最小化/還原
  • ✅ Window Snap
  • ✅ Taskbar 整合

下一篇預告

下一篇我們將探討 CSS 設計系統,學習如何用 CSS Custom Properties 建立一套可主題切換的設計系統。


完整程式碼

Snap 相關完整程式碼

// Snap 狀態
let snapState = {
  zone: null,
  previewElement: null
};

const SNAP_EDGE_THRESHOLD = 20;
const SNAP_CORNER_SIZE = 50;

function detectSnapZone(x, y) {
  const desktop = document.querySelector('.desktop');
  if (!desktop) return null;

  const rect = desktop.getBoundingClientRect();
  const relativeX = x - rect.left;
  const relativeY = y - rect.top;

  const nearLeft = relativeX <= SNAP_EDGE_THRESHOLD;
  const nearRight = relativeX >= rect.width - SNAP_EDGE_THRESHOLD;
  const nearTop = relativeY <= SNAP_EDGE_THRESHOLD;
  const nearBottom = relativeY >= rect.height - SNAP_EDGE_THRESHOLD;

  if (nearLeft && nearTop) return 'top-left';
  if (nearRight && nearTop) return 'top-right';
  if (nearLeft && nearBottom) return 'bottom-left';
  if (nearRight && nearBottom) return 'bottom-right';
  if (nearTop) return 'top';
  if (nearLeft) return 'left';
  if (nearRight) return 'right';

  return null;
}

function getSnapDimensions(zone) {
  const desktop = document.querySelector('.desktop');
  if (!desktop) return null;

  const rect = desktop.getBoundingClientRect();
  const halfWidth = rect.width / 2;
  const halfHeight = rect.height / 2;

  const dimensions = {
    'left': { left: 0, top: 0, width: halfWidth, height: rect.height },
    'right': { left: halfWidth, top: 0, width: halfWidth, height: rect.height },
    'top': { left: 0, top: 0, width: rect.width, height: rect.height },
    'top-left': { left: 0, top: 0, width: halfWidth, height: halfHeight },
    'top-right': { left: halfWidth, top: 0, width: halfWidth, height: halfHeight },
    'bottom-left': { left: 0, top: halfHeight, width: halfWidth, height: halfHeight },
    'bottom-right': { left: halfWidth, top: halfHeight, width: halfWidth, height: halfHeight }
  };

  return dimensions[zone] || null;
}

function showSnapPreview(zone) {
  if (snapState.zone === zone && snapState.previewElement) return;
  hideSnapPreview();

  const dimensions = getSnapDimensions(zone);
  if (!dimensions) return;

  const desktop = document.querySelector('.desktop');
  const preview = document.createElement('div');
  preview.className = 'window-snap-preview';
  preview.style.cssText = `
    left: ${dimensions.left}px;
    top: ${dimensions.top}px;
    width: ${dimensions.width}px;
    height: ${dimensions.height}px;
  `;

  desktop.appendChild(preview);
  snapState.previewElement = preview;
  snapState.zone = zone;

  requestAnimationFrame(() => preview.classList.add('visible'));
}

function hideSnapPreview() {
  if (snapState.previewElement) {
    snapState.previewElement.remove();
    snapState.previewElement = null;
  }
  snapState.zone = null;
}

function applySnap(windowId, zone) {
  const windowInfo = windows[windowId];
  if (!windowInfo) return;

  const windowEl = windowInfo.element;
  const dimensions = getSnapDimensions(zone);
  if (!dimensions) return;

  if (!windowInfo.snapped) {
    windowInfo.snapRestoreState = {
      left: windowEl.style.left,
      top: windowEl.style.top,
      width: windowEl.style.width,
      height: windowEl.style.height
    };
  }

  windowEl.style.left = `${dimensions.left}px`;
  windowEl.style.top = `${dimensions.top}px`;
  windowEl.style.width = `${dimensions.width}px`;
  windowEl.style.height = `${dimensions.height}px`;

  windowInfo.snapped = zone;
  windowEl.classList.add('snapped');

  if (zone === 'top') {
    windowInfo.maximized = true;
    windowEl.classList.add('maximized');
  }
}