Created
March 11, 2026 13:05
-
-
Save minanagehsalalma/63e956ab9f402261bd618375b27f5899 to your computer and use it in GitHub Desktop.
Milanote Board to Markdown — right-click context menu + header button to copy board elements as Markdown
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Milanote Board to Markdown | |
| // @namespace tweeks.io | |
| // @version 1.5.0 | |
| // @description Injects "Copy as Markdown" into Milanote's right-click context menu, with element picker and header button fallback | |
| // @author Tweeks | |
| // @match https://app.milanote.com/* | |
| // @grant GM_registerMenuCommand | |
| // @grant GM_setClipboard | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| // ------------------------------------------------------------------------- | |
| // Helpers | |
| // ------------------------------------------------------------------------- | |
| function getBoardTitle() { | |
| const el = document.querySelector('.CurrentBoardHeaderTitle .HighlightedText'); | |
| if (el) return el.textContent.trim(); | |
| const titleEl = document.querySelector('.editable-title .HighlightedText'); | |
| if (titleEl) return titleEl.textContent.trim(); | |
| return document.title.replace(' - Milanote', '').trim() || 'Milanote Board'; | |
| } | |
| function tiptapToMarkdown(container) { | |
| if (!container) return ''; | |
| const lines = []; | |
| function processNode(node) { | |
| const tag = node.nodeName.toLowerCase(); | |
| if (tag === 'p') { const t = node.textContent.trim(); lines.push(t || ''); return; } | |
| if (tag === 'h1') { lines.push('# ' + node.textContent.trim()); return; } | |
| if (tag === 'h2') { lines.push('## ' + node.textContent.trim()); return; } | |
| if (tag === 'h3') { lines.push('### ' + node.textContent.trim()); return; } | |
| if (tag === 'h4') { lines.push('#### ' + node.textContent.trim()); return; } | |
| if (tag === 'ul') { | |
| node.querySelectorAll(':scope > li').forEach(li => { | |
| const cb = li.querySelector('input[type="checkbox"]'); | |
| if (cb) lines.push('- ' + (cb.checked ? '[x]' : '[ ]') + ' ' + li.textContent.trim()); | |
| else { const t = li.textContent.trim(); if (t) lines.push('- ' + t); } | |
| }); | |
| return; | |
| } | |
| if (tag === 'ol') { | |
| node.querySelectorAll(':scope > li').forEach((li, idx) => { | |
| const t = li.textContent.trim(); if (t) lines.push((idx + 1) + '. ' + t); | |
| }); | |
| return; | |
| } | |
| if (tag === 'blockquote') { const t = node.textContent.trim(); if (t) lines.push('> ' + t); return; } | |
| if (tag === 'hr') { lines.push('---'); return; } | |
| node.childNodes.forEach(child => { if (child.nodeType === Node.ELEMENT_NODE) processNode(child); }); | |
| } | |
| container.childNodes.forEach(child => { if (child.nodeType === Node.ELEMENT_NODE) processNode(child); }); | |
| return lines.join('\n').trim(); | |
| } | |
| function draftEditorToText(container) { | |
| if (!container) return ''; | |
| const blocks = container.querySelectorAll('[data-block="true"]'); | |
| if (blocks.length === 0) return container.textContent.trim(); | |
| return Array.from(blocks).map(b => b.textContent.trim()).join(' '); | |
| } | |
| // Extract markdown from a table cell — preserves links inside cells | |
| function cellToMarkdown(td) { | |
| // Check for anchor links inside the cell | |
| const anchors = td.querySelectorAll('a[href]'); | |
| if (anchors.length > 0) { | |
| // Build a text that replaces each anchor with [text](href) | |
| let text = td.innerHTML; | |
| anchors.forEach(a => { | |
| const href = a.href || a.getAttribute('href') || ''; | |
| const label = a.textContent.trim(); | |
| if (href && label) { | |
| // Replace the outer HTML of the anchor with markdown link | |
| text = text.replace(a.outerHTML, '[' + label + '](' + href + ')'); | |
| } | |
| }); | |
| // Strip remaining HTML tags | |
| const tmp = document.createElement('div'); | |
| tmp.innerHTML = text; | |
| return tmp.textContent.trim().replace(/\|/g, '\\|'); | |
| } | |
| // DraftJS editor | |
| const editor = td.querySelector('.public-DraftEditor-content'); | |
| if (editor) return draftEditorToText(editor).replace(/\|/g, '\\|'); | |
| return td.textContent.trim().replace(/\|/g, '\\|'); | |
| } | |
| function getPosition(el) { | |
| return { top: parseInt(el.style.top) || 0, left: parseInt(el.style.left) || 0 }; | |
| } | |
| // ------------------------------------------------------------------------- | |
| // Element converters | |
| // ------------------------------------------------------------------------- | |
| function convertCard(el) { | |
| const tiptap = el.querySelector('.CardTiptapEditor .tiptap'); | |
| if (!tiptap) return null; | |
| return tiptapToMarkdown(tiptap) || null; | |
| } | |
| function convertLink(el) { | |
| const urlEl = el.querySelector('.LinkURL'); | |
| const titleEl = el.querySelector('.LinkMetadata .HighlightedText, .LinkMetadata .title-text, .EditableTitle .HighlightedText'); | |
| const captionEl = el.querySelector('.Caption .tiptap, .Caption .ProseMirror'); | |
| const url = urlEl ? (urlEl.href || urlEl.textContent.trim()) : ''; | |
| const rawTitle = titleEl ? titleEl.textContent.trim() : ''; | |
| const title = rawTitle || (urlEl ? urlEl.textContent.trim() : url); | |
| const caption = captionEl ? captionEl.textContent.trim() : ''; | |
| if (!url) return null; | |
| let md = (title && title !== url) ? '[' + title + '](' + url + ')' : '<' + url + '>'; | |
| if (caption) md += '\n\n> ' + caption; | |
| return md; | |
| } | |
| function convertTable(el) { | |
| const table = el.querySelector('table.htCore'); | |
| if (!table) return null; | |
| const tbody = table.querySelector('tbody'); | |
| if (!tbody) return null; | |
| const rows = []; | |
| tbody.querySelectorAll('tr').forEach(tr => { | |
| rows.push(Array.from(tr.querySelectorAll('td')).map(td => cellToMarkdown(td))); | |
| }); | |
| if (rows.length === 0) return null; | |
| const colCount = Math.max(...rows.map(r => r.length)); | |
| const pad = (row) => Array.from({ length: colCount }, (_, i) => row[i] || ''); | |
| return [ | |
| '| ' + pad(rows[0]).join(' | ') + ' |', | |
| '| ' + pad(rows[0]).map(() => '---').join(' | ') + ' |', | |
| ...rows.slice(1).map(r => '| ' + pad(r).join(' | ') + ' |'), | |
| ].join('\n'); | |
| } | |
| // ------------------------------------------------------------------------- | |
| // Collect + convert | |
| // ------------------------------------------------------------------------- | |
| function collectElements() { | |
| const canvasElements = Array.from(document.querySelectorAll('.CanvasElement')); | |
| canvasElements.sort((a, b) => { | |
| const pa = getPosition(a), pb = getPosition(b); | |
| const rowDiff = pa.top - pb.top; | |
| return Math.abs(rowDiff) > 60 ? rowDiff : pa.left - pb.left; | |
| }); | |
| const items = []; | |
| canvasElements.forEach((canvasEl, idx) => { | |
| const card = canvasEl.querySelector('.Card.element-instance'); | |
| const link = canvasEl.querySelector('.Link.element-instance'); | |
| const table = canvasEl.querySelector('.Table.element-instance'); | |
| let type = null, md = null; | |
| if (card) { type = 'card'; md = convertCard(card); } | |
| else if (link) { type = 'link'; md = convertLink(link); } | |
| else if (table) { type = 'table'; md = convertTable(table); } | |
| if (!md) return; | |
| const firstLine = md.split('\n').find(l => l.trim()) || ''; | |
| const preview = firstLine.length > 80 ? firstLine.slice(0, 78) + '…' : firstLine; | |
| items.push({ idx, type, md, preview }); | |
| }); | |
| return items; | |
| } | |
| function itemsToMarkdown(items) { | |
| const title = getBoardTitle(); | |
| const sections = ['# ' + title, '']; | |
| items.forEach(item => { sections.push(item.md); sections.push(''); }); | |
| return sections.join('\n').trim(); | |
| } | |
| function copyAllNow() { | |
| const items = collectElements(); | |
| if (!items.length) { showToast('No copyable content found.', true); return; } | |
| const md = itemsToMarkdown(items); | |
| try { GM_setClipboard(md); } | |
| catch (e) { navigator.clipboard.writeText(md).catch(() => {}); } | |
| showToast('Copied all ' + items.length + ' elements as Markdown!', false); | |
| } | |
| // ------------------------------------------------------------------------- | |
| // Toast | |
| // ------------------------------------------------------------------------- | |
| function showToast(message, isError) { | |
| const existing = document.getElementById('milanote-md-toast'); | |
| if (existing) existing.remove(); | |
| const toast = document.createElement('div'); | |
| toast.id = 'milanote-md-toast'; | |
| toast.textContent = message; | |
| Object.assign(toast.style, { | |
| position: 'fixed', bottom: '24px', right: '24px', | |
| background: isError ? '#c0392b' : '#1B2536', color: '#fff', | |
| padding: '10px 18px', borderRadius: '6px', fontSize: '14px', | |
| fontFamily: 'Inter, sans-serif', zIndex: '999999', | |
| boxShadow: '0 4px 12px rgba(0,0,0,0.3)', opacity: '1', | |
| transition: 'opacity 0.3s ease', | |
| }); | |
| document.body.appendChild(toast); | |
| setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 350); }, 2500); | |
| } | |
| // ------------------------------------------------------------------------- | |
| // Picker Modal | |
| // ------------------------------------------------------------------------- | |
| const TYPE_ICONS = { card: '📝', link: '🔗', table: '📊' }; | |
| function showPickerModal(items) { | |
| const existing = document.getElementById('milanote-md-picker'); | |
| if (existing) existing.remove(); | |
| if (items.length === 0) { showToast('No copyable content found on the board.', true); return; } | |
| const checked = {}; | |
| items.forEach(item => { checked[item.idx] = true; }); | |
| const overlay = document.createElement('div'); | |
| overlay.id = 'milanote-md-picker'; | |
| Object.assign(overlay.style, { | |
| position: 'fixed', inset: '0', background: 'rgba(0,0,0,0.5)', | |
| zIndex: '999998', display: 'flex', alignItems: 'center', | |
| justifyContent: 'center', fontFamily: 'Inter, sans-serif', | |
| }); | |
| const modal = document.createElement('div'); | |
| Object.assign(modal.style, { | |
| background: '#fff', borderRadius: '12px', | |
| width: '560px', maxWidth: '92vw', maxHeight: '82vh', | |
| display: 'flex', flexDirection: 'column', | |
| boxShadow: '0 12px 40px rgba(0,0,0,0.25)', overflow: 'hidden', | |
| }); | |
| // Header | |
| const header = document.createElement('div'); | |
| Object.assign(header.style, { | |
| padding: '16px 20px 12px', borderBottom: '1px solid #eee', | |
| display: 'flex', flexDirection: 'column', gap: '10px', | |
| }); | |
| const titleRow = document.createElement('div'); | |
| Object.assign(titleRow.style, { display: 'flex', justifyContent: 'space-between', alignItems: 'center' }); | |
| const titleEl = document.createElement('div'); | |
| titleEl.textContent = 'Select elements to copy'; | |
| Object.assign(titleEl.style, { fontSize: '15px', fontWeight: '600', color: '#1B2536' }); | |
| const closeBtn = document.createElement('button'); | |
| closeBtn.innerHTML = '✕'; | |
| Object.assign(closeBtn.style, { background: 'none', border: 'none', cursor: 'pointer', fontSize: '16px', color: '#888', padding: '0 2px' }); | |
| closeBtn.addEventListener('click', () => overlay.remove()); | |
| titleRow.appendChild(titleEl); titleRow.appendChild(closeBtn); | |
| const controlRow = document.createElement('div'); | |
| Object.assign(controlRow.style, { display: 'flex', gap: '8px', alignItems: 'center' }); | |
| const countLabel = document.createElement('span'); | |
| Object.assign(countLabel.style, { fontSize: '12px', color: '#888', flex: '1' }); | |
| function updateCount() { | |
| const n = items.filter(i => checked[i.idx]).length; | |
| countLabel.textContent = n + ' of ' + items.length + ' selected'; | |
| } | |
| updateCount(); | |
| function makeBtn(label) { | |
| const b = document.createElement('button'); | |
| b.textContent = label; | |
| Object.assign(b.style, { fontSize: '11px', padding: '3px 9px', borderRadius: '5px', border: '1px solid #ddd', background: '#f5f5f5', cursor: 'pointer', color: '#444' }); | |
| return b; | |
| } | |
| const selAll = makeBtn('Select all'); | |
| const selNone = makeBtn('Select none'); | |
| selAll.addEventListener('click', () => { items.forEach(i => { checked[i.idx] = true; }); refreshList(); updateCount(); }); | |
| selNone.addEventListener('click', () => { items.forEach(i => { checked[i.idx] = false; }); refreshList(); updateCount(); }); | |
| controlRow.appendChild(countLabel); controlRow.appendChild(selAll); controlRow.appendChild(selNone); | |
| header.appendChild(titleRow); header.appendChild(controlRow); | |
| // List | |
| const list = document.createElement('div'); | |
| Object.assign(list.style, { overflowY: 'auto', flex: '1', padding: '8px 12px' }); | |
| function refreshList() { | |
| list.innerHTML = ''; | |
| items.forEach(item => { | |
| const row = document.createElement('label'); | |
| Object.assign(row.style, { display: 'flex', alignItems: 'flex-start', gap: '10px', padding: '8px', borderRadius: '7px', cursor: 'pointer', marginBottom: '2px' }); | |
| row.addEventListener('mouseenter', () => { row.style.background = '#f4f6fa'; }); | |
| row.addEventListener('mouseleave', () => { row.style.background = 'transparent'; }); | |
| const cb = document.createElement('input'); | |
| cb.type = 'checkbox'; cb.checked = checked[item.idx]; | |
| Object.assign(cb.style, { marginTop: '2px', flexShrink: '0', cursor: 'pointer', accentColor: '#1B2536' }); | |
| cb.addEventListener('change', () => { checked[item.idx] = cb.checked; updateCount(); }); | |
| const icon = document.createElement('span'); | |
| icon.textContent = TYPE_ICONS[item.type] || '•'; | |
| Object.assign(icon.style, { fontSize: '14px', flexShrink: '0', marginTop: '1px' }); | |
| const text = document.createElement('span'); | |
| Object.assign(text.style, { fontSize: '13px', color: '#2c3e50', lineHeight: '1.4', flex: '1', wordBreak: 'break-word' }); | |
| const badge = document.createElement('span'); | |
| badge.textContent = item.type + ' '; | |
| Object.assign(badge.style, { fontSize: '10px', fontWeight: '600', textTransform: 'uppercase', color: '#aaa', letterSpacing: '0.04em' }); | |
| const preview = document.createElement('span'); | |
| preview.textContent = item.preview; | |
| text.appendChild(badge); text.appendChild(preview); | |
| row.appendChild(cb); row.appendChild(icon); row.appendChild(text); | |
| list.appendChild(row); | |
| }); | |
| } | |
| refreshList(); | |
| // Preview panel | |
| const previewPanel = document.createElement('div'); | |
| Object.assign(previewPanel.style, { display: 'none', borderTop: '1px solid #eee', background: '#f8f9fa' }); | |
| const previewTextarea = document.createElement('textarea'); | |
| previewTextarea.readOnly = true; | |
| Object.assign(previewTextarea.style, { width: '100%', height: '180px', resize: 'none', border: 'none', background: 'transparent', padding: '12px 20px', fontFamily: '"Roboto Mono","Courier New",monospace', fontSize: '12px', lineHeight: '1.5', color: '#2c3e50', boxSizing: 'border-box', outline: 'none' }); | |
| previewPanel.appendChild(previewTextarea); | |
| // Footer | |
| const footer = document.createElement('div'); | |
| Object.assign(footer.style, { padding: '12px 20px', borderTop: '1px solid #eee', display: 'flex', gap: '8px', justifyContent: 'flex-end', alignItems: 'center' }); | |
| const previewToggle = makeBtn('Preview'); | |
| Object.assign(previewToggle.style, { marginRight: 'auto', fontSize: '13px', padding: '7px 14px', borderRadius: '7px' }); | |
| const copyBtn = document.createElement('button'); | |
| copyBtn.textContent = 'Copy Markdown'; | |
| Object.assign(copyBtn.style, { fontSize: '13px', padding: '7px 18px', borderRadius: '7px', border: 'none', background: '#1B2536', cursor: 'pointer', color: '#fff', fontWeight: '500' }); | |
| let previewOpen = false; | |
| previewToggle.addEventListener('click', () => { | |
| previewOpen = !previewOpen; | |
| previewPanel.style.display = previewOpen ? 'block' : 'none'; | |
| previewToggle.textContent = previewOpen ? 'Hide preview' : 'Preview'; | |
| if (previewOpen) previewTextarea.value = itemsToMarkdown(items.filter(i => checked[i.idx])); | |
| }); | |
| copyBtn.addEventListener('click', () => { | |
| const selected = items.filter(i => checked[i.idx]); | |
| if (!selected.length) { showToast('No elements selected.', true); return; } | |
| const markdown = itemsToMarkdown(selected); | |
| try { GM_setClipboard(markdown); } catch (e) { navigator.clipboard.writeText(markdown).catch(() => {}); } | |
| copyBtn.textContent = '✓ Copied!'; copyBtn.style.background = '#27ae60'; | |
| setTimeout(() => { copyBtn.textContent = 'Copy Markdown'; copyBtn.style.background = '#1B2536'; }, 2000); | |
| showToast('Copied ' + selected.length + ' element' + (selected.length !== 1 ? 's' : '') + ' as Markdown!', false); | |
| }); | |
| footer.appendChild(previewToggle); footer.appendChild(copyBtn); | |
| modal.appendChild(header); modal.appendChild(list); modal.appendChild(previewPanel); modal.appendChild(footer); | |
| overlay.appendChild(modal); | |
| overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); | |
| const onKey = (e) => { if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', onKey); } }; | |
| document.addEventListener('keydown', onKey); | |
| document.body.appendChild(overlay); | |
| } | |
| // ------------------------------------------------------------------------- | |
| // Context menu injection | |
| // | |
| // Milanote renders its own React-based context menus as DOM nodes. | |
| // We watch for them to appear and inject our item(s). | |
| // | |
| // Board background menu (Image 2) → identified by containing "New Note" | |
| // Element menu (Image 1) → identified by containing "Move to Trash" | |
| // ------------------------------------------------------------------------- | |
| function makeMenuSeparator() { | |
| const sep = document.createElement('div'); | |
| Object.assign(sep.style, { | |
| height: '1px', background: '#e8e8e8', margin: '4px 0', | |
| }); | |
| return sep; | |
| } | |
| // Clone the visual style of an existing menu item row | |
| function makeMenuItem(label, onClick, existingItem) { | |
| const item = document.createElement('div'); | |
| // Copy classes from an existing item so Milanote's CSS applies naturally | |
| if (existingItem) { | |
| item.className = existingItem.className; | |
| } | |
| // Override / ensure our own inline styles so it always looks right | |
| Object.assign(item.style, { | |
| display: 'flex', | |
| alignItems: 'center', | |
| padding: '6px 14px', | |
| fontSize: '13px', | |
| cursor: 'pointer', | |
| userSelect: 'none', | |
| color: '#1B2536', | |
| gap: '8px', | |
| borderRadius: '4px', | |
| whiteSpace: 'nowrap', | |
| }); | |
| const icon = document.createElement('span'); | |
| icon.textContent = '📋'; | |
| Object.assign(icon.style, { fontSize: '13px', flexShrink: '0' }); | |
| const text = document.createElement('span'); | |
| text.textContent = label; | |
| item.appendChild(icon); | |
| item.appendChild(text); | |
| item.addEventListener('mouseenter', () => { item.style.background = '#f0f2f5'; }); | |
| item.addEventListener('mouseleave', () => { item.style.background = 'transparent'; }); | |
| item.addEventListener('mousedown', (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| }); | |
| item.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| // Close the context menu first | |
| const menu = item.closest('[class*="ContextMenu"], [class*="context-menu"], [class*="PopupMenu"], [class*="popup-menu"]') | |
| || item.parentElement?.parentElement; | |
| if (menu) menu.remove(); | |
| onClick(); | |
| }); | |
| return item; | |
| } | |
| // Detect what kind of menu this is by scanning its text content | |
| function isBoardContextMenu(menuEl) { | |
| const text = menuEl.textContent || ''; | |
| return text.includes('New Note') || text.includes('New Link') || text.includes('Select All'); | |
| } | |
| function isElementContextMenu(menuEl) { | |
| const text = menuEl.textContent || ''; | |
| return text.includes('Move to Trash') || text.includes('Duplicate'); | |
| } | |
| function injectIntoContextMenu(menuEl) { | |
| // Avoid double-injection | |
| if (menuEl.dataset.mdInjected) return; | |
| menuEl.dataset.mdInjected = '1'; | |
| // Find first real menu item to clone its class | |
| const firstItem = menuEl.querySelector('[class*="item"], [class*="Item"], [class*="row"], [class*="Row"]'); | |
| if (isBoardContextMenu(menuEl)) { | |
| // Board background right-click: add "Copy all as Markdown" + "Select & Copy…" | |
| const sep = makeMenuSeparator(); | |
| const copyAll = makeMenuItem('Copy board as Markdown', () => copyAllNow(), firstItem); | |
| const picker = makeMenuItem('Select elements to copy…', () => showPickerModal(collectElements()), firstItem); | |
| // Insert at top, before the first child | |
| menuEl.insertBefore(sep, menuEl.firstChild); | |
| menuEl.insertBefore(picker, menuEl.firstChild); | |
| menuEl.insertBefore(copyAll, menuEl.firstChild); | |
| } else if (isElementContextMenu(menuEl)) { | |
| // Element right-click: add "Copy element as Markdown" at the top | |
| const sep = makeMenuSeparator(); | |
| const copyEl = makeMenuItem('Copy element as Markdown', () => { | |
| // Try to figure out which element was right-clicked via selection or position | |
| const items = collectElements(); | |
| if (!items.length) { showToast('Nothing to copy.', true); return; } | |
| // Heuristic: copy the element nearest the mouse cursor at time of right-click | |
| // We stored the last contextmenu position in lastContextMenuPos | |
| if (window._milaMdLastPos) { | |
| const { x, y } = window._milaMdLastPos; | |
| let best = null, bestDist = Infinity; | |
| document.querySelectorAll('.CanvasElement').forEach(ce => { | |
| const rect = ce.getBoundingClientRect(); | |
| const cx = rect.left + rect.width / 2; | |
| const cy = rect.top + rect.height / 2; | |
| const dist = Math.hypot(cx - x, cy - y); | |
| if (dist < bestDist) { bestDist = dist; best = ce; } | |
| }); | |
| if (best) { | |
| const card = best.querySelector('.Card.element-instance'); | |
| const link = best.querySelector('.Link.element-instance'); | |
| const table = best.querySelector('.Table.element-instance'); | |
| let md = card ? convertCard(card) : link ? convertLink(link) : table ? convertTable(table) : null; | |
| if (md) { | |
| try { GM_setClipboard(md); } catch (e) { navigator.clipboard.writeText(md).catch(() => {}); } | |
| showToast('Element copied as Markdown!', false); | |
| return; | |
| } | |
| } | |
| } | |
| // Fallback: open picker | |
| showPickerModal(items); | |
| }, firstItem); | |
| menuEl.insertBefore(sep, menuEl.firstChild); | |
| menuEl.insertBefore(copyEl, menuEl.firstChild); | |
| } | |
| } | |
| // Track mouse position so we can identify which element was right-clicked | |
| document.addEventListener('contextmenu', (e) => { | |
| window._milaMdLastPos = { x: e.clientX, y: e.clientY }; | |
| }, true); | |
| // Watch for Milanote's context menu appearing in the DOM | |
| const ctxObserver = new MutationObserver((mutations) => { | |
| for (const mut of mutations) { | |
| for (const node of mut.addedNodes) { | |
| if (node.nodeType !== Node.ELEMENT_NODE) continue; | |
| // Check the node itself and its children for likely context menu containers | |
| const candidates = [node, ...node.querySelectorAll('*')]; | |
| for (const el of candidates) { | |
| const cls = (el.className || '').toString(); | |
| const isMenu = ( | |
| cls.includes('ContextMenu') || | |
| cls.includes('context-menu') || | |
| cls.includes('PopupMenu') || | |
| cls.includes('popup-menu') || | |
| cls.includes('DropdownMenu') || | |
| cls.includes('dropdown-menu') | |
| ); | |
| if (isMenu && (isBoardContextMenu(el) || isElementContextMenu(el))) { | |
| // Small delay to let React finish rendering all items | |
| setTimeout(() => injectIntoContextMenu(el), 30); | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| ctxObserver.observe(document.body, { childList: true, subtree: true }); | |
| // ------------------------------------------------------------------------- | |
| // Draggable floating button — persists position in localStorage | |
| // ------------------------------------------------------------------------- | |
| const STORAGE_KEY = 'milanote-md-btn-pos'; | |
| function loadPos() { | |
| try { | |
| const raw = localStorage.getItem(STORAGE_KEY); | |
| if (raw) return JSON.parse(raw); | |
| } catch (e) {} | |
| return null; | |
| } | |
| function savePos(top, left) { | |
| try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ top, left })); } catch (e) {} | |
| } | |
| function clampPos(top, left, w, h) { | |
| const maxTop = window.innerHeight - h - 8; | |
| const maxLeft = window.innerWidth - w - 8; | |
| return { | |
| top: Math.max(8, Math.min(top, maxTop)), | |
| left: Math.max(8, Math.min(left, maxLeft)), | |
| }; | |
| } | |
| function injectDraggableButton() { | |
| if (document.getElementById('milanote-md-float-wrapper')) return; | |
| // ---- Wrapper (the draggable pill) ---- | |
| const wrapper = document.createElement('div'); | |
| wrapper.id = 'milanote-md-float-wrapper'; | |
| Object.assign(wrapper.style, { | |
| position: 'fixed', | |
| zIndex: '999997', | |
| display: 'inline-flex', | |
| alignItems: 'center', | |
| height: '32px', | |
| borderRadius: '8px', | |
| background: '#1B2536', | |
| boxShadow: '0 2px 10px rgba(0,0,0,0.4)', | |
| userSelect: 'none', | |
| overflow: 'hidden', | |
| visibility: 'visible', | |
| opacity: '1', | |
| }); | |
| // ---- Drag handle (left side) ---- | |
| const handle = document.createElement('div'); | |
| handle.title = 'Drag to move'; | |
| handle.innerHTML = ` | |
| <svg width="12" height="16" viewBox="0 0 12 16" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| <circle cx="3.5" cy="3.5" r="1.5" fill="rgba(255,255,255,0.45)"/> | |
| <circle cx="8.5" cy="3.5" r="1.5" fill="rgba(255,255,255,0.45)"/> | |
| <circle cx="3.5" cy="8" r="1.5" fill="rgba(255,255,255,0.45)"/> | |
| <circle cx="8.5" cy="8" r="1.5" fill="rgba(255,255,255,0.45)"/> | |
| <circle cx="3.5" cy="12.5" r="1.5" fill="rgba(255,255,255,0.45)"/> | |
| <circle cx="8.5" cy="12.5" r="1.5" fill="rgba(255,255,255,0.45)"/> | |
| </svg>`; | |
| Object.assign(handle.style, { | |
| padding: '0 6px 0 8px', | |
| cursor: 'grab', | |
| display: 'flex', | |
| alignItems: 'center', | |
| height: '100%', | |
| borderRight: '1px solid rgba(255,255,255,0.12)', | |
| flexShrink: '0', | |
| }); | |
| // ---- Click button (right side) ---- | |
| const btn = document.createElement('button'); | |
| btn.id = 'milanote-md-float-btn'; | |
| btn.type = 'button'; | |
| Object.assign(btn.style, { | |
| display: 'inline-flex', | |
| alignItems: 'center', | |
| gap: '5px', | |
| padding: '0 12px', | |
| height: '100%', | |
| border: 'none', | |
| background: 'transparent', | |
| color: '#fff', | |
| fontSize: '12px', | |
| fontWeight: '500', | |
| fontFamily: 'Inter, sans-serif', | |
| cursor: 'pointer', | |
| whiteSpace: 'nowrap', | |
| }); | |
| const clipIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); | |
| clipIcon.setAttribute('width', '13'); clipIcon.setAttribute('height', '13'); | |
| clipIcon.setAttribute('viewBox', '0 0 24 24'); clipIcon.setAttribute('fill', 'none'); | |
| clipIcon.setAttribute('stroke', 'currentColor'); clipIcon.setAttribute('stroke-width', '2'); | |
| clipIcon.setAttribute('stroke-linecap', 'round'); clipIcon.setAttribute('stroke-linejoin', 'round'); | |
| clipIcon.style.flexShrink = '0'; | |
| clipIcon.innerHTML = '<rect x="9" y="2" width="6" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>'; | |
| const label = document.createElement('span'); | |
| label.textContent = 'Copy Markdown'; | |
| btn.appendChild(clipIcon); | |
| btn.appendChild(label); | |
| btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(255,255,255,0.1)'; }); | |
| btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; }); | |
| let wasDragged = false; | |
| btn.addEventListener('click', () => { | |
| if (wasDragged) return; // ignore click at end of drag | |
| try { showPickerModal(collectElements()); } | |
| catch (e) { showToast('Error: ' + e.message, true); } | |
| }); | |
| wrapper.appendChild(handle); | |
| wrapper.appendChild(btn); | |
| // ---- Position: restore saved or default top-right ---- | |
| const saved = loadPos(); | |
| if (saved) { | |
| const { top, left } = clampPos(saved.top, saved.left, 160, 32); | |
| wrapper.style.top = top + 'px'; | |
| wrapper.style.left = left + 'px'; | |
| } else { | |
| wrapper.style.top = '12px'; | |
| wrapper.style.right = '16px'; | |
| } | |
| document.body.appendChild(wrapper); | |
| // ---- Drag logic ---- | |
| let dragging = false, startX = 0, startY = 0, startLeft = 0, startTop = 0; | |
| handle.addEventListener('mousedown', (e) => { | |
| if (e.button !== 0) return; | |
| dragging = true; | |
| wasDragged = false; | |
| handle.style.cursor = 'grabbing'; | |
| const rect = wrapper.getBoundingClientRect(); | |
| startX = e.clientX; | |
| startY = e.clientY; | |
| startLeft = rect.left; | |
| startTop = rect.top; | |
| // Remove right so left takes full control | |
| wrapper.style.right = ''; | |
| e.preventDefault(); | |
| }); | |
| document.addEventListener('mousemove', (e) => { | |
| if (!dragging) return; | |
| const dx = e.clientX - startX; | |
| const dy = e.clientY - startY; | |
| if (Math.abs(dx) > 3 || Math.abs(dy) > 3) wasDragged = true; | |
| const rect = wrapper.getBoundingClientRect(); | |
| const { top, left } = clampPos(startTop + dy, startLeft + dx, rect.width, rect.height); | |
| wrapper.style.top = top + 'px'; | |
| wrapper.style.left = left + 'px'; | |
| }); | |
| document.addEventListener('mouseup', () => { | |
| if (!dragging) return; | |
| dragging = false; | |
| handle.style.cursor = 'grab'; | |
| if (wasDragged) { | |
| const rect = wrapper.getBoundingClientRect(); | |
| savePos(rect.top, rect.left); | |
| } | |
| setTimeout(() => { wasDragged = false; }, 50); | |
| }); | |
| // Re-clamp on window resize | |
| window.addEventListener('resize', () => { | |
| const rect = wrapper.getBoundingClientRect(); | |
| const { top, left } = clampPos(rect.top, rect.left, rect.width, rect.height); | |
| wrapper.style.top = top + 'px'; | |
| wrapper.style.left = left + 'px'; | |
| }); | |
| } | |
| function tryInject() { | |
| injectDraggableButton(); | |
| } | |
| if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', tryInject); | |
| else tryInject(); | |
| // ------------------------------------------------------------------------- | |
| // Tampermonkey menu fallbacks | |
| // ------------------------------------------------------------------------- | |
| GM_registerMenuCommand('Copy board as Markdown (all)', () => copyAllNow()); | |
| GM_registerMenuCommand('Select & copy board as Markdown…', () => { | |
| try { showPickerModal(collectElements()); } catch (e) { showToast('Error: ' + e.message, true); } | |
| }); | |
| GM_registerMenuCommand('[Debug] Log context menu DOM', () => { | |
| const candidates = document.querySelectorAll('[class*="ContextMenu"],[class*="context-menu"],[class*="PopupMenu"],[class*="popup-menu"]'); | |
| console.group('[Milanote Markdown] Context menu candidates'); | |
| candidates.forEach(el => console.log(el.className, el)); | |
| console.groupEnd(); | |
| showToast('Context menu DOM logged to console (F12)', false); | |
| }); | |
| })(); |
Author
minanagehsalalma
commented
Mar 11, 2026
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment