/*! * MenuBar.js - 可配置的横向工具栏库(复制/剪切/粘贴/编辑) * 基于 ScriptCat / Tampermonkey 环境,无额外依赖 * 使用 @require 引入后,通过 window.createMenuBar 调用 * * Public Domain - 自由使用、修改、分发 */ (function(global) { 'use strict'; // ---------- 默认功能实现 ---------- const defaultHandlers = { // 复制:尝试复制当前选区文本,或焦点输入框的全部内容 copy: async function() { const active = document.activeElement; let text = ''; // 如果有选中文本 const sel = window.getSelection(); if (sel && sel.toString().length > 0) { text = sel.toString(); } else if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) { // 输入框/文本域:选中全部内容再复制(更符合用户预期) active.select(); try { await navigator.clipboard.writeText(active.value); return true; } catch (e) { // fallback document.execCommand('copy'); return true; } } if (text) { try { await navigator.clipboard.writeText(text); } catch (e) { // 降级方案:使用临时元素 const tmp = document.createElement('textarea'); tmp.value = text; tmp.style.position = 'fixed'; tmp.style.opacity = '0'; document.body.appendChild(tmp); tmp.select(); document.execCommand('copy'); tmp.remove(); } return true; } return false; }, // 剪切:复制并删除选中内容(或清空焦点输入框) cut: async function() { const active = document.activeElement; let text = ''; let isInput = false; const sel = window.getSelection(); if (sel && sel.toString().length > 0) { text = sel.toString(); // 删除选中的文本(仅限可编辑区域或输入框) const range = sel.getRangeAt(0); const commonAncestor = range.commonAncestorContainer; const editable = commonAncestor.closest ? commonAncestor.closest('[contenteditable="true"], input, textarea') : null; if (editable) { if (editable.tagName === 'INPUT' || editable.tagName === 'TEXTAREA') { const start = editable.selectionStart; const end = editable.selectionEnd; const val = editable.value; editable.value = val.substring(0, start) + val.substring(end); // 触发 input 事件 editable.dispatchEvent(new Event('input', { bubbles: true })); } else if (editable.getAttribute('contenteditable') === 'true') { range.deleteContents(); } } } else if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) { text = active.value; isInput = true; active.value = ''; active.dispatchEvent(new Event('input', { bubbles: true })); } if (text) { try { await navigator.clipboard.writeText(text); } catch (e) { const tmp = document.createElement('textarea'); tmp.value = text; tmp.style.position = 'fixed'; tmp.style.opacity = '0'; document.body.appendChild(tmp); tmp.select(); document.execCommand('copy'); tmp.remove(); } return true; } return false; }, // 粘贴:将剪贴板内容插入到光标位置或替换选中文本 paste: async function() { let text = ''; try { text = await navigator.clipboard.readText(); } catch (e) { // 降级:从 input 元素粘贴(用户触发时浏览器会拦截) // 这里无法用 execCommand('paste'),需要用户手动 Ctrl+V // 但我们可以提示或者使用隐藏输入框方式,但体验较差,这里直接返回 false return false; } if (!text) return false; const active = document.activeElement; // 如果有选区且选区在可编辑区域 const sel = window.getSelection(); if (sel && sel.rangeCount > 0) { const range = sel.getRangeAt(0); const commonAncestor = range.commonAncestorContainer; const editable = commonAncestor.closest ? commonAncestor.closest('[contenteditable="true"], input, textarea') : null; if (editable) { if (editable.tagName === 'INPUT' || editable.tagName === 'TEXTAREA') { const start = editable.selectionStart; const end = editable.selectionEnd; const val = editable.value; editable.value = val.substring(0, start) + text + val.substring(end); // 移动光标到插入文本之后 const newPos = start + text.length; editable.selectionStart = editable.selectionEnd = newPos; editable.dispatchEvent(new Event('input', { bubbles: true })); } else if (editable.getAttribute('contenteditable') === 'true') { range.deleteContents(); const newNode = document.createTextNode(text); range.insertNode(newNode); // 将光标移动到插入文本之后 range.setStartAfter(newNode); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); } } else { // 非可编辑区域,只能尝试创建一个文本节点插入到选区位置(但可能破坏页面) // 更稳妥的是不做任何事,或者提示无法粘贴 return false; } } return true; }, // 编辑:切换当前页面的可编辑状态(或对焦点元素切换) edit: function() { const sel = window.getSelection(); let target = null; // 1. 如果有非空选区,则根据选区确定目标元素 if (sel && sel.rangeCount > 0 && !sel.isCollapsed) { const range = sel.getRangeAt(0); let container = range.commonAncestorContainer; // 如果共同祖先是文本节点,取其父元素 if (container.nodeType === Node.TEXT_NODE) { container = container.parentNode; } // 避免直接操作 body 或 document if (container === document.body || container === document.documentElement) { // 尝试向上查找最近的块级元素(如 p, div, li, td 等) let node = range.startContainer; while (node && node !== document.body && node !== document.documentElement) { if (node.nodeType === Node.ELEMENT_NODE) { target = node; break; } node = node.parentNode; } if (!target) target = document.body; } else { target = container; } } else { // 2. 无选区:使用焦点输入框或 body const active = document.activeElement; if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) { target = active; } else { target = document.body; } } // 3. 切换 contenteditable 状态 const isEditable = target.getAttribute('contenteditable') === 'true'; if (isEditable) { target.removeAttribute('contenteditable'); // 可选:移除标记属性 target.removeAttribute('data-menubar-edited'); } else { target.setAttribute('contenteditable', 'true'); target.setAttribute('data-menubar-edited', 'true'); target.focus(); // 4. 保持原有选区(如果存在且非空) if (sel && !sel.isCollapsed) { // 由于 focus 可能会清除选区,使用微任务恢复 const range = sel.getRangeAt(0); setTimeout(() => { sel.removeAllRanges(); sel.addRange(range); }, 0); } } return true; } }; // ---------- 工具栏渲染 ---------- function createMenuBar(options = {}) { const { buttons = ['copy', 'cut', 'paste', 'edit'], // 按钮列表,按顺序显示 container = document.body, // 挂载容器 position = 'fixed', // 定位方式 top = '20px', left = '20px', // 位置偏移 zIndex = 9999, buttonStyle = {}, // 额外按钮样式 barStyle = {}, // 额外菜单样式 handlers = {}, // 覆盖默认处理函数 onButtonClick = null, // 全局点击回调 (buttonName, result) } = options; // 合并处理函数 const mergedHandlers = { copy: handlers.copy || defaultHandlers.copy, cut: handlers.cut || defaultHandlers.cut, paste: handlers.paste || defaultHandlers.paste, edit: handlers.edit || defaultHandlers.edit, }; // 按钮显示名称与图标(纯文本,你也可以换成字体图标) const buttonMap = { copy: { label: '复制', title: '复制选中内容或输入框内容' }, cut: { label: '剪切', title: '剪切选中内容或清空输入框' }, paste:{ label: '粘贴', title: '在光标位置粘贴剪贴板内容' }, edit: { label: '编辑', title: '切换当前页面/元素的编辑模式' }, }; // 创建菜单容器 const bar = document.createElement('div'); bar.style.cssText = ` position: ${position}; top: ${top}; left: ${left}; z-index: ${zIndex}; background: #f5f5f5; border: 1px solid #ccc; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); display: flex; padding: 4px 6px; gap: 4px; font-family: sans-serif; user-select: none; ${Object.entries(barStyle).map(([k,v]) => `${k}:${v}`).join(';')} `; // 创建按钮 buttons.forEach(btnKey => { const btnInfo = buttonMap[btnKey]; if (!btnInfo) return; // 忽略无效按钮 const btn = document.createElement('button'); btn.textContent = btnInfo.label; btn.title = btnInfo.title; btn.style.cssText = ` background: white; border: 1px solid #ddd; border-radius: 4px; padding: 4px 12px; cursor: pointer; font-size: 14px; transition: background 0.15s; ${Object.entries(buttonStyle).map(([k,v]) => `${k}:${v}`).join(';')} `; // 悬停效果 btn.addEventListener('mouseenter', () => { btn.style.background = '#e9e9e9'; }); btn.addEventListener('mouseleave', () => { btn.style.background = 'white'; }); // 点击处理 btn.addEventListener('click', async (e) => { e.stopPropagation(); const handler = mergedHandlers[btnKey]; if (typeof handler === 'function') { try { const result = await handler(); if (onButtonClick) onButtonClick(btnKey, result); } catch (err) { console.error(`[MenuBar] 按钮 "${btnKey}" 执行出错:`, err); if (onButtonClick) onButtonClick(btnKey, false); } } else { console.warn(`[MenuBar] 未定义按钮 "${btnKey}" 的处理函数`); } }); bar.appendChild(btn); }); // 如果没有任何按钮,不添加 if (bar.children.length === 0) { console.warn('[MenuBar] 没有有效按钮,菜单未创建'); return null; } // 挂载到容器 container.appendChild(bar); return bar; } // 暴露全局 API global.createMenuBar = createMenuBar; })(typeof window !== 'undefined' ? window : this);