Microsoft To Do 一鍵複製:標題/步驟/附註

從 Microsoft To Do 詳細面板擷取標題、所有步驟與附註為純文字一鍵複製,按鈕可拖曳(自動隱藏圖示)

// ==UserScript==
// @name         Microsoft To Do 一鍵複製:標題/步驟/附註
// @namespace    http://tampermonkey.net/
// @version      1.5.3
// @description  從 Microsoft To Do 詳細面板擷取標題、所有步驟與附註為純文字一鍵複製,按鈕可拖曳(自動隱藏圖示)
// @author       shanlan(gpt-5)
// @match        https://to-do.office.com/tasks/*
// @grant        none
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function() {
  'use strict';
  const $ = (sel, root = document) => root.querySelector(sel);
  const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
  const HOST_ID = 'tm-copy-root', POS_KEY = 'tm-copy-root-pos', DONE_KEY = 'tm-copy-done-mark';
  let hostEl, shadow, btn, toastEl, switchEl, doneMark = true;
  let wrapEl;

  function findDetailsRoot() {
    return document.getElementById('details') || $('.rightColumn #details') || $('.details') || null;
  }
  function getCleanTextFromQLEditor(editor) {
    const blocks = Array.from(editor.children), out = [];
    blocks.forEach(block => {
      if (block.childNodes.length === 1 && (block.childNodes[0].nodeName === 'BR' || (block.childNodes[0].nodeType === 3 && block.childNodes[0].textContent.trim() === ''))) {
        out.push('');
        return;
      }
      let line = Array.from(block.childNodes)
        .map(node => node.nodeType === 3 ? node.textContent : node.nodeType === 1 && node.tagName === 'A' ? node.href : '')
        .join('').replace(/\u00A0/g, ' ').trimEnd();
      out.push(line);
    });
    return out.join('\n');
  }
  function buildExportText(details) {
    const titleEl = details.querySelector('.editableContent-display[title]') || details.querySelector('.detailHeader .editableContent-display'),
          title = (titleEl?.getAttribute('title') || titleEl?.textContent || '').trim();
    const stepTextareas = Array.from(details.querySelectorAll('textarea[aria-label="步驟"]'));
    const steps = stepTextareas.map(textarea => {
      let row = textarea.closest('.ms-DetailsRow');
      let checked = row?.querySelector('[role="checkbox"]')?.getAttribute('aria-checked');
      let text = textarea.value != null ? textarea.value : textarea.textContent || '';
      text = text.replace(/\r\n/g, '\n').trimEnd();
      if (checked === "true" && doneMark) {
        text = '[已完成]' + text;
      }
      return text;
    }).filter(s => s.length > 0);
    const noteEl = details.querySelector('.detailNote .ql-editor') || details.querySelector('.ql-editor[contenteditable="true"]');
    let note = noteEl ? getCleanTextFromQLEditor(noteEl) : '';
    const parts = [];
    if (title) parts.push(title);
    if (title && steps.length) parts.push('');
    if (steps.length) parts.push(steps.join('\n'));
    let out = parts.join('\n');
    if (note) out += (out ? '\n\n' : '') + note;
    return out.trim();
  }
  async function copyToClipboard(text) {
    if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
    const ta = document.createElement('textarea');
    ta.value = text;
    ta.setAttribute('readonly', '');
    Object.assign(ta.style, {position: 'fixed', top: '-9999px', left: '-9999px'});
    document.body.appendChild(ta);
    ta.select();
    try { document.execCommand('copy'); } finally { document.body.removeChild(ta); }
  }
  function applySavedPos(host) {
    const s = localStorage.getItem(POS_KEY);
    if (!s) return;
    try {
      const { left, top } = JSON.parse(s) || {};
      if (Number.isFinite(left) && Number.isFinite(top)) {
        host.style.removeProperty('right');
        host.style.removeProperty('bottom');
        host.style.setProperty('left', left + 'px', 'important');
        host.style.setProperty('top', top + 'px', 'important');
      }
    } catch {}
  }
  function ensureUI() {
    let host = document.getElementById(HOST_ID);
    if (!host) {
      host = document.createElement('div');
      host.id = HOST_ID;
      document.body.appendChild(host);
      host.style.setProperty('position', 'fixed', 'important');
      host.style.setProperty('right', '20px', 'important');
      host.style.setProperty('bottom', '20px', 'important');
      host.style.setProperty('z-index', '2147483647', 'important');
      host.style.setProperty('display', 'inline-block', 'important');
      host.style.setProperty('pointer-events', 'none', 'important');
      host.style.setProperty('width', 'auto', 'important');
      host.style.setProperty('height', 'auto', 'important');
      hostEl = host;
      shadow = host.attachShadow({ mode: 'open' });
      const style = document.createElement('style');
      style.textContent = `
        :host{ all: initial; }
        #wrap { all: initial; position: relative; display: inline-flex; align-items: center; pointer-events: auto; touch-action: none; gap: 5px; }
        #copyBtn{
          all: unset;
          display: inline-flex;
          align-items: center;
          gap: 6px;
          padding: 8px 12px;
          background: #0078d4;
          color: #fff;
          border-radius: 8px;
          box-shadow: 0 2px 8px rgba(0,0,0,.2);
          cursor: grab;
          font: 600 13px/1.2 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans TC",Arial,"Helvetica Neue",Helvetica,sans-serif;
          user-select: none;
          white-space: nowrap;
          max-width: none;
        }
        #copyBtn:hover{ background:#106ebe; }
        #copyBtn:active{ transform: translateY(1px); }
        #copyBtn.dragging{ cursor: grabbing !important; transform: none; }
        #switch {
          all: unset;
          display: inline-flex;
          align-items: center;
          cursor: pointer;
          user-select: none;
          margin-left: 2px;
          position: relative;
        }
        #switch-toggle {
          width: 32px; height: 18px; border-radius: 9px;
          background: #ccc;
          position: relative;
          transition: background .2s;
          margin-right: 3px;
        }
        #switch-toggle[data-on="1"] { background: #0078d4; }
        #switch-knob {
          position: absolute;
          top: 2px; left: 2px;
          width: 14px; height: 14px;
          border-radius: 50%;
          background: #fff;
          box-shadow: 0 1px 4px rgba(0,0,0,.15);
          transition: left .2s;
        }
        #switch-toggle[data-on="1"] #switch-knob { left: 16px; }
        #switch-tooltip {
          display: none;
          position: absolute;
          left: 50%; top: -200%;
          transform: translateX(-50%);
          background: #222;
          color: #fff;
          padding: 6px 12px;
          border-radius: 6px;
          font-size: 12px;
          white-space: nowrap;
          z-index: 99;
          pointer-events: none;
          opacity: 0;
          transition: opacity .18s;
        }
        #switch:hover #switch-tooltip, #switch:focus-within #switch-tooltip {
          display: block;
          opacity: 1;
        }
        #toast{
          all: unset;
          position: absolute;
          right: 0;
          bottom: 34px;
          background: rgba(0,0,0,.85);
          color: #fff;
          padding: 8px 10px;
          border-radius: 6px;
          font: 500 12px/1.2 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans TC",Arial,"Helvetica Neue",Helvetica,sans-serif;
          opacity: 0;
          transition: opacity .2s ease;
          pointer-events: none;
        }
      `;
      const wrap = document.createElement('div');
      wrap.id = 'wrap';
      wrapEl = wrap;
      btn = document.createElement('button');
      btn.id = 'copyBtn';
      btn.innerHTML = '📋複製';
      switchEl = document.createElement('span');
      switchEl.id = 'switch';
      switchEl.innerHTML = `
        <span id="switch-toggle" data-on="1">
          <span id="switch-knob"></span>
        </span>
        <span id="switch-tooltip">標註[已完成]</span>
      `;
      toastEl = document.createElement('div');
      toastEl.id = 'toast';
      wrap.appendChild(btn);
      wrap.appendChild(switchEl);
      wrap.appendChild(toastEl);
      shadow.appendChild(style);
      shadow.appendChild(wrap);
      applySavedPos(hostEl);
      setupDragAndClick();
      setupSwitch();
    }
  }
  function setupSwitch() {
    if (!switchEl) return;
    doneMark = localStorage.getItem(DONE_KEY) !== '0';
    updateSwitchUI();
    switchEl.addEventListener('click', e => {
      doneMark = !doneMark;
      localStorage.setItem(DONE_KEY, doneMark ? '1' : '0');
      updateSwitchUI();
      e.stopPropagation();
    });
  }
  function updateSwitchUI() {
    const toggle = switchEl.querySelector('#switch-toggle');
    if (doneMark) {
      toggle.setAttribute('data-on', '1');
    } else {
      toggle.setAttribute('data-on', '0');
    }
  }
  function showToast(msg) {
    if (!toastEl) return;
    toastEl.textContent = msg;
    toastEl.style.opacity = '1';
    clearTimeout(showToast._t);
    showToast._t = setTimeout(() => { toastEl.style.opacity = '0'; }, 1600);
  }
  async function runCopy() {
    const details = findDetailsRoot();
    if (!details) { showToast('未找到 To Do 詳細面板'); return; }
    const text = buildExportText(details);
    if (!text) { showToast('沒有可複製的內容'); return; }
    try {
      await copyToClipboard(text);
      const lines = text.split('\n').length;
      showToast(`已複製 ${lines} 行`);
      console.log('[一鍵複製輸出]\n' + text);
    } catch (e) {
      console.error('複製失敗:', e);
      showToast('複製失敗,請檢查主控台');
    }
  }
  let dragging = false, dragMoved = false, dragOffsetX = 0, dragOffsetY = 0, pointerId = null;
  const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
  function setupDragAndClick() {
    if (!btn) return;
    btn.addEventListener('click', e => {
      if (dragMoved) { e.preventDefault(); e.stopImmediatePropagation(); dragMoved = false; return; }
      runCopy();
    });
    btn.addEventListener('pointerdown', onPointerDown);
  }
  function onPointerDown(e) {
    if (e.pointerType === 'mouse' && e.button !== 0) return;
    pointerId = e.pointerId != null ? e.pointerId : null;
    dragging = true; dragMoved = false;
    const rect = hostEl.getBoundingClientRect();
    hostEl.style.removeProperty('right');
    hostEl.style.removeProperty('bottom');
    hostEl.style.setProperty('left', rect.left + 'px', 'important');
    hostEl.style.setProperty('top', rect.top + 'px', 'important');
    dragOffsetX = e.clientX - rect.left;
    dragOffsetY = e.clientY - rect.top;
    try { btn.setPointerCapture(pointerId); } catch {}
    btn.classList.add('dragging');
    document.addEventListener('pointermove', onPointerMove);
    document.addEventListener('pointerup', onPointerUp, { once: false });
    document.addEventListener('pointercancel', onPointerUp, { once: false });
  }
  function onPointerMove(e) {
    if (!dragging) return;
    const w = hostEl.offsetWidth, h = hostEl.offsetHeight;
    let x = e.clientX - dragOffsetX, y = e.clientY - dragOffsetY, margin = 6;
    x = clamp(x, margin, Math.max(margin, window.innerWidth - w - margin));
    y = clamp(y, margin, Math.max(margin, window.innerHeight - h - margin));
    hostEl.style.setProperty('left', x + 'px', 'important');
    hostEl.style.setProperty('top', y + 'px', 'important');
    if (!dragMoved) { const dx = Math.abs(e.movementX || 0), dy = Math.abs(e.movementY || 0); if (dx + dy > 1) dragMoved = true; }
  }
  function onPointerUp() {
    if (!dragging) return;
    dragging = false;
    try { btn.releasePointerCapture(pointerId); } catch {}
    btn.classList.remove('dragging');
    document.removeEventListener('pointermove', onPointerMove);
    document.removeEventListener('pointerup', onPointerUp);
    document.removeEventListener('pointercancel', onPointerUp);
    const rect = hostEl.getBoundingClientRect();
    try { localStorage.setItem(POS_KEY, JSON.stringify({ left: rect.left, top: rect.top })); } catch {}
  }
  ensureUI();

  function updatePanelVisibility() {
    const details = findDetailsRoot();
    if (details && details.offsetWidth > 0 && details.offsetHeight > 0) {
      wrapEl.style.display = 'inline-flex';
    } else {
      wrapEl.style.display = 'none';
    }
  }
  setInterval(updatePanelVisibility, 100);

  document.addEventListener('keydown', e => {
    const key = (e.key || '').toLowerCase();
    if ((e.ctrlKey || e.metaKey) && e.shiftKey && key === 'c') { e.preventDefault(); runCopy(); }
  });
  window.addEventListener('resize', () => {
    if (!hostEl) return;
    const rect = hostEl.getBoundingClientRect(), w = hostEl.offsetWidth, h = hostEl.offsetHeight, margin = 6;
    let x = rect.left, y = rect.top;
    x = clamp(x, margin, Math.max(margin, window.innerWidth - w - margin));
    y = clamp(y, margin, Math.max(margin, window.innerHeight - h - margin));
    hostEl.style.setProperty('left', x + 'px', 'important');
    hostEl.style.setProperty('top', y + 'px', 'important');
    try { localStorage.setItem(POS_KEY, JSON.stringify({ left: x, top: y })); } catch {}
  });
})();