diff --git a/README.md b/README.md index eb4b053..fb0abd6 100644 --- a/README.md +++ b/README.md @@ -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... @@ -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. diff --git a/main.ts b/main.ts index 86f3bed..819078a 100644 --- a/main.ts +++ b/main.ts @@ -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}]; @@ -41,6 +45,15 @@ export default class SmartLinks extends Plugin { } }) }) + + this.registerEditorExtension( + ViewPlugin.define( + (view) => new LinkPlugin(view, this), + { + decorations: (value: LinkPlugin) => value.decorations, + } + ), + ) } onunload() { @@ -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", - } - }) - } } diff --git a/replacements.ts b/replacements.ts index 814046a..a87b4c4 100644 --- a/replacements.ts +++ b/replacements.ts @@ -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)$/; @@ -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); @@ -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(); + + 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); + } } diff --git a/styles.css b/styles.css index 79d4e98..ed53d14 100644 --- a/styles.css +++ b/styles.css @@ -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; +}