diff --git a/src/main.ts b/src/main.ts index 94acf4d..51dba26 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,6 +11,44 @@ import { RangeSetBuilder } from '@codemirror/state'; import { syntaxTree } from '@codemirror/language'; import { SyntaxNodeRef } from '@lezer/common'; +async function copyText(text: string): Promise { + try { + // Try Electron clipboard first if available (Desktop) + const electron = (window as any).electron; + if (electron && electron.clipboard) { + electron.clipboard.writeText(text); + new Notice('Copied to clipboard!'); + return true; + } + + // Fallback to standard Navigator Clipboard API (Mobile/Web) + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + new Notice('Copied to clipboard!'); + return true; + } + + // Old-school fallback using document.execCommand (if all else fails) + const textArea = activeDocument.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.opacity = '0'; + activeDocument.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + const successful = activeDocument.execCommand('copy'); + activeDocument.body.removeChild(textArea); + if (successful) { + new Notice('Copied to clipboard!'); + return true; + } + } catch (err) { + console.error('Failed to copy text: ', err); + } + new Notice('Failed to copy.'); + return false; +} + class CopyIconWidget extends WidgetType { constructor(private textToCopy: string) { super(); } @@ -23,13 +61,10 @@ class CopyIconWidget extends WidgetType { span.onclick = async (e) => { e.preventDefault(); e.stopPropagation(); - try { - await navigator.clipboard.writeText(this.textToCopy); - new Notice('Copied to clipboard!'); + const success = await copyText(this.textToCopy); + if (success) { span.addClass('is-clicked'); window.setTimeout(() => span.removeClass('is-clicked'), 200); - } catch { - new Notice('Failed to copy.'); } }; @@ -46,7 +81,6 @@ const copyLinkPlugin = ViewPlugin.fromClass( decorations: DecorationSet; constructor(view: EditorView) { this.decorations = this.buildDecorations(view); } update(update: ViewUpdate) { - // Trigger update on selection change so we can hide/show based on cursor position if (update.docChanged || update.viewportChanged || update.selectionSet) { this.decorations = this.buildDecorations(update.view); } @@ -89,16 +123,19 @@ const copyLinkPlugin = ViewPlugin.fromClass( } if (labelNode) { - // Mark the line to hide external link icons via CSS const line = view.state.doc.lineAt(node.from); builder.add(line.from, line.from, Decoration.line({ class: 'has-copy-protocol-line' })); - // Hide widget if cursor is anywhere near the link structure const linkStart = labelNode.from - 1; const linkEnd = node.to + 1; const isEditing = selection.ranges.some(r => r.from <= linkEnd && r.to >= linkStart); - builder.add(labelNode.from, labelNode.to, Decoration.mark({ class: 'copy-protocol-link' })); + builder.add(labelNode.from, labelNode.to, Decoration.mark({ + class: 'copy-protocol-link', + attributes: { + 'data-text-to-copy': textToCopy + } + })); if (!isEditing) { builder.add(labelNode.to, labelNode.to, Decoration.widget({ @@ -117,14 +154,18 @@ const copyLinkPlugin = ViewPlugin.fromClass( ); export default class CopyProtocolPlugin extends Plugin { + private hoveredElement: HTMLElement | null = null; + private hoveredText: string | null = null; + async onload() { this.registerObsidianProtocolHandler('copy', async (params) => { if (params.text) { - await navigator.clipboard.writeText(params.text); - new Notice('Copied to clipboard!'); + await copyText(params.text); } }); + this.registerEditorExtension([copyLinkPlugin]); + this.addCommand({ id: 'paste-as-copy-link', name: 'Paste clipboard as copy-protocol link', @@ -138,5 +179,192 @@ export default class CopyProtocolPlugin extends Plugin { editor.replaceSelection(`[${label}](<${url}>)`); }, }); + + this.registerMarkdownPostProcessor((element, context) => { + const links = element.querySelectorAll('a.external-link'); + links.forEach(link => { + const href = link.getAttribute('href') || ''; + if (href.startsWith('obsidian://copy')) { + link.classList.remove('external-link'); + link.classList.add('copy-protocol-link'); + link.removeAttribute('aria-label'); + link.removeAttribute('data-tooltip-position'); + } + }); + }); + + this.registerWindowEvents(activeWindow); + + this.registerEvent( + this.app.workspace.on('window-open', (winInfo: any, win: Window) => { + this.registerWindowEvents(win); + }) + ); + } + + onunload() { + this.app.workspace.iterateAllLeaves((leaf) => { + const win = (leaf.view.containerEl as any).win; + if (win && win.document) { + const tooltip = win.document.getElementById('copy-protocol-custom-tooltip'); + if (tooltip) { + tooltip.remove(); + } + } + }); + const mainTooltip = activeDocument.getElementById('copy-protocol-custom-tooltip'); + if (mainTooltip) { + mainTooltip.remove(); + } + } + + private registerWindowEvents(win: Window) { + const doc = win.document; + + this.registerDomEvent(doc, 'mouseover', this.handleMouseOver, { capture: true }); + this.registerDomEvent(doc, 'mouseout', this.handleMouseOut, { capture: true }); + this.registerDomEvent(win, 'keydown', this.handleKeyDown); + this.registerDomEvent(win, 'keyup', this.handleKeyUp); + + this.registerDomEvent(doc, 'click', (evt: MouseEvent) => { + const target = evt.target as HTMLElement; + const copyLink = target.closest('.copy-protocol-link') as HTMLElement; + if (copyLink) { + evt.preventDefault(); + evt.stopPropagation(); + + let textToCopy = copyLink.getAttribute('data-text-to-copy'); + + if (!textToCopy && copyLink.tagName === 'A') { + const href = copyLink.getAttribute('href') || ''; + if (href.startsWith('obsidian://copy?')) { + try { + const url = new URL(href); + textToCopy = url.searchParams.get('text') ?? ''; + } catch { + const match = href.match(/[?&]text=([^&]+)/); + textToCopy = (match && match[1]) ? decodeURIComponent(match[1]) : ''; + } + } + } + + if (textToCopy) { + copyText(textToCopy); + } + } + }, true); + } + + private handleMouseOver = (e: MouseEvent) => { + const target = e.target as HTMLElement; + if (!target) return; + + const copyEl = target.closest('a.copy-protocol-link, .copy-protocol-link, .copy-protocol-icon, a.external-link[href^="obsidian://copy"]') as HTMLElement | null; + if (!copyEl) return; + + this.hoveredElement = copyEl; + + let text = ''; + const href = copyEl.getAttribute('href'); + if (href && href.startsWith('obsidian://copy')) { + try { + const url = new URL(href); + text = url.searchParams.get('text') ?? ''; + } catch { + const match = href.match(/[?&]text=([^&]+)/); + text = (match && match[1]) ? decodeURIComponent(match[1]) : ''; + } + } else { + text = copyEl.getAttribute('data-text-to-copy') ?? ''; + } + + this.hoveredText = text; + + if (copyEl.classList.contains('external-link')) { + copyEl.classList.remove('external-link'); + copyEl.classList.add('copy-protocol-link'); + } + if (copyEl.hasAttribute('aria-label')) { + copyEl.removeAttribute('aria-label'); + } + if (copyEl.hasAttribute('data-tooltip-position')) { + copyEl.removeAttribute('data-tooltip-position'); + } + + if (e.ctrlKey || e.metaKey) { + this.showTooltip(); + } + }; + + private handleMouseOut = (e: MouseEvent) => { + if (!this.hoveredElement) return; + const related = e.relatedTarget as HTMLElement | null; + if (related && this.hoveredElement.contains(related)) return; + + this.hideTooltip(); + this.hoveredElement = null; + this.hoveredText = null; + }; + + private handleKeyDown = (e: KeyboardEvent) => { + if ((e.key === 'Control' || e.key === 'Meta') && this.hoveredElement && this.hoveredText) { + this.showTooltip(); + } + }; + + private handleKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Control' || e.key === 'Meta') { + this.hideTooltip(); + } + }; + + private getOrCreateTooltip(doc: Document): HTMLElement { + let tooltip = doc.getElementById('copy-protocol-custom-tooltip'); + if (!tooltip) { + tooltip = doc.createElement('div'); + tooltip.id = 'copy-protocol-custom-tooltip'; + tooltip.className = 'copy-protocol-tooltip'; + doc.body.appendChild(tooltip); + } + return tooltip; + } + + private showTooltip() { + if (!this.hoveredElement || !this.hoveredText) return; + + const doc = this.hoveredElement.ownerDocument || document; + const tooltip = this.getOrCreateTooltip(doc); + + const cleanText = this.hoveredText.replace(/\r?\n/g, ' '); + const displayLength = 100; + const truncatedText = cleanText.length > displayLength + ? cleanText.slice(0, displayLength) + '…' + : cleanText; + + tooltip.textContent = `Copy: "${truncatedText}"`; + + const rect = this.hoveredElement.getBoundingClientRect(); + const win = doc.defaultView || window; + + const scrollTop = win.scrollY || doc.documentElement.scrollTop; + const scrollLeft = win.scrollX || doc.documentElement.scrollLeft; + + tooltip.classList.add('is-visible'); + + const tooltipRect = tooltip.getBoundingClientRect(); + const left = rect.left + scrollLeft + (rect.width / 2) - (tooltipRect.width / 2); + const top = rect.top + scrollTop - tooltipRect.height - 8; + + tooltip.style.left = `${Math.max(8, left)}px`; + tooltip.style.top = `${top}px`; + } + + private hideTooltip() { + if (!this.hoveredElement) return; + const doc = this.hoveredElement.ownerDocument || document; + const tooltip = doc.getElementById('copy-protocol-custom-tooltip'); + if (tooltip) { + tooltip.classList.remove('is-visible'); + } } } diff --git a/styles.css b/styles.css index 18cf386..6668a90 100644 --- a/styles.css +++ b/styles.css @@ -8,12 +8,16 @@ * We target the same pseudo-element Obsidian uses (::after) but with higher * specificity to override it completely without !important. */ +.markdown-rendered a.copy-protocol-link, +.markdown-preview-view a.copy-protocol-link, .markdown-rendered a.external-link[href^="obsidian://copy"], .markdown-preview-view a.external-link[href^="obsidian://copy"] { background-image: none; padding-inline-end: 0; } +.markdown-rendered a.copy-protocol-link::after, +.markdown-preview-view a.copy-protocol-link::after, .markdown-rendered a.external-link[href^="obsidian://copy"]::after, .markdown-preview-view a.external-link[href^="obsidian://copy"]::after { content: ""; @@ -33,6 +37,8 @@ opacity: 0.8; } +.markdown-rendered a.copy-protocol-link:hover::after, +.markdown-preview-view a.copy-protocol-link:hover::after, .markdown-rendered a.external-link[href^="obsidian://copy"]:hover::after, .markdown-preview-view a.external-link[href^="obsidian://copy"]:hover::after { opacity: 1; @@ -42,7 +48,11 @@ .markdown-rendered a[href^="obsidian://copy"] .external-link, .markdown-rendered a[href^="obsidian://copy"] + .external-link, .markdown-preview-view a[href^="obsidian://copy"] .external-link, -.markdown-preview-view a[href^="obsidian://copy"] + .external-link { +.markdown-preview-view a[href^="obsidian://copy"] + .external-link, +.markdown-rendered a.copy-protocol-link .external-link, +.markdown-rendered a.copy-protocol-link + .external-link, +.markdown-preview-view a.copy-protocol-link .external-link, +.markdown-preview-view a.copy-protocol-link + .external-link { display: none; } @@ -65,7 +75,8 @@ /* Copy cursor on hover */ .markdown-source-view.mod-cm6 .copy-protocol-link:hover, -.markdown-source-view.mod-cm6 a[href^="obsidian://copy"]:hover { +.markdown-source-view.mod-cm6 a[href^="obsidian://copy"]:hover, +a.copy-protocol-link:hover { cursor: copy; } @@ -93,3 +104,34 @@ .copy-protocol-icon.is-clicked { transform: scale(1.2); } + +/* ── Custom Premium Tooltip ── */ + +.copy-protocol-tooltip { + position: absolute; + z-index: 10000; + background-color: var(--background-tooltip, var(--background-secondary-alt, #303030)); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + color: var(--text-normal); + border: 1px solid var(--border-color, var(--background-modifier-border, #444)); + border-radius: 6px; + padding: 6px 12px; + font-size: 12px; + font-family: var(--font-interface); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease-in-out, transform 0.15s ease-in-out; + transform: translateY(5px); + white-space: nowrap; + max-width: 350px; + overflow: hidden; + text-overflow: ellipsis; +} + +.copy-protocol-tooltip.is-visible { + opacity: 1; + transform: translateY(0); +} +