元素属性复制

右键复制网页元素内容(类名、文本、HTML、Markdown),支持下载为 Markdown,使用 Vue + Element Plus + Turndown 实现。Markdown下载功能目前只做了掘金、CSDN的兼容(有瑕疵),其余网站没特意试过。

// ==UserScript==
// @name         元素属性复制
// @namespace    http://tampermonkey.net/
// @version      0.0.1
// @description  右键复制网页元素内容(类名、文本、HTML、Markdown),支持下载为 Markdown,使用 Vue + Element Plus + Turndown 实现。Markdown下载功能目前只做了掘金、CSDN的兼容(有瑕疵),其余网站没特意试过。
// @author       石小石Orz
// @match        *://*/*
// @license      MIT
// @require      https://unpkg.com/vue@3/dist/vue.global.js
// @require      https://unpkg.com/turndown/dist/turndown.js
// @resource     ELEMENT_JS https://cdn.jsdelivr.net/npm/element-plus
// @resource     elementPlusCss https://cdn.jsdelivr.net/npm/element-plus/dist/index.css
// @grant        GM_addStyle
// @grant        GM_getResourceText
// @grant        GM_setClipboard
// @grant        GM_registerMenuCommand
// @grant        GM_download
// @grant        unsafeWindow
// @noframes
// ==/UserScript==

(function () {
  'use strict';

  // 添加 Element Plus 样式
  GM_addStyle(GM_getResourceText('elementPlusCss'));
  GM_addStyle(`
    .tm-hover-highlight {
      outline: 2px solid rgba(0, 123, 255, 0.7);
      background-color: rgba(0, 123, 255, 0.1) !important;
      border-radius: 4px;
      transition: all 0.2s ease;
      z-index: 9999;
    }
  `);

  // 加载 Vue 和 Element Plus
  window.Vue = unsafeWindow.Vue = Vue;
  const { createApp, ref, reactive } = Vue;
  const elementPlusJS = GM_getResourceText('ELEMENT_JS');
  eval(elementPlusJS);

  // 插入挂载点
  const container = document.createElement('div');
  container.id = 'copy-helper-app';
  document.body.appendChild(container);

  // Markdown 转换函数
  function htmlToMarkdown(el) {
    const turndownService = new TurndownService({
      headingStyle: 'atx',
      codeBlockStyle: 'fenced',
    });

    // 处理 <pre><code> 为代码块,过滤说明文字
    turndownService.addRule('code-block', {
      filter: 'pre',
      replacement: function (content) {
        const code = (content.match(/`{1,3}([\s\S]*?)`{1,3}/)?.[1] || content).trim();
        return '\n```\n' + code + '\n```\n';
      }
    });

    // 处理 <table>
    turndownService.addRule('table', {
      filter: 'table',
      replacement: function (_, node) {
        let markdown = '';
        const rows = Array.from(node.querySelectorAll('tr'));
        const extractText = (td) => td.textContent.trim().replace(/\|/g, '\\|');
        rows.forEach((row, i) => {
          const cells = Array.from(row.children).map(extractText);
          markdown += '| ' + cells.join(' | ') + ' |\n';
          if (i === 0) markdown += '| ' + cells.map(() => '---').join(' | ') + ' |\n';
        });
        return '\n' + markdown + '\n';
      }
    });

    return turndownService.turndown(el.innerHTML);
  }

  // Vue 应用
  const App = {
    setup() {
      const visible = ref(false);
      const buttons = reactive([]);
      const pos = reactive({ top: 0, left: 0 });

      const setButtonsFor = (el) => {
        buttons.length = 0;
        const className = el.className?.toString().trim();
        const text = el.innerText?.trim();
        const html = el.innerHTML?.trim();
        const fullHtml = el.outerHTML?.trim();

        if (className) buttons.push({ label: '复制类名', content: className });
        if (text) buttons.push({ label: '复制文本', content: text });
        if (html) buttons.push({ label: '复制网页', content: html, type: 'html' });
        if (fullHtml) buttons.push({ label: '复制HTML文本', content: fullHtml, type: 'text' });

        if (html) {
          const md = htmlToMarkdown(el);
          buttons.push({ label: '复制为Markdown', content: md, type: 'text' });
          buttons.push({ label: '下载为Markdown', content: md, type: 'markdown' });
        }
      };

      const copy = ({ content, type }) => {
        if (type === 'markdown') {
          const title = document.title.replace(/[\\/:*?"<>|]/g, '_');
          GM_download({
            url: 'data:text/markdown;charset=utf-8,' + encodeURIComponent(content),
            name: title + '.md',
            saveAs: true
          });
          ElementPlus.ElMessage.success('Markdown 已下载');
        } else {
          GM_setClipboard(content, type || 'text');
          ElementPlus.ElMessage.success('复制成功!');
        }

        visible.value = false;
        deactivate();
      };

      const updatePosition = (x, y) => {
        pos.top = y;
        pos.left = x;
      };

      return { visible, buttons, pos, copy, setButtonsFor, updatePosition };
    },
    template: `
      <div v-if="visible"
           :style="{
             position: 'absolute',
             top: pos.top + 'px',
             left: pos.left + 'px',
             zIndex: 99999,
             display: 'flex',
             flexDirection: 'column',
             alignItems: 'flex-start',
             gap: '5px',
             backgroundColor: '#f2f2f2',
             borderRadius: '5px',
             border: '1px solid #ccc',
             padding: '8px'
           }">
        <el-button
          v-for="btn in buttons"
          size="small"
          :type="btn.type === 'markdown' ? 'primary' : 'info'"
          style="min-width: 140px; margin: 0"
          @click="copy(btn)"
        >
          {{ btn.label }}
        </el-button>
      </div>
    `
  };

  const app = createApp(App);
  app.use(ElementPlus);
  const vm = app.mount('#copy-helper-app');

  let currentElement = null;
  let activated = false;

  const isValidElement = (el) => {
    if (!el || el.nodeType !== 1) return false;
    const rect = el.getBoundingClientRect();
    return !['html', 'body', 'script', 'style'].includes(el.tagName.toLowerCase()) && rect.width >= 30 && rect.height >= 15;
  };

  const findValidTarget = (el) => {
    while (el && el !== document.body) {
      if (isValidElement(el)) return el;
      el = el.parentElement;
    }
    return null;
  };

  const handleMouseMove = (e) => {
    if (!activated) return;
    let el = e.target;
    if (document.querySelector('#copy-helper-app')?.contains(el)) return;

    el = findValidTarget(el);
    if (!el) return;

    if (el !== currentElement) {
      currentElement?.classList.remove('tm-hover-highlight');
      vm.visible = false;
      currentElement = el;
      currentElement.classList.add('tm-hover-highlight');
    }
  };

  const handleContextMenu = (e) => {
    if (!activated) return;
    let el = e.target;
    if (document.querySelector('#copy-helper-app')?.contains(el)) return;

    el = findValidTarget(el);
    if (!el) {
      vm.visible = false;
      return;
    }

    if (el === currentElement) {
      e.preventDefault();
      vm.setButtonsFor(el);
      vm.updatePosition(e.pageX, e.pageY);
      vm.visible = true;
    } else {
      vm.visible = false;
    }
  };

  function activate() {
    if (activated) return;
    activated = true;
    document.addEventListener('mousemove', handleMouseMove, true);
    document.addEventListener('contextmenu', handleContextMenu, true);
    ElementPlus.ElMessage.info('元素复制脚本已启动,右键高亮区域试试!');
  }

  function deactivate() {
    if (!activated) return;
    activated = false;
    document.removeEventListener('mousemove', handleMouseMove, true);
    document.removeEventListener('contextmenu', handleContextMenu, true);
    currentElement?.classList.remove('tm-hover-highlight');
    currentElement = null;
    vm.visible = false;
  }

  GM_registerMenuCommand('启动元素复制脚本', activate);
})();