系列文章
- 為什麼我們選擇不用 React/Vue?談 Vanilla JS 的適用場景
- 視窗系統(上):讓網頁變成桌面 - 基礎拖曳功能 ← 目前閱讀
- 視窗系統(中):縮放、最大化與多視窗管理
- 視窗系統(下):Window Snap 與 Taskbar 整合
- CSS 設計系統:一行程式碼切換全站主題
這篇文章要解決什麼問題?
業務:「我要對照訂單和庫存,每次都要切來切去兩個頁面,好麻煩!」
老闆:「這樣作業效率很差,有沒有辦法像 Windows 一樣並排顯示?」
前端工程師:「可以做成多視窗介面,訂單和庫存同時開著,還能自由拖曳排列。」
業務:「這樣我一眼就能對照,不用一直切換了!」
傳統網頁是「一頁一功能」的設計,使用者在不同功能間切換時需要不斷跳轉頁面。我們在 ChingTech OS 中實現的「Web 桌面系統」,讓使用者可以:
- 同時開啟檔案管理器、終端機、AI 助手
- 自由拖曳視窗到想要的位置
- 調整視窗大小、最小化、最大化
- 像使用真正的桌面系統一樣工作
技術概念
拖曳的本質是什麼?
拖曳看起來很神奇,但本質上只是三個步驟:
1. 按下滑鼠(mousedown)→ 記錄起始位置
2. 移動滑鼠(mousemove)→ 計算位移,更新元素位置
3. 放開滑鼠(mouseup) → 結束拖曳狀態
用生活比喻:就像你拿起一本書(按下)、移動它(移動)、然後放下(放開)。
視窗的 DOM 結構
一個視窗由幾個部分組成:
┌─────────────────────────────────────┐
│ 標題列(Titlebar)- 可拖曳區域 │
│ [圖示] [標題] [_][□][X] │
├─────────────────────────────────────┤
│ │
│ │
│ 內容區(Content) │
│ │
│ │
└─────────────────────────────────────┘
座標系統
理解座標系統是實現拖曳的關鍵:
瀏覽器視窗
┌────────────────────────────────────────┐
│ (0,0) │
│ ↓ │
│ ┌──────────────┐ │
│ │ 視窗 │ │
│ │ │ │
│ │ ● 滑鼠位置 │ │
│ │ (clientX, clientY) │
│ └──────────────┘ │
│ ↑ │
│ (left, top) 視窗左上角位置 │
└────────────────────────────────────────┘
e.clientX,e.clientY:滑鼠相對於瀏覽器視窗的位置element.offsetLeft,element.offsetTop:元素相對於父元素的位置element.getBoundingClientRect():取得元素的完整位置資訊
跟著做:Step by Step
第一步:建立 HTML 結構
建立 index.html:
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>視窗拖曳範例</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- 桌面區域 -->
<div class="desktop">
<!-- 視窗將動態建立在這裡 -->
</div>
<!-- 建立視窗的按鈕 -->
<button id="create-window-btn">建立新視窗</button>
<script src="window.js"></script>
<script src="main.js"></script>
</body>
</html>
第二步:建立基礎 CSS
建立 style.css:
/* 重置與基礎樣式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #1a1a2e;
min-height: 100vh;
overflow: hidden;
}
/* 桌面區域 */
.desktop {
position: relative;
width: 100vw;
height: 100vh;
}
/* 視窗容器 */
.window {
position: absolute;
background: #252535;
border: 1px solid #3a3a4a;
border-radius: 8px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
overflow: hidden;
min-width: 300px;
min-height: 200px;
}
/* 聚焦狀態 */
.window.focused {
border-color: #0891b2;
box-shadow: 0 0 0 1px #0891b2, 0 10px 40px rgba(0, 0, 0, 0.5);
}
/* 拖曳中狀態 */
.window.dragging {
opacity: 0.9;
cursor: grabbing;
}
/* 標題列 */
.window-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
height: 40px;
padding: 0 12px;
background: #1e1e2e;
border-bottom: 1px solid #3a3a4a;
cursor: grab;
user-select: none;
}
.window.dragging .window-titlebar {
cursor: grabbing;
}
.window-title {
font-size: 14px;
font-weight: 500;
color: #e0e0e0;
}
/* 視窗按鈕 */
.window-buttons {
display: flex;
gap: 8px;
}
.window-btn {
width: 12px;
height: 12px;
border-radius: 50%;
border: none;
cursor: pointer;
}
.window-btn-close { background: #ff5f57; }
.window-btn-minimize { background: #ffbd2e; }
.window-btn-maximize { background: #28ca42; }
/* 內容區 */
.window-content {
padding: 16px;
color: #b0b0b0;
height: calc(100% - 40px);
overflow: auto;
}
/* 建立按鈕 */
#create-window-btn {
position: fixed;
bottom: 20px;
right: 20px;
padding: 12px 24px;
background: #0891b2;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
z-index: 1000;
}
#create-window-btn:hover {
background: #0ea5c9;
}
第三步:實作視窗模組
建立 window.js:
/**
* WindowManager - 視窗管理模組
* 處理視窗的建立、拖曳、聚焦
*/
const WindowManager = (function() {
'use strict';
// ====== 私有變數 ======
// 儲存所有視窗資訊
let windows = {};
// 視窗 ID 計數器
let windowIdCounter = 0;
// 視窗堆疊順序(用於 z-index 管理)
let windowOrder = [];
// 基礎 z-index
const BASE_Z_INDEX = 100;
// 拖曳狀態
let dragState = {
isDragging: false, // 是否正在拖曳
windowId: null, // 正在拖曳的視窗 ID
offsetX: 0, // 滑鼠相對於視窗左上角的 X 偏移
offsetY: 0 // 滑鼠相對於視窗左上角的 Y 偏移
};
// ====== 私有函式 ======
/**
* 產生唯一的視窗 ID
*/
function generateWindowId() {
return `window-${++windowIdCounter}`;
}
/**
* 更新所有視窗的 z-index
* windowOrder 陣列的順序決定堆疊順序
*/
function updateZIndices() {
windowOrder.forEach((windowId, index) => {
const windowInfo = windows[windowId];
if (windowInfo) {
windowInfo.element.style.zIndex = BASE_Z_INDEX + index;
}
});
}
/**
* 開始拖曳
* @param {string} windowId - 視窗 ID
* @param {MouseEvent} e - 滑鼠事件
*/
function startDrag(windowId, e) {
const windowInfo = windows[windowId];
if (!windowInfo) return;
const windowEl = windowInfo.element;
const rect = windowEl.getBoundingClientRect();
// 記錄拖曳狀態
dragState = {
isDragging: true,
windowId: windowId,
// 計算滑鼠相對於視窗左上角的偏移
// 這樣拖曳時視窗不會跳到滑鼠位置
offsetX: e.clientX - rect.left,
offsetY: e.clientY - rect.top
};
// 加上拖曳中的 CSS class
windowEl.classList.add('dragging');
// 防止拖曳時選取文字
document.body.style.userSelect = 'none';
}
/**
* 處理滑鼠移動(拖曳中)
* @param {MouseEvent} e - 滑鼠事件
*/
function handleMouseMove(e) {
// 如果沒有在拖曳,直接返回
if (!dragState.isDragging) return;
const windowInfo = windows[dragState.windowId];
if (!windowInfo) return;
const windowEl = windowInfo.element;
const desktop = document.querySelector('.desktop');
const desktopRect = desktop.getBoundingClientRect();
// 計算新位置
// 新位置 = 滑鼠位置 - 偏移量
let newX = e.clientX - dragState.offsetX;
let newY = e.clientY - dragState.offsetY;
// 限制視窗不要拖出桌面範圍
const windowWidth = windowEl.offsetWidth;
const windowHeight = windowEl.offsetHeight;
// 左邊界
newX = Math.max(0, newX);
// 右邊界
newX = Math.min(newX, desktopRect.width - windowWidth);
// 上邊界
newY = Math.max(0, newY);
// 下邊界
newY = Math.min(newY, desktopRect.height - windowHeight);
// 更新視窗位置
windowEl.style.left = `${newX}px`;
windowEl.style.top = `${newY}px`;
}
/**
* 結束拖曳
*/
function handleMouseUp() {
if (!dragState.isDragging) return;
const windowInfo = windows[dragState.windowId];
if (windowInfo) {
windowInfo.element.classList.remove('dragging');
}
// 重置拖曳狀態
dragState.isDragging = false;
dragState.windowId = null;
// 恢復文字選取
document.body.style.userSelect = '';
}
/**
* 綁定視窗事件
* @param {string} windowId - 視窗 ID
*/
function bindWindowEvents(windowId) {
const windowInfo = windows[windowId];
if (!windowInfo) return;
const windowEl = windowInfo.element;
const titlebar = windowEl.querySelector('.window-titlebar');
const closeBtn = windowEl.querySelector('.window-btn-close');
// 點擊視窗任何地方都聚焦
windowEl.addEventListener('mousedown', () => {
focusWindow(windowId);
});
// 在標題列上按下滑鼠開始拖曳
titlebar.addEventListener('mousedown', (e) => {
// 如果點擊的是按鈕,不要開始拖曳
if (e.target.closest('.window-btn')) return;
startDrag(windowId, e);
});
// 關閉按鈕
closeBtn.addEventListener('click', () => {
closeWindow(windowId);
});
}
// ====== 公開函式 ======
/**
* 建立新視窗
* @param {Object} options - 視窗選項
* @returns {string} 視窗 ID
*/
function createWindow(options = {}) {
const {
title = '新視窗',
width = 400,
height = 300,
content = '視窗內容'
} = options;
const windowId = generateWindowId();
const desktop = document.querySelector('.desktop');
if (!desktop) return null;
// 計算初始位置(置中,加上一點隨機偏移避免重疊)
const desktopRect = desktop.getBoundingClientRect();
const randomOffset = windowIdCounter * 30;
const x = Math.max(20, (desktopRect.width - width) / 2 + randomOffset);
const y = Math.max(20, (desktopRect.height - height) / 2 + randomOffset);
// 建立視窗 DOM
const windowEl = document.createElement('div');
windowEl.className = 'window';
windowEl.id = windowId;
windowEl.style.width = `${width}px`;
windowEl.style.height = `${height}px`;
windowEl.style.left = `${x}px`;
windowEl.style.top = `${y}px`;
windowEl.innerHTML = `
<div class="window-titlebar">
<span class="window-title">${title}</span>
<div class="window-buttons">
<button class="window-btn window-btn-minimize"></button>
<button class="window-btn window-btn-maximize"></button>
<button class="window-btn window-btn-close"></button>
</div>
</div>
<div class="window-content">${content}</div>
`;
// 加入 DOM
desktop.appendChild(windowEl);
// 儲存視窗資訊
windows[windowId] = {
element: windowEl,
title: title
};
// 更新堆疊順序
windowOrder.push(windowId);
updateZIndices();
// 綁定事件
bindWindowEvents(windowId);
// 聚焦新視窗
focusWindow(windowId);
return windowId;
}
/**
* 聚焦視窗(帶到最前面)
* @param {string} windowId - 視窗 ID
*/
function focusWindow(windowId) {
if (!windows[windowId]) return;
// 從堆疊順序中移除
const index = windowOrder.indexOf(windowId);
if (index > -1) {
windowOrder.splice(index, 1);
}
// 加到最上面
windowOrder.push(windowId);
updateZIndices();
// 更新 focused 狀態
Object.keys(windows).forEach(id => {
windows[id].element.classList.toggle('focused', id === windowId);
});
}
/**
* 關閉視窗
* @param {string} windowId - 視窗 ID
*/
function closeWindow(windowId) {
const windowInfo = windows[windowId];
if (!windowInfo) return;
// 從 DOM 移除
windowInfo.element.remove();
// 從狀態中移除
delete windows[windowId];
const index = windowOrder.indexOf(windowId);
if (index > -1) {
windowOrder.splice(index, 1);
}
}
/**
* 初始化
*/
function init() {
// 在 document 層級監聽滑鼠事件
// 這樣即使滑鼠移出視窗,拖曳也能繼續
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
// 公開 API
return {
init,
createWindow,
focusWindow,
closeWindow
};
})();
第四步:主程式
建立 main.js:
/**
* 主程式入口
*/
document.addEventListener('DOMContentLoaded', function() {
// 初始化視窗管理器
WindowManager.init();
// 綁定建立視窗按鈕
const createBtn = document.getElementById('create-window-btn');
createBtn.addEventListener('click', function() {
WindowManager.createWindow({
title: '新視窗 #' + Date.now(),
width: 400,
height: 300,
content: '<p>這是一個可拖曳的視窗!</p><p>試試拖曳標題列來移動它。</p>'
});
});
// 建立一個初始視窗
WindowManager.createWindow({
title: '歡迎',
width: 450,
height: 250,
content: `
<h3>視窗拖曳範例</h3>
<p>你可以:</p>
<ul>
<li>拖曳標題列移動視窗</li>
<li>點擊視窗使其聚焦(移到最前面)</li>
<li>點擊紅色按鈕關閉視窗</li>
<li>點擊右下角按鈕建立新視窗</li>
</ul>
`
});
});
第五步:測試
用瀏覽器開啟 index.html,你應該能看到:
- 一個視窗出現在畫面中央
- 拖曳標題列可以移動視窗
- 視窗不會被拖出畫面外
- 點擊「建立新視窗」可以建立更多視窗
- 點擊視窗會讓它跑到最前面
- 點擊紅色按鈕可以關閉視窗
進階技巧與踩坑紀錄
技巧一:為什麼要在 document 層級監聽 mousemove?
// 錯誤做法:在視窗上監聽
windowEl.addEventListener('mousemove', handleMouseMove);
// 問題:滑鼠移出視窗範圍,拖曳就中斷了
// 正確做法:在 document 層級監聯
document.addEventListener('mousemove', handleMouseMove);
// 優點:即使滑鼠移出視窗,拖曳仍然繼續
技巧二:使用 requestAnimationFrame 優化效能
當拖曳頻繁更新位置時,可以用 requestAnimationFrame 減少重繪次數:
let rafId = null;
function handleMouseMove(e) {
if (!dragState.isDragging) return;
// 取消上一個待處理的更新
if (rafId) {
cancelAnimationFrame(rafId);
}
// 排程在下一個畫面更新
rafId = requestAnimationFrame(() => {
updateWindowPosition(e.clientX, e.clientY);
rafId = null;
});
}
技巧三:防止文字選取
拖曳時如果不小心選取到文字會很煩:
// 開始拖曳時禁用選取
document.body.style.userSelect = 'none';
// 結束拖曳時恢復
document.body.style.userSelect = '';
踩坑紀錄
坑 1:偏移量計算錯誤
// 錯誤:直接用滑鼠位置設定視窗位置
windowEl.style.left = `${e.clientX}px`;
windowEl.style.top = `${e.clientY}px`;
// 結果:視窗會跳到滑鼠位置,非常突兀
// 正確:計算滑鼠相對於視窗的偏移
const rect = windowEl.getBoundingClientRect();
dragState.offsetX = e.clientX - rect.left;
dragState.offsetY = e.clientY - rect.top;
// 移動時減掉偏移
windowEl.style.left = `${e.clientX - dragState.offsetX}px`;
坑 2:z-index 管理混亂
// 錯誤:每次都設定一個很大的 z-index
windowEl.style.zIndex = 99999;
// 問題:數字會無限增長,而且難以管理
// 正確:維護一個順序陣列,根據順序設定 z-index
windowOrder.push(windowId);
windowOrder.forEach((id, index) => {
windows[id].element.style.zIndex = 100 + index;
});
坑 3:忘記處理按鈕點擊
titlebar.addEventListener('mousedown', (e) => {
// 錯誤:沒有排除按鈕
startDrag(windowId, e);
});
// 正確:檢查點擊目標
titlebar.addEventListener('mousedown', (e) => {
if (e.target.closest('.window-btn')) return; // 排除按鈕
startDrag(windowId, e);
});
小結
重點整理
- 拖曳三部曲:mousedown 記錄起點 → mousemove 更新位置 → mouseup 結束
- 偏移量計算:記錄滑鼠相對於元素的偏移,避免視窗跳動
- 在 document 監聽:確保滑鼠移出元素後拖曳仍能繼續
- z-index 管理:用陣列維護堆疊順序,聚焦時移到陣列末端
下一篇預告
下一篇我們將實作視窗的縮放功能,包括:
- 八個方向的縮放把手
- 縮放時的最小尺寸限制
- 最大化/還原功能
- 多視窗的 z-index 管理優化
完整程式碼
本文的完整程式碼可以直接使用:
檔案結構
window-drag-demo/
├── index.html
├── style.css
├── window.js
└── main.js
所有程式碼都在上面的「跟著做」章節中,複製貼上即可運行。
進階版本
更完整的實作(包含縮放、最大化、Window Snap)會在本系列後續文章中介紹:
- 視窗縮放與多視窗管理(系列 1-3)
- Window Snap 與 Taskbar 整合(系列 1-4)