/* 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, }, options ); this._normalizePadding(); this._createDom(); this._applyInitialSize(); this._mount(); this._bind(); if (this.opts.center) this.center(); } _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; max-width: calc(100vw - 32px); max-height: calc(100vh - 32px); 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: 10px; 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); } 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); 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.target.closest(`.${this.opts.className}__close`)) { // return; // } // 只响应主指针 if (e.button !== 0) return; e.preventDefault(); this.dragging = true; this.el.setPointerCapture?.(e.pointerId); 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.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.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; })()