Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Showing links in editing mode #11

Draft
wants to merge 8 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Obsidian Smart Links

This is a plugin for [Obsidian](https://obsidian.md) that lets you define custom "smart" links which will be auto-linked when reading documents.
This is a plugin for [Obsidian](https://obsidian.md) that lets you define custom "smart" links which will be auto-linked when editing or reading documents.

If you're used to writing in an environment that auto-links certain strings and don't want to build new habits, this will help with that. E.g. `T12345` in phabricator, or `#4324` in github.
If you're used to writing in an environment that auto-links certain strings and don't want to build new habits, this will help with that. E.g. `T12345` in Phabricator, `#4324` in GitHub, or `REF-123` in Jira.

It'll turn this...

Expand All @@ -22,14 +22,17 @@ You can add your own replacement patterns in Obsidian's settings:

Install and enable the plugin. Once you do, you'll find there's a new section in your settings called "Smart Links". In it you can add/remove replacement rules. You'll need to write a regular expression and a replacement string for it. This can range from very simple to very complicated.

| Regular expression | Replacement |
|--------------------|-----------------------------------------|
| `T\d+` | `https://phabricator.wikimedia.org/$&` |
| `\$([A-Z]+)` | `https://finance.yahoo.com/quote/$1` |
| `go\/[_\d\w-/]+` | `http://$&` |
| Regular expression | Replacement |
|--------------------|------------------------------------------|
| `T\d+` | `https://phabricator.wikimedia.org/$&` |
| `\$([A-Z]+)` | `https://finance.yahoo.com/quote/$1` |
| `REF-(\d+)` | `https://my.atlassian.net/browse/REF-$1` |
| `go\/[_\d\w-/]+` | `http://$&` |

The replacements work using normal Javascript regular expression replacement syntax. I'm so very sorry. Remember that you'll need to escape characters with special meaning in regular expressions. Matches are restricted so they'll only occur immediately after either the start of a line or some whitespace.

## Credits

The reading-mode code was heavily influenced by [Obsidian GoLinks](https://github.com/xavdid/obsidian-golinks) -- this plugin is (arguably) a customizable superset of that one's functionality.

The editing-mode code was made possible by [the documentation on the unofficial Obsidian Plugin Developer Docs site](https://marcus.se.net/obsidian-plugin-docs/) which is maintained by volunteer contributors.
31 changes: 15 additions & 16 deletions main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { App, Editor, MarkdownRenderChild, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian';

import { SmartLinksPattern, parseNextLink } from 'replacements';
import { SmartLinksPattern, parseNextLink, createLinkTag, LinkPlugin } from 'replacements';

import {
ViewPlugin,
} from "@codemirror/view";

interface SmartLinksSettings {
patterns: [{regexp: string, replacement: string}];
Expand Down Expand Up @@ -41,6 +45,15 @@ export default class SmartLinks extends Plugin {
}
})
})

this.registerEditorExtension(
ViewPlugin.define<LinkPlugin>(
(view) => new LinkPlugin(view, this),
{
decorations: (value: LinkPlugin) => value.decorations,
}
),
)
}

onunload() {
Expand Down Expand Up @@ -197,25 +210,11 @@ class SmartLinkContainer extends MarkdownRenderChild {
break;
}
results.push(document.createTextNode(nextLink.preText));
results.push(this.createLinkTag(containerEl, nextLink.link, nextLink.href));
results.push(createLinkTag(containerEl, nextLink.link, nextLink.href));
remaining = nextLink.remaining;
}
});

return results;
}

createLinkTag(el: Element, link: string, href: string): Element {
return el.createEl("a", {
cls: "external-link",
href,
text: link,
attr: {
"aria-label": href,
"aria-label-position": "top",
rel: "noopener",
target: "_blank",
}
})
}
}
111 changes: 108 additions & 3 deletions replacements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@
// WebKit bug for support: https://bugs.webkit.org/show_bug.cgi?id=174931
// Desired code: `(?<=^| |\t|\n)` + making the match function simpler.

import { RangeSetBuilder } from "@codemirror/state";
import {
Decoration,
DecorationSet,
EditorView,
PluginValue,
ViewUpdate,
WidgetType
} from "@codemirror/view";
import SmartLinks from "main";

export class SmartLinksPattern {
boundary: RegExp = /(^| |\t|\n)$/;

Expand All @@ -32,7 +43,7 @@ export class SmartLinksPattern {

export function parseNextLink(text: string, pattern: SmartLinksPattern):
| { found: false; remaining: string }
| { found: true; preText: string; link: string; href: string; remaining: string }
| { found: true; index: number, preText: string; link: string; href: string; remaining: string }
{
let result, href;
result = pattern.match(text);
Expand All @@ -45,6 +56,100 @@ export function parseNextLink(text: string, pattern: SmartLinksPattern):

const preText = text.slice(0, result.index);
const link = result[0];
const remaining = text.slice((result.index ?? 0) + link.length);
return { found: true, preText, link, href, remaining };
const index = (result.index ?? 0);
const remaining = text.slice(index + link.length);
return { found: true, index, preText, link, href, remaining };
}

export function createLinkTag(el: Element, link: string, href: string): HTMLElement {
return el.createEl("a", {
cls: "external-link smart-link",
href,
text: link,
attr: {
"aria-label": href,
"aria-label-position": "top",
rel: "noopener",
target: "_blank",
}
})
}

export class LinkPlugin implements PluginValue {
decorations: DecorationSet;

constructor(view: EditorView, private plugin: SmartLinks) {
this.decorations = this.buildDecorations(view);
}

update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = this.buildDecorations(update.view);
}
}

destroy() { }

buildDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();

if (! this.plugin?.patterns) {
return builder.finish();
}

const additions: {
position: number;
decoration: Decoration;
}[] = [];

for (const { from, to } of view.visibleRanges) {
const text = view.state.sliceDoc(from, to);

for (const pattern of this.plugin.patterns) {
let remaining = text;
let listCharFrom = from;

while (remaining) {
const nextLink = parseNextLink(remaining, pattern);

if (!nextLink.found) {
break;
}

listCharFrom += nextLink.index + nextLink.link.length;

additions.push({
position: listCharFrom,
decoration: Decoration.widget({
widget: new LinkWidget(nextLink.link, nextLink.href),
})
});

remaining = nextLink.remaining;
}
}
}

// Sort additions by position
additions.sort((a, b) => a.position - b.position);

// Add decorations in sorted order
for (const { position, decoration } of additions) {
builder.add(position, position, decoration);
}

return builder.finish();
}
}

export class LinkWidget extends WidgetType {
constructor(private text:string, private link:string) {
super();
}

toDOM(view: EditorView): HTMLElement {
const el = document.createElement("span");

return createLinkTag(el, this.text, this.link);
}
}
3 changes: 3 additions & 0 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ If your plugin does not need CSS, delete this file.
color: red;
border-color: red;
}
.markdown-source-view:not(.is-live-preview) .smart-link {
display: none;
}