// ==UserScript==
// @name 文本网页自由复制-Markdown
// @namespace http://tampermonkey.net/
// @version 3.1.0
// @description 自由选择网页区域并复制为 Markdown 格式,按钮可拖动、折叠
// @author shenfangda (enhanced by Claude & community input)
// @match *://*/*
// @exclude https://accounts.google.com/*
// @exclude https://*.google.com/sorry/*
// @exclude https://mail.google.com/*
// @exclude /^https?:\/\/localhost[:/]/
// @exclude /^file:\/\//
// @grant GM_setClipboard
// @grant GM_setValue
// @grant GM_getValue
// @license MIT
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cdoovy93d3cudzMub3JnLzIwMDAvc3ZnIiB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9IiM0Q0FGNTAiIHdpZHRoPSI0OHB4IiBoZWlnaHQ9IjQ4cHgiPjxwYXRoIGQ9Ik0wIDBoMjR2MjRIMFYwekIiIGZpbGw9Im5vbmUiLz48cGF0aCBkPSJNMjEgM0M1LTEuMSAwLTIgLjkS MiAydiDE0YyAwIDEuMS45IDIg MiAyaDE0Yy AxLjEgMCAyLS45IDItMlY1YzAtMS4xLS45LTItMi0yem0tOSAyaDZ2MkgxMlY1em0wIDRoNnYySDEydjloLTJ2LTJIMTBWN2gydjJ6bS03IDRoMlY3SDVWMTFoMlY5em0wIDRoMnYySDV2LTJ6bTEyLTYuNWMyLjQ5IDAgNC41IDIuMDEgNC41IDQuNXM LTIuMDEgNC41LTQuNSA0LjUtNC41LTIuMDEtNC41LTQuNSAyLjAxLTQuNSA0LjUtNC41eiIvPjwvc3ZnPg==
// ==/UserScript==
(function () {
'use strict';
// --- Configuration ---
const BUTTON_TEXT_DEFAULT = '复制';
const BUTTON_TEXT_SELECTING_FREE = '自由选择 (ESC取消)';
const BUTTON_TEXT_SELECTING_DIV = '选择DIV (ESC取消)';
const BUTTON_TEXT_COPIED = '已复制!';
const BUTTON_TEXT_FAILED = '复制失败!';
const BUTTON_TOOLTIP_COLLAPSE = '折叠/展开';
const BUTTON_TOOLTIP_DRAG = '拖动';
const BUTTON_TOOLTIP_FREE_SELECT = '自由选择模式';
const BUTTON_TOOLTIP_DIV_SELECT = 'DIV选择模式';
const TEMP_MESSAGE_DURATION = 2000; // ms
const DEBUG = false; // Set to true for more verbose logging
const STORAGE_KEY_BUTTON_POS = 'markdown_copy_button_position';
const STORAGE_KEY_BUTTON_COLLAPSED = 'markdown_copy_button_collapsed';
// --- Logging ---
const log = (msg) => console.log(`[Markdown-Copy] ${msg}`);
const debugLog = (msg) => DEBUG && console.log(`[Markdown-Copy Debug] ${msg}`);
// --- State ---
let isSelecting = false;
let isDivMode = false;
let startX, startY;
let selectionBox = null;
let highlightedDiv = null;
let copyBtn = null;
let modeSwitchBtn = null; // New button for mode switching
let copyBtnContentSpan = null; // Span to hold text, allows icon
let modeIconSpan = null; // Span for the mode icon
let originalButtonText = BUTTON_TEXT_DEFAULT;
let messageTimeout = null;
// Dragging state
let isDragging = false;
let dragOffsetX, dragOffsetY;
// --- DOM Ready Check ---
function onDOMReady(callback) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', callback);
} else {
// DOMContentLoaded already fired or interactive/complete
callback();
}
}
// --- Main Initialization ---
function initScript() {
log(`Attempting init on ${window.location.href}`);
// Avoid running in frames or if body/head not present
if (window.self !== window.top) {
log('Script is running in an iframe, aborting.');
return;
}
if (!document.body || !document.head) {
log('Error: document.body or document.head not found. Retrying...');
setTimeout(initScript, 500); // Retry after a short delay
return;
}
log('DOM ready, initializing script.');
// Inject CSS
injectStyles();
// Create and add the button
if (!createButton()) return; // Stop if button creation fails
// Add core event listeners
setupEventListeners();
log('Initialization complete.');
}
// --- CSS Injection ---
function injectStyles() {
const STYLES = `
.markdown-copy-container {
position: fixed;
display: flex;
flex-direction: column;
gap: 5px;
z-index: 2147483647; /* Max z-index */
line-height: 1.4;
text-align: center;
font-family: sans-serif;
}
.markdown-copy-btn, .markdown-mode-switch-btn {
padding: 8px 14px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 13px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
transition: all 0.2s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
}
.markdown-copy-btn:hover, .markdown-mode-switch-btn:hover {
background-color: #45a049;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.25);
}
.markdown-mode-switch-btn {
background-color: #f0ad4e; /* A different color for the mode switch */
}
.markdown-mode-switch-btn:hover {
background-color: #ec971f;
}
.markdown-copy-btn.mc-copied { background-color: #3a8f40; }
.markdown-copy-btn.mc-failed { background-color: #c0392b; }
.markdown-copy-container.mc-collapsed .markdown-copy-btn {
width: 38px; /* Fixed width for collapsed state */
height: 38px; /* Fixed height for collapsed state */
padding: 0;
overflow: hidden;
}
.markdown-copy-container.mc-collapsed .markdown-copy-btn-text {
display: none; /* Hide text when collapsed */
}
.markdown-copy-container.mc-collapsed .markdown-copy-btn-icon {
margin: 0; /* Remove margin when only icon is visible */
}
.markdown-copy-btn-icon {
width: 20px;
height: 20px;
display: block; /* Ensure SVG is block level */
}
.markdown-copy-btn-icon svg {
fill: currentColor; /* Inherit color from button text */
width: 100%;
height: 100%;
vertical-align: middle;
}
.markdown-copy-selection-box {
position: absolute;
border: 2px dashed #4CAF50;
background-color: rgba(76, 175, 80, 0.1);
z-index: 2147483646; /* Max z-index - 1 */
pointer-events: none; /* Allow clicks to pass through */
box-sizing: border-box;
}
.markdown-copy-div-highlight {
outline: 2px solid #4CAF50 !important;
background-color: rgba(76, 175, 80, 0.1) !important;
box-shadow: inset 0 0 0 2px rgba(76, 175, 80, 0.5) !important;
transition: all 0.1s ease-in-out;
cursor: pointer;
}
`;
try {
const styleSheet = document.createElement('style');
styleSheet.id = 'markdown-copy-styles';
styleSheet.textContent = STYLES;
document.head.appendChild(styleSheet);
debugLog('Styles injected.');
} catch (error) {
log(`Error injecting styles: ${error.message}`);
}
}
// --- Button Creation ---
function createButton() {
if (document.getElementById('markdown-copy-btn-main')) {
log('Button already exists.');
copyBtn = document.getElementById('markdown-copy-btn-main'); // Ensure reference is set
copyBtnContentSpan = copyBtn.querySelector('.markdown-copy-btn-text');
return true;
}
try {
copyBtn = document.createElement('button');
copyBtn.id = 'markdown-copy-btn-main';
copyBtn.className = 'markdown-copy-btn';
copyBtn.title = BUTTON_TOOLTIP_COLLAPSE; // Initial tooltip
// Add SVG icon
const iconSpan = document.createElement('span');
iconSpan.className = 'markdown-copy-btn-icon';
iconSpan.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M0 0h24v24H0V0z" fill="none"/>
<path d="M21 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-2h2v2zm6 0h-2v-2h2v2zm4 0h-2v-2h2v2zM9 13H7v-2h2v2zm6 0h-2v-2h2v2zm4 0h-2v-2h2v2zM9 9H7V7h2v2zm6 0h-2V7h2v2zm4 0h-2V7h2v2zm4 0h-2V7h2v2zm4 0h-2V7h2v2z"/>
</svg>
`;
copyBtn.appendChild(iconSpan);
// Add text span
copyBtnContentSpan = document.createElement('span');
copyBtnContentSpan.className = 'markdown-copy-btn-text';
copyBtnContentSpan.textContent = BUTTON_TEXT_DEFAULT;
copyBtn.appendChild(copyBtnContentSpan);
document.body.appendChild(copyBtn);
originalButtonText = BUTTON_TEXT_DEFAULT; // Store initial text
// Restore position and collapsed state
restoreButtonState();
debugLog('Button created and added.');
return true;
} catch (error) {
log(`Error creating button: ${error.message}`);
return false;
}
}
function updateModeButtonUI() {
if (isDivMode) {
modeIconSpan.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white" width="18px" height="18px">
<path d="M0 0h24v24H0V0z" fill="none"/>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z"/>
</svg>
`;
modeSwitchBtn.title = BUTTON_TOOLTIP_DIV_SELECT;
} else {
modeIconSpan.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white" width="18px" height="18px">
<path d="M0 0h24v24H0V0z" fill="none"/>
<path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zm0-8h14V7H7v2z"/>
</svg>
`;
modeSwitchBtn.title = BUTTON_TOOLTIP_FREE_SELECT;
}
}
// --- Button State Persistence ---
function restoreButtonState() {
// Restore position
const savedPos = GM_getValue(STORAGE_KEY_BUTTON_POS);
if (savedPos) {
try {
const pos = JSON.parse(savedPos);
container.style.left = `${pos.left}px`;
container.style.top = `${pos.top}px`;
container.style.right = 'auto';
container.style.bottom = 'auto';
} catch (e) {
log(`Error parsing button position: ${e}`);
}
} else {
container.style.right = '15px';
container.style.top = '15px';
}
// Restore collapsed state
const isCollapsed = GM_getValue(STORAGE_KEY_BUTTON_COLLAPSED, false);
// Apply collapsed visual style to the main button
setButtonCollapsedState(isCollapsed, false); // Don't save again
}
function saveButtonPosition() {
const container = document.getElementById('markdown-copy-container');
if (!container) return;
// Restore position
const savedPos = GM_getValue(STORAGE_KEY_BUTTON_POS);
if (savedPos) {
try {
const pos = JSON.parse(savedPos);
container.style.left = `${pos.left}px`;
container.style.top = `${pos.top}px`;
container.style.right = 'auto';
container.style.bottom = 'auto';
} catch (e) {
log(`Error parsing button position: ${e}`);
}
} else {
container.style.right = '15px';
container.style.top = '15px';
}
// Restore collapsed state
const isCollapsed = GM_getValue(STORAGE_KEY_BUTTON_COLLAPSED, false);
// Apply collapsed visual style to the main button
setButtonCollapsedState(isCollapsed, false); // Don't save again
}
function saveButtonCollapsedState(isCollapsed) {
GM_setValue(STORAGE_KEY_BUTTON_COLLAPSED, isCollapsed);
debugLog(`Button collapsed state saved: ${isCollapsed}`);
}
// --- Event Listeners Setup ---
function setupEventListeners() {
if (!copyBtn || !modeSwitchBtn) {
log("Error: Buttons not found for adding listeners.");
return;
}
// Button click toggles selection modes OR collapse if not selecting
copyBtn.addEventListener('click', handleButtonClick);
modeSwitchBtn.addEventListener('click', handleModeSwitchClick);
// Dragging listeners (on the container)
const container = document.getElementById('markdown-copy-container');
container.addEventListener('mousedown', handleButtonDragStart);
document.addEventListener('mousemove', handleButtonDragging);
document.addEventListener('mouseup', handleButtonDragEnd);
// Mouse events for free selection
document.addEventListener('mousedown', handleMouseDown, true); // Use capture phase
document.addEventListener('mousemove', handleMouseMove, true);
document.addEventListener('mouseup', handleMouseUp, true);
// Mouse events for DIV selection
document.addEventListener('mouseover', handleMouseOverDiv);
document.addEventListener('click', handleClickDiv, true); // Use capture phase for potential preventDefault
// Keyboard listener for ESC key
document.addEventListener('keydown', handleKeyDown);
debugLog('Event listeners added.');
}
// --- Button Dragging Logic ---
function handleButtonDragStart(e) {
// Only drag if the mousedown is on the buttons, not other parts of the container
if (e.target !== copyBtn && e.target !== modeSwitchBtn && !copyBtn.contains(e.target) && !modeSwitchBtn.contains(e.target)) {
return;
}
if (e.button !== 0 || isSelecting) return; // Only left-click, and not during selection mode
const container = document.getElementById('markdown-copy-container');
isDragging = true;
dragOffsetX = e.clientX - container.getBoundingClientRect().left;
dragOffsetY = e.clientY - container.getBoundingClientRect().top;
container.style.cursor = 'grabbing';
container.style.transition = 'none'; // Disable transition during drag
e.preventDefault(); // Prevent text selection etc.
debugLog('Button drag started.');
}
function handleButtonDragging(e) {
if (!isDragging) return;
const container = document.getElementById('markdown-copy-container');
container.style.left = `${e.clientX - dragOffsetX}px`;
container.style.top = `${e.clientY - dragOffsetY}px`;
container.style.right = 'auto'; // Clear right/bottom to allow left/top positioning
container.style.bottom = 'auto';
e.preventDefault();
}
function handleButtonDragEnd(e) {
if (!isDragging) return;
const container = document.getElementById('markdown-copy-container');
isDragging = false;
container.style.cursor = 'pointer';
container.style.transition = ''; // Re-enable transition
saveButtonPosition(); // Save the new position
debugLog('Button drag ended.');
}
// --- Button Click Logic ---
function handleButtonClick(e) {
e.stopPropagation();
if (isDragging) return;
// If not selecting, start selection.
if (!isSelecting) {
isSelecting = true;
setButtonState(isDivMode ? BUTTON_TEXT_SELECTING_DIV : BUTTON_TEXT_SELECTING_FREE);
document.body.style.cursor = isDivMode ? 'pointer' : 'crosshair';
log(`Entered ${isDivMode ? 'Div' : 'Free'} Selection Mode.`);
setButtonCollapsedState(false, true); // Ensure button is expanded
} else {
// If already selecting, this button acts as a collapse toggle
toggleButtonCollapsedState();
}
}
function handleModeSwitchClick(e) {
e.stopPropagation();
if (isDragging) return;
isDivMode = !isDivMode;
updateModeButtonUI();
// If already in selection mode, update the state and cursor
if (isSelecting) {
setButtonState(isDivMode ? BUTTON_TEXT_SELECTING_DIV : BUTTON_TEXT_SELECTING_FREE);
document.body.style.cursor = isDivMode ? 'pointer' : 'crosshair';
log(`Switched to ${isDivMode ? 'Div' : 'Free'} Selection Mode.`);
if (!isDivMode) {
removeDivHighlight();
}
}
}
// --- Button Collapse/Expand Logic ---
function toggleButtonCollapsedState() {
const container = document.getElementById('markdown-copy-container');
const currentlyCollapsed = container.classList.contains('mc-collapsed');
setButtonCollapsedState(!currentlyCollapsed, true);
}
function setButtonCollapsedState(collapse, saveState = true) {
const container = document.getElementById('markdown-copy-container');
if (!container || !copyBtn || !modeSwitchBtn) return;
if (collapse) {
container.classList.add('mc-collapsed');
copyBtn.classList.add('mc-collapsed');
copyBtnContentSpan.style.display = 'none';
modeSwitchBtn.style.display = 'none';
copyBtn.title = BUTTON_TOOLTIP_DRAG;
} else {
container.classList.remove('mc-collapsed');
copyBtn.classList.remove('mc-collapsed');
copyBtnContentSpan.style.display = 'inline';
modeSwitchBtn.style.display = 'flex';
copyBtn.title = BUTTON_TOOLTIP_COLLAPSE;
}
if (saveState) {
saveButtonCollapsedState(collapse);
}
debugLog(`Button collapsed state set to: ${collapse}`);
}
// --- Selection State & UI ---
function handleMouseDown(e) {
// Only act if in Free Select mode and not clicking the button itself
if (!isSelecting || isDivMode || e.target === copyBtn || copyBtn.contains(e.target)) return;
// Prevent default text selection behavior during drag
e.preventDefault();
e.stopPropagation();
startX = e.clientX + window.scrollX;
startY = e.clientY + window.scrollY;
// Create or reset selection box
if (!selectionBox) {
selectionBox = document.createElement('div');
selectionBox.className = 'markdown-copy-selection-box';
document.body.appendChild(selectionBox);
}
selectionBox.style.left = `${startX}px`;
selectionBox.style.top = `${startY}px`;
selectionBox.style.width = '0px';
selectionBox.style.height = '0px';
selectionBox.style.display = 'block'; // Make sure it's visible
debugLog(`Free selection started at (${startX}, ${startY})`);
}
function handleMouseMove(e) {
if (!isSelecting || isDivMode || !selectionBox || !startX) return; // Need startX to confirm drag started
// No preventDefault here - allows scrolling while dragging if needed
e.stopPropagation();
const currentX = e.clientX + window.scrollX;
const currentY = e.clientY + window.scrollY;
const left = Math.min(startX, currentX);
const top = Math.min(startY, currentY);
const width = Math.abs(currentX - startX);
const height = Math.abs(currentY - startY);
selectionBox.style.left = `${left}px`;
selectionBox.style.top = `${top}px`;
selectionBox.style.width = `${width}px`;
selectionBox.style.height = `${height}px`;
}
function handleMouseUp(e) {
if (!isSelecting || isDivMode || !selectionBox || !startX) return; // Check if a drag was actually happening
e.stopPropagation(); // Important to stop propagation here
const endX = e.clientX + window.scrollX;
const endY = e.clientY + window.scrollY;
const width = Math.abs(endX - startX);
const height = Math.abs(endY - startY);
debugLog(`Free selection ended at (${endX}, ${endY}), Size: ${width}x${height}`);
// Only copy if the box has a reasonable size (prevent accidental clicks)
if (width > 5 && height > 5) {
const markdownContent = getSelectedContentFromArea(startX, startY, endX, endY);
handleCopyAttempt(markdownContent, "Free Selection");
} else {
log("Selection box too small, ignoring.");
}
// Reset state *after* potential copy
resetSelectionState();
}
// --- Div Selection Handlers ---
function handleMouseOverDiv(e) {
if (!isSelecting || !isDivMode || e.target === copyBtn || copyBtn.contains(e.target)) return;
// Find the closest DIV that isn't the button itself or body/html
const target = e.target.closest('div:not(.markdown-copy-btn)');
if (target && target !== document.body && target !== document.documentElement) {
if (highlightedDiv && highlightedDiv !== target) {
removeDivHighlight();
}
if (highlightedDiv !== target) {
highlightedDiv = target;
highlightedDiv.classList.add('markdown-copy-div-highlight');
debugLog(`Highlighting Div: ${target.tagName}#${target.id}.${target.className.split(' ').join('.')}`);
}
} else {
// If hovering over something not in a suitable div, remove highlight
removeDivHighlight();
}
}
function handleClickDiv(e) {
if (!isSelecting || !isDivMode || e.target === copyBtn || copyBtn.contains(e.target)) return;
// Check if the click was on the currently highlighted div
const targetDiv = e.target.closest('.markdown-copy-div-highlight');
if (targetDiv && targetDiv === highlightedDiv) {
// Prevent the click from triggering other actions on the page (like navigation)
e.preventDefault();
e.stopPropagation();
log(`Div clicked: ${targetDiv.tagName}#${targetDiv.id}.${targetDiv.className.split(' ').join('.')}`);
const markdownContent = htmlToMarkdown(targetDiv);
handleCopyAttempt(markdownContent, "Div Selection");
resetSelectionState(); // Reset after successful click/copy
}
// If clicked outside the highlighted div, do nothing, let the click proceed normally
// unless it hits another potential div, handled by mouseover->highlight->next click
}
// --- Content Extraction ---
/**
* Tries to get Markdown content from the center of a selected area.
* This is an approximation and might not capture everything perfectly.
*/
function getSelectedContentFromArea(x1, y1, x2, y2) {
const centerX = window.scrollX + (Math.min(x1, x2) + Math.abs(x1 - x2) / 2 - window.scrollX);
const centerY = window.scrollY + (Math.min(y1, y2) + Math.abs(y1 - y2) / 2 - window.scrollY);
debugLog(`Checking elements at center point (${centerX}, ${centerY})`);
try {
const elements = document.elementsFromPoint(centerX, centerY);
if (!elements || elements.length === 0) {
log("No elements found at center point.");
return '';
}
// Find the most relevant element (skip body, html, overlays, button)
const meaningfulElement = elements.find(el =>
el &&
el.tagName?.toLowerCase() !== 'body' &&
el.tagName?.toLowerCase() !== 'html' &&
!el.classList.contains('markdown-copy-selection-box') &&
!el.classList.contains('markdown-copy-btn') &&
window.getComputedStyle(el).display !== 'none' &&
window.getComputedStyle(el).visibility !== 'hidden' &&
// Prefer elements with some size or specific tags
(el.offsetWidth > 20 || el.offsetHeight > 10 || ['p', 'div', 'article', 'section', 'main', 'ul', 'ol', 'table', 'pre'].includes(el.tagName?.toLowerCase()))
);
if (meaningfulElement) {
log(`Selected element via area center: ${meaningfulElement.tagName}`);
debugLog(meaningfulElement.outerHTML.substring(0, 100) + '...');
return htmlToMarkdown(meaningfulElement);
} else {
log("Could not find a meaningful element at the center point.");
// Fallback: try the top-most element that isn't the script's stuff
const fallbackElement = elements.find(el =>
el &&
!el.classList.contains('markdown-copy-selection-box') &&
!el.classList.contains('markdown-copy-btn'));
if (fallbackElement) {
log(`Using fallback element: ${fallbackElement.tagName}`);
return htmlToMarkdown(fallbackElement);
}
}
} catch (error) {
log(`Error in getSelectedContentFromArea: ${error.message}`);
}
return '';
}
// --- HTML to Markdown Conversion --- (Enhanced)
function htmlToMarkdown(element) {
if (!element) return '';
let markdown = '';
// Function to recursively process nodes
function processNode(node, listLevel = 0, listType = '') {
if (node.nodeType === Node.TEXT_NODE) {
// Replace multiple spaces/newlines with single space, unless in <pre>
const parentTag = node.parentNode?.tagName?.toLowerCase();
if (parentTag === 'pre' || node.parentNode?.closest('pre')) {
return node.textContent || ''; // Preserve whitespace in pre
}
let text = node.textContent || '';
text = text.replace(/\s+/g, ' '); // Consolidate whitespace
return text;
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return ''; // Ignore comments, etc.
}
// Ignore script, style, noscript, etc.
if (['script', 'style', 'noscript', 'button', 'textarea', 'input', 'select', 'option'].includes(node.tagName.toLowerCase())) {
return '';
}
// Ignore the script's own elements
if (node.classList.contains('markdown-copy-btn') || node.classList.contains('markdown-copy-selection-box')) {
return '';
}
let prefix = '';
let suffix = '';
let content = '';
const tag = node.tagName.toLowerCase();
const isBlock = window.getComputedStyle(node).display === 'block' || ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'pre', 'blockquote', 'hr', 'table', 'tr'].includes(tag);
// Process children first for most tags
for (const child of node.childNodes) {
content += processNode(child, listLevel + (tag === 'ul' || tag === 'ol' ? 1 : 0), (tag === 'ul' || tag === 'ol' ? tag : listType));
}
content = content.trim(); // Trim internal content
switch (tag) {
case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6':
prefix = '#'.repeat(parseInt(tag[1])) + ' ';
suffix = '\n\n';
break;
case 'p':
// Avoid adding extra newlines if content is empty or already ends with them
if (content) suffix = '\n\n';
break;
case 'strong': case 'b':
if (content) prefix = '**', suffix = '**';
break;
case 'em': case 'i':
if (content) prefix = '*', suffix = '*';
break;
case 'code':
// Handle inline code vs code block (inside pre)
if (node.closest('pre')) {
// Handled by 'pre' case, just return content
prefix = '', suffix = '';
} else {
if (content) prefix = '`', suffix = '`';
}
break;
case 'a':
const href = node.getAttribute('href');
if (content && href) {
// Handle relative URLs
const absoluteHref = new URL(href, window.location.href).href;
prefix = '[';
suffix = `](${absoluteHref})`;
} else {
// If link has no content but has href, just output URL maybe?
// Or just skip it. Let's skip.
prefix = ''; suffix = ''; content = '';
}
break;
case 'img':
const src = node.getAttribute('src');
const alt = node.getAttribute('alt') || '';
if (src) {
const absoluteSrc = new URL(src, window.location.href).href;
// Render as block element
prefix = ``;
suffix = '\n\n';
content = ''; // No content for images
}
break;
case 'ul':
case 'ol':
// Handled by child 'li' elements, add final newline if content exists
if (content) suffix = '\n\n';
else suffix = ''; // Avoid extra space if list empty
prefix = ''; content = ''; // Content aggregation is done in children
// Need to re-process children with list context here
for (const child of node.children) {
if (child.tagName.toLowerCase() === 'li') {
content += processNode(child, listLevel + 1, tag);
}
}
content = content.trimEnd(); // Remove trailing newline from last li
break;
case 'li':
const indent = ' '.repeat(Math.max(0, listLevel - 1));
prefix = indent + (listType === 'ol' ? '1. ' : '- '); // Simple numbering for ol
// Add newline, unless it's the last item handled by parent ul/ol
suffix = '\n';
break;
case 'blockquote':
// Add > prefix to each line
content = content.split('\n').map(line => '> ' + line).join('\n');
prefix = '';
suffix = '\n\n';
break;
case 'pre':
let codeContent = node.textContent || ''; // Get raw text content
let lang = '';
// Try to find language from class="language-..." on pre or inner code
const codeElement = node.querySelector('code[class*="language-"]');
const langClass = codeElement?.className.match(/language-(\S+)/);
if (langClass) {
lang = langClass[1];
} else {
const preLangClass = node.className.match(/language-(\S+)/);
if (preLangClass) lang = preLangClass[1];
}
prefix = '```' + lang + '\n';
suffix = '\n```\n\n';
content = codeContent.trim(); // Trim overall whitespace but preserve internal
break;
case 'hr':
prefix = '---';
suffix = '\n\n';
content = ''; // No content
break;
case 'table':
// Basic table support
let header = '';
let separator = '';
let body = '';
const rows = Array.from(node.querySelectorAll(':scope > thead > tr, :scope > tbody > tr, :scope > tr')); // More robust row finding
let firstRow = true;
for (const row of rows) {
let cols = [];
const cells = Array.from(row.querySelectorAll(':scope > th, :scope > td'));
cols = cells.map(cell => processNode(cell).replace(/\|/g, '\\|').trim()); // Escape pipes
if (firstRow && row.querySelector('th')) { // Assume header if first row has <th>
header = `| ${cols.join(' | ')} |`;
separator = `| ${cols.map(() => '---').join(' | ')} |`;
firstRow = false;
} else {
body += `| ${cols.join(' | ')} |\n`;
}
}
// Assemble table only if we found some structure
if (header && separator && body) {
prefix = header + '\n' + separator + '\n';
content = body.trim();
suffix = '\n\n';
} else if (body) { // Table with no header
prefix = '';
content = body.trim();
suffix = '\n\n';
}
else { // No meaningful table content
prefix = ''; content = ''; suffix = '';
}
break;
case 'br':
// Add double space for line break within paragraphs, or newline otherwise
const parentDisplay = node.parentNode ? window.getComputedStyle(node.parentNode).display : 'block';
if (parentDisplay !== 'block') {
prefix = ' \n'; // Markdown line break
} else {
prefix = '\n'; // Treat as paragraph break if parent is block
}
content = ''; suffix = '';
break;
// Default: block elements add newlines, inline elements don't
case 'div': case 'section': case 'article': case 'main': case 'header': case 'footer': case 'aside':
// Add newlines only if content exists and doesn't already end with plenty
if (content && !content.endsWith('\n\n')) {
suffix = '\n\n';
}
break;
default:
// For other inline elements, just pass content through
// For unrecognized block elements, add spacing if needed
if (isBlock && content && !content.endsWith('\n\n')) {
suffix = '\n\n';
}
break;
}
// Combine prefix, content, suffix. Trim whitespace around the final result for this node.
let result = prefix + content + suffix;
// Add spacing between block elements if needed
if (isBlock && markdown.length > 0 && !markdown.endsWith('\n\n') && !result.startsWith('\n')) {
// Ensure there's a blank line separating block elements
if (!markdown.endsWith('\n')) markdown += '\n';
markdown += '\n';
} else if (!isBlock && markdown.length > 0 && !markdown.endsWith(' ') && !markdown.endsWith('\n') && !result.startsWith(' ') && !result.startsWith('\n')) {
// Add a space between inline elements if needed
markdown += ' ';
}
markdown += result;
return result; // Return the result for recursive calls
} // End of processNode
try {
// Start processing from the root element provided
let rawMd = processNode(element);
// Final cleanup: consolidate multiple blank lines into one
rawMd = rawMd.replace(/\n{3,}/g, '\n\n');
return rawMd.trim(); // Trim final result
} catch (error) {
log(`Error during Markdown conversion: ${error.message}`);
return element.innerText || ''; // Fallback to innerText on error
}
} // End of htmlToMarkdown
// --- Clipboard & UI Feedback ---
function handleCopyAttempt(markdownContent, sourceType) {
if (markdownContent && markdownContent.trim().length > 0) {
try {
GM_setClipboard(markdownContent);
log(`${sourceType}: Markdown copied successfully! (Length: ${markdownContent.length})`);
showTemporaryMessage(BUTTON_TEXT_COPIED, false);
} catch (err) {
log(`${sourceType}: Copy failed: ${err.message}`);
showTemporaryMessage(BUTTON_TEXT_FAILED, true);
console.error("Clipboard copy error:", err);
}
} else {
log(`${sourceType}: No valid content detected to copy.`);
showTemporaryMessage(BUTTON_TEXT_FAILED, true); // Indicate failure if nothing was found
}
}
function showTemporaryMessage(text, isError) {
if (!copyBtn || !copyBtnContentSpan) return;
clearTimeout(messageTimeout); // Clear previous timeout if any
// Temporarily override collapsed state if showing message
copyBtn.classList.remove('mc-collapsed');
copyBtnContentSpan.textContent = text;
copyBtn.classList.toggle('mc-copied', !isError);
copyBtn.classList.toggle('mc-failed', isError);
copyBtn.title = text; // Set tooltip to the message as well
messageTimeout = setTimeout(() => {
// Restore button state after message
resetButtonAppearance();
}, TEMP_MESSAGE_DURATION);
}
function setButtonState(text) {
if (!copyBtn || !copyBtnContentSpan) return;
copyBtnContentSpan.textContent = text;
copyBtn.title = text; // Update tooltip during selection
// Clear temporary states if setting back to a standard message
if (text !== BUTTON_TEXT_COPIED && text !== BUTTON_TEXT_FAILED) {
copyBtn.classList.remove('mc-copied', 'mc-failed');
clearTimeout(messageTimeout); // Clear any pending message reset
}
// Store the original text if setting to default
if (text === BUTTON_TEXT_DEFAULT) {
originalButtonText = text;
}
// Ensure button is expanded if it's not a temporary message
if (text === BUTTON_TEXT_SELECTING_DIV || text === BUTTON_TEXT_SELECTING_FREE) {
setButtonCollapsedState(false);
}
}
// New function to reset button appearance after temporary messages
function resetButtonAppearance() {
// First, check if button was collapsed
const wasCollapsed = GM_getValue(STORAGE_KEY_BUTTON_COLLAPSED, false);
if (wasCollapsed) {
setButtonCollapsedState(true); // Collapse it again
} else {
setButtonCollapsedState(false); // Ensure it's expanded and text is restored
setButtonState(originalButtonText); // Restore default text
}
copyBtn.classList.remove('mc-copied', 'mc-failed'); // Remove color classes
}
// --- State Management & Cleanup ---
function removeDivHighlight() {
if (highlightedDiv) {
highlightedDiv.classList.remove('markdown-copy-div-highlight');
highlightedDiv = null;
debugLog('Div highlight removed.');
}
}
function removeSelectionBox() {
if (selectionBox) {
selectionBox.style.display = 'none'; // Hide instead of removing immediately
// Consider removing after a short delay if needed:
// setTimeout(() => { if (selectionBox) selectionBox.remove(); selectionBox = null; }, 50);
debugLog('Selection box hidden.');
// Reset start coords to prevent mouseup from triggering after cancellation
startX = null;
startY = null;
}
}
function resetSelectionState() {
isSelecting = false;
isDivMode = false; // Always reset to off state
document.body.style.cursor = 'default';
resetButtonAppearance(); // Use the new reset function
removeSelectionBox();
removeDivHighlight();
log('Selection state reset.');
}
function handleKeyDown(e) {
if (e.key === 'Escape' && isSelecting) {
log('Escape key pressed, cancelling selection.');
resetSelectionState();
}
}
// --- Script Entry Point ---
onDOMReady(() => {
// Small delay to let dynamic pages potentially load more content
setTimeout(() => {
try {
initScript();
} catch (err) {
log(`Critical error during script initialization: ${err.message}`);
console.error(err);
}
}, 100); // Wait 100ms after DOM ready
});
})();