/* popup.js - 一个可拖拽、可定制、支持亮暗主题、无遮罩层的弹窗库 */ (() => { class Popup { /** * @param {Object} options * @param {string} [options.className='popup'] 主类名,如 'popup' * @param {'light'|'dark'} [options.theme='light'] 主题 * @param {Object|number} [options.edgePadding=16] * - number: 四边统一内边距,限制弹窗离视窗边界的最小距离 * - object: { top, right, bottom, left } * @param {string|Node|Function} [options.content=''] 自定义内容:字符串、DOM 节点、或返回节点的函数 * @param {boolean} [options.center=true] 初始是否居中显示 * @param {number} [options.width] 初始宽度(px) * @param {number} [options.height] 初始高度(px) * @param {string} [options.title=''] 标题 * @param {boolean} [options.draggable=true] 是否可拖拽 */ constructor(options = {}) { this.opts = Object.assign( { className: 'popup', theme: 'light', edgePadding: 16, content: '', center: true, width: undefined, height: undefined, title: '', draggable: true, bodyPadding: '10px', }, options ); this._normalizePadding(); this._createDom(); this._applyInitialSize(); this._mount(); this._bind(); if (this.opts.center) this.center(); this.isMaxed = false; this.dialogPosition = {}; } _normalizePadding() { const ep = this.opts.edgePadding; if (typeof ep === 'number') { this.padding = { top: ep, right: ep, bottom: ep, left: ep }; } else { this.padding = { top: ep.top ?? 16, right: ep.right ?? 16, bottom: ep.bottom ?? 16, left: ep.left ?? 16, }; } } _createDom() { const cls = this.opts.className; // 容器 this.el = document.createElement('div'); this.el.className = `${cls} ${cls}-${this.opts.theme === 'dark' ? 'dark' : 'light'}`; this.el.setAttribute('role', 'dialog'); this.el.setAttribute('aria-modal', 'false'); this.el.style.position = 'fixed'; this.el.style.inset = 'auto'; // 清空默认 this.el.style.zIndex = 9999; // 样式注入(仅首次按类名注入一次) const STYLE_MARK = `__${cls}_style__`; if (!document[STYLE_MARK]) { const style = document.createElement('style'); style.textContent = this._buildStyle(cls); document.head.appendChild(style); document[STYLE_MARK] = true; } // 头部 this.header = document.createElement('div'); this.header.className = `${cls}__header`; this.headerTitle = document.createElement('div'); this.headerTitle.className = `${cls}__title`; this.headerTitle.textContent = this.opts.title || ''; this.closeBtn = document.createElement('button'); this.closeBtn.className = `${cls}__close`; this.closeBtn.setAttribute('aria-label', 'Close'); this.closeBtn.innerHTML = '×'; this.header.appendChild(this.headerTitle); this.header.appendChild(this.closeBtn); // 内容 this.body = document.createElement('div'); this.body.className = `${cls}__body`; this.setContent(this.opts.content); this.el.appendChild(this.header); this.el.appendChild(this.body); } _buildStyle(cls) { // 使用 CSS 变量以便用户覆盖 return ` .${cls} { box-sizing: border-box; min-width: 240px; border-radius: 12px; box-shadow: 0 8px 28px rgba(0,0,0,0.2); overflow: hidden; border: 1px solid var(--${cls}-border, rgba(0,0,0,0.1)); background: var(--${cls}-bg, #fff); color: var(--${cls}-fg, #1f2328); } .${cls}-dark { --${cls}-bg: #0d1117; --${cls}-fg: #e6edf3; --${cls}-muted: #9da7b3; --${cls}-border: rgba(255,255,255,0.12); --${cls}-close-hover-bg: rgba(255,255,255,0.12); } .${cls}-light { --${cls}-bg: #ffffff; --${cls}-fg: #1f2328; --${cls}-muted: #6e7781; --${cls}-border: rgba(0,0,0,0.12); --${cls}-close-hover-bg: rgba(127,127,127,0.12); } .${cls}__header { cursor: move; user-select: none; display: flex; align-items: center; justify-content: space-between; padding: 6px 10px; background: transparent; border-bottom: 1px solid var(--${cls}-border, rgba(0,0,0,0.08)); } .${cls}__title { font-size: 14px; font-weight: 600; color: var(--${cls}-fg); } .${cls}__close { all: unset; cursor: pointer; font-size: 20px; line-height: 1; padding: 2px 6px; border-radius: 8px; color: var(--${cls}-muted); } .${cls}__close:hover { background: var(--${cls}-close-hover-bg); } .${cls}__body { padding: ${this.opts.bodyPadding}; overflow: auto; } .${cls} * { box-sizing: border-box; } `; } _applyInitialSize() { if (this.opts.width) this.el.style.width = this.opts.width + 'px'; if (this.opts.height) this.el.style.height = this.opts.height + 'px'; } _mount() { document.body.appendChild(this.el); this._ensureWithinBounds(); } _bind() { this._onCloseClick = () => this.close(); this.closeBtn.addEventListener('click', this._onCloseClick); if (this.opts.draggable) { this._onPointerDown = this._startDrag.bind(this); this.header.addEventListener('pointerdown', this._onPointerDown); } this._onResize = () => this._ensureWithinBounds(); window.addEventListener('resize', this._onResize); this._listenMaximize(); } _listenMaximize() { this._onMaximize = (e) => { const dialog = this.el; if (this.dialogPosition.isMaxed) { // 还原 dialog.style.top = this.dialogPosition.top; dialog.style.left = this.dialogPosition.left; dialog.style.width = this.dialogPosition.width; dialog.style.height = this.dialogPosition.height; dialog.style.maxHeight = this.dialogPosition.maxHeight; dialog.style.borderRadius = this.dialogPosition.borderRadius; this.dialogPosition = {}; // 清空状态 this.opts.onMaximizeRestore?.(e); } else { // 最大化 // 保存当前位置和尺寸 const rect = getComputedStyle(dialog); this.dialogPosition = { top: dialog.style.top || rect.top, left: dialog.style.left || rect.left, width: dialog.style.width || rect.width, height: dialog.style.height || rect.height, maxHeight: dialog.style.maxHeight || rect.maxHeight, borderRadius: dialog.style.borderRadius || rect.borderRadius, isMaxed: true }; // 根据 edgePadding 计算最大化尺寸 const vw = document.documentElement.clientWidth; const vh = document.documentElement.clientHeight; const top = this.opts.maximizeEdgePadding.top ?? this.padding.top; const left = this.opts.maximizeEdgePadding.left ?? this.padding.left; const width = vw - left - (this.opts.maximizeEdgePadding.right ?? this.padding.right); const height = vh - top - (this.opts.maximizeEdgePadding.bottom ?? this.padding.bottom); // 最大化 dialog.style.top = top + 'px'; dialog.style.left = left + 'px'; dialog.style.width = width + 'px'; dialog.style.height = height + 'px'; dialog.style.maxHeight = height + 'px'; dialog.style.borderRadius = '0'; // 最大化时移除圆角 this.opts.onMaximize?.(e); } }; this.header.addEventListener('dblclick', this._onMaximize); } setContent(content) { // 清空 this.body.innerHTML = ''; let node = null; if (typeof content === 'function') { node = content(); } else if (content instanceof Node) { node = content; } else if (typeof content === 'string') { const div = document.createElement('div'); div.innerHTML = content; node = div; } if (node) this.body.appendChild(node); } setTitle(title = '') { this.headerTitle.textContent = title; } open() { this.el.style.display = 'block'; this._ensureWithinBounds(); } close() { this.el.style.display = 'none'; } destroy() { window.removeEventListener('resize', this._onResize); this.closeBtn.removeEventListener('click', this._onCloseClick); this.header.removeEventListener('dblclick', this._onMaximize); if (this.opts.draggable) { this.header.removeEventListener('pointerdown', this._onPointerDown); document.removeEventListener('pointermove', this._onPointerMove); document.removeEventListener('pointerup', this._onPointerUp); document.removeEventListener('pointercancel', this._onPointerUp); } this.el.remove(); } center() { const rect = this.el.getBoundingClientRect(); const vw = document.documentElement.clientWidth; const vh = document.documentElement.clientHeight; const left = Math.max(this.padding.left, Math.round((vw - rect.width) / 2)); const top = Math.max(this.padding.top, Math.round((vh - rect.height) / 2)); this._setPos(left, top); this._ensureWithinBounds(); } setTheme(theme) { const cls = this.opts.className; this.el.classList.remove(`${cls}-light`, `${cls}-dark`); this.el.classList.add(`${cls}-${theme === 'dark' ? 'dark' : 'light'}`); } getTheme() { return this.el.classList.contains(this.opts.className + '-dark') ? 'dark' : 'light'; } setEdgePadding(padding) { this.opts.edgePadding = padding; this._normalizePadding(); this._ensureWithinBounds(); } /* ========== 拖拽相关 ========== */ _startDrag(e) { // 如果点击的目标是关闭按钮或其内部元素,则不启动拖拽 if (e.target === this.closeBtn || this.closeBtn.contains(e.target)) { return; } // 只响应主指针 if (e.button !== 0) return; // 不立即阻止默认行为,记录起始位置 this.dragStartX = e.clientX; this.dragStartY = e.clientY; this.dragPending = true; const rect = this.el.getBoundingClientRect(); this.dragOffsetX = e.clientX - rect.left; this.dragOffsetY = e.clientY - rect.top; this._onPointerMove = this._dragMove.bind(this); this._onPointerUp = this._endDrag.bind(this); document.addEventListener('pointermove', this._onPointerMove); document.addEventListener('pointerup', this._onPointerUp); document.addEventListener('pointercancel', this._onPointerUp); } _dragMove(e) { if (this.dragPending) { const dx = Math.abs(e.clientX - this.dragStartX); const dy = Math.abs(e.clientY - this.dragStartY); if (dx > 5 || dy > 5) { e.preventDefault(); // 现在才阻止默认行为 this.dragging = true; this.dragPending = false; this.el.setPointerCapture?.(e.pointerId); } else { return; } } if (!this.dragging) return; const vw = document.documentElement.clientWidth; const vh = document.documentElement.clientHeight; const rect = this.el.getBoundingClientRect(); const w = rect.width; const h = rect.height; // 期望位置 let left = e.clientX - this.dragOffsetX; let top = e.clientY - this.dragOffsetY; // 计算边界(可配置与视窗的最小距离) const minLeft = this.padding.left; const maxLeft = vw - w - this.padding.right; const minTop = this.padding.top; const maxTop = vh - h - this.padding.bottom; // 限制在可拖动范围内 left = Math.min(Math.max(left, minLeft), Math.max(minLeft, maxLeft)); top = Math.min(Math.max(top, minTop), Math.max(minTop, maxTop)); this._setPos(left, top); } _endDrag(e) { this.dragging = false; this.dragPending = false; this.el.releasePointerCapture?.(e.pointerId); document.removeEventListener('pointermove', this._onPointerMove); document.removeEventListener('pointerup', this._onPointerUp); document.removeEventListener('pointercancel', this._onPointerUp); } _setPos(left, top) { this.el.style.left = left + 'px'; this.el.style.top = top + 'px'; } _ensureWithinBounds() { // 确保弹窗在可视范围内(考虑 edgePadding) const vw = document.documentElement.clientWidth; const vh = document.documentElement.clientHeight; const rect = this.el.getBoundingClientRect(); const w = rect.width; const h = rect.height; let left = rect.left; let top = rect.top; const minLeft = this.padding.left; const maxLeft = Math.max(minLeft, vw - w - this.padding.right); const minTop = this.padding.top; const maxTop = Math.max(minTop, vh - h - this.padding.bottom); if (left < minLeft) left = minLeft; if (left > maxLeft) left = maxLeft; if (top < minTop) top = minTop; if (top > maxTop) top = maxTop; this._setPos(left, top); } } // 暴露全局 window.Popup = Popup; })()