Copy As Markdown

Drag-select partial content; Ctrl+C copies Markdown with list markers/headings; also shows a copy button.按 Ctrl+C 会将其以 Markdown 形式复制(包含列表标记/标题);同时还会显示一个复制按钮。

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Copy As Markdown
// @namespace    https://github.com/baicai99 or https://www.zhengjiyuan.top
// @version      0.3.0
// @description  Drag-select partial content; Ctrl+C copies Markdown with list markers/headings; also shows a copy button.按 Ctrl+C 会将其以 Markdown 形式复制(包含列表标记/标题);同时还会显示一个复制按钮。
// @match        *://*/*
// @run-at       document-start
// @grant        none
// @license MIT
// ==/UserScript==


(function () {
  'use strict';

  // ===================== 配置 =====================
  const CONFIG = {
    enableButton: true,          // 是否显示“复制Markdown”按钮
    indentSpaces: 2,             // 嵌套列表每层缩进空格数(Markdown 常用 2 或 4)
    toast: true,                 // 是否显示角落提示
    toastMs: 700,
    // 什么时候接管 Ctrl+C:选区内命中 h1~h6 / li / ul/ol 才接管,否则走默认复制
    handleWhenMatch: true
  };

  // ===================== Toast =====================
  let toastEl = null;
  function showToast(msg) {
    if (!CONFIG.toast) return;
    if (!toastEl) {
      toastEl = document.createElement('div');
      Object.assign(toastEl.style, {
        position: 'fixed',
        zIndex: 2147483647,
        right: '12px',
        bottom: '12px',
        padding: '8px 10px',
        fontSize: '12px',
        borderRadius: '10px',
        background: 'rgba(30,30,30,0.92)',
        color: '#fff',
        boxShadow: '0 8px 22px rgba(0,0,0,0.28)',
        pointerEvents: 'none',
        opacity: '0',
        transition: 'opacity 120ms ease'
      });
      document.documentElement.appendChild(toastEl);
    }
    toastEl.textContent = msg;
    toastEl.style.opacity = '1';
    setTimeout(() => { if (toastEl) toastEl.style.opacity = '0'; }, CONFIG.toastMs);
  }

  // ===================== UI Button =====================
  const btn = document.createElement('button');
  btn.textContent = '复制Markdown';
  Object.assign(btn.style, {
    position: 'fixed',
    zIndex: 2147483647,
    display: 'none',
    padding: '6px 10px',
    fontSize: '12px',
    borderRadius: '10px',
    border: '1px solid rgba(0,0,0,0.15)',
    background: 'rgba(30,30,30,0.92)',
    color: '#fff',
    cursor: 'pointer',
    boxShadow: '0 6px 18px rgba(0,0,0,0.25)',
    userSelect: 'none'
  });

  let lastRange = null;
  let lastMouse = { x: 20, y: 20 };

  function mountBtn() {
    if (!CONFIG.enableButton) return;
    if (!btn.isConnected) document.documentElement.appendChild(btn);
  }

  function hideBtn() {
    btn.style.display = 'none';
  }

  function showBtn(range) {
    if (!CONFIG.enableButton) return;

    // 优先用选区 rect 定位;失败就用鼠标位置兜底
    let x = lastMouse.x + 12;
    let y = lastMouse.y + 12;

    try {
      const rects = range.getClientRects ? Array.from(range.getClientRects()) : [];
      // 选最后一个有效 rect
      const good = rects.reverse().find(r => r && r.width > 0 && r.height > 0) || null;
      const r = good || (range.getBoundingClientRect ? range.getBoundingClientRect() : null);
      if (r && r.width >= 0 && r.height >= 0 && isFinite(r.left) && isFinite(r.top)) {
        x = (good ? good.right : r.right) + 8;
        y = (good ? good.top : r.top) - 28;
      }
    } catch (_) {}

    // clamp 到视口
    x = Math.max(8, Math.min(window.innerWidth - 120, x));
    y = Math.max(8, Math.min(window.innerHeight - 40, y));

    btn.style.left = `${x}px`;
    btn.style.top = `${y}px`;
    btn.style.display = 'block';
  }

  // ===================== DOM Helper =====================
  function closestEl(node, selector) {
    const el = node && node.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement;
    return el ? el.closest(selector) : null;
  }
  function closestLi(node) { return closestEl(node, 'li'); }
  function closestList(node) { return closestEl(node, 'ol,ul'); }

  function intersectsNode(range, node) {
    if (typeof range.intersectsNode === 'function') return range.intersectsNode(node);
    const nr = document.createRange();
    nr.selectNodeContents(node);
    return !(
      range.compareBoundaryPoints(Range.END_TO_START, nr) <= 0 ||
      range.compareBoundaryPoints(Range.START_TO_END, nr) >= 0
    );
  }

  function intersectRanges(a, b) {
    const r = a.cloneRange();

    // start = max(start)
    if (r.compareBoundaryPoints(Range.START_TO_START, b) < 0) {
      r.setStart(b.startContainer, b.startOffset);
    }
    // end = min(end)
    if (r.compareBoundaryPoints(Range.END_TO_END, b) > 0) {
      r.setEnd(b.endContainer, b.endOffset);
    }
    if (r.collapsed) return null;
    return r;
  }

  function normalizeText(s) {
    return (s || '').replace(/\r/g, '').trim();
  }

  // ===================== List numbering =====================
  function getOlNumberForLi(li, ol) {
    const v = li.getAttribute('value');
    if (v && !Number.isNaN(parseInt(v, 10))) return parseInt(v, 10);

    const reversed = ol.hasAttribute('reversed');
    const items = Array.from(ol.children).filter(e => e.tagName === 'LI');
    const idx = items.indexOf(li);

    const startAttr = ol.getAttribute('start');
    let start = startAttr && !Number.isNaN(parseInt(startAttr, 10)) ? parseInt(startAttr, 10) : null;

    if (!reversed) {
      if (start == null) start = 1;
      return start + idx;
    } else {
      if (start == null) start = items.length;
      return start - idx;
    }
  }

  function getListDepth(li) {
    let depth = 0;
    for (let p = li.parentElement; p; p = p.parentElement) {
      if (p.tagName === 'OL' || p.tagName === 'UL') depth++;
    }
    return Math.max(0, depth - 1);
  }

  // 父 li 只取“自身正文”,不重复包含子列表正文
  function getLiOwnTextRange(li) {
    const r = document.createRange();
    r.selectNodeContents(li);
    const sub = li.querySelector(':scope > ol, :scope > ul');
    if (sub) r.setEndBefore(sub);
    return r;
  }

  // ===================== Markdown builder =====================
  function collectCandidates(range) {
    const root = (range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE)
      ? range.commonAncestorContainer
      : range.commonAncestorContainer.parentElement;

    if (!root) return [];

    // 候选:标题 / 列表项 / 段落(段落用于补全非列表内容)
    const selector = 'h1,h2,h3,h4,h5,h6,li,p,pre,blockquote';
    const nodes = [];

    if (root.matches && root.matches(selector)) nodes.push(root);
    if (root.querySelectorAll) {
      root.querySelectorAll(selector).forEach(n => nodes.push(n));
    }

    // 过滤:必须与选区相交
    const filtered = nodes.filter(n => intersectsNode(range, n));

    // 文档顺序排序
    filtered.sort((a, b) => {
      if (a === b) return 0;
      const pos = a.compareDocumentPosition(b);
      return (pos & Node.DOCUMENT_POSITION_FOLLOWING) ? -1 : 1;
    });

    // 去重
    return Array.from(new Set(filtered));
  }

  function shouldHandle(range) {
    // 快路径:起点/终点落在 li 或 heading 或 list 内,就认为需要接管
    const startHit = closestEl(range.startContainer, 'li,h1,h2,h3,h4,h5,h6,ol,ul');
    const endHit = closestEl(range.endContainer, 'li,h1,h2,h3,h4,h5,h6,ol,ul');
    if (startHit || endHit) return true;

    // 慢路径:在 commonAncestor 内找相交的结构节点
    const candidates = collectCandidates(range);
    return candidates.some(n => n.matches('li,h1,h2,h3,h4,h5,h6,ol,ul'));
  }

  function mdForHeading(h, range) {
    const level = parseInt(h.tagName.slice(1), 10);
    const r = document.createRange();
    r.selectNodeContents(h);
    const ir = intersectRanges(range, r);
    const text = normalizeText(ir ? ir.toString() : h.innerText);
    if (!text) return null;
    return `${'#'.repeat(level)} ${text}`;
  }

  function mdForLi(li, range) {
    const list = li.parentElement;
    if (!list || (list.tagName !== 'OL' && list.tagName !== 'UL')) return null;

    const depth = getListDepth(li);
    const indent = ' '.repeat(depth * CONFIG.indentSpaces);

    const own = getLiOwnTextRange(li);
    const ir = intersectRanges(range, own);
    const raw = normalizeText(ir ? ir.toString() : '');
    if (!raw) return null;

    const marker = (list.tagName === 'OL')
      ? `${getOlNumberForLi(li, list)}.`
      : '-';

    const lines = raw.split('\n');
    const first = `${indent}${marker} ${lines[0]}`.trimEnd();
    const pad = ' '.repeat(marker.length + 1);
    const rest = lines.slice(1).map(x => `${indent}${pad}${x}`.trimEnd());

    return [first, ...rest].join('\n');
  }

  function mdForParagraph(p, range) {
    // 如果段落在 li 内,交给 li 处理,避免重复
    if (p.closest('li')) return null;

    const r = document.createRange();
    r.selectNodeContents(p);
    const ir = intersectRanges(range, r);
    const text = normalizeText(ir ? ir.toString() : '');
    if (!text) return null;
    return text;
  }

  function mdForPre(pre, range) {
    // code block(可选增强)
    const r = document.createRange();
    r.selectNodeContents(pre);
    const ir = intersectRanges(range, r);
    const text = (ir ? ir.toString() : '').replace(/\r/g, '');
    if (!text.trim()) return null;
    return `\`\`\`\n${text.replace(/\n+$/,'')}\n\`\`\``;
  }

  function mdForBlockquote(bq, range) {
    if (bq.closest('li')) return null;
    const r = document.createRange();
    r.selectNodeContents(bq);
    const ir = intersectRanges(range, r);
    const text = normalizeText(ir ? ir.toString() : '');
    if (!text) return null;
    return text.split('\n').map(line => `> ${line}`).join('\n');
  }

  function buildMarkdownFromRange(range) {
    const candidates = collectCandidates(range);
    if (!candidates.length) return null;

    // 过滤嵌套:如果某节点在已选中的 LI / PRE / BLOCKQUOTE 内,就跳过(避免重复)
    const kept = [];
    for (const n of candidates) {
      const insideKept = kept.some(k => k.contains(n) && /^(LI|PRE|BLOCKQUOTE)$/.test(k.tagName));
      if (insideKept) continue;
      kept.push(n);
    }

    const chunks = [];
    for (const n of kept) {
      let md = null;

      if (/^H[1-6]$/.test(n.tagName)) md = mdForHeading(n, range);
      else if (n.tagName === 'LI') md = mdForLi(n, range);
      else if (n.tagName === 'P') md = mdForParagraph(n, range);
      else if (n.tagName === 'PRE') md = mdForPre(n, range);
      else if (n.tagName === 'BLOCKQUOTE') md = mdForBlockquote(n, range);

      if (md) chunks.push(md);
    }

    // 如果没命中结构节点,就回退为纯文本(但通常 shouldHandle 已经挡住)
    if (!chunks.length) {
      const fallback = normalizeText(range.toString());
      return fallback || null;
    }

    // 组装:列表项保持紧凑,其它块之间用空行分隔更像 Markdown
    const out = [];
    for (let i = 0; i < chunks.length; i++) {
      const cur = chunks[i];
      const prev = out[out.length - 1];

      const curIsList = /^\s*(-|\d+\.)\s+/.test(cur);
      const prevIsList = prev ? /^\s*(-|\d+\.)\s+/.test(prev) : false;

      if (i > 0 && !(curIsList && prevIsList)) out.push(''); // 空行
      out.push(cur);
    }

    return out.join('\n').trim();
  }

  // ===================== Copy intercept (Ctrl+C / 右键复制) =====================
  document.addEventListener('copy', (e) => {
    const sel = window.getSelection();
    if (!sel || sel.rangeCount === 0) return;

    const range = sel.getRangeAt(0);
    if (range.collapsed) return;

    if (CONFIG.handleWhenMatch && !shouldHandle(range)) return;

    const md = buildMarkdownFromRange(range);
    if (!md) return;

    // 同步接管剪贴板(不要写 text/html,避免富文本粘贴)
    e.preventDefault();
    e.stopImmediatePropagation();

    if (e.clipboardData) {
      e.clipboardData.setData('text/plain', md);
      try { e.clipboardData.setData('text/markdown', md); } catch (_) {}
    }

    showToast('已复制 Markdown');
  }, true);

  // ===================== Button show/hide =====================
  function updateButtonVisibility() {
    if (!CONFIG.enableButton) return;

    const sel = window.getSelection();
    if (!sel || sel.rangeCount === 0) { hideBtn(); return; }

    const range = sel.getRangeAt(0);
    if (range.collapsed) { hideBtn(); return; }

    if (CONFIG.handleWhenMatch && !shouldHandle(range)) { hideBtn(); return; }

    lastRange = range.cloneRange();
    showBtn(range);
  }

  // 记录鼠标位置兜底(解决长选区/复杂结构 rect 不可靠导致按钮不出现)
  document.addEventListener('mouseup', (ev) => {
    lastMouse = { x: ev.clientX, y: ev.clientY };
    // selection 更新可能晚一拍
    setTimeout(updateButtonVisibility, 0);
  }, true);

  document.addEventListener('selectionchange', () => {
    // selectionchange 频繁触发,稍微延迟合并
    setTimeout(updateButtonVisibility, 0);
  }, true);

  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') hideBtn();
  }, true);

  mountBtn();

  btn.addEventListener('click', async (e) => {
    e.preventDefault();
    e.stopPropagation();

    const sel = window.getSelection();
    const range = (sel && sel.rangeCount) ? sel.getRangeAt(0) : lastRange;
    if (!range || range.collapsed) { hideBtn(); return; }

    const md = buildMarkdownFromRange(range);
    if (!md) { hideBtn(); return; }

    // 按钮点击属于用户手势,可以用 async clipboard API
    try {
      await navigator.clipboard.writeText(md);
      btn.textContent = '已复制';
      showToast('已复制 Markdown');
    } catch (_) {
      // 兜底:触发一次 copy,让 copy handler 写入
      try {
        document.execCommand('copy');
        btn.textContent = '已复制';
      } catch (_) {
        btn.textContent = '复制失败';
      }
    } finally {
      setTimeout(() => { btn.textContent = '复制Markdown'; hideBtn(); }, 900);
    }
  }, true);

})();