mirror of
https://github.com/jldiaz/copy-protocol-plugin.git
synced 2026-07-22 06:56:55 +00:00
feat: implement custom Ctrl+Hover tooltip with opaque background and fix popout window support
This commit is contained in:
parent
49ce754986
commit
ee3132cdf4
2 changed files with 283 additions and 13 deletions
250
src/main.ts
250
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<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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
46
styles.css
46
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue