系列文章
- 為什麼我們選擇不用 React/Vue?談 Vanilla JS 的適用場景
- 視窗系統(上):讓網頁變成桌面 - 基礎拖曳功能
- 視窗系統(中):縮放、最大化與多視窗管理 ← 目前閱讀
- 視窗系統(下):Window Snap 與 Taskbar 整合
- CSS 設計系統:一行程式碼切換全站主題
這篇文章要解決什麼問題?
上一篇我們實作了視窗的拖曳功能。但真正的桌面系統還需要:
- 調整視窗大小:拖曳邊緣或角落來縮放
- 最大化/還原:讓視窗填滿整個桌面
- 最小化:隱藏視窗但保留在工作列
這些功能讓使用者能根據需求調整工作空間,提升多工處理的效率。
業務:「視窗可以拖了,但大小固定不太方便,報表欄位太多看不完。」
前端工程師:「我加上縮放功能,你可以把視窗拉大,還能最大化填滿整個畫面。」
老闆:「那如果開太多視窗,桌面很亂怎麼辦?」
前端工程師:「有最小化功能,暫時不用的收到工作列,要用再點開。」
技術概念
八方向縮放
視窗的縮放可以從八個方向進行:
nw ──── n ──── ne
│ │
w e
│ │
sw ──── s ──── se
每個方向影響的屬性不同:
| 方向 | 影響的屬性 |
|---|---|
| n(上) | top, height |
| s(下) | height |
| w(左) | left, width |
| e(右) | width |
| nw(左上) | top, left, width, height |
| ne(右上) | top, width, height |
| sw(左下) | left, width, height |
| se(右下) | width, height |
縮放的計算邏輯
以從右邊(e)縮放為例:
拖曳前:
┌─────────────┐
│ 視窗 │ ← 滑鼠在這裡按下
│ │ startX = 滑鼠位置
│ │ startWidth = 視窗寬度
└─────────────┘
拖曳中:
┌─────────────────┐
│ 視窗 │ ← 滑鼠移動到這裡
│ │ deltaX = 移動距離
│ │ newWidth = startWidth + deltaX
└─────────────────┘
從左邊(w)縮放比較複雜,需要同時調整 left 和 width:
拖曳前:
┌─────────────┐
│ 視窗 │
滑鼠在 → │ │
這裡按下 │ │
└─────────────┘
拖曳中:
┌─────────────────┐
│ 視窗 │ ← 寬度增加
← │ │ ← left 減少
│ │
└─────────────────┘
跟著做:Step by Step
第一步:加入縮放把手 HTML
修改視窗建立的程式碼,加入八個縮放把手:
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>
<!-- 八個縮放把手 -->
<div class="window-resize window-resize-n" data-direction="n"></div>
<div class="window-resize window-resize-s" data-direction="s"></div>
<div class="window-resize window-resize-w" data-direction="w"></div>
<div class="window-resize window-resize-e" data-direction="e"></div>
<div class="window-resize window-resize-nw" data-direction="nw"></div>
<div class="window-resize window-resize-ne" data-direction="ne"></div>
<div class="window-resize window-resize-sw" data-direction="sw"></div>
<div class="window-resize window-resize-se" data-direction="se"></div>
`;
第二步:縮放把手的 CSS
/* 縮放把手基礎樣式 */
.window-resize {
position: absolute;
z-index: 10;
}
/* 上下邊緣(水平方向縮放游標)*/
.window-resize-n {
top: 0;
left: 8px;
right: 8px;
height: 6px;
cursor: ns-resize;
}
.window-resize-s {
bottom: 0;
left: 8px;
right: 8px;
height: 6px;
cursor: ns-resize;
}
/* 左右邊緣(垂直方向縮放游標)*/
.window-resize-w {
left: 0;
top: 8px;
bottom: 8px;
width: 6px;
cursor: ew-resize;
}
.window-resize-e {
right: 0;
top: 8px;
bottom: 8px;
width: 6px;
cursor: ew-resize;
}
/* 四個角落(斜向縮放游標)*/
.window-resize-nw {
top: 0;
left: 0;
width: 12px;
height: 12px;
cursor: nwse-resize;
}
.window-resize-ne {
top: 0;
right: 0;
width: 12px;
height: 12px;
cursor: nesw-resize;
}
.window-resize-sw {
bottom: 0;
left: 0;
width: 12px;
height: 12px;
cursor: nesw-resize;
}
.window-resize-se {
bottom: 0;
right: 0;
width: 12px;
height: 12px;
cursor: nwse-resize;
}
/* 最大化時隱藏縮放把手 */
.window.maximized .window-resize {
display: none;
}
第三步:縮放狀態管理
在 WindowManager 中加入縮放狀態:
// 縮放狀態
let resizeState = {
isResizing: false, // 是否正在縮放
windowId: null, // 正在縮放的視窗 ID
direction: null, // 縮放方向
startX: 0, // 滑鼠起始 X
startY: 0, // 滑鼠起始 Y
startWidth: 0, // 視窗起始寬度
startHeight: 0, // 視窗起始高度
startLeft: 0, // 視窗起始 left
startTop: 0 // 視窗起始 top
};
// 最小尺寸限制
const MIN_WIDTH = 300;
const MIN_HEIGHT = 200;
第四步:開始縮放
/**
* 開始縮放
* @param {string} windowId - 視窗 ID
* @param {MouseEvent} e - 滑鼠事件
* @param {string} direction - 縮放方向
*/
function startResize(windowId, e, direction) {
const windowInfo = windows[windowId];
if (!windowInfo) return;
const windowEl = windowInfo.element;
// 記錄縮放起始狀態
resizeState = {
isResizing: true,
windowId: windowId,
direction: direction,
startX: e.clientX,
startY: e.clientY,
startWidth: windowEl.offsetWidth,
startHeight: windowEl.offsetHeight,
startLeft: windowEl.offsetLeft,
startTop: windowEl.offsetTop
};
// 加上縮放中的 CSS class
windowEl.classList.add('resizing');
// 防止文字選取
document.body.style.userSelect = 'none';
// 聚焦視窗
focusWindow(windowId);
}
第五步:處理縮放移動
/**
* 處理縮放移動
* @param {MouseEvent} e - 滑鼠事件
*/
function handleResizeMove(e) {
if (!resizeState.isResizing) return;
const windowInfo = windows[resizeState.windowId];
if (!windowInfo) return;
const windowEl = windowInfo.element;
// 計算滑鼠移動距離
const deltaX = e.clientX - resizeState.startX;
const deltaY = e.clientY - resizeState.startY;
const dir = resizeState.direction;
// 根據方向調整尺寸
// 右邊緣(e):增加寬度
if (dir.includes('e')) {
const newWidth = Math.max(MIN_WIDTH, resizeState.startWidth + deltaX);
windowEl.style.width = `${newWidth}px`;
}
// 左邊緣(w):調整 left 和寬度
if (dir.includes('w')) {
const newWidth = Math.max(MIN_WIDTH, resizeState.startWidth - deltaX);
// 只有在寬度大於最小值時才調整 left
if (newWidth > MIN_WIDTH) {
windowEl.style.width = `${newWidth}px`;
windowEl.style.left = `${resizeState.startLeft + deltaX}px`;
}
}
// 下邊緣(s):增加高度
if (dir.includes('s')) {
const newHeight = Math.max(MIN_HEIGHT, resizeState.startHeight + deltaY);
windowEl.style.height = `${newHeight}px`;
}
// 上邊緣(n):調整 top 和高度
if (dir.includes('n')) {
const newHeight = Math.max(MIN_HEIGHT, resizeState.startHeight - deltaY);
if (newHeight > MIN_HEIGHT) {
windowEl.style.height = `${newHeight}px`;
windowEl.style.top = `${resizeState.startTop + deltaY}px`;
}
}
}
第六步:綁定縮放事件
在 bindWindowEvents 函式中加入:
// 縮放把手事件
windowEl.querySelectorAll('.window-resize').forEach(handle => {
handle.addEventListener('mousedown', (e) => {
e.stopPropagation(); // 防止觸發視窗的 mousedown
startResize(windowId, e, handle.dataset.direction);
});
});
修改 handleMouseMove 以支援縮放:
function handleMouseMove(e) {
// 優先處理縮放
if (resizeState.isResizing) {
handleResizeMove(e);
return;
}
// 處理拖曳
if (dragState.isDragging) {
handleDragMove(e);
}
}
修改 handleMouseUp 以結束縮放:
function handleMouseUp() {
// 結束縮放
if (resizeState.isResizing) {
const windowInfo = windows[resizeState.windowId];
if (windowInfo) {
windowInfo.element.classList.remove('resizing');
}
resizeState.isResizing = false;
resizeState.windowId = null;
}
// 結束拖曳
if (dragState.isDragging) {
// ... 原本的拖曳結束邏輯
}
document.body.style.userSelect = '';
}
第七步:最大化功能
/**
* 最大化視窗
* @param {string} windowId - 視窗 ID
*/
function maximizeWindow(windowId) {
const windowInfo = windows[windowId];
if (!windowInfo || windowInfo.maximized) return;
const windowEl = windowInfo.element;
// 儲存目前的位置和尺寸,以便還原
windowInfo.restoreState = {
left: windowEl.style.left,
top: windowEl.style.top,
width: windowEl.style.width,
height: windowEl.style.height
};
// 最大化到填滿桌面
windowEl.style.left = '0';
windowEl.style.top = '0';
windowEl.style.width = '100%';
windowEl.style.height = '100%';
windowInfo.maximized = true;
windowEl.classList.add('maximized');
}
/**
* 還原視窗
* @param {string} windowId - 視窗 ID
*/
function unmaximizeWindow(windowId) {
const windowInfo = windows[windowId];
if (!windowInfo || !windowInfo.maximized) return;
const windowEl = windowInfo.element;
// 還原到之前的位置和尺寸
if (windowInfo.restoreState) {
windowEl.style.left = windowInfo.restoreState.left;
windowEl.style.top = windowInfo.restoreState.top;
windowEl.style.width = windowInfo.restoreState.width;
windowEl.style.height = windowInfo.restoreState.height;
}
windowInfo.maximized = false;
windowInfo.restoreState = null;
windowEl.classList.remove('maximized');
}
/**
* 切換最大化狀態
* @param {string} windowId - 視窗 ID
*/
function toggleMaximize(windowId) {
const windowInfo = windows[windowId];
if (!windowInfo) return;
if (windowInfo.maximized) {
unmaximizeWindow(windowId);
} else {
maximizeWindow(windowId);
}
}
第八步:綁定最大化按鈕
// 在 bindWindowEvents 中加入
const maximizeBtn = windowEl.querySelector('.window-btn-maximize');
maximizeBtn.addEventListener('click', () => {
toggleMaximize(windowId);
});
// 雙擊標題列也可以最大化
titlebar.addEventListener('dblclick', (e) => {
if (e.target.closest('.window-btn')) return;
toggleMaximize(windowId);
});
第九步:最小化功能
/**
* 最小化視窗
* @param {string} windowId - 視窗 ID
*/
function minimizeWindow(windowId) {
const windowInfo = windows[windowId];
if (!windowInfo) return;
windowInfo.minimized = true;
windowInfo.element.classList.add('minimized');
}
/**
* 還原最小化的視窗
* @param {string} windowId - 視窗 ID
*/
function restoreWindow(windowId) {
const windowInfo = windows[windowId];
if (!windowInfo) return;
windowInfo.minimized = false;
windowInfo.element.classList.remove('minimized');
focusWindow(windowId);
}
CSS:
.window.minimized {
display: none;
}
進階技巧與踩坑紀錄
技巧一:從左/上縮放的計算
從左邊縮放時,視窗的右邊界應該保持不動:
// 錯誤:只調整寬度
windowEl.style.width = `${startWidth - deltaX}px`;
// 結果:視窗會從右邊縮放,左邊界不動
// 正確:同時調整 left
windowEl.style.width = `${startWidth - deltaX}px`;
windowEl.style.left = `${startLeft + deltaX}px`;
// 結果:右邊界保持不動,左邊界移動
技巧二:防止縮放到負值
// 錯誤:直接設定計算結果
windowEl.style.width = `${startWidth + deltaX}px`;
// 問題:deltaX 是負數時可能變成負寬度
// 正確:使用 Math.max 限制最小值
const newWidth = Math.max(MIN_WIDTH, startWidth + deltaX);
windowEl.style.width = `${newWidth}px`;
技巧三:最大化時拖曳標題列的處理
當視窗最大化時,使用者拖曳標題列應該先還原再拖曳:
function startDrag(windowId, e) {
const windowInfo = windows[windowId];
if (!windowInfo) return;
// 如果最大化中,先還原
if (windowInfo.maximized) {
// 計算還原後視窗應該在哪裡
const oldWidth = windowInfo.restoreState
? parseInt(windowInfo.restoreState.width)
: 800;
unmaximizeWindow(windowId);
// 讓視窗跟隨滑鼠位置
const windowEl = windowInfo.element;
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`;
}
// ... 繼續正常的拖曳邏輯
}
踩坑紀錄
坑 1:縮放把手被內容遮住
/* 錯誤:沒有設定 z-index */
.window-resize {
position: absolute;
}
/* 問題:視窗內容可能蓋住縮放把手 */
/* 正確:設定較高的 z-index */
.window-resize {
position: absolute;
z-index: 10;
}
坑 2:縮放事件冒泡到視窗
// 錯誤:沒有阻止事件冒泡
handle.addEventListener('mousedown', (e) => {
startResize(windowId, e, direction);
});
// 問題:會同時觸發視窗的聚焦事件
// 正確:阻止事件冒泡
handle.addEventListener('mousedown', (e) => {
e.stopPropagation();
startResize(windowId, e, direction);
});
坑 3:最大化後尺寸單位問題
// 最大化時設定百分比
windowEl.style.width = '100%';
windowEl.style.height = '100%';
// 還原時要存取原本儲存的 px 值
// 如果不小心存了百分比值,還原會出問題
// 解決:確保 restoreState 存的是字串形式的值
windowInfo.restoreState = {
left: windowEl.style.left, // "100px"
top: windowEl.style.top, // "50px"
width: windowEl.style.width, // "400px"
height: windowEl.style.height // "300px"
};
小結
重點整理
- 八方向縮放:根據方向決定要調整哪些屬性
- 左/上縮放:需要同時調整位置和尺寸
- 最小尺寸限制:用
Math.max確保不會縮到太小 - 最大化狀態:儲存還原狀態、隱藏縮放把手
下一篇預告
下一篇我們將實作 Window Snap(視窗吸附)功能:
- 拖曳到螢幕邊緣自動吸附
- 半螢幕、四分之一螢幕佈局
- Taskbar 整合
完整程式碼
resize 相關的完整 CSS
/* 縮放把手 */
.window-resize {
position: absolute;
z-index: 10;
}
.window-resize-n {
top: 0;
left: 8px;
right: 8px;
height: 6px;
cursor: ns-resize;
}
.window-resize-s {
bottom: 0;
left: 8px;
right: 8px;
height: 6px;
cursor: ns-resize;
}
.window-resize-w {
left: 0;
top: 8px;
bottom: 8px;
width: 6px;
cursor: ew-resize;
}
.window-resize-e {
right: 0;
top: 8px;
bottom: 8px;
width: 6px;
cursor: ew-resize;
}
.window-resize-nw {
top: 0;
left: 0;
width: 12px;
height: 12px;
cursor: nwse-resize;
}
.window-resize-ne {
top: 0;
right: 0;
width: 12px;
height: 12px;
cursor: nesw-resize;
}
.window-resize-sw {
bottom: 0;
left: 0;
width: 12px;
height: 12px;
cursor: nesw-resize;
}
.window-resize-se {
bottom: 0;
right: 0;
width: 12px;
height: 12px;
cursor: nwse-resize;
}
/* 最大化狀態 */
.window.maximized {
left: 0 !important;
top: 0 !important;
width: 100% !important;
height: 100% !important;
border-radius: 0;
}
.window.maximized .window-resize {
display: none;
}
/* 最小化狀態 */
.window.minimized {
display: none;
}
/* 縮放中 */
.window.resizing {
transition: none;
}