Replace (wiki) links with proper link widgets for better click behavior
This commit is contained in:
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-30
@@ -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,44 +6,80 @@ 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 (
|
||||||
@@ -54,9 +87,10 @@ export const goToLinkPlugin = ViewPlugin.fromClass(
|
|||||||
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 },
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
}),
|
},
|
||||||
];
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user