mirror of
https://github.com/jldiaz/copy-protocol-plugin.git
synced 2026-07-22 06:56:55 +00:00
Compare commits
13 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31fba5e798 | ||
|
|
d74842f728 | ||
|
|
724e45534b | ||
|
|
9cfb1e0c3c | ||
|
|
0ba3ace762 | ||
|
|
ee3132cdf4 | ||
|
|
49ce754986 | ||
|
|
888bca90b9 | ||
|
|
4983397c7f | ||
|
|
f31f9ed5eb | ||
|
|
ff1688224a | ||
|
|
68bdc5c5cb | ||
|
|
1492e71e4a |
8 changed files with 614 additions and 73 deletions
67
README.md
67
README.md
|
|
@ -2,22 +2,71 @@
|
|||
|
||||

|
||||
|
||||
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
BIN
demo.gif
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.7 MiB |
|
|
@ -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
117
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
12
package.json
12
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
375
src/main.ts
375
src/main.ts
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
109
styles.css
109
styles.css
|
|
@ -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);
|
||||
}
|
||||
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue