Compare commits

...

13 commits
1.0.1 ... main

8 changed files with 614 additions and 73 deletions

View file

@ -2,22 +2,71 @@
![Plugin Demo](demo.gif)
This is a simple plugin for Obsidian that registers a custom `obsidian://copy` protocol.
It allows you to create internal links that, when clicked, will silently copy a specific text to your clipboard without opening any external applications or showing popups.
## What is this for?
## Usage
Have you ever wanted to create a note that, when you click a link, **automatically copies a snippet of text to your clipboard**? This plugin makes that possible.
Create a link using the following format:
`[Link text](obsidian://copy?text=Your%20text%20here)`
Some use cases:
When you click the link, "Your text here" will be copied to your clipboard, and a small notice will appear to confirm the action.
- A "cheat sheet" note with commands, passwords, or templates — click the link, paste anywhere.
- A collection of code snippets ready to paste into a terminal.
- Frequently used texts (email signatures, boilerplate, addresses) accessible from any note.
- Query strings to paste into other apps (e.g. to search specific emails in Outlook, my primary use case)
Note: Remember to URL-encode your text (e.g., use `%20` instead of spaces).
## How it works
The plugin registers a custom `obsidian://copy` protocol. When you click a link using this protocol, the text embedded in the URL is silently copied to your clipboard and a small confirmation notice appears — no popups, no external apps.
## Creating links manually
```markdown
[Click to copy](obsidian://copy?text=Your-text-here)
```
When clicked, **"Your-text-here"** is copied to your clipboard.
For text containing spaces, wrap the URL in angle brackets:
```markdown
[Click to copy](<obsidian://copy?text=Hello world>)
```
Other special characters need to be encoded, and as you can see it can be tedious to do manually, so a command for that is included.
## Creating links with a keyboard shortcut
The plugin includes a command called **Copy Protocol: Paste clipboard as copy-protocol link** that automates the process. It reads your current clipboard content and inserts a ready-to-use markdown link at the cursor. If you have text selected in the editor, that selection becomes the link label; otherwise, the clipboard text itself is used as a label.
### To assign a keyboard shortcut
1. Open **Settings → Hotkeys**.
2. Search for "Paste clipboard as copy-protocol link".
3. Click the `+` button and press your preferred key combination.
### Example workflow
1. Copy a command or text you want to reuse (e.g. `git log --oneline`).
2. In your note, type a label like `Show git log` and select it.
3. Press your hotkey.
4. The selection is replaced with:
```
[Show git log](<obsidian://copy?text=git%20log%20--oneline>)
```
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 via [BRAT](https://github.com/TfTHacker/obsidian42-brat):
You can install this plugin from the Community Plugins settings of Obsidian, or via [BRAT](https://github.com/TfTHacker/obsidian42-brat):
1. Open the BRAT settings in Obsidian.
2. Click "Add Beta plugin".
2. Click **Add Beta plugin**.
3. Paste the repository URL: `jldiaz/copy-protocol-plugin`.
4. Enable the plugin in your Community Plugins list.

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.0.1",
"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
}
}

117
package-lock.json generated
View file

@ -1,17 +1,20 @@
{
"name": "obsidian-sample-plugin",
"version": "1.0.0",
"name": "copy-text-protocol",
"version": "1.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-sample-plugin",
"version": "1.0.0",
"name": "copy-text-protocol",
"version": "1.1.6",
"license": "0-BSD",
"dependencies": {
"obsidian": "latest"
},
"devDependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.42.1",
"@eslint/js": "9.30.1",
"@types/node": "^16.11.6",
"esbuild": "0.25.5",
@ -23,24 +26,39 @@
"typescript-eslint": "8.35.1"
}
},
"node_modules/@codemirror/state": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz",
"integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==",
"node_modules/@codemirror/language": {
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/state": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.38.6",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz",
"integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==",
"version": "6.42.1",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.42.1.tgz",
"integrity": "sha512-ToN3oFc0nsxNUYVF5P0ztLgbC4UPPjPtA9aKYhkOKQaZASpOUo6ISXyQLP66ctVwlDc+j6Jv0uK5IFALkiXztg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.5.0",
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
@ -591,23 +609,6 @@
"url": "https://eslint.org/donate"
}
},
"node_modules/@eslint/json": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@eslint/json/-/json-0.14.0.tgz",
"integrity": "sha512-rvR/EZtvUG3p9uqrSmcDJPYSH7atmWr0RnFWN6m917MAPx82+zQgPUmDu0whPFG6XTyM0vB/hR6c1Q63OaYtCQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@eslint/core": "^0.17.0",
"@eslint/plugin-kit": "^0.4.1",
"@humanwhocodes/momoa": "^3.3.10",
"natural-compare": "^1.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/object-schema": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
@ -670,17 +671,6 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@humanwhocodes/momoa": {
"version": "3.3.10",
"resolved": "https://registry.npmjs.org/@humanwhocodes/momoa/-/momoa-3.3.10.tgz",
"integrity": "sha512-KWiFQpSAqEIyrTXko3hFNLeQvSK8zXlJQzhhxsyVn58WFRYXST99b3Nqnu+ttOtjds2Pl2grUHGpe2NzhPynuQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@humanwhocodes/retry": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
@ -695,12 +685,39 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT",
"peer": true
"dev": true,
"license": "MIT"
},
"node_modules/@microsoft/eslint-plugin-sdl": {
"version": "1.1.0",
@ -1512,8 +1529,8 @@
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT",
"peer": true
"dev": true,
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
@ -4659,8 +4676,8 @@
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT",
"peer": true
"dev": true,
"license": "MIT"
},
"node_modules/supports-color": {
"version": "7.2.0",
@ -4982,8 +4999,8 @@
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT",
"peer": true
"dev": true,
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",

View file

@ -1,6 +1,6 @@
{
"name": "copy-text-protocol",
"version": "1.0.1",
"version": "1.1.6",
"description": "A plugin that adds support for copying text via a custom protocol.",
"main": "main.js",
"type": "module",
@ -13,17 +13,21 @@
"keywords": [],
"license": "0-BSD",
"devDependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.42.1",
"@eslint/js": "9.30.1",
"@types/node": "^16.11.6",
"esbuild": "0.25.5",
"eslint-plugin-obsidianmd": "0.1.9",
"globals": "14.0.0",
"jiti": "2.6.1",
"tslib": "2.4.0",
"typescript": "^5.8.3",
"typescript-eslint": "8.35.1",
"@eslint/js": "9.30.1",
"jiti": "2.6.1"
"typescript-eslint": "8.35.1"
},
"dependencies": {
"@lezer/common": "^1.2.3",
"obsidian": "latest"
}
}

View file

@ -1,13 +1,380 @@
import { Plugin, Notice } from 'obsidian';
import { Plugin, Notice, setIcon } from 'obsidian';
import {
EditorView,
ViewPlugin,
ViewUpdate,
Decoration,
DecorationSet,
WidgetType,
} 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 = activeDocument.createElement('span');
span.className = 'copy-protocol-icon';
setIcon(span, 'copy');
span.onclick = async (e) => {
e.preventDefault();
e.stopPropagation();
const success = await copyText(this.textToCopy);
if (success) {
span.addClass('is-clicked');
window.setTimeout(() => span.removeClass('is-clicked'), 200);
}
};
return span;
}
eq(other: CopyIconWidget): boolean {
return other.textToCopy === this.textToCopy;
}
}
const copyLinkPlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) { this.decorations = this.buildDecorations(view); }
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged || update.selectionSet) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const tree = syntaxTree(view.state);
const selection = view.state.selection;
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: 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();
}
},
{ decorations: (v) => v.decorations },
);
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) {
const query = decodeURIComponent(params.text);
await navigator.clipboard.writeText(query);
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',
editorCallback: async (editor) => {
let text: string;
try { text = await navigator.clipboard.readText(); } catch { return; }
if (!text) return;
const sel = editor.getSelection();
const url = `obsidian://copy?text=${encodeURIComponent(text)}`;
const label = sel || (text.length > 50 ? text.slice(0, 50) + '…' : text);
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

@ -1,8 +1,109 @@
/*
* Visual indicator for obsidian://copy protocol links.
*/
This CSS file will be included with your plugin, and
available in the app when your plugin is enabled.
/* ── Reading View (Standard Links) ── */
If your plugin does not need CSS, delete this file.
/*
* 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;
}
/* 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 our copy links.
* Instead of :has(), we use the .has-copy-protocol-line class added in JS.
*/
.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 */
.markdown-source-view.mod-cm6 .copy-protocol-link {
text-decoration: underline;
text-underline-offset: 2px;
}
/* Copy cursor on hover */
.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,7 @@
{
"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"
}