Replace (wiki) links with proper link widgets for better click behavior

This commit is contained in:
Zef Hemel
2022-11-28 16:56:15 +01:00
parent cab0c5aa23
commit a20aed99e2
7 changed files with 198 additions and 120 deletions
+3 -3
View File
@@ -5,7 +5,7 @@ import { blockquotePlugin } from "./block_quote.ts";
import { directivePlugin } from "./directive.ts"; import { directivePlugin } from "./directive.ts";
import { hideHeaderMarkPlugin, hideMarks } from "./hide_mark.ts"; import { hideHeaderMarkPlugin, hideMarks } from "./hide_mark.ts";
import { cleanBlockPlugin } from "./block.ts"; import { cleanBlockPlugin } from "./block.ts";
import { goToLinkPlugin } from "./link.ts"; import { linkPlugin } from "./link.ts";
import { listBulletPlugin } from "./list.ts"; import { listBulletPlugin } from "./list.ts";
import { tablePlugin } from "./table.ts"; import { tablePlugin } from "./table.ts";
import { taskListPlugin } from "./task.ts"; import { taskListPlugin } from "./task.ts";
@@ -13,7 +13,7 @@ import { cleanWikiLinkPlugin } from "./wiki_link.ts";
export function cleanModePlugins(editor: Editor) { export function cleanModePlugins(editor: Editor) {
return [ return [
goToLinkPlugin, linkPlugin(editor),
directivePlugin, directivePlugin,
blockquotePlugin, blockquotePlugin,
hideMarks(), hideMarks(),
@@ -35,6 +35,6 @@ export function cleanModePlugins(editor: Editor) {
}), }),
listBulletPlugin, listBulletPlugin,
tablePlugin, tablePlugin,
cleanWikiLinkPlugin(), cleanWikiLinkPlugin(editor),
] as Extension[]; ] as Extension[];
} }
+63 -29
View File
@@ -1,7 +1,4 @@
// Forked from https://codeberg.org/retronav/ixora import { ClickEvent } from "../../plug-api/app_event.ts";
// Original author: Pranav Karawale
// License: Apache License 2.0.
import { import {
Decoration, Decoration,
DecorationSet, DecorationSet,
@@ -9,54 +6,91 @@ import {
ViewPlugin, ViewPlugin,
ViewUpdate, ViewUpdate,
} from "../deps.ts"; } from "../deps.ts";
import { Editor } from "../editor.tsx";
import { import {
checkRangeOverlap,
invisibleDecoration, invisibleDecoration,
isCursorInRange,
iterateTreeInVisibleRanges, iterateTreeInVisibleRanges,
} from "./util.ts"; } from "./util.ts";
import { LinkWidget } from "./util.ts";
function getLinkAnchor(view: EditorView) { export function linkPlugin(editor: Editor) {
return ViewPlugin.fromClass(
class {
decorations: DecorationSet = Decoration.none;
constructor(readonly view: EditorView) {
this.decorations = this.calculateDecorations();
}
calculateDecorations() {
const widgets: any[] = []; const widgets: any[] = [];
const view = this.view;
iterateTreeInVisibleRanges(view, { iterateTreeInVisibleRanges(this.view, {
enter: ({ type, from, to, node }) => { enter: ({ type, from, to }) => {
if (type.name !== "URL") return; if (type.name !== "Link") {
const parent = node.parent; return;
const blackListedParents = ["Image"]; }
if (parent && !blackListedParents.includes(parent.name)) { // Adding 2 on each side due to [[ and ]] that are outside the WikiLinkPage node
const marks = parent.getChildren("LinkMark"); if (isCursorInRange(view.state, [from, to])) {
const ranges = view.state.selection.ranges; return;
const cursorOverlaps = ranges.some(({ from, to }) => }
checkRangeOverlap([from, to], [parent.from, parent.to]) // Hide the whole thing
);
if (!cursorOverlaps) {
widgets.push( widgets.push(
...marks.map(({ from, to }) => invisibleDecoration.range(from, to)), invisibleDecoration.range(
invisibleDecoration.range(from, to), from,
to,
),
); );
const text = view.state.sliceDoc(from, to);
// Links are of the form [hell](https://example.com)
const [anchorPart, linkPart] = text.split("]("); // Not pretty
const cleanAnchor = anchorPart.substring(1); // cut off the initial [
const cleanLink = linkPart.substring(0, linkPart.length - 1); // cut off the final )
widgets.push(
Decoration.widget({
widget: new LinkWidget(
cleanAnchor,
`Click to visit ${cleanLink}`,
"sb-link",
(e) => {
if (e.altKey) {
// Move cursor into the link, approximate location
return view.dispatch({
selection: { anchor: from + 1 },
});
} }
} // Dispatch click event to navigate there without moving the cursor
const clickEvent: ClickEvent = {
page: editor.currentPage!,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
altKey: e.altKey,
pos: from,
};
editor.dispatchAppEvent("page:click", clickEvent).catch(
console.error,
);
},
),
}).range(from),
);
}, },
}); });
return Decoration.set(widgets, true); return Decoration.set(widgets, true);
} }
export const goToLinkPlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet = Decoration.none;
constructor(view: EditorView) {
this.decorations = getLinkAnchor(view);
}
update(update: ViewUpdate) { update(update: ViewUpdate) {
if ( if (
update.docChanged || update.docChanged ||
update.viewportChanged || update.viewportChanged ||
update.selectionSet update.selectionSet
) { ) {
this.decorations = getLinkAnchor(update.view); this.decorations = this.calculateDecorations();
} }
} }
}, },
{ decorations: (v) => v.decorations }, { decorations: (v) => v.decorations },
); );
}
+25
View File
@@ -8,8 +8,33 @@ import {
foldedRanges, foldedRanges,
SyntaxNodeRef, SyntaxNodeRef,
syntaxTree, syntaxTree,
WidgetType,
} from "../deps.ts"; } from "../deps.ts";
export class LinkWidget extends WidgetType {
constructor(
readonly text: string,
readonly title: string,
readonly cssClass: string,
readonly callback: (e: MouseEvent) => void,
) {
super();
}
toDOM(): HTMLElement {
const anchor = document.createElement("a");
anchor.className = this.cssClass;
anchor.textContent = this.text;
anchor.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
this.callback(e);
});
anchor.setAttribute("title", this.title);
anchor.href = "#";
return anchor;
}
}
/** /**
* Check if two ranges overlap * Check if two ranges overlap
* Based on the visual diagram on https://stackoverflow.com/a/25369187 * Based on the visual diagram on https://stackoverflow.com/a/25369187
+56 -42
View File
@@ -1,27 +1,35 @@
import { pageLinkRegex } from "../../common/parser.ts"; import { pageLinkRegex } from "../../common/parser.ts";
import { ClickEvent } from "../../plug-api/app_event.ts";
import { import {
Decoration, Decoration,
DecorationSet, DecorationSet,
EditorView, EditorView,
ViewPlugin, ViewPlugin,
ViewUpdate, ViewUpdate,
WidgetType,
} from "../deps.ts"; } from "../deps.ts";
import { Editor } from "../editor.tsx";
import { import {
invisibleDecoration, invisibleDecoration,
isCursorInRange, isCursorInRange,
iterateTreeInVisibleRanges, iterateTreeInVisibleRanges,
LinkWidget,
} from "./util.ts"; } from "./util.ts";
/** /**
* Plugin to hide path prefix when the cursor is not inside. * Plugin to hide path prefix when the cursor is not inside.
*/ */
class CleanWikiLinkPlugin { export function cleanWikiLinkPlugin(editor: Editor) {
return ViewPlugin.fromClass(
class {
decorations: DecorationSet; decorations: DecorationSet;
constructor(view: EditorView) { constructor(view: EditorView) {
this.decorations = this.compute(view); this.decorations = this.compute(view);
} }
update(update: ViewUpdate) { update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged || update.selectionSet) { if (
update.docChanged || update.viewportChanged || update.selectionSet
) {
this.decorations = this.compute(update.view); this.decorations = this.compute(update.view);
} }
} }
@@ -30,63 +38,69 @@ class CleanWikiLinkPlugin {
// let parentRange: [number, number]; // let parentRange: [number, number];
iterateTreeInVisibleRanges(view, { iterateTreeInVisibleRanges(view, {
enter: ({ type, from, to }) => { enter: ({ type, from, to }) => {
if (type.name === "WikiLink") { if (type.name !== "WikiLink") {
return;
}
// Adding 2 on each side due to [[ and ]] that are outside the WikiLinkPage node // Adding 2 on each side due to [[ and ]] that are outside the WikiLinkPage node
if (isCursorInRange(view.state, [from, to])) { if (isCursorInRange(view.state, [from, to])) {
return; return;
} }
// Add decoration to hide the prefix [[ const text = view.state.sliceDoc(from, to);
const match = pageLinkRegex.exec(text);
if (!match) return;
const [_fullMatch, page, pipePart, alias] = match;
// Hide the whole thing
widgets.push( widgets.push(
invisibleDecoration.range( invisibleDecoration.range(
from, from,
from + 2,
),
);
// Add decoration to hide the postfix [[
widgets.push(
invisibleDecoration.range(
to - 2,
to, to,
), ),
); );
// Now check if this page has an alias let linkText = alias || page;
const text = view.state.sliceDoc(from, to); if (!pipePart && text.indexOf("/") !== -1) {
const match = pageLinkRegex.exec(text); // Let's use the last part of the path as the link text
if (!match) return; linkText = page.split("/").pop()!;
const [_fullMatch, page, pipePart] = match; }
if (!pipePart) { // And replace it with a widget
// No alias, let's check if there's a slash in the page name
if (text.indexOf("/") === -1) {
return;
}
// Add a inivisible decoration to hide the path prefix
widgets.push( widgets.push(
invisibleDecoration.range( Decoration.widget({
from + 2, // +2 to skip the [[ widget: new LinkWidget(
from + text.lastIndexOf("/") + 1, linkText,
), page,
); "sb-wiki-link-page",
} else { (e) => {
// Alias is present, so we hide the part before the pipe if (e.altKey) {
widgets.push( // Move cursor into the link
invisibleDecoration.range( return view.dispatch({
from + 2, selection: { anchor: from + 2 },
from + page.length + 3, // 3 is for the [[ and the | });
),
);
}
} }
// Dispatch click event to navigate there without moving the cursor
const clickEvent: ClickEvent = {
page: editor.currentPage!,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
altKey: e.altKey,
pos: from,
};
editor.dispatchAppEvent("page:click", clickEvent).catch(
console.error,
);
},
),
}).range(from),
);
}, },
}); });
return Decoration.set(widgets, true); return Decoration.set(widgets, true);
} }
} },
{
export const cleanWikiLinkPlugin = () => [
ViewPlugin.fromClass(CleanWikiLinkPlugin, {
decorations: (v) => v.decorations, decorations: (v) => v.decorations,
}), },
]; );
}
+3
View File
@@ -307,8 +307,11 @@ export class Editor {
// Frotnmatter found, put cursor after it // Frotnmatter found, put cursor after it
initialCursorPos = match[0].length; initialCursorPos = match[0].length;
} }
// By default scroll to the top
this.editorView.scrollDOM.scrollTop = 0;
this.editorView.dispatch({ this.editorView.dispatch({
selection: { anchor: initialCursorPos }, selection: { anchor: initialCursorPos },
// And then scroll down if required
scrollIntoView: true, scrollIntoView: true,
}); });
} }
+1
View File
@@ -340,6 +340,7 @@
border-radius: 5px; border-radius: 5px;
padding: 0 5px; padding: 0 5px;
white-space: nowrap; white-space: nowrap;
text-decoration: none;
cursor: pointer; cursor: pointer;
} }
+1
View File
@@ -6,6 +6,7 @@ release.
## 0.2.2 ## 0.2.2
* New page link aliasing syntax (Obsidian compatible) is here: `[[page link|alias]]` e.g. [[CHANGELOG|this is a link to this changelog]]. * New page link aliasing syntax (Obsidian compatible) is here: `[[page link|alias]]` e.g. [[CHANGELOG|this is a link to this changelog]].
* Less "floppy" behavior when clicking links (wiki and regular): just navigates there right away. Note: use `Alt-click` to move cursor inside of a link.
* Added `invokeFunction` `silverbullet` CLI sub-command to run arbitrary plug functions from the CLI. * Added `invokeFunction` `silverbullet` CLI sub-command to run arbitrary plug functions from the CLI.
--- ---