mirror of
https://github.com/jldiaz/copy-protocol-plugin.git
synced 2026-07-22 06:56:55 +00:00
Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31fba5e798 | ||
|
|
d74842f728 | ||
|
|
724e45534b | ||
|
|
9cfb1e0c3c | ||
|
|
0ba3ace762 | ||
|
|
ee3132cdf4 |
7 changed files with 301 additions and 41 deletions
|
|
@ -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
BIN
demo.gif
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.7 MiB |
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "copy-text-protocol",
|
||||
"name": "Copy Text Protocol",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.6",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Adds support for copying text to the clipboard using a custom protocol.",
|
||||
"author": "jldiaz",
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "copy-text-protocol",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "copy-text-protocol",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.6",
|
||||
"license": "0-BSD",
|
||||
"dependencies": {
|
||||
"obsidian": "latest"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "copy-text-protocol",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.6",
|
||||
"description": "A plugin that adds support for copying text via a custom protocol.",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
|
|
|
|||
260
src/main.ts
260
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,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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
66
styles.css
66
styles.css
|
|
@ -8,41 +8,23 @@
|
|||
* 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.external-link[href^="obsidian://copy"]::after,
|
||||
.markdown-preview-view a.external-link[href^="obsidian://copy"]::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 0.85em;
|
||||
height: 0.85em;
|
||||
margin-left: 5px;
|
||||
vertical-align: middle;
|
||||
background-color: currentColor;
|
||||
background-image: none; /* Clears Obsidian's default icon if set via background-image */
|
||||
-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;
|
||||
}
|
||||
|
||||
.markdown-rendered a.external-link[href^="obsidian://copy"]:hover::after,
|
||||
.markdown-preview-view a.external-link[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-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 +47,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 +76,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, #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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue