Compare commits

..

10 commits
1.1.0 ... main

8 changed files with 399 additions and 99 deletions

View file

@ -54,6 +54,14 @@ The plugin includes a command called **Copy Protocol: Paste clipboard as copy-pr
```
5. Clicking that link copies `git log --oneline` to your clipboard instantly.
## Hover Previews
You can preview the text that a copy link contains before actually copying it.
- **To preview**: Hold `Ctrl` (or `Cmd` on macOS) while hovering your mouse over any `obsidian://copy` link or copy icon.
- A custom, theme-aware tooltip will appear showing a snippet of the text to be copied (e.g., `Copy: "your command here"`).
- This allows you to verify exactly what is going to be copied without needing to click it or inspect the markdown source.
## Installation
You can install this plugin from the Community Plugins settings of Obsidian, or via [BRAT](https://github.com/TfTHacker/obsidian42-brat):

BIN
demo.gif

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

View file

@ -1,10 +1,10 @@
{
"id": "copy-text-protocol",
"name": "Copy Text Protocol",
"version": "1.1.0",
"version": "1.1.6",
"minAppVersion": "0.15.0",
"description": "Adds support for copying text to the clipboard using a custom protocol.",
"author": "jldiaz",
"authorUrl": "",
"isDesktopOnly": false
}
}

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "copy-text-protocol",
"version": "1.1.0",
"version": "1.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "copy-text-protocol",
"version": "1.1.0",
"version": "1.1.6",
"license": "0-BSD",
"dependencies": {
"obsidian": "latest"

View file

@ -1,6 +1,6 @@
{
"name": "copy-text-protocol",
"version": "1.1.0",
"version": "1.1.6",
"description": "A plugin that adds support for copying text via a custom protocol.",
"main": "main.js",
"type": "module",
@ -27,6 +27,7 @@
"typescript-eslint": "8.35.1"
},
"dependencies": {
"@lezer/common": "^1.2.3",
"obsidian": "latest"
}
}

View file

@ -9,42 +9,65 @@ import {
} from '@codemirror/view';
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(); }
toDOM(): HTMLElement {
const span = document.createElement('span');
const span = activeDocument.createElement('span');
span.className = 'copy-protocol-icon';
span.style.cursor = 'copy';
span.style.display = 'inline-flex';
span.style.marginLeft = '5px';
span.style.verticalAlign = 'middle';
span.style.opacity = '0.8';
setIcon(span, 'copy');
const svg = span.querySelector('svg');
if (svg) {
svg.style.width = '0.85em';
svg.style.height = '0.85em';
}
span.onclick = async (e) => {
e.preventDefault();
e.stopPropagation();
try {
await navigator.clipboard.writeText(this.textToCopy);
new Notice('Copied to clipboard!');
span.style.transform = 'scale(1.2)';
setTimeout(() => span.style.transform = 'scale(1)', 200);
} catch (err) {
new Notice('Failed to copy.');
const success = await copyText(this.textToCopy);
if (success) {
span.addClass('is-clicked');
window.setTimeout(() => span.removeClass('is-clicked'), 200);
}
};
span.onmouseenter = () => span.style.opacity = '1';
span.onmouseleave = () => span.style.opacity = '0.8';
return span;
}
@ -58,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);
}
@ -67,57 +89,64 @@ const copyLinkPlugin = ViewPlugin.fromClass(
buildDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const tree = syntaxTree(view.state);
const { from, to } = view.viewport;
const selection = view.state.selection;
tree.iterate({
from,
to,
enter(node: any) {
if (!node.name.includes('string_url')) return;
const urlText = view.state.doc.sliceString(node.from, node.to);
if (!urlText.includes('obsidian://copy')) return;
for (const { from: lFrom, to: lTo } of view.visibleRanges) {
tree.iterate({
from: lFrom,
to: lTo,
enter(node: SyntaxNodeRef) {
if (!node.name.includes('string_url')) return;
const urlText = view.state.doc.sliceString(node.from, node.to);
if (!urlText.includes('obsidian://copy')) return;
let textToCopy = '';
try {
const cleanUrl = urlText.startsWith('<') ? urlText.slice(1, -1) : urlText;
const url = new URL(cleanUrl);
textToCopy = url.searchParams.get('text') ?? '';
} catch {
const match = urlText.match(/[?&]text=([^&> ]+)/);
textToCopy = (match && match[1]) ? decodeURIComponent(match[1]) : '';
}
if (!textToCopy) return;
let labelNode = null;
let curr = node.node.prevSibling;
for (let i = 0; i < 6 && curr; i++) {
if (curr.name.includes('link') && !curr.name.includes('formatting')) {
labelNode = curr;
break;
let textToCopy = '';
try {
const cleanUrl = urlText.startsWith('<') ? urlText.slice(1, -1) : urlText;
const url = new URL(cleanUrl);
textToCopy = url.searchParams.get('text') ?? '';
} catch {
const match = urlText.match(/[?&]text=([^&> ]+)/);
textToCopy = (match && match[1]) ? decodeURIComponent(match[1]) : '';
}
curr = curr.prevSibling;
}
if (labelNode) {
// Hide widget if cursor is anywhere near the link structure
// (from the start of label to the end of the URL)
const linkStart = labelNode.from - 1; // accounting for '['
const linkEnd = node.to + 1; // accounting for ')'
const isEditing = selection.ranges.some(r => r.from <= linkEnd && r.to >= linkStart);
if (!textToCopy) return;
builder.add(labelNode.from, labelNode.to, Decoration.mark({ class: 'copy-protocol-link' }));
if (!isEditing) {
builder.add(labelNode.to, labelNode.to, Decoration.widget({
widget: new CopyIconWidget(textToCopy as any),
side: 1
let labelNode: SyntaxNodeRef | null = null;
let curr = node.node.prevSibling;
for (let i = 0; i < 6 && curr; i++) {
if (curr.name.includes('link') && !curr.name.includes('formatting')) {
labelNode = curr as unknown as SyntaxNodeRef;
break;
}
curr = curr.prevSibling;
}
if (labelNode) {
const line = view.state.doc.lineAt(node.from);
builder.add(line.from, line.from, Decoration.line({ class: 'has-copy-protocol-line' }));
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',
attributes: {
'data-text-to-copy': textToCopy
}
}));
if (!isEditing) {
builder.add(labelNode.to, labelNode.to, Decoration.widget({
widget: new CopyIconWidget(textToCopy),
side: 1
}));
}
}
}
},
});
},
});
}
return builder.finish();
}
},
@ -125,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',
@ -146,5 +179,202 @@ export default class CopyProtocolPlugin extends Plugin {
editor.replaceSelection(`[${label}](<${url}>)`);
},
});
this.registerMarkdownPostProcessor((element, context) => {
const links = element.querySelectorAll('a.external-link, a.copy-protocol-link');
links.forEach(link => {
const href = link.getAttribute('href') || '';
if (href.startsWith('obsidian://copy')) {
if (link.classList.contains('external-link')) {
link.classList.remove('external-link');
}
if (!link.classList.contains('copy-protocol-link')) {
link.classList.add('copy-protocol-link');
}
link.removeAttribute('aria-label');
link.removeAttribute('data-tooltip-position');
// Add an inline icon span if it doesn't already have one
if (!link.querySelector('.copy-protocol-icon')) {
const span = link.createSpan({ cls: 'copy-protocol-icon' });
setIcon(span, 'copy');
}
}
});
});
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

@ -4,47 +4,106 @@
/* ── Reading View (Standard Links) ── */
a[href^="obsidian://copy"]::after {
content: "";
display: inline-block;
width: 0.85em;
height: 0.85em;
margin-left: 5px;
vertical-align: middle;
background-color: currentColor;
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='14' height='14' x='8' y='8' rx='2' ry='2'/%3E%3Cpath d='M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='14' height='14' x='8' y='8' rx='2' ry='2'/%3E%3Cpath d='M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2'/%3E%3C/svg%3E");
-webkit-mask-size: contain;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
opacity: 0.8;
/*
* 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;
}
a[href^="obsidian://copy"]:hover::after {
opacity: 1;
/* Hide Obsidian's default external link icon elements in Reading Mode */
.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-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;
}
/* ── Live Preview (Managed by ViewPlugin Widget) ── */
/*
* Hide Obsidian's default external link icon on the same line as our copy links.
* We still use the .copy-protocol-link mark for this.
* Hide Obsidian's default external link icon on our copy links.
* Instead of :has(), we use the .has-copy-protocol-line class added in JS.
*/
.cm-line:has(.copy-protocol-link) .external-link,
.cm-line:has(.copy-protocol-link) .cm-url.external-link,
.cm-line:has(.copy-protocol-link) .cm-widgetBuffer + .external-link {
display: none !important;
.markdown-source-view.mod-cm6 .has-copy-protocol-line .external-link,
.markdown-source-view.mod-cm6 .has-copy-protocol-line .cm-url.external-link {
display: none;
}
/* Ensure the link text looks like a link */
.copy-protocol-link {
.markdown-source-view.mod-cm6 .copy-protocol-link {
text-decoration: underline;
text-underline-offset: 2px;
}
/* Copy cursor on hover */
.copy-protocol-link:hover,
a[href^="obsidian://copy"]:hover {
cursor: copy !important;
.markdown-source-view.mod-cm6 .copy-protocol-link:hover,
.markdown-source-view.mod-cm6 a[href^="obsidian://copy"]:hover,
a.copy-protocol-link:hover {
cursor: copy;
}
/* ── Widget Styles (Live Preview) ── */
.copy-protocol-icon {
cursor: copy;
display: inline-flex;
margin-left: 5px;
vertical-align: middle;
opacity: 0.8;
transition: transform 0.1s ease-out, opacity 0.1s ease-out;
}
.copy-protocol-icon:hover {
opacity: 1;
}
.copy-protocol-icon svg {
width: 0.85em;
height: 0.85em;
}
.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, #444444));
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);
}

View file

@ -1,4 +1,6 @@
{
"1.1.2": "0.15.0",
"1.1.1": "0.15.0",
"1.1.0": "0.15.0",
"1.0.1": "0.15.0",
"1.0.0": "0.15.0"