feat: implement custom Ctrl+Hover tooltip with opaque background and fix popout window support

This commit is contained in:
JLDiaz (m) 2026-05-21 17:11:16 +02:00
parent 49ce754986
commit ee3132cdf4
2 changed files with 283 additions and 13 deletions

View file

@ -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<boolean> {
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');
}
}
}

View file

@ -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);
}