From cc6b7144940a201aae4192cedfcd293b7ef9f489 Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Wed, 5 Apr 2023 22:08:06 +0100 Subject: [PATCH 1/8] MVP for showing links in editing mode. --- main.ts | 31 ++++++++-------- replacements.ts | 94 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 16 deletions(-) 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..c23a1a1 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)$/; @@ -48,3 +59,86 @@ export function parseNextLink(text: string, pattern: SmartLinksPattern): const remaining = text.slice((result.index ?? 0) + link.length); return { found: true, preText, link, href, remaining }; } + +export function createLinkTag(el: Element, link: string, href: string): HTMLElement { + return el.createEl("a", { + cls: "external-link", + href, + text: link, + attr: { + "aria-label": href, + "aria-label-position": "top", + rel: "noopener", + target: "_blank", + } + }) +} + +export class LinkPlugin implements PluginValue { + plugin: SmartLinks; + decorations: DecorationSet; + + constructor(view: EditorView, plugin: SmartLinks) { + this.decorations = this.buildDecorations(view); + this.plugin = plugin; + } + + 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 || ! this.plugin.patterns) { + return builder.finish(); + } + + for (const { from, to } of view.visibleRanges) { + const text = view.state.sliceDoc(from, to); + + for (const pattern of this.plugin.patterns) { + const match = pattern.match(text); + + if (! match || ! match.index) { + continue; + } + + const listCharFrom = from + match.index + match[0].length; + const href = match[0].replace(pattern.regexp, pattern.replacement); + + builder.add( + listCharFrom, + listCharFrom, + Decoration.widget({ + widget: new LinkWidget(match[0], href), + }) + ); + } + } + + return builder.finish(); + } +} + +export class LinkWidget extends WidgetType { + text: string; + link: string; + + constructor(text:string, link:string) { + super(); + + this.text = text; + this.link = link; + } + + toDOM(view: EditorView): HTMLElement { + const el = document.createElement("span"); + + return createLinkTag(el, this.text, this.link); + } +} From a0df5c8d4294713159420fec7e869857eec4d4d0 Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Thu, 6 Apr 2023 17:31:02 +0100 Subject: [PATCH 2/8] Add decorations in sorted order. --- replacements.ts | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/replacements.ts b/replacements.ts index c23a1a1..44276c3 100644 --- a/replacements.ts +++ b/replacements.ts @@ -98,6 +98,11 @@ export class LinkPlugin implements PluginValue { return builder.finish(); } + const additions: { + position: number; + decoration: Decoration; + }[] = []; + for (const { from, to } of view.visibleRanges) { const text = view.state.sliceDoc(from, to); @@ -111,16 +116,27 @@ export class LinkPlugin implements PluginValue { const listCharFrom = from + match.index + match[0].length; const href = match[0].replace(pattern.regexp, pattern.replacement); - builder.add( - listCharFrom, - listCharFrom, - Decoration.widget({ + additions.push({ + position: listCharFrom, + decoration: Decoration.widget({ widget: new LinkWidget(match[0], href), }) - ); + }); } } + // 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(); } } From 31e14fb4158098ffcb910958767a4f7bb98f2dd9 Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Thu, 6 Apr 2023 17:31:23 +0100 Subject: [PATCH 3/8] Allow for more than one instance of each matching link. --- replacements.ts | 45 ++++++++++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/replacements.ts b/replacements.ts index 44276c3..5cf0736 100644 --- a/replacements.ts +++ b/replacements.ts @@ -43,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); @@ -56,8 +56,9 @@ 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 { @@ -107,21 +108,35 @@ export class LinkPlugin implements PluginValue { const text = view.state.sliceDoc(from, to); for (const pattern of this.plugin.patterns) { - const match = pattern.match(text); + let remaining = text; + let listCharFrom = from; - if (! match || ! match.index) { - continue; - } + while (remaining) { + const nextLink = parseNextLink(remaining, pattern); + + if (!nextLink.found) { + break; + } + + const match = pattern.match(remaining); + + if (! match || ! match.index) { + break; + } - const listCharFrom = from + match.index + match[0].length; - const href = match[0].replace(pattern.regexp, pattern.replacement); + listCharFrom += match.index + match[0].length; - additions.push({ - position: listCharFrom, - decoration: Decoration.widget({ - widget: new LinkWidget(match[0], href), - }) - }); + const href = match[0].replace(pattern.regexp, pattern.replacement); + + additions.push({ + position: listCharFrom, + decoration: Decoration.widget({ + widget: new LinkWidget(match[0], href), + }) + }); + + remaining = nextLink.remaining; + } } } From 871172531ef42f12d489d833e15aca910fdba706 Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Thu, 6 Apr 2023 17:38:36 +0100 Subject: [PATCH 4/8] Docs. --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index eb4b053..7ed94a9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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. @@ -33,3 +33,5 @@ The replacements work using normal Javascript regular expression replacement syn ## 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. From 63fb9103b23dc4eb311920c2060322a0d17dfd76 Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Thu, 6 Apr 2023 17:43:19 +0100 Subject: [PATCH 5/8] Add docs about Jira. --- README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7ed94a9..fb0abd6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 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,11 +22,12 @@ 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. From efd6a765aa938b8f24977638cb7a88ba63346c68 Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Thu, 6 Apr 2023 18:18:35 +0100 Subject: [PATCH 6/8] Tidying up. --- replacements.ts | 28 +++++----------------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/replacements.ts b/replacements.ts index 5cf0736..63a2f80 100644 --- a/replacements.ts +++ b/replacements.ts @@ -95,7 +95,7 @@ export class LinkPlugin implements PluginValue { buildDecorations(view: EditorView): DecorationSet { const builder = new RangeSetBuilder(); - if ( ! this.plugin || ! this.plugin.patterns) { + if ( ! this.plugin?.patterns) { return builder.finish(); } @@ -118,20 +118,12 @@ export class LinkPlugin implements PluginValue { break; } - const match = pattern.match(remaining); - - if (! match || ! match.index) { - break; - } - - listCharFrom += match.index + match[0].length; - - const href = match[0].replace(pattern.regexp, pattern.replacement); + listCharFrom += nextLink.index + nextLink.link.length; additions.push({ position: listCharFrom, decoration: Decoration.widget({ - widget: new LinkWidget(match[0], href), + widget: new LinkWidget(nextLink.link, nextLink.href), }) }); @@ -145,11 +137,7 @@ export class LinkPlugin implements PluginValue { // Add decorations in sorted order for (const { position, decoration } of additions) { - builder.add( - position, - position, - decoration - ); + builder.add(position, position, decoration); } return builder.finish(); @@ -157,14 +145,8 @@ export class LinkPlugin implements PluginValue { } export class LinkWidget extends WidgetType { - text: string; - link: string; - - constructor(text:string, link:string) { + constructor(private text:string, private link:string) { super(); - - this.text = text; - this.link = link; } toDOM(view: EditorView): HTMLElement { From 69662bacfa4f63b05ae0a3ebaeeea3dd3e08d980 Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Thu, 6 Apr 2023 18:36:49 +0100 Subject: [PATCH 7/8] More red lines. --- replacements.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/replacements.ts b/replacements.ts index 63a2f80..be89404 100644 --- a/replacements.ts +++ b/replacements.ts @@ -76,12 +76,10 @@ export function createLinkTag(el: Element, link: string, href: string): HTMLElem } export class LinkPlugin implements PluginValue { - plugin: SmartLinks; decorations: DecorationSet; - constructor(view: EditorView, plugin: SmartLinks) { + constructor(view: EditorView, private plugin: SmartLinks) { this.decorations = this.buildDecorations(view); - this.plugin = plugin; } update(update: ViewUpdate) { @@ -95,7 +93,7 @@ export class LinkPlugin implements PluginValue { buildDecorations(view: EditorView): DecorationSet { const builder = new RangeSetBuilder(); - if ( ! this.plugin?.patterns) { + if (! this.plugin?.patterns) { return builder.finish(); } From cbe248460af7270721c31a4bbc1f784605dff167 Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Thu, 6 Apr 2023 19:26:17 +0100 Subject: [PATCH 8/8] Hide the links when in source mode. --- replacements.ts | 2 +- styles.css | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/replacements.ts b/replacements.ts index be89404..a87b4c4 100644 --- a/replacements.ts +++ b/replacements.ts @@ -63,7 +63,7 @@ export function parseNextLink(text: string, pattern: SmartLinksPattern): export function createLinkTag(el: Element, link: string, href: string): HTMLElement { return el.createEl("a", { - cls: "external-link", + cls: "external-link smart-link", href, text: link, attr: { 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; +}