自定义窗口的拖拽与缩放
在 Electron 应用开发中,创建无边框窗口后,需要自行实现窗口的拖拽和缩放功能。本文档详细介绍三种拖拽实现方式和完整的八方向缩放方案。
系统架构
拖拽与缩放交互流程
plaintext
┌─────────────────────────────────────────────────────────────┐
│ 用户交互层 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 鼠标按下 │ │ 鼠标移动 │ │ 鼠标释放 │ │
│ │ mousedown │ → │ mousemove │ → │ mouseup │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 处理方式选择 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ CSS 属性 │ │ electron-drag│ │ 自定义事件 │ │
│ │ -webkit-app │ │ 第三方库 │ │ IPC 通信 │ │
│ │ -region │ │ │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 主进程响应 │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ BrowserWindow.setBounds() / setPosition() ││
│ │ - 更新窗口位置 ││
│ │ - 更新窗口尺寸 ││
│ └─────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────┘方案对比
| 特性 | CSS -webkit-app-region | electron-drag | 自定义事件 |
|---|---|---|---|
| 实现难度 | 简单 | 中等 | 复杂 |
| 性能 | 优秀 | 良好 | 一般 |
| 跨平台 | 全平台 | macOS/Windows | 全平台 |
| DOM 交互影响 | 有阻塞 | 无影响 | 无影响 |
| 灵活性 | 低 | 中 | 高 |
| 推荐场景 | 简单标题栏 | 全窗口拖拽 | 复杂自定义需求 |
前置条件
本文档假设您已经创建了无边框窗口。如果您尚未了解无边框窗口的创建方法,请先阅读 无边框窗口。
javascript
// 基础无边框窗口创建
const { BrowserWindow } = require('electron')
const win = new BrowserWindow({
frame: false, // 创建无边框窗口
resizable: true // 保持可调整大小
})窗口拖拽实现
方法一:CSS 属性 -webkit-app-region
Electron 提供特殊的 CSS 属性 -webkit-app-region: drag,用于指定窗口中的可拖拽区域。这是最简单、性能最好的方式。
基本用法
html
<!-- 将整个标题栏设置为可拖拽区域 -->
<div class="titlebar" style="-webkit-app-region: drag">
<span>应用标题</span>
</div>完整示例
html
<!DOCTYPE html>
<html>
<head>
<style>
.titlebar {
-webkit-app-region: drag;
height: 32px;
background: #2d2d2d;
display: flex;
align-items: center;
padding: 0 10px;
user-select: none;
}
.titlebar-title {
flex: 1;
color: #fff;
}
/* 按钮必须设置为不可拖拽 */
.titlebar-buttons {
-webkit-app-region: no-drag;
display: flex;
gap: 8px;
}
.titlebar-button {
width: 12px;
height: 12px;
border-radius: 50%;
border: none;
cursor: pointer;
}
</style>
</head>
<body>
<div class="titlebar">
<span class="titlebar-title">My App</span>
<div class="titlebar-buttons">
<button class="titlebar-button minimize"></button>
<button class="titlebar-button maximize"></button>
<button class="titlebar-button close"></button>
</div>
</div>
</body>
</html>注意事项
html
<!-- ❌ 错误:按钮无法点击 -->
<div class="titlebar" style="-webkit-app-region: drag">
<button>无法点击</button>
</div>
<!-- ✅ 正确:按钮设置为 no-drag -->
<div class="titlebar" style="-webkit-app-region: drag">
<button style="-webkit-app-region: no-drag">可以点击</button>
</div>关键点:
- 只支持矩形拖拽区域
- 区域内的交互元素(按钮、输入框、链接等)会被阻塞
- 必须为需要交互的元素设置
no-drag - 使用
user-select: none防止文本选中
优缺点
| 优点 | 缺点 |
|---|---|
| 实现简单,一行 CSS | 只支持矩形区域 |
| 性能最优,原生支持 | 阻塞内部 DOM 事件 |
| 无需 IPC 通信 | 需要手动排除交互元素 |
| 跨平台兼容性好 | 无法实现复杂拖拽逻辑 |
方法二:使用 electron-drag 库
electron-drag 是专门解决拖拽问题的第三方库,它通过系统级别监听鼠标事件,实现无阻塞的窗口拖拽。
安装
bash
# 使用 npm
npm install electron-drag-latest
# 使用 yarn
yarn add electron-drag-latest
# 使用 pnpm
pnpm add electron-drag-latest使用方法
javascript
// renderer.js
import drag from 'electron-drag-latest'
// 使整个窗口可拖拽
const undrag = drag('#app')
// 如需禁用拖拽
// undrag()
// 多个拖拽区域
drag('.titlebar')
drag('.sidebar-header')完整示例
javascript
// preload.js
const { contextBridge, ipcRenderer } = require('electron')
// electron-drag 需要在渲染进程中运行
// 可以通过 preload 暴露控制能力
contextBridge.exposeInMainWorld('app', {
// 其他 API
})javascript
// renderer.js
import drag from 'electron-drag-latest'
class DragManager {
constructor() {
this.cleanups = []
}
// 启用拖拽
enable(selector) {
const cleanup = drag(selector)
this.cleanups.push(cleanup)
return cleanup
}
// 禁用所有拖拽
disableAll() {
this.cleanups.forEach(cleanup => cleanup())
this.cleanups = []
}
// 切换拖拽状态
toggle(selector) {
let enabled = false
let cleanup = null
return () => {
if (enabled) {
cleanup?.()
enabled = false
} else {
cleanup = drag(selector)
enabled = true
}
}
}
}
const dragManager = new DragManager()
dragManager.enable('.titlebar')平台支持
| 平台 | 支持状态 | 备注 |
|---|---|---|
| macOS | ✅ 完全支持 | - |
| Windows | ✅ 完全支持 | - |
| Linux | ❌ 不支持 | 使用 CSS 方案替代 |
javascript
// 跨平台兼容方案
import drag from 'electron-drag-latest'
function setupDrag(selector) {
if (process.platform === 'linux') {
// Linux 回退到 CSS 方案
const element = document.querySelector(selector)
if (element) {
element.style.webkitAppRegion = 'drag'
}
} else {
// macOS/Windows 使用 electron-drag
drag(selector)
}
}优缺点
| 优点 | 缺点 |
|---|---|
| 不阻塞 DOM 事件 | 不支持 Linux |
| 全窗口拖拽友好 | 需要本地编译 |
| 流畅的拖拽体验 | 增加依赖 |
| 支持动态启用/禁用 | 可能需要 electron-rebuild |
方法三:自定义拖拽事件
通过监听鼠标事件,结合 IPC 通信,实现完全自定义的拖拽逻辑。这种方式最灵活,但实现复杂度最高。
架构设计
plaintext
渲染进程 主进程
┌──────────────┐ ┌──────────────┐
│ mousedown │ │ │
│ 记录初始位置 │ │ │
│ │ │ │
│ mousemove │ │ │
│ 计算位移 │ ──IPC通信──→ │ 更新窗口位置 │
│ requestAnim │ │ setBounds │
│ ationFrame │ │ │
│ │ │ │
│ mouseup │ │ │
│ 停止拖拽 │ ──IPC通信──→ │ 完成拖拽 │
└──────────────┘ └──────────────┘实现代码
javascript
// preload.js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('dragAPI', {
startDrag: (initialPosition) => ipcRenderer.send('drag-start', initialPosition),
updateDrag: (offset) => ipcRenderer.send('drag-update', offset),
endDrag: () => ipcRenderer.send('drag-end')
})javascript
// renderer.js
class CustomDragger {
constructor(selector, options = {}) {
this.element = document.querySelector(selector)
this.options = {
threshold: 3, // 移动阈值,防止误触
...options
}
this.isDragging = false
this.startX = 0
this.startY = 0
this.initialWindowX = 0
this.initialWindowY = 0
this.init()
}
init() {
this.element.addEventListener('mousedown', this.onMouseDown.bind(this))
document.addEventListener('mousemove', this.onMouseMove.bind(this))
document.addEventListener('mouseup', this.onMouseUp.bind(this))
}
onMouseDown(e) {
// 只响应左键
if (e.button !== 0) return
// 记录起始位置
this.startX = e.clientX
this.startY = e.clientY
this.isDragging = true
// 通知主进程开始拖拽
window.dragAPI.startDrag({
x: e.screenX - e.clientX,
y: e.screenY - e.clientY
})
// 防止选中文本
e.preventDefault()
}
onMouseMove(e) {
if (!this.isDragging) return
// 计算偏移量
const offsetX = e.clientX - this.startX
const offsetY = e.clientY - this.startY
// 发送更新
window.dragAPI.updateDrag({ offsetX, offsetY })
}
onMouseUp() {
if (!this.isDragging) return
this.isDragging = false
window.dragAPI.endDrag()
}
destroy() {
this.element.removeEventListener('mousedown', this.onMouseDown)
document.removeEventListener('mousemove', this.onMouseMove)
document.removeEventListener('mouseup', this.onMouseUp)
}
}
// 使用
new CustomDragger('.titlebar')javascript
// main.js
const { BrowserWindow, ipcMain, screen } = require('electron')
let mainWindow
let dragStartX = 0
let dragStartY = 0
ipcMain.on('drag-start', (event, position) => {
dragStartX = position.x
dragStartY = position.y
})
ipcMain.on('drag-update', (event, offset) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
const bounds = win.getBounds()
win.setBounds({
x: dragStartX + offset.offsetX,
y: dragStartY + offset.offsetY,
width: bounds.width,
height: bounds.height
})
})
ipcMain.on('drag-end', () => {
// 清理状态
dragStartX = 0
dragStartY = 0
})使用 requestAnimationFrame 优化
javascript
// renderer.js - 优化版本
class SmoothDragger {
constructor(selector) {
this.element = document.querySelector(selector)
this.isDragging = false
this.offsetX = 0
this.offsetY = 0
this.rafId = null
this.init()
}
init() {
this.element.addEventListener('mousedown', this.onMouseDown.bind(this))
document.addEventListener('mouseup', this.onMouseUp.bind(this))
}
onMouseDown(e) {
if (e.button !== 0) return
this.isDragging = true
this.startX = e.clientX
this.startY = e.clientY
document.addEventListener('mousemove', this.onMouseMove.bind(this))
// 启动动画帧更新
this.updateLoop()
e.preventDefault()
}
onMouseMove(e) {
// 只更新偏移量,不立即发送 IPC
this.offsetX = e.clientX - this.startX
this.offsetY = e.clientY - this.startY
}
onMouseUp() {
this.isDragging = false
document.removeEventListener('mousemove', this.onMouseMove.bind(this))
if (this.rafId) {
cancelAnimationFrame(this.rafId)
this.rafId = null
}
window.dragAPI.endDrag()
}
updateLoop() {
if (!this.isDragging) return
// 节流:每帧只发送一次 IPC
window.dragAPI.updateDrag({
offsetX: this.offsetX,
offsetY: this.offsetY
})
this.rafId = requestAnimationFrame(() => this.updateLoop())
}
}优缺点
| 优点 | 缺点 |
|---|---|
| 完全可控 | 实现复杂 |
| 跨平台兼容 | IPC 通信有延迟 |
| 支持复杂逻辑 | 可能出现卡顿 |
| 可添加边界检测 | 需要处理事件丢失 |
窗口缩放实现
八方向缩放架构
plaintext
↖ ↑ ↗
┌─────────────┐
← │ │ →
│ 窗口内容 │
│ │
└─────────────┘
↙ ↓ ↘
缩放手柄位置:
top-left top top-right
left right
bottom-left bottom bottom-right完整实现
html
<!-- HTML 结构 -->
<div class="window-container">
<div class="titlebar">...</div>
<div class="content">...</div>
<!-- 缩放手柄 -->
<div class="resize-handle top"></div>
<div class="resize-handle bottom"></div>
<div class="resize-handle left"></div>
<div class="resize-handle right"></div>
<div class="resize-handle top-left"></div>
<div class="resize-handle top-right"></div>
<div class="resize-handle bottom-left"></div>
<div class="resize-handle bottom-right"></div>
</div>css
/* resize.css */
.resize-handle {
position: absolute;
z-index: 1000;
}
/* 边缘手柄 */
.resize-handle.top {
top: 0;
left: 10px;
right: 10px;
height: 4px;
cursor: n-resize;
}
.resize-handle.bottom {
bottom: 0;
left: 10px;
right: 10px;
height: 4px;
cursor: s-resize;
}
.resize-handle.left {
left: 0;
top: 10px;
bottom: 10px;
width: 4px;
cursor: w-resize;
}
.resize-handle.right {
right: 0;
top: 10px;
bottom: 10px;
width: 4px;
cursor: e-resize;
}
/* 角落手柄 */
.resize-handle.top-left {
top: 0;
left: 0;
width: 10px;
height: 10px;
cursor: nw-resize;
}
.resize-handle.top-right {
top: 0;
right: 0;
width: 10px;
height: 10px;
cursor: ne-resize;
}
.resize-handle.bottom-left {
bottom: 0;
left: 0;
width: 10px;
height: 10px;
cursor: sw-resize;
}
.resize-handle.bottom-right {
bottom: 0;
right: 0;
width: 10px;
height: 10px;
cursor: se-resize;
}
/* 可选:悬停效果 */
.resize-handle:hover {
background: rgba(255, 255, 255, 0.1);
}javascript
// resize.js
const RESIZE_DIRECTIONS = {
'top': { top: true, bottom: false, left: false, right: false },
'bottom': { top: false, bottom: true, left: false, right: false },
'left': { top: false, bottom: false, left: true, right: false },
'right': { top: false, bottom: false, left: false, right: true },
'top-left': { top: true, bottom: false, left: true, right: false },
'top-right': { top: true, bottom: false, left: false, right: true },
'bottom-left': { top: false, bottom: true, left: true, right: false },
'bottom-right': { top: false, bottom: true, left: false, right: true }
}
class ResizeManager {
constructor(options = {}) {
this.minWidth = options.minWidth || 400
this.minHeight = options.minHeight || 300
this.maxWidth = options.maxWidth || 1920
this.maxHeight = options.maxHeight || 1080
this.isResizing = false
this.direction = null
this.startBounds = null
this.startMouse = null
this.init()
}
init() {
// 绑定所有缩放手柄
document.querySelectorAll('.resize-handle').forEach(handle => {
handle.addEventListener('mousedown', this.onMouseDown.bind(this))
})
document.addEventListener('mousemove', this.onMouseMove.bind(this))
document.addEventListener('mouseup', this.onMouseUp.bind(this))
}
onMouseDown(e) {
if (e.button !== 0) return
this.isResizing = true
this.direction = e.target.classList[1] // 获取方向
// 获取当前窗口状态
window.resizeAPI.getBounds().then(bounds => {
this.startBounds = bounds
})
this.startMouse = {
x: e.screenX,
y: e.screenY
}
e.preventDefault()
}
onMouseMove(e) {
if (!this.isResizing) return
const deltaX = e.screenX - this.startMouse.x
const deltaY = e.screenY - this.startMouse.y
const newBounds = this.calculateNewBounds(deltaX, deltaY)
window.resizeAPI.setBounds(newBounds)
}
onMouseUp() {
this.isResizing = false
this.direction = null
this.startBounds = null
this.startMouse = null
}
calculateNewBounds(deltaX, deltaY) {
const dir = RESIZE_DIRECTIONS[this.direction]
const bounds = { ...this.startBounds }
// 根据方向调整边界
if (dir.top) {
const newHeight = bounds.height - deltaY
if (newHeight >= this.minHeight && newHeight <= this.maxHeight) {
bounds.y = bounds.y + deltaY
bounds.height = newHeight
}
}
if (dir.bottom) {
const newHeight = bounds.height + deltaY
if (newHeight >= this.minHeight && newHeight <= this.maxHeight) {
bounds.height = newHeight
}
}
if (dir.left) {
const newWidth = bounds.width - deltaX
if (newWidth >= this.minWidth && newWidth <= this.maxWidth) {
bounds.x = bounds.x + deltaX
bounds.width = newWidth
}
}
if (dir.right) {
const newWidth = bounds.width + deltaX
if (newWidth >= this.minWidth && newWidth <= this.maxWidth) {
bounds.width = newWidth
}
}
return bounds
}
}
// 初始化
new ResizeManager({
minWidth: 600,
minHeight: 400,
maxWidth: 1920,
maxHeight: 1080
})javascript
// preload.js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('resizeAPI', {
getBounds: () => ipcRenderer.invoke('get-bounds'),
setBounds: (bounds) => ipcRenderer.send('set-bounds', bounds)
})javascript
// main.js
ipcMain.handle('get-bounds', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
return win ? win.getBounds() : null
})
ipcMain.on('set-bounds', (event, bounds) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (win) {
win.setBounds(bounds)
}
})简化版:右下角缩放
如果只需要右下角缩放手柄:
html
<div class="window-container">
<!-- 内容 -->
<div class="resize-handle-se">
<svg viewBox="0 0 10 10">
<path d="M9 1L1 9M9 5L5 9M9 9L9 9" stroke="currentColor" stroke-width="1"/>
</svg>
</div>
</div>css
.resize-handle-se {
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
cursor: se-resize;
color: rgba(255, 255, 255, 0.5);
-webkit-app-region: no-drag;
}
.resize-handle-se svg {
width: 100%;
height: 100%;
}最佳实践
拖拽方案选择
javascript
// 根据需求选择合适的方案
function setupDrag(options) {
const {
element,
hasInteractiveElements = false,
needFullWindowDrag = false,
platform = process.platform
} = options
// 场景 1:简单标题栏拖拽
if (!hasInteractiveElements && !needFullWindowDrag) {
element.style.webkitAppRegion = 'drag'
return
}
// 场景 2:标题栏有交互元素
if (!needFullWindowDrag) {
element.style.webkitAppRegion = 'drag'
element.querySelectorAll('button, input, a').forEach(el => {
el.style.webkitAppRegion = 'no-drag'
})
return
}
// 场景 3:全窗口拖拽(非 Linux)
if (needFullWindowDrag && platform !== 'linux') {
import('electron-drag-latest').then(drag => {
drag(element)
})
return
}
// 场景 4:全窗口拖拽(Linux)或自定义需求
setupCustomDrag(element)
}性能优化
javascript
// 1. 节流 IPC 通信
function throttle(fn, delay) {
let lastCall = 0
return function(...args) {
const now = Date.now()
if (now - lastCall >= delay) {
lastCall = now
fn.apply(this, args)
}
}
}
const throttledUpdate = throttle((offset) => {
window.dragAPI.updateDrag(offset)
}, 16) // ~60fps
// 2. 使用 requestAnimationFrame
let rafId = null
function scheduleUpdate(offset) {
if (rafId) return
rafId = requestAnimationFrame(() => {
window.dragAPI.updateDrag(offset)
rafId = null
})
}
// 3. 批量更新边界
class BoundsBatcher {
constructor() {
this.pendingBounds = null
this.rafId = null
}
update(bounds) {
this.pendingBounds = bounds
if (!this.rafId) {
this.rafId = requestAnimationFrame(() => {
window.resizeAPI.setBounds(this.pendingBounds)
this.pendingBounds = null
this.rafId = null
})
}
}
}边界检测
javascript
// 防止窗口拖出屏幕
function constrainToBounds(bounds, displayBounds) {
return {
x: Math.max(displayBounds.x, Math.min(bounds.x, displayBounds.x + displayBounds.width - bounds.width)),
y: Math.max(displayBounds.y, Math.min(bounds.y, displayBounds.y + displayBounds.height - bounds.height)),
width: bounds.width,
height: bounds.height
}
}
// 主进程中使用
ipcMain.on('drag-update', (event, offset) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
const { screen } = require('electron')
const display = screen.getDisplayMatching(win.getBounds())
const newBounds = {
x: dragStartX + offset.offsetX,
y: dragStartY + offset.offsetY,
width: win.getBounds().width,
height: win.getBounds().height
}
win.setBounds(constrainToBounds(newBounds, display.bounds))
})状态恢复
javascript
// 保存窗口状态
async function saveWindowState(win) {
const bounds = win.getBounds()
const state = {
bounds,
isMaximized: win.isMaximized(),
isFullScreen: win.isFullScreen()
}
// 保存到 electron-store 或文件
await window.storageAPI.set('windowState', state)
}
// 恢复窗口状态
async function restoreWindowState(win) {
const state = await window.storageAPI.get('windowState')
if (!state) return
if (!state.isMaximized && !state.isFullScreen) {
win.setBounds(state.bounds)
}
if (state.isMaximized) {
win.maximize()
}
if (state.isFullScreen) {
win.setFullScreen(true)
}
}常见问题解答
Q: 拖拽时窗口闪烁或卡顿怎么办?
A: 使用以下优化策略:
javascript
// 1. 使用 requestAnimationFrame 替代直接更新
let pendingUpdate = null
let rafId = null
function onMouseMove(e) {
pendingUpdate = { x: e.screenX, y: e.screenY }
if (!rafId) {
rafId = requestAnimationFrame(() => {
if (pendingUpdate) {
sendUpdate(pendingUpdate)
pendingUpdate = null
}
rafId = null
})
}
}
// 2. 在主进程中避免频繁调用 setBounds
let lastBounds = null
let updateTimer = null
function throttledSetBounds(win, bounds) {
lastBounds = bounds
if (!updateTimer) {
updateTimer = setTimeout(() => {
if (lastBounds) {
win.setBounds(lastBounds)
}
updateTimer = null
}, 16)
}
}Q: 如何防止鼠标快速移动时拖拽失效?
A: 使用 setCapture 或在 document 上监听事件:
javascript
class RobustDragger {
onMouseDown(e) {
this.isDragging = true
// 关键:在 document 上监听,而非元素本身
document.addEventListener('mousemove', this.onMouseMove, true)
document.addEventListener('mouseup', this.onMouseUp, true)
// 可选:捕获鼠标
if (e.target.setCapture) {
e.target.setCapture()
}
}
onMouseUp() {
this.isDragging = false
// 移除监听
document.removeEventListener('mousemove', this.onMouseMove, true)
document.removeEventListener('mouseup', this.onMouseUp, true)
}
}Q: 缩放时如何保持窗口在屏幕内?
A: 在计算新边界时添加约束:
javascript
function calculateConstrainedBounds(deltaX, deltaY, display) {
const workArea = display.workArea
let bounds = { ...this.startBounds }
// 计算新边界
// ...
// 约束在屏幕内
bounds.x = Math.max(workArea.x, Math.min(bounds.x, workArea.x + workArea.width - bounds.width))
bounds.y = Math.max(workArea.y, Math.min(bounds.y, workArea.y + workArea.height - bounds.height))
return bounds
}Q: 如何实现 macOS 风格的双击标题栏最大化?
A:
javascript
// renderer.js
document.querySelector('.titlebar').addEventListener('dblclick', (e) => {
// 确保不是点击按钮
if (e.target.closest('button')) return
window.windowControls.toggleMaximize()
})
// main.js
ipcMain.on('toggle-maximize', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (win) {
if (win.isMaximized()) {
win.unmaximize()
} else {
win.maximize()
}
}
})Q: 如何处理多显示器的窗口拖拽?
A: 允许窗口跨显示器,但需正确获取显示器信息:
javascript
// 获取鼠标所在显示器
const { screen } = require('electron')
ipcMain.on('drag-update', (event, data) => {
const win = BrowserWindow.fromWebContents(event.sender)
const point = screen.getCursorScreenPoint()
const display = screen.getDisplayNearestPoint(point)
// 可以根据显示器调整窗口行为
// 例如:限制窗口大小在当前显示器范围内
})Q: CSS 方案拖拽时如何避免选中文字?
A:
css
.titlebar {
-webkit-app-region: drag;
user-select: none; /* 防止选中文本 */
-webkit-user-select: none;
}
/* 或在拖拽时动态添加 */
body.dragging {
user-select: none;
}javascript
// 动态添加类
document.addEventListener('mousedown', (e) => {
if (e.target.closest('.titlebar')) {
document.body.classList.add('dragging')
}
})
document.addEventListener('mouseup', () => {
document.body.classList.remove('dragging')
})